Skip to content

model: Treat topics case-insensitively in Unreads, topic-visibility, and recent-senders #1608

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from

Conversation

chrisbobbe
Copy link
Collaborator

Fixes: #980

… tests

And fix a test that was breaking an invariant in its setup.
…ages

And change some callers slightly, to adapt.

Since fillWithMessages is implemented as handling a sequence of
new-message events, require the messages to be sorted without
duplicates, for realism.

This doesn't change what the model's state looks like for any of the
tests, because messages in the input with the "read" flag are
ignored by model.handleMessageEvent, and we're not changing the
order of the unread messages. Also, the previous commit started
checking the invariant that the model's message-ID lists are sorted.
The event's doc says:

https://zulip.com/api/get-events#user_topic
> Event sent to a user's clients when the user mutes/unmutes a
> topic, or otherwise modifies their personal per-topic
> configuration.

The new name is still accurate for "adding" a configuration (i.e.
setting it to something not-"none"), but now also covers updating a
configuration (moving between not-"none" configurations) and
removing one (resetting to "none).
@chrisbobbe chrisbobbe added the maintainer review PR ready for review by Zulip maintainers label Jun 18, 2025
Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

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

Thanks for taking care of this! Generally this all looks good; mostly small comments below.

(I just saw you'd marked it for maintainer review first, ah well — already wrote this review, so posting it.)

Comment on lines 165 to -142
eg.streamMessage(id: 7, stream: stream2, topic: 'c', flags: []),
eg.streamMessage(id: 8, stream: stream2, topic: 'c', flags: []),
eg.dmMessage(id: 9, from: user1, to: [eg.selfUser], flags: []),
eg.dmMessage(id: 10, from: user1, to: [eg.selfUser], flags: []),
Copy link
Member

Choose a reason for hiding this comment

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

nit:

unreads test: Make room for two more message IDs (7, 8) in a test's setup

9, 10, right?

Comment on lines +382 to +384
LinkedHashMap<TopicName, V> makeTopicKeyedMap<V>() => LinkedHashMap<TopicName, V>(
equals: (a, b) => a.isSameAs(b),
hashCode: (k) => k.canonicalize().hashCode,
Copy link
Member

Choose a reason for hiding this comment

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

I guess the one way this is less efficient than a FoldDict approach is that when looking up a key and finding a match (or a match of the hash code), the FoldDict only needs to call toLowerCase on the new key because it already has the lower-case form of the key that's in the map; this version needs to call toLowerCase twice.

It's probably fine. If later we find this is hot in profiling, we can optimize.

(And if we do, we might go farther anyway: turn TopicName into a class instead of an extension type, memoizing its canonicalized form. That would make FoldDict unnecessary too.)

LinkedHashMap<TopicName, V> makeTopicKeyedMap<V>() => LinkedHashMap<TopicName, V>(
equals: (a, b) => a.isSameAs(b),
hashCode: (k) => k.canonicalize().hashCode,
isValidKey: (k) => k is TopicName,
Copy link
Member

Choose a reason for hiding this comment

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

Is this argument needed? It looks from the docs like this is the default behavior.

Comment on lines -19 to +22
final Map<int, Map<TopicName, Map<int, MessageIdTracker>>> topicSenders = {};
final Map<int, LinkedHashMap<TopicName, Map<int, MessageIdTracker>>> topicSenders = {};
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, this doesn't really express the meaning we care about. It is a LinkedHashMap, true — but so is what you'd get from just {}. If these were some other (reasonable) Map implementation instead, that'd be fine too as long as they used the particular equals and hashCode etc. that makeTopicKeyedMap passes.

I think just Map here would be fine (and better than saying LinkedHashMap). It could also be a type alias, like TopicKeyedMap<Map<int, …>> — that'd express the desired meaning, even though it wouldn't be enforced.

Copy link
Member

Choose a reason for hiding this comment

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

Apropos of this: the upstream tree just converted places that were saying LinkedHashMap to say plain Map:
flutter/flutter#170713
which apparently will be needed as part of a future lint change there.

Comment on lines +5 to +6
extension MessageIdTrackerChecks on Subject<MessageIdTracker> {
Subject<QueueList<int>> get ids => has((x) => x.ids, 'ids');
Copy link
Member

Choose a reason for hiding this comment

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

nit: let's put this inside recent_senders_test.dart — for checks-extensions that are unlikely to be used outside a given test file, I've preferred doing it that way

I'm not totally satisfied with that pattern, because sometimes things are used in two or three files, and sometimes they start out used in just one and later are used in another, so that we end up having one test file import another which feels a little unclean.

But the trade-off is that when this file is present, it makes it harder to type a command like flutter test test/model/recent_senders_test.dart — the tab-completion has to stop at the …/recent_senders_ to ask if the next character should be a t or a c 🙂

Comment on lines +138 to +142
check(model.topicSenders).values.single.entries.single
..key.equals(eg.t('thing'))
..value.entries.single.which((it) => it
..key.equals(userX.userId)
..value.isA<MessageIdTracker>().ids.length.equals(2));
Copy link
Member

Choose a reason for hiding this comment

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

This felt a bit verbose to me, so I tried simplifying it by using deepEquals.

Not sure whether this is an improvement — the need for a Condition in there does dampen things:

      check(model.topicSenders).values.single.deepEquals(
        {eg.t('thing'): {userX.userId: (Subject<Object?> it) =>
           it.isA<MessageIdTracker>().ids.length.equals(2)}});

Maybe better if a bit more expanded in formatting:

      check(model.topicSenders).values.single.deepEquals(
        {eg.t('thing'):
          {userX.userId: (Subject<Object?> it) =>
             it.isA<MessageIdTracker>().ids.length.equals(2)}});

@@ -41,14 +42,25 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {
required CorePerAccountStore core,
required ChannelStore channelStore,
}) {
final streams = <int, Map<TopicName, QueueList<int>>>{};
final streams = <int, LinkedHashMap<TopicName, QueueList<int>>>{};
Copy link
Member

Choose a reason for hiding this comment

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

Searching this file for "Map<TopicName", it looks like there's one more spot that probably needs makeTopicKeyedMap: in handleUpdateMessageFlagsEvent when marking unread.

Comment on lines +53 to +62
if (topics[topic] != null) {
// Older servers differentiate topics case-sensitively, but shouldn't:
// https://github.com/zulip/zulip/pull/31869
// Our topic-keyed map is case-insensitive. When we've seen this
// topic before, modulo case, aggregate instead of clobbering.
// TODO(server-10) simplify away
topics[topic] =
setUnion(topics[topic]!, unreadChannelSnapshot.unreadMessageIds);
} else {
topics[topic] = QueueList.from(unreadChannelSnapshot.unreadMessageIds);
Copy link
Member

Choose a reason for hiding this comment

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

This will look up topic in the map two or three times. Since this is in a potentially hot path (processing the unreads data from the initial snapshot, so often 50k message IDs across perhaps 1000s of conversations), let's avoid that: topics.update(topic, …).

Future<void> addUserTopic(ZulipStream stream, String topic, UserTopicVisibilityPolicy visibilityPolicy) async {
Future<void> setUserTopic(ZulipStream stream, String topic, UserTopicVisibilityPolicy visibilityPolicy) async {
Copy link
Member

Choose a reason for hiding this comment

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

test [nfc]: s/addUserTopic/setUserTopic/ in PerAccountStoreTestExtension

The event's doc says:

https://zulip.com/api/get-events#user_topic
> Event sent to a user's clients when the user mutes/unmutes a
> topic, or otherwise modifies their personal per-topic
> configuration.

The new name is still accurate for "adding" a configuration (i.e.
setting it to something not-"none"), but now also covers updating a
configuration (moving between not-"none" configurations) and
removing one (resetting to "none).

Ah, good thought.

Comment on lines +166 to +168
// Case-insensitive
check(store.topicVisibilityPolicy(stream1.streamId, eg.t('ToPiC')))
.equals(policy);
Copy link
Member

Choose a reason for hiding this comment

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

I think my ideal version of these tests would have most of the new checks in this file be separate test cases, rather than added to existing tests. This also doesn't need a full matrix with the other variables: for example, this check can be done with any one of the non-none policies, rather than all three.

These are fine, though. No need to rewrite them — we have higher-priority things to do.

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.

Case-insensitive topics in unreads data
3 participants