Skip to content

Conversation

@sm-sayedi
Copy link
Collaborator

#1508 but rebased on top of main with the review of @gnprice addressed.

(Thanks @PIG208 for all of your previous work in #1508)

Fixes: #1499

@sm-sayedi sm-sayedi added the maintainer review PR ready for review by Zulip maintainers label Oct 28, 2025
@sm-sayedi sm-sayedi requested a review from chrisbobbe October 28, 2025 21:25
Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Glad to be maintaining a data structure for this. 🙂

Comments below, from reading the implementation in the first commit (I haven't yet read the tests):

d6b2242 channel: Keep track of channel topics, and keep up-to-date with events

import 'store.dart';
import 'user.dart';

final _apiGetChannelTopics = getStreamTopics; // similar to _apiSendMessage in lib/model/message.dart
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: too-long line; put comment on line above

/// can be retrieved with [getChannelTopics].
Future<void> fetchTopics(int channelId);

/// Pairs of the known topics and its latest message ID, in the given channel.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Pairs of the known topics and its latest message ID, in the given channel.
/// The topics in the given channel, along with their latest message ID.


/// Pairs of the known topics and its latest message ID, in the given channel.
///
/// Returns null if the data has never been fetched yet.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
/// Returns null if the data has never been fetched yet.
/// Returns null if the data has not been fetched yet.

Comment on lines 97 to 98
/// The result is guaranteed to be sorted by [GetStreamTopicsEntry.maxId]
/// descending, and the topics are guaranteed to be distinct.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, omit needless words:

Suggested change
/// The result is guaranteed to be sorted by [GetStreamTopicsEntry.maxId]
/// descending, and the topics are guaranteed to be distinct.
/// The result is sorted by [GetStreamTopicsEntry.maxId] descending,
/// and the topics are distinct.

Comment on lines 100 to 104
/// In some cases, the same maxId affected by message moves can be present in
/// multiple [GetStreamTopicsEntry] entries. For this reason, the caller
/// should not rely on [getChannelTopics] to determine which topic the message
/// is in. Instead, refer to [PerAccountStore.messages].
/// See [handleUpdateMessageEvent] on how this could happen.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems worth highlighting in general that maxId might be…incorrect, basically. (Not just that two topics might have the same maxId.) Maybe something like:

(Also, isn't message deletion another reason maxId could be wrong?)

Suggested change
/// In some cases, the same maxId affected by message moves can be present in
/// multiple [GetStreamTopicsEntry] entries. For this reason, the caller
/// should not rely on [getChannelTopics] to determine which topic the message
/// is in. Instead, refer to [PerAccountStore.messages].
/// See [handleUpdateMessageEvent] on how this could happen.
/// Occasionally, [GetStreamTopicsEntry.maxId] will refer to a message
/// that doesn't exist or is no longer in the topic.
/// This happens when a topic's latest message is moved or deleted
/// and we don't have enough information
/// to replace [GetStreamTopicsEntry.maxId] accurately.
/// (We don't keep a snapshot of all messages.)
/// Use [PerAccountStore.messages] to check a message's topic accurately.

/// Handle a [MessageEvent], returning whether listeners should be notified.
bool handleMessageEvent(MessageEvent event) {
if (event.message is! StreamMessage) return false;
final StreamMessage(:streamId, :topic) = event.message as StreamMessage;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
final StreamMessage(:streamId, :topic) = event.message as StreamMessage;
final StreamMessage(streamId: channelId, :topic) = event.message as StreamMessage;

Comment on lines 653 to 655
// If this message is already the latest message in the topic because it was
// received through fetch in fetch/event race, or it is a message sent even
// before the latest message of the fetch, we don't do the update.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm getting confused trying to parse this sentence. Instead, how about, inside the if just below this, say something like:

// The event raced with a message fetch.

Comment on lines 646 to 651
// If we don't already know about the list of topics of the channel this
// message belongs to, we don't want to proceed and put one entry about the
// topic of this message, otherwise [fetchTopics] and the callers of
// [getChannelTopics] would assume that the channel only has this one topic
// and would never fetch the complete list of topics for that matter.
if (latestMessageIdsByTopic == null) return false;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about:

Suggested change
// If we don't already know about the list of topics of the channel this
// message belongs to, we don't want to proceed and put one entry about the
// topic of this message, otherwise [fetchTopics] and the callers of
// [getChannelTopics] would assume that the channel only has this one topic
// and would never fetch the complete list of topics for that matter.
if (latestMessageIdsByTopic == null) return false;
if (latestMessageIdsByTopic == null) {
// We're not tracking this channel's topics yet.
// We start doing that when [fetchTopics] is called,
// and we fill in all the topics at that time.
return false;
}

Comment on lines 673 to 689
final origLatestMessageIdsByTopics = _latestMessageIdsByChannelTopic[origStreamId];
// We only handle the case where all the messages of [origTopic] are
// moved to [newTopic]; in that case we can remove [origTopic] safely.
// But if only one messsage is moved (`PropagateMode.changeOne`) or a few
// messages are moved (`PropagateMode.changeLater`), we cannot do anything
// about [origTopic] here as we cannot determine the new `maxId` for it.
// (This is the case where there could be multiple channel-topic keys with
// the same `maxId`)
if (propagateMode == PropagateMode.changeAll
&& origLatestMessageIdsByTopics != null) {
shouldNotify = origLatestMessageIdsByTopics.remove(origTopic) != null;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With a switch/case on propagateMode, I think we can be less verbose in the comment, e.g.:

Suggested change
final origLatestMessageIdsByTopics = _latestMessageIdsByChannelTopic[origStreamId];
// We only handle the case where all the messages of [origTopic] are
// moved to [newTopic]; in that case we can remove [origTopic] safely.
// But if only one messsage is moved (`PropagateMode.changeOne`) or a few
// messages are moved (`PropagateMode.changeLater`), we cannot do anything
// about [origTopic] here as we cannot determine the new `maxId` for it.
// (This is the case where there could be multiple channel-topic keys with
// the same `maxId`)
if (propagateMode == PropagateMode.changeAll
&& origLatestMessageIdsByTopics != null) {
shouldNotify = origLatestMessageIdsByTopics.remove(origTopic) != null;
}
final origLatestMessageIdsByTopics = _latestMessageIdsByChannelTopic[origStreamId];
switch (propagateMode) {
case PropagateMode.changeOne:
case PropagateMode.changeLater:
// We can't know the new `maxId` for the original topic.
// Shrug; leave it unchanged. (See dartdoc of [getChannelTopics],
// where we call out this possibility that `maxId` is incorrect.
break;
case PropagateMode.changeAll:
if (origLatestMessageIdsByTopics != null) {
origLatestMessageIdsByTopics.remove(origTopic);
shouldNotify = true;
}
}

Comment on lines 686 to 699
final newLatestMessageIdsByTopics = _latestMessageIdsByChannelTopic[newStreamId];
if (newLatestMessageIdsByTopics != null) {
final movedMaxId = event.messageIds.max;
if (!newLatestMessageIdsByTopics.containsKey(newTopic)
|| newLatestMessageIdsByTopics[newTopic]! < movedMaxId) {
newLatestMessageIdsByTopics[newTopic] = movedMaxId;
shouldNotify = true;
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit easier to read, I think:

Suggested change
final newLatestMessageIdsByTopics = _latestMessageIdsByChannelTopic[newStreamId];
if (newLatestMessageIdsByTopics != null) {
final movedMaxId = event.messageIds.max;
if (!newLatestMessageIdsByTopics.containsKey(newTopic)
|| newLatestMessageIdsByTopics[newTopic]! < movedMaxId) {
newLatestMessageIdsByTopics[newTopic] = movedMaxId;
shouldNotify = true;
}
}
final newLatestMessageIdsByTopics = _latestMessageIdsByChannelTopic[newStreamId];
if (newLatestMessageIdsByTopics != null) {
final movedMaxId = event.messageIds.max;
final currentMaxId = newLatestMessageIdsByTopics[newTopic];
if (currentMaxId == null || currentMaxId < movedMaxId) {
newLatestMessageIdsByTopics[newTopic] = movedMaxId;
shouldNotify = true;
}
}

@sm-sayedi
Copy link
Collaborator Author

Thanks @chrisbobbe for the review. Pushed new revision.

@sm-sayedi sm-sayedi requested a review from chrisbobbe November 4, 2025 19:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintainer review PR ready for review by Zulip maintainers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Keep topic-list page updated, by tracking topics in ChannelStore

3 participants