@@ -6,10 +6,13 @@ import '../api/route/messages.dart';
6
6
import '../generated/l10n/zulip_localizations.dart' ;
7
7
import '../model/autocomplete.dart' ;
8
8
import '../model/emoji.dart' ;
9
+ import '../model/store.dart' ;
9
10
import 'color.dart' ;
11
+ import 'content.dart' ;
10
12
import 'dialog.dart' ;
11
13
import 'emoji.dart' ;
12
14
import 'inset_shadow.dart' ;
15
+ import 'profile.dart' ;
13
16
import 'store.dart' ;
14
17
import 'text.dart' ;
15
18
import 'theme.dart' ;
@@ -120,6 +123,22 @@ class ReactionChipsList extends StatelessWidget {
120
123
final int messageId;
121
124
final Reactions reactions;
122
125
126
+ void showReactedUsers (BuildContext context, ReactionWithVotes selectedReaction) {
127
+ final store = PerAccountStoreWidget .of (context);
128
+
129
+ showModalBottomSheet <void >(
130
+ context: context,
131
+ builder: (BuildContext context) => PerAccountStoreWidget (
132
+ accountId: store.accountId,
133
+ child: _ReactionUsersSheet (
134
+ reactions: reactions,
135
+ initialSelectedReaction: selectedReaction,
136
+ store: store,
137
+ ),
138
+ ),
139
+ );
140
+ }
141
+
123
142
@override
124
143
Widget build (BuildContext context) {
125
144
final store = PerAccountStoreWidget .of (context);
@@ -129,21 +148,271 @@ class ReactionChipsList extends StatelessWidget {
129
148
return Wrap (spacing: 4 , runSpacing: 4 , crossAxisAlignment: WrapCrossAlignment .center,
130
149
children: reactions.aggregated.map ((reactionVotes) => ReactionChip (
131
150
showName: showNames,
132
- messageId: messageId, reactionWithVotes: reactionVotes),
151
+ messageId: messageId, reactionWithVotes: reactionVotes,
152
+ showReactedUsers: showReactedUsers,
153
+ ),
133
154
).toList ());
134
155
}
135
156
}
136
157
158
+ class _ReactionUsersSheet extends StatefulWidget {
159
+ const _ReactionUsersSheet ({
160
+ required this .reactions,
161
+ required this .initialSelectedReaction,
162
+ required this .store,
163
+ });
164
+
165
+ final Reactions reactions;
166
+ final ReactionWithVotes initialSelectedReaction;
167
+ final PerAccountStore store;
168
+
169
+ @override
170
+ State <_ReactionUsersSheet > createState () => _ReactionUsersSheetState ();
171
+ }
172
+
173
+ class _ReactionUsersSheetState extends State <_ReactionUsersSheet > {
174
+ late ReactionWithVotes ? _selectedReaction;
175
+
176
+ /// Cache for emoji displays to avoid recomputing them
177
+ final Map <String , Widget > _emojiCache = {};
178
+
179
+ @override
180
+ void initState () {
181
+ super .initState ();
182
+ _selectedReaction = widget.initialSelectedReaction;
183
+ _prepareEmojiCache ();
184
+ }
185
+
186
+ /// Pre-compute emoji displays for better performance
187
+ void _prepareEmojiCache () {
188
+ for (final reaction in widget.reactions.aggregated) {
189
+ final key = '${reaction .reactionType }_${reaction .emojiCode }' ;
190
+ if (! _emojiCache.containsKey (key)) {
191
+ final emojiDisplay = widget.store.emojiDisplayFor (
192
+ emojiType: reaction.reactionType,
193
+ emojiCode: reaction.emojiCode,
194
+ emojiName: reaction.emojiName,
195
+ ).resolve (widget.store.userSettings);
196
+
197
+ final emoji = switch (emojiDisplay) {
198
+ UnicodeEmojiDisplay () => _UnicodeEmoji (
199
+ emojiDisplay: emojiDisplay),
200
+ ImageEmojiDisplay () => _ImageEmoji (
201
+ emojiDisplay: emojiDisplay, emojiName: reaction.emojiName, selected: false ),
202
+ TextEmojiDisplay () => _TextEmoji (
203
+ emojiDisplay: emojiDisplay, selected: false ),
204
+ };
205
+
206
+ _emojiCache[key] = SizedBox (
207
+ width: 20 ,
208
+ height: 20 ,
209
+ child: emoji,
210
+ );
211
+ }
212
+ }
213
+ }
214
+
215
+ Widget _getEmojiWidget (ReactionWithVotes reaction) {
216
+ final key = '${reaction .reactionType }_${reaction .emojiCode }' ;
217
+ return _emojiCache[key]! ;
218
+ }
219
+
220
+ Widget _buildEmojiButton (ReactionWithVotes reaction) {
221
+ final isSelected = _selectedReaction == reaction;
222
+ final reactionTheme = EmojiReactionTheme .of (context);
223
+
224
+ return Material (
225
+ color: Colors .transparent,
226
+ child: InkWell (
227
+ onTap: () {
228
+ setState (() {
229
+ _selectedReaction = reaction;
230
+ });
231
+ },
232
+ child: Padding (
233
+ padding: const EdgeInsets .symmetric (horizontal: 3 , vertical: 4 ),
234
+ child: Column (
235
+ mainAxisSize: MainAxisSize .min,
236
+ children: [
237
+ Container (
238
+ padding: const EdgeInsets .symmetric (horizontal: 12 , vertical: 6 ),
239
+ decoration: BoxDecoration (
240
+ color: isSelected ? reactionTheme.bgSelected.withValues (alpha: 0.1 ) : Colors .transparent,
241
+ borderRadius: BorderRadius .circular (20 ),
242
+ ),
243
+ child: Row (
244
+ mainAxisSize: MainAxisSize .min,
245
+ children: [
246
+ _getEmojiWidget (reaction),
247
+ const SizedBox (width: 4 ),
248
+ Text (
249
+ reaction.userIds.length.toString (),
250
+ style: TextStyle (
251
+ fontSize: 14 ,
252
+ fontWeight: isSelected ? FontWeight .bold : FontWeight .normal,
253
+ color: isSelected ? reactionTheme.textSelected : reactionTheme.textUnselected,
254
+ ),
255
+ ),
256
+ ],
257
+ ),
258
+ ),
259
+ AnimatedContainer (
260
+ duration: const Duration (milliseconds: 300 ),
261
+ margin: const EdgeInsets .only (top: 4 ),
262
+ height: 2 ,
263
+ width: isSelected ? 20 : 0 ,
264
+ decoration: BoxDecoration (
265
+ color: isSelected ? reactionTheme.textSelected : Colors .transparent,
266
+ borderRadius: BorderRadius .circular (1 ),
267
+ ),
268
+ ),
269
+ ],
270
+ ),
271
+ ),
272
+ ),
273
+ );
274
+ }
275
+
276
+ Widget _buildAllButton () {
277
+ final reactionTheme = EmojiReactionTheme .of (context);
278
+ final isSelected = _selectedReaction == null ;
279
+
280
+ return Material (
281
+ color: Colors .transparent,
282
+ child: InkWell (
283
+ onTap: () {
284
+ setState (() {
285
+ _selectedReaction = null ;
286
+ });
287
+ },
288
+ child: Padding (
289
+ padding: const EdgeInsets .symmetric (horizontal: 3 , vertical: 4 ),
290
+ child: Column (
291
+ mainAxisSize: MainAxisSize .min,
292
+ children: [
293
+ Container (
294
+ padding: const EdgeInsets .symmetric (horizontal: 12 , vertical: 6 ),
295
+ decoration: BoxDecoration (
296
+ color: isSelected ? reactionTheme.bgSelected.withValues (alpha: 0.1 ) : Colors .transparent,
297
+ borderRadius: BorderRadius .circular (20 ),
298
+ ),
299
+ child: Text (
300
+ 'All ${widget .reactions .total }' ,
301
+ style: TextStyle (
302
+ fontSize: 14 ,
303
+ fontWeight: isSelected ? FontWeight .bold : FontWeight .normal,
304
+ color: isSelected ? reactionTheme.textSelected : reactionTheme.textUnselected,
305
+ ),
306
+ ),
307
+ ),
308
+ AnimatedContainer (
309
+ duration: const Duration (milliseconds: 300 ),
310
+ margin: const EdgeInsets .only (top: 4 ),
311
+ height: 2 ,
312
+ width: isSelected ? 20 : 0 ,
313
+ decoration: BoxDecoration (
314
+ color: isSelected ? reactionTheme.textSelected : Colors .transparent,
315
+ borderRadius: BorderRadius .circular (1 ),
316
+ ),
317
+ ),
318
+ ],
319
+ ),
320
+ ),
321
+ ),
322
+ );
323
+ }
324
+
325
+ List <({String name, Widget emoji, int userId})> _getUserNamesWithEmojis () {
326
+ if (_selectedReaction == null ) {
327
+ // Show all users when "All" is selected
328
+ final allUserReactions = < ({String name, Widget emoji, int userId})> [];
329
+
330
+ for (final reaction in widget.reactions.aggregated) {
331
+ // Add each user-reaction combination separately
332
+ for (final userId in reaction.userIds) {
333
+ allUserReactions.add ((
334
+ name: widget.store.users[userId]? .fullName ?? '(unknown user)' ,
335
+ emoji: _getEmojiWidget (reaction),
336
+ userId: userId,
337
+ ));
338
+ }
339
+ }
340
+
341
+ // Sort by name to group the same user's reactions together
342
+ return allUserReactions..sort ((a, b) => a.name.compareTo (b.name));
343
+ } else {
344
+ // Show users for selected reaction
345
+ return _selectedReaction! .userIds.map ((userId) => (
346
+ name: widget.store.users[userId]? .fullName ?? '(unknown user)' ,
347
+ emoji: _getEmojiWidget (_selectedReaction! ),
348
+ userId: userId,
349
+ )).toList ()..sort ((a, b) => a.name.compareTo (b.name));
350
+ }
351
+ }
352
+
353
+ @override
354
+ Widget build (BuildContext context) {
355
+ final users = _getUserNamesWithEmojis ();
356
+
357
+ return SafeArea (
358
+ child: Column (
359
+ mainAxisSize: MainAxisSize .min,
360
+ crossAxisAlignment: CrossAxisAlignment .start,
361
+ children: [
362
+ Padding (
363
+ padding: const EdgeInsets .all (16 ),
364
+ child: SingleChildScrollView (
365
+ scrollDirection: Axis .horizontal,
366
+ child: Row (
367
+ children: [
368
+ _buildAllButton (),
369
+ ...widget.reactions.aggregated.map ((reaction) => _buildEmojiButton (reaction)),
370
+ ],
371
+ ),
372
+ ),
373
+ ),
374
+ Flexible (
375
+ child: ListView .builder (
376
+ shrinkWrap: true ,
377
+ itemCount: users.length,
378
+ itemBuilder: (context, index) => InkWell (
379
+ onTap: () => Navigator .push (context,
380
+ ProfilePage .buildRoute (context: context,
381
+ userId: users[index].userId)),
382
+ child: ListTile (
383
+ leading: Avatar (
384
+ size: 32 ,
385
+ borderRadius: 3 ,
386
+ userId: users[index].userId,
387
+ ),
388
+ title: Row (
389
+ children: [
390
+ Expanded (child: Text (users[index].name)),
391
+ users[index].emoji,
392
+ ],
393
+ ),
394
+ ),
395
+ ),
396
+ ),
397
+ ),
398
+ ],
399
+ ),
400
+ );
401
+ }
402
+ }
403
+
137
404
class ReactionChip extends StatelessWidget {
138
405
final bool showName;
139
406
final int messageId;
140
407
final ReactionWithVotes reactionWithVotes;
408
+ final void Function (BuildContext , ReactionWithVotes ) showReactedUsers;
141
409
142
410
const ReactionChip ({
143
411
super .key,
144
412
required this .showName,
145
413
required this .messageId,
146
414
required this .reactionWithVotes,
415
+ required this .showReactedUsers,
147
416
});
148
417
149
418
@override
@@ -214,6 +483,9 @@ class ReactionChip extends StatelessWidget {
214
483
emojiName: emojiName,
215
484
);
216
485
},
486
+ onLongPress: () {
487
+ showReactedUsers (context, reactionWithVotes);
488
+ },
217
489
child: Padding (
218
490
// 1px of this padding accounts for the border, which Flutter
219
491
// just paints without changing size.
0 commit comments