Skip to content

Introduce EmojiStore for some existing emoji logic #967

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Sep 30, 2024
29 changes: 29 additions & 0 deletions lib/api/model/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,35 @@ enum UserTopicVisibilityPolicy {
int? toJson() => apiValue;
}

/// Convert a Unicode emoji's Zulip "emoji code" into the
/// actual Unicode code points.
///
/// The argument corresponds to [Reaction.emojiCode] when [Reaction.emojiType]
/// is [ReactionType.unicodeEmoji]. For docs, see:
/// https://zulip.com/api/add-reaction#parameter-reaction_type
///
/// In addition to reactions, these appear in Zulip content HTML;
/// see [UnicodeEmojiNode.emojiUnicode].
String? tryParseEmojiCodeToUnicode(String emojiCode) {
// Ported from: https://github.com/zulip/zulip-mobile/blob/c979530d6804db33310ed7d14a4ac62017432944/src/emoji/data.js#L108-L112
// which refers to a comment in the server implementation:
// https://github.com/zulip/zulip/blob/63c9296d5339517450f79f176dc02d77b08020c8/zerver/models.py#L3235-L3242
// In addition to what's in the doc linked above, that comment adds:
//
// > For examples, see "non_qualified" or "unified" in the following data,
// > with "non_qualified" taking precedence when both present:
// > https://github.com/raw/iamcal/emoji-data/a8174c74675355c8c6a9564516b2e961fe7257ef/emoji_pretty.json
// > [link fixed to permalink; original comment says "master" for the commit]
try {
return String.fromCharCodes(emojiCode.split('-')
.map((hex) => int.parse(hex, radix: 16)));
} on FormatException { // thrown by `int.parse`
return null;
} on ArgumentError { // thrown by `String.fromCharCodes`
return null;
}
}

/// As in the get-messages response.
///
/// https://zulip.com/api/get-messages#response
Expand Down
22 changes: 1 addition & 21 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart';

import '../api/model/model.dart';
import 'code_block.dart';

/// A node in a parse tree for Zulip message-style content.
Expand Down Expand Up @@ -716,27 +717,6 @@ class GlobalTimeNode extends InlineContentNode {

////////////////////////////////////////////////////////////////

// Ported from https://github.com/zulip/zulip-mobile/blob/c979530d6804db33310ed7d14a4ac62017432944/src/emoji/data.js#L108-L112
//
// Which was in turn ported from https://github.com/zulip/zulip/blob/63c9296d5339517450f79f176dc02d77b08020c8/zerver/models.py#L3235-L3242
// and that describes the encoding as follows:
//
// > * For Unicode emoji, [emoji_code is] a dash-separated hex encoding of
// > the sequence of Unicode codepoints that define this emoji in the
// > Unicode specification. For examples, see "non_qualified" or
// > "unified" in the following data, with "non_qualified" taking
// > precedence when both present:
// > https://github.com/raw/iamcal/emoji-data/master/emoji_pretty.json
String? tryParseEmojiCodeToUnicode(String code) {
try {
return String.fromCharCodes(code.split('-').map((hex) => int.parse(hex, radix: 16)));
} on FormatException { // thrown by `int.parse`
return null;
} on ArgumentError { // thrown by `String.fromCharCodes`
return null;
}
}

/// What sort of nodes a [_ZulipContentParser] is currently expecting to find.
enum _ParserContext {
/// The parser is currently looking for block nodes.
Expand Down
137 changes: 137 additions & 0 deletions lib/model/emoji.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import '../api/model/events.dart';
import '../api/model/initial_snapshot.dart';
import '../api/model/model.dart';

/// An emoji, described by how to display it in the UI.
sealed class EmojiDisplay {
/// The emoji's name, as in [Reaction.emojiName].
final String emojiName;

EmojiDisplay({required this.emojiName});

EmojiDisplay resolve(UserSettings? userSettings) { // TODO(server-5)
if (this is TextEmojiDisplay) return this;
if (userSettings?.emojiset == Emojiset.text) {
return TextEmojiDisplay(emojiName: emojiName);
}
return this;
}
}

/// An emoji to display as Unicode text, relying on an emoji font.
class UnicodeEmojiDisplay extends EmojiDisplay {
/// The actual Unicode text representing this emoji; for example, "🙂".
final String emojiUnicode;

UnicodeEmojiDisplay({required super.emojiName, required this.emojiUnicode});
}

/// An emoji to display as an image.
class ImageEmojiDisplay extends EmojiDisplay {
/// An absolute URL for the emoji's image file.
final Uri resolvedUrl;

/// An absolute URL for a still version of the emoji's image file;
/// compare [RealmEmojiItem.stillUrl].
final Uri? resolvedStillUrl;

ImageEmojiDisplay({
required super.emojiName,
required this.resolvedUrl,
required this.resolvedStillUrl,
});
}

/// An emoji to display as its name, in plain text.
///
/// We do this based on a user preference,
/// and as a fallback when the Unicode or image approaches fail.
class TextEmojiDisplay extends EmojiDisplay {
TextEmojiDisplay({required super.emojiName});
}

/// The portion of [PerAccountStore] describing what emoji exist.
mixin EmojiStore {
/// The realm's custom emoji (for [ReactionType.realmEmoji],
/// indexed by [Reaction.emojiCode].
Map<String, RealmEmojiItem> get realmEmoji;

EmojiDisplay emojiDisplayFor({
required ReactionType emojiType,
required String emojiCode,
required String emojiName,
});
}

/// The implementation of [EmojiStore] that does the work.
///
/// Generally the only code that should need this class is [PerAccountStore]
/// itself. Other code accesses this functionality through [PerAccountStore],
/// or through the mixin [EmojiStore] which describes its interface.
class EmojiStoreImpl with EmojiStore {
EmojiStoreImpl({
required this.realmUrl,
required this.realmEmoji,
});

/// The same as [PerAccountStore.realmUrl].
final Uri realmUrl;

@override
Map<String, RealmEmojiItem> realmEmoji;

/// The realm-relative URL of the unique "Zulip extra emoji", :zulip:.
static const kZulipEmojiUrl = '/static/generated/emoji/images/emoji/unicode/zulip.png';

@override
EmojiDisplay emojiDisplayFor({
required ReactionType emojiType,
required String emojiCode,
required String emojiName,
}) {
switch (emojiType) {
case ReactionType.unicodeEmoji:
final parsed = tryParseEmojiCodeToUnicode(emojiCode);
if (parsed == null) break;
return UnicodeEmojiDisplay(emojiName: emojiName, emojiUnicode: parsed);

case ReactionType.realmEmoji:
final item = realmEmoji[emojiCode];
if (item == null) break;
// TODO we don't check emojiName matches the known realm emoji; is that right?
return _tryImageEmojiDisplay(
sourceUrl: item.sourceUrl, stillUrl: item.stillUrl,
emojiName: emojiName);

case ReactionType.zulipExtraEmoji:
return _tryImageEmojiDisplay(
sourceUrl: kZulipEmojiUrl, stillUrl: null, emojiName: emojiName);
}
return TextEmojiDisplay(emojiName: emojiName);
}

EmojiDisplay _tryImageEmojiDisplay({
required String sourceUrl,
required String? stillUrl,
required String emojiName,
}) {
final source = Uri.tryParse(sourceUrl);
if (source == null) return TextEmojiDisplay(emojiName: emojiName);

Uri? still;
if (stillUrl != null) {
still = Uri.tryParse(stillUrl);
if (still == null) return TextEmojiDisplay(emojiName: emojiName);
}

return ImageEmojiDisplay(
emojiName: emojiName,
resolvedUrl: realmUrl.resolveUri(source),
resolvedStillUrl: still == null ? null : realmUrl.resolveUri(still),
);
}

void handleRealmEmojiUpdateEvent(RealmEmojiUpdateEvent event) {
realmEmoji = event.realmEmoji;
}
}
34 changes: 28 additions & 6 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import '../log.dart';
import '../notifications/receive.dart';
import 'autocomplete.dart';
import 'database.dart';
import 'emoji.dart';
import 'message.dart';
import 'message_list.dart';
import 'recent_dm_conversations.dart';
Expand Down Expand Up @@ -202,7 +203,7 @@ abstract class GlobalStore extends ChangeNotifier {
/// This class does not attempt to poll an event queue
/// to keep the data up to date. For that behavior, see
/// [UpdateMachine].
class PerAccountStore extends ChangeNotifier with ChannelStore, MessageStore {
class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, MessageStore {
/// Construct a store for the user's data, starting from the given snapshot.
///
/// The global store must already have been updated with
Expand All @@ -227,16 +228,18 @@ class PerAccountStore extends ChangeNotifier with ChannelStore, MessageStore {
connection ??= globalStore.apiConnectionFromAccount(account);
assert(connection.zulipFeatureLevel == account.zulipFeatureLevel);

final realmUrl = account.realmUrl;
final channels = ChannelStoreImpl(initialSnapshot: initialSnapshot);
return PerAccountStore._(
globalStore: globalStore,
connection: connection,
realmUrl: account.realmUrl,
realmUrl: realmUrl,
maxFileUploadSizeMib: initialSnapshot.maxFileUploadSizeMib,
realmDefaultExternalAccounts: initialSnapshot.realmDefaultExternalAccounts,
realmEmoji: initialSnapshot.realmEmoji,
customProfileFields: _sortCustomProfileFields(initialSnapshot.customProfileFields),
emailAddressVisibility: initialSnapshot.emailAddressVisibility,
emoji: EmojiStoreImpl(
realmUrl: realmUrl, realmEmoji: initialSnapshot.realmEmoji),
accountId: accountId,
selfUserId: account.userId,
userSettings: initialSnapshot.userSettings,
Expand Down Expand Up @@ -268,9 +271,9 @@ class PerAccountStore extends ChangeNotifier with ChannelStore, MessageStore {
required this.realmUrl,
required this.maxFileUploadSizeMib,
required this.realmDefaultExternalAccounts,
required this.realmEmoji,
required this.customProfileFields,
required this.emailAddressVisibility,
required EmojiStoreImpl emoji,
required this.accountId,
required this.selfUserId,
required this.userSettings,
Expand All @@ -284,7 +287,9 @@ class PerAccountStore extends ChangeNotifier with ChannelStore, MessageStore {
}) : assert(selfUserId == globalStore.getAccount(accountId)!.userId),
assert(realmUrl == globalStore.getAccount(accountId)!.realmUrl),
assert(realmUrl == connection.realmUrl),
assert(emoji.realmUrl == realmUrl),
_globalStore = globalStore,
_emoji = emoji,
_channels = channels,
_messages = messages;

Expand Down Expand Up @@ -320,11 +325,28 @@ class PerAccountStore extends ChangeNotifier with ChannelStore, MessageStore {
String get zulipVersion => account.zulipVersion;
final int maxFileUploadSizeMib; // No event for this.
final Map<String, RealmDefaultExternalAccount> realmDefaultExternalAccounts;
Map<String, RealmEmojiItem> realmEmoji;
List<CustomProfileField> customProfileFields;
/// For docs, please see [InitialSnapshot.emailAddressVisibility].
final EmailAddressVisibility? emailAddressVisibility; // TODO(#668): update this realm setting

////////////////////////////////
// The realm's repertoire of available emoji.

@override
Map<String, RealmEmojiItem> get realmEmoji => _emoji.realmEmoji;

@override
EmojiDisplay emojiDisplayFor({
required ReactionType emojiType,
required String emojiCode,
required String emojiName
}) {
return _emoji.emojiDisplayFor(
emojiType: emojiType, emojiCode: emojiCode, emojiName: emojiName);
}

EmojiStoreImpl _emoji;

////////////////////////////////
// Data attached to the self-account on the realm.

Expand Down Expand Up @@ -423,7 +445,7 @@ class PerAccountStore extends ChangeNotifier with ChannelStore, MessageStore {

case RealmEmojiUpdateEvent():
assert(debugLog("server event: realm_emoji/update"));
realmEmoji = event.realmEmoji;
_emoji.handleRealmEmojiUpdateEvent(event);
notifyListeners();

case AlertWordsEvent():
Expand Down
Loading