Skip to content

Support setting typing status in more situations #1033

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 15 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/model/binding.dart
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ abstract class ZulipBinding {
/// This wraps [url_launcher.closeInAppWebView].
Future<void> closeInAppWebView();

/// Provides access to a new stopwatch.
///
/// Outside tests, this just calls the [Stopwatch] constructor.
Stopwatch stopwatch();

/// Provides device and operating system information,
/// via package:device_info_plus.
///
Expand Down Expand Up @@ -360,6 +365,9 @@ class LiveZulipBinding extends ZulipBinding {
return url_launcher.closeInAppWebView();
}

@override
Stopwatch stopwatch() => Stopwatch();

@override
Future<BaseDeviceInfo?> get deviceInfo => _deviceInfo;
late Future<BaseDeviceInfo?> _deviceInfo;
Expand Down
11 changes: 11 additions & 0 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,13 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
accountId: accountId,
selfUserId: account.userId,
userSettings: initialSnapshot.userSettings,
typingNotifier: TypingNotifier(
connection: connection,
typingStoppedWaitPeriod: Duration(
milliseconds: initialSnapshot.serverTypingStoppedWaitPeriodMilliseconds),
typingStartedWaitPeriod: Duration(
milliseconds: initialSnapshot.serverTypingStartedWaitPeriodMilliseconds),
),
users: Map.fromEntries(
initialSnapshot.realmUsers
.followedBy(initialSnapshot.realmNonActiveUsers)
Expand Down Expand Up @@ -311,6 +318,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
required this.accountId,
required this.selfUserId,
required this.userSettings,
required this.typingNotifier,
required this.users,
required this.typingStatus,
required ChannelStoreImpl channels,
Expand Down Expand Up @@ -413,6 +421,8 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess

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

final TypingNotifier typingNotifier;

////////////////////////////////
// Users and data about them.

Expand Down Expand Up @@ -493,6 +503,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
unreads.dispose();
_messages.dispose();
typingStatus.dispose();
typingNotifier.dispose();
updateMachine?.dispose();
connection.close();
_disposed = true;
Expand Down
150 changes: 149 additions & 1 deletion lib/model/typing_status.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import 'dart:async';

import 'package:flutter/foundation.dart';

import '../api/core.dart';
import '../api/model/events.dart';
import '../api/route/typing.dart';
import 'binding.dart';
import 'narrow.dart';

/// The model for tracking the typing status organized by narrows.
Expand All @@ -23,7 +26,7 @@ class TypingStatus extends ChangeNotifier {
_timerMapsByNarrow[narrow]?.keys ?? [];

// Using SendableNarrow as the key covers the narrows
// where typing notifications are supported (topics and DMs).
// where typing notices are supported (topics and DMs).
final Map<SendableNarrow, Map<int, Timer>> _timerMapsByNarrow = {};

@override
Expand Down Expand Up @@ -84,3 +87,148 @@ class TypingStatus extends ChangeNotifier {
}
}
}

/// Sends the self-user's typing-status updates.
///
/// See also:
/// * https://github.com/zulip/zulip/blob/52a9846cdf4abfbe937a94559690d508e95f4065/web/shared/src/typing_status.ts
/// * https://zulip.readthedocs.io/en/latest/subsystems/typing-indicators.html
class TypingNotifier {
TypingNotifier({
required this.connection,
required this.typingStoppedWaitPeriod,
required this.typingStartedWaitPeriod,
});

final ApiConnection connection;
final Duration typingStoppedWaitPeriod;
final Duration typingStartedWaitPeriod;

SendableNarrow? _currentDestination;

/// Records time elapsed since the last time we notify the server;
/// this is `null` when the user is not actively typing.
Stopwatch? _sinceLastPing;

/// A timer that resets on every [keystroke].
///
/// Upon its expiry, the user is considered idle and
/// a "typing stopped" notice will be sent.
Timer? _idleTimer;

void dispose() {
_idleTimer?.cancel();
}

/// Updates the server, if needed, that a keystroke was made when
/// composing a new message to [destination].
///
/// To be called on all keystrokes in the composing session.
/// Sends "typing started" notices, throttled appropriately,
/// for repeated calls to the same [destination].
///
/// If [destination] differs from the previous call, such as after a topic
/// input change, sends a "typing stopped" notice for the old destination.
///
/// Keeps a timer to send a "typing stopped" notice when this and
/// [stoppedComposing] haven't been called in some time.
void keystroke(SendableNarrow destination) {
if (!debugEnable) return;

if (_currentDestination != null) {
if (destination == _currentDestination) {
// Nothing has really changed, except we may need
// to send a ping to the server and extend out our idle time.
if (_sinceLastPing!.elapsed > typingStartedWaitPeriod) {
_actuallyPingServer();
}
_startOrExtendIdleTimer();
return;
}

_stopLastNotification();
}

// We just started typing to this destination, so notify the server.
_currentDestination = destination;
_startOrExtendIdleTimer();
return _actuallyPingServer();
}

/// Sends the server a "typing stopped" notice for the destination of
/// the current composing session, if there is one.
///
/// To be called on cues that the user has exited a new-message composing session,
/// e.g., send button tapped, compose box unfocused, nav changed, app quit.
///
/// If [keystroke] hasn't been called in some time, does nothing.
///
/// Otherwise:
/// - Users will see our user's typing indicator disappear immediately
/// instead of after [keystroke]'s timer.
/// - [keystroke]'s timer is canceled.
///
/// (This has no "destination" param because the user can really only compose
/// to one destination at a time. This function acts on the current session
/// regardless of its destination.)
void stoppedComposing() {
if (!debugEnable) return;

if (_currentDestination != null) {
_stopLastNotification();
}
}

void _startOrExtendIdleTimer() {
_idleTimer?.cancel();
_idleTimer = Timer(typingStoppedWaitPeriod, _stopLastNotification);
}

void _actuallyPingServer() {
// This allows us to use [clock.stopwatch] only when testing.
_sinceLastPing = ZulipBinding.instance.stopwatch()..start();

unawaited(setTypingStatus(
connection,
op: TypingOp.start,
destination: _currentDestination!.destination));
}

void _stopLastNotification() {
assert(_currentDestination != null);
final destination = _currentDestination!;

_idleTimer!.cancel();
_currentDestination = null;
_sinceLastPing = null;

unawaited(setTypingStatus(
connection,
op: TypingOp.stop,
destination: destination.destination));
}

/// In debug mode, controls whether typing notices should be sent.
///
/// Outside of debug mode, this is always true and the setter has no effect.
static bool get debugEnable {
bool result = true;
assert(() {
result = _debugEnable;
return true;
}());
return result;
}
static bool _debugEnable = true;
static set debugEnable(bool value) {
assert(() {
_debugEnable = value;
return true;
}());
}

@visibleForTesting
static void debugReset() {
_debugEnable = true;
}
}
Loading