Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions lib/api/route/channels.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,43 +41,43 @@ Future<void> unsubscribeFromChannel(ApiConnection connection, {
}

/// https://zulip.com/api/get-stream-topics
Future<GetStreamTopicsResult> getStreamTopics(ApiConnection connection, {
required int streamId,
Future<GetChannelTopicsResult> getChannelTopics(ApiConnection connection, {
required int channelId,
required bool allowEmptyTopicName,
}) {
assert(allowEmptyTopicName, '`allowEmptyTopicName` should only be true');
return connection.get('getStreamTopics', GetStreamTopicsResult.fromJson, 'users/me/$streamId/topics', {
return connection.get('getChannelTopics', GetChannelTopicsResult.fromJson, 'users/me/$channelId/topics', {
'allow_empty_topic_name': allowEmptyTopicName,
});
}

@JsonSerializable(fieldRename: FieldRename.snake)
class GetStreamTopicsResult {
final List<GetStreamTopicsEntry> topics;
class GetChannelTopicsResult {
final List<GetChannelTopicsEntry> topics;

GetStreamTopicsResult({
GetChannelTopicsResult({
required this.topics,
});

factory GetStreamTopicsResult.fromJson(Map<String, dynamic> json) =>
_$GetStreamTopicsResultFromJson(json);
factory GetChannelTopicsResult.fromJson(Map<String, dynamic> json) =>
_$GetChannelTopicsResultFromJson(json);

Map<String, dynamic> toJson() => _$GetStreamTopicsResultToJson(this);
Map<String, dynamic> toJson() => _$GetChannelTopicsResultToJson(this);
}

@JsonSerializable(fieldRename: FieldRename.snake)
class GetStreamTopicsEntry {
class GetChannelTopicsEntry {
final int maxId;
final TopicName name;

GetStreamTopicsEntry({
GetChannelTopicsEntry({
required this.maxId,
required this.name,
});

factory GetStreamTopicsEntry.fromJson(Map<String, dynamic> json) => _$GetStreamTopicsEntryFromJson(json);
factory GetChannelTopicsEntry.fromJson(Map<String, dynamic> json) => _$GetChannelTopicsEntryFromJson(json);

Map<String, dynamic> toJson() => _$GetStreamTopicsEntryToJson(this);
Map<String, dynamic> toJson() => _$GetChannelTopicsEntryToJson(this);
}

/// Update a topic's visibility policy.
Expand Down
18 changes: 9 additions & 9 deletions lib/api/route/channels.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 3 additions & 11 deletions lib/model/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import 'package:unorm_dart/unorm_dart.dart' as unorm;

import '../api/model/events.dart';
import '../api/model/model.dart';
import '../api/route/channels.dart';
import '../generated/l10n/zulip_localizations.dart';
import '../widgets/compose_box.dart';
import 'algorithms.dart';
Expand Down Expand Up @@ -1239,22 +1238,15 @@ class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, Top
final int streamId;

Iterable<TopicName> _topics = [];
bool _isFetching = false;

/// Fetches topics of the current stream narrow, expected to fetch
/// only once per lifecycle.
/// Fetches topics of the current stream narrow.
///
/// Starts fetching once the stream narrow is active, then when results
/// are fetched it restarts search to refresh UI showing the newly
/// fetched topics.
Future<void> _fetch() async {
assert(!_isFetching);
_isFetching = true;
final result = await getStreamTopics(store.connection, streamId: streamId,
allowEmptyTopicName: true,
);
_topics = result.topics.map((e) => e.name);
_isFetching = false;
await store.topics.fetchChannelTopics(streamId); // TODO: handle fetch failure
_topics = store.topics.getChannelTopics(streamId)!.map((e) => e.name);
return _startSearch();
}

Expand Down
2 changes: 1 addition & 1 deletion lib/model/narrow.dart
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ class TopicNarrow extends Narrow implements SendableNarrow {
bool containsMessage(MessageBase message) {
final conversation = message.conversation;
return conversation is StreamConversation
&& conversation.streamId == streamId && conversation.topic == topic;
&& conversation.streamId == streamId && conversation.topic.isSameAs(topic);
Comment on lines 112 to +115
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, good catch.

narrow: Use TopicName.isSameAs in TopicNarrow.containsMessage

TopicNarrow.containsMessage used to use raw equality operator (==)
for comparing topics, which was not ideal for how we treat two
topics the same.

Did this have a user-visible symptom? What were the symptoms?

Copy link
Collaborator Author

@sm-sayedi sm-sayedi Dec 2, 2025

Choose a reason for hiding this comment

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

Yeah, there is one: when looking at a topic narrow for a topic named "t", and then there's a new message event in the topic named "T", it will not be shown in the current view.
Will mention this in the commit message.

}

@override
Expand Down
7 changes: 7 additions & 0 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import 'server_support.dart';
import 'channel.dart';
import 'saved_snippet.dart';
import 'settings.dart';
import 'topics.dart';
import 'typing_status.dart';
import 'unreads.dart';
import 'user.dart';
Expand Down Expand Up @@ -571,6 +572,7 @@ class PerAccountStore extends PerAccountStoreBase with
presence: Presence(realm: realm,
initial: initialSnapshot.presences),
channels: channels,
topics: Topics(core: core),
messages: MessageStoreImpl(channels: channels),
unreads: Unreads(core: core, channelStore: channels,
initial: initialSnapshot.unreadMsgs),
Expand All @@ -593,6 +595,7 @@ class PerAccountStore extends PerAccountStoreBase with
required this.typingStatus,
required this.presence,
required ChannelStoreImpl channels,
required this.topics,
required MessageStoreImpl messages,
required this.unreads,
required this.recentDmConversationsView,
Expand Down Expand Up @@ -686,6 +689,8 @@ class PerAccountStore extends PerAccountStoreBase with
ChannelStore get channelStore => _channels;
final ChannelStoreImpl _channels;

final Topics topics;

//|//////////////////////////////
// Messages, and summaries of messages.

Expand Down Expand Up @@ -879,13 +884,15 @@ class PerAccountStore extends PerAccountStoreBase with
unreads.handleMessageEvent(event);
recentDmConversationsView.handleMessageEvent(event);
recentSenders.handleMessage(event.message); // TODO(#824)
topics.handleMessageEvent(event);
// When adding anything here (to handle [MessageEvent]),
// it probably belongs in [reconcileMessages] too.

case UpdateMessageEvent():
assert(debugLog("server event: update_message ${event.messageId}"));
_messages.handleUpdateMessageEvent(event);
unreads.handleUpdateMessageEvent(event);
topics.handleUpdateMessageEvent(event);

case DeleteMessageEvent():
assert(debugLog("server event: delete_message ${event.messageIds}"));
Expand Down
158 changes: 158 additions & 0 deletions lib/model/topics.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import 'dart:async';

import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';

import '../api/model/events.dart';
import '../api/model/model.dart';
import '../api/route/channels.dart';
import 'channel.dart';
import 'store.dart';

// similar to _apiSendMessage in lib/model/message.dart
final _apiGetChannelTopics = getChannelTopics;

/// The view-model for tracking channel topics.
///
/// Use [fetchChannelTopics] to first fetch the topics for a channel from the
/// server and then use [getChannelTopics] to get those topics, affected
/// by the relevant events.
class Topics extends PerAccountStoreBase with ChangeNotifier {
Topics({required super.core});

/// Maps indexed by channel IDs, of the known latest message IDs in each topic.
///
/// For example: `_latestMessageIdsByChannelTopic[channel.streamId][topic] = maxId`
///
/// Occasionally, the latest message ID of a topic will refer to a message
/// that doesn't exist or is no longer in the topic.
/// This happens when the topic's latest message is deleted or moved
/// and we don't have enough information to replace it accurately.
/// (We don't keep a snapshot of all messages.)
// TODO(#2004): handle more cases where this can change
final Map<int, TopicKeyedMap<int>> _latestMessageIdsByChannelTopic = {};

final Map<int, Future<GetChannelTopicsResult>> _channelTopicsFetching = {};

/// Fetch topics in a channel from the server, only if they're not fetched yet.
///
/// Once fetched, the data will be updated by events;
/// use [getChannelTopics] to consume the data.
Future<void> fetchChannelTopics(int channelId) async {
if (_latestMessageIdsByChannelTopic[channelId] != null) return;

Future<GetChannelTopicsResult>? future = _channelTopicsFetching[channelId];
// If another call has already started fetching topics for this channel,
// ignore this call.
if (future != null) return;

future = _apiGetChannelTopics(connection, channelId: channelId,
allowEmptyTopicName: true);
_channelTopicsFetching[channelId] = future;

try {
final result = await future;
assert(_latestMessageIdsByChannelTopic[channelId] == null);
(_latestMessageIdsByChannelTopic[channelId] = makeTopicKeyedMap())
.addEntries(result.topics.map((entry) => MapEntry(entry.name, entry.maxId)));
} finally {
unawaited(_channelTopicsFetching.remove(channelId));
}
}

/// Map of topics per channel, sorted by latest message IDs descending.
///
/// Derived from [_latestMessageIdsByChannelTopic];
/// used for optimized topics sorting.
///
/// A channel entry should be discarded when there is a change to
/// the ordering of its topics or the channel is no longer present
/// in [_latestMessageIdsByChannelTopic], allowing [getChannelTopics]
/// to recalculate the sorted topics on demand.
// TODO(#2004): handle more cases where this can change
final Map<int, List<GetChannelTopicsEntry>> _sortedTopicsByChannel = {};

/// The topics the user can access, along with their latest message ID,
/// reflecting updates from events that arrived since the data was fetched.
///
/// Returns null if the data has not been fetched yet.
/// To fetch it from the server, use [fetchChannelTopics].
///
/// The result is sorted by [GetChannelTopicsEntry.maxId] descending,
/// and the topics are distinct.
///
/// Occasionally, [GetChannelTopicsEntry.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 deleted or moved
/// and we don't have enough information
/// to replace [GetChannelTopicsEntry.maxId] accurately.
/// (We don't keep a snapshot of all messages.)
/// Use [PerAccountStore.messages] to check a message's topic accurately.
List<GetChannelTopicsEntry>? getChannelTopics(int channelId) {
final latestMessageIdsByTopic = _latestMessageIdsByChannelTopic[channelId];
if (latestMessageIdsByTopic == null) return null;
return _sortedTopicsByChannel[channelId] ??= latestMessageIdsByTopic.entries
.map((e) => GetChannelTopicsEntry(maxId: e.value, name: e.key))
.sortedBy((value) => -value.maxId);
}

void handleMessageEvent(MessageEvent event) {
if (event.message is! StreamMessage) return;
final StreamMessage(:id, streamId: channelId, :topic) = event.message as StreamMessage;

final latestMessageIdsByTopic = _latestMessageIdsByChannelTopic[channelId];
if (latestMessageIdsByTopic == null) {
// We're not tracking this channel's topics yet.
// We'll start doing that when we get the full topic list;
// see [fetchChannelTopics].
return;
}

final currentLatestMessageId = latestMessageIdsByTopic[topic];
if (currentLatestMessageId != null && currentLatestMessageId >= id) {
// The event raced with a topic-list fetch.
return;
}
latestMessageIdsByTopic[topic] = id;
_sortedTopicsByChannel.remove(channelId);
notifyListeners();
}

void handleUpdateMessageEvent(UpdateMessageEvent event) {
if (event.moveData == null) return;
final UpdateMessageMoveData(
:origStreamId, :origTopic, :newStreamId, :newTopic, :propagateMode,
) = event.moveData!;
bool shouldNotify = false;

final origLatestMessageIdsByTopic = _latestMessageIdsByChannelTopic[origStreamId];
if (origLatestMessageIdsByTopic != null) {
switch (propagateMode) {
case .changeOne:
case .changeLater:
Comment on lines +130 to +132
Copy link
Collaborator

Choose a reason for hiding this comment

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

On the other hand, the shorthand as it's used here looks great to me!

// 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 .changeAll:
origLatestMessageIdsByTopic.remove(origTopic);
_sortedTopicsByChannel.remove(origStreamId);
shouldNotify = true;
}
}

final newLatestMessageIdsByTopic = _latestMessageIdsByChannelTopic[newStreamId];
if (newLatestMessageIdsByTopic != null) {
// TODO(server-11): rely on `event.messageIds` being sorted, to avoid this linear scan
final movedMaxId = event.messageIds.max;
final currentMaxId = newLatestMessageIdsByTopic[newTopic];
if (currentMaxId == null || currentMaxId < movedMaxId) {
newLatestMessageIdsByTopic[newTopic] = movedMaxId;
_sortedTopicsByChannel.remove(newStreamId);
shouldNotify = true;
}
}

if (shouldNotify) notifyListeners();
}
}
Loading