Skip to content

Commit 47c0515

Browse files
committed
model: Add view-model for typing status.
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 212b454 commit 47c0515

File tree

4 files changed

+349
-0
lines changed

4 files changed

+349
-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;
@@ -251,6 +252,10 @@ class PerAccountStore extends ChangeNotifier with StreamStore, MessageStore {
251252
),
252253
recentDmConversationsView: RecentDmConversationsView(
253254
initial: initialSnapshot.recentPrivateConversations, selfUserId: account.userId),
255+
typingStatus: TypingStatus(
256+
selfUserId: account.userId,
257+
typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds),
258+
)
254259
);
255260
}
256261

@@ -270,6 +275,7 @@ class PerAccountStore extends ChangeNotifier with StreamStore, MessageStore {
270275
required MessageStoreImpl messages,
271276
required this.unreads,
272277
required this.recentDmConversationsView,
278+
required this.typingStatus,
273279
}) : assert(selfUserId == globalStore.getAccount(accountId)!.userId),
274280
assert(realmUrl == globalStore.getAccount(accountId)!.realmUrl),
275281
assert(realmUrl == connection.realmUrl),
@@ -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

@@ -380,6 +388,7 @@ class PerAccountStore extends ChangeNotifier with StreamStore, MessageStore {
380388

381389
@override
382390
void dispose() {
391+
typingStatus.dispose();
383392
recentDmConversationsView.dispose();
384393
unreads.dispose();
385394
_messages.dispose();
@@ -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

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

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.private,
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

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

0 commit comments

Comments
 (0)