@@ -6,10 +6,13 @@ import '../api/route/messages.dart';
66import '../generated/l10n/zulip_localizations.dart' ;
77import '../model/autocomplete.dart' ;
88import '../model/emoji.dart' ;
9+ import '../model/store.dart' ;
910import 'color.dart' ;
11+ import 'content.dart' ;
1012import 'dialog.dart' ;
1113import 'emoji.dart' ;
1214import 'inset_shadow.dart' ;
15+ import 'profile.dart' ;
1316import 'store.dart' ;
1417import 'text.dart' ;
1518import 'theme.dart' ;
@@ -120,6 +123,22 @@ class ReactionChipsList extends StatelessWidget {
120123 final int messageId;
121124 final Reactions reactions;
122125
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+
123142 @override
124143 Widget build (BuildContext context) {
125144 final store = PerAccountStoreWidget .of (context);
@@ -129,21 +148,271 @@ class ReactionChipsList extends StatelessWidget {
129148 return Wrap (spacing: 4 , runSpacing: 4 , crossAxisAlignment: WrapCrossAlignment .center,
130149 children: reactions.aggregated.map ((reactionVotes) => ReactionChip (
131150 showName: showNames,
132- messageId: messageId, reactionWithVotes: reactionVotes),
151+ messageId: messageId, reactionWithVotes: reactionVotes,
152+ showReactedUsers: showReactedUsers,
153+ ),
133154 ).toList ());
134155 }
135156}
136157
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+
137404class ReactionChip extends StatelessWidget {
138405 final bool showName;
139406 final int messageId;
140407 final ReactionWithVotes reactionWithVotes;
408+ final void Function (BuildContext , ReactionWithVotes ) showReactedUsers;
141409
142410 const ReactionChip ({
143411 super .key,
144412 required this .showName,
145413 required this .messageId,
146414 required this .reactionWithVotes,
415+ required this .showReactedUsers,
147416 });
148417
149418 @override
@@ -214,6 +483,9 @@ class ReactionChip extends StatelessWidget {
214483 emojiName: emojiName,
215484 );
216485 },
486+ onLongPress: () {
487+ showReactedUsers (context, reactionWithVotes);
488+ },
217489 child: Padding (
218490 // 1px of this padding accounts for the border, which Flutter
219491 // just paints without changing size.
0 commit comments