Skip to content

Commit 4f6ad02

Browse files
committed
emoji [nfc]: Move "how to show this emoji" logic to EmojiStore
This will apply in other places we show an emoji, too, such as the emoji picker. What, you might ask, about the other place we already show emoji, namely in message content? We should probably apply this logic there too, but we'll leave that for later. Tracking it as issue #966.
1 parent 0d68853 commit 4f6ad02

File tree

3 files changed

+147
-59
lines changed

3 files changed

+147
-59
lines changed

lib/model/emoji.dart

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,66 @@
11
import '../api/model/events.dart';
2+
import '../api/model/initial_snapshot.dart';
23
import '../api/model/model.dart';
34

5+
/// An emoji, described by how to display it in the UI.
6+
sealed class EmojiDisplay {
7+
/// The emoji's name, as in [Reaction.emojiName].
8+
final String emojiName;
9+
10+
EmojiDisplay({required this.emojiName});
11+
12+
EmojiDisplay resolve(UserSettings? userSettings) { // TODO(server-5)
13+
if (this is TextEmojiDisplay) return this;
14+
if (userSettings?.emojiset == Emojiset.text) {
15+
return TextEmojiDisplay(emojiName: emojiName);
16+
}
17+
return this;
18+
}
19+
}
20+
21+
/// An emoji to display as Unicode text, relying on an emoji font.
22+
class UnicodeEmojiDisplay extends EmojiDisplay {
23+
/// The actual Unicode text representing this emoji; for example, "🙂".
24+
final String emojiUnicode;
25+
26+
UnicodeEmojiDisplay({required super.emojiName, required this.emojiUnicode});
27+
}
28+
29+
/// An emoji to display as an image.
30+
class ImageEmojiDisplay extends EmojiDisplay {
31+
/// An absolute URL for the emoji's image file.
32+
final Uri resolvedUrl;
33+
34+
/// An absolute URL for a still version of the emoji's image file;
35+
/// compare [RealmEmojiItem.stillUrl].
36+
final Uri? resolvedStillUrl;
37+
38+
ImageEmojiDisplay({
39+
required super.emojiName,
40+
required this.resolvedUrl,
41+
required this.resolvedStillUrl,
42+
});
43+
}
44+
45+
/// An emoji to display as its name, in plain text.
46+
///
47+
/// We do this based on a user preference,
48+
/// and as a fallback when the Unicode or image approaches fail.
49+
class TextEmojiDisplay extends EmojiDisplay {
50+
TextEmojiDisplay({required super.emojiName});
51+
}
52+
453
/// The portion of [PerAccountStore] describing what emoji exist.
554
mixin EmojiStore {
655
/// The realm's custom emoji (for [ReactionType.realmEmoji],
756
/// indexed by [Reaction.emojiCode].
857
Map<String, RealmEmojiItem> get realmEmoji;
58+
59+
EmojiDisplay emojiDisplayFor({
60+
required ReactionType emojiType,
61+
required String emojiCode,
62+
required String emojiName,
63+
});
964
}
1065

1166
/// The implementation of [EmojiStore] that does the work.
@@ -25,6 +80,57 @@ class EmojiStoreImpl with EmojiStore {
2580
@override
2681
Map<String, RealmEmojiItem> realmEmoji;
2782

83+
/// The realm-relative URL of the unique "Zulip extra emoji", :zulip:.
84+
static const kZulipEmojiUrl = '/static/generated/emoji/images/emoji/unicode/zulip.png';
85+
86+
@override
87+
EmojiDisplay emojiDisplayFor({
88+
required ReactionType emojiType,
89+
required String emojiCode,
90+
required String emojiName,
91+
}) {
92+
switch (emojiType) {
93+
case ReactionType.unicodeEmoji:
94+
final parsed = tryParseEmojiCodeToUnicode(emojiCode);
95+
if (parsed == null) break;
96+
return UnicodeEmojiDisplay(emojiName: emojiName, emojiUnicode: parsed);
97+
98+
case ReactionType.realmEmoji:
99+
final item = realmEmoji[emojiCode];
100+
if (item == null) break;
101+
// TODO we don't check emojiName matches the known realm emoji; is that right?
102+
return _tryImageEmojiDisplay(
103+
sourceUrl: item.sourceUrl, stillUrl: item.stillUrl,
104+
emojiName: emojiName);
105+
106+
case ReactionType.zulipExtraEmoji:
107+
return _tryImageEmojiDisplay(
108+
sourceUrl: kZulipEmojiUrl, stillUrl: null, emojiName: emojiName);
109+
}
110+
return TextEmojiDisplay(emojiName: emojiName);
111+
}
112+
113+
EmojiDisplay _tryImageEmojiDisplay({
114+
required String sourceUrl,
115+
required String? stillUrl,
116+
required String emojiName,
117+
}) {
118+
final source = Uri.tryParse(sourceUrl);
119+
if (source == null) return TextEmojiDisplay(emojiName: emojiName);
120+
121+
Uri? still;
122+
if (stillUrl != null) {
123+
still = Uri.tryParse(stillUrl);
124+
if (still == null) return TextEmojiDisplay(emojiName: emojiName);
125+
}
126+
127+
return ImageEmojiDisplay(
128+
emojiName: emojiName,
129+
resolvedUrl: realmUrl.resolveUri(source),
130+
resolvedStillUrl: still == null ? null : realmUrl.resolveUri(still),
131+
);
132+
}
133+
28134
void handleRealmEmojiUpdateEvent(RealmEmojiUpdateEvent event) {
29135
realmEmoji = event.realmEmoji;
30136
}

lib/model/store.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,16 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
335335
@override
336336
Map<String, RealmEmojiItem> get realmEmoji => _emoji.realmEmoji;
337337

338+
@override
339+
EmojiDisplay emojiDisplayFor({
340+
required ReactionType emojiType,
341+
required String emojiCode,
342+
required String emojiName
343+
}) {
344+
return _emoji.emojiDisplayFor(
345+
emojiType: emojiType, emojiCode: emojiCode, emojiName: emojiName);
346+
}
347+
338348
EmojiStoreImpl _emoji;
339349

340350
////////////////////////////////

lib/widgets/emoji_reaction.dart

Lines changed: 31 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
33

44
import '../api/model/model.dart';
55
import '../api/route/messages.dart';
6+
import '../model/emoji.dart';
67
import 'content.dart';
78
import 'store.dart';
89
import 'text.dart';
@@ -148,8 +149,6 @@ class ReactionChip extends StatelessWidget {
148149
final emojiName = reactionWithVotes.emojiName;
149150
final userIds = reactionWithVotes.userIds;
150151

151-
final emojiset = store.userSettings?.emojiset ?? Emojiset.google;
152-
153152
final selfVoted = userIds.contains(store.selfUserId);
154153
final label = showName
155154
// TODO(i18n): List formatting, like you can do in JavaScript:
@@ -175,26 +174,20 @@ class ReactionChip extends StatelessWidget {
175174
);
176175
final shape = StadiumBorder(side: borderSide);
177176

178-
final Widget emoji;
179-
if (emojiset == Emojiset.text) {
180-
emoji = _TextEmoji(emojiName: emojiName, selected: selfVoted);
181-
} else {
182-
switch (reactionType) {
183-
case ReactionType.unicodeEmoji:
184-
emoji = _UnicodeEmoji(
185-
emojiCode: emojiCode,
186-
emojiName: emojiName,
187-
selected: selfVoted,
188-
);
189-
case ReactionType.realmEmoji:
190-
case ReactionType.zulipExtraEmoji:
191-
emoji = _ImageEmoji(
192-
emojiCode: emojiCode,
193-
emojiName: emojiName,
194-
selected: selfVoted,
195-
);
196-
}
197-
}
177+
final emojiDisplay = store.emojiDisplayFor(
178+
emojiType: reactionType,
179+
emojiCode: emojiCode,
180+
emojiName: emojiName,
181+
).resolve(store.userSettings);
182+
183+
final emoji = switch (emojiDisplay) {
184+
UnicodeEmojiDisplay() => _UnicodeEmoji(
185+
emojiDisplay: emojiDisplay, selected: selfVoted),
186+
ImageEmojiDisplay() => _ImageEmoji(
187+
emojiDisplay: emojiDisplay, emojiName: emojiName, selected: selfVoted),
188+
TextEmojiDisplay() => _TextEmoji(
189+
emojiDisplay: emojiDisplay, selected: selfVoted),
190+
};
198191

199192
return Tooltip(
200193
// TODO(#434): Semantics with eg "Reaction: <emoji name>; you and N others: <names>"
@@ -301,22 +294,15 @@ TextScaler _labelTextScalerClamped(BuildContext context) =>
301294

302295
class _UnicodeEmoji extends StatelessWidget {
303296
const _UnicodeEmoji({
304-
required this.emojiCode,
305-
required this.emojiName,
297+
required this.emojiDisplay,
306298
required this.selected,
307299
});
308300

309-
final String emojiCode;
310-
final String emojiName;
301+
final UnicodeEmojiDisplay emojiDisplay;
311302
final bool selected;
312303

313304
@override
314305
Widget build(BuildContext context) {
315-
final parsed = tryParseEmojiCodeToUnicode(emojiCode);
316-
if (parsed == null) { // TODO(log)
317-
return _TextEmoji(emojiName: emojiName, selected: selected);
318-
}
319-
320306
switch (defaultTargetPlatform) {
321307
case TargetPlatform.android:
322308
case TargetPlatform.fuchsia:
@@ -329,7 +315,7 @@ class _UnicodeEmoji extends StatelessWidget {
329315
fontSize: _notoColorEmojiTextSize,
330316
),
331317
strutStyle: const StrutStyle(fontSize: _notoColorEmojiTextSize, forceStrutHeight: true),
332-
parsed);
318+
emojiDisplay.emojiUnicode);
333319
case TargetPlatform.iOS:
334320
case TargetPlatform.macOS:
335321
// We expect the font "Apple Color Emoji" to be used. There are some
@@ -355,29 +341,25 @@ class _UnicodeEmoji extends StatelessWidget {
355341
textScaler: _squareEmojiScalerClamped(context),
356342
style: const TextStyle(fontSize: _squareEmojiSize),
357343
strutStyle: const StrutStyle(fontSize: _squareEmojiSize, forceStrutHeight: true),
358-
parsed)),
344+
emojiDisplay.emojiUnicode)),
359345
]);
360346
}
361347
}
362348
}
363349

364350
class _ImageEmoji extends StatelessWidget {
365351
const _ImageEmoji({
366-
required this.emojiCode,
352+
required this.emojiDisplay,
367353
required this.emojiName,
368354
required this.selected,
369355
});
370356

371-
final String emojiCode;
357+
final ImageEmojiDisplay emojiDisplay;
372358
final String emojiName;
373359
final bool selected;
374360

375-
Widget get _textFallback => _TextEmoji(emojiName: emojiName, selected: selected);
376-
377361
@override
378362
Widget build(BuildContext context) {
379-
final store = PerAccountStoreWidget.of(context);
380-
381363
// Some people really dislike animated emoji.
382364
final doNotAnimate =
383365
// From reading code, this doesn't actually get set on iOS:
@@ -390,43 +372,33 @@ class _ImageEmoji extends StatelessWidget {
390372
// See GitHub comment linked above.
391373
&& WidgetsBinding.instance.platformDispatcher.accessibilityFeatures.reduceMotion);
392374

393-
final String src;
394-
switch (emojiCode) {
395-
case 'zulip': // the single "zulip extra emoji"
396-
src = '/static/generated/emoji/images/emoji/unicode/zulip.png';
397-
default:
398-
final item = store.realmEmoji[emojiCode];
399-
if (item == null) {
400-
return _textFallback;
401-
}
402-
src = doNotAnimate && item.stillUrl != null ? item.stillUrl! : item.sourceUrl;
403-
}
404-
final parsedSrc = Uri.tryParse(src);
405-
if (parsedSrc == null) { // TODO(log)
406-
return _textFallback;
407-
}
408-
final resolved = store.realmUrl.resolveUri(parsedSrc);
375+
final resolvedUrl = doNotAnimate
376+
? (emojiDisplay.resolvedStillUrl ?? emojiDisplay.resolvedUrl)
377+
: emojiDisplay.resolvedUrl;
409378

410379
// Unicode and text emoji get scaled; it would look weird if image emoji didn't.
411380
final size = _squareEmojiScalerClamped(context).scale(_squareEmojiSize);
412381

413382
return RealmContentNetworkImage(
414-
resolved,
383+
resolvedUrl,
415384
width: size,
416385
height: size,
417-
errorBuilder: (context, _, __) => _textFallback,
386+
errorBuilder: (context, _, __) => _TextEmoji(
387+
emojiDisplay: TextEmojiDisplay(emojiName: emojiName), selected: selected),
418388
);
419389
}
420390
}
421391

422392
class _TextEmoji extends StatelessWidget {
423-
const _TextEmoji({required this.emojiName, required this.selected});
393+
const _TextEmoji({required this.emojiDisplay, required this.selected});
424394

425-
final String emojiName;
395+
final TextEmojiDisplay emojiDisplay;
426396
final bool selected;
427397

428398
@override
429399
Widget build(BuildContext context) {
400+
final emojiName = emojiDisplay.emojiName;
401+
430402
// Encourage line breaks before "_" (common in these), but try not
431403
// to leave a colon alone on a line. See:
432404
// <https://github.com/flutter/flutter/issues/61081#issuecomment-1103330522>

0 commit comments

Comments
 (0)