Skip to content

Topic link autocomplete#2137

Open
sm-sayedi wants to merge 10 commits intozulip:mainfrom
sm-sayedi:124-topic-link-autocomplete
Open

Topic link autocomplete#2137
sm-sayedi wants to merge 10 commits intozulip:mainfrom
sm-sayedi:124-topic-link-autocomplete

Conversation

@sm-sayedi
Copy link
Collaborator

@sm-sayedi sm-sayedi commented Feb 12, 2026

Fixes: #124

A follow-up to #1902.

Design discussion: #mobile-design > topic link autocomplete

Screenshots
Empty query (light) Empty query (dark)
1  Empty query (Light) 1  Empty query (Dark)
New topic Long names Shortcut syntax
2  New topic Screenshot 2026-03-06 at 9 05 58 PM 4  Shortcut syntax
Screen recording
Topic.link.autocomplete.mov

@sm-sayedi sm-sayedi force-pushed the 124-topic-link-autocomplete branch 2 times, most recently from 3ea07c0 to c98acb1 Compare February 17, 2026 18:49
@sm-sayedi sm-sayedi force-pushed the 124-topic-link-autocomplete branch from c98acb1 to c604d9e Compare February 17, 2026 19:28
@sm-sayedi sm-sayedi marked this pull request as ready for review February 17, 2026 19:28
@sm-sayedi sm-sayedi added the maintainer review PR ready for review by Zulip maintainers label Feb 17, 2026
@sm-sayedi sm-sayedi requested a review from chrisbobbe February 17, 2026 19:29
@alya
Copy link
Collaborator

alya commented Mar 6, 2026

Hmm, I think the multi-line channel name doesn't look great (topic is not too bad). On web, we abbreviate both to one line -- maybe we should do the same here?

Screenshot 2026-03-05 at 23 56 27@2x

@sm-sayedi sm-sayedi force-pushed the 124-topic-link-autocomplete branch 2 times, most recently from 06f335c to 07b4f45 Compare March 6, 2026 16:33
@sm-sayedi
Copy link
Collaborator Author

Thank you @alya for the suggestion. Made it one liner.

Before After
Screenshot 2026-03-06 at 8 50 11 PM Screenshot 2026-03-06 at 9 05 58 PM

This was missed when we first added the channel link autocomplete feature.
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.
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 in 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 to 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 autocomplete.
In the next commit(s), we'll need the narrow field in autocomplete intent.
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.
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.
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
In the next commit(s), this helper will be used in other test groups too.
@sm-sayedi sm-sayedi force-pushed the 124-topic-link-autocomplete branch from 07b4f45 to d9a3b48 Compare March 12, 2026 16:24
Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

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

Thanks! 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.

List<String> normalizedNameWordsForChannel(ZulipStream channel) {
return _normalizedNameWordsByChannel[channel.streamId]
?? normalizedNameForChannel(channel).split(' ');
??= normalizedNameForChannel(channel).split(' ');
Copy link
Collaborator

Choose a reason for hiding this comment

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

Indeed, thanks for catching this!

Comment on lines +1272 to 1275
List<String> normalizedNameWordsForChannelTopic(int channelId, String topic) {
return (_normalizedNameWordsByChannelTopic[channelId] ?? {})[topic]
??= normalizedNameForChannelTopic(channelId, topic).split(' ');
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
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?

Comment on lines +1670 to +1673
final queryTopicResult = query.testQueryTopic(TopicName(query.raw),
matchedTopics: unsorted.whereType<TopicLinkAutocompleteTopicResult>()
.map((r) => r.topic));
if (queryTopicResult != null) unsorted.add(queryTopicResult);
Copy link
Collaborator

Choose a reason for hiding this comment

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

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.

Comment on lines +1675 to +1676
return bucketSort(unsorted, (r) => r.rank,
numBuckets: TopicLinkAutocompleteQuery._numResultRanks);
Copy link
Collaborator

Choose a reason for hiding this comment

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

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.rank moves to subclass TopicLinkAutocompleteTopicResult.rank, because it's only about topics
  • _rankChannelResult disappears
  • _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;

Comment on lines +1718 to +1728
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));
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

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);
Copy link
Collaborator

Choose a reason for hiding this comment

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

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.

Comment on lines +86 to +88
value = value.replaced(
TextRange(start: pos, end: value.selection.end),
channelLink(channel, isComplete: false, store: store));
Copy link
Collaborator

Choose a reason for hiding this comment

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

            // 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();
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can this happen?

Comment on lines +453 to +454
icon = Icon(iconDataForStream(channel), size: _iconSize,
color: colorSwatchFor(context, store.subscriptions[channel.streamId]));
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: indentation

Comment on lines +487 to +488
]))),
);
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: put parens on same line

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.

stream/topic autocomplete (e.g. #**mobile**)

3 participants