Skip to content

Commit 6632f36

Browse files
committed
channel: Keep get-stream-topics data up-to-date
Fixes: #1499
1 parent f0de00e commit 6632f36

File tree

7 files changed

+572
-25
lines changed

7 files changed

+572
-25
lines changed

lib/model/channel.dart

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
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';
11+
12+
final _apiGetStreamTopics = getStreamTopics; // similar to _apiSendMessage in lib/model/message.dart
613

714
/// The portion of [PerAccountStore] for channels, topics, and stuff about them.
815
///
@@ -34,6 +41,26 @@ mixin ChannelStore {
3441
/// and [streamsByName].
3542
Map<int, Subscription> get subscriptions;
3643

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

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

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

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';
@@ -694,6 +695,12 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
694695
Map<String, ZulipStream> get streamsByName => _channels.streamsByName;
695696
@override
696697
Map<int, Subscription> get subscriptions => _channels.subscriptions;
698+
699+
@override
700+
Future<void> fetchTopics(int streamId) => _channels.fetchTopics(streamId);
701+
@override
702+
List<GetStreamTopicsEntry>? getStreamTopics(int streamId) =>
703+
_channels.getStreamTopics(streamId);
697704
@override
698705
UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic) =>
699706
_channels.topicVisibilityPolicy(streamId, topic);
@@ -891,13 +898,15 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
891898
unreads.handleMessageEvent(event);
892899
recentDmConversationsView.handleMessageEvent(event);
893900
recentSenders.handleMessage(event.message); // TODO(#824)
901+
if (_channels.handleMessageEvent(event)) notifyListeners();
894902
// When adding anything here (to handle [MessageEvent]),
895903
// it probably belongs in [reconcileMessages] too.
896904

897905
case UpdateMessageEvent():
898906
assert(debugLog("server event: update_message ${event.messageId}"));
899907
_messages.handleUpdateMessageEvent(event);
900908
unreads.handleUpdateMessageEvent(event);
909+
if (_channels.handleUpdateMessageEvent(event)) notifyListeners();
901910

902911
case DeleteMessageEvent():
903912
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';
@@ -128,16 +129,13 @@ class _TopicList extends StatefulWidget {
128129

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

135133
@override
136134
void onNewStore() {
137135
unreadsModel?.removeListener(_modelChanged);
138136
final store = PerAccountStoreWidget.of(context);
139137
unreadsModel = store.unreads..addListener(_modelChanged);
140-
_fetchTopics();
138+
_fetchTopics(store);
141139
}
142140

143141
@override
@@ -152,31 +150,30 @@ class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMi
152150
});
153151
}
154152

155-
void _fetchTopics() async {
153+
void _fetchTopics(PerAccountStore store) async {
156154
// Do nothing when the fetch fails; the topic-list will stay on
157155
// the loading screen, until the user navigates away and back.
158156
// 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,
162-
allowEmptyTopicName: true);
157+
await store.fetchTopics(widget.streamId);
163158
if (!mounted) return;
164159
setState(() {
165-
lastFetchedTopics = result.topics;
160+
// The actuall state lives in the PerAccountStore
166161
});
167162
}
168163

169164
@override
170165
Widget build(BuildContext context) {
171-
if (lastFetchedTopics == null) {
166+
final store = PerAccountStoreWidget.of(context);
167+
final streamTopics = store.getStreamTopics(widget.streamId);
168+
if (streamTopics == null) {
172169
return const Center(child: CircularProgressIndicator());
173170
}
174171

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

177174
// This is adapted from parts of the build method on [_InboxPageState].
178175
final topicItems = <_TopicItemData>[];
179-
for (final GetStreamTopicsEntry(:maxId, name: topic) in lastFetchedTopics!) {
176+
for (final GetStreamTopicsEntry(:maxId, name: topic) in streamTopics) {
180177
final unreadMessageIds =
181178
unreadsModel!.streams[widget.streamId]?[topic] ?? <int>[];
182179
final countInTopic = unreadMessageIds.length;
@@ -186,9 +183,6 @@ class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMi
186183
topic: topic,
187184
unreadCount: countInTopic,
188185
hasMention: hasMention,
189-
// `lastFetchedTopics.maxId` can become outdated when a new message
190-
// arrives or when there are message moves, until we re-fetch.
191-
// TODO(#1499): track changes to this
192186
maxId: maxId,
193187
));
194188
}
@@ -237,6 +231,14 @@ class _TopicItem extends StatelessWidget {
237231
final isTopicVisibleInStream = store.isTopicVisibleInStream(streamId, topic);
238232
final visibilityIcon = iconDataForTopicVisibilityPolicy(
239233
store.topicVisibilityPolicy(streamId, topic));
234+
// The message with `maxId` might not remain in `topic` since we last fetch
235+
// the list of topics. Make sure we check for that before passing `maxId`
236+
// to the topic action sheet.
237+
// See also: [ChannelStore.getStreamTopics]
238+
final message = store.messages[maxId];
239+
final isMaxIdInTopic = message is StreamMessage
240+
&& message.streamId == streamId
241+
&& message.topic.isSameAs(topic);
240242

241243
final trailingWidgets = [
242244
if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign),
@@ -255,7 +257,7 @@ class _TopicItem extends StatelessWidget {
255257
onLongPress: () => showTopicActionSheet(context,
256258
channelId: streamId,
257259
topic: topic,
258-
someMessageIdInTopic: maxId),
260+
someMessageIdInTopic: isMaxIdInTopic ? maxId : null),
259261
splashFactory: NoSplash.splashFactory,
260262
child: Padding(padding: EdgeInsetsDirectional.fromSTEB(28, 8, 12, 8),
261263
child: Row(

test/api/route/route_checks.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
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

46
extension SendMessageResultChecks on Subject<SendMessageResult> {
57
Subject<int> get id => has((e) => e.id, 'id');
68
}
79

10+
extension GetStreamTopicEntryChecks on Subject<GetStreamTopicsEntry> {
11+
Subject<int> get maxId => has((e) => e.maxId, 'maxId');
12+
Subject<TopicName> get name => has((e) => e.name, 'name');
13+
}
14+
815
// TODO add similar extensions for other classes in api/route/*.dart

0 commit comments

Comments
 (0)