Skip to content

Commit a8bd42a

Browse files
committed
model: Add TypingNotifier
The debugEnable setup is borrowed from debugEnableRegisterNotificationToken. This acts as a hook to temporarily disable typing notifications 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 Signed-off-by: Zixuan James Li <[email protected]>
1 parent bb1c54f commit a8bd42a

File tree

3 files changed

+418
-1
lines changed

3 files changed

+418
-1
lines changed

lib/model/store.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,10 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
252252
selfUserId: account.userId,
253253
typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds),
254254
),
255+
typingNotifier: TypingNotifier(
256+
typingStoppedWaitPeriod: Duration(milliseconds: initialSnapshot.serverTypingStoppedWaitPeriodMilliseconds),
257+
typingStartedWaitPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedWaitPeriodMilliseconds),
258+
),
255259
channels: channels,
256260
messages: MessageStoreImpl(),
257261
unreads: Unreads(
@@ -279,6 +283,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
279283
required this.userSettings,
280284
required this.users,
281285
required this.typingStatus,
286+
required this.typingNotifier,
282287
required ChannelStoreImpl channels,
283288
required MessageStoreImpl messages,
284289
required this.unreads,
@@ -364,6 +369,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
364369
final Map<int, User> users;
365370

366371
final TypingStatus typingStatus;
372+
final TypingNotifier typingNotifier;
367373

368374
////////////////////////////////
369375
// Streams, topics, and stuff about them.
@@ -434,6 +440,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
434440
recentDmConversationsView.dispose();
435441
unreads.dispose();
436442
_messages.dispose();
443+
typingNotifier.dispose();
437444
typingStatus.dispose();
438445
super.dispose();
439446
}

lib/model/typing_status.dart

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import 'dart:async';
22

3-
import 'package:flutter/foundation.dart';
3+
import 'package:flutter/widgets.dart';
44

55
import '../api/model/events.dart';
6+
import '../api/route/typing.dart';
7+
import 'binding.dart';
68
import 'narrow.dart';
9+
import 'store.dart';
710

811
/// The model for tracking the typing status organized by narrows.
912
///
@@ -84,3 +87,141 @@ class TypingStatus extends ChangeNotifier {
8487
}
8588
}
8689
}
90+
91+
/// Manages updates to the user's typing status.
92+
///
93+
/// See the web implementation:
94+
/// https://github.com/zulip/zulip/blob/52a9846cdf4abfbe937a94559690d508e95f4065/web/shared/src/typing_status.ts
95+
class TypingNotifier {
96+
TypingNotifier({
97+
required this.typingStoppedWaitPeriod,
98+
required this.typingStartedWaitPeriod,
99+
});
100+
101+
final Duration typingStoppedWaitPeriod;
102+
final Duration typingStartedWaitPeriod;
103+
104+
/// In debug mode, controls whether typing notifications should be sent.
105+
///
106+
/// Outside of debug mode, this is always true and the setter has no effect.
107+
static bool get debugEnable {
108+
bool result = true;
109+
assert(() {
110+
result = _debugEnable;
111+
return true;
112+
}());
113+
return result;
114+
}
115+
static bool _debugEnable = true;
116+
static set debugEnable(bool value) {
117+
assert(() {
118+
_debugEnable = value;
119+
return true;
120+
}());
121+
}
122+
123+
@visibleForTesting
124+
static void debugReset() {
125+
_debugEnable = true;
126+
}
127+
128+
/// The narrow where the user is currently typing in.
129+
SendableNarrow? _currentDestination;
130+
131+
/// The time elapsed since the last typing started request was sent.
132+
Stopwatch? _lastNotifyStart;
133+
134+
/// A timer that resets whenever the user starts typing.
135+
///
136+
/// Upon its expiry, the user is considered idle and a typing stopped request
137+
/// will be sent.
138+
Timer? _idleTimer;
139+
140+
void dispose() {
141+
_idleTimer?.cancel();
142+
}
143+
144+
/// Update the store, and notify the server as needed, on the user's typing
145+
/// status.
146+
///
147+
/// This can and should be called frequently, on each keystroke. The
148+
/// implementation sends "still typing" notices at an appropriate throttled
149+
/// rate, and keeps a timer to send a "stopped typing" notice when the user
150+
/// hasn't typed for a few seconds.
151+
///
152+
/// Call with `destination` as `null` when the user actively stops composing a
153+
/// message. If the user switches from one destination to another, there's no
154+
/// need to call with `null` in between; the implementation tracks the change
155+
/// and behaves appropriately.
156+
///
157+
/// See docs/subsystems/typing-indicators.md for detailed background on the
158+
/// typing indicators system.
159+
void handleTypingStatusUpdate(PerAccountStore store, {
160+
SendableNarrow? destination,
161+
}) {
162+
if (!debugEnable) return;
163+
164+
if (_currentDestination != null) {
165+
if (destination == _currentDestination) {
166+
// Nothing has really changed, except we may need
167+
// to send a ping to the server and extend out our idle time
168+
_maybePingServer(store);
169+
_startOrExtendIdleTimer(store);
170+
return;
171+
}
172+
173+
// We stopped typing to our old destination,
174+
// so we must stop the old typing status.
175+
// Don't return yet, because we may have a new destination.
176+
_stopLastNotification(store);
177+
}
178+
179+
if (destination == null) {
180+
// If we are not talking to somebody we care about,
181+
// then there is no more action to take.
182+
return;
183+
}
184+
185+
// We just started typing to the destination, so notify the server.
186+
_currentDestination = destination;
187+
_startOrExtendIdleTimer(store);
188+
_actuallyPingServer(store);
189+
}
190+
191+
void _startOrExtendIdleTimer(PerAccountStore store) {
192+
_idleTimer?.cancel();
193+
_idleTimer = Timer(
194+
typingStoppedWaitPeriod, () => _stopLastNotification(store));
195+
}
196+
197+
Future<void> _maybePingServer(PerAccountStore store) async {
198+
if (_lastNotifyStart == null
199+
|| _lastNotifyStart!.elapsed > typingStartedWaitPeriod) {
200+
await _actuallyPingServer(store);
201+
}
202+
}
203+
204+
Future<void> _actuallyPingServer(PerAccountStore store) {
205+
// This allows us to use [clock.stopwatch] only when testing.
206+
_lastNotifyStart = ZulipBinding.instance.stopwatch()..start();
207+
208+
return setTypingStatus(
209+
store.connection,
210+
op: TypingOp.start,
211+
destination: _currentDestination!.destination);
212+
}
213+
214+
Future<void> _stopLastNotification(PerAccountStore store) {
215+
assert(_currentDestination != null);
216+
final destination = _currentDestination!;
217+
218+
_idleTimer!.cancel();
219+
_currentDestination = null;
220+
_lastNotifyStart = null;
221+
222+
return setTypingStatus(
223+
store.connection,
224+
op: TypingOp.stop,
225+
destination: destination.destination);
226+
}
227+
}

0 commit comments

Comments
 (0)