-
Notifications
You must be signed in to change notification settings - Fork 380
Keep track of the known topics in store #1951
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
Conversation
1019746 to
2e0fcfd
Compare
chrisbobbe
left a comment
There was a problem hiding this 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
lib/model/channel.dart
Outdated
| import 'store.dart'; | ||
| import 'user.dart'; | ||
|
|
||
| final _apiGetChannelTopics = getStreamTopics; // similar to _apiSendMessage in lib/model/message.dart |
There was a problem hiding this comment.
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
lib/model/channel.dart
Outdated
| /// can be retrieved with [getChannelTopics]. | ||
| Future<void> fetchTopics(int channelId); | ||
|
|
||
| /// Pairs of the known topics and its latest message ID, in the given channel. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| /// 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. |
lib/model/channel.dart
Outdated
|
|
||
| /// Pairs of the known topics and its latest message ID, in the given channel. | ||
| /// | ||
| /// Returns null if the data has never been fetched yet. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
| /// Returns null if the data has never been fetched yet. | |
| /// Returns null if the data has not been fetched yet. |
lib/model/channel.dart
Outdated
| /// The result is guaranteed to be sorted by [GetStreamTopicsEntry.maxId] | ||
| /// descending, and the topics are guaranteed to be distinct. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit, omit needless words:
| /// 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. |
lib/model/channel.dart
Outdated
| /// 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. |
There was a problem hiding this comment.
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?)
| /// 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. |
lib/model/channel.dart
Outdated
| /// 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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
| final StreamMessage(:streamId, :topic) = event.message as StreamMessage; | |
| final StreamMessage(streamId: channelId, :topic) = event.message as StreamMessage; |
lib/model/channel.dart
Outdated
| // 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. |
There was a problem hiding this comment.
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.
lib/model/channel.dart
Outdated
| // 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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about:
| // 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; | |
| } |
lib/model/channel.dart
Outdated
| 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; | ||
| } |
There was a problem hiding this comment.
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.:
| 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; | |
| } | |
| } |
lib/model/channel.dart
Outdated
| final newLatestMessageIdsByTopics = _latestMessageIdsByChannelTopic[newStreamId]; | ||
| if (newLatestMessageIdsByTopics != null) { | ||
| final movedMaxId = event.messageIds.max; | ||
| if (!newLatestMessageIdsByTopics.containsKey(newTopic) | ||
| || newLatestMessageIdsByTopics[newTopic]! < movedMaxId) { | ||
| newLatestMessageIdsByTopics[newTopic] = movedMaxId; | ||
| shouldNotify = true; | ||
| } | ||
| } |
There was a problem hiding this comment.
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:
| 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; | |
| } | |
| } |
2e0fcfd to
c37de07
Compare
|
Thanks @chrisbobbe for the review. Pushed new revision. |
02ed253 to
7fca9bb
Compare
chrisbobbe
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, and sorry for the delay! Comments below, this time from a full review.
test/widgets/topic_list_test.dart
Outdated
| }); | ||
|
|
||
| testWidgets('fetch again when navigating away and back', (tester) async { | ||
| testWidgets("dont't fetch again when navigating away and back", (tester) async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: spelling of "don't"
test/api/route/route_checks.dart
Outdated
| Subject<Uri> get realmUrl => has((e) => e.realmUrl, 'realmUrl'); | ||
| } | ||
|
|
||
| extension GetStreamTopicEntryChecks on Subject<GetStreamTopicsEntry> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"GetStreamTopicsEntryChecks"
test/model/store_checks.dart
Outdated
| Subject<Map<int, ZulipStream>> get streams => has((x) => x.streams, 'streams'); | ||
| Subject<Map<String, ZulipStream>> get streamsByName => has((x) => x.streamsByName, 'streamsByName'); | ||
| Subject<Map<int, Subscription>> get subscriptions => has((x) => x.subscriptions, 'subscriptions'); | ||
| Subject<List<GetStreamTopicsEntry>?> getStreamTopics(int streamId) => has((x) => x.getChannelTopics(streamId), 'getStreamTopics'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getChannelTopics and 'getChannelTopics', to match PerAccountStore.getChannelTopics
lib/model/channel.dart
Outdated
| import 'user.dart'; | ||
|
|
||
| // similar to _apiSendMessage in lib/model/message.dart | ||
| final _apiGetChannelTopics = getStreamTopics; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you add a prep commit that renames the existing binding getStreamTopics to getChannelTopics, then consistently use "channel topics" naming instead of "stream topics" naming?
lib/model/channel.dart
Outdated
| /// can be retrieved with [getChannelTopics]. | ||
| Future<void> fetchTopics(int channelId); | ||
|
|
||
| /// The topics in the given channel, along with their latest message ID. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| /// The topics in the given channel, along with their latest message ID. | |
| /// The topics the user can access, along with their latest message ID, | |
| /// reflecting updates from events that arrived since the data was fetched. |
test/widgets/topic_list_test.dart
Outdated
| connection.prepare(json: GetStreamTopicsResult( | ||
| topics: [eg.getStreamTopicsEntry(name: 'topic B')]).toJson()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we don't expect to fetch again, we shouldn't prepare a response, right?
test/widgets/topic_list_test.dart
Outdated
| ]); | ||
| testWidgets('show topic action sheet before and after moves', (tester) async { | ||
| final channel = eg.stream(); | ||
| final message = eg.streamMessage(id: 123, stream: channel, topic: 'foo'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is 123 chosen because it equals eg.defaultStreamMessageStreamId? We should use eg.defaultStreamMessageStreamId instead, if so, or else pass 123 as streamId to the eg.stream() call.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this case 123 is just an arbitrary message ID, compared to eg.defaultStreamMessageStreamId which is stream/channel ID. Anyways, changed it to 100 to differentiate from the stream ID.
test/widgets/topic_list_test.dart
Outdated
| // After the move, the message with maxId moved away from "foo". The topic | ||
| // actions that require `someMessageIdInTopic` is no longer available. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: "are no longer available"
test/widgets/topic_list_test.dart
Outdated
| await tester.tap(find.text('Cancel')); | ||
| await tester.pump(); | ||
|
|
||
| // Topic actions that require `someMessageIdInTopic` is available |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: "are available"
test/widgets/topic_list_test.dart
Outdated
| check(find.descendant(of: topicItemFinder, | ||
| matching: find.text('foo'))).findsOne(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about:
check(findInTopicItemAt(0, find.text('foo'))).findsOne();?
7fca9bb to
62ed7ea
Compare
62ed7ea to
b008d16
Compare
|
Thanks @chrisbobbe for the review. New changes pushed, PTAL. |
b008d16 to
ec8f84d
Compare
gnprice
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks to you both!
I see this is still in maintainer review, and want that to continue until it's done. Since I'll be on vacation next week, though (and with the Thanksgiving holiday here starting Thursday), here's some high-level comments from an initial skim.
| 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); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, thanks. Would you file a quick issue describing that bug? Then this can point to that, which will help us track which release we fix the bug in.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Filed #2060.
lib/model/channel.dart
Outdated
| Future<void> fetchChannelTopics(int channelId) async { | ||
| if (_latestMessageIdsByChannelTopic[channelId] != null) return; | ||
|
|
||
| final result = await _apiGetChannelTopics(connection, channelId: channelId, | ||
| allowEmptyTopicName: true); | ||
| (_latestMessageIdsByChannelTopic[channelId] = makeTopicKeyedMap()) | ||
| .addEntries(result.topics.map((entry) => MapEntry(entry.name, entry.maxId))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this method gets called while another call is already in the middle of a request, it'll make a fresh request. Then whichever request finishes later will clobber the results of the one that finished first.
Is that a situation that's likely to happen in practice? What would the consequences be?
If we want to handle that situation smoothly, see the logic in GlobalStore.perAccount around _perAccountStoresLoading.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, that can happen in cases like when a topic-list page is opened, but then before the request is finished, the page is closed and then opened again, resulting in a fresh request. It may result in stale data when the data of the latest request is replaced by an earlier request that took longer.
Added the logic you mentioned to handle this situation.
lib/model/store.dart
Outdated
| unreads.handleMessageEvent(event); | ||
| recentDmConversationsView.handleMessageEvent(event); | ||
| recentSenders.handleMessage(event.message); // TODO(#824) | ||
| if (_channels.handleMessageEvent(event)) notifyListeners(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmmm — this means that once the user has been either looking at a topic list, or choosing a topic in a channel-narrow compose box, any new-message events in that channel will cause the overall PerAccountStore to notify listeners.
There are a small number of event types that happen frequently: new message, mark as read, typing status, and presence. Because those happen much more often than other events, those are the ones where we've spent effort on having separate ChangeNotifiers so that the bulk of the app's UI doesn't need to rebuild when those events happen.
It'd therefore be good to maintain that property here. Probably cleanest is to have this data structure go on a new substore, maybe TopicListStore. The whole substore could be the ChangeNotifier (like Unreads and friends), or there could be one ChangeNotifier per channel; whichever seems easier to implement is fine.
lib/widgets/topic_list.dart
Outdated
| // `maxId` might be incorrect (see [ChannelStore.getChannelTopics]). | ||
| // Check if it refers to a message that's currently in the topic; | ||
| // if not, we just won't have `someMessageIdInTopic` for the action sheet. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm interesting. This was already something that could happen, right? So it seems like this change could just as well happen in a separate earlier commit, and that'd be clearer.
What was the consequence of using maxId here without this check?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What was the consequence of using
maxIdhere without this check?
Quoting from #1508 (comment):
If the message ID is not in the topic anymore, then resolve/unresolve will not be successful and will display a dialog with the following message:
You only have permission to move the 1/7 most recent messages in this topic.
(the topic where the ID now actually belongs to has 7 messages in total)
That's the case when the message is moved.
And when the message is deleted, it will show the dialog with this message: Invalid message(s).
e6866a2 to
7f1e560
Compare
|
Thanks @gnprice for the review. New changes pushed. @chrisbobbe This is now ready for a new round of maintainer review. |
chrisbobbe
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! Comments below, all small.
test/model/message_list_test.dart
Outdated
| check(model).messages.length.equals(30); | ||
| }); | ||
|
|
||
| test('in topic narrow with case-sensitively different topic', () async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about 'in topic narrow: topics compared case-insensitively'
("Different topic" is potentially confusing wording, because in Zulip we consider "T" and "t" to be the same topic.)
lib/model/store.dart
Outdated
| presence: Presence(realm: realm, | ||
| initial: initialSnapshot.presences), | ||
| channels: channels, | ||
| topics: .new(core: core), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting! This must be the new Dart shorthand feature that's come up a few times in our weekly calls.
Here, I find myself wondering…"new what?" I guess the answer is "new Topics". I may feel differently when we've used this feature in more places in the code, but for now my preference is to stick with the old style: topics: Topics(core: core),.
I think we've been mostly excited for this feature in the context of enums.
lib/model/topics.dart
Outdated
| (_latestMessageIdsByChannelTopic[channelId] = makeTopicKeyedMap()) | ||
| .addEntries(result.topics.map((entry) => .new(entry.name, entry.maxId))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's add an assert(_latestMessageIdsByChannelTopic[channelId] == null) just above this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also same comment about the .new shorthand: I may feel differently later, but for now I don't love it; it feels like I'm losing an opportunity to see really quickly what kind of new thing is being created.
| switch (propagateMode) { | ||
| case .changeOne: | ||
| case .changeLater: |
There was a problem hiding this comment.
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!
test/model/topics_test.dart
Outdated
| connection.prepare(json: GetChannelTopicsResult(topics: [ | ||
| eg.getChannelTopicsEntry(name: 'foo', maxId: 100), | ||
| ]).toJson(), delay: Duration(seconds: 3)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The delay is important for the story of the test, right, but I feel like it's getting a little lost when it shares a line with the characters ]).toJson(), . How about putting it on its own line, maybe something like this:
connection.prepare(
json: GetChannelTopicsResult(
topics: [eg.getChannelTopicsEntry(name: 'foo', maxId: 100)]).toJson(),
delay: Duration(seconds: 3));There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've often liked putting the delay parameter first, before the content of the response.
After all, the delay happens chronologically prior to the response arriving. The delay is also in the nature of metadata, while the response (json:) is the content or payload — similar to a child or children parameter on a widget constructor, which we always put last.
test/model/topics_test.dart
Outdated
| checkNotifiedOnce(); | ||
| }); | ||
|
|
||
| test('topic moved to known topic in the same channel', () async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(same comment about "existing" vs. "known")
test/model/autocomplete_test.dart
Outdated
|
|
||
| connection.prepare(json: GetChannelTopicsResult(topics: [topic1, topic2]).toJson()); | ||
| final view1 = TopicAutocompleteView.init(store: store, streamId: 1000, | ||
| query: .new('')); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(same comment about .new, here and below)
lib/widgets/topic_list.dart
Outdated
| unreadsModel = newStore.unreads..addListener(_modelChanged); | ||
| topicsModel?.removeListener(_modelChanged); | ||
| topicsModel = newStore.topics..addListener(_modelChanged); | ||
| _fetchTopics(topicsModel!); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we don't have to pass topicsModel through like this; the _fetchTopics implementation can just look it up using the getter, with !:
diff --git lib/widgets/topic_list.dart lib/widgets/topic_list.dart
index 1e29c5516..73673f38a 100644
--- lib/widgets/topic_list.dart
+++ lib/widgets/topic_list.dart
@@ -135,7 +135,7 @@ class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMi
unreadsModel = newStore.unreads..addListener(_modelChanged);
topicsModel?.removeListener(_modelChanged);
topicsModel = newStore.topics..addListener(_modelChanged);
- _fetchTopics(topicsModel!);
+ _fetchTopics();
}
@override
@@ -150,11 +150,11 @@ class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMi
});
}
- void _fetchTopics(Topics topicsModel) async {
+ void _fetchTopics() async {
// Do nothing when the fetch fails; the topic-list will stay on
// the loading screen, until the user navigates away and back.
// TODO(design) show a nice error message on screen when this fails
- await topicsModel.fetchChannelTopics(widget.streamId);
+ await topicsModel!.fetchChannelTopics(widget.streamId);
if (!mounted) return;
setState(() {
// The actual state lives in the `topicsModel`.
lib/widgets/topic_list.dart
Outdated
| // The actual state lives in `unreadsModel`. | ||
| // The actual states lives in `unreadsModel` and `topicsModel`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: "live"
| _fetchTopics(); | ||
| unreadsModel = newStore.unreads..addListener(_modelChanged); | ||
| topicsModel?.removeListener(_modelChanged); | ||
| topicsModel = newStore.topics..addListener(_modelChanged); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should remove the listener in dispose.
7f1e560 to
1c569dc
Compare
|
Thanks for the review. Pushed the new revision. |
|
Thanks! LGTM; marking for Greg's review. |
72b24e2 to
0235d91
Compare
gnprice
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @sm-sayedi for picking this up, and @chrisbobbe for the previous reviews! Comments below.
I've read the first 8 commits (including the main model commit):
200f744 narrow: Use TopicName.isSameAs in TopicNarrow.containsMessage
6ad5a40 api [nfc]: Rename GetStreamTopicsEntry to GetChannelTopicsEntry
6b1fdcd example data [nfc]: Rename getStreamTopicsEntry to getChannelTopicsEntry
2497a31 api [nfc]: Rename GetStreamTopicsResult to GetChannelTopicsResult
8f80e29 api [nfc]: Rename getStreamTopics to getChannelTopics
6857a4b topics: Introduce Topics model for tracking known channel topics
2d0df3c autocomplete test [nfc]: s/streams/topics in a topic test name
33e848f autocomplete test [nfc]: Add and use isTopic condition
I'll leave the remaining 3 for a future round:
ec9c495 autocomplete: Use topic data from Topics model in topic autocomplete
ecc407c topics: Pass maxId to topic sheet if the related message is still in topic
0235d91 topics: Keep topic-list page updated, by using data from Topics model
| 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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, thanks. Would you file a quick issue describing that bug? Then this can point to that, which will help us track which release we fix the bug in.
lib/widgets/topic_list.dart
Outdated
| setState(() { | ||
| lastFetchedTopics = result.topics; | ||
| // The actual state lives in the `topicsModel`. | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this still needed? Seems like the listener on Topics should cover it.
lib/model/autocomplete.dart
Outdated
| await store.topics.fetchChannelTopics(streamId); // TODO: handle fetch failure | ||
| _topics = store.topics.getChannelTopics(streamId)!.map((e) => e.name); | ||
| return _startSearch(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm. If a fetch is already underway, then this fetchChannelTopics will complete immediately, right? Then we'll attempt to start the search but we have no data to search through.
lib/model/topics.dart
Outdated
| /// | ||
| /// Once fetched, the data will be updated by events; | ||
| /// use [getChannelTopics] to consume the data. | ||
| Future<void> fetchChannelTopics(int channelId) async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do the callers of this method want to await it?
If they do, then they probably want to await the fetch being finished. As is, the future returned by this method can have two quite different meanings:
- Sometimes it means the result of the actual fetch.
- Other times it just means that a fetch was ongoing when the method was called, at which the method immediately returned.
The caller can't tell which meaning the future has. It's unlikely that something with those two different meanings is possible to use in a way that has an effect but doesn't have a bug in one of those cases.
If callers don't want to await the result, then the method can return void rather than Future<void>, to indicate that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In some cases the callers need to wait for it, for example in topic autocomplete. Changed it so that it returns the ongoing future instead, so it has one meaning.
test/model/topics_test.dart
Outdated
| await model.fetchChannelTopics(channel.streamId); | ||
| check(connection.takeRequests()).isEmpty(); | ||
| check(model).getChannelTopics(channel.streamId).isNotNull() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These test cases should be checking whether listeners are notified or not (just like many of the later test cases do).
In general, all the test cases that are about making changes to the data — therefore something that potentially might cause listeners to be notified — should be checking that.
test/model/topics_test.dart
Outdated
| assert(messages.isNotEmpty); | ||
| assert(messages.every((m) => m.streamId == channel.streamId)); | ||
| await store.addMessages(messages); | ||
| check(model).getChannelTopics(channel.streamId).isNotNull().deepEquals(expectedTopics); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this step would be clearest at the respective call sites. It'd make them only one line longer; and the benefit is that it makes this check a visible point of comparison for the later checks in each test.
The point of this check is basically to be a baseline for those later checks, so that's a significant benefit in ease of reading.
| }); | ||
| }); | ||
|
|
||
| group('PropagateMode.changeLater', () { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This behaves exactly the same as .changeOne, right?
I don't really want to read 🙂 additional tests that say almost the same thing as the tests above. Let's pick just one of these .changeLater tests, whichever seems most emblematic of the .changeLater behavior
| test('TopicAutocompleteView updates results when streams are loaded', () async { | ||
| test('TopicAutocompleteView updates results when topics are loaded', () async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
autocomplete test [nfc]: s/streams/topics in a topic test name
Include a closing slash:
autocomplete test [nfc]: s/streams/topics/ in a topic test name
Otherwise it looks like the replacement text extends through the rest of the line: "topics in a topic test name".
TopicNarrow.containsMessage used to use raw equality operator (==) for comparing topics, which was not ideal for how we treat two topics the same. This would result in not displaying a new message of topic "T" when looking at a topic narrow of topic "t" (treated message topics case-sensitively in topic narrow). Fixes: zulip#2060
To make things consistent for renaming the binding getStreamTopics to getChannelTopics in the following commits.
To make things consistent for renaming the binding getStreamTopics to getChannelTopics in the following commits.
To make things consistent for renaming the binding getStreamTopics to getChannelTopics in the following commits.
0235d91 to
f87c571
Compare
|
Thanks Greg for the review. Pushed new changes, PTAL. |
gnprice
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the revision! Generally this looks good. I've now read the whole thing; comments below.
| } | ||
|
|
||
|
|
||
| void handleMessageEvent(MessageEvent event) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: double blank line
lib/model/topics.dart
Outdated
| topics = await future; | ||
| notifyListeners(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: I think it's a bit clearer for the reader to verify the logic here if this notifyListeners happens next to the mutations that make it necessary, i.e. in _getChannelTopics where we update _latestMessageIdsByChannelTopic.
lib/model/autocomplete.dart
Outdated
|
|
||
| /// Fetches topics of the current stream narrow, expected to fetch | ||
| /// only once per lifecycle. | ||
| /// Fetches topics of the current stream narrow. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| /// Fetches topics of the current stream narrow. | |
| /// Fetches topics of the current stream narrow, if needed. |
Since this no longer necessarily causes a fetch.
lib/widgets/topic_list.dart
Outdated
| final designVariables = DesignVariables.of(context); | ||
| // `maxId` might be incorrect (see [ChannelStore.getChannelTopics]). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: separate stanza, because this multi-line calculation has a different character from the two quick boring lookups preceding it
| final designVariables = DesignVariables.of(context); | |
| // `maxId` might be incorrect (see [ChannelStore.getChannelTopics]). | |
| final designVariables = DesignVariables.of(context); | |
| // `maxId` might be incorrect (see [ChannelStore.getChannelTopics]). |
| final someMessageIdInTopic = | ||
| (maxIdMessage != null && TopicNarrow(streamId, topic).containsMessage(maxIdMessage)) | ||
| ? maxIdMessage.id | ||
| : null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
topics: Pass `maxId` to topic sheet if the related message is still in topic
This should say "only if", not plain "if". As is, it sounds like the change is that we now start passing maxId when we didn't before.
lib/widgets/topic_list.dart
Outdated
| unreadsModel = newStore.unreads..addListener(_modelChanged); | ||
| topicsModel?.removeListener(_modelChanged); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: put topicsModel before unreadsModel (here and in declarations); seems like it's more fundamental to what this topic-list widget is doing
lib/widgets/topic_list.dart
Outdated
| // The actual state lives in `unreadsModel`. | ||
| // The actual states live in `unreadsModel` and `topicsModel`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: I'd still say "state lives", singular — "the state" is a bit abstract but describes the whole ensemble of data that drives this widget
test/widgets/topic_list_test.dart
Outdated
| check(connection.takeRequests()) | ||
| ..length.equals(2) // one for the messages request, another for the topics |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can be made a bit cleaner by turning this comment into self-checking code: put another connection.takeRequests call sooner, after the messages request, and just check there that there's 1 request. (Or not even check that, since the point of this test is about the requests on the topic-list page.)
That way this one covers only the request(s) made at this step of navigating to the topic-list page, and it can make a stronger check by saying there's just this one request.
test/widgets/topic_list_test.dart
Outdated
| // … then back to the topic-list page, expecting not to fetch again but | ||
| // use existing data which is kept up-to-date anyway. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This comment reads a little oddly. I think it also doesn't entirely make sense when reading the new version of the code as it is — it's really more part of a conversation about the change from the old version (before this PR) to the new version.
Instead, that conversation should be kept to commit messages (and PR descriptions), and the comments in the code should be about the current version of the code:
https://github.com/zulip/zulip-mobile/blob/main/docs/style.md#commit-messages-vs-comments
So e.g.:
// … then back to the topic-list page. No new fetch request occurs.f87c571 to
77cf465
Compare
|
Thanks for the review. Pushed new revision. |
Co-authored-by: Zixuan James Li <zixuan@zulip.com>
This will be cleaner when we add more tests in future commits.
…ill in topic Co-authored-by: Zixuan James Li <zixuan@zulip.com>
Fixes: zulip#1499 Co-authored-by: Zixuan James Li <zixuan@zulip.com>
77cf465 to
5d27008
Compare
|
Thanks! Looks good; merging. |
#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