Skip to content

Commit b708052

Browse files
PIG208gnprice
authored andcommitted
channel: Keep get-stream-topics data up-to-date
Fixes: #1499
1 parent 2b0944f commit b708052

File tree

7 files changed

+569
-25
lines changed

7 files changed

+569
-25
lines changed

lib/model/channel.dart

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
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';
811
import 'store.dart';
912
import 'user.dart';
1013

14+
final _apiGetStreamTopics = getStreamTopics; // similar to _apiSendMessage in lib/model/message.dart
15+
1116
/// The portion of [PerAccountStore] for channels, topics, and stuff about them.
1217
///
1318
/// This type is useful for expressing the needs of other parts of the
@@ -38,6 +43,26 @@ mixin ChannelStore on UserStore {
3843
/// and [streamsByName].
3944
Map<int, Subscription> get subscriptions;
4045

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

227+
@override
228+
Future<void> fetchTopics(int streamId) => channelStore.fetchTopics(streamId);
229+
230+
@override
231+
List<GetStreamTopicsEntry>? getStreamTopics(int streamId) =>
232+
channelStore.getStreamTopics(streamId);
233+
202234
@override
203235
UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic) =>
204236
channelStore.topicVisibilityPolicy(streamId, topic);
@@ -260,6 +292,34 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore {
260292
@override
261293
final Map<int, Subscription> subscriptions;
262294

295+
/// Maps indexed by stream IDs, of the known latest message IDs in each topic.
296+
///
297+
/// For example: `_latestMessageIdsByStreamTopic[stream.id][topic] = maxId`
298+
///
299+
/// In some cases, the same message IDs, when affected by message moves, can
300+
/// be present for mutliple stream-topic keys.
301+
final Map<int, Map<TopicName, int>> _latestMessageIdsByStreamTopic = {};
302+
303+
@override
304+
Future<void> fetchTopics(int streamId) async {
305+
final result = await _apiGetStreamTopics(connection, streamId: streamId,
306+
allowEmptyTopicName: true);
307+
_latestMessageIdsByStreamTopic[streamId] = {
308+
for (final GetStreamTopicsEntry(:name, :maxId) in result.topics)
309+
name: maxId,
310+
};
311+
}
312+
313+
@override
314+
List<GetStreamTopicsEntry>? getStreamTopics(int streamId) {
315+
final latestMessageIdsInStream = _latestMessageIdsByStreamTopic[streamId];
316+
if (latestMessageIdsInStream == null) return null;
317+
return [
318+
for (final MapEntry(:key, :value) in latestMessageIdsInStream.entries)
319+
GetStreamTopicsEntry(maxId: value, name: key),
320+
].sortedBy((value) => -value.maxId);
321+
}
322+
263323
@override
264324
Map<int, TopicKeyedMap<UserTopicVisibilityPolicy>> get debugTopicVisibility => topicVisibility;
265325

@@ -425,6 +485,47 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore {
425485
forStream[event.topicName] = visibilityPolicy;
426486
}
427487
}
488+
489+
/// Handle a [MessageEvent], returning whether listeners should be notified.
490+
bool handleMessageEvent(MessageEvent event) {
491+
if (event.message is! StreamMessage) return false;
492+
final StreamMessage(:streamId, :topic) = event.message as StreamMessage;
493+
494+
final latestMessageIdsByTopic = _latestMessageIdsByStreamTopic[streamId];
495+
if (latestMessageIdsByTopic == null) return false;
496+
assert(!latestMessageIdsByTopic.containsKey(topic)
497+
|| latestMessageIdsByTopic[topic]! < event.message.id);
498+
latestMessageIdsByTopic[topic] = event.message.id;
499+
return true;
500+
}
501+
502+
/// Handle a [UpdateMessageEvent], returning whether listeners should be
503+
/// notified.
504+
bool handleUpdateMessageEvent(UpdateMessageEvent event) {
505+
if (event.moveData == null) return false;
506+
final UpdateMessageMoveData(
507+
:origStreamId, :origTopic, :newStreamId, :newTopic, :propagateMode,
508+
) = event.moveData!;
509+
bool shouldNotify = false;
510+
511+
final origLatestMessageIdsByTopics = _latestMessageIdsByStreamTopic[origStreamId];
512+
if (propagateMode == PropagateMode.changeAll
513+
&& origLatestMessageIdsByTopics != null) {
514+
shouldNotify = origLatestMessageIdsByTopics.remove(origTopic) != null;
515+
}
516+
517+
final newLatestMessageIdsByTopics = _latestMessageIdsByStreamTopic[newStreamId];
518+
if (newLatestMessageIdsByTopics != null) {
519+
final movedMaxId = event.messageIds.max;
520+
if (!newLatestMessageIdsByTopics.containsKey(newTopic)
521+
|| newLatestMessageIdsByTopics[newTopic]! < movedMaxId) {
522+
newLatestMessageIdsByTopics[newTopic] = movedMaxId;
523+
shouldNotify = true;
524+
}
525+
}
526+
527+
return shouldNotify;
528+
}
428529
}
429530

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

lib/model/store.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,13 +781,15 @@ class PerAccountStore extends PerAccountStoreBase with
781781
unreads.handleMessageEvent(event);
782782
recentDmConversationsView.handleMessageEvent(event);
783783
recentSenders.handleMessage(event.message); // TODO(#824)
784+
if (_channels.handleMessageEvent(event)) notifyListeners();
784785
// When adding anything here (to handle [MessageEvent]),
785786
// it probably belongs in [reconcileMessages] too.
786787

787788
case UpdateMessageEvent():
788789
assert(debugLog("server event: update_message ${event.messageId}"));
789790
_messages.handleUpdateMessageEvent(event);
790791
unreads.handleUpdateMessageEvent(event);
792+
if (_channels.handleUpdateMessageEvent(event)) notifyListeners();
791793

792794
case DeleteMessageEvent():
793795
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)