Repository navigation
Conversation
3ea07c0 to
c98acb1
Compare
c98acb1 to
c604d9e
Compare
06f335c to
07b4f45
Compare
|
Thank you @alya for the suggestion. Made it one liner.
|
07b4f45 to
d9a3b48
Compare
chrisbobbe
left a comment
There was a problem hiding this comment.
Thanks! Sorry again that I've let this sit. I noticed the symptom of #226 when testing this manually, and considered it a blocker—the topic items don't pop up immediately when you tap a channel, which they definitely should. Anyway, I've sent flutter/flutter#183705 upstream for that, and hopefully it'll land soon, giving us new API that we'll use to fix the bug. 🙂
Comments below.
| @@ -1095,7 +1095,7 @@ class AutocompleteDataCache { | |||
|
|
|||
| List<String> normalizedNameWordsForChannel(ZulipStream channel) { | |||
| return _normalizedNameWordsByChannel[channel.streamId] | |||
| ?? normalizedNameForChannel(channel).split(' '); | |||
| ??= normalizedNameForChannel(channel).split(' '); | |||
There was a problem hiding this comment.
Indeed, thanks for catching this!
| List<String> normalizedNameWordsForChannelTopic(int channelId, String topic) { | ||
| return (_normalizedNameWordsByChannelTopic[channelId] ?? {})[topic] | ||
| ??= normalizedNameForChannelTopic(channelId, topic).split(' '); | ||
| } |
There was a problem hiding this comment.
| List<String> normalizedNameWordsForChannelTopic(int channelId, String topic) { | |
| return (_normalizedNameWordsByChannelTopic[channelId] ?? {})[topic] | |
| ??= normalizedNameForChannelTopic(channelId, topic).split(' '); | |
| } | |
| List<String> normalizedNameWordsForChannelTopic(int channelId, String topic) { | |
| return (_normalizedNameWordsByChannelTopic[channelId] ??= {})[topic] | |
| ??= normalizedNameForChannelTopic(channelId, topic).split(' '); | |
| } |
Right?
| final queryTopicResult = query.testQueryTopic(TopicName(query.raw), | ||
| matchedTopics: unsorted.whereType<TopicLinkAutocompleteTopicResult>() | ||
| .map((r) => r.topic)); | ||
| if (queryTopicResult != null) unsorted.add(queryTopicResult); |
There was a problem hiding this comment.
It should be possible to check whether a topic exists in constant time; let's try to avoid a linear scan through the filtered topics (unsorted) on every keystroke just for that. How about calling store.topics.latestMessageInTopic, where .latestMessageInTopic is a new int?-returning method you add to Topics, which checks _latestMessageIdsByChannelTopic.
| return bucketSort(unsorted, (r) => r.rank, | ||
| numBuckets: TopicLinkAutocompleteQuery._numResultRanks); |
There was a problem hiding this comment.
This bucket-sort step (here and in other autocompletes) is really handling two things:
a) Query relevance i.e. match quality
b) Everything we want to take precedence over query relevance
For example, in @-mention autocomplete, we rank user-group results below user results even when the user-group result would be a better match for the query.
Here, the ranking of TopicLinkAutocompleteChannelResult is clearly (b): its position relative to other list items has nothing to do with how well it matches the query. In fact we're not really "matching" this result to the query at all—we're just implementing the product choice that it should be present, at the top, when the query is empty, and absent otherwise.
How about hard-coding the rank of TopicLinkAutocompleteChannelResult to 0? (Also adding some dartdoc so its meaning is clearer):
+/// A result for just a channel link, after all, not any topic.
+///
+/// Offered at the top of the list when the topic query is empty.
class TopicLinkAutocompleteChannelResult extends TopicLinkAutocompleteResult {
- TopicLinkAutocompleteChannelResult({required this.channelId, required this.rank});
+ TopicLinkAutocompleteChannelResult({required this.channelId});
@override
final int channelId;
+ /// This should always come first in the list of options.
@override
- final int rank;
+ int get rank => 0;
}Then follow the resulting analyzer errors, on paths that should make sense intuitively, which I think means: TopicLinkAutocompleteQuery.testChannel disappears, and its caller is replaced with something like
if (query.raw.isEmpty) {
unsorted.add(TopicLinkAutocompleteChannelResult(channelId: channelId));
}and so on; also update dartdocs appropriately, e.g.:
- Dartdoc on
TopicLinkAutocompleteResult.rankmoves to subclassTopicLinkAutocompleteTopicResult.rank, because it's only about topics _rankChannelResultdisappears_rankTopicResult's dartdoc is updated, e.g.:
/// A measure of a topic result's quality in the context of the query,
- /// from 0 (best) to one less than [_numResultRanks].
+ /// from 1 (best) to one less than [_numResultRanks].
///
- /// See also [_rankChannelResult].
+ /// (Rank 0 is reserved for [TopicLinkAutocompleteChannelResult].)
static int _rankTopicResult({- and
_numResultRanks:
- /// The number of possible values returned by [_rankResult].
+ /// The number of possible values returned by [_rankTopicResult],
+ /// plus one for the [TopicLinkAutocompleteChannelResult].
static const _numResultRanks = 5;There was a problem hiding this comment.
Interesting points. Brought the changes except for TopicLinkAutocompleteQuery.testChannel still in place. I think it and the other method (testQueryTopic, now testNewTopic) make it easier for the test code to check the rankings. Please let me know if it needs changing! 🙂
| TopicLinkAutocompleteTopicResult? testQueryTopic(TopicName queryTopic, { | ||
| required Iterable<TopicName> matchedTopics, | ||
| }) { | ||
| assert(raw == queryTopic.apiName); | ||
| if (raw.isEmpty) return null; | ||
| final queryTopicExists = matchedTopics.any((t) => t.isSameAs(queryTopic)); | ||
| if (queryTopicExists) return null; | ||
| return TopicLinkAutocompleteTopicResult( | ||
| channelId: channelId, topic: queryTopic, isNew: true, | ||
| rank: TopicLinkAutocompleteQuery._rankTopicResult(isNewTopic: true)); | ||
| } |
There was a problem hiding this comment.
Like the testChannel case, I think this work can be inlined in TopicLinkAutocompleteView.computeResults, for similar reasons. (Also see my previous comment about checking for topic existence.)
I would also probably make a new subclass of TopicLinkAutocompleteResult just for the new-topic item, with rank hard-coded to 1 (similarly to the channel result's hardcoding to 0), making sure to update TopicLinkAutocompleteResult's dartdoc which refers to two subclasses.
| } | ||
| } | ||
| } else if (charAtPos == '[') { | ||
| final channelFallbackLinkMatch = _channelFallbackLinkWithTopicDelimiterRegex.matchAsPrefix(textUntilCursor, pos); |
There was a problem hiding this comment.
autocomplete: Identify when the user intends a topic link autocomplete
Would you separate the "fallback link" edge-case handling into its own commit? There's a lot going on in this commit, and I'd like to focus on the common case first.
| value = value.replaced( | ||
| TextRange(start: pos, end: value.selection.end), | ||
| channelLink(channel, isComplete: false, store: store)); |
There was a problem hiding this comment.
// Replace "#**…** >" with "#**…>" to trigger the topic autocomplete
// interaction for the channel.This seems helpful, but it also seems like its own "coherent idea" that should go in its own commit, for ease of review.
Also: I wouldn't expect side effects (setting value) in the autocompleteIntent method. I think of it as basically a getter, except by convention we write getters as methods when they involve expensive computation, basically as a hint to potential callers that they should think about performance.
Can we change value at a different layer? From Flutter docs, I think a custom TextInputFormatter looks like the right thing for this. It would need to do its own scan backward through the text, but that can be skipped except when there's a ">" to the left of the cursor.
Then this autocompleteIntent pseudo-getter would stay focused on the job of detecting an intent, making it much easier to reason about.
| final store = PerAccountStoreWidget.of(context); | ||
| final channel = store.streams[option.channelId]; | ||
|
|
||
| if (channel == null) return SizedBox.shrink(); |
There was a problem hiding this comment.
Yeah, in cases like ChannelDeleteEvent.
| icon = Icon(iconDataForStream(channel), size: _iconSize, | ||
| color: colorSwatchFor(context, store.subscriptions[channel.streamId])); |
| ]))), | ||
| ); |
There was a problem hiding this comment.
nit: put parens on same line
d9a3b48 to
442fd7e
Compare
|
Thanks @chrisbobbe for the review, and sorry for the late revision. Pushed new changes, PTAL. |
442fd7e to
0347af8
Compare
0347af8 to
e4dfa12
Compare
|
(rebased atop main, resolving conflicts.) |
chrisbobbe
left a comment
There was a problem hiding this comment.
Coming back to this, I see I had a partial review that I forgot to send—apologies, here it is.
| void invalidateChannelTopic(int channelId, {String? topic}) { | ||
| if (topic == null) { | ||
| _normalizedNamesByChannelTopic.remove(channelId); | ||
| _normalizedNameWordsByChannelTopic.remove(channelId); | ||
| } else { | ||
| _normalizedNamesByChannelTopic[channelId]?.remove(topic); | ||
| _normalizedNameWordsByChannelTopic[channelId]?.remove(topic); | ||
| } | ||
| } |
There was a problem hiding this comment.
Hmm, I thought I already said this but I'm not finding it for some reason:
Let's skip the complexity of nesting topics under channel IDs in these caches. We still want to memoize the work of producing these normalized forms. But the channel ID is irrelevant to that work: we can just have Maps keyed by topic display name, right:
_normalizedTopicDisplayNames_normalizedTopicDisplayNameWords
And we can skip cache invalidation too; there's no stateful input to the computation of "some topic" and ["some", "topic"] from "söme ToPiC".
|
|
||
| /// An [AutocompleteView] for a #channel>topic autocomplete interaction, | ||
| /// an example of a [ComposeAutocompleteView]. | ||
| class TopicLinkAutocompleteView extends AutocompleteView<TopicLinkAutocompleteQuery, TopicLinkAutocompleteResult> { |
There was a problem hiding this comment.
nit: double space before { at end of line
| ..topic.equals(topic); | ||
| } | ||
|
|
||
| test('misc', () async { |
There was a problem hiding this comment.
How about "smoke" instead of "misc" (as in "smoke test")
| ]); | ||
| }); | ||
|
|
||
| test('updates results when topics are loaded', () => awaitFakeAsync((async) async { |
There was a problem hiding this comment.
The "misc" test also checks that results are loaded, but I guess this test is different because it also checks the intermediate state after TopicLinkAutocompleteView.init and before the results are loaded. With the amount of code in each test, I'd prefer to just drop one of these tests; or could alternatively deduplicate with helper functions.
(edit: but see a later comment where I suggest a change that would make the intermediate state less interesting, by not showing the "channel result" or "new topic result")
There was a problem hiding this comment.
Dropped the first one.
| String channelLink(ZulipStream channel, {required PerAccountStore store}) { | ||
| /// | ||
| /// Set [pendingTopicAutocomplete] to `true` for an incomplete syntax: | ||
| /// #**…> or [#…](#…)> |
There was a problem hiding this comment.
nit: The two #s in the "fallback" form confused me at first; then I realized they had different meanings. How about spelling out more of the abbreviation, like [#…](#narrow…)>
| // otherwise '#**channel**' (which is a completed channel link syntax) and | ||
| // any text followed by that will always match. |
There was a problem hiding this comment.
This existing comment looks wrong; would you fix it? It should say "and any text following that" (or "after that"), right? (Similarly in _topicLinkIntentRegex where you copy-pasted the error.)
| // TODO: match the server constraints | ||
| const nameCharExclusions = r'\r\n'; | ||
| // TODO: incorporate the server constraints | ||
| const nameCharExclusions = r'>\r\n'; |
There was a problem hiding this comment.
">" is valid in a channel name, isn't it?, at least according to the comment here.
There's tension, though, because of this character's special role in topic-link autocomplete. Rather than add > to this list and explain why it's there, though (and you haven't explained it in this revision actually 🙂), can we instead cause topic-link autocomplete to "win" by switching the order of checking for matches of _topicLinkIntentRegex and _channelLinkIntentRegex? So that we check _topicLinkIntentRegex first.
There was a problem hiding this comment.
We could remove > from nameCharExclusions and reorder checking for matches, but it would mismatch some other syntaxes. For example, [#…](#narrow…)>topic, which is a fallback topic link intent with topic as the query string, would be detected as a channel link intent with narrow…)>topic as the query string.
How about renaming nameCharExclusions to something that describes the purpose more accurately; how about queryCharExclusions?
| int get _maxLookbackForAutocompleteIntent { | ||
| return 1 // intent character, e.g. "#" | ||
| + 2 // some optional characters e.g., "_" for silent mention or "**" | ||
|
|
||
| // Per the API doc, maxChannelNameLength is in Unicode code points. | ||
| // Longest autocomplete syntax is the fallback topic link intent (as of 2026-02): | ||
| // [#escapedChannelName](#narrow/channel/channelId-slugifiedChannelName)>topicName | ||
| return 2 // [# | ||
| // Largest length of an escaped channel name (see `compose.escapeChannelTopicAvoidedChars`). | ||
| + 5 * store.maxChannelNameLength | ||
| + 18 // ](#narrow/channel/ | ||
| + 19 // largest channel ID (9223372036854775807 — largest int) length | ||
| + 1 // hyphen character (-) | ||
| // Largest length of a slugified channel name (see `internal_link.narrowLinkFragment`). | ||
| + 3 * store.maxChannelNameLength | ||
| + 2 // )> | ||
| // Per the API doc, maxTopicLength is in Unicode code points. | ||
| // We walk the string by UTF-16 code units, and there might be one or two | ||
| // of those encoding each Unicode code point. | ||
| + 2 * store.maxChannelNameLength; | ||
| + 2 * store.maxTopicLength; | ||
| } |
There was a problem hiding this comment.
I see; the point is that we want to show the (probably single) autocomplete option when you backspace through the end of the topic-link syntax, and in particular we want do that even when the topic-link syntax is extremely long.
From some profiling data, it looks like this is unlikely to cause perf problems, at least—#mobile-team > profilePrint test on autocomplete with different maxlookback @ 💬—but I still think it might be best to hard-code a value, to save the dev overhead of maintaining this complicated expression. Happy to wait for Greg's thoughts in integration review though.
| if (topicIntentMatch == null) continue; | ||
| final channelId = topicIntentMatch[1] != null | ||
| ? _channelIdFromNarrow | ||
| : store.streamsByName[topicIntentMatch[2]]?.streamId; |
There was a problem hiding this comment.
Can we avoid adding this dependency on external state (channels) and handle the channel lookup at a different layer than this string-processing step?
I'm thinking:
TopicLinkAutocompleteQuerystores the channel name asString?, with null for the shorthand#>topiccase (which I learned about just today from reading this PR!)TopicLinkAutocompleteViewreceives the narrow from the widget code and stores that, just likeChannelLinkAutocompleteViewand otherAutocompleteViews do.TopicLinkAutocompleteViewlooks up the channel from the channel name on the query or from the narrow
There was a problem hiding this comment.
The current revision has this refactoring, but revisiting this, I am somewhat skeptical about it. 🙂
- I think it adds more complexity to the code, specifically to the
TopicLinkAutocompleteViewclass, where we should checkchannelId != nullin several places. - I think allowing
channelIdto benullinside theTopicLinkAutocompleteViewclass makes it a little hard to reason about, or at least raises the question about "how could it be null?", for which the main answer is that the channel doesn't exist! So if the channel doesn't exist, surely we don't want to provide the autocomplete for its topics. So wouldn't it be better to avoid triggering an autocomplete intent (thus creating aTopicLinkAutocompleteViewobject) in the first place? - There are already some dependencies on external states (eg,
store.maxChannelNameLengthandstore.maxTopicLength) inside theautocompleteIntentmethod. I am not suggesting that this is a legit reason to add more too, but I assume it would be a good trade-off for more code clarity.
| Future<void> _fetch() async { | ||
| // TODO: handle fetch failure | ||
| // TODO(#2154): do not fetch topics for "only general chat" channel | ||
| _topics = (await store.topics.getChannelTopics(channelId)).map((e) => e.name); | ||
| return _startSearch(); | ||
| } |
There was a problem hiding this comment.
This _startSearch() will always come after a _startSearch() that's doomed to find zero results because it happens before we get the topics list. I mean the one in the super constructor:
/// Construct a view-model for an autocomplete interaction,
/// and begin the search for the initial query.
AutocompleteView({required this.store, required this._query}) {
_startSearch();
store.autocompleteViewManager.registerAutocomplete(this);
}Can we do just one _startSearch() at the beginning instead of two? How about instead making the await store.topics.getChannelTopics call at the top of computeResults? It's true that computeResults is a hot path (called on each keystroke of the query) but getChannelTopics is intentionally designed to not fetch redundantly for the same channel.
I guess this means we'd delay showing the "channel result" or "new topic result" until the topics are fetched. That seems fine to me, and in fact more correct for the latter (because we can't know if the topic is "new" until we know the existing topics).
There was a problem hiding this comment.
Yeah, we could do that, but I wonder if it would make the user wait for the topics to be fetched, even if they only wanted to generate a channel link. Started a new discussion on CZO.
There was a problem hiding this comment.
As decided in the discussion linked above, brought the new changes.
680494c to
ee7bb81
Compare
|
Thanks @chrisbobbe for the review. Pushed new changes, PTAL. Also added a few replies in the thread above: #2137 (comment) and #2137 (comment). |
Before this commit, `shouldStop` detected a stale search by checking whether the query had changed. That cannot tell apart two searches for an equal query, which the widgets code does start: it sets `query` on each change to the text field, including changes that leave the query the same. (For example, any cursor movement in the compose box's topic input.) Both searches would then run to completion and notify twice; the fresh results win only because the newer search happens to be scheduled last. Soon, changes to the data being searched will also start a same-query search (zulip#2137, zulip#2326: e.g. on any change to `Topics`), and we don't want the results to depend on which of two searches over different data finishes first. So count searches with a generation number instead: capture it when a search starts, and stop when it has moved on. (This follows the pattern of `MessageListView.generation` for invalidating stale fetches.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Before this commit, `shouldStop` detected a stale search by checking whether the query had changed. That cannot tell apart two searches for an equal query, which the widget code does start: it sets `query` on each change to the text field, including changes that leave the query the same. (For example, any cursor movement in the compose box's topic input.) Both searches then run to completion and notify twice. That's harmless today beyond the wasted work, since both see the same data. But soon, changes to the data being searched will also start a same-query search (zulip#2137, zulip#2326: e.g. on any change to `Topics`), and then we don't want the results to depend on which of two searches over different data finishes first. So count searches with a generation number instead: capture it when a search starts, and stop when it has moved on. (This follows the pattern of `MessageListView.generation` for invalidating stale fetches.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In the next commit(s), this will become handy for efficiently determining if a topic exists, in constant time. Prompted by zulip#2137 (comment).
ce1dca1 to
2225301
Compare
In the next commit(s), this will become handy for efficiently determining if a topic exists, in constant time. Prompted by zulip#2137 (comment).
2225301 to
26c98e5
Compare
Before this commit, `shouldStop` detected a stale search by checking whether the query had changed. That cannot tell apart two searches for an equal query, which the widget code does start: it sets `query` on each change to the text field, including changes that leave the query the same. (For example, any cursor movement in the compose box's topic input.) Both searches then run to completion and notify twice. That's harmless today beyond the wasted work, since both see the same data. But soon, changes to the data being searched will also start a same-query search (zulip#2137, zulip#2326: e.g. on any change to `Topics`), and then we don't want the results to depend on which of two searches over different data finishes first. So count searches with a generation number instead: capture it when a search starts, and stop when it has moved on. (This follows the pattern of `MessageListView.generation` for invalidating stale fetches.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In the next commit(s), this will become handy for efficiently determining if a topic exists, in constant time. Prompted by zulip#2137 (comment).
26c98e5 to
4842f28
Compare
Before this commit, `shouldStop` detected a stale search by checking whether the query had changed. That cannot tell apart two searches for an equal query, which the widget code does start: it sets `query` on each change to the text field, including changes that leave the query the same. (For example, any cursor movement in the compose box's topic input.) Both searches then run to completion and notify twice. That's harmless today beyond the wasted work, since both see the same data. But soon, changes to the data being searched will also start a same-query search (zulip#2137, zulip#2326: e.g. on any change to `Topics`), and then we don't want the results to depend on which of two searches over different data finishes first. So count searches with a generation number instead: capture it when a search starts, and stop when it has moved on. (This follows the pattern of `MessageListView.generation` for invalidating stale fetches.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In the next commit(s), this will become handy for efficiently determining if a topic exists, in constant time. Prompted by zulip#2137 (comment).
This was missed when we first added the channel link autocomplete feature.
In the next commit(s), this will become handy for efficiently determining if a topic exists, in constant time. Prompted by zulip#2137 (comment).
`shouldStop` awaits `Future(() {})`, i.e. `Timer.run`,
which schedules an event-queue task, not a microtask.
The Dart event loop has two queues:
the event queue and the microtask queue.
Each time an item is dequeued from the event queue,
the event loop first drains the microtask queue.
Read more about the two queues:
https://web.archive.org/web/20170704074724/https://webdev.dartlang.org/articles/performance/event-loop
As of this commit, it's not yet possible in the app to initiate a topic link autocomplete interaction. So in the widgets code that would consume the results of such an interaction, we just throw for now, leaving that to be implemented in a later commit. Co-authored-by: Chris Bobbe <cbobbe@zulip.com>
This will be used in the next commits for generating incomplete syntax in addition to the previous completed syntax. The new incomplete syntax will be inserted into the compose box when a channel is chosen from the channel autocomplete. This way, the topic autocomplete interaction will be readily available. The old completed syntax will be inserted into the compose box when the channel is chosen from the topic autocomplete.
This will be used in the next commits for generating topic link syntax in the compose box when a topic is chosen from the topic link autocomplete.
…election This comment in `ComposeContentAutocomplete.autocompleteIntent` already says that we don't require [isCollapsed], because backspace and autocorrect transiently expand the selection to the left. Say what requiring it would cost: the widgets code tears down and recreates the view-model, causing the options list to stay hidden until the next edit, and the wasted work of recomputing the results from scratch makes backspacing through a query laggy and janky, with dropped frames.
We already cancel out an autocomplete intent whenever the selection is expanding to the left of the intent character (e.g @ or #). To simplify this, we can start the search for the intent character from the selection start, going leftwards. This is basically NFC, but in the new approach if there's an autocomplete intent inside the selection that will be cancelled, it still keeps looking for another autocomplete intent to the left, while previously it would cancel the looking process too. I think simplifying this part of the code is worth the additional looking which is basically harmless.
This help with better readability.
Also, simplify some of the implementation comments by removing the details that are already mentioned in the dartdoc. Additionally, add a new implementation comment to nameCharExclusions.
This will soon be shared by other regexes in the file.
For this commit we temporarily intercept the query at the AutocompleteField widget, to avoid invoking the widgets that are still unimplemented. That lets us defer those widgets' logic to a separate later commit.
A `TextInputFormatter` for the compose content input that will be used in the next commit(s) to replace a channel (fallback) link followed by a ">", with a pending topic link autocomplete syntax.
From the web repo; edited in Inkscape to remove the padding around it. https://github.com/zulip/zulip/blob/052655eee/web/icons/corner-down-right.svg
Fixes zulip#124. Adapted from the Figma design and the web app design: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=7953-30398&t=IeFc4HZDSNwrfGkU-0 https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/topic.20link.20autocomplete/near/2370602



Fixes: #124
A follow-up to #1902.
Design discussion: #mobile-design > topic link autocomplete
Screenshots
Screencast
Topic.link.autocomplete.mov