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