Skip to content

Commit 3b4aeb7

Browse files
committed
emoji: Fetch server emoji data (about Unicode emoji)
This completes the data we'll need for use in #669, which in turn will let us offer an emoji picker for reactions and for use inside message content. The doc on fetchEmojiData is based on a comment at the corresponding code in the legacy zulip-mobile app, in src/events/eventActions.js .
1 parent dc760ec commit 3b4aeb7

File tree

3 files changed

+158
-1
lines changed

3 files changed

+158
-1
lines changed

lib/model/emoji.dart

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import '../api/model/events.dart';
22
import '../api/model/initial_snapshot.dart';
33
import '../api/model/model.dart';
4+
import '../api/route/realm.dart';
45

56
/// An emoji, described by how to display it in the UI.
67
sealed class EmojiDisplay {
@@ -61,6 +62,12 @@ mixin EmojiStore {
6162
required String emojiCode,
6263
required String emojiName,
6364
});
65+
66+
// TODO cut debugServerEmojiData once we can query for lists of emoji;
67+
// have tests make those queries end-to-end
68+
Map<String, List<String>>? get debugServerEmojiData;
69+
70+
void setServerEmojiData(ServerEmojiData data);
6471
}
6572

6673
/// The implementation of [EmojiStore] that does the work.
@@ -72,7 +79,7 @@ class EmojiStoreImpl with EmojiStore {
7279
EmojiStoreImpl({
7380
required this.realmUrl,
7481
required this.realmEmoji,
75-
});
82+
}) : _serverEmojiData = null; // TODO(#974) maybe start from a hard-coded baseline
7683

7784
/// The same as [PerAccountStore.realmUrl].
7885
final Uri realmUrl;
@@ -131,6 +138,21 @@ class EmojiStoreImpl with EmojiStore {
131138
);
132139
}
133140

141+
@override
142+
Map<String, List<String>>? get debugServerEmojiData => _serverEmojiData;
143+
144+
/// The server's list of Unicode emoji and names for them,
145+
/// from [ServerEmojiData].
146+
///
147+
/// This is null until [UpdateMachine.fetchEmojiData] finishes
148+
/// retrieving the data.
149+
Map<String, List<String>>? _serverEmojiData;
150+
151+
@override
152+
void setServerEmojiData(ServerEmojiData data) {
153+
_serverEmojiData = data.codeToNames;
154+
}
155+
134156
void handleRealmEmojiUpdateEvent(RealmEmojiUpdateEvent event) {
135157
realmEmoji = event.realmEmoji;
136158
}

lib/model/store.dart

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import '../api/model/model.dart';
1616
import '../api/route/events.dart';
1717
import '../api/route/messages.dart';
1818
import '../api/backoff.dart';
19+
import '../api/route/realm.dart';
1920
import '../log.dart';
2021
import '../notifications/receive.dart';
2122
import 'autocomplete.dart';
@@ -345,6 +346,15 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
345346
emojiType: emojiType, emojiCode: emojiCode, emojiName: emojiName);
346347
}
347348

349+
@override
350+
Map<String, List<String>>? get debugServerEmojiData => _emoji.debugServerEmojiData;
351+
352+
@override
353+
void setServerEmojiData(ServerEmojiData data) {
354+
_emoji.setServerEmojiData(data);
355+
notifyListeners();
356+
}
357+
348358
EmojiStoreImpl _emoji;
349359

350360
////////////////////////////////
@@ -746,6 +756,7 @@ class UpdateMachine {
746756
final updateMachine = UpdateMachine.fromInitialSnapshot(
747757
store: store, initialSnapshot: initialSnapshot);
748758
updateMachine.poll();
759+
unawaited(updateMachine.fetchEmojiData(initialSnapshot.serverEmojiDataUrl));
749760
// TODO do registerNotificationToken before registerQueue:
750761
// https://github.com/zulip/zulip-flutter/pull/325#discussion_r1365982807
751762
unawaited(updateMachine.registerNotificationToken());
@@ -772,6 +783,43 @@ class UpdateMachine {
772783
}
773784
}
774785

786+
/// Fetch emoji data from the server, and update the store with the result.
787+
///
788+
/// This functions a lot like [registerQueue] and the surrounding logic
789+
/// in [load] above, but it's unusual in that we've separated it out.
790+
/// Effectively it's data that *would have* been in the [registerQueue]
791+
/// response, except that we pulled it out to its own endpoint as part of
792+
/// a caching strategy, because the data changes infrequently.
793+
///
794+
/// Conveniently (a) this deferred fetch doesn't cause any fetch/event race,
795+
/// because this data doesn't get updated by events anyway (it can change
796+
/// only on a server restart); and (b) we don't need this data for displaying
797+
/// messages or anything else, only for certain UIs like the emoji picker,
798+
/// so it's fine that we go without it for a while.
799+
Future<void> fetchEmojiData(Uri serverEmojiDataUrl) async {
800+
if (!debugEnableFetchEmojiData) return;
801+
BackoffMachine? backoffMachine;
802+
ServerEmojiData data;
803+
while (true) {
804+
try {
805+
data = await fetchServerEmojiData(store.connection,
806+
emojiDataUrl: serverEmojiDataUrl);
807+
assert(debugLog('Got emoji data: ${data.codeToNames.length} emoji'));
808+
break;
809+
} catch (e) {
810+
assert(debugLog('Error fetching emoji data: $e\n' // TODO(log)
811+
'Backing off, then will retry…'));
812+
// The emoji data is a lot less urgent than the initial fetch,
813+
// or even the event-queue poll request. So wait longer.
814+
backoffMachine ??= BackoffMachine(firstBound: const Duration(seconds: 2),
815+
maxBound: const Duration(minutes: 2));
816+
await backoffMachine.wait();
817+
}
818+
}
819+
820+
store.setServerEmojiData(data);
821+
}
822+
775823
Completer<void>? _debugLoopSignal;
776824

777825
/// In debug mode, causes the polling loop to pause before the next
@@ -890,6 +938,26 @@ class UpdateMachine {
890938
NotificationService.instance.token.removeListener(_registerNotificationToken);
891939
}
892940

941+
/// In debug mode, controls whether [fetchEmojiData] should
942+
/// have its normal effect.
943+
///
944+
/// Outside of debug mode, this is always true and the setter has no effect.
945+
static bool get debugEnableFetchEmojiData {
946+
bool result = true;
947+
assert(() {
948+
result = _debugEnableFetchEmojiData;
949+
return true;
950+
}());
951+
return result;
952+
}
953+
static bool _debugEnableFetchEmojiData = true;
954+
static set debugEnableFetchEmojiData(bool value) {
955+
assert(() {
956+
_debugEnableFetchEmojiData = value;
957+
return true;
958+
}());
959+
}
960+
893961
/// In debug mode, controls whether [registerNotificationToken] should
894962
/// have its normal effect.
895963
///

test/model/store_test.dart

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import 'package:checks/checks.dart';
44
import 'package:flutter/foundation.dart';
55
import 'package:http/http.dart' as http;
66
import 'package:test/scaffolding.dart';
7+
import 'package:zulip/api/core.dart';
78
import 'package:zulip/api/model/events.dart';
89
import 'package:zulip/api/model/model.dart';
910
import 'package:zulip/api/route/events.dart';
1011
import 'package:zulip/api/route/messages.dart';
12+
import 'package:zulip/api/route/realm.dart';
1113
import 'package:zulip/model/message_list.dart';
1214
import 'package:zulip/model/narrow.dart';
1315
import 'package:zulip/model/store.dart';
@@ -230,6 +232,8 @@ void main() {
230232
await globalStore.insertAccount(account.toCompanion(false));
231233
connection = (globalStore.apiConnectionFromAccount(account)
232234
as FakeApiConnection);
235+
UpdateMachine.debugEnableFetchEmojiData = false;
236+
addTearDown(() => UpdateMachine.debugEnableFetchEmojiData = true);
233237
UpdateMachine.debugEnableRegisterNotificationToken = false;
234238
addTearDown(() => UpdateMachine.debugEnableRegisterNotificationToken = true);
235239
}
@@ -317,6 +321,69 @@ void main() {
317321
// TODO test UpdateMachine.load calls registerNotificationToken
318322
});
319323

324+
group('UpdateMachine.fetchEmojiData', () {
325+
late UpdateMachine updateMachine;
326+
late PerAccountStore store;
327+
late FakeApiConnection connection;
328+
329+
void prepareStore() {
330+
updateMachine = eg.updateMachine();
331+
store = updateMachine.store;
332+
connection = store.connection as FakeApiConnection;
333+
}
334+
335+
final emojiDataUrl = Uri.parse('https://cdn.example/emoji.json');
336+
final data = {
337+
'1f642': ['smile'],
338+
'1f34a': ['orange', 'tangerine', 'mandarin'],
339+
};
340+
341+
void checkLastRequest() {
342+
check(connection.takeRequests()).single.isA<http.Request>()
343+
..method.equals('GET')
344+
..url.equals(emojiDataUrl)
345+
..headers.deepEquals(kFallbackUserAgentHeader);
346+
}
347+
348+
test('happy case', () => awaitFakeAsync((async) async {
349+
prepareStore();
350+
check(store.debugServerEmojiData).isNull();
351+
352+
connection.prepare(json: ServerEmojiData(codeToNames: data).toJson());
353+
await updateMachine.fetchEmojiData(emojiDataUrl);
354+
checkLastRequest();
355+
check(store.debugServerEmojiData).deepEquals(data);
356+
}));
357+
358+
test('retries on failure', () => awaitFakeAsync((async) async {
359+
prepareStore();
360+
check(store.debugServerEmojiData).isNull();
361+
362+
// Try to fetch, inducing an error in the request.
363+
connection.prepare(exception: Exception('failed'));
364+
final future = updateMachine.fetchEmojiData(emojiDataUrl);
365+
bool complete = false;
366+
future.whenComplete(() => complete = true);
367+
async.flushMicrotasks();
368+
checkLastRequest();
369+
check(complete).isFalse();
370+
check(store.debugServerEmojiData).isNull();
371+
372+
// The retry doesn't happen immediately; there's a timer.
373+
check(async.pendingTimers).length.equals(1);
374+
async.elapse(Duration.zero);
375+
check(connection.lastRequest).isNull();
376+
check(async.pendingTimers).length.equals(1);
377+
378+
// After a timer, we retry.
379+
connection.prepare(json: ServerEmojiData(codeToNames: data).toJson());
380+
await future;
381+
check(complete).isTrue();
382+
checkLastRequest();
383+
check(store.debugServerEmojiData).deepEquals(data);
384+
}));
385+
});
386+
320387
group('UpdateMachine.poll', () {
321388
late TestGlobalStore globalStore;
322389
late UpdateMachine updateMachine;

0 commit comments

Comments
 (0)