Skip to content

Commit 231d6ed

Browse files
committed
model: Add TypingNotifier
The debugEnable setup is borrowed from debugEnableRegisterNotificationToken. This acts as a hook to temporarily disable typing notices in later widget tests. The documentation and implementation are based on the web app: https://github.com/zulip/zulip/blob/52a9846cdf4abfbe937a94559690d508e95f4065/web/shared/src/typing_status.ts We do not expose Futures with keystroke/stopComposing. Because the caller shouldn't need to wait for the underlying API request to finish. Perhaps one nice thing we can do is defining a TypingNotifier mixin and add that to both PerAccountStore and TypingNotifier, similar to ChannelStore. But TypingNotifier is not complicated enough for that to be worthwhile. Signed-off-by: Zixuan James Li <[email protected]>
1 parent e6c9ac2 commit 231d6ed

File tree

3 files changed

+449
-0
lines changed

3 files changed

+449
-0
lines changed

lib/model/store.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,13 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
277277
accountId: accountId,
278278
selfUserId: account.userId,
279279
userSettings: initialSnapshot.userSettings,
280+
typingNotifier: TypingNotifier(
281+
connection: connection,
282+
typingStoppedWaitPeriod: Duration(
283+
milliseconds: initialSnapshot.serverTypingStoppedWaitPeriodMilliseconds),
284+
typingStartedWaitPeriod: Duration(
285+
milliseconds: initialSnapshot.serverTypingStartedWaitPeriodMilliseconds),
286+
),
280287
users: Map.fromEntries(
281288
initialSnapshot.realmUsers
282289
.followedBy(initialSnapshot.realmNonActiveUsers)
@@ -311,6 +318,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
311318
required this.accountId,
312319
required this.selfUserId,
313320
required this.userSettings,
321+
required this.typingNotifier,
314322
required this.users,
315323
required this.typingStatus,
316324
required ChannelStoreImpl channels,
@@ -413,6 +421,8 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
413421

414422
final UserSettings? userSettings; // TODO(server-5)
415423

424+
final TypingNotifier typingNotifier;
425+
416426
////////////////////////////////
417427
// Users and data about them.
418428

@@ -493,6 +503,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
493503
unreads.dispose();
494504
_messages.dispose();
495505
typingStatus.dispose();
506+
typingNotifier.dispose();
496507
updateMachine?.dispose();
497508
connection.close();
498509
_disposed = true;

lib/model/typing_status.dart

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import 'dart:async';
22

33
import 'package:flutter/foundation.dart';
44

5+
import '../api/core.dart';
56
import '../api/model/events.dart';
7+
import '../api/route/typing.dart';
8+
import 'binding.dart';
69
import 'narrow.dart';
710

811
/// The model for tracking the typing status organized by narrows.
@@ -84,3 +87,148 @@ class TypingStatus extends ChangeNotifier {
8487
}
8588
}
8689
}
90+
91+
/// Sends the self-user's typing-status updates.
92+
///
93+
/// See also:
94+
/// * https://github.com/zulip/zulip/blob/52a9846cdf4abfbe937a94559690d508e95f4065/web/shared/src/typing_status.ts
95+
/// * https://zulip.readthedocs.io/en/latest/subsystems/typing-indicators.html
96+
class TypingNotifier {
97+
TypingNotifier({
98+
required this.connection,
99+
required this.typingStoppedWaitPeriod,
100+
required this.typingStartedWaitPeriod,
101+
});
102+
103+
final ApiConnection connection;
104+
final Duration typingStoppedWaitPeriod;
105+
final Duration typingStartedWaitPeriod;
106+
107+
SendableNarrow? _currentDestination;
108+
109+
/// Records time elapsed since the last time we notify the server;
110+
/// this is `null` when the user is not actively typing.
111+
Stopwatch? _sinceLastPing;
112+
113+
/// A timer that resets on every [keystroke].
114+
///
115+
/// Upon its expiry, the user is considered idle and
116+
/// a "typing stopped" notice will be sent.
117+
Timer? _idleTimer;
118+
119+
void dispose() {
120+
_idleTimer?.cancel();
121+
}
122+
123+
/// Updates the server, if needed, that a keystroke was made when
124+
/// composing a new message to [destination].
125+
///
126+
/// To be called on all keystrokes in the composing session.
127+
/// Sends "typing started" notices, throttled appropriately,
128+
/// for repeated calls to the same [destination].
129+
///
130+
/// If [destination] differs from the previous call, such as after a topic
131+
/// input change, sends a "typing stopped" notice for the old destination.
132+
///
133+
/// Keeps a timer to send a "typing stopped" notice when this and
134+
/// [stoppedComposing] haven't been called in some time.
135+
void keystroke(SendableNarrow destination) {
136+
if (!debugEnable) return;
137+
138+
if (_currentDestination != null) {
139+
if (destination == _currentDestination) {
140+
// Nothing has really changed, except we may need
141+
// to send a ping to the server and extend out our idle time.
142+
if (_sinceLastPing!.elapsed > typingStartedWaitPeriod) {
143+
_actuallyPingServer();
144+
}
145+
_startOrExtendIdleTimer();
146+
return;
147+
}
148+
149+
_stopLastNotification();
150+
}
151+
152+
// We just started typing to this destination, so notify the server.
153+
_currentDestination = destination;
154+
_startOrExtendIdleTimer();
155+
_actuallyPingServer();
156+
}
157+
158+
/// Sends the server a "typing stopped" notice for the destination of
159+
/// the current composing session, if there is one.
160+
///
161+
/// To be called on cues that the user has exited a new-message composing session,
162+
/// e.g., send button tapped, compose box unfocused, nav changed, app quit.
163+
///
164+
/// If [keystroke] hasn't been called in some time, does nothing.
165+
///
166+
/// Otherwise:
167+
/// - Users will see our user's typing indicator disappear immediately
168+
/// instead of after [keystroke]'s timer.
169+
/// - [keystroke]'s timer is canceled.
170+
///
171+
/// (This has no "destination" param because the user can really only compose
172+
/// to one destination at a time. This function acts on the current session
173+
/// regardless of its destination.)
174+
void stoppedComposing() {
175+
if (!debugEnable) return;
176+
177+
if (_currentDestination != null) {
178+
_stopLastNotification();
179+
}
180+
}
181+
182+
void _startOrExtendIdleTimer() {
183+
_idleTimer?.cancel();
184+
_idleTimer = Timer(typingStoppedWaitPeriod, _stopLastNotification);
185+
}
186+
187+
void _actuallyPingServer() {
188+
// This allows us to use [clock.stopwatch] only when testing.
189+
_sinceLastPing = ZulipBinding.instance.stopwatch()..start();
190+
191+
unawaited(setTypingStatus(
192+
connection,
193+
op: TypingOp.start,
194+
destination: _currentDestination!.destination));
195+
}
196+
197+
void _stopLastNotification() {
198+
assert(_currentDestination != null);
199+
final destination = _currentDestination!;
200+
201+
_idleTimer!.cancel();
202+
_currentDestination = null;
203+
_sinceLastPing = null;
204+
205+
unawaited(setTypingStatus(
206+
connection,
207+
op: TypingOp.stop,
208+
destination: destination.destination));
209+
}
210+
211+
/// In debug mode, controls whether typing notices should be sent.
212+
///
213+
/// Outside of debug mode, this is always true and the setter has no effect.
214+
static bool get debugEnable {
215+
bool result = true;
216+
assert(() {
217+
result = _debugEnable;
218+
return true;
219+
}());
220+
return result;
221+
}
222+
static bool _debugEnable = true;
223+
static set debugEnable(bool value) {
224+
assert(() {
225+
_debugEnable = value;
226+
return true;
227+
}());
228+
}
229+
230+
@visibleForTesting
231+
static void debugReset() {
232+
_debugEnable = true;
233+
}
234+
}

0 commit comments

Comments
 (0)