Skip to content

Commit 5148c45

Browse files
PIG208gnprice
authored andcommitted
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 fb36a8a commit 5148c45

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: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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+
_timerMapsByNarrow.clear();
37+
super.dispose();
38+
}
39+
40+
bool _addTypist(SendableNarrow narrow, int typistUserId) {
41+
final narrowTimerMap = _timerMapsByNarrow[narrow] ??= {};
42+
final typistTimer = narrowTimerMap[typistUserId];
43+
final isNewTypist = typistTimer == null;
44+
typistTimer?.cancel();
45+
narrowTimerMap[typistUserId] = Timer(typingStartedExpiryPeriod, () {
46+
if (_removeTypist(narrow, typistUserId)) {
47+
notifyListeners();
48+
}
49+
});
50+
return isNewTypist;
51+
}
52+
53+
bool _removeTypist(SendableNarrow narrow, int typistUserId) {
54+
final narrowTimerMap = _timerMapsByNarrow[narrow];
55+
final typistTimer = narrowTimerMap?.remove(typistUserId);
56+
if (typistTimer == null) {
57+
return false;
58+
}
59+
typistTimer.cancel();
60+
if (narrowTimerMap!.isEmpty) _timerMapsByNarrow.remove(narrow);
61+
return true;
62+
}
63+
64+
void handleTypingEvent(TypingEvent event) {
65+
SendableNarrow narrow = switch (event.messageType) {
66+
MessageType.direct => DmNarrow(
67+
allRecipientIds: event.recipientIds!..sort(), selfUserId: selfUserId),
68+
MessageType.stream => TopicNarrow(event.streamId!, event.topic!),
69+
};
70+
71+
bool hasUpdate = false;
72+
switch (event.op) {
73+
case TypingOp.start:
74+
hasUpdate = _addTypist(narrow, event.senderId);
75+
case TypingOp.stop:
76+
hasUpdate = _removeTypist(narrow, event.senderId);
77+
}
78+
79+
if (hasUpdate) {
80+
notifyListeners();
81+
}
82+
}
83+
}

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: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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+
test('dispose', () => awaitFakeAsync((async) async {
66+
prepareModel(typistsByNarrow: {dmNarrow: [eg.otherUser]});
67+
68+
model.dispose();
69+
checkTypists({});
70+
check(async.pendingTimers).isEmpty();
71+
}));
72+
73+
group('handle typing start events', () {
74+
test('add typists in separate narrows', () {
75+
prepareModel();
76+
77+
handleTypingEvent(dmNarrow, TypingOp.start, eg.otherUser);
78+
checkTypists({dmNarrow: [eg.otherUser]});
79+
checkNotifiedOnce();
80+
81+
handleTypingEvent(groupNarrow, TypingOp.start, eg.thirdUser);
82+
checkTypists({dmNarrow: [eg.otherUser], groupNarrow: [eg.thirdUser]});
83+
checkNotifiedOnce();
84+
85+
handleTypingEvent(topicNarrow, TypingOp.start, eg.fourthUser);
86+
checkTypists({
87+
dmNarrow: [eg.otherUser],
88+
groupNarrow: [eg.thirdUser],
89+
topicNarrow: [eg.fourthUser]});
90+
checkNotifiedOnce();
91+
});
92+
93+
test('add a typist in the same narrow', () {
94+
prepareModel(typistsByNarrow: {groupNarrow: [eg.otherUser]});
95+
96+
handleTypingEvent(groupNarrow, TypingOp.start, eg.thirdUser);
97+
checkTypists({groupNarrow: [eg.otherUser, eg.thirdUser]});
98+
checkNotifiedOnce();
99+
});
100+
101+
test('sort dm recipients', () {
102+
prepareModel(selfUserId: 5);
103+
final recipientIds = [5, 4, 10, 8, 2, 1];
104+
105+
final eventUnsorted = TypingEvent(id: 1, op: TypingOp.start,
106+
senderId: 1,
107+
messageType: MessageType.direct,
108+
recipientIds: recipientIds,
109+
streamId: null, topic: null);
110+
// DmNarrow's constructor expects the recipient IDs to be sorted,
111+
// and [model.handleTypingEvent] should handle that.
112+
model.handleTypingEvent(eventUnsorted);
113+
check(model.typistIdsInNarrow(
114+
DmNarrow(allRecipientIds: recipientIds..sort(), selfUserId: 5),
115+
)).single.equals(1);
116+
});
117+
});
118+
119+
group('handle typing stop events', () {
120+
test('remove a typist from an unknown narrow', () {
121+
prepareModel(typistsByNarrow: {groupNarrow: [eg.otherUser]});
122+
123+
handleTypingEvent(dmNarrow, TypingOp.stop, eg.otherUser);
124+
checkTypists({groupNarrow: [eg.otherUser]});
125+
checkNotNotified();
126+
});
127+
128+
test('remove an unknown typist from a known narrow', () {
129+
prepareModel(typistsByNarrow: {groupNarrow: [eg.otherUser]});
130+
131+
handleTypingEvent(groupNarrow, TypingOp.stop, eg.thirdUser);
132+
checkTypists({groupNarrow: [eg.otherUser]});
133+
checkNotNotified();
134+
});
135+
136+
test('remove one of two typists in the same narrow', () {
137+
prepareModel(typistsByNarrow: {
138+
groupNarrow: [eg.otherUser, eg.thirdUser],
139+
});
140+
141+
handleTypingEvent(groupNarrow, TypingOp.stop, eg.otherUser);
142+
checkTypists({groupNarrow: [eg.thirdUser]});
143+
checkNotifiedOnce();
144+
});
145+
146+
test('remove all typists in a narrow', () {
147+
prepareModel(typistsByNarrow: {dmNarrow: [eg.otherUser]});
148+
149+
handleTypingEvent(dmNarrow, TypingOp.stop, eg.otherUser);
150+
checkTypists({});
151+
checkNotifiedOnce();
152+
});
153+
154+
test('remove typists from different narrows', () {
155+
prepareModel(typistsByNarrow: {
156+
dmNarrow: [eg.otherUser],
157+
groupNarrow: [eg.thirdUser],
158+
topicNarrow: [eg.fourthUser],
159+
});
160+
161+
handleTypingEvent(groupNarrow, TypingOp.stop, eg.thirdUser);
162+
checkTypists({dmNarrow: [eg.otherUser], topicNarrow: [eg.fourthUser]});
163+
checkNotifiedOnce();
164+
165+
handleTypingEvent(dmNarrow, TypingOp.stop, eg.otherUser);
166+
checkTypists({topicNarrow: [eg.fourthUser]});
167+
checkNotifiedOnce();
168+
169+
handleTypingEvent(topicNarrow, TypingOp.stop, eg.fourthUser);
170+
checkTypists({});
171+
checkNotifiedOnce();
172+
});
173+
});
174+
175+
group('typing start expiry period', () {
176+
test('typist is removed when the expiry period ends', () => awaitFakeAsync((async) async {
177+
prepareModel(typistsByNarrow: {dmNarrow: [eg.otherUser]});
178+
179+
async.elapse(const Duration(seconds: 5));
180+
checkTypists({dmNarrow: [eg.otherUser]});
181+
checkNotNotified();
182+
183+
async.elapse(const Duration(seconds: 10));
184+
checkTypists({});
185+
check(async.pendingTimers).isEmpty();
186+
checkNotifiedOnce();
187+
}));
188+
189+
test('early typing stop event cancels the timer', () => awaitFakeAsync((async) async {
190+
prepareModel(typistsByNarrow: {dmNarrow: [eg.otherUser]});
191+
check(async.pendingTimers).length.equals(1);
192+
193+
handleTypingEvent(dmNarrow, TypingOp.stop, eg.otherUser);
194+
checkTypists({});
195+
check(async.pendingTimers).isEmpty();
196+
checkNotifiedOnce();
197+
}));
198+
199+
test('repeated typing start event resets the timer', () => awaitFakeAsync((async) async {
200+
prepareModel(typistsByNarrow: {dmNarrow: [eg.otherUser]});
201+
check(async.pendingTimers).length.equals(1);
202+
203+
async.elapse(const Duration(seconds: 10));
204+
checkTypists({dmNarrow: [eg.otherUser]});
205+
// The timer should restart from the event.
206+
handleTypingEvent(dmNarrow, TypingOp.start, eg.otherUser);
207+
check(async.pendingTimers).length.equals(1);
208+
checkNotNotified();
209+
210+
async.elapse(const Duration(seconds: 10));
211+
checkTypists({dmNarrow: [eg.otherUser]});
212+
check(async.pendingTimers).length.equals(1);
213+
checkNotNotified();
214+
215+
async.elapse(const Duration(seconds: 5));
216+
checkTypists({});
217+
check(async.pendingTimers).isEmpty();
218+
checkNotifiedOnce();
219+
}));
220+
});
221+
}

0 commit comments

Comments
 (0)