Skip to content

Commit 5d43df2

Browse files
chrisbobbegnprice
authored andcommitted
store: Add Presence model, storing and reporting presence
We plan to write tests for this as a followup: #1620. Notable differences from zulip-mobile: - Here, we make report-presence requests more frequently: our "app state" listener triggers a request immediately, instead of scheduling it when the "ping interval" expires. This approach anticipates the requests being handled much more efficiently, with presence_last_update_id (#1611) -- but it shouldn't regress on performance now, because these immediate requests are done (for now) as "ping only", i.e., asking the server not to compute a presence data payload. - 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 Fixes: #196
1 parent 3284ea5 commit 5d43df2

File tree

3 files changed

+195
-2
lines changed

3 files changed

+195
-2
lines changed

lib/model/presence.dart

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import 'dart:async';
2+
3+
import 'package:flutter/scheduler.dart';
4+
import 'package:flutter/widgets.dart';
5+
6+
import '../api/model/events.dart';
7+
import '../api/model/model.dart';
8+
import '../api/route/users.dart';
9+
import 'store.dart';
10+
11+
/// The model for tracking which users are online, idle, and offline.
12+
///
13+
/// Use [presenceStatusForUser]. If that returns null, the user is offline.
14+
///
15+
/// This substore is its own [ChangeNotifier],
16+
/// so callers need to remember to add a listener (and remove it on dispose).
17+
/// In particular, [PerAccountStoreWidget] doesn't subscribe a widget subtree
18+
/// to updates.
19+
class Presence extends PerAccountStoreBase with ChangeNotifier {
20+
Presence({
21+
required super.core,
22+
required this.serverPresencePingInterval,
23+
required this.serverPresenceOfflineThresholdSeconds,
24+
required this.realmPresenceDisabled,
25+
required Map<int, PerUserPresence> initial,
26+
}) : _map = initial;
27+
28+
final Duration serverPresencePingInterval;
29+
final int serverPresenceOfflineThresholdSeconds;
30+
// TODO(#668): update this realm setting (probably by accessing it from a new
31+
// realm/server-settings substore that gets passed to Presence)
32+
final bool realmPresenceDisabled;
33+
34+
Map<int, PerUserPresence> _map;
35+
36+
AppLifecycleListener? _appLifecycleListener;
37+
38+
void _handleLifecycleStateChange(AppLifecycleState newState) {
39+
assert(!_disposed); // We remove the listener in [dispose].
40+
41+
// Since this handler can cause multiple requests within a
42+
// serverPresencePingInterval period, we pass `pingOnly: true`, for now, because:
43+
// - This makes the request cheap for the server.
44+
// - We don't want to record stale presence data when responses arrive out
45+
// of order. This handler would increase the risk of that by potentially
46+
// sending requests more frequently than serverPresencePingInterval.
47+
// (`pingOnly: true` causes presence data to be omitted in the response.)
48+
// TODO(#1611) Both of these reasons can be easily addressed by passing
49+
// lastUpdateId. Do that, and stop sending `pingOnly: true`.
50+
// (For the latter point, we'd ignore responses with a stale lastUpdateId.)
51+
_maybePingAndRecordResponse(newState, pingOnly: true);
52+
}
53+
54+
bool _hasStarted = false;
55+
56+
void start() async {
57+
if (!debugEnable) return;
58+
if (_hasStarted) {
59+
throw StateError('Presence.start should only be called once.');
60+
}
61+
_hasStarted = true;
62+
63+
_appLifecycleListener = AppLifecycleListener(
64+
onStateChange: _handleLifecycleStateChange);
65+
66+
_poll();
67+
}
68+
69+
Future<void> _maybePingAndRecordResponse(AppLifecycleState? appLifecycleState, {
70+
required bool pingOnly,
71+
}) async {
72+
if (realmPresenceDisabled) return;
73+
74+
final UpdatePresenceResult result;
75+
switch (appLifecycleState) {
76+
case null:
77+
case AppLifecycleState.hidden:
78+
case AppLifecycleState.paused:
79+
// No presence update.
80+
return;
81+
case AppLifecycleState.detached:
82+
// > The application is still hosted by a Flutter engine but is
83+
// > detached from any host views.
84+
// TODO see if this actually works as a way to send an "idle" update
85+
// when the user closes the app completely.
86+
result = await updatePresence(connection,
87+
pingOnly: pingOnly,
88+
status: PresenceStatus.idle,
89+
newUserInput: false);
90+
case AppLifecycleState.resumed:
91+
// > […] the default running mode for a running application that has
92+
// > input focus and is visible.
93+
result = await updatePresence(connection,
94+
pingOnly: pingOnly,
95+
status: PresenceStatus.active,
96+
newUserInput: true);
97+
case AppLifecycleState.inactive:
98+
// > At least one view of the application is visible, but none have
99+
// > input focus. The application is otherwise running normally.
100+
// For example, we expect this state when the user is selecting a file
101+
// to upload.
102+
result = await updatePresence(connection,
103+
pingOnly: pingOnly,
104+
status: PresenceStatus.active,
105+
newUserInput: false);
106+
}
107+
if (!pingOnly) {
108+
_map = result.presences!;
109+
notifyListeners();
110+
}
111+
}
112+
113+
void _poll() async {
114+
assert(!_disposed);
115+
while (true) {
116+
// We put the wait upfront because we already have data when [start] is
117+
// called; it comes from /register.
118+
await Future<void>.delayed(serverPresencePingInterval);
119+
if (_disposed) return;
120+
121+
await _maybePingAndRecordResponse(
122+
SchedulerBinding.instance.lifecycleState, pingOnly: false);
123+
if (_disposed) return;
124+
}
125+
}
126+
127+
bool _disposed = false;
128+
129+
@override
130+
void dispose() {
131+
_appLifecycleListener?.dispose();
132+
_disposed = true;
133+
super.dispose();
134+
}
135+
136+
/// The [PresenceStatus] for [userId], or null if the user is offline.
137+
PresenceStatus? presenceStatusForUser(int userId, {required DateTime utcNow}) {
138+
final now = utcNow.millisecondsSinceEpoch ~/ 1000;
139+
final perUserPresence = _map[userId];
140+
if (perUserPresence == null) return null;
141+
final PerUserPresence(:activeTimestamp, :idleTimestamp) = perUserPresence;
142+
143+
if (now - activeTimestamp <= serverPresenceOfflineThresholdSeconds) {
144+
return PresenceStatus.active;
145+
} else if (now - idleTimestamp <= serverPresenceOfflineThresholdSeconds) {
146+
// The API doc is kind of confusing, but this seems correct:
147+
// https://chat.zulip.org/#narrow/channel/378-api-design/topic/presence.3A.20.22potentially.20present.22.3F/near/2202431
148+
// TODO clarify that API doc
149+
return PresenceStatus.idle;
150+
} else {
151+
return null;
152+
}
153+
}
154+
155+
void handlePresenceEvent(PresenceEvent event) {
156+
// TODO(#1618)
157+
}
158+
159+
/// In debug mode, controls whether presence requests are made.
160+
///
161+
/// Outside of debug mode, this is always true and the setter has no effect.
162+
static bool get debugEnable {
163+
bool result = true;
164+
assert(() {
165+
result = _debugEnable;
166+
return true;
167+
}());
168+
return result;
169+
}
170+
static bool _debugEnable = true;
171+
static set debugEnable(bool value) {
172+
assert(() {
173+
_debugEnable = value;
174+
return true;
175+
}());
176+
}
177+
178+
@visibleForTesting
179+
static void debugReset() {
180+
debugEnable = true;
181+
}
182+
}

lib/model/store.dart

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import 'emoji.dart';
2626
import 'localizations.dart';
2727
import 'message.dart';
2828
import 'message_list.dart';
29+
import 'presence.dart';
2930
import 'recent_dm_conversations.dart';
3031
import 'recent_senders.dart';
3132
import 'channel.dart';
@@ -501,8 +502,12 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
501502
),
502503
users: UserStoreImpl(core: core, initialSnapshot: initialSnapshot),
503504
typingStatus: TypingStatus(core: core,
504-
typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds),
505-
),
505+
typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds)),
506+
presence: Presence(core: core,
507+
serverPresencePingInterval: Duration(seconds: initialSnapshot.serverPresencePingIntervalSeconds),
508+
serverPresenceOfflineThresholdSeconds: initialSnapshot.serverPresenceOfflineThresholdSeconds,
509+
realmPresenceDisabled: initialSnapshot.realmPresenceDisabled,
510+
initial: initialSnapshot.presences),
506511
channels: channels,
507512
messages: MessageStoreImpl(core: core,
508513
realmEmptyTopicDisplayName: initialSnapshot.realmEmptyTopicDisplayName),
@@ -538,6 +543,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
538543
required this.typingNotifier,
539544
required UserStoreImpl users,
540545
required this.typingStatus,
546+
required this.presence,
541547
required ChannelStoreImpl channels,
542548
required MessageStoreImpl messages,
543549
required this.unreads,
@@ -663,6 +669,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
663669

664670
final TypingStatus typingStatus;
665671

672+
final Presence presence;
673+
666674
/// Whether [user] has passed the realm's waiting period to be a full member.
667675
///
668676
/// See:
@@ -1228,6 +1236,7 @@ class UpdateMachine {
12281236
// TODO do registerNotificationToken before registerQueue:
12291237
// https://github.com/zulip/zulip-flutter/pull/325#discussion_r1365982807
12301238
unawaited(updateMachine.registerNotificationToken());
1239+
store.presence.start();
12311240
return updateMachine;
12321241
}
12331242

test/model/store_test.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import 'package:zulip/api/route/messages.dart';
1717
import 'package:zulip/api/route/realm.dart';
1818
import 'package:zulip/log.dart';
1919
import 'package:zulip/model/actions.dart';
20+
import 'package:zulip/model/presence.dart';
2021
import 'package:zulip/model/store.dart';
2122
import 'package:zulip/notifications/receive.dart';
2223

@@ -31,6 +32,7 @@ import 'test_store.dart';
3132

3233
void main() {
3334
TestZulipBinding.ensureInitialized();
35+
Presence.debugEnable = false;
3436

3537
final account1 = eg.selfAccount.copyWith(id: 1);
3638
final account2 = eg.otherAccount.copyWith(id: 2);

0 commit comments

Comments
 (0)