Skip to content

Commit 6b7ff1d

Browse files
committed
channel: Keep get-stream-topics data up-to-date
Fixes: zulip#1499
1 parent f04f979 commit 6b7ff1d

File tree

5 files changed

+467
-13
lines changed

5 files changed

+467
-13
lines changed

lib/model/channel.dart

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
import 'dart:async';
2+
3+
import 'package:collection/collection.dart';
14
import 'package:flutter/foundation.dart';
25

36
import '../api/model/events.dart';
47
import '../api/model/initial_snapshot.dart';
58
import '../api/model/model.dart';
9+
import '../api/route/channels.dart';
10+
import 'store.dart';
611

712
/// The portion of [PerAccountStore] for channels, topics, and stuff about them.
813
///
@@ -34,6 +39,22 @@ mixin ChannelStore {
3439
/// and [streamsByName].
3540
Map<int, Subscription> get subscriptions;
3641

42+
/// Fetch topics in a stream from the server.
43+
///
44+
/// The results from the last successful fetch
45+
/// can be retrieved with [topicMaxIdsInStream].
46+
Future<void> fetchTopics(int streamId);
47+
48+
/// Pairs of the known topics and its latest message ID, in the given stream.
49+
///
50+
/// The result is guaranteed to be sorted by maxId, and the topics are
51+
/// guaranteed to be distinct. In some cases when message moves occur,
52+
/// the same maxId can be present in multiple topics.
53+
///
54+
/// Returns null if the data has never been fetched yet.
55+
/// To fetch it from the server, use [fetchTopics].
56+
List<TopicMaxId>? topicMaxIdsInStream(int streamId);
57+
3758
/// The visibility policy that the self-user has for the given topic.
3859
///
3960
/// This does not incorporate the user's channel-level policy,
@@ -208,6 +229,31 @@ class ChannelStoreImpl extends PerAccountStoreBase with ChannelStore {
208229
@override
209230
final Map<int, Subscription> subscriptions;
210231

232+
/// Maps indexed by stream IDs, of the the known latest message IDs in each
233+
/// topic.
234+
///
235+
/// For example: `_topicMaxIdsByStream[stream.id][topic] = maxId`.
236+
final Map<int, Map<TopicName, int>> _topicMaxIdsByStream = {};
237+
238+
@override
239+
Future<void> fetchTopics(int streamId) async {
240+
final result = await getStreamTopics(connection, streamId: streamId);
241+
_topicMaxIdsByStream[streamId] = {
242+
for (final GetStreamTopicsEntry(:name, :maxId) in result.topics)
243+
name: maxId,
244+
};
245+
}
246+
247+
@override
248+
List<TopicMaxId>? topicMaxIdsInStream(int streamId) {
249+
final topicMaxIdsInStream = _topicMaxIdsByStream[streamId];
250+
if (topicMaxIdsInStream == null) return null;
251+
return [
252+
for (final MapEntry(:key, :value) in topicMaxIdsInStream.entries)
253+
(topic: key, maxId: value)
254+
].sortedBy((value) => -value.maxId);
255+
}
256+
211257
@override
212258
Map<int, Map<TopicName, UserTopicVisibilityPolicy>> get debugTopicVisibility => topicVisibility;
213259

@@ -374,4 +420,48 @@ class ChannelStoreImpl extends PerAccountStoreBase with ChannelStore {
374420
forStream[event.topicName] = visibilityPolicy;
375421
}
376422
}
423+
424+
/// Handle a [MessageEvent], returning whether listeners should be notified.
425+
bool handleMessageEvent(MessageEvent event) {
426+
if (event.message is! StreamMessage) return false;
427+
final StreamMessage(:streamId, :topic) = event.message as StreamMessage;
428+
429+
final topicMaxIdsInStream = _topicMaxIdsByStream[streamId];
430+
if (topicMaxIdsInStream == null) return false;
431+
assert(!topicMaxIdsInStream.containsKey(topic)
432+
|| topicMaxIdsInStream[topic]! < event.message.id);
433+
topicMaxIdsInStream[topic] = event.message.id;
434+
return true;
435+
}
436+
437+
/// Handle a [UpdateMessageEvent], returning whether listeners should be
438+
/// notified.
439+
bool handleUpdateMessageEvent(UpdateMessageEvent event) {
440+
if (event.moveData == null) return false;
441+
final UpdateMessageMoveData(
442+
:origStreamId, :origTopic, :newStreamId, :newTopic, :propagateMode,
443+
) = event.moveData!;
444+
bool shouldNotify = false;
445+
446+
final topicMaxIdsInOrigStream = _topicMaxIdsByStream[origStreamId];
447+
if (propagateMode == PropagateMode.changeAll
448+
&& topicMaxIdsInOrigStream != null) {
449+
shouldNotify = topicMaxIdsInOrigStream.remove(origTopic) != null;
450+
}
451+
452+
final topicMaxIdsInNewStream = _topicMaxIdsByStream[newStreamId];
453+
if (topicMaxIdsInNewStream != null) {
454+
final movedMaxId = event.messageIds.max;
455+
if (!topicMaxIdsInNewStream.containsKey(newTopic)
456+
|| topicMaxIdsInNewStream[newTopic]! < movedMaxId) {
457+
topicMaxIdsInNewStream[newTopic] = movedMaxId;
458+
shouldNotify = true;
459+
}
460+
}
461+
462+
return shouldNotify;
463+
}
377464
}
465+
466+
/// A topic and its known latest message ID.
467+
typedef TopicMaxId = ({TopicName topic, int maxId});

lib/model/store.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,12 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
693693
Map<String, ZulipStream> get streamsByName => _channels.streamsByName;
694694
@override
695695
Map<int, Subscription> get subscriptions => _channels.subscriptions;
696+
697+
@override
698+
Future<void> fetchTopics(int streamId) => _channels.fetchTopics(streamId);
699+
@override
700+
List<TopicMaxId>? topicMaxIdsInStream(int streamId) =>
701+
_channels.topicMaxIdsInStream(streamId);
696702
@override
697703
UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic) =>
698704
_channels.topicVisibilityPolicy(streamId, topic);
@@ -890,13 +896,15 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
890896
unreads.handleMessageEvent(event);
891897
recentDmConversationsView.handleMessageEvent(event);
892898
recentSenders.handleMessage(event.message); // TODO(#824)
899+
if (_channels.handleMessageEvent(event)) notifyListeners();
893900
// When adding anything here (to handle [MessageEvent]),
894901
// it probably belongs in [reconcileMessages] too.
895902

896903
case UpdateMessageEvent():
897904
assert(debugLog("server event: update_message ${event.messageId}"));
898905
_messages.handleUpdateMessageEvent(event);
899906
unreads.handleUpdateMessageEvent(event);
907+
if (_channels.handleUpdateMessageEvent(event)) notifyListeners();
900908

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

lib/widgets/topic_list.dart

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import 'package:flutter/material.dart';
22

33
import '../api/model/model.dart';
4-
import '../api/route/channels.dart';
54
import '../generated/l10n/zulip_localizations.dart';
5+
import '../model/channel.dart';
66
import '../model/narrow.dart';
77
import '../model/unreads.dart';
88
import 'action_sheet.dart';
@@ -128,9 +128,6 @@ class _TopicList extends StatefulWidget {
128128

129129
class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMixin {
130130
Unreads? unreadsModel;
131-
// TODO(#1499): store the results on [ChannelStore], and keep them
132-
// up-to-date by handling events
133-
List<GetStreamTopicsEntry>? lastFetchedTopics;
134131

135132
@override
136133
void onNewStore() {
@@ -156,26 +153,26 @@ class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMi
156153
// Do nothing when the fetch fails; the topic-list will stay on
157154
// the loading screen, until the user navigates away and back.
158155
// TODO(design) show a nice error message on screen when this fails
159-
final store = PerAccountStoreWidget.of(context);
160-
final result = await getStreamTopics(store.connection,
161-
streamId: widget.streamId);
156+
await PerAccountStoreWidget.of(context).fetchTopics(widget.streamId);
162157
if (!mounted) return;
163158
setState(() {
164-
lastFetchedTopics = result.topics;
159+
// TODO maybe notify listeners from store.fetchTopics
160+
// The actuall state lives in the PerAccountStore
165161
});
166162
}
167163

168164
@override
169165
Widget build(BuildContext context) {
170-
if (lastFetchedTopics == null) {
166+
final store = PerAccountStoreWidget.of(context);
167+
if (store.topicMaxIdsInStream(widget.streamId) == null) {
171168
return const Center(child: CircularProgressIndicator());
172169
}
173170

174171
// TODO(design) handle the rare case when `lastFetchTopics` is empty
175172

176173
// This is adapted from parts of the build method on [_InboxPageState].
177174
final topicItems = <_TopicItemData>[];
178-
for (final GetStreamTopicsEntry(:maxId, name: topic) in lastFetchedTopics!) {
175+
for (final TopicMaxId(:topic, :maxId) in store.topicMaxIdsInStream(widget.streamId)!) {
179176
final unreadMessageIds =
180177
unreadsModel!.streams[widget.streamId]?[topic] ?? <int>[];
181178
final countInTopic = unreadMessageIds.length;
@@ -185,9 +182,6 @@ class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMi
185182
topic: topic,
186183
unreadCount: countInTopic,
187184
hasMention: hasMention,
188-
// `lastFetchedTopics.maxId` can become outdated when a new message
189-
// arrives or when there are message moves, until we re-fetch.
190-
// TODO(#1499): track changes to this
191185
maxId: maxId,
192186
));
193187
}

0 commit comments

Comments
 (0)