Skip to content

Commit 001d8d0

Browse files
committed
msglist: Show message reactions!
And also support: - removing a reaction you've already made, and - joining in on existing reactions that other people have made. It leaves out one part of zulip#125, for now: - joining in on an existing reaction other people have made As is our habit with the message list, this aims to be faithful to the web app, as accessed today. That should be a good baseline to make mobile-specific adjustments from. (In particular I think we'll want larger touch targets.) Unlike the web app, we use a font instead of a sprite sheet to render Unicode emoji. This means that we, unlike web, have to account for text-layout algorithms, and things like font metrics. So if Unicode emoji appear noticeably differently from web, that's worth being aware of. Fixes: zulip#121 Fixes-partly: zulip#125
1 parent be36153 commit 001d8d0

File tree

2 files changed

+267
-0
lines changed

2 files changed

+267
-0
lines changed

lib/widgets/emoji_reaction.dart

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import 'dart:ui';
2+
3+
import 'package:flutter/material.dart';
4+
5+
import '../api/model/initial_snapshot.dart';
6+
import '../api/model/model.dart';
7+
import '../api/route/messages.dart';
8+
import '../model/content.dart';
9+
import 'content.dart';
10+
import 'store.dart';
11+
import 'text.dart';
12+
13+
class ReactionChipsList extends StatelessWidget {
14+
final int messageId;
15+
final Reactions reactions;
16+
17+
const ReactionChipsList({
18+
super.key,
19+
required this.messageId,
20+
required this.reactions,
21+
});
22+
23+
@override
24+
Widget build(BuildContext context) {
25+
final store = PerAccountStoreWidget.of(context);
26+
final displayEmojiReactionUsers = store.userSettings?.displayEmojiReactionUsers ?? false;
27+
final showNames = displayEmojiReactionUsers && reactions.total <= 3;
28+
29+
return Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center,
30+
children: reactions.aggregated.map((reactionVotes) => ReactionChip(
31+
showName: showNames,
32+
messageId: messageId, reactionWithVotes: reactionVotes),
33+
).toList());
34+
}
35+
}
36+
37+
final _textColorSelected = const HSLColor.fromAHSL(1, 210, 0.20, 0.20).toColor();
38+
final _textColorUnselected = const HSLColor.fromAHSL(1, 210, 0.20, 0.25).toColor();
39+
40+
const _backgroundColorSelected = Colors.white;
41+
// TODO shadow effect, following web, which uses `box-shadow: inset`:
42+
// https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow#inset
43+
// Needs Flutter support for something like that:
44+
// https://github.com/flutter/flutter/issues/18636
45+
// https://github.com/flutter/flutter/issues/52999
46+
// Until then use a solid color; a much-lightened version of the shadow color.
47+
// Also adapt by making [_borderColorUnselected] more transparent, so we'll
48+
// want to check that against web when implementing the shadow.
49+
final _backgroundColorUnselected = const HSLColor.fromAHSL(0.15, 210, 0.50, 0.875).toColor();
50+
51+
final _borderColorSelected = Colors.black.withOpacity(0.40);
52+
// TODO see TODO on [_backgroundColorUnselected] about shadow effect
53+
final _borderColorUnselected = Colors.black.withOpacity(0.06);
54+
55+
class ReactionChip extends StatelessWidget {
56+
final bool showName;
57+
final int messageId;
58+
final ReactionWithVotes reactionWithVotes;
59+
60+
const ReactionChip({
61+
super.key,
62+
required this.showName,
63+
required this.messageId,
64+
required this.reactionWithVotes,
65+
});
66+
67+
@override
68+
Widget build(BuildContext context) {
69+
final store = PerAccountStoreWidget.of(context);
70+
71+
final reactionType = reactionWithVotes.reactionType;
72+
final emojiCode = reactionWithVotes.emojiCode;
73+
final emojiName = reactionWithVotes.emojiName;
74+
final userIds = reactionWithVotes.userIds;
75+
76+
final emojiset = store.userSettings?.emojiset ?? Emojiset.google;
77+
78+
final selfUserId = store.account.userId;
79+
final selfVoted = userIds.contains(selfUserId);
80+
final label = showName
81+
? userIds.map((id) {
82+
return id == selfUserId
83+
? 'You'
84+
: store.users[id]?.fullName ?? '(unknown user)';
85+
}).join(', ')
86+
: userIds.length.toString();
87+
88+
final borderColor = selfVoted ? _borderColorSelected : _borderColorUnselected;
89+
final labelColor = selfVoted ? _textColorSelected : _textColorUnselected;
90+
final backgroundColor = selfVoted ? _backgroundColorSelected : _backgroundColorUnselected;
91+
final splashColor = selfVoted ? _backgroundColorUnselected : _backgroundColorSelected;
92+
final highlightColor = splashColor.withOpacity(0.5);
93+
94+
final borderSide = BorderSide(color: borderColor, width: 1);
95+
final shape = StadiumBorder(side: borderSide);
96+
97+
return Tooltip(
98+
excludeFromSemantics: true, // TODO: Semantics with eg "Reaction: <emoji name>; you and N others: <names>"
99+
message: emojiName,
100+
child: Material(
101+
color: backgroundColor,
102+
shape: shape,
103+
child: InkWell(
104+
customBorder: shape,
105+
splashColor: splashColor,
106+
highlightColor: highlightColor,
107+
onTap: () {
108+
(selfVoted ? removeReaction : addReaction).call(store.connection,
109+
messageId: messageId,
110+
reactionType: reactionType,
111+
emojiCode: emojiCode,
112+
emojiName: emojiName,
113+
);
114+
},
115+
child: Padding(
116+
// 1px of this padding accounts for the border, which Flutter
117+
// just paints without changing size.
118+
//
119+
// Separately, web has 1px less than this on the left, but that
120+
// asymmetry doesn't seem to help us.
121+
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
122+
child: Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [
123+
Padding(padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
124+
child: (() {
125+
if (emojiset == Emojiset.text) {
126+
return _TextEmoji(emojiName: emojiName, selected: selfVoted);
127+
}
128+
switch (reactionType) {
129+
case ReactionType.unicodeEmoji:
130+
return _UnicodeEmoji(
131+
emojiCode: emojiCode,
132+
emojiName: emojiName,
133+
selected: selfVoted,
134+
);
135+
case ReactionType.realmEmoji:
136+
case ReactionType.zulipExtraEmoji:
137+
return _ImageEmoji(
138+
emojiCode: emojiCode,
139+
emojiName: emojiName,
140+
selected: selfVoted,
141+
);
142+
}
143+
})()),
144+
Flexible(
145+
// Added vertical: 1 to give some space when the label is
146+
// taller than the emoji (e.g. because it needs multiple lines)
147+
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
148+
child: Text(
149+
textWidthBasis: TextWidthBasis.longestLine,
150+
style: TextStyle(
151+
fontFamily: 'Source Sans 3',
152+
fontSize: (14 * 0.90),
153+
height: 13 / (14 * 0.90),
154+
color: labelColor,
155+
).merge(selfVoted
156+
? weightVariableTextStyle(context, wght: 600, wghtIfPlatformRequestsBold: 900)
157+
: weightVariableTextStyle(context)),
158+
label)),
159+
),
160+
])))));
161+
}
162+
}
163+
164+
class _UnicodeEmoji extends StatelessWidget {
165+
const _UnicodeEmoji({
166+
required this.emojiCode,
167+
required this.emojiName,
168+
required this.selected,
169+
});
170+
171+
final String emojiCode;
172+
final String emojiName;
173+
final bool selected;
174+
175+
@override
176+
Widget build(BuildContext context) {
177+
final parsed = tryParseEmojiCodeToUnicode(emojiCode);
178+
if (parsed == null) {
179+
return _TextEmoji(emojiName: emojiName, selected: selected);
180+
}
181+
final textScaler = MediaQuery.textScalerOf(context);
182+
return SizedBox(
183+
width: textScaler.scale(17),
184+
child: Text(
185+
style: const TextStyle(fontSize: 17),
186+
strutStyle: const StrutStyle(fontSize: 17, forceStrutHeight: true),
187+
parsed));
188+
}
189+
}
190+
191+
class _ImageEmoji extends StatelessWidget {
192+
const _ImageEmoji({
193+
required this.emojiCode,
194+
required this.emojiName,
195+
required this.selected,
196+
});
197+
198+
final String emojiCode;
199+
final String emojiName;
200+
final bool selected;
201+
202+
Widget get _textFallback => _TextEmoji(emojiName: emojiName, selected: selected);
203+
204+
@override
205+
Widget build(BuildContext context) {
206+
final store = PerAccountStoreWidget.of(context);
207+
208+
// Some people really dislike animated emoji.
209+
final doNotAnimate = MediaQuery.of(context).disableAnimations
210+
|| PlatformDispatcher.instance.accessibilityFeatures.reduceMotion;
211+
212+
String src;
213+
switch (emojiCode) {
214+
case 'zulip': // the single "zulip extra emoji"
215+
src = '/static/generated/emoji/images/emoji/unicode/zulip.png';
216+
default:
217+
final item = store.realmEmoji[emojiCode];
218+
if (item == null) {
219+
return _textFallback;
220+
}
221+
src = doNotAnimate && item.stillUrl != null ? item.stillUrl! : item.sourceUrl;
222+
}
223+
final parsedSrc = Uri.tryParse(src);
224+
if (parsedSrc == null) {
225+
return _textFallback;
226+
}
227+
final resolved = store.account.realmUrl.resolveUri(parsedSrc);
228+
229+
// Unicode emoji get scaled; it would look weird if image emoji didn't.
230+
final size = MediaQuery.textScalerOf(context).scale(17);
231+
232+
return Center(
233+
child: RealmContentNetworkImage(
234+
resolved,
235+
width: size,
236+
height: size,
237+
errorBuilder: (context, _, __) => _textFallback,
238+
));
239+
}
240+
}
241+
242+
class _TextEmoji extends StatelessWidget {
243+
final String emojiName;
244+
final bool selected;
245+
246+
const _TextEmoji({required this.emojiName, required this.selected});
247+
248+
@override
249+
Widget build(BuildContext context) {
250+
return SizedBox(
251+
height: 17,
252+
child: Center(
253+
child: Text(
254+
style: TextStyle(
255+
fontFamily: 'Source Sans 3',
256+
fontSize: 14 * 0.8,
257+
height: 16 / (14 * 0.8),
258+
color: selected ? _textColorSelected : _textColorUnselected,
259+
).merge(selected
260+
? weightVariableTextStyle(context, wght: 600, wghtIfPlatformRequestsBold: 900)
261+
: weightVariableTextStyle(context)),
262+
':$emojiName:')));
263+
}
264+
}

lib/widgets/message_list.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import 'action_sheet.dart';
1515
import 'compose_box.dart';
1616
import 'content.dart';
1717
import 'dialog.dart';
18+
import 'emoji_reaction.dart';
1819
import 'icons.dart';
1920
import 'page.dart';
2021
import 'profile.dart';
@@ -696,6 +697,8 @@ class MessageWithPossibleSender extends StatelessWidget {
696697
const SizedBox(height: 4),
697698
],
698699
MessageContent(message: message, content: item.content),
700+
if ((message.reactions?.total ?? 0) > 0)
701+
ReactionChipsList(messageId: message.id, reactions: message.reactions!)
699702
])),
700703
Container(
701704
width: 80,

0 commit comments

Comments
 (0)