-
Notifications
You must be signed in to change notification settings - Fork 350
Channel link autocomplete #1902
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
base: main
Are you sure you want to change the base?
Conversation
|
Exciting! Thanks for building this. Just to record here what I said on the team call yesterday: for this PR, we can start the reviews in parallel with you writing the tests. So I'd suggest going ahead and adding those docs and comments next — then once you consider the PR all ready except for the tests, just mention that here and add the "maintainer review" label. |
5a8171d to
6671cfa
Compare
|
Thanks @gnprice for mentioning this. This is now ready for an initial review. (While working on the first todo, I realized that there were a few other places that needed some changes, which caused a delay 😀) |
6671cfa to
fe25a32
Compare
|
(just rebased atop main with conflicts resolved) |
fe25a32 to
480e787
Compare
6b2fb06 to
5072dce
Compare
|
@chrisbobbe Pushed a new revision with tests included. |
|
Thanks for this, and apologies for my delay in reviewing! Here's a review of the first six commits: 05c8437 channel: Add isRecentlyActive field plus some comments on later commits where I happened to see something interesting. 🙂 |
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.
Oh no—thanks for the ping in DMs, somehow I didn't actually submit that review yesterday! Here it is.
test/example_data.dart
Outdated
| historyPublicToSubscribers: historyPublicToSubscribers ?? true, | ||
| messageRetentionDays: messageRetentionDays, | ||
| channelPostPolicy: channelPostPolicy ?? ChannelPostPolicy.any, | ||
| isRecentlyActive: isRecentlyActive ?? 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.
I think true might be a more natural default value for 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.
Changed to true. The false default value was to make the tests in "ranking across signals" and "final results end-to-end" of "ChannelLinkAutocompleteView" group a little less verbose.🙂
lib/api/model/model.dart
Outdated
| @JsonKey(name: 'stream_post_policy') | ||
| ChannelPostPolicy? channelPostPolicy; // TODO(server-10) remove | ||
| // final bool isAnnouncementOnly; // deprecated for `channelPostPolicy`; ignore | ||
| bool? isRecentlyActive; // TODO(server-10) |
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.
channel: Add isRecentlyActive field
Since we're already not matching the order in the API doc (see e.g. #1894 (comment) ), I'd put this next to the related-looking field streamWeeklyTraffic, perhaps just above it without an empty line in between.
Similarly elsewhere in this commit.
lib/api/model/events.dart
Outdated
| required super.id, | ||
| required this.streams, | ||
| required this.streamIds, | ||
| }) : assert(streams != null || streamIds != 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.
We try to reserve assert for invariants that are our own responsibility, i.e. those that won't be broken except for some broken logic in the client. Here, the invariant exists, but it's one that can be broken by something out of our control, in particular a badly-behaving server.
Also, asserts don't run in production, so this won't work as "crunchy shell" validation. It makes sense to want such validation, so the "soft center" of the app can rely on this invariant. But let's do it in ChannelDeleteEvent.fromJson; for an example to follow, see DeleteMessageEvent.fromJson.
lib/api/model/events.dart
Outdated
|
|
||
| final List<ZulipStream> streams; | ||
| final List<ZulipStreamId>? streams; // TODO(server-10): remove | ||
| final List<int>? streamIds; // TODO(server-10): remove nullability |
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: we'd normally say TODO(server-10) make required or just TODO(server-10).
lib/model/channel.dart
Outdated
| streams.remove(stream.streamId); | ||
| streamsByName.remove(stream.name); | ||
| subscriptions.remove(stream.streamId); | ||
| final channelIds = event.streamIds ?? event.streams!.map((e) => e.streamId); |
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.
Oh actually, we can make this nicer by encapsulating this conditional in the API-binding layer—ChannelDeleteEvent can have just final List<int> streamIds (maybe channelIds, as the more modern name?), and it can read its value depending on what the JSON looks like.
Something like this (untested)?:
/// A [ChannelEvent] with op `delete`: https://zulip.com/api/get-events#stream-delete
@JsonSerializable(fieldRename: FieldRename.snake)
class ChannelDeleteEvent extends ChannelEvent {
@override
@JsonKey(includeToJson: true)
String get op => 'delete';
@JsonKey(readValue: _readChannelIds)
final List<int> channelIds;
// TODO(server-10) simplify away; rely on stream_ids
static List<int> _readChannelIds(Map<dynamic, dynamic> json, String key) {
final channelIds = json['stream_ids'] as List<int>?;
if (channelIds != null) return channelIds;
final channels = json['streams'] as List<dynamic>;
return channels
.map((c) => (c as Map<String, dynamic>)['stream_id'] as int)
.toList();
}
ChannelDeleteEvent({
required super.id,
required this.channelIds,
});
factory ChannelDeleteEvent.fromJson(Map<String, dynamic> json) =>
_$ChannelDeleteEventFromJson(json);
@override
Map<String, dynamic> toJson() => _$ChannelDeleteEventToJson(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.
(In that code, the crunchy-shell validation is done by the final channels = json['streams'] as List<dynamic>; line, which will throw if both .stream_ids and .streams are absent in the json.)
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.
Changed to the new version. One thing that this does is that in toJson, there will be an entry with key channel_ids; not exactly what the server gives us (stream_ids or streams). Should we edit the toJson method to match the server data, or is it not that important since we don't use it to send it back to the server?
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.
Ahh, so there was a test failing in test/model/store_test.dart and the fix was to include streams in 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.
Hmm, streams is deprecated and may be removed, so I think we should treat it as valid if streams is absent and stream_ids is present. I'd want our tests to tolerate that form without failing.
I took a look and found a bug in _readChannelIds in this revision 🙂:
diff --git lib/api/model/events.dart lib/api/model/events.dart
index 6a0d9ffa4..4d9c5121c 100644
--- lib/api/model/events.dart
+++ lib/api/model/events.dart
@@ -622,7 +622,7 @@ class ChannelDeleteEvent extends ChannelEvent {
// TODO(server-10) simplify away; rely on stream_ids
static List<int> _readChannelIds(Map<dynamic, dynamic> json, String key) {
final channelIds = json['stream_ids'] as List<dynamic>?;
- if (channelIds != null) channelIds.map((id) => id as int).toList();
+ if (channelIds != null) return channelIds.map((id) => id as int).toList();
final channels = json['streams'] as List<dynamic>;
return channels
lib/widgets/autocomplete.dart
Outdated
| overflow: TextOverflow.ellipsis, | ||
| color: designVariables.contextMenuItemMeta)), | ||
| child: BlockContentList( | ||
| nodes: parseContent(channel!.renderedDescription).nodes), |
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.
Neat! Unfortunately we're not ready to show the rendered channel description; some of our content widgets will break if they appear outside the context of a message, because they use InheritedMessage.of(context), and we need to address that systematically, which is #488. See related issues:
- Show channel description in channel action sheet #1896
- content: Support Zulip content outside messages (even outside per-account contexts) #488
For now let's do as I did in #1877:
- Not try to show the channel description here
- File an issue for it and leave a TODO
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 #1945. Also, looking at https://github.com/zulip/zulip-flutter/blob/main/lib/widgets/content.dart, it seems like InheritedMessage.of(context) is used in two places, MessageImagePreview and MessageInlineVideo, and by manually testing, it seems like the server is not sending the related HTML for rendering these widgets when there is an image or video in the channel description. So I think it will be safe to show the channel description now. But as it’s possible that InheritedMessage.of(context) will be used in other widgets, it's good to wait for #488 as you mentioned.
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.
by manually testing, it seems like the server is not sending the related HTML for rendering these widgets when there is an image or video in the channel description. So I think it will be safe to show the channel description now.
For this sort of detail I'd want to rely on API guarantees rather than manual testing on a current server. It's at least plausible that we could run into different behavior with some old server we still support (like 7), or even on any server, including a modern one like CZO, for a channel that was last updated when the server version was ancient, like 3 or something.
But as it’s possible that
InheritedMessage.of(context)will be used in other widgets, it's good to wait for #488 as you mentioned.
Yep, this is also true :) it'll be nice to be systematic about it.
| // Behavior we have that web doesn't and might like to follow: | ||
| // - A "word-prefixes" match quality on channel names: | ||
| // see [NameMatchQuality.wordPrefixes], which we rank on. | ||
| // | ||
| // Behavior web has that seems undesired, which we don't plan to follow: | ||
| // - A "word-boundary" match quality on channel names: | ||
| // special rank when the whole query appears contiguously | ||
| // right after a word-boundary character. | ||
| // Our [NameMatchQuality.wordPrefixes] seems smarter. | ||
| // - Ranking some case-sensitive matches differently from case-insensitive | ||
| // matches. Users will expect a lowercase query to be adequate. |
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.
Am I reading web's sort_streams correctly that it also considers channel descriptions in the filtering and ranking? I don't personally think we need to do that, but it probably deserves a mention here.
lib/model/autocomplete.dart
Outdated
| return switch((tryCast<Subscription>(a), tryCast<Subscription>(b))) { | ||
| (Subscription(), null) => -1, | ||
| (null, Subscription()) => 1, | ||
| (Subscription(isMuted: false), Subscription(isMuted: true)) => -1, | ||
| (Subscription(isMuted: true), Subscription(isMuted: false)) => 1, | ||
| (Subscription(pinToTop: true), Subscription(pinToTop: false)) => -1, | ||
| (Subscription(pinToTop: false), Subscription(pinToTop: true)) => 1, | ||
| _ => 0, | ||
| }; |
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'd prefer not to add and use tryCast for this, and instead do something like:
| return switch((tryCast<Subscription>(a), tryCast<Subscription>(b))) { | |
| (Subscription(), null) => -1, | |
| (null, Subscription()) => 1, | |
| (Subscription(isMuted: false), Subscription(isMuted: true)) => -1, | |
| (Subscription(isMuted: true), Subscription(isMuted: false)) => 1, | |
| (Subscription(pinToTop: true), Subscription(pinToTop: false)) => -1, | |
| (Subscription(pinToTop: false), Subscription(pinToTop: true)) => 1, | |
| _ => 0, | |
| }; | |
| if (a is Subscription && b is! Subscription) return -1; | |
| if (a is! Subscription && b is Subscription) return 1; | |
| return switch((a, b)) { | |
| (Subscription(isMuted: false), Subscription(isMuted: true)) => -1, | |
| (Subscription(isMuted: true), Subscription(isMuted: false)) => 1, | |
| (Subscription(pinToTop: true), Subscription(pinToTop: false)) => -1, | |
| (Subscription(pinToTop: false), Subscription(pinToTop: true)) => 1, | |
| _ => 0, | |
| }; |
which is equivalent and doesn't add a step for the reader to interpret where null comes from and what it means. It also separates the main, headline logic from the rest, corresponding to the dartdoc's choice of what goes in its first line vs. the body:
/// Comparator that puts subscribed channels before unsubscribed ones.
///
/// For subscribed channels, it puts them in the following way:
/// pinned unmuted > unpinned unmuted > pinned muted > unpinned mutedThere 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 nit: s/way/order/ in that dartdoc)
lib/model/autocomplete.dart
Outdated
| /// Comparator that puts channels with more weekly traffic first. | ||
| /// | ||
| /// A channel with undefined weekly traffic (`null`) is put after the channel | ||
| /// with a weekly traffic defined (even if it is zero). | ||
| /// | ||
| /// Weekly traffic is the average number of messages sent to the channel per | ||
| /// week, which is determined by [ZulipStream.streamWeeklyTraffic]. |
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.
| /// Comparator that puts channels with more weekly traffic first. | |
| /// | |
| /// A channel with undefined weekly traffic (`null`) is put after the channel | |
| /// with a weekly traffic defined (even if it is zero). | |
| /// | |
| /// Weekly traffic is the average number of messages sent to the channel per | |
| /// week, which is determined by [ZulipStream.streamWeeklyTraffic]. | |
| /// Comparator that puts channels with more [ZulipStream.streamWeeklyTraffic] first. | |
| /// | |
| /// A channel with undefined weekly traffic (`null`) is put after the channel | |
| /// with a weekly traffic defined (even if it is zero). |
This is a very reasonable definition of "weekly traffic" 🙂 and so isn't likely to bitrot i.e. become incorrect over time. But since we're just using ZulipStream.streamWeeklyTraffic directly (no computations on it), let's leave that field's definition as the single place where we write its definition, so we only have to change that one thing if the meaning changes.
…I see that we haven't actually written down the field's meaning, which we might've done in dartdoc on the field. But that's quite normal and appropriate; by leaving it blank, we mean to defer to the API documentation, which is linked in the class ZulipStream dartdoc.
test/model/compose_test.dart
Outdated
| eg.stream(streamId: 5, name: 'UI [v2]'), | ||
| eg.stream(streamId: 6, name: r'Save $$'), | ||
| ]; | ||
| store.addStreams(channels); |
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 should be awaited; similarly in a few other places.
Thanks to the discarded_futures lint for catching this, actually; I was playing with it for #731 this morning 🙂
8cde339 to
ea64c45
Compare
|
Thanks @chrisbobbe for the review. Pushed new changes, PTAL. |
ea64c45 to
d1abf20
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! This is working great; comments below, this time from reading the whole branch.
lib/api/model/events.dart
Outdated
| case ChannelPropertyName.isRecentlyActive: | ||
| return value as bool?; |
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: bump on #1902 (comment)
test/example_data.dart
Outdated
| case ChannelPropertyName.isRecentlyActive: | ||
| assert(value is bool?); |
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: bump on #1902 (comment)
lib/model/channel.dart
Outdated
| streams.remove(stream.streamId); | ||
| streamsByName.remove(stream.name); | ||
| subscriptions.remove(stream.streamId); | ||
| final channelIds = event.streamIds ?? event.streams!.map((e) => e.streamId); |
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, streams is deprecated and may be removed, so I think we should treat it as valid if streams is absent and stream_ids is present. I'd want our tests to tolerate that form without failing.
I took a look and found a bug in _readChannelIds in this revision 🙂:
diff --git lib/api/model/events.dart lib/api/model/events.dart
index 6a0d9ffa4..4d9c5121c 100644
--- lib/api/model/events.dart
+++ lib/api/model/events.dart
@@ -622,7 +622,7 @@ class ChannelDeleteEvent extends ChannelEvent {
// TODO(server-10) simplify away; rely on stream_ids
static List<int> _readChannelIds(Map<dynamic, dynamic> json, String key) {
final channelIds = json['stream_ids'] as List<dynamic>?;
- if (channelIds != null) channelIds.map((id) => id as int).toList();
+ if (channelIds != null) return channelIds.map((id) => id as int).toList();
final channels = json['streams'] as List<dynamic>;
return channels| for (final view in _channelLinkAutocompleteViews) { | ||
| view.reassemble(); | ||
| } | ||
| } |
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.
Sure! Sounds like that would help us avoid a bug like the one fixed here.
lib/model/autocomplete.dart
Outdated
| // Similar reasoning as in _mentionIntentRegex. | ||
| const before = r'(?<=^|\s|\p{Punctuation})'; |
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.
| // As Web, match both '#channel' and '#**channel'. In both cases, the raw | ||
| // query is going to be 'channel'. Matching the second case ('#**channel') | ||
| // is useful when the user selects a channel from the autocomplete list, but | ||
| // then starts pressing "backspace" to edit the query and choose another | ||
| // option, instead of clearing the entire query and starting from scratch. |
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. Looks like web also does the corresponding thing for @-mentions: https://github.com/zulip/zulip/blob/1e78447c5/web/src/composebox_typeahead.ts#L516-L531
function filter_mention_name(current_token: string): string | undefined {
if (current_token.startsWith("**")) {
current_token = current_token.slice(2);
} else if (current_token.startsWith("*")) {
current_token = current_token.slice(1);
}
if (current_token.includes("*")) {
return undefined;
}
// Don't autocomplete if there is a space following an '@'
if (current_token.startsWith(" ")) {
return undefined;
}
return current_token;
}This is maybe more important for channel/topic autocomplete, right? Because (once the topic part is implemented) you might backspace as part of figuring out how to get just a channel link without a topic. But this is a good prompt to file an issue and add a TODO for doing this with @-mention autocomplete; would you do those?
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.
Sure, that would be an improvement. I have always missed that feature for @-mentions; filed #1967.
| } else { | ||
| icon = null; | ||
| iconColor = null; | ||
| label = zulipLocalizations.unknownChannelName; |
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 normally show "(unknown channel)" in italics, to distinguish it from a potential channel with that name.
lib/widgets/autocomplete.dart
Outdated
| padding: EdgeInsetsDirectional.fromSTEB(12, 4, 10, 4), | ||
| child: Row(spacing: 10, children: [ | ||
| SizedBox.square(dimension: 24, | ||
| child: Icon(size: 18, color: iconColor, icon)), |
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 rows have 44px height in the Figma, but this gives 32px height (unless increased via the device text-size setting).
To bring it up to 44px, we could structure it similarly to MentionAutocompleteItem—
| padding: EdgeInsetsDirectional.fromSTEB(12, 4, 10, 4), | |
| child: Row(spacing: 10, children: [ | |
| SizedBox.square(dimension: 24, | |
| child: Icon(size: 18, color: iconColor, icon)), | |
| padding: EdgeInsetsDirectional.fromSTEB(4, 4, 8, 4), | |
| child: Row(spacing: 6, children: [ | |
| SizedBox.square(dimension: 36, | |
| child: Center( | |
| child: Icon(size: 18, color: iconColor, icon))), |
—which could be helpful in a potential future where we made a generic AutocompleteItem widget that serves both kinds of autocomplete.
lib/model/compose.dart
Outdated
| /// | ||
| /// [fallbackMarkdownLink] will be used if the channel name includes some faulty | ||
| /// characters that will break normal #**channel** rendering. | ||
| String channelLinkSyntax(ZulipStream 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.
Just channelLink, I think; the "syntax" part is implied. (As in existing functions in this file, like quoteAndReply which creates quote-and-reply syntax, or userMention which creates user-mention syntax.)
| /// Markdown link for channel, topic, or message when the channel or topic name | ||
| /// includes characters that will break normal markdown rendering. | ||
| /// | ||
| /// Refer to [_channelTopicFaultyCharsReplacements] for a complete list of | ||
| /// these characters. | ||
| // Corresponds to `topic_link_util.get_fallback_markdown_link` in Zulip web; | ||
| // https://github.com/zulip/zulip/blob/b42d3e77e/web/src/topic_link_util.ts#L96-L108 | ||
| String fallbackMarkdownLink({ |
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.
channel: Finish channel link autocomplete for compose box
Could you separate this special character-replacement logic into its own commit? I want to see if we can ground our reasoning in API documentation. As far as that goes, there's nothing "invalid" or "faulty" about these characters appearing in channel names.
d1abf20 to
acb6615
Compare
|
Thanks @chrisbobbe for the previous review. Pushed a new revision, with some new commits added, PTAL. 65ad611 api: Add InitialSnapshot.maxChannelNameLength (The media in the PR description has also been updated.) |
acb6615 to
513bc80
Compare
|
Ah I see this has gathered some conflicts; would you resolve those please? |
513bc80 to
6d28107
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! A few comments below.
| final Set<MentionAutocompleteView> _mentionAutocompleteViews = {}; | ||
| final Set<TopicAutocompleteView> _topicAutocompleteViews = {}; | ||
| final Set<EmojiAutocompleteView> _emojiAutocompleteViews = {}; | ||
| final Set<AutocompleteView> _autocompleteViews = {}; |
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.
autocomplete: Introduce `AutocompleteViewManager._autocompleteViews`
I'd prefer to separate the bugfix commit from the NFC (refactor) commit, so each can be verified independently.
lib/model/autocomplete.dart
Outdated
| /// and begin the search for the initial query. | ||
| AutocompleteView({required this.store, required QueryT query}) | ||
| : _query = query { | ||
| store.autocompleteViewManager.registerAutocomplete(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.
Does this change the order of this and the _startSearch() call, and if so, is that OK?
I'm not sure if this is the right place for the .registerAutocomplete call—it might be best for it to stay in the subclasses' init factories.
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 the order will not make a difference. This line adds the autocomplete view to the manager so that its reassemble method is called during hot reload, calling _startSearch again. In the new revision, I swapped its order with _startSearch so as to match its calling order with what was before the refactor.
I think staying .registerAutocomplete in subclasses' init factories won't make a difference with the current state of the codebase. Please let me know if I am missing something, I'd be glad to move it back there. 🙂
lib/widgets/compose_box.dart
Outdated
| ComposeBoxController({required this.store}) | ||
| : content = ComposeContentController(store: store); | ||
|
|
||
| final PerAccountStore store; | ||
| final ComposeContentController content; |
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.
No need for store to be a field on ComposeBoxController, I think; the constructor can just pass it to ComposeContentController and then drop it:
| ComposeBoxController({required this.store}) | |
| : content = ComposeContentController(store: store); | |
| final PerAccountStore store; | |
| final ComposeContentController content; | |
| ComposeBoxController({required PerAccountStore store}) | |
| : content = ComposeContentController(store: store); | |
| final ComposeContentController content; |
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 felt kind of unsatisfied with the way ComposeTopicController (and now ComposeContentController) hold references to the store; it feels like the kind of detail that could be involved in bugs where we mishandle logging out, or dealing with expired event queues.
I have a local branch sketching out how things could look if we remove the store field from ComposeTopicController, and instead ask callers for a store when they want interpretations of the topic-input value (textNormalized, validationErrors, etc.). @gnprice do you want to see that? The change feels a little awkward: if controller is our ComposeBoxController, instead of saying controller.topic.textNormalized we'd say controller.topic.interpretValue(store).textNormalized. It's more verbose, and also store is often disregarded because interpretValue caches its result to save computation.
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 point re: the store changing.
What if we have it hold a BuildContext instead? Then it can use that to look up the store when needed, without complicating the API for its getters and methods.
… On the other hand: there's only one place these controllers live (outside of tests), namely _ComposeBoxState. We have an onNewStore implementation there which seems like it should be solid for keeping the store fields up to date. So I think we can be pretty confident there isn't an existing bug of this form, and I think this isn't particularly vulnerable to gaining such a bug in the future.
| int get _maxLookbackForAutocompleteIntent { | ||
| return 1 // intent character i.e., "#" | ||
| + 2 // some optional characters i.e., "_" for silent mention or "**" | ||
| + store.maxChannelNameLength; | ||
| } |
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.
| int get _maxLookbackForAutocompleteIntent { | |
| return 1 // intent character i.e., "#" | |
| + 2 // some optional characters i.e., "_" for silent mention or "**" | |
| + store.maxChannelNameLength; | |
| } | |
| int get _maxLookbackForAutocompleteIntent { | |
| return 1 // intent character i.e., "#" | |
| + 2 // some optional characters i.e., "_" for silent mention or "**" | |
| // Per the API doc, maxChannelNameLength 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; | |
| } |
I think?
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 nit: "e.g.", not "i.e.": https://www.merriam-webster.com/grammar/ie-vs-eg-abbreviation-meaning-usage-difference
| /// these characters. | ||
| // Corresponds to `topic_link_util.get_fallback_markdown_link` in Zulip web; | ||
| // https://github.com/zulip/zulip/blob/b42d3e77e/web/src/topic_link_util.ts#L96-L108 | ||
| String fallbackMarkdownLink({ |
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 web, when I tap the channel autocomplete option "chris-public-test-*", it gives
[#chris-public-test-*](#narrow/channel/533-chris-public-test-*)
but mobile gives
[chris-public-test-*](#narrow/channel/533-chris-public-test-*)
so I think we should start the text part with "#".
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.
Absolutely, thanks for the catch.
Right now, this is useful in how far back from the cursor we look to find a channel-link autocomplete (actually any autocomplete) interaction in compose box.
In the following commits, this will be used as one of the criteria for sorting channels in channel link autocomplete.
There are new changes made to `stream op: delete` event in server-10:
- The `streams` field which used to be an array of the just-deleted
channel objects is now an array of objects which only contains IDs
of the just-deleted channels (the app throws away its event queue
and reregisters before this commit).
- The same `streams` field is also deprecated and will be removed in a
future release.
- As a replacement to `streams`, `stream_ids` is introduced which is
an array of the just-deleted channels IDs.
Related CZO discussion:
https://chat.zulip.org/#narrow/channel/378-api-design/topic/stream.20deletion.20events/near/2284969
So to call AutocompleteViewManager.unregisterEmojiAutocomplete.
…iews` This set replaces the three sets of different `AutocompleteView` subclasses, simplifying the code.
These two methods were introduced but never called.
Also, generalize the dartdoc of NameMatchQuality. For almost all types of autocompletes, the matching mechanism/quality to an autocomplete query seems to be the same with rare exceptions (at the time of writing this —— 2025-11, only the emoji autocomplete matching mechanism is different).
As of this commit, it's not yet possible in the app to initiate a channel 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 way, subclasses can use the reference to the store for different purposes, such as using `max_topic_length` for the topic length instead of the hard-coded limit of 60, or using `max_stream_name_length` for how far back from the cursor we look to find a channel-link autocomplete interaction in compose box.
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.
This will make it easy to use the fragment string in several other places, such as in the next commits where we need to create a fallback markdown link for a channel.
d4e2458 to
b654dd1
Compare
|
Thanks for the review. New changes pushed. |
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! LGTM, marking for Greg's review.
| // '🙂' is 2 utf-16 code units. | ||
| doTest('check ~#**🙂🙂🙂🙂🙂^', channelLink('🙂🙂🙂🙂🙂'), maxChannelName: 5); | ||
| doTest('check #**🙂🙂🙂🙂🙂🙂^', null, maxChannelName: 5); |
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.
Nice, these are helpful test cases.
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 building this, and @chrisbobbe for the previous reviews!
Here's a review of the first 10 commits:
fac4457 api: Add InitialSnapshot.maxChannelNameLength
fd4d629 realm: Add RealmStore.maxChannelNameLength
ca7414b api: Add ZulipStream.isRecentlyActive
5b7d81b api: Update ChannelDeleteEvent to match new API changes
5563131 autocomplete: Call EmojiAutocompleteView.reassemble where needed
89a8a40 emoji: Add EmojiAutocompleteView.dispose
b7c5278 autocomplete [nfc]: Introduce AutocompleteViewManager._autocompleteViews
9696df9 store: Call AutocompleteViewManager.handleUserGroupRemove/UpdateEvent
994944e autocomplete [nfc]: Move _matchName up to AutocompleteQuery
64d68a0 autocomplete: Add view-model ChannelLinkAutocompleteView
except for the last one's tests. Still ahead are those tests and the remaining 8 commits:
3543b71 autocomplete test [nfc]: Use MarkedTextParse as the return type of parseMarkedText
1a9bbf6 compose: Introduce PerAccountStore in ComposeController
267f7b0 autocomplete: Identify when the user intends a channel link autocomplete
b58f34c autocomplete [nfc]: Add a TODO(#1967) for ignoring starting "**" after "#"
5510c30 autocomplete test: Make setupToComposeInput accept channels param
a6d0938 internal_link [nfc]: Factor out constructing fragment in its own method
831ccf1 compose: Introduce fallbackMarkdownLink function
b654dd1 channel: Finish channel link autocomplete for compose box
| case ChannelPropertyName.isRecentlyActive: | ||
| return value as bool?; |
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 should be just bool, right? This property will be a bool when it exists at all.
| final channel = streams[channelId]; | ||
| if (channel == null) break; | ||
| assert(identical(streams[channel.streamId], streamsByName[channel.name])); | ||
| assert(subscriptions[channelId] == null | ||
| || identical(subscriptions[channelId], streams[channelId])); | ||
| streams.remove(channel.streamId); | ||
| streamsByName.remove(channel.name); | ||
| subscriptions.remove(channel.streamId); |
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's kind of confusing how this alternates between channelId and channel.streamId. Those should be equal. It'd therefore be clearer to assert those are equal, and then use channelId throughout.
| final channel = streams[channelId]; | ||
| if (channel == null) break; | ||
| assert(identical(streams[channel.streamId], streamsByName[channel.name])); | ||
| assert(subscriptions[channelId] == null | ||
| || identical(subscriptions[channelId], streams[channelId])); |
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.
Similarly, since this now looks up streams[channelId] up front, it can skip repeating that lookup in these asserts.
| final channel = streams[channelId]; | ||
| if (channel == null) break; | ||
| assert(identical(streams[channel.streamId], streamsByName[channel.name])); | ||
| assert(subscriptions[channelId] == null | ||
| || identical(subscriptions[channelId], streams[channelId])); | ||
| streams.remove(channel.streamId); |
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.
Rather than look up the channel by ID once, then look it up again as part of remove, this can call remove up front and keep the return value.
(The existing code has asserts that lack that kind of optimization — but they're asserts, so only run in debug builds anyway and optimization is less of a concern.)
| final channelId = switch (narrow) { | ||
| ChannelNarrow(:var streamId) || TopicNarrow(:var streamId) => streamId, | ||
| DmNarrow() => null, | ||
| CombinedFeedNarrow() | ||
| || MentionsNarrow() | ||
| || StarredMessagesNarrow() | ||
| || KeywordSearchNarrow() => () { |
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 a switch statement (like in MentionAutocompleteView) would be easier to read here.
One reason is that the formatting would be more consistent that way:
- each case gets one line (vs. here where TopicNarrow appears off on the right);
- each outcome starts at the same column (vs. here where
streamId,null, and the IIFE all start in quite different places).
The switch statement would also simplify diffs when adding (or removing) subclasses for Narrow: just insert (or remove) lines case FooNarrow:, vs. here where existing lines might need to be edited to add or remove a || prefix or => () { suffix. Put another way: it's helpful when the different items in a list are all formatted the same, vs. the first or last items being formatted differently.
| /// A channel with undefined weekly traffic (`null`) is put after the channel | ||
| /// with a weekly traffic defined (even if it is zero). |
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 that behavior desired?
From the code at your handy link above (https://github.com/zulip/zulip/blob/c3fdee6ed/web/src/typeahead_helper.ts#L972-L988), it looks like web treats unknown weekly traffic equal to zero.
From the API docs for stream_weekly_traffic:
If
null, the channel was recently created and there is insufficient data to estimate the average traffic.
That seems like it should rank at least as high as a value of zero.
So probably we should just match the web behavior.
| return switch ((a.streamId, b.streamId)) { | ||
| (int id, _) when id == composingToChannelId => -1, | ||
| (_, int id) when id == composingToChannelId => 1, |
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.
When both channels match, this gives -1 but should give 0.
| static int compareByRecentActivity(ZulipStream a, ZulipStream b) { | ||
| return switch((a.isRecentlyActive, b.isRecentlyActive)) { | ||
| (true, false) => -1, | ||
| (false, true) => 1, |
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 corresponding logic in web seems to be a bit more complicated, in stream_list_sort.ts:
export function has_recent_activity(sub: StreamSubscription): boolean {
if (!filter_out_inactives || sub.pin_to_top) {
// If users don't want to filter inactive streams
// to the bottom, we respect that setting and don't
// treat any streams as dormant.
//
// Currently this setting is automatically determined
// by the number of streams. See the callers
// to set_filter_out_inactives.
return true;
}
return sub.is_recently_active || sub.newly_subscribed;Can you explain why we don't match those other wrinkles?
| // Check `typeahead_helper.compare_by_activity` in Zulip web; | ||
| // We follow the behavior of Web but with a small difference in that Web | ||
| // compares "recent activity" only for subscribed channels, but we do it | ||
| // for unsubscribed ones too. | ||
| // https://github.com/zulip/zulip/blob/c3fdee6ed/web/src/typeahead_helper.ts#L972-L988 | ||
| static int _compareByRelevance(ZulipStream a, ZulipStream b, { |
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 sounds like an implementation comment, so let's put it inside the method's body.
For an example of how to present a (more complex) comparison with web's behavior, see EmojiAutocompleteView._rankResult. That might in particular be helpful when adding an answer to my question about compareByRecentActivity vs. stream_list_sort.has_recent_activity.
| // - Matching and ranking on channel descriptions but only when the query | ||
| // is present (but not an exact match, total-prefix, or word-boundary match) | ||
| // in the channel name. This doesn't seem to be helpful in most cases, | ||
| // because it is hard for a query to be present in the name (the way | ||
| // mentioned before) and also present in the description. |
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.
Weird! Sounds like a bug.
Fixes-partly: #124 (topic link autocomplete will be its own PR)
Screenshots
Screen recording
Channel.autocomplete.recording.mov