Skip to content

Commit 6337a98

Browse files
chrisbobbegnprice
authored andcommitted
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 6354f28 commit 6337a98

File tree

3 files changed

+596
-0
lines changed

3 files changed

+596
-0
lines changed

lib/widgets/emoji_reaction.dart

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

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';
@@ -775,6 +776,8 @@ class MessageWithPossibleSender extends StatelessWidget {
775776
const SizedBox(height: 4),
776777
],
777778
MessageContent(message: message, content: item.content),
779+
if ((message.reactions?.total ?? 0) > 0)
780+
ReactionChipsList(messageId: message.id, reactions: message.reactions!)
778781
])),
779782
Container(
780783
width: 80,

0 commit comments

Comments
 (0)