Skip to content

Commit 4c5abf1

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. 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 ddedccb commit 4c5abf1

File tree

2 files changed

+328
-0
lines changed

2 files changed

+328
-0
lines changed

lib/widgets/emoji_reaction.dart

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
import 'package:flutter/foundation.dart';
2+
import 'package:flutter/material.dart';
3+
4+
import '../api/model/initial_snapshot.dart';
5+
import '../api/model/model.dart';
6+
import '../api/route/messages.dart';
7+
import '../model/content.dart';
8+
import 'content.dart';
9+
import 'store.dart';
10+
import 'text.dart';
11+
12+
class ReactionChipsList extends StatelessWidget {
13+
const ReactionChipsList({
14+
super.key,
15+
required this.messageId,
16+
required this.reactions,
17+
});
18+
19+
final int messageId;
20+
final Reactions reactions;
21+
22+
@override
23+
Widget build(BuildContext context) {
24+
final store = PerAccountStoreWidget.of(context);
25+
final displayEmojiReactionUsers = store.userSettings?.displayEmojiReactionUsers ?? false;
26+
final showNames = displayEmojiReactionUsers && reactions.total <= 3;
27+
28+
return Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center,
29+
children: reactions.aggregated.map((reactionVotes) => ReactionChip(
30+
showName: showNames,
31+
messageId: messageId, reactionWithVotes: reactionVotes),
32+
).toList());
33+
}
34+
}
35+
36+
final _textColorSelected = const HSLColor.fromAHSL(1, 210, 0.20, 0.20).toColor();
37+
final _textColorUnselected = const HSLColor.fromAHSL(1, 210, 0.20, 0.25).toColor();
38+
39+
const _backgroundColorSelected = Colors.white;
40+
// TODO shadow effect, following web, which uses `box-shadow: inset`:
41+
// https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow#inset
42+
// Needs Flutter support for something like that:
43+
// https://github.com/flutter/flutter/issues/18636
44+
// https://github.com/flutter/flutter/issues/52999
45+
// Until then use a solid color; a much-lightened version of the shadow color.
46+
// Also adapt by making [_borderColorUnselected] more transparent, so we'll
47+
// want to check that against web when implementing the shadow.
48+
final _backgroundColorUnselected = const HSLColor.fromAHSL(0.15, 210, 0.50, 0.875).toColor();
49+
50+
final _borderColorSelected = Colors.black.withOpacity(0.40);
51+
// TODO see TODO on [_backgroundColorUnselected] about shadow effect
52+
final _borderColorUnselected = Colors.black.withOpacity(0.06);
53+
54+
class ReactionChip extends StatelessWidget {
55+
final bool showName;
56+
final int messageId;
57+
final ReactionWithVotes reactionWithVotes;
58+
59+
const ReactionChip({
60+
super.key,
61+
required this.showName,
62+
required this.messageId,
63+
required this.reactionWithVotes,
64+
});
65+
66+
@override
67+
Widget build(BuildContext context) {
68+
final store = PerAccountStoreWidget.of(context);
69+
70+
final reactionType = reactionWithVotes.reactionType;
71+
final emojiCode = reactionWithVotes.emojiCode;
72+
final emojiName = reactionWithVotes.emojiName;
73+
final userIds = reactionWithVotes.userIds;
74+
75+
final emojiset = store.userSettings?.emojiset ?? Emojiset.google;
76+
77+
final selfUserId = store.account.userId;
78+
final selfVoted = userIds.contains(selfUserId);
79+
final label = showName
80+
? userIds.map((id) {
81+
return id == selfUserId
82+
? 'You'
83+
: store.users[id]?.fullName ?? '(unknown user)';
84+
}).join(', ')
85+
: userIds.length.toString();
86+
87+
final borderColor = selfVoted ? _borderColorSelected : _borderColorUnselected;
88+
final labelColor = selfVoted ? _textColorSelected : _textColorUnselected;
89+
final backgroundColor = selfVoted ? _backgroundColorSelected : _backgroundColorUnselected;
90+
final splashColor = selfVoted ? _backgroundColorUnselected : _backgroundColorSelected;
91+
final highlightColor = splashColor.withOpacity(0.5);
92+
93+
final borderSide = BorderSide(color: borderColor, width: 1);
94+
final shape = StadiumBorder(side: borderSide);
95+
96+
final Widget emoji;
97+
if (emojiset == Emojiset.text) {
98+
emoji = _TextEmoji(emojiName: emojiName, selected: selfVoted);
99+
} else {
100+
switch (reactionType) {
101+
case ReactionType.unicodeEmoji:
102+
emoji = _UnicodeEmoji(
103+
emojiCode: emojiCode,
104+
emojiName: emojiName,
105+
selected: selfVoted,
106+
);
107+
case ReactionType.realmEmoji:
108+
case ReactionType.zulipExtraEmoji:
109+
emoji = _ImageEmoji(
110+
emojiCode: emojiCode,
111+
emojiName: emojiName,
112+
selected: selfVoted,
113+
);
114+
}
115+
}
116+
117+
return Tooltip(
118+
excludeFromSemantics: true, // TODO: Semantics with eg "Reaction: <emoji name>; you and N others: <names>"
119+
message: emojiName,
120+
child: Material(
121+
color: backgroundColor,
122+
shape: shape,
123+
child: InkWell(
124+
customBorder: shape,
125+
splashColor: splashColor,
126+
highlightColor: highlightColor,
127+
onTap: () {
128+
(selfVoted ? removeReaction : addReaction).call(store.connection,
129+
messageId: messageId,
130+
reactionType: reactionType,
131+
emojiCode: emojiCode,
132+
emojiName: emojiName,
133+
);
134+
},
135+
child: Padding(
136+
// 1px of this padding accounts for the border, which Flutter
137+
// just paints without changing size.
138+
padding: const EdgeInsetsDirectional.fromSTEB(4, 2, 5, 2),
139+
child: Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [
140+
Padding(padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
141+
child: emoji),
142+
Flexible(
143+
// Added vertical: 1 to give some space when the label is
144+
// taller than the emoji (e.g. because it needs multiple lines)
145+
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
146+
child: Text(
147+
textWidthBasis: TextWidthBasis.longestLine,
148+
textScaler: _labelTextScalerClamped(context),
149+
style: TextStyle(
150+
fontFamily: 'Source Sans 3',
151+
fontSize: (14 * 0.90),
152+
height: 13 / (14 * 0.90),
153+
color: labelColor,
154+
).merge(selfVoted
155+
? weightVariableTextStyle(context, wght: 600, wghtIfPlatformRequestsBold: 900)
156+
: weightVariableTextStyle(context)),
157+
label))),
158+
])))));
159+
}
160+
}
161+
162+
/// The size of the emoji square, or for :text_emojis:, the allotted height.
163+
///
164+
/// Should be scaled by [_emojiTextScalerClamped] for all emoji types.
165+
const _emojiSize = 17.0;
166+
167+
/// A font size that, with Noto Color Emoji and our line-height config,
168+
/// causes a Unicode emoji to occupy a square of [_emojiSize] in the layout.
169+
///
170+
/// Determined experimentally:
171+
/// <https://github.com/zulip/zulip-flutter/pull/410#discussion_r1402808701>
172+
// TODO(#404) Actually bundle Noto Color Emoji with the app. Some newer Android
173+
// phones seem to use Noto Color Emoji automatically, but it might not be
174+
// universal, and at least the office Android device (which is old) doesn't.
175+
const _notoColorEmojiTextSize = 14.5;
176+
177+
/// A [TextScaler] that limits the emoji's max scale factor,
178+
/// to try to prevent breaking the layout.
179+
///
180+
/// This should scale [_emojiSize] for all emoji types.
181+
// TODO(i18n) find a reasonable layout that doesn't limit the scale factor?
182+
TextScaler _emojiTextScalerClamped(BuildContext context) =>
183+
MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 2);
184+
185+
/// A [TextScaler] that limits the label's max scale factor,
186+
/// to try to prevent breaking the layout.
187+
// TODO(i18n) find a reasonable layout that doesn't limit the scale factor?
188+
TextScaler _labelTextScalerClamped(BuildContext context) =>
189+
MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 2);
190+
191+
class _UnicodeEmoji extends StatelessWidget {
192+
const _UnicodeEmoji({
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+
@override
203+
Widget build(BuildContext context) {
204+
final parsed = tryParseEmojiCodeToUnicode(emojiCode);
205+
if (parsed == null) { // TODO(log)
206+
return _TextEmoji(emojiName: emojiName, selected: selected);
207+
}
208+
209+
if (defaultTargetPlatform != TargetPlatform.iOS
210+
&& defaultTargetPlatform != TargetPlatform.macOS) {
211+
return Text(
212+
textScaler: _emojiTextScalerClamped(context),
213+
style: const TextStyle(fontSize: _notoColorEmojiTextSize),
214+
strutStyle: const StrutStyle(fontSize: _notoColorEmojiTextSize, forceStrutHeight: true),
215+
parsed);
216+
}
217+
218+
// Special logic for iOS.
219+
//
220+
// We expect the font "Apple Color Emoji" to be used. There are some
221+
// surprises in how Flutter wants to render emojis in this font:
222+
// - With a font size of 17px, the emoji visually seems to be about 17px
223+
// square. (Unlike on Android, with Noto Color Emoji, where a 14.5px font
224+
// size gives an emoji that looks 17px square.) See:
225+
// <https://github.com/flutter/flutter/issues/28894>
226+
// - The emoji doesn't fill the space taken by the [Text] in the layout.
227+
// There's whitespace above, below, and on the right. See:
228+
// <https://github.com/flutter/flutter/issues/119623>
229+
//
230+
// That extra space would be problematic, except we've used a [Stack] to
231+
// make the [Text] "positioned" so the space doesn't add margins around the
232+
// visible part. Key points that enable the [Stack] workaround:
233+
// - The emoji seems approximately vertically centered (this is
234+
// accomplished with help from a [StrutStyle]; see below).
235+
// - There seems to be approximately no space on its left.
236+
final boxSize = _emojiTextScalerClamped(context).scale(_emojiSize);
237+
return Stack(alignment: Alignment.centerLeft, clipBehavior: Clip.none, children: [
238+
SizedBox(height: boxSize, width: boxSize),
239+
PositionedDirectional(start: 0, child: Text(
240+
textScaler: _emojiTextScalerClamped(context),
241+
style: const TextStyle(fontSize: _emojiSize),
242+
strutStyle: const StrutStyle(fontSize: _emojiSize, forceStrutHeight: true),
243+
parsed)),
244+
]);
245+
}
246+
}
247+
248+
class _ImageEmoji extends StatelessWidget {
249+
const _ImageEmoji({
250+
required this.emojiCode,
251+
required this.emojiName,
252+
required this.selected,
253+
});
254+
255+
final String emojiCode;
256+
final String emojiName;
257+
final bool selected;
258+
259+
Widget get _textFallback => _TextEmoji(emojiName: emojiName, selected: selected);
260+
261+
@override
262+
Widget build(BuildContext context) {
263+
final store = PerAccountStoreWidget.of(context);
264+
265+
// Some people really dislike animated emoji.
266+
final doNotAnimate = MediaQuery.disableAnimationsOf(context)
267+
|| (defaultTargetPlatform == TargetPlatform.iOS
268+
// This might be exposed somewhere else in the future,
269+
// like on a new inherited widget:
270+
// https://github.com/flutter/flutter/issues/65874
271+
&& WidgetsBinding.instance.platformDispatcher.accessibilityFeatures.reduceMotion);
272+
273+
final String src;
274+
switch (emojiCode) {
275+
case 'zulip': // the single "zulip extra emoji"
276+
src = '/static/generated/emoji/images/emoji/unicode/zulip.png';
277+
default:
278+
final item = store.realmEmoji[emojiCode];
279+
if (item == null) {
280+
return _textFallback;
281+
}
282+
src = doNotAnimate && item.stillUrl != null ? item.stillUrl! : item.sourceUrl;
283+
}
284+
final parsedSrc = Uri.tryParse(src);
285+
if (parsedSrc == null) {
286+
return _textFallback;
287+
}
288+
final resolved = store.account.realmUrl.resolveUri(parsedSrc);
289+
290+
// Unicode emoji get scaled; it would look weird if image emoji didn't.
291+
final size = _emojiTextScalerClamped(context).scale(_emojiSize);
292+
293+
return RealmContentNetworkImage(
294+
resolved,
295+
width: size,
296+
height: size,
297+
errorBuilder: (context, _, __) => _textFallback,
298+
);
299+
}
300+
}
301+
302+
class _TextEmoji extends StatelessWidget {
303+
final String emojiName;
304+
final bool selected;
305+
306+
const _TextEmoji({required this.emojiName, required this.selected});
307+
308+
@override
309+
Widget build(BuildContext context) {
310+
return SizedBox(
311+
height: _emojiTextScalerClamped(context).scale(_emojiSize),
312+
child: Center(
313+
child: Text(
314+
textScaler: _emojiTextScalerClamped(context),
315+
style: TextStyle(
316+
fontFamily: 'Source Sans 3',
317+
fontSize: 14 * 0.8,
318+
height: 16 / (14 * 0.8),
319+
color: selected ? _textColorSelected : _textColorUnselected,
320+
).merge(selected
321+
? weightVariableTextStyle(context, wght: 600, wghtIfPlatformRequestsBold: 900)
322+
: weightVariableTextStyle(context)),
323+
':$emojiName:')));
324+
}
325+
}

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)