Skip to content

Commit a4b5987

Browse files
Added modal bottom sheet that shows list of users and their reactions on long pressing reactions
1 parent 35d83f2 commit a4b5987

File tree

1 file changed

+273
-1
lines changed

1 file changed

+273
-1
lines changed

lib/widgets/emoji_reaction.dart

+273-1
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ import '../api/route/messages.dart';
66
import '../generated/l10n/zulip_localizations.dart';
77
import '../model/autocomplete.dart';
88
import '../model/emoji.dart';
9+
import '../model/store.dart';
910
import 'color.dart';
11+
import 'content.dart';
1012
import 'dialog.dart';
1113
import 'emoji.dart';
1214
import 'inset_shadow.dart';
15+
import 'profile.dart';
1316
import 'store.dart';
1417
import 'text.dart';
1518
import '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+
137404
class 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

Comments
 (0)