Skip to content

Commit e813313

Browse files
committed
model: Add model for handling typing status events.
Using SendableNarrow as the key covers the narrows where typing notifications are supported (topics and dms). Consumers of the typing status will only be notified if a typist has been added or removed from any of the narrows. At the moment, getTypistIdsInNarrow is unused. It will get exercised by the UI code that implements the typing indicator. Signed-off-by: Zixuan James Li <[email protected]>
1 parent 902b63e commit e813313

File tree

4 files changed

+313
-0
lines changed

4 files changed

+313
-0
lines changed

lib/model/store.dart

+12
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

+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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+
class TypingStatus extends ChangeNotifier {
10+
TypingStatus({
11+
required this.selfUserId,
12+
required this.typingStartedExpiryPeriod,
13+
});
14+
15+
final int selfUserId;
16+
final Duration typingStartedExpiryPeriod;
17+
18+
Iterable<SendableNarrow> get debugActiveNarrows => _timerMapsByNarrow.keys;
19+
20+
Iterable<int> typistIdsInNarrow(SendableNarrow narrow) =>
21+
_timerMapsByNarrow[narrow]?.keys ?? [];
22+
23+
final Map<SendableNarrow, Map<int, Timer>> _timerMapsByNarrow = {};
24+
25+
@override
26+
void dispose() {
27+
for (final timersByTypistId in _timerMapsByNarrow.values) {
28+
for (final timer in timersByTypistId.values) {
29+
timer.cancel();
30+
}
31+
}
32+
super.dispose();
33+
}
34+
35+
bool _addTypist(SendableNarrow narrow, int typistUserId) {
36+
final narrowTimerMap = _timerMapsByNarrow[narrow] ??= {};
37+
final typistTimer = narrowTimerMap[typistUserId];
38+
final isNewTypist = typistTimer == null;
39+
typistTimer?.cancel();
40+
narrowTimerMap[typistUserId] = Timer(typingStartedExpiryPeriod, () {
41+
if (_removeTypist(narrow, typistUserId)) {
42+
notifyListeners();
43+
}
44+
});
45+
return isNewTypist;
46+
}
47+
48+
bool _removeTypist(SendableNarrow narrow, int typistUserId) {
49+
final narrowTimerMap = _timerMapsByNarrow[narrow];
50+
final typistTimer = narrowTimerMap?.remove(typistUserId);
51+
if (typistTimer == null) {
52+
return false;
53+
}
54+
typistTimer.cancel();
55+
if (narrowTimerMap!.isEmpty) _timerMapsByNarrow.remove(narrow);
56+
return true;
57+
}
58+
59+
void handleTypingEvent(TypingEvent event) {
60+
SendableNarrow narrow = switch (event.messageType) {
61+
MessageType.direct => DmNarrow(
62+
allRecipientIds: event.recipientIds!..sort(), selfUserId: selfUserId),
63+
MessageType.stream => TopicNarrow(event.streamId!, event.topic!),
64+
};
65+
66+
bool hasUpdate = false;
67+
switch (event.op) {
68+
case TypingOp.start:
69+
hasUpdate = _addTypist(narrow, event.senderId);
70+
case TypingOp.stop:
71+
hasUpdate = _removeTypist(narrow, event.senderId);
72+
}
73+
74+
if (hasUpdate) {
75+
notifyListeners();
76+
}
77+
}
78+
}

test/example_data.dart

+16
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

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

0 commit comments

Comments
 (0)