Skip to content

Support setting typing status #897

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

Merged
merged 9 commits into from
Nov 6, 2024
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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix link; binding: Add stopwatch.

Commit-message nit: I think there's a missing word or two in here:

[…] We need to for testability of stopwatches with fake async. […]

///
/// 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();
_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;
}
}
101 changes: 94 additions & 7 deletions lib/widgets/compose_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -269,19 +269,100 @@ class ComposeContentController extends ComposeController<ContentValidationError>
}
}

class _ContentInput extends StatelessWidget {
class _ContentInput extends StatefulWidget {
const _ContentInput({
required this.narrow,
required this.destination,
required this.controller,
required this.focusNode,
required this.hintText,
});

final Narrow narrow;
final SendableNarrow destination;
final ComposeContentController controller;
final FocusNode focusNode;
final String hintText;

@override
State<_ContentInput> createState() => _ContentInputState();
}

class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
widget.controller.addListener(_contentChanged);
widget.focusNode.addListener(_focusChanged);
WidgetsBinding.instance.addObserver(this);
}

@override
void didUpdateWidget(covariant _ContentInput oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller != oldWidget.controller) {
oldWidget.controller.removeListener(_contentChanged);
widget.controller.addListener(_contentChanged);
}
if (widget.focusNode != oldWidget.focusNode) {
oldWidget.focusNode.removeListener(_focusChanged);
widget.focusNode.addListener(_focusChanged);
}
}

@override
void dispose() {
widget.controller.removeListener(_contentChanged);
widget.focusNode.removeListener(_focusChanged);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}

void _contentChanged() {
final store = PerAccountStoreWidget.of(context);
(widget.controller.text.isEmpty)
? store.typingNotifier.stoppedComposing()
: store.typingNotifier.keystroke(widget.destination);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rereading the API docs:
https://zulip.com/api/set-typing-status

the spec for what a client should do includes:

While the user continues to actively type or otherwise interact with the compose UI (e.g. interacting with the compose box emoji picker), send regular "op": "start" requests to this endpoint

Thinking about that, here's a couple more situations where we should be sending such requests:

  • The user hits one of the compose buttons. (Eventually if they do choose an image, etc., then that will cause a typing notice anyway because it'll edit the text in the input; but that may be many seconds later. In a fast-moving conversation, it may make a difference to let other participants know immediately that they're preparing something to send.)
  • The text input's selection changes, as opposed to its text. That's what it'll look like to us if the user is moving their cursor around the input, preparing their next edit; or if they're selecting a part of their draft to cut and perhaps move around.
  • Ideally, also if the user scrolls around the results of an autocomplete. If this one is at all complicated to implement, though, it's low priority — I think generally people are not likely to do this for more than a couple of seconds, between typing more letters to narrow down the autocomplete and then either picking a result or giving up.

Of those, the second one can be squashed into the main commit here, since it's just removing some logic that that one adds; the other two should probably be separate commits, just like your existing followup commit for app lifecycle changes.

}

void _focusChanged() {
if (widget.focusNode.hasFocus) {
// Content input getting focus doesn't necessarily mean that
// the user started typing, so do nothing.
return;
}
final store = PerAccountStoreWidget.of(context);
store.typingNotifier.stoppedComposing();
}

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.hidden:
case AppLifecycleState.paused:
case AppLifecycleState.detached:
// Transition to either [hidden] or [paused] signals that
// > [the] application is not currently visible to the user, and not
// > responding to user input.
//
// When transitioning to [detached], the compose box can't exist:
// > The application defaults to this state before it initializes, and
// > can be in this state (applicable on Android, iOS, and web) after
// > all views have been detached.
//
// For all these states, we can conclude that the user is not
// composing a message.
final store = PerAccountStoreWidget.of(context);
store.typingNotifier.stoppedComposing();
case AppLifecycleState.inactive:
// > At least one view of the application is visible, but none have
// > input focus. The application is otherwise running normally.
// For example, we expect this state when the user is selecting a file
// to upload.
case AppLifecycleState.resumed:
}
}

@override
Widget build(BuildContext context) {
ColorScheme colorScheme = Theme.of(context).colorScheme;
Expand All @@ -296,15 +377,15 @@ class _ContentInput extends StatelessWidget {
maxHeight: 200,
),
child: ComposeAutocomplete(
narrow: narrow,
controller: controller,
focusNode: focusNode,
narrow: widget.narrow,
controller: widget.controller,
focusNode: widget.focusNode,
fieldViewBuilder: (context) {
return TextField(
controller: controller,
focusNode: focusNode,
controller: widget.controller,
focusNode: widget.focusNode,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration.collapsed(hintText: hintText),
decoration: InputDecoration.collapsed(hintText: widget.hintText),
maxLines: null,
textCapitalization: TextCapitalization.sentences,
);
Expand Down Expand Up @@ -370,6 +451,7 @@ class _StreamContentInputState extends State<_StreamContentInput> {
?? zulipLocalizations.composeBoxUnknownChannelName;
return _ContentInput(
narrow: widget.narrow,
destination: TopicNarrow(widget.narrow.streamId, _topicTextNormalized),
controller: widget.controller,
focusNode: widget.focusNode,
hintText: zulipLocalizations.composeBoxChannelContentHint(streamName, _topicTextNormalized));
Expand Down Expand Up @@ -446,6 +528,7 @@ class _FixedDestinationContentInput extends StatelessWidget {
Widget build(BuildContext context) {
return _ContentInput(
narrow: narrow,
destination: narrow,
controller: controller,
focusNode: focusNode,
hintText: _hintText(context));
Expand Down Expand Up @@ -818,6 +901,10 @@ class _SendButtonState extends State<_SendButton> {
final content = widget.contentController.textNormalized;

widget.contentController.clear();
// The following `stoppedComposing` call is currently redundant,
// because clearing input sends a "typing stopped" notice.
// It will be necessary once we resolve #720.
store.typingNotifier.stoppedComposing();

try {
// TODO(#720) clear content input only on success response;
Expand Down
4 changes: 4 additions & 0 deletions test/model/binding.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:async';

import 'package:clock/clock.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
Expand Down Expand Up @@ -209,6 +210,9 @@ class TestZulipBinding extends ZulipBinding {
_closeInAppWebViewCallCount++;
}

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

/// The value that `ZulipBinding.instance.deviceInfo` should return.
BaseDeviceInfo deviceInfoResult = _defaultDeviceInfoResult;
static const _defaultDeviceInfoResult = AndroidDeviceInfo(sdkInt: 33, release: '13');
Expand Down
Loading
Loading