Line data Source code
1 : /*
2 : * Famedly Matrix SDK
3 : * Copyright (C) 2019, 2020, 2021 Famedly GmbH
4 : *
5 : * This program is free software: you can redistribute it and/or modify
6 : * it under the terms of the GNU Affero General Public License as
7 : * published by the Free Software Foundation, either version 3 of the
8 : * License, or (at your option) any later version.
9 : *
10 : * This program is distributed in the hope that it will be useful,
11 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 : * GNU Affero General Public License for more details.
14 : *
15 : * You should have received a copy of the GNU Affero General Public License
16 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 : */
18 :
19 : import 'package:matrix/matrix.dart';
20 :
21 : /// Represents a user in the context of a Matrix room, not a global user profile.
22 : ///
23 : /// This class extends [StrippedStateEvent] to handle room-specific user state,
24 : /// including membership status, display name, and avatar within that room.
25 : /// The user information is derived from room member state events.
26 : ///
27 : /// For example, a user may have different display names or avatars in different rooms,
28 : /// and this class represents that room-specific view of the user rather than their
29 : /// global profile.
30 : ///
31 : class User extends StrippedStateEvent {
32 : final Room room;
33 : final Map<String, Object?>? prevContent;
34 : final DateTime? originServerTs;
35 :
36 10 : factory User(
37 : String id, {
38 : String? membership,
39 : String? displayName,
40 : String? avatarUrl,
41 : DateTime? originServerTs,
42 : required Room room,
43 : }) {
44 10 : return User.fromState(
45 : stateKey: id,
46 : senderId: id,
47 10 : content: {
48 9 : if (membership != null) 'membership': membership,
49 9 : if (displayName != null) 'displayname': displayName,
50 4 : if (avatarUrl != null) 'avatar_url': avatarUrl,
51 : },
52 : typeKey: EventTypes.RoomMember,
53 : room: room,
54 : originServerTs: originServerTs,
55 : );
56 : }
57 :
58 40 : User.fromState({
59 : required String super.stateKey,
60 : super.content = const {},
61 : required String typeKey,
62 : required super.senderId,
63 : required this.room,
64 : this.originServerTs,
65 : this.prevContent,
66 40 : }) : super(
67 : type: typeKey,
68 : );
69 :
70 : /// The full qualified Matrix ID in the format @username:server.abc.
71 80 : String get id => stateKey ?? '@unknown:unknown';
72 :
73 : /// The displayname of the user if the user has set one.
74 11 : String? get displayName =>
75 22 : content.tryGet<String>('displayname') ??
76 20 : (membership == Membership.join
77 : ? null
78 2 : : prevContent?.tryGet<String>('displayname'));
79 :
80 : /// Returns the power level of this user.
81 16 : int get powerLevel => room.getPowerLevelByUserId(id);
82 :
83 : /// The membership status of the user. One of:
84 : /// join
85 : /// invite
86 : /// leave
87 : /// ban
88 80 : Membership get membership => Membership.values.firstWhere(
89 40 : (e) {
90 80 : if (content['membership'] != null) {
91 200 : return e.toString() == 'Membership.${content['membership']}';
92 : }
93 : return false;
94 : },
95 9 : orElse: () => Membership.join,
96 : );
97 :
98 : /// The avatar if the user has one.
99 2 : Uri? get avatarUrl {
100 4 : final uri = content.tryGet<String>('avatar_url') ??
101 0 : (membership == Membership.join
102 : ? null
103 0 : : prevContent?.tryGet<String>('avatar_url'));
104 2 : return uri == null ? null : Uri.tryParse(uri);
105 : }
106 :
107 : /// Returns the displayname or the local part of the Matrix ID if the user
108 : /// has no displayname. If [formatLocalpart] is true, then the localpart will
109 : /// be formatted in the way, that all "_" characters are becomming white spaces and
110 : /// the first character of each word becomes uppercase.
111 : /// If [mxidLocalPartFallback] is true, then the local part of the mxid will be shown
112 : /// if there is no other displayname available. If not then this will return "Unknown user".
113 7 : String calcDisplayname({
114 : bool? formatLocalpart,
115 : bool? mxidLocalPartFallback,
116 : MatrixLocalizations i18n = const MatrixDefaultLocalizations(),
117 : }) {
118 21 : formatLocalpart ??= room.client.formatLocalpart;
119 21 : mxidLocalPartFallback ??= room.client.mxidLocalPartFallback;
120 7 : final displayName = this.displayName;
121 5 : if (displayName != null && displayName.isNotEmpty) {
122 : return displayName;
123 : }
124 4 : final stateKey = this.stateKey;
125 : if (stateKey != null && mxidLocalPartFallback) {
126 : if (!formatLocalpart) {
127 2 : return stateKey.localpart ?? '';
128 : }
129 12 : final words = stateKey.localpart?.replaceAll('_', ' ').split(' ') ?? [];
130 12 : for (var i = 0; i < words.length; i++) {
131 8 : if (words[i].isNotEmpty) {
132 28 : words[i] = words[i][0].toUpperCase() + words[i].substring(1);
133 : }
134 : }
135 8 : return words.join(' ').trim();
136 : }
137 2 : return i18n.unknownUser;
138 : }
139 :
140 : /// Call the Matrix API to kick this user from this room.
141 8 : Future<void> kick() async => await room.kick(id);
142 :
143 : /// Call the Matrix API to ban this user from this room.
144 8 : Future<void> ban() async => await room.ban(id);
145 :
146 : /// Call the Matrix API to unban this banned user from this room.
147 8 : Future<void> unban() async => await room.unban(id);
148 :
149 : /// Call the Matrix API to change the power level of this user.
150 8 : Future<void> setPower(int power) async => await room.setPower(id, power);
151 :
152 : /// Returns an existing direct chat ID with this user or creates a new one.
153 : /// Returns null on error.
154 2 : Future<String> startDirectChat({
155 : bool? enableEncryption,
156 : List<StateEvent>? initialState,
157 : bool waitForSync = true,
158 : }) async =>
159 6 : room.client.startDirectChat(
160 2 : id,
161 : enableEncryption: enableEncryption,
162 : initialState: initialState,
163 : waitForSync: waitForSync,
164 : );
165 :
166 : /// The newest presence of this user if there is any and null if not.
167 0 : @Deprecated('Deprecated in favour of currentPresence.')
168 0 : Presence? get presence => room.client.presences[id]?.toPresence();
169 :
170 0 : @Deprecated('Use fetchCurrentPresence() instead')
171 0 : Future<CachedPresence> get currentPresence => fetchCurrentPresence();
172 :
173 : /// The newest presence of this user if there is any. Fetches it from the
174 : /// database first and then from the server if necessary or returns offline.
175 2 : Future<CachedPresence> fetchCurrentPresence() =>
176 8 : room.client.fetchCurrentPresence(id);
177 :
178 : /// Whether the client is able to ban/unban this user.
179 6 : bool get canBan => room.canBan && powerLevel < room.ownPowerLevel;
180 :
181 : /// Whether the client is able to kick this user.
182 2 : bool get canKick =>
183 : {
184 2 : Membership.join,
185 2 : Membership.invite,
186 2 : Membership.knock,
187 4 : }.contains(membership) &&
188 4 : room.canKick &&
189 0 : powerLevel < room.ownPowerLevel;
190 :
191 0 : @Deprecated('Use [canChangeUserPowerLevel] instead.')
192 0 : bool get canChangePowerLevel => canChangeUserPowerLevel;
193 :
194 : /// Whether the client is allowed to change the power level of this user.
195 : /// Please be aware that you can only set the power level to at least your own!
196 2 : bool get canChangeUserPowerLevel =>
197 4 : room.canChangePowerLevel &&
198 18 : (powerLevel < room.ownPowerLevel || id == room.client.userID);
199 :
200 1 : @override
201 1 : bool operator ==(Object other) => (other is User &&
202 3 : other.id == id &&
203 3 : other.room == room &&
204 3 : other.membership == membership);
205 :
206 0 : @override
207 0 : int get hashCode => Object.hash(id, room, membership);
208 :
209 : /// Get the mention text to use in a plain text body to mention this specific user
210 : /// in this specific room
211 2 : String get mention {
212 : // if the displayname has [ or ] or : we can't build our more fancy stuff, so fall back to the id
213 : // [] is used for the delimitors
214 : // If we allowed : we could get collissions with the mxid fallbacks
215 2 : final displayName = this.displayName;
216 : if (displayName == null ||
217 2 : displayName.isEmpty ||
218 10 : {'[', ']', ':'}.any(displayName.contains)) {
219 2 : return id;
220 : }
221 :
222 : final identifier =
223 8 : '@${RegExp(r'^\w+$').hasMatch(displayName) ? displayName : '[$displayName]'}';
224 :
225 : // get all the users with the same display name
226 4 : final allUsersWithSameDisplayname = room.getParticipants();
227 2 : allUsersWithSameDisplayname.removeWhere(
228 2 : (user) =>
229 6 : user.id == id ||
230 4 : (user.displayName?.isEmpty ?? true) ||
231 4 : user.displayName != displayName,
232 : );
233 2 : if (allUsersWithSameDisplayname.isEmpty) {
234 : return identifier;
235 : }
236 : // ok, we have multiple users with the same display name....time to calculate a hash
237 8 : final hashes = allUsersWithSameDisplayname.map((u) => _hash(u.id));
238 4 : final ourHash = _hash(id);
239 : // hash collission...just return our own mxid again
240 2 : if (hashes.contains(ourHash)) {
241 0 : return id;
242 : }
243 2 : return '$identifier#$ourHash';
244 : }
245 :
246 : /// Get the mention fragments for this user.
247 4 : Set<String> get mentionFragments {
248 4 : final displayName = this.displayName;
249 : if (displayName == null ||
250 4 : displayName.isEmpty ||
251 20 : {'[', ']', ':'}.any(displayName.contains)) {
252 : return {};
253 : }
254 : final identifier =
255 16 : '@${RegExp(r'^\w+$').hasMatch(displayName) ? displayName : '[$displayName]'}';
256 :
257 8 : final hash = _hash(id);
258 8 : return {identifier, '$identifier#$hash'};
259 : }
260 : }
261 :
262 : const _maximumHashLength = 10000;
263 4 : String _hash(String s) =>
264 24 : (s.codeUnits.fold<int>(0, (a, b) => a + b) % _maximumHashLength).toString();
265 :
266 : extension FromStrippedStateEventExtension on StrippedStateEvent {
267 80 : User asUser(Room room) => User.fromState(
268 : // state key should always be set for member events
269 40 : stateKey: stateKey!,
270 40 : content: content,
271 40 : typeKey: type,
272 40 : senderId: senderId,
273 : room: room,
274 : originServerTs: null,
275 : );
276 : }
|