Skip to content

Commit 13b526e

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 13b526e

File tree

4 files changed

+333
-0
lines changed

4 files changed

+333
-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: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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+
// and [model.handleTypingEvent] should handle that.
101+
model.handleTypingEvent(eventUnsorted);
102+
check(model.typistIdsInNarrow(
103+
DmNarrow(allRecipientIds: recipientIds..sort(), selfUserId: 5),
104+
)).single.equals(1);
105+
});
106+
});
107+
108+
group('handle typing stop events', () {
109+
test('remove a typist from an unknown narrow', () {
110+
prepareModel();
111+
112+
handleTypingEvent(groupNarrow, TypingOp.start, eg.otherUser);
113+
checkTypists({groupNarrow: [eg.otherUser]});
114+
checkNotifiedOnce();
115+
116+
handleTypingEvent(dmNarrow, TypingOp.stop, eg.otherUser);
117+
checkTypists({groupNarrow: [eg.otherUser]});
118+
checkNotNotified();
119+
});
120+
121+
test('remove one of two typists in the same narrow', () {
122+
prepareModel(typistsByNarrow: {
123+
groupNarrow: [eg.otherUser, eg.thirdUser],
124+
});
125+
126+
handleTypingEvent(groupNarrow, TypingOp.stop, eg.otherUser);
127+
checkTypists({groupNarrow: [eg.thirdUser]});
128+
checkNotifiedOnce();
129+
});
130+
131+
test('remove all typists in a narrow', () {
132+
prepareModel(typistsByNarrow: {dmNarrow: [eg.otherUser]});
133+
134+
handleTypingEvent(dmNarrow, TypingOp.stop, eg.otherUser);
135+
checkTypists({});
136+
checkNotifiedOnce();
137+
});
138+
139+
test('remove typists from different narrows', () {
140+
prepareModel(typistsByNarrow: {
141+
dmNarrow: [eg.otherUser],
142+
groupNarrow: [eg.thirdUser],
143+
topicNarrow: [eg.fourthUser],
144+
});
145+
146+
handleTypingEvent(groupNarrow, TypingOp.stop, eg.thirdUser);
147+
checkTypists({dmNarrow: [eg.otherUser], topicNarrow: [eg.fourthUser]});
148+
checkNotifiedOnce();
149+
150+
handleTypingEvent(dmNarrow, TypingOp.stop, eg.otherUser);
151+
checkTypists({topicNarrow: [eg.fourthUser]});
152+
checkNotifiedOnce();
153+
154+
handleTypingEvent(topicNarrow, TypingOp.stop, eg.fourthUser);
155+
checkTypists({});
156+
checkNotifiedOnce();
157+
});
158+
});
159+
160+
group('cancelling old timer', () {
161+
test('when typing stopped early', () => awaitFakeAsync((async) async {
162+
prepareModel();
163+
164+
handleTypingEvent(dmNarrow, TypingOp.start, eg.otherUser);
165+
checkTypists({dmNarrow: [eg.otherUser]});
166+
checkNotifiedOnce();
167+
check(async.pendingTimers).length.equals(1);
168+
169+
handleTypingEvent(dmNarrow, TypingOp.stop, eg.otherUser);
170+
checkTypists({});
171+
checkNotifiedOnce();
172+
check(async.pendingTimers).isEmpty();
173+
}));
174+
175+
test('when typing repeatedly started', () => awaitFakeAsync((async) async {
176+
prepareModel();
177+
178+
handleTypingEvent(groupNarrow, TypingOp.start, eg.otherUser);
179+
checkTypists({groupNarrow: [eg.otherUser]});
180+
checkNotifiedOnce();
181+
check(async.pendingTimers).length.equals(1);
182+
183+
// The new timer should be active and the old timer should be cancelled.
184+
handleTypingEvent(groupNarrow, TypingOp.start, eg.otherUser);
185+
checkTypists({groupNarrow: [eg.otherUser]});
186+
check(async.pendingTimers).length.equals(1);
187+
checkNotNotified();
188+
}));
189+
});
190+
191+
group('typing start expiry period', () {
192+
test('typist is removed when the expiry period ends', () => awaitFakeAsync((async) async {
193+
prepareModel();
194+
195+
handleTypingEvent(dmNarrow, TypingOp.start, eg.otherUser);
196+
async.elapse(const Duration(seconds: 5));
197+
checkTypists({dmNarrow: [eg.otherUser]});
198+
async.elapse(const Duration(seconds: 10));
199+
checkTypists({});
200+
}));
201+
202+
test('repeated typing start event resets the timer', () => awaitFakeAsync((async) async {
203+
prepareModel();
204+
205+
handleTypingEvent(dmNarrow, TypingOp.start, eg.otherUser);
206+
checkNotifiedOnce();
207+
208+
async.elapse(const Duration(seconds: 10));
209+
checkTypists({dmNarrow: [eg.otherUser]});
210+
// We expect the timer to restart from the event.
211+
handleTypingEvent(dmNarrow, TypingOp.start, eg.otherUser);
212+
checkNotNotified();
213+
214+
async.elapse(const Duration(seconds: 10));
215+
checkTypists({dmNarrow: [eg.otherUser]});
216+
checkNotNotified();
217+
218+
async.elapse(const Duration(seconds: 5));
219+
checkTypists({});
220+
checkNotifiedOnce();
221+
}));
222+
});
223+
}

0 commit comments

Comments
 (0)