Skip to content

Commit e7a7bef

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 may 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 probably has something to do with it. Fixes: zulip#121 Fixes-partly: zulip#125
1 parent d5b9fb1 commit e7a7bef

File tree

3 files changed

+588
-0
lines changed

3 files changed

+588
-0
lines changed

lib/widgets/emoji_reaction.dart

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
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+
// TODO(i18n): List formatting, like you can do in JavaScript:
81+
// new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya', 'Shu'])
82+
// // 'Chris、Greg、Alya、Shu'
83+
? userIds.map((id) {
84+
return id == selfUserId
85+
? 'You'
86+
: store.users[id]?.fullName ?? '(unknown user)'; // TODO(i18n)
87+
}).join(', ')
88+
: userIds.length.toString();
89+
90+
final borderColor = selfVoted ? _borderColorSelected : _borderColorUnselected;
91+
final labelColor = selfVoted ? _textColorSelected : _textColorUnselected;
92+
final backgroundColor = selfVoted ? _backgroundColorSelected : _backgroundColorUnselected;
93+
final splashColor = selfVoted ? _backgroundColorUnselected : _backgroundColorSelected;
94+
final highlightColor = splashColor.withOpacity(0.5);
95+
96+
final borderSide = BorderSide(color: borderColor, width: 1);
97+
final shape = StadiumBorder(side: borderSide);
98+
99+
final Widget emoji;
100+
if (emojiset == Emojiset.text) {
101+
emoji = _TextEmoji(emojiName: emojiName, selected: selfVoted);
102+
} else {
103+
switch (reactionType) {
104+
case ReactionType.unicodeEmoji:
105+
emoji = _UnicodeEmoji(
106+
emojiCode: emojiCode,
107+
emojiName: emojiName,
108+
selected: selfVoted,
109+
);
110+
case ReactionType.realmEmoji:
111+
case ReactionType.zulipExtraEmoji:
112+
emoji = _ImageEmoji(
113+
emojiCode: emojiCode,
114+
emojiName: emojiName,
115+
selected: selfVoted,
116+
);
117+
}
118+
}
119+
120+
return Tooltip(
121+
excludeFromSemantics: true, // TODO: Semantics with eg "Reaction: <emoji name>; you and N others: <names>"
122+
message: emojiName,
123+
child: Material(
124+
color: backgroundColor,
125+
shape: shape,
126+
child: InkWell(
127+
customBorder: shape,
128+
splashColor: splashColor,
129+
highlightColor: highlightColor,
130+
onTap: () {
131+
(selfVoted ? removeReaction : addReaction).call(store.connection,
132+
messageId: messageId,
133+
reactionType: reactionType,
134+
emojiCode: emojiCode,
135+
emojiName: emojiName,
136+
);
137+
},
138+
child: Padding(
139+
// 1px of this padding accounts for the border, which Flutter
140+
// just paints without changing size.
141+
padding: const EdgeInsetsDirectional.fromSTEB(4, 2, 5, 2),
142+
child: LayoutBuilder(
143+
builder: (context, constraints) {
144+
final maxRowWidth = constraints.maxWidth;
145+
// To give text emojis some room so they need fewer line breaks
146+
// when the label is long
147+
final maxLabelWidth = (maxRowWidth - 6) * 0.75; // 6 is padding
148+
149+
return Row(
150+
mainAxisSize: MainAxisSize.min,
151+
crossAxisAlignment: CrossAxisAlignment.center,
152+
children: [
153+
// So text-emoji chips are at least as tall as square-emoji
154+
// ones (probably a good thing).
155+
SizedBox(height: _squareEmojiScalerClamped(context).scale(_squareEmojiSize)),
156+
Flexible( // [Flexible] to let text emojis expand if they can
157+
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
158+
child: emoji)),
159+
// Added vertical: 1 to give some space when the label is
160+
// taller than the emoji (e.g. because it needs multiple lines)
161+
Padding(padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
162+
child: Container(
163+
constraints: BoxConstraints(maxWidth: maxLabelWidth),
164+
child: Text(
165+
textWidthBasis: TextWidthBasis.longestLine,
166+
textScaler: _labelTextScalerClamped(context),
167+
style: TextStyle(
168+
fontFamily: 'Source Sans 3',
169+
fontSize: (14 * 0.90),
170+
height: 13 / (14 * 0.90),
171+
color: labelColor,
172+
).merge(selfVoted
173+
? weightVariableTextStyle(context, wght: 600, wghtIfPlatformRequestsBold: 900)
174+
: weightVariableTextStyle(context)),
175+
label),
176+
)),
177+
]);
178+
})))));
179+
}
180+
}
181+
182+
/// The size of a square emoji (Unicode or image).
183+
///
184+
/// Should be scaled by [_emojiTextScalerClamped].
185+
const _squareEmojiSize = 17.0;
186+
187+
/// A font size that, with Noto Color Emoji and our line-height config,
188+
/// causes a Unicode emoji to occupy a [_squareEmojiSize] square in the layout.
189+
///
190+
/// Determined experimentally:
191+
/// <https://github.com/zulip/zulip-flutter/pull/410#discussion_r1402808701>
192+
// TODO(#404) Actually bundle Noto Color Emoji with the app. Some Android
193+
// phones use Noto Color Emoji automatically, and some don't; e.g., Samsung
194+
// has its own emoji font:
195+
// <https://github.com/zulip/zulip-flutter/pull/410#discussion_r1408403111>
196+
const _notoColorEmojiTextSize = 14.5;
197+
198+
/// A [TextScaler] that limits Unicode and image emojis' max scale factor,
199+
/// to leave space for the label.
200+
///
201+
/// This should scale [_squareEmojiSize] for Unicode and image emojis.
202+
// TODO(a11y) clamp higher?
203+
TextScaler _squareEmojiScalerClamped(BuildContext context) =>
204+
MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 2);
205+
206+
/// A [TextScaler] that limits text emojis' max scale factor,
207+
/// to minimize the need for line breaks.
208+
// TODO(a11y) clamp higher?
209+
TextScaler _textEmojiScalerClamped(BuildContext context) =>
210+
MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5);
211+
212+
/// A [TextScaler] that limits the label's max scale factor,
213+
/// to minimize the need for line breaks.
214+
// TODO(a11y) clamp higher?
215+
TextScaler _labelTextScalerClamped(BuildContext context) =>
216+
MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 2);
217+
218+
class _UnicodeEmoji extends StatelessWidget {
219+
const _UnicodeEmoji({
220+
required this.emojiCode,
221+
required this.emojiName,
222+
required this.selected,
223+
});
224+
225+
final String emojiCode;
226+
final String emojiName;
227+
final bool selected;
228+
229+
@override
230+
Widget build(BuildContext context) {
231+
final parsed = tryParseEmojiCodeToUnicode(emojiCode);
232+
if (parsed == null) { // TODO(log)
233+
return _TextEmoji(emojiName: emojiName, selected: selected);
234+
}
235+
236+
switch (defaultTargetPlatform) {
237+
case TargetPlatform.android:
238+
case TargetPlatform.fuchsia:
239+
case TargetPlatform.linux:
240+
case TargetPlatform.windows:
241+
return Text(
242+
textScaler: _squareEmojiScalerClamped(context),
243+
style: const TextStyle(fontSize: _notoColorEmojiTextSize),
244+
strutStyle: const StrutStyle(fontSize: _notoColorEmojiTextSize, forceStrutHeight: true),
245+
parsed);
246+
case TargetPlatform.iOS:
247+
case TargetPlatform.macOS:
248+
// We expect the font "Apple Color Emoji" to be used. There are some
249+
// surprises in how Flutter ends up rendering emojis in this font:
250+
// - With a font size of 17px, the emoji visually seems to be about 17px
251+
// square. (Unlike on Android, with Noto Color Emoji, where a 14.5px font
252+
// size gives an emoji that looks 17px square.) See:
253+
// <https://github.com/flutter/flutter/issues/28894>
254+
// - The emoji doesn't fill the space taken by the [Text] in the layout.
255+
// There's whitespace above, below, and on the right. See:
256+
// <https://github.com/flutter/flutter/issues/119623>
257+
//
258+
// That extra space would be problematic, except we've used a [Stack] to
259+
// make the [Text] "positioned" so the space doesn't add margins around the
260+
// visible part. Key points that enable the [Stack] workaround:
261+
// - The emoji seems approximately vertically centered (this is
262+
// accomplished with help from a [StrutStyle]; see below).
263+
// - There seems to be approximately no space on its left.
264+
final boxSize = _squareEmojiScalerClamped(context).scale(_squareEmojiSize);
265+
return Stack(alignment: Alignment.centerLeft, clipBehavior: Clip.none, children: [
266+
SizedBox(height: boxSize, width: boxSize),
267+
PositionedDirectional(start: 0, child: Text(
268+
textScaler: _squareEmojiScalerClamped(context),
269+
style: const TextStyle(fontSize: _squareEmojiSize),
270+
strutStyle: const StrutStyle(fontSize: _squareEmojiSize, forceStrutHeight: true),
271+
parsed)),
272+
]);
273+
}
274+
}
275+
}
276+
277+
class _ImageEmoji extends StatelessWidget {
278+
const _ImageEmoji({
279+
required this.emojiCode,
280+
required this.emojiName,
281+
required this.selected,
282+
});
283+
284+
final String emojiCode;
285+
final String emojiName;
286+
final bool selected;
287+
288+
Widget get _textFallback => _TextEmoji(emojiName: emojiName, selected: selected);
289+
290+
@override
291+
Widget build(BuildContext context) {
292+
final store = PerAccountStoreWidget.of(context);
293+
294+
// Some people really dislike animated emoji.
295+
final doNotAnimate =
296+
// From reading code, this doesn't actually get set on iOS:
297+
// https://github.com/zulip/zulip-flutter/pull/410#discussion_r1408522293
298+
MediaQuery.disableAnimationsOf(context)
299+
|| (defaultTargetPlatform == TargetPlatform.iOS
300+
// TODO(upstream) On iOS 17+ (new in 2023), there's a more closely
301+
// relevant setting than "reduce motion". It's called "auto-play
302+
// animated images", and we should file an issue to expose it.
303+
// See GitHub comment linked above.
304+
&& WidgetsBinding.instance.platformDispatcher.accessibilityFeatures.reduceMotion);
305+
306+
final String src;
307+
switch (emojiCode) {
308+
case 'zulip': // the single "zulip extra emoji"
309+
src = '/static/generated/emoji/images/emoji/unicode/zulip.png';
310+
default:
311+
final item = store.realmEmoji[emojiCode];
312+
if (item == null) {
313+
return _textFallback;
314+
}
315+
src = doNotAnimate && item.stillUrl != null ? item.stillUrl! : item.sourceUrl;
316+
}
317+
final parsedSrc = Uri.tryParse(src);
318+
if (parsedSrc == null) { // TODO(log)
319+
return _textFallback;
320+
}
321+
final resolved = store.account.realmUrl.resolveUri(parsedSrc);
322+
323+
// Unicode and text emoji get scaled; it would look weird if image emoji didn't.
324+
final size = _squareEmojiScalerClamped(context).scale(_squareEmojiSize);
325+
326+
return RealmContentNetworkImage(
327+
resolved,
328+
width: size,
329+
height: size,
330+
errorBuilder: (context, _, __) => _textFallback,
331+
);
332+
}
333+
}
334+
335+
class _TextEmoji extends StatelessWidget {
336+
const _TextEmoji({required this.emojiName, required this.selected});
337+
338+
final String emojiName;
339+
final bool selected;
340+
341+
@override
342+
Widget build(BuildContext context) {
343+
return Text(
344+
textAlign: TextAlign.end,
345+
textScaler: _textEmojiScalerClamped(context),
346+
style: TextStyle(
347+
fontFamily: 'Source Sans 3',
348+
fontSize: 14 * 0.8,
349+
height: 1, // to be denser when we have to wrap
350+
color: selected ? _textColorSelected : _textColorUnselected,
351+
).merge(selected
352+
? weightVariableTextStyle(context, wght: 600, wghtIfPlatformRequestsBold: 900)
353+
: weightVariableTextStyle(context)),
354+
// Encourage line breaks before "_" (common in these), but try not
355+
// to leave a colon alone on a line. See:
356+
// <https://github.com/flutter/flutter/issues/61081#issuecomment-1103330522>
357+
':\ufeff${emojiName.replaceAll('_', '\u200b_')}\ufeff:');
358+
}
359+
}

lib/widgets/message_list.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import 'action_sheet.dart';
1616
import 'compose_box.dart';
1717
import 'content.dart';
1818
import 'dialog.dart';
19+
import 'emoji_reaction.dart';
1920
import 'icons.dart';
2021
import 'page.dart';
2122
import 'profile.dart';
@@ -786,6 +787,8 @@ class MessageWithPossibleSender extends StatelessWidget {
786787
const SizedBox(height: 4),
787788
],
788789
MessageContent(message: message, content: item.content),
790+
if ((message.reactions?.total ?? 0) > 0)
791+
ReactionChipsList(messageId: message.id, reactions: message.reactions!)
789792
])),
790793
Container(
791794
width: 80,

0 commit comments

Comments
 (0)