Skip to content
Merged
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
182 changes: 182 additions & 0 deletions lib/model/presence.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import 'dart:async';

import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';

import '../api/model/events.dart';
import '../api/model/model.dart';
import '../api/route/users.dart';
import 'store.dart';

/// The model for tracking which users are online, idle, and offline.
///
/// Use [presenceStatusForUser]. If that returns null, the user is offline.
///
/// This substore is its own [ChangeNotifier],
/// so callers need to remember to add a listener (and remove it on dispose).
/// In particular, [PerAccountStoreWidget] doesn't subscribe a widget subtree
/// to updates.
class Presence extends PerAccountStoreBase with ChangeNotifier {
Presence({
required super.core,
required this.serverPresencePingInterval,
required this.serverPresenceOfflineThresholdSeconds,
required this.realmPresenceDisabled,
required Map<int, PerUserPresence> initial,
}) : _map = initial;

final Duration serverPresencePingInterval;
final int serverPresenceOfflineThresholdSeconds;
// TODO(#668): update this realm setting (probably by accessing it from a new
// realm/server-settings substore that gets passed to Presence)
final bool realmPresenceDisabled;

Map<int, PerUserPresence> _map;

AppLifecycleListener? _appLifecycleListener;

void _handleLifecycleStateChange(AppLifecycleState newState) {
assert(!_disposed); // We remove the listener in [dispose].

// Since this handler can cause multiple requests within a
// serverPresencePingInterval period, we pass `pingOnly: true`, for now, because:
// - This makes the request cheap for the server.
// - We don't want to record stale presence data when responses arrive out
// of order. This handler would increase the risk of that by potentially
// sending requests more frequently than serverPresencePingInterval.
// (`pingOnly: true` causes presence data to be omitted in the response.)
// TODO(#1611) Both of these reasons can be easily addressed by passing
// lastUpdateId. Do that, and stop sending `pingOnly: true`.
// (For the latter point, we'd ignore responses with a stale lastUpdateId.)
_maybePingAndRecordResponse(newState, pingOnly: true);
}

bool _hasStarted = false;

void start() async {
if (!debugEnable) return;
if (_hasStarted) {
throw StateError('Presence.start should only be called once.');
}
_hasStarted = true;

_appLifecycleListener = AppLifecycleListener(
onStateChange: _handleLifecycleStateChange);

_poll();
}

Future<void> _maybePingAndRecordResponse(AppLifecycleState? appLifecycleState, {
required bool pingOnly,
}) async {
if (realmPresenceDisabled) return;

final UpdatePresenceResult result;
switch (appLifecycleState) {
case null:
case AppLifecycleState.hidden:
case AppLifecycleState.paused:
// No presence update.
return;
case AppLifecycleState.detached:
// > The application is still hosted by a Flutter engine but is
// > detached from any host views.
// TODO see if this actually works as a way to send an "idle" update
// when the user closes the app completely.
result = await updatePresence(connection,
pingOnly: pingOnly,
status: PresenceStatus.idle,
newUserInput: false);
case AppLifecycleState.resumed:
// > […] the default running mode for a running application that has
// > input focus and is visible.
result = await updatePresence(connection,
pingOnly: pingOnly,
status: PresenceStatus.active,
newUserInput: true);
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.
result = await updatePresence(connection,
pingOnly: pingOnly,
status: PresenceStatus.active,
newUserInput: false);
Comment on lines +90 to +105
Copy link
Member

Choose a reason for hiding this comment

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

- The newUserInput param is now usually true instead of always
  false. This seems more correct to me, and the change seems
  low-stakes (the doc says it's used to implement usage statistics);
  see the doc:
    https://zulip.com/api/update-presence#parameter-new_user_input

This sounds reasonable to me, but probably good to check with Tim in case he has thoughts based on how that's actually used on the server — perhaps @-mention him in the #mobile-team thread.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

#mobile-team > presence @ 💬

Quoting Tim:

Probably true is a better value if one wants to do something quick. Most accurately, you should be passing true if any only if the user has interacted with the app at all since the last presence request.

I think on a mobile device, always true is a fairly good simulation of that. But I'd definitely an M6 issue for refining it to implement that detail on the spec fully and then move on.

}
if (!pingOnly) {
_map = result.presences!;
notifyListeners();
}
}

void _poll() async {
assert(!_disposed);
while (true) {
// We put the wait upfront because we already have data when [start] is
// called; it comes from /register.
await Future<void>.delayed(serverPresencePingInterval);
if (_disposed) return;

await _maybePingAndRecordResponse(
SchedulerBinding.instance.lifecycleState, pingOnly: false);
if (_disposed) return;
}
}

bool _disposed = false;

@override
void dispose() {
_appLifecycleListener?.dispose();
_disposed = true;
super.dispose();
}

/// The [PresenceStatus] for [userId], or null if the user is offline.
PresenceStatus? presenceStatusForUser(int userId, {required DateTime utcNow}) {
final now = utcNow.millisecondsSinceEpoch ~/ 1000;
final perUserPresence = _map[userId];
if (perUserPresence == null) return null;
final PerUserPresence(:activeTimestamp, :idleTimestamp) = perUserPresence;

if (now - activeTimestamp <= serverPresenceOfflineThresholdSeconds) {
return PresenceStatus.active;
} else if (now - idleTimestamp <= serverPresenceOfflineThresholdSeconds) {
// The API doc is kind of confusing, but this seems correct:
// https://chat.zulip.org/#narrow/channel/378-api-design/topic/presence.3A.20.22potentially.20present.22.3F/near/2202431
// TODO clarify that API doc
return PresenceStatus.idle;
} else {
return null;
}
}

void handlePresenceEvent(PresenceEvent event) {
// TODO(#1618)
}

/// In debug mode, controls whether presence requests are made.
///
/// 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;
}
}
23 changes: 21 additions & 2 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import 'emoji.dart';
import 'localizations.dart';
import 'message.dart';
import 'message_list.dart';
import 'presence.dart';
import 'recent_dm_conversations.dart';
import 'recent_senders.dart';
import 'channel.dart';
Expand Down Expand Up @@ -474,9 +475,12 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
final channels = ChannelStoreImpl(initialSnapshot: initialSnapshot);
return PerAccountStore._(
core: core,
serverPresencePingIntervalSeconds: initialSnapshot.serverPresencePingIntervalSeconds,
serverPresenceOfflineThresholdSeconds: initialSnapshot.serverPresenceOfflineThresholdSeconds,
realmWildcardMentionPolicy: initialSnapshot.realmWildcardMentionPolicy,
realmMandatoryTopics: initialSnapshot.realmMandatoryTopics,
realmWaitingPeriodThreshold: initialSnapshot.realmWaitingPeriodThreshold,
realmPresenceDisabled: initialSnapshot.realmPresenceDisabled,
maxFileUploadSizeMib: initialSnapshot.maxFileUploadSizeMib,
realmEmptyTopicDisplayName: initialSnapshot.realmEmptyTopicDisplayName,
realmAllowMessageEditing: initialSnapshot.realmAllowMessageEditing,
Expand All @@ -498,8 +502,12 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
),
users: UserStoreImpl(core: core, initialSnapshot: initialSnapshot),
typingStatus: TypingStatus(core: core,
typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds),
),
typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds)),
presence: Presence(core: core,
serverPresencePingInterval: Duration(seconds: initialSnapshot.serverPresencePingIntervalSeconds),
serverPresenceOfflineThresholdSeconds: initialSnapshot.serverPresenceOfflineThresholdSeconds,
realmPresenceDisabled: initialSnapshot.realmPresenceDisabled,
initial: initialSnapshot.presences),
channels: channels,
messages: MessageStoreImpl(core: core,
realmEmptyTopicDisplayName: initialSnapshot.realmEmptyTopicDisplayName),
Expand All @@ -516,9 +524,12 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor

PerAccountStore._({
required super.core,
required this.serverPresencePingIntervalSeconds,
required this.serverPresenceOfflineThresholdSeconds,
required this.realmWildcardMentionPolicy,
required this.realmMandatoryTopics,
required this.realmWaitingPeriodThreshold,
required this.realmPresenceDisabled,
required this.maxFileUploadSizeMib,
required String? realmEmptyTopicDisplayName,
required this.realmAllowMessageEditing,
Expand All @@ -532,6 +543,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
required this.typingNotifier,
required UserStoreImpl users,
required this.typingStatus,
required this.presence,
required ChannelStoreImpl channels,
required MessageStoreImpl messages,
required this.unreads,
Expand Down Expand Up @@ -570,12 +582,16 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
////////////////////////////////
// Data attached to the realm or the server.

final int serverPresencePingIntervalSeconds;
final int serverPresenceOfflineThresholdSeconds;

final RealmWildcardMentionPolicy realmWildcardMentionPolicy; // TODO(#668): update this realm setting
final bool realmMandatoryTopics; // TODO(#668): update this realm setting
/// For docs, please see [InitialSnapshot.realmWaitingPeriodThreshold].
final int realmWaitingPeriodThreshold; // TODO(#668): update this realm setting
final bool realmAllowMessageEditing; // TODO(#668): update this realm setting
final int? realmMessageContentEditLimitSeconds; // TODO(#668): update this realm setting
final bool realmPresenceDisabled; // TODO(#668): update this realm setting
final int maxFileUploadSizeMib; // No event for this.

/// The display name to use for empty topics.
Expand Down Expand Up @@ -653,6 +669,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor

final TypingStatus typingStatus;

final Presence presence;

/// Whether [user] has passed the realm's waiting period to be a full member.
///
/// See:
Expand Down Expand Up @@ -1218,6 +1236,7 @@ class UpdateMachine {
// TODO do registerNotificationToken before registerQueue:
// https://github.com/zulip/zulip-flutter/pull/325#discussion_r1365982807
unawaited(updateMachine.registerNotificationToken());
store.presence.start();
return updateMachine;
}

Expand Down
Loading