Skip to content

Commit 355312b

Browse files
PIG208gnprice
authored andcommitted
channel: Keep get-stream-topics data up-to-date
Fixes: #1499
1 parent 89e1ac4 commit 355312b

File tree

7 files changed

+570
-25
lines changed

7 files changed

+570
-25
lines changed

lib/model/channel.dart

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1+
import 'dart:async';
12
import 'dart:collection';
23

4+
import 'package:collection/collection.dart';
35
import 'package:flutter/foundation.dart';
46

57
import '../api/model/events.dart';
68
import '../api/model/initial_snapshot.dart';
79
import '../api/model/model.dart';
10+
import '../api/route/channels.dart';
11+
import 'store.dart';
12+
13+
final _apiGetStreamTopics = getStreamTopics; // similar to _apiSendMessage in lib/model/message.dart
814

915
/// The portion of [PerAccountStore] for channels, topics, and stuff about them.
1016
///
@@ -36,6 +42,26 @@ mixin ChannelStore {
3642
/// and [streamsByName].
3743
Map<int, Subscription> get subscriptions;
3844

45+
/// Fetch topics in a stream from the server.
46+
///
47+
/// The results from the last successful fetch
48+
/// can be retrieved with [getStreamTopics].
49+
Future<void> fetchTopics(int streamId);
50+
51+
/// Pairs of the known topics and its latest message ID, in the given stream.
52+
///
53+
/// Returns null if the data has never been fetched yet.
54+
/// To fetch it from the server, use [fetchTopics].
55+
///
56+
/// The result is guaranteed to be sorted by [GetStreamTopicsEntry.maxId], and the
57+
/// topics are guaranteed to be distinct.
58+
///
59+
/// In some cases, the same maxId affected by message moves can be present in
60+
/// multiple [GetStreamTopicsEntry] entries. For this reason, the caller
61+
/// should not rely on [getStreamTopics] to determine which topic the message
62+
/// is in. Instead, refer to [PerAccountStore.messages].
63+
List<GetStreamTopicsEntry>? getStreamTopics(int streamId);
64+
3965
/// The visibility policy that the self-user has for the given topic.
4066
///
4167
/// This does not incorporate the user's channel-level policy,
@@ -212,6 +238,34 @@ class ChannelStoreImpl extends PerAccountStoreBase with ChannelStore {
212238
@override
213239
final Map<int, Subscription> subscriptions;
214240

241+
/// Maps indexed by stream IDs, of the known latest message IDs in each topic.
242+
///
243+
/// For example: `_latestMessageIdsByStreamTopic[stream.id][topic] = maxId`
244+
///
245+
/// In some cases, the same message IDs, when affected by message moves, can
246+
/// be present for mutliple stream-topic keys.
247+
final Map<int, Map<TopicName, int>> _latestMessageIdsByStreamTopic = {};
248+
249+
@override
250+
Future<void> fetchTopics(int streamId) async {
251+
final result = await _apiGetStreamTopics(connection, streamId: streamId,
252+
allowEmptyTopicName: true);
253+
_latestMessageIdsByStreamTopic[streamId] = {
254+
for (final GetStreamTopicsEntry(:name, :maxId) in result.topics)
255+
name: maxId,
256+
};
257+
}
258+
259+
@override
260+
List<GetStreamTopicsEntry>? getStreamTopics(int streamId) {
261+
final latestMessageIdsInStream = _latestMessageIdsByStreamTopic[streamId];
262+
if (latestMessageIdsInStream == null) return null;
263+
return [
264+
for (final MapEntry(:key, :value) in latestMessageIdsInStream.entries)
265+
GetStreamTopicsEntry(maxId: value, name: key),
266+
].sortedBy((value) => -value.maxId);
267+
}
268+
215269
@override
216270
Map<int, TopicKeyedMap<UserTopicVisibilityPolicy>> get debugTopicVisibility => topicVisibility;
217271

@@ -377,6 +431,47 @@ class ChannelStoreImpl extends PerAccountStoreBase with ChannelStore {
377431
forStream[event.topicName] = visibilityPolicy;
378432
}
379433
}
434+
435+
/// Handle a [MessageEvent], returning whether listeners should be notified.
436+
bool handleMessageEvent(MessageEvent event) {
437+
if (event.message is! StreamMessage) return false;
438+
final StreamMessage(:streamId, :topic) = event.message as StreamMessage;
439+
440+
final latestMessageIdsByTopic = _latestMessageIdsByStreamTopic[streamId];
441+
if (latestMessageIdsByTopic == null) return false;
442+
assert(!latestMessageIdsByTopic.containsKey(topic)
443+
|| latestMessageIdsByTopic[topic]! < event.message.id);
444+
latestMessageIdsByTopic[topic] = event.message.id;
445+
return true;
446+
}
447+
448+
/// Handle a [UpdateMessageEvent], returning whether listeners should be
449+
/// notified.
450+
bool handleUpdateMessageEvent(UpdateMessageEvent event) {
451+
if (event.moveData == null) return false;
452+
final UpdateMessageMoveData(
453+
:origStreamId, :origTopic, :newStreamId, :newTopic, :propagateMode,
454+
) = event.moveData!;
455+
bool shouldNotify = false;
456+
457+
final origLatestMessageIdsByTopics = _latestMessageIdsByStreamTopic[origStreamId];
458+
if (propagateMode == PropagateMode.changeAll
459+
&& origLatestMessageIdsByTopics != null) {
460+
shouldNotify = origLatestMessageIdsByTopics.remove(origTopic) != null;
461+
}
462+
463+
final newLatestMessageIdsByTopics = _latestMessageIdsByStreamTopic[newStreamId];
464+
if (newLatestMessageIdsByTopics != null) {
465+
final movedMaxId = event.messageIds.max;
466+
if (!newLatestMessageIdsByTopics.containsKey(newTopic)
467+
|| newLatestMessageIdsByTopics[newTopic]! < movedMaxId) {
468+
newLatestMessageIdsByTopics[newTopic] = movedMaxId;
469+
shouldNotify = true;
470+
}
471+
}
472+
473+
return shouldNotify;
474+
}
380475
}
381476

382477
/// A [Map] with [TopicName] keys and [V] values.

lib/model/store.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import '../api/exception.dart';
1313
import '../api/model/events.dart';
1414
import '../api/model/initial_snapshot.dart';
1515
import '../api/model/model.dart';
16+
import '../api/route/channels.dart';
1617
import '../api/route/events.dart';
1718
import '../api/route/messages.dart';
1819
import '../api/backoff.dart';
@@ -759,6 +760,12 @@ class PerAccountStore extends PerAccountStoreBase with
759760
Map<String, ZulipStream> get streamsByName => _channels.streamsByName;
760761
@override
761762
Map<int, Subscription> get subscriptions => _channels.subscriptions;
763+
764+
@override
765+
Future<void> fetchTopics(int streamId) => _channels.fetchTopics(streamId);
766+
@override
767+
List<GetStreamTopicsEntry>? getStreamTopics(int streamId) =>
768+
_channels.getStreamTopics(streamId);
762769
@override
763770
UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic) =>
764771
_channels.topicVisibilityPolicy(streamId, topic);
@@ -986,13 +993,15 @@ class PerAccountStore extends PerAccountStoreBase with
986993
unreads.handleMessageEvent(event);
987994
recentDmConversationsView.handleMessageEvent(event);
988995
recentSenders.handleMessage(event.message); // TODO(#824)
996+
if (_channels.handleMessageEvent(event)) notifyListeners();
989997
// When adding anything here (to handle [MessageEvent]),
990998
// it probably belongs in [reconcileMessages] too.
991999

9921000
case UpdateMessageEvent():
9931001
assert(debugLog("server event: update_message ${event.messageId}"));
9941002
_messages.handleUpdateMessageEvent(event);
9951003
unreads.handleUpdateMessageEvent(event);
1004+
if (_channels.handleUpdateMessageEvent(event)) notifyListeners();
9961005

9971006
case DeleteMessageEvent():
9981007
assert(debugLog("server event: delete_message ${event.messageIds}"));

lib/widgets/topic_list.dart

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import '../api/model/model.dart';
44
import '../api/route/channels.dart';
55
import '../generated/l10n/zulip_localizations.dart';
66
import '../model/narrow.dart';
7+
import '../model/store.dart';
78
import '../model/unreads.dart';
89
import 'action_sheet.dart';
910
import 'app_bar.dart';
@@ -121,16 +122,13 @@ class _TopicList extends StatefulWidget {
121122

122123
class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMixin {
123124
Unreads? unreadsModel;
124-
// TODO(#1499): store the results on [ChannelStore], and keep them
125-
// up-to-date by handling events
126-
List<GetStreamTopicsEntry>? lastFetchedTopics;
127125

128126
@override
129127
void onNewStore() {
130128
unreadsModel?.removeListener(_modelChanged);
131129
final store = PerAccountStoreWidget.of(context);
132130
unreadsModel = store.unreads..addListener(_modelChanged);
133-
_fetchTopics();
131+
_fetchTopics(store);
134132
}
135133

136134
@override
@@ -145,31 +143,30 @@ class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMi
145143
});
146144
}
147145

148-
void _fetchTopics() async {
146+
void _fetchTopics(PerAccountStore store) async {
149147
// Do nothing when the fetch fails; the topic-list will stay on
150148
// the loading screen, until the user navigates away and back.
151149
// TODO(design) show a nice error message on screen when this fails
152-
final store = PerAccountStoreWidget.of(context);
153-
final result = await getStreamTopics(store.connection,
154-
streamId: widget.streamId,
155-
allowEmptyTopicName: true);
150+
await store.fetchTopics(widget.streamId);
156151
if (!mounted) return;
157152
setState(() {
158-
lastFetchedTopics = result.topics;
153+
// The actuall state lives in the PerAccountStore
159154
});
160155
}
161156

162157
@override
163158
Widget build(BuildContext context) {
164-
if (lastFetchedTopics == null) {
159+
final store = PerAccountStoreWidget.of(context);
160+
final streamTopics = store.getStreamTopics(widget.streamId);
161+
if (streamTopics == null) {
165162
return const Center(child: CircularProgressIndicator());
166163
}
167164

168165
// TODO(design) handle the rare case when `lastFetchedTopics` is empty
169166

170167
// This is adapted from parts of the build method on [_InboxPageState].
171168
final topicItems = <_TopicItemData>[];
172-
for (final GetStreamTopicsEntry(:maxId, name: topic) in lastFetchedTopics!) {
169+
for (final GetStreamTopicsEntry(:maxId, name: topic) in streamTopics) {
173170
final unreadMessageIds =
174171
unreadsModel!.streams[widget.streamId]?[topic] ?? <int>[];
175172
final countInTopic = unreadMessageIds.length;
@@ -179,9 +176,6 @@ class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMi
179176
topic: topic,
180177
unreadCount: countInTopic,
181178
hasMention: hasMention,
182-
// `lastFetchedTopics.maxId` can become outdated when a new message
183-
// arrives or when there are message moves, until we re-fetch.
184-
// TODO(#1499): track changes to this
185179
maxId: maxId,
186180
));
187181
}
@@ -231,6 +225,14 @@ class _TopicItem extends StatelessWidget {
231225

232226
final store = PerAccountStoreWidget.of(context);
233227
final designVariables = DesignVariables.of(context);
228+
// The message with `maxId` might not remain in `topic` since we last fetch
229+
// the list of topics. Make sure we check for that before passing `maxId`
230+
// to the topic action sheet.
231+
// See also: [ChannelStore.getStreamTopics]
232+
final message = store.messages[maxId];
233+
final isMaxIdInTopic = message is StreamMessage
234+
&& message.streamId == streamId
235+
&& message.topic.isSameAs(topic);
234236

235237
final visibilityPolicy = store.topicVisibilityPolicy(streamId, topic);
236238
final double opacity;
@@ -259,7 +261,7 @@ class _TopicItem extends StatelessWidget {
259261
onLongPress: () => showTopicActionSheet(context,
260262
channelId: streamId,
261263
topic: topic,
262-
someMessageIdInTopic: maxId),
264+
someMessageIdInTopic: isMaxIdInTopic ? maxId : null),
263265
splashFactory: NoSplash.splashFactory,
264266
child: Padding(padding: EdgeInsetsDirectional.fromSTEB(6, 8, 12, 8),
265267
child: Row(

test/api/route/route_checks.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import 'package:checks/checks.dart';
2+
import 'package:zulip/api/model/model.dart';
3+
import 'package:zulip/api/route/channels.dart';
24
import 'package:zulip/api/route/messages.dart';
35
import 'package:zulip/api/route/saved_snippets.dart';
46

@@ -9,4 +11,9 @@ extension CreateSavedSnippetResultChecks on Subject<CreateSavedSnippetResult> {
911
Subject<int> get savedSnippetId => has((e) => e.savedSnippetId, 'savedSnippetId');
1012
}
1113

14+
extension GetStreamTopicEntryChecks on Subject<GetStreamTopicsEntry> {
15+
Subject<int> get maxId => has((e) => e.maxId, 'maxId');
16+
Subject<TopicName> get name => has((e) => e.name, 'name');
17+
}
18+
1219
// TODO add similar extensions for other classes in api/route/*.dart

0 commit comments

Comments
 (0)