Skip to content

Commit 2f5f104

Browse files
committed
model: Add model for handling typing status events.
The map of typist-id-to-timer maps design was inspired by the web app's implementation of typing status. We use a nested map instead to avoid introducing a custom hashing algorithm, and to make it easier to query typists in the same narrow. See also: https://github.com/zulip/zulip/blob/09bad82131abdedf253d53b4cb44c8b95f6a49f1/static/js/typing_data.js Signed-off-by: Zixuan James Li <[email protected]>
1 parent c0d0aeb commit 2f5f104

File tree

4 files changed

+332
-0
lines changed

4 files changed

+332
-0
lines changed

lib/model/store.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import 'message.dart';
2424
import 'message_list.dart';
2525
import 'recent_dm_conversations.dart';
2626
import 'stream.dart';
27+
import 'typing_status.dart';
2728
import 'unreads.dart';
2829

2930
export 'package:drift/drift.dart' show Value;
@@ -242,6 +243,10 @@ class PerAccountStore extends ChangeNotifier with StreamStore, MessageStore {
242243
.followedBy(initialSnapshot.realmNonActiveUsers)
243244
.followedBy(initialSnapshot.crossRealmBots)
244245
.map((user) => MapEntry(user.userId, user))),
246+
typingStatus: TypingStatus(
247+
selfUserId: account.userId,
248+
typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds),
249+
),
245250
streams: streams,
246251
messages: MessageStoreImpl(),
247252
unreads: Unreads(
@@ -266,6 +271,7 @@ class PerAccountStore extends ChangeNotifier with StreamStore, MessageStore {
266271
required this.selfUserId,
267272
required this.userSettings,
268273
required this.users,
274+
required this.typingStatus,
269275
required StreamStoreImpl streams,
270276
required MessageStoreImpl messages,
271277
required this.unreads,
@@ -319,6 +325,8 @@ class PerAccountStore extends ChangeNotifier with StreamStore, MessageStore {
319325

320326
final Map<int, User> users;
321327

328+
final TypingStatus typingStatus;
329+
322330
////////////////////////////////
323331
// Streams, topics, and stuff about them.
324332

@@ -383,6 +391,7 @@ class PerAccountStore extends ChangeNotifier with StreamStore, MessageStore {
383391
recentDmConversationsView.dispose();
384392
unreads.dispose();
385393
_messages.dispose();
394+
typingStatus.dispose();
386395
super.dispose();
387396
}
388397

@@ -485,6 +494,9 @@ class PerAccountStore extends ChangeNotifier with StreamStore, MessageStore {
485494
assert(debugLog("server event: update_message_flags/${event.op} ${event.flag.toJson()}"));
486495
_messages.handleUpdateMessageFlagsEvent(event);
487496
unreads.handleUpdateMessageFlagsEvent(event);
497+
} else if (event is TypingEvent) {
498+
assert(debugLog("server event: typing/${event.op} ${event.messageType}"));
499+
typingStatus.handleTypingEvent(event);
488500
} else if (event is ReactionEvent) {
489501
assert(debugLog("server event: reaction/${event.op}"));
490502
_messages.handleReactionEvent(event);

lib/model/typing_status.dart

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import 'dart:async';
2+
3+
import 'package:flutter/foundation.dart';
4+
5+
import '../api/model/events.dart';
6+
import 'narrow.dart';
7+
8+
/// The model for tracking the typing status organized by narrows.
9+
///
10+
/// Listeners are notified when a typist is added or removed from any narrow.
11+
class TypingStatus extends ChangeNotifier {
12+
TypingStatus({
13+
required this.selfUserId,
14+
required this.typingStartedExpiryPeriod,
15+
});
16+
17+
final int selfUserId;
18+
final Duration typingStartedExpiryPeriod;
19+
20+
Iterable<SendableNarrow> get debugActiveNarrows => _timerMapsByNarrow.keys;
21+
22+
Iterable<int> typistIdsInNarrow(SendableNarrow narrow) =>
23+
_timerMapsByNarrow[narrow]?.keys ?? [];
24+
25+
// Using SendableNarrow as the key covers the narrows
26+
// where typing notifications are supported (topics and DMs).
27+
final Map<SendableNarrow, Map<int, Timer>> _timerMapsByNarrow = {};
28+
29+
@override
30+
void dispose() {
31+
for (final timersByTypistId in _timerMapsByNarrow.values) {
32+
for (final timer in timersByTypistId.values) {
33+
timer.cancel();
34+
}
35+
}
36+
super.dispose();
37+
}
38+
39+
bool _addTypist(SendableNarrow narrow, int typistUserId) {
40+
final narrowTimerMap = _timerMapsByNarrow[narrow] ??= {};
41+
final typistTimer = narrowTimerMap[typistUserId];
42+
final isNewTypist = typistTimer == null;
43+
typistTimer?.cancel();
44+
narrowTimerMap[typistUserId] = Timer(typingStartedExpiryPeriod, () {
45+
if (_removeTypist(narrow, typistUserId)) {
46+
notifyListeners();
47+
}
48+
});
49+
return isNewTypist;
50+
}
51+
52+
bool _removeTypist(SendableNarrow narrow, int typistUserId) {
53+
final narrowTimerMap = _timerMapsByNarrow[narrow];
54+
final typistTimer = narrowTimerMap?.remove(typistUserId);
55+
if (typistTimer == null) {
56+
return false;
57+
}
58+
typistTimer.cancel();
59+
if (narrowTimerMap!.isEmpty) _timerMapsByNarrow.remove(narrow);
60+
return true;
61+
}
62+
63+
void handleTypingEvent(TypingEvent event) {
64+
SendableNarrow narrow = switch (event.messageType) {
65+
MessageType.direct => DmNarrow(
66+
allRecipientIds: event.recipientIds!..sort(), selfUserId: selfUserId),
67+
MessageType.stream => TopicNarrow(event.streamId!, event.topic!),
68+
};
69+
70+
bool hasUpdate = false;
71+
switch (event.op) {
72+
case TypingOp.start:
73+
hasUpdate = _addTypist(narrow, event.senderId);
74+
case TypingOp.stop:
75+
hasUpdate = _removeTypist(narrow, event.senderId);
76+
}
77+
78+
if (hasUpdate) {
79+
notifyListeners();
80+
}
81+
}
82+
}

test/example_data.dart

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,22 @@ UpdateMessageFlagsRemoveEvent updateMessageFlagsRemoveEvent(
504504
})));
505505
}
506506

507+
TypingEvent typingEvent(SendableNarrow narrow, TypingOp op, int senderId) {
508+
switch (narrow) {
509+
case TopicNarrow():
510+
return TypingEvent(id: 1, op: op, senderId: senderId,
511+
messageType: MessageType.stream,
512+
streamId: narrow.streamId,
513+
topic: narrow.topic,
514+
recipientIds: null);
515+
case DmNarrow():
516+
return TypingEvent(id: 1, op: op, senderId: senderId,
517+
messageType: MessageType.direct,
518+
recipientIds: narrow.allRecipientIds,
519+
streamId: null,
520+
topic: null);
521+
}
522+
}
507523

508524
ReactionEvent reactionEvent(Reaction reaction, ReactionOp op, int messageId) {
509525
return ReactionEvent(

test/model/typing_status_test.dart

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import 'package:checks/checks.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:zulip/api/model/events.dart';
4+
import 'package:zulip/api/model/model.dart';
5+
import 'package:zulip/model/narrow.dart';
6+
import 'package:zulip/model/typing_status.dart';
7+
8+
import '../example_data.dart' as eg;
9+
import '../fake_async.dart';
10+
11+
void main() {
12+
late TypingStatus model;
13+
late int notifiedCount;
14+
15+
void checkNotNotified() {
16+
check(notifiedCount).equals(0);
17+
}
18+
19+
void checkNotifiedOnce() {
20+
check(notifiedCount).equals(1);
21+
notifiedCount = 0;
22+
}
23+
24+
void handleTypingEvent(SendableNarrow narrow, TypingOp op, User sender) {
25+
assert(sender != eg.selfUser);
26+
model.handleTypingEvent(eg.typingEvent(narrow, op, sender.userId));
27+
}
28+
29+
void checkTypists(Map<SendableNarrow, List<User>> typistsByNarrow) {
30+
final actualTypistsByNarrow = <SendableNarrow, Iterable<int>>{};
31+
for (final narrow in model.debugActiveNarrows) {
32+
actualTypistsByNarrow[narrow] = model.typistIdsInNarrow(narrow);
33+
}
34+
check(actualTypistsByNarrow).deepEquals(
35+
typistsByNarrow.map((k, v) => MapEntry(k, v.map((e) => e.userId))));
36+
}
37+
38+
void prepareModel({
39+
int? selfUserId,
40+
Map<SendableNarrow, List<User>> typistsByNarrow = const {},
41+
}) {
42+
model = TypingStatus(
43+
selfUserId: selfUserId ?? eg.selfUser.userId,
44+
typingStartedExpiryPeriod: const Duration(milliseconds: 15000));
45+
check(model.debugActiveNarrows).isEmpty();
46+
notifiedCount = 0;
47+
model.addListener(() => notifiedCount += 1);
48+
49+
typistsByNarrow.forEach((narrow, typists) {
50+
for (final typist in typists) {
51+
handleTypingEvent(narrow, TypingOp.start, typist);
52+
checkNotifiedOnce();
53+
}
54+
});
55+
checkTypists(typistsByNarrow);
56+
}
57+
58+
final stream = eg.stream();
59+
final topicNarrow = TopicNarrow(stream.streamId, 'foo');
60+
61+
final dmNarrow = DmNarrow.withUser(eg.otherUser.userId, selfUserId: eg.selfUser.userId);
62+
final groupNarrow = DmNarrow.withOtherUsers(
63+
[eg.otherUser.userId, eg.thirdUser.userId], selfUserId: eg.selfUser.userId);
64+
65+
group('handle typing start events', () {
66+
test('add typists in separate narrows', () {
67+
prepareModel();
68+
69+
handleTypingEvent(dmNarrow, TypingOp.start, eg.otherUser);
70+
checkTypists({dmNarrow: [eg.otherUser]});
71+
checkNotifiedOnce();
72+
73+
handleTypingEvent(groupNarrow, TypingOp.start, eg.thirdUser);
74+
checkTypists({dmNarrow: [eg.otherUser], groupNarrow: [eg.thirdUser]});
75+
checkNotifiedOnce();
76+
});
77+
78+
test('add a typist in the same narrow', () {
79+
prepareModel();
80+
81+
handleTypingEvent(groupNarrow, TypingOp.start, eg.otherUser);
82+
checkTypists({groupNarrow: [eg.otherUser]});
83+
checkNotifiedOnce();
84+
85+
handleTypingEvent(groupNarrow, TypingOp.start, eg.thirdUser);
86+
checkTypists({groupNarrow: [eg.otherUser, eg.thirdUser]});
87+
checkNotifiedOnce();
88+
});
89+
90+
test('sort dm recipients', () {
91+
prepareModel(selfUserId: 5);
92+
final recipientIds = [5, 4, 10, 8, 2, 1];
93+
94+
final eventUnsorted = TypingEvent(id: 1, op: TypingOp.start,
95+
senderId: 1,
96+
messageType: MessageType.direct,
97+
recipientIds: recipientIds,
98+
streamId: null, topic: null);
99+
// DmNarrow's constructor expects the recipient IDs to be sorted.
100+
model.handleTypingEvent(eventUnsorted);
101+
check(model.typistIdsInNarrow(
102+
DmNarrow(allRecipientIds: recipientIds..sort(), selfUserId: 5),
103+
)).single.equals(1);
104+
});
105+
});
106+
107+
group('handle typing stop events', () {
108+
test('remove a typist from an unknown narrow', () {
109+
prepareModel();
110+
111+
handleTypingEvent(groupNarrow, TypingOp.start, eg.otherUser);
112+
checkTypists({groupNarrow: [eg.otherUser]});
113+
checkNotifiedOnce();
114+
115+
handleTypingEvent(dmNarrow, TypingOp.stop, eg.otherUser);
116+
checkTypists({groupNarrow: [eg.otherUser]});
117+
checkNotNotified();
118+
});
119+
120+
test('remove one of two typists in the same narrow', () {
121+
prepareModel(typistsByNarrow: {
122+
groupNarrow: [eg.otherUser, eg.thirdUser],
123+
});
124+
125+
handleTypingEvent(groupNarrow, TypingOp.stop, eg.otherUser);
126+
checkTypists({groupNarrow: [eg.thirdUser]});
127+
checkNotifiedOnce();
128+
});
129+
130+
test('remove all typists in a narrow', () {
131+
prepareModel(typistsByNarrow: {dmNarrow: [eg.otherUser]});
132+
133+
handleTypingEvent(dmNarrow, TypingOp.stop, eg.otherUser);
134+
checkTypists({});
135+
checkNotifiedOnce();
136+
});
137+
138+
test('remove typists from different narrows', () {
139+
prepareModel(typistsByNarrow: {
140+
dmNarrow: [eg.otherUser],
141+
groupNarrow: [eg.thirdUser],
142+
topicNarrow: [eg.fourthUser],
143+
});
144+
145+
handleTypingEvent(groupNarrow, TypingOp.stop, eg.thirdUser);
146+
checkTypists({dmNarrow: [eg.otherUser], topicNarrow: [eg.fourthUser]});
147+
checkNotifiedOnce();
148+
149+
handleTypingEvent(dmNarrow, TypingOp.stop, eg.otherUser);
150+
checkTypists({topicNarrow: [eg.fourthUser]});
151+
checkNotifiedOnce();
152+
153+
handleTypingEvent(topicNarrow, TypingOp.stop, eg.fourthUser);
154+
checkTypists({});
155+
checkNotifiedOnce();
156+
});
157+
});
158+
159+
group('cancelling old timer', () {
160+
test('when typing stopped early', () => awaitFakeAsync((async) async {
161+
prepareModel();
162+
163+
handleTypingEvent(dmNarrow, TypingOp.start, eg.otherUser);
164+
checkTypists({dmNarrow: [eg.otherUser]});
165+
checkNotifiedOnce();
166+
check(async.pendingTimers).length.equals(1);
167+
168+
handleTypingEvent(dmNarrow, TypingOp.stop, eg.otherUser);
169+
checkTypists({});
170+
checkNotifiedOnce();
171+
check(async.pendingTimers).isEmpty();
172+
}));
173+
174+
test('when typing repeatedly started', () => awaitFakeAsync((async) async {
175+
prepareModel();
176+
177+
handleTypingEvent(groupNarrow, TypingOp.start, eg.otherUser);
178+
checkTypists({groupNarrow: [eg.otherUser]});
179+
checkNotifiedOnce();
180+
check(async.pendingTimers).length.equals(1);
181+
182+
// The new timer should be active and the old timer should be cancelled.
183+
handleTypingEvent(groupNarrow, TypingOp.start, eg.otherUser);
184+
checkTypists({groupNarrow: [eg.otherUser]});
185+
check(async.pendingTimers).length.equals(1);
186+
checkNotNotified();
187+
}));
188+
});
189+
190+
group('typing start expiry period', () {
191+
test('typist is removed when the expiry period ends', () => awaitFakeAsync((async) async {
192+
prepareModel();
193+
194+
handleTypingEvent(dmNarrow, TypingOp.start, eg.otherUser);
195+
async.elapse(const Duration(seconds: 5));
196+
checkTypists({dmNarrow: [eg.otherUser]});
197+
async.elapse(const Duration(seconds: 10));
198+
checkTypists({});
199+
}));
200+
201+
test('repeated typing start event resets the timer', () => awaitFakeAsync((async) async {
202+
prepareModel();
203+
204+
handleTypingEvent(dmNarrow, TypingOp.start, eg.otherUser);
205+
checkNotifiedOnce();
206+
207+
async.elapse(const Duration(seconds: 10));
208+
checkTypists({dmNarrow: [eg.otherUser]});
209+
// We expect the timer to restart from the event.
210+
handleTypingEvent(dmNarrow, TypingOp.start, eg.otherUser);
211+
checkNotNotified();
212+
213+
async.elapse(const Duration(seconds: 10));
214+
checkTypists({dmNarrow: [eg.otherUser]});
215+
checkNotNotified();
216+
217+
async.elapse(const Duration(seconds: 5));
218+
checkTypists({});
219+
checkNotifiedOnce();
220+
}));
221+
});
222+
}

0 commit comments

Comments
 (0)