From 236c11c8a1ee0ea9adeacb62361943bd8ada62d2 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 29 Oct 2025 12:42:39 +0430 Subject: [PATCH 01/16] api: Add InitialSnapshot.maxChannelNameLength --- lib/api/model/initial_snapshot.dart | 3 +++ lib/api/model/initial_snapshot.g.dart | 2 ++ test/example_data.dart | 2 ++ 3 files changed, 7 insertions(+) diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index eeedcde14d..95bf33fa3e 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -24,6 +24,8 @@ class InitialSnapshot { final List customProfileFields; + @JsonKey(name: 'max_stream_name_length') + final int maxChannelNameLength; final int maxTopicLength; final int serverPresencePingIntervalSeconds; @@ -160,6 +162,7 @@ class InitialSnapshot { required this.zulipMergeBase, required this.alertWords, required this.customProfileFields, + required this.maxChannelNameLength, required this.maxTopicLength, required this.serverPresencePingIntervalSeconds, required this.serverPresenceOfflineThresholdSeconds, diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 1c5505a653..bdb23179d1 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -22,6 +22,7 @@ InitialSnapshot _$InitialSnapshotFromJson( customProfileFields: (json['custom_profile_fields'] as List) .map((e) => CustomProfileField.fromJson(e as Map)) .toList(), + maxChannelNameLength: (json['max_stream_name_length'] as num).toInt(), maxTopicLength: (json['max_topic_length'] as num).toInt(), serverPresencePingIntervalSeconds: (json['server_presence_ping_interval_seconds'] as num).toInt(), @@ -153,6 +154,7 @@ Map _$InitialSnapshotToJson( 'zulip_merge_base': instance.zulipMergeBase, 'alert_words': instance.alertWords, 'custom_profile_fields': instance.customProfileFields, + 'max_stream_name_length': instance.maxChannelNameLength, 'max_topic_length': instance.maxTopicLength, 'server_presence_ping_interval_seconds': instance.serverPresencePingIntervalSeconds, diff --git a/test/example_data.dart b/test/example_data.dart index a6e3e9655d..7ef1f19367 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1312,6 +1312,7 @@ InitialSnapshot initialSnapshot({ String? zulipMergeBase, List? alertWords, List? customProfileFields, + int? maxChannelNameLength, int? maxTopicLength, int? serverPresencePingIntervalSeconds, int? serverPresenceOfflineThresholdSeconds, @@ -1367,6 +1368,7 @@ InitialSnapshot initialSnapshot({ zulipMergeBase: zulipMergeBase ?? recentZulipVersion, alertWords: alertWords ?? ['klaxon'], customProfileFields: customProfileFields ?? [], + maxChannelNameLength: maxChannelNameLength ?? 60, maxTopicLength: maxTopicLength ?? 60, serverPresencePingIntervalSeconds: serverPresencePingIntervalSeconds ?? 60, serverPresenceOfflineThresholdSeconds: serverPresenceOfflineThresholdSeconds ?? 140, From 9c7c25f01f40a5863fea463849f679ba4de0f3a0 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 29 Oct 2025 13:42:53 +0430 Subject: [PATCH 02/16] realm: Add RealmStore.maxChannelNameLength 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. --- lib/model/realm.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/model/realm.dart b/lib/model/realm.dart index 5255e50623..9acfbda757 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -76,6 +76,7 @@ mixin RealmStore on PerAccountStoreBase, UserGroupStore { Map get realmDefaultExternalAccounts; + int get maxChannelNameLength; int get maxTopicLength; //|////////////////////////////// @@ -194,6 +195,8 @@ mixin ProxyRealmStore on RealmStore { @override Map get realmDefaultExternalAccounts => realmStore.realmDefaultExternalAccounts; @override + int get maxChannelNameLength => realmStore.maxChannelNameLength; + @override int get maxTopicLength => realmStore.maxTopicLength; @override List get customProfileFields => realmStore.customProfileFields; @@ -244,6 +247,7 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { realmDeleteOwnMessagePolicy = initialSnapshot.realmDeleteOwnMessagePolicy, _realmEmptyTopicDisplayName = initialSnapshot.realmEmptyTopicDisplayName, realmDefaultExternalAccounts = initialSnapshot.realmDefaultExternalAccounts, + maxChannelNameLength = initialSnapshot.maxChannelNameLength, maxTopicLength = initialSnapshot.maxTopicLength, customProfileFields = _sortCustomProfileFields(initialSnapshot.customProfileFields); @@ -411,6 +415,8 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { @override final Map realmDefaultExternalAccounts; + @override + final int maxChannelNameLength; @override final int maxTopicLength; From 5ff30b86497dfcacddb18c68340510c439d54558 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 6 Oct 2025 21:34:55 +0430 Subject: [PATCH 03/16] api: Add ZulipStream.isRecentlyActive In the following commits, this will be used as one of the criteria for sorting channels in channel link autocomplete. --- lib/api/model/events.dart | 2 ++ lib/api/model/events.g.dart | 1 + lib/api/model/model.dart | 5 +++++ lib/api/model/model.g.dart | 5 +++++ lib/model/channel.dart | 2 ++ test/example_data.dart | 5 +++++ 6 files changed, 20 insertions(+) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index e095c5360b..0ff9471f8f 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -691,6 +691,8 @@ class ChannelUpdateEvent extends ChannelEvent { case ChannelPropertyName.canSendMessageGroup: case ChannelPropertyName.canSubscribeGroup: return GroupSettingValue.fromJson(value); + case ChannelPropertyName.isRecentlyActive: + return value as bool?; case ChannelPropertyName.streamWeeklyTraffic: return value as int?; case null: diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index dff0d21fa2..3581d8b914 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -468,6 +468,7 @@ const _$ChannelPropertyNameEnumMap = { ChannelPropertyName.canDeleteOwnMessageGroup: 'can_delete_own_message_group', ChannelPropertyName.canSendMessageGroup: 'can_send_message_group', ChannelPropertyName.canSubscribeGroup: 'can_subscribe_group', + ChannelPropertyName.isRecentlyActive: 'is_recently_active', ChannelPropertyName.streamWeeklyTraffic: 'stream_weekly_traffic', }; diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index da12823520..57c9e474de 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -669,6 +669,7 @@ class ZulipStream { GroupSettingValue? canSendMessageGroup; // TODO(server-10) GroupSettingValue? canSubscribeGroup; // TODO(server-10) + bool? isRecentlyActive; // TODO(server-10) // TODO(server-8): added in FL 199, was previously only on [Subscription] objects int? streamWeeklyTraffic; @@ -691,6 +692,7 @@ class ZulipStream { required this.canDeleteOwnMessageGroup, required this.canSendMessageGroup, required this.canSubscribeGroup, + required this.isRecentlyActive, required this.streamWeeklyTraffic, }); @@ -715,6 +717,7 @@ class ZulipStream { canDeleteOwnMessageGroup: subscription.canDeleteOwnMessageGroup, canSendMessageGroup: subscription.canSendMessageGroup, canSubscribeGroup: subscription.canSubscribeGroup, + isRecentlyActive: subscription.isRecentlyActive, streamWeeklyTraffic: subscription.streamWeeklyTraffic, ); } @@ -752,6 +755,7 @@ enum ChannelPropertyName { canDeleteOwnMessageGroup, canSendMessageGroup, canSubscribeGroup, + isRecentlyActive, streamWeeklyTraffic; /// Get a [ChannelPropertyName] from a raw, snake-case string we recognize, else null. @@ -837,6 +841,7 @@ class Subscription extends ZulipStream { required super.canDeleteOwnMessageGroup, required super.canSendMessageGroup, required super.canSubscribeGroup, + required super.isRecentlyActive, required super.streamWeeklyTraffic, required this.desktopNotifications, required this.emailNotifications, diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index b835c493c5..43915affd3 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -267,6 +267,7 @@ ZulipStream _$ZulipStreamFromJson(Map json) => ZulipStream( canSubscribeGroup: json['can_subscribe_group'] == null ? null : GroupSettingValue.fromJson(json['can_subscribe_group']), + isRecentlyActive: json['is_recently_active'] as bool?, streamWeeklyTraffic: (json['stream_weekly_traffic'] as num?)?.toInt(), ); @@ -290,6 +291,7 @@ Map _$ZulipStreamToJson(ZulipStream instance) => 'can_delete_own_message_group': instance.canDeleteOwnMessageGroup, 'can_send_message_group': instance.canSendMessageGroup, 'can_subscribe_group': instance.canSubscribeGroup, + 'is_recently_active': instance.isRecentlyActive, 'stream_weekly_traffic': instance.streamWeeklyTraffic, }; @@ -333,6 +335,7 @@ Subscription _$SubscriptionFromJson(Map json) => Subscription( canSubscribeGroup: json['can_subscribe_group'] == null ? null : GroupSettingValue.fromJson(json['can_subscribe_group']), + isRecentlyActive: json['is_recently_active'] as bool?, streamWeeklyTraffic: (json['stream_weekly_traffic'] as num?)?.toInt(), desktopNotifications: json['desktop_notifications'] as bool?, emailNotifications: json['email_notifications'] as bool?, @@ -364,6 +367,7 @@ Map _$SubscriptionToJson(Subscription instance) => 'can_delete_own_message_group': instance.canDeleteOwnMessageGroup, 'can_send_message_group': instance.canSendMessageGroup, 'can_subscribe_group': instance.canSubscribeGroup, + 'is_recently_active': instance.isRecentlyActive, 'stream_weekly_traffic': instance.streamWeeklyTraffic, 'desktop_notifications': instance.desktopNotifications, 'email_notifications': instance.emailNotifications, @@ -554,6 +558,7 @@ const _$ChannelPropertyNameEnumMap = { ChannelPropertyName.canDeleteOwnMessageGroup: 'can_delete_own_message_group', ChannelPropertyName.canSendMessageGroup: 'can_send_message_group', ChannelPropertyName.canSubscribeGroup: 'can_subscribe_group', + ChannelPropertyName.isRecentlyActive: 'is_recently_active', ChannelPropertyName.streamWeeklyTraffic: 'stream_weekly_traffic', }; diff --git a/lib/model/channel.dart b/lib/model/channel.dart index 10e9596eed..8f35cfa6bb 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -458,6 +458,8 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore { stream.canSendMessageGroup = event.value as GroupSettingValue; case ChannelPropertyName.canSubscribeGroup: stream.canSubscribeGroup = event.value as GroupSettingValue; + case ChannelPropertyName.isRecentlyActive: + stream.isRecentlyActive = event.value as bool?; case ChannelPropertyName.streamWeeklyTraffic: stream.streamWeeklyTraffic = event.value as int?; } diff --git a/test/example_data.dart b/test/example_data.dart index 7ef1f19367..04450883db 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -471,6 +471,7 @@ ZulipStream stream({ GroupSettingValue? canDeleteOwnMessageGroup, GroupSettingValue? canSendMessageGroup, GroupSettingValue? canSubscribeGroup, + bool? isRecentlyActive, int? streamWeeklyTraffic, }) { if (channelPostPolicy == null) { @@ -503,6 +504,7 @@ ZulipStream stream({ canDeleteOwnMessageGroup: canDeleteOwnMessageGroup ?? GroupSettingValueNamed(nobodyGroup.id), canSendMessageGroup: canSendMessageGroup, canSubscribeGroup: canSubscribeGroup ?? GroupSettingValueNamed(nobodyGroup.id), + isRecentlyActive: isRecentlyActive ?? true, streamWeeklyTraffic: streamWeeklyTraffic, ); } @@ -548,6 +550,7 @@ Subscription subscription( canDeleteOwnMessageGroup: stream.canDeleteOwnMessageGroup, canSendMessageGroup: stream.canSendMessageGroup, canSubscribeGroup: stream.canSubscribeGroup, + isRecentlyActive: stream.isRecentlyActive, streamWeeklyTraffic: stream.streamWeeklyTraffic, desktopNotifications: desktopNotifications ?? false, emailNotifications: emailNotifications ?? false, @@ -1271,6 +1274,8 @@ ChannelUpdateEvent channelUpdateEvent( case ChannelPropertyName.canSendMessageGroup: case ChannelPropertyName.canSubscribeGroup: assert(value is GroupSettingValue); + case ChannelPropertyName.isRecentlyActive: + assert(value is bool?); case ChannelPropertyName.streamWeeklyTraffic: assert(value is int?); } From eb3393441e6cf9ae71a45b1b414168cf7c5e97ea Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Thu, 9 Oct 2025 20:43:38 +0430 Subject: [PATCH 04/16] api: Update ChannelDeleteEvent to match new API changes 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 --- lib/api/model/events.dart | 16 ++++++++++++++-- lib/api/model/events.g.dart | 10 ++++++---- lib/model/channel.dart | 16 +++++++++------- lib/model/message.dart | 3 +-- test/model/message_test.dart | 2 +- test/model/store_test.dart | 2 +- test/widgets/action_sheet_test.dart | 3 ++- 7 files changed, 34 insertions(+), 18 deletions(-) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 0ff9471f8f..8768cedb17 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -616,9 +616,21 @@ class ChannelDeleteEvent extends ChannelEvent { @JsonKey(includeToJson: true) String get op => 'delete'; - final List streams; + @JsonKey(name: 'stream_ids', readValue: _readChannelIds) + final List channelIds; + + // TODO(server-10) simplify away; rely on stream_ids + static List _readChannelIds(Map json, String key) { + final channelIds = json['stream_ids'] as List?; + if (channelIds != null) return channelIds.map((id) => id as int).toList(); + + final channels = json['streams'] as List; + return channels + .map((c) => (c as Map)['stream_id'] as int) + .toList(); + } - ChannelDeleteEvent({required super.id, required this.streams}); + ChannelDeleteEvent({required super.id, required this.channelIds}); factory ChannelDeleteEvent.fromJson(Map json) => _$ChannelDeleteEventFromJson(json); diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 3581d8b914..fc0c3a95e2 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -410,9 +410,11 @@ Map _$ChannelCreateEventToJson(ChannelCreateEvent instance) => ChannelDeleteEvent _$ChannelDeleteEventFromJson(Map json) => ChannelDeleteEvent( id: (json['id'] as num).toInt(), - streams: (json['streams'] as List) - .map((e) => ZulipStream.fromJson(e as Map)) - .toList(), + channelIds: + (ChannelDeleteEvent._readChannelIds(json, 'stream_ids') + as List) + .map((e) => (e as num).toInt()) + .toList(), ); Map _$ChannelDeleteEventToJson(ChannelDeleteEvent instance) => @@ -420,7 +422,7 @@ Map _$ChannelDeleteEventToJson(ChannelDeleteEvent instance) => 'id': instance.id, 'type': instance.type, 'op': instance.op, - 'streams': instance.streams, + 'stream_ids': instance.channelIds, }; ChannelUpdateEvent _$ChannelUpdateEventFromJson(Map json) => diff --git a/lib/model/channel.dart b/lib/model/channel.dart index 8f35cfa6bb..db4bfc4316 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -398,13 +398,15 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore { // details will come in a later `subscription` event.) case ChannelDeleteEvent(): - for (final stream in event.streams) { - assert(identical(streams[stream.streamId], streamsByName[stream.name])); - assert(subscriptions[stream.streamId] == null - || identical(subscriptions[stream.streamId], streams[stream.streamId])); - streams.remove(stream.streamId); - streamsByName.remove(stream.name); - subscriptions.remove(stream.streamId); + for (final channelId in event.channelIds) { + 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); } case ChannelUpdateEvent(): diff --git a/lib/model/message.dart b/lib/model/message.dart index 936bc3bb1f..16f015416b 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -556,8 +556,7 @@ class MessageStoreImpl extends HasChannelStore with MessageStore, _OutboxMessage } void handleChannelDeleteEvent(ChannelDeleteEvent event) { - final channelIds = event.streams.map((channel) => channel.streamId); - _handleSubscriptionsRemoved(channelIds); + _handleSubscriptionsRemoved(event.channelIds); } void handleSubscriptionRemoveEvent(SubscriptionRemoveEvent event) { diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 96d95c0c04..74dae7a438 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -634,7 +634,7 @@ void main() { // Subscribe, to mark message as not-stale, setting up another check… await store.addSubscription(eg.subscription(otherChannel)); - await store.handleEvent(ChannelDeleteEvent(id: 1, streams: [otherChannel])); + await store.handleEvent(ChannelDeleteEvent(id: 1, channelIds: [otherChannel.streamId])); // Message was in a channel that became unknown, so clobber. checkClobber(); }); diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 508b376dab..c0ab76e1bb 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -862,7 +862,7 @@ void main() { // Then prepare an event on which handleEvent will throw // because it hits that broken invariant. connection.prepare(json: GetEventsResult(events: [ - ChannelDeleteEvent(id: 1, streams: [stream]), + ChannelDeleteEvent(id: 1, channelIds: [stream.streamId]), ], queueId: null).toJson()); } diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index c43c6daab7..553eb8b54a 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -341,7 +341,8 @@ void main() { testWidgets('unknown channel', (tester) async { await prepare(); - await store.handleEvent(ChannelDeleteEvent(id: 1, streams: [someChannel])); + await store.handleEvent(ChannelDeleteEvent(id: 1, + channelIds: [someChannel.streamId])); check(store.streams[someChannel.streamId]).isNull(); await showFromTopicListAppBar(tester); check(findInHeader(find.byType(Icon))).findsNothing(); From bdd47bf2358ff34e3f67a7954dc571ef6e8e81ed Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Fri, 24 Oct 2025 09:50:46 +0430 Subject: [PATCH 05/16] autocomplete: Introduce `AutocompleteViewManager._autocompleteViews` This set replaces the three sets of different `AutocompleteView` subclasses, simplifying the code. This is NFC except in that `EmojiAutocompleteView.reasseble` was not called in `AutocompleteViewManager.reasseble` and `AutocompleteViewManager.unregisterEmojiAutocomplete` was not called in `EmojiAutocompleteView.dispose`. --- lib/model/autocomplete.dart | 73 ++++++++++--------------------------- lib/model/emoji.dart | 4 +- 2 files changed, 20 insertions(+), 57 deletions(-) diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index cf5c6b86e0..7a6908d2f4 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -206,39 +206,17 @@ class AutocompleteIntent { /// /// On reassemble, call [reassemble]. class AutocompleteViewManager { - final Set _mentionAutocompleteViews = {}; - final Set _topicAutocompleteViews = {}; - final Set _emojiAutocompleteViews = {}; + final Set _autocompleteViews = {}; AutocompleteDataCache autocompleteDataCache = AutocompleteDataCache(); - void registerMentionAutocomplete(MentionAutocompleteView view) { - final added = _mentionAutocompleteViews.add(view); + void registerAutocomplete(AutocompleteView view) { + final added = _autocompleteViews.add(view); assert(added); } - void unregisterMentionAutocomplete(MentionAutocompleteView view) { - final removed = _mentionAutocompleteViews.remove(view); - assert(removed); - } - - void registerTopicAutocomplete(TopicAutocompleteView view) { - final added = _topicAutocompleteViews.add(view); - assert(added); - } - - void unregisterTopicAutocomplete(TopicAutocompleteView view) { - final removed = _topicAutocompleteViews.remove(view); - assert(removed); - } - - void registerEmojiAutocomplete(EmojiAutocompleteView view) { - final added = _emojiAutocompleteViews.add(view); - assert(added); - } - - void unregisterEmojiAutocomplete(EmojiAutocompleteView view) { - final removed = _emojiAutocompleteViews.remove(view); + void unregisterAutocomplete(AutocompleteView view) { + final removed = _autocompleteViews.remove(view); assert(removed); } @@ -263,10 +241,7 @@ class AutocompleteViewManager { /// Calls [AutocompleteView.reassemble] for all that are registered. /// void reassemble() { - for (final view in _mentionAutocompleteViews) { - view.reassemble(); - } - for (final view in _topicAutocompleteViews) { + for (final view in _autocompleteViews) { view.reassemble(); } } @@ -307,6 +282,7 @@ abstract class AutocompleteView Date: Thu, 9 Oct 2025 21:23:22 +0430 Subject: [PATCH 06/16] store: Call AutocompleteViewManager.handleUserGroupRemove/UpdateEvent These two methods were introduced but never called. --- lib/model/store.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/model/store.dart b/lib/model/store.dart index fda6e5b695..a23ed1df51 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -793,6 +793,11 @@ class PerAccountStore extends PerAccountStoreBase with case UserGroupEvent(): assert(debugLog("server event: user_group/${event.op}")); _groups.handleUserGroupEvent(event); + if (event is UserGroupRemoveEvent) { + autocompleteViewManager.handleUserGroupRemoveEvent(event); + } else if (event is UserGroupUpdateEvent) { + autocompleteViewManager.handleUserGroupUpdateEvent(event); + } notifyListeners(); case RealmUserAddEvent(): From 0f6f88db8d89d9a15d2e3801dced117685f667c7 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 7 Oct 2025 22:05:49 +0430 Subject: [PATCH 07/16] autocomplete [nfc]: Move _matchName up to AutocompleteQuery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- lib/model/autocomplete.dart | 42 ++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 7a6908d2f4..4a014749b2 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -736,6 +736,25 @@ abstract class AutocompleteQuery { return compatibilityNormalized.replaceAll(_regExpStripMarkCharacters, ''); } + NameMatchQuality? _matchName({ + required String normalizedName, + required List normalizedNameWords, + }) { + if (normalizedName.startsWith(_normalized)) { + if (normalizedName.length == _normalized.length) { + return NameMatchQuality.exact; + } else { + return NameMatchQuality.totalPrefix; + } + } + + if (_testContainsQueryWords(normalizedNameWords)) { + return NameMatchQuality.wordPrefixes; + } + + return null; + } + /// Whether all of this query's words have matches in [words], /// insensitively to case and diacritics, that appear in order. /// @@ -761,8 +780,8 @@ abstract class AutocompleteQuery { } } -/// The match quality of a [User.fullName] or [UserGroup.name] -/// to a mention autocomplete query. +/// The match quality of some kind of name (e.g. [User.fullName]) +/// to an autocomplete query. /// /// All matches are case-insensitive. enum NameMatchQuality { @@ -839,25 +858,6 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery { nameMatchQuality: nameMatchQuality, matchesEmail: matchesEmail)); } - NameMatchQuality? _matchName({ - required String normalizedName, - required List normalizedNameWords, - }) { - if (normalizedName.startsWith(_normalized)) { - if (normalizedName.length == _normalized.length) { - return NameMatchQuality.exact; - } else { - return NameMatchQuality.totalPrefix; - } - } - - if (_testContainsQueryWords(normalizedNameWords)) { - return NameMatchQuality.wordPrefixes; - } - - return null; - } - bool _matchEmail(User user, AutocompleteDataCache cache) { final normalizedEmail = cache.normalizedEmailForUser(user); if (normalizedEmail == null) return false; // Email not known From ef60f4a1d9d0bc270bdb0578cdcf8968b6586f94 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 8 Oct 2025 17:19:42 +0430 Subject: [PATCH 08/16] autocomplete: Add view-model ChannelLinkAutocompleteView 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. --- lib/model/autocomplete.dart | 291 +++++++++++++++++ lib/model/store.dart | 5 + lib/widgets/autocomplete.dart | 3 + test/model/autocomplete_checks.dart | 4 + test/model/autocomplete_test.dart | 484 ++++++++++++++++++++++++++++ 5 files changed, 787 insertions(+) diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 4a014749b2..9216a93b3d 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:unorm_dart/unorm_dart.dart' as unorm; @@ -10,6 +11,7 @@ import '../api/route/channels.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../widgets/compose_box.dart'; import 'algorithms.dart'; +import 'channel.dart'; import 'compose.dart'; import 'emoji.dart'; import 'narrow.dart'; @@ -236,6 +238,16 @@ class AutocompleteViewManager { autocompleteDataCache.invalidateUserGroup(event.groupId); } + void handleChannelDeleteEvent(ChannelDeleteEvent event) { + for (final channelId in event.channelIds) { + autocompleteDataCache.invalidateChannel(channelId); + } + } + + void handleChannelUpdateEvent(ChannelUpdateEvent event) { + autocompleteDataCache.invalidateChannel(event.streamId); + } + /// Called when the app is reassembled during debugging, e.g. for hot reload. /// /// Calls [AutocompleteView.reassemble] for all that are registered. @@ -1000,6 +1012,21 @@ class AutocompleteDataCache { ??= normalizedNameForUserGroup(userGroup).split(' '); } + final Map _normalizedNamesByChannel = {}; + + /// The normalized `name` of [channel]. + String normalizedNameForChannel(ZulipStream channel) { + return _normalizedNamesByChannel[channel.streamId] + ??= AutocompleteQuery.lowercaseAndStripDiacritics(channel.name); + } + + final Map> _normalizedNameWordsByChannel = {}; + + List normalizedNameWordsForChannel(ZulipStream channel) { + return _normalizedNameWordsByChannel[channel.streamId] + ?? normalizedNameForChannel(channel).split(' '); + } + void invalidateUser(int userId) { _normalizedNamesByUser.remove(userId); _normalizedNameWordsByUser.remove(userId); @@ -1010,6 +1037,11 @@ class AutocompleteDataCache { _normalizedNamesByUserGroup.remove(id); _normalizedNameWordsByUserGroup.remove(id); } + + void invalidateChannel(int channelId) { + _normalizedNamesByChannel.remove(channelId); + _normalizedNameWordsByChannel.remove(channelId); + } } /// A result the user chose, or might choose, from an autocomplete interaction. @@ -1208,3 +1240,262 @@ class TopicAutocompleteResult extends AutocompleteResult { TopicAutocompleteResult({required this.topic}); } + +/// An [AutocompleteView] for a #channel autocomplete interaction, +/// an example of a [ComposeAutocompleteView]. +class ChannelLinkAutocompleteView extends AutocompleteView { + ChannelLinkAutocompleteView._({ + required super.store, + required super.query, + required this.narrow, + required this.sortedChannels, + }); + + factory ChannelLinkAutocompleteView.init({ + required PerAccountStore store, + required Narrow narrow, + required ChannelLinkAutocompleteQuery query, + }) { + return ChannelLinkAutocompleteView._( + store: store, + query: query, + narrow: narrow, + sortedChannels: _channelsByRelevance(store: store, narrow: narrow), + ); + } + + final Narrow narrow; + final List sortedChannels; + + static List _channelsByRelevance({ + required PerAccountStore store, + required Narrow narrow, + }) { + return store.streams.values.sorted(_comparator(narrow: narrow)); + } + + /// Compare the channels the same way they would be sorted as + /// autocomplete candidates, given [query]. + /// + /// The channels must both match the query. + /// + /// This behaves the same as the comparator used for sorting in + /// [_channelsByRelevance], combined with the ranking applied at the end + /// of [computeResults]. + /// + /// This is useful for tests in order to distinguish "A comes before B" + /// from "A ranks equal to B, and the sort happened to put A before B", + /// particularly because [List.sort] makes no guarantees about the order + /// of items that compare equal. + int debugCompareChannels(ZulipStream a, ZulipStream b) { + final rankA = query.testChannel(a, store)!.rank; + final rankB = query.testChannel(b, store)!.rank; + if (rankA != rankB) return rankA.compareTo(rankB); + + return _comparator(narrow: narrow)(a, b); + } + + static Comparator _comparator({required Narrow narrow}) { + // See also [ChannelLinkAutocompleteQuery._rankResult]; + // that ranking takes precedence over this. + + final channelId = switch (narrow) { + ChannelNarrow(:var streamId) || TopicNarrow(:var streamId) => streamId, + DmNarrow() => null, + CombinedFeedNarrow() + || MentionsNarrow() + || StarredMessagesNarrow() + || KeywordSearchNarrow() => () { + assert(false, 'No compose box, thus no autocomplete is available in ${narrow.runtimeType}.'); + return null; + }(), + }; + return (a, b) => _compareByRelevance(a, b, composingToChannelId: channelId); + } + + // 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, { + required int? composingToChannelId, + }) { + if (composingToChannelId != null) { + final composingToResult = compareByComposingTo(a, b, + composingToChannelId: composingToChannelId); + if (composingToResult != 0) return composingToResult; + } + + final beingSubscribedResult = compareByBeingSubscribed(a, b); + if (beingSubscribedResult != 0) return beingSubscribedResult; + + final recentActivityResult = compareByRecentActivity(a, b); + if (recentActivityResult != 0) return recentActivityResult; + + final weeklyTrafficResult = compareByWeeklyTraffic(a, b); + if (weeklyTrafficResult != 0) return weeklyTrafficResult; + + return ChannelStore.compareChannelsByName(a, b); + } + + /// Comparator that puts the channel being composed to, before other ones. + @visibleForTesting + static int compareByComposingTo(ZulipStream a, ZulipStream b, { + required int composingToChannelId, + }) { + return switch ((a.streamId, b.streamId)) { + (int id, _) when id == composingToChannelId => -1, + (_, int id) when id == composingToChannelId => 1, + _ => 0, + }; + } + + /// Comparator that puts subscribed channels before unsubscribed ones. + /// + /// For subscribed channels, it puts them in the following order: + /// pinned unmuted > unpinned unmuted > pinned muted > unpinned muted + @visibleForTesting + static int compareByBeingSubscribed(ZulipStream a, ZulipStream b) { + 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, + }; + } + + /// Comparator that puts recently-active channels before inactive ones. + /// + /// Being recently-active is determined by [ZulipStream.isRecentlyActive]. + @visibleForTesting + static int compareByRecentActivity(ZulipStream a, ZulipStream b) { + return switch((a.isRecentlyActive, b.isRecentlyActive)) { + (true, false) => -1, + (false, true) => 1, + // The combination of `null` and `bool` is not possible as they're both + // either `null` or `bool`, before or after server-10, respectively. + // TODO(server-10): remove the preceding comment + _ => 0, + }; + } + + /// 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). + @visibleForTesting + static int compareByWeeklyTraffic(ZulipStream a, ZulipStream b) { + return switch((a.streamWeeklyTraffic, b.streamWeeklyTraffic)) { + (int a, int b) => -a.compareTo(b), + (int(), null) => -1, + (null, int()) => 1, + _ => 0, + }; + } + + @override + Future?> computeResults() async { + final unsorted = []; + if (await filterCandidates(filter: _testChannel, + candidates: sortedChannels, results: unsorted)) { + return null; + } + + return bucketSort(unsorted, + (r) => r.rank, numBuckets: ChannelLinkAutocompleteQuery._numResultRanks); + } + + ChannelLinkAutocompleteResult? _testChannel(ChannelLinkAutocompleteQuery query, ZulipStream channel) { + return query.testChannel(channel, store); + } +} + +/// A #channel autocomplete query, used by [ChannelLinkAutocompleteView]. +class ChannelLinkAutocompleteQuery extends ComposeAutocompleteQuery { + ChannelLinkAutocompleteQuery(super.raw); + + @override + ChannelLinkAutocompleteView initViewModel({ + required PerAccountStore store, + required ZulipLocalizations localizations, + required Narrow narrow, + }) { + return ChannelLinkAutocompleteView.init(store: store, query: this, narrow: narrow); + } + + ChannelLinkAutocompleteResult? testChannel(ZulipStream channel, PerAccountStore store) { + final cache = store.autocompleteViewManager.autocompleteDataCache; + final matchQuality = _matchName( + normalizedName: cache.normalizedNameForChannel(channel), + normalizedNameWords: cache.normalizedNameWordsForChannel(channel)); + if (matchQuality == null) return null; + return ChannelLinkAutocompleteResult( + channelId: channel.streamId, rank: _rankResult(matchQuality)); + } + + /// A measure of a channel result's quality in the context of the query, + /// from 0 (best) to one less than [_numResultRanks]. + static int _rankResult(NameMatchQuality matchQuality) { + return switch(matchQuality) { + NameMatchQuality.exact => 0, + NameMatchQuality.totalPrefix => 1, + NameMatchQuality.wordPrefixes => 2, + }; + } + + /// The number of possible values returned by [_rankResult]. + static const _numResultRanks = 3; + + @override + String toString() { + return '${objectRuntimeType(this, 'ChannelLinkAutocompleteQuery')}($raw)'; + } + + @override + bool operator ==(Object other) { + return other is ChannelLinkAutocompleteQuery && other.raw == raw; + } + + @override + int get hashCode => Object.hash('ChannelLinkAutocompleteQuery', raw); +} + +/// An autocomplete result for a #channel. +class ChannelLinkAutocompleteResult extends ComposeAutocompleteResult { + ChannelLinkAutocompleteResult({required this.channelId, required this.rank}); + + final int channelId; + + /// A measure of the result's quality in the context of the query. + /// + /// Used internally by [ChannelLinkAutocompleteView] for ranking the results. + // See also [ChannelLinkAutocompleteView._channelsByRelevance]; + // results with equal [rank] will appear in the order they were put in + // by that method. + // + // Compare sort_streams in Zulip web: + // https://github.com/zulip/zulip/blob/a5d25826b/web/src/typeahead_helper.ts#L998-L1008 + // + // 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. + // - 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. + final int rank; +} diff --git a/lib/model/store.dart b/lib/model/store.dart index a23ed1df51..132dd9b37b 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -830,6 +830,11 @@ class PerAccountStore extends PerAccountStoreBase with _messages.handleChannelDeleteEvent(event); } _channels.handleChannelEvent(event); + if (event is ChannelDeleteEvent) { + autocompleteViewManager.handleChannelDeleteEvent(event); + } else if (event is ChannelUpdateEvent) { + autocompleteViewManager.handleChannelUpdateEvent(event); + } notifyListeners(); case SubscriptionEvent(): diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index 7db0de0ebd..ab0299960b 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -226,6 +226,8 @@ class ComposeAutocomplete extends AutocompleteField MentionAutocompleteItem( option: option, narrow: narrow), + ChannelLinkAutocompleteResult() => throw UnimplementedError(), // TODO(#124) EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option), }; return InkWell( diff --git a/test/model/autocomplete_checks.dart b/test/model/autocomplete_checks.dart index 65e3aaf755..336d166454 100644 --- a/test/model/autocomplete_checks.dart +++ b/test/model/autocomplete_checks.dart @@ -33,3 +33,7 @@ extension UserGroupMentionAutocompleteResultChecks on Subject { Subject get topic => has((r) => r.topic, 'topic'); } + +extension ChannelLinkAutocompleteResultChecks on Subject { + Subject get channelId => has((r) => r.channelId, 'channelId'); +} diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart index a560512553..da3a7acae0 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -21,6 +21,7 @@ import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../fake_async.dart'; import '../stdlib_checks.dart'; +import '../widgets/action_sheet_test.dart'; import 'test_store.dart'; import 'autocomplete_checks.dart'; @@ -1307,6 +1308,489 @@ void main() { doCheck('nam', 'Name', true); }); }); + + group('ChannelLinkAutocompleteView', () { + Condition isChannel(int channelId) { + return (it) => it.isA() + .channelId.equals(channelId); + } + + test('misc', () async { + const narrow = ChannelNarrow(1); + final channel1 = eg.stream(streamId: 1, name: 'First'); + final channel2 = eg.stream(streamId: 2, name: 'Second'); + final store = eg.store(initialSnapshot: + eg.initialSnapshot(streams: [channel1, channel2])); + + final view = ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: ChannelLinkAutocompleteQuery('')); + bool done = false; + view.addListener(() { done = true; }); + await Future(() {}); + check(done).isTrue(); + // Based on alphabetical order. For how the ordering works, see the + // dedicated test group "sorting results" below. + check(view.results).deepEquals([1, 2].map(isChannel)); + }); + + test('results update after query change', () async { + const narrow = ChannelNarrow(1); + final channel1 = eg.stream(streamId: 1, name: 'First'); + final channel2 = eg.stream(streamId: 2, name: 'Second'); + final store = eg.store(initialSnapshot: + eg.initialSnapshot(streams: [channel1, channel2])); + + final view = ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: ChannelLinkAutocompleteQuery('Fir')); + bool done = false; + view.addListener(() { done = true; }); + await Future(() {}); + check(done).isTrue(); + check(view.results).single.which(isChannel(1)); + + done = false; + view.query = ChannelLinkAutocompleteQuery('sec'); + await Future(() {}); + check(done).isTrue(); + check(view.results).single.which(isChannel(2)); + }); + + group('sorting results', () { + group('compareByComposingTo', () { + int compareAB(int a, int b, {required int composingToChannelId}) => + ChannelLinkAutocompleteView.compareByComposingTo( + eg.stream(streamId: a), eg.stream(streamId: b), + composingToChannelId: composingToChannelId); + + test('favor the channel being composed to', () { + check(compareAB(1, 2, composingToChannelId: 1)).isLessThan(0); + check(compareAB(1, 2, composingToChannelId: 2)).isGreaterThan(0); + }); + + test('none is the channel being composed to, favor none', () { + check(compareAB(1, 2, composingToChannelId: 3)).equals(0); + }); + }); + + group('compareByBeingSubscribed', () { + final channelA = eg.stream(); + final channelB = eg.stream(); + + Subscription subA({bool? isMuted, bool? pinToTop}) => + eg.subscription(channelA, isMuted: isMuted, pinToTop: pinToTop); + Subscription subB({bool? isMuted, bool? pinToTop}) => + eg.subscription(channelB, isMuted: isMuted, pinToTop: pinToTop); + + int compareAB(ZulipStream a, ZulipStream b) => + ChannelLinkAutocompleteView.compareByBeingSubscribed(a, b); + + test('favor subscribed channel over unsubscribed', () { + check(compareAB(subA(), channelB)).isLessThan(0); + check(compareAB(channelA, subB())).isGreaterThan(0); + }); + + test('both channels unsubscribed, favor none', () { + check(compareAB(channelA, channelB)).equals(0); + }); + + group('both channels subscribed', () { + test('favor unmuted over muted, regardless of pinned status', () { + check(compareAB( + subA(isMuted: false, pinToTop: true), + subB(isMuted: true, pinToTop: false), + )).isLessThan(0); + check(compareAB( + subA(isMuted: false, pinToTop: false), + subB(isMuted: true, pinToTop: true), + )).isLessThan(0); + + check(compareAB( + subA(isMuted: true, pinToTop: true), + subB(isMuted: false, pinToTop: false), + )).isGreaterThan(0); + check(compareAB( + subA(isMuted: true, pinToTop: false), + subB(isMuted: false, pinToTop: true), + )).isGreaterThan(0); + }); + + test('same muted status, favor pinned over unpinned', () { + check(compareAB( + subA(isMuted: false, pinToTop: true), + subB(isMuted: false, pinToTop: false), + )).isLessThan(0); + check(compareAB( + subA(isMuted: false, pinToTop: false), + subB(isMuted: false, pinToTop: true), + )).isGreaterThan(0); + + check(compareAB( + subA(isMuted: true, pinToTop: true), + subB(isMuted: true, pinToTop: false), + )).isLessThan(0); + check(compareAB( + subA(isMuted: true, pinToTop: false), + subB(isMuted: true, pinToTop: true), + )).isGreaterThan(0); + }); + + test('same muted and same pinned status, favor none', () { + check(compareAB( + subA(isMuted: false, pinToTop: false), + subB(isMuted: false, pinToTop: false), + )).equals(0); + check(compareAB( + subA(isMuted: false, pinToTop: true), + subB(isMuted: false, pinToTop: true), + )).equals(0); + + check(compareAB( + subA(isMuted: true, pinToTop: false), + subB(isMuted: true, pinToTop: false), + )).equals(0); + check(compareAB( + subA(isMuted: true, pinToTop: true), + subB(isMuted: true, pinToTop: true), + )).equals(0); + }); + }); + }); + + group('compareByRecentActivity', () { + int compareAB(bool a, bool b) => ChannelLinkAutocompleteView.compareByRecentActivity( + eg.stream(isRecentlyActive: a), eg.stream(isRecentlyActive: b)); + + test('favor recently-active channel over inactive', () { + check(compareAB(true, false)).isLessThan(0); + check(compareAB(false, true)).isGreaterThan(0); + }); + + test('both channels are the same, favor none', () { + check(compareAB(true, true)).equals(0); + check(compareAB(false, false)).equals(0); + }); + }); + + group('compareByWeeklyTraffic', () { + int compareAB(int? a, int? b) => ChannelLinkAutocompleteView.compareByWeeklyTraffic( + eg.stream(streamWeeklyTraffic: a), eg.stream(streamWeeklyTraffic: b)); + + test('favor channel with more traffic', () { + check(compareAB(100, 50)).isLessThan(0); + check(compareAB(50, 100)).isGreaterThan(0); + }); + + test('favor channel with traffic defined', () { + check(compareAB(100, null)).isLessThan(0); + check(compareAB(0, null)).isLessThan(0); + check(compareAB(null, 100)).isGreaterThan(0); + check(compareAB(null, 0)).isGreaterThan(0); + }); + + test('both channels are the same, favor none', () { + check(compareAB(100, 100)).equals(0); + check(compareAB(null, null)).equals(0); + }); + }); + + group('ranking across signals', () { + store = eg.store(); + + void checkPrecedes(Narrow narrow, ZulipStream a, Iterable bs) { + final view = ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: ChannelLinkAutocompleteQuery('')); + for (final b in bs) { + check(view.debugCompareChannels(a, b)).isLessThan(0); + check(view.debugCompareChannels(b, a)).isGreaterThan(0); + } + view.dispose(); + } + + void checkRankEqual(Narrow narrow, List channels) { + final view = ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: ChannelLinkAutocompleteQuery('')); + for (int i = 0; i < channels.length; i++) { + for (int j = i + 1; j < channels.length; j++) { + check(view.debugCompareChannels(channels[i], channels[j])).equals(0); + check(view.debugCompareChannels(channels[j], channels[i])).equals(0); + } + } + view.dispose(); + } + + final channels = [ + // Wins by being the composing-to channel. + eg.stream(streamId: 1, name: 'Z'), + + // Next four are runners-up by being subscribed to. + // Runner-up by being unmuted pinned. + eg.subscription(eg.stream(streamId: 2, name: 'Y'), isMuted: false, pinToTop: true), + // Runner-up by being unmuted unpinned. + eg.subscription(eg.stream(streamId: 3, name: 'X'), isMuted: false, pinToTop: false), + // Runner-up by being muted pinned. + eg.subscription(eg.stream(streamId: 4, name: 'W'), isMuted: true, pinToTop: true), + // Runner-up by being muted unpinned. + eg.subscription(eg.stream(streamId: 5, name: 'V'), isMuted: true, pinToTop: false), + + // The rest are runners-up by not being subscribed to. + // Runner-up by being recently active. + eg.stream(streamId: 6, name: 'U', isRecentlyActive: true), + // Runner-up by having more weekly traffic. + eg.stream(streamId: 7, name: 'T', isRecentlyActive: false, streamWeeklyTraffic: 100), + // Runner-up by having weekly traffic defined. + eg.stream(streamId: 8, name: 'S', isRecentlyActive: false, streamWeeklyTraffic: 0), + // Runner-up by name. + eg.stream(streamId: 9, name: 'A', isRecentlyActive: false), + // Next two are tied because no remaining criteria. + eg.stream(streamId: 10, name: 'B', isRecentlyActive: false), + eg.stream(streamId: 11, name: 'b', isRecentlyActive: false), + ]; + for (final narrow in [ + eg.topicNarrow(1, 'this'), + ChannelNarrow(1), + ]) { + test('${narrow.runtimeType}: composing-to channel > subscribed (unmuted > pinned) > recently active > weekly traffic > name', () { + checkPrecedes(narrow, channels[0], channels.skip(1)); + checkPrecedes(narrow, channels[1], channels.skip(2)); + checkPrecedes(narrow, channels[2], channels.skip(3)); + checkPrecedes(narrow, channels[3], channels.skip(4)); + checkPrecedes(narrow, channels[4], channels.skip(5)); + checkPrecedes(narrow, channels[5], channels.skip(6)); + checkPrecedes(narrow, channels[6], channels.skip(7)); + checkPrecedes(narrow, channels[7], channels.skip(8)); + checkPrecedes(narrow, channels[8], channels.skip(9)); + checkRankEqual(narrow, [channels[9], channels[10]]); + }); + } + + test('DmNarrow: subscribed (unmuted > pinned) > recently active > weekly traffic > name', () { + final channels = [ + // Next four are runners-up by being subscribed to. + // Runner-up by being unmuted pinned. + eg.subscription(eg.stream(streamId: 1, name: 'Y'), isMuted: false, pinToTop: true), + // Runner-up by being unmuted unpinned. + eg.subscription(eg.stream(streamId: 2, name: 'X'), isMuted: false, pinToTop: false), + // Runner-up by being muted pinned. + eg.subscription(eg.stream(streamId: 3, name: 'W'), isMuted: true, pinToTop: true), + // Runner-up by being muted unpinned. + eg.subscription(eg.stream(streamId: 4, name: 'V'), isMuted: true, pinToTop: false), + + // The rest are runners-up by not being subscribed to. + // Runner-up by being recently active. + eg.stream(streamId: 5, name: 'U', isRecentlyActive: true), + // Runner-up by having more weekly traffic. + eg.stream(streamId: 6, name: 'T', isRecentlyActive: false, streamWeeklyTraffic: 100), + // Runner-up by having weekly traffic defined. + eg.stream(streamId: 7, name: 'S', isRecentlyActive: false, streamWeeklyTraffic: 0), + // Runner-up by name. + eg.stream(streamId: 8, name: 'A', isRecentlyActive: false), + // Next two are tied because no remaining criteria. + eg.stream(streamId: 9, name: 'B', isRecentlyActive: false), + eg.stream(streamId: 10, name: 'b', isRecentlyActive: false), + ]; + final narrow = DmNarrow.withUser(1, selfUserId: 10); + checkPrecedes(narrow, channels[0], channels.skip(1)); + checkPrecedes(narrow, channels[1], channels.skip(2)); + checkPrecedes(narrow, channels[2], channels.skip(3)); + checkPrecedes(narrow, channels[3], channels.skip(4)); + checkPrecedes(narrow, channels[4], channels.skip(5)); + checkPrecedes(narrow, channels[5], channels.skip(6)); + checkPrecedes(narrow, channels[6], channels.skip(7)); + checkPrecedes(narrow, channels[7], channels.skip(8)); + checkRankEqual(narrow, [channels[8], channels[9]]); + }); + + test('CombinedFeedNarrow gives error', () async { + const narrow = CombinedFeedNarrow(); + check(() => ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: ChannelLinkAutocompleteQuery(''))) + .throws(); + }); + + test('MentionsNarrow gives error', () async { + const narrow = MentionsNarrow(); + check(() => ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: ChannelLinkAutocompleteQuery(''))) + .throws(); + }); + + test('StarredMessagesNarrow gives error', () async { + const narrow = StarredMessagesNarrow(); + check(() => ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: ChannelLinkAutocompleteQuery(''))) + .throws(); + }); + + test('KeywordSearchNarrow gives error', () async { + final narrow = KeywordSearchNarrow(''); + check(() => ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: ChannelLinkAutocompleteQuery(''))) + .throws(); + }); + }); + + test('final results end-to-end', () async { + Future> getResults( + Narrow narrow, ChannelLinkAutocompleteQuery query) async { + bool done = false; + final view = ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: query); + view.addListener(() { done = true; }); + await Future(() {}); + check(done).isTrue(); + final results = view.results; + view.dispose(); + return results; + } + + final channels = [ + eg.stream(streamId: 1, name: 'Channel One', isRecentlyActive: false, + streamWeeklyTraffic: 0), + eg.stream(streamId: 2, name: 'Channel Two', isRecentlyActive: true), + eg.stream(streamId: 3, name: 'Channel Three', isRecentlyActive: false, + streamWeeklyTraffic: 100), + eg.stream(streamId: 4, name: 'Channel Four', isRecentlyActive: false), + eg.stream(streamId: 5, name: 'Channel Five', isRecentlyActive: false), + eg.stream(streamId: 6, name: 'Channel Six'), + eg.stream(streamId: 7, name: 'Channel Seven'), + eg.stream(streamId: 8, name: 'Channel Eight'), + eg.stream(streamId: 9, name: 'Channel Nine'), + eg.stream(streamId: 10, name: 'Channel Ten'), + ]; + store = eg.store(initialSnapshot: eg.initialSnapshot( + streams: channels, + subscriptions: [ + eg.subscription(channels[6 - 1], isMuted: false, pinToTop: true), + eg.subscription(channels[7 - 1], isMuted: false, pinToTop: false), + eg.subscription(channels[8 - 1], isMuted: true, pinToTop: true), + eg.subscription(channels[9 - 1], isMuted: true, pinToTop: false), + ], + )); + + final narrow = eg.topicNarrow(10, 'this'); + + // The order should be: + // 1. composing-to channel + // 2. subscribed channels + // 1. unmuted pinned + // 2. unmuted unpinned + // 3. muted pinned + // 4. muted unpinned + // 3. recently-active channels + // 4. channels with more traffic + // 5. channels by name alphabetical order + + // Check the ranking of the full list of options, + // i.e. the results for an empty query. + check(await getResults(narrow, ChannelLinkAutocompleteQuery(''))) + .deepEquals([10, 6, 7, 8, 9, 2, 3, 1, 5, 4].map(isChannel)); + + // Check the ranking applies also to results filtered by a query. + check(await getResults(narrow, ChannelLinkAutocompleteQuery('t'))) + .deepEquals([10, 2, 3].map(isChannel)); + check(await getResults(narrow, ChannelLinkAutocompleteQuery('F'))) + .deepEquals([5, 4].map(isChannel)); + }); + }); + }); + + group('ChannelLinkAutocompleteQuery', () { + test('testChannel: channel is included if name words match the query', () { + void doCheck(String rawQuery, ZulipStream channel, bool expected) { + final result = ChannelLinkAutocompleteQuery(rawQuery).testChannel(channel, store); + expected + ? check(result).isA() + : check(result).isNull(); + } + + store = eg.store(); + + doCheck('', eg.stream(name: 'Channel Name'), true); + doCheck('', eg.stream(name: ''), true); // unlikely case, but should not crash + doCheck('Channel Name', eg.stream(name: 'Channel Name'), true); + doCheck('channel name', eg.stream(name: 'Channel Name'), true); + doCheck('Channel Name', eg.stream(name: 'channel name'), true); + doCheck('Channel', eg.stream(name: 'Channel Name'), true); + doCheck('Name', eg.stream(name: 'Channel Name'), true); + doCheck('Channel Name', eg.stream(name: 'Channels Names'), true); + doCheck('Channel Four', eg.stream(name: 'Channel Name Four Words'), true); + doCheck('Name Words', eg.stream(name: 'Channel Name Four Words'), true); + doCheck('Channel F', eg.stream(name: 'Channel Name Four Words'), true); + doCheck('C Four', eg.stream(name: 'Channel Name Four Words'), true); + doCheck('channel channel', eg.stream(name: 'Channel Channel Name'), true); + doCheck('channel channel', eg.stream(name: 'Channel Name Channel'), true); + + doCheck('C', eg.stream(name: ''), false); // unlikely case, but should not crash + doCheck('Channels Names', eg.stream(name: 'Channel Name'), false); + doCheck('Channel Name', eg.stream(name: 'Channel'), false); + doCheck('Channel Name', eg.stream(name: 'Name'), false); + doCheck('nnel ame', eg.stream(name: 'Channel Name'), false); + doCheck('nnel Name', eg.stream(name: 'Channel Name'), false); + doCheck('Channel ame', eg.stream(name: 'Channel Name'), false); + doCheck('Channel Channel', eg.stream(name: 'Channel Name'), false); + doCheck('Name Name', eg.stream(name: 'Channel Name'), false); + doCheck('Name Channel', eg.stream(name: 'Channel Name'), false); + doCheck('Name Four Channel Words', eg.stream(name: 'Channel Name Four Words'), false); + doCheck('F Channel', eg.stream(name: 'Channel Name Four Words'), false); + doCheck('Four C', eg.stream(name: 'Channel Name Four Words'), false); + }); + + group('ranking', () { + store = eg.store(); + + int rankOf(String query, ZulipStream channel) { + // (i.e. throw here if it's not a match) + return ChannelLinkAutocompleteQuery(query) + .testChannel(channel, store)!.rank; + } + + void checkPrecedes(String query, ZulipStream a, ZulipStream b) { + check(rankOf(query, a)).isLessThan(rankOf(query, b)); + } + + void checkAllSameRank(String query, Iterable channels) { + final firstRank = rankOf(query, channels.first); + final remainingRanks = channels.skip(1).map((e) => rankOf(query, e)); + check(remainingRanks).every((it) => it.equals(firstRank)); + } + + test('channel name is case- and diacritics-insensitive', () { + final channels = [ + eg.stream(name: 'Über Cars'), + eg.stream(name: 'über cars'), + eg.stream(name: 'Uber Cars'), + eg.stream(name: 'uber cars'), + ]; + + checkAllSameRank('Über Cars', channels); // exact + checkAllSameRank('über cars', channels); // exact + checkAllSameRank('Uber Cars', channels); // exact + checkAllSameRank('uber cars', channels); // exact + + checkAllSameRank('Über Ca', channels); // total-prefix + checkAllSameRank('über ca', channels); // total-prefix + checkAllSameRank('Uber Ca', channels); // total-prefix + checkAllSameRank('uber ca', channels); // total-prefix + + checkAllSameRank('Üb Ca', channels); // word-prefixes + checkAllSameRank('üb ca', channels); // word-prefixes + checkAllSameRank('Ub Ca', channels); // word-prefixes + checkAllSameRank('ub ca', channels); // word-prefixes + }); + + test('channel name match: exact over total-prefix', () { + final channel1 = eg.stream(name: 'Resume'); + final channel2 = eg.stream(name: 'Resume Tips'); + checkPrecedes('resume', channel1, channel2); + }); + + test('channel name match: total-prefix over word-prefixes', () { + final channel1 = eg.stream(name: 'So Many Ideas'); + final channel2 = eg.stream(name: 'Some Media Channel'); + checkPrecedes('so m', channel1, channel2); + }); + }); + }); } typedef WildcardTester = void Function(String query, Narrow narrow, List expected); From 0d30546986863525536cee10c29d8713117056e7 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 20 Oct 2025 21:11:41 +0430 Subject: [PATCH 09/16] autocomplete test [nfc]: Use MarkedTextParse as the return type of parseMarkedText This was first added in 0886948ff, but seems to have been accidentally removed in 046ceabfd. --- test/model/autocomplete_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart index da3a7acae0..e1680b57a9 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -30,7 +30,7 @@ typedef MarkedTextParse = ({int? expectedSyntaxStart, TextEditingValue value}); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; void main() { - ({int? expectedSyntaxStart, TextEditingValue value}) parseMarkedText(String markedText) { + MarkedTextParse parseMarkedText(String markedText) { final TextSelection selection; int? expectedSyntaxStart; final textBuffer = StringBuffer(); From f6ba102aeb93b8952054c93fd2a88246659207f0 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 29 Oct 2025 18:46:52 +0430 Subject: [PATCH 10/16] compose: Introduce PerAccountStore in ComposeController 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. --- lib/widgets/compose_box.dart | 41 ++++++++++++++++++++--------- test/model/autocomplete_test.dart | 3 ++- test/widgets/action_sheet_test.dart | 4 +-- test/widgets/compose_box_test.dart | 9 ++++--- 4 files changed, 38 insertions(+), 19 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 1b00654b8f..a4bc81e56a 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -89,7 +89,9 @@ const double _composeButtonSize = 44; /// /// Subclasses must ensure that [_update] is called in all exposed constructors. abstract class ComposeController extends TextEditingController { - ComposeController({super.text}); + ComposeController({super.text, required this.store}); + + PerAccountStore store; int get maxLengthUnicodeCodePoints; @@ -152,12 +154,10 @@ enum TopicValidationError { } class ComposeTopicController extends ComposeController { - ComposeTopicController({super.text, required this.store}) { + ComposeTopicController({super.text, required super.store}) { _update(); } - PerAccountStore store; - // TODO(#668): listen to [PerAccountStore] once we subscribe to this value bool get mandatory => store.realmMandatoryTopics; @@ -234,7 +234,11 @@ enum ContentValidationError { } class ComposeContentController extends ComposeController { - ComposeContentController({super.text, this.requireNotEmpty = true}) { + ComposeContentController({ + super.text, + required super.store, + this.requireNotEmpty = true, + }) { _update(); } @@ -1575,7 +1579,11 @@ class _EditMessageComposeBoxBody extends _ComposeBoxBody { } sealed class ComposeBoxController { - final content = ComposeContentController(); + ComposeBoxController({required this.store}) + : content = ComposeContentController(store: store); + + final PerAccountStore store; + final ComposeContentController content; final contentFocusNode = FocusNode(); /// If no input is focused, requests focus on the appropriate input. @@ -1668,7 +1676,7 @@ enum ComposeTopicInteractionStatus { } class StreamComposeBoxController extends ComposeBoxController { - StreamComposeBoxController({required PerAccountStore store}) + StreamComposeBoxController({required super.store}) : topic = ComposeTopicController(store: store); final ComposeTopicController topic; @@ -1698,21 +1706,25 @@ class StreamComposeBoxController extends ComposeBoxController { } } -class FixedDestinationComposeBoxController extends ComposeBoxController {} +class FixedDestinationComposeBoxController extends ComposeBoxController { + FixedDestinationComposeBoxController({required super.store}); +} class EditMessageComposeBoxController extends ComposeBoxController { EditMessageComposeBoxController({ + required super.store, required this.messageId, required this.originalRawContent, required String? initialText, }) : _content = ComposeContentController( text: initialText, + store: store, // Editing to delete the content is a supported form of // deletion: https://zulip.com/help/delete-a-message#delete-message-content requireNotEmpty: false); - factory EditMessageComposeBoxController.empty(int messageId) => - EditMessageComposeBoxController(messageId: messageId, + factory EditMessageComposeBoxController.empty(PerAccountStore store, int messageId) => + EditMessageComposeBoxController(store: store, messageId: messageId, originalRawContent: null, initialText: null); @override ComposeContentController get content => _content; @@ -2058,6 +2070,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM setState(() { controller.dispose(); _controller = EditMessageComposeBoxController( + store: store, messageId: messageId, originalRawContent: failedEdit.originalRawContent, initialText: failedEdit.newContent, @@ -2067,8 +2080,9 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM } void _editFromRawContentFetch(int messageId) async { + final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); - final emptyEditController = EditMessageComposeBoxController.empty(messageId); + final emptyEditController = EditMessageComposeBoxController.empty(store, messageId); setState(() { controller.dispose(); _controller = emptyEditController; @@ -2136,10 +2150,11 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM switch (controller) { case StreamComposeBoxController(): + controller.content.store = newStore; controller.topic.store = newStore; case FixedDestinationComposeBoxController(): case EditMessageComposeBoxController(): - // no reference to the store that needs updating + controller.content.store = newStore; } } @@ -2149,7 +2164,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM _controller = StreamComposeBoxController(store: store); case TopicNarrow(): case DmNarrow(): - _controller = FixedDestinationComposeBoxController(); + _controller = FixedDestinationComposeBoxController(store: store); case CombinedFeedNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart index e1680b57a9..29c1099cf2 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -82,7 +82,8 @@ void main() { ? 'in ${jsonEncode(markedText)}, query ${jsonEncode(expectedQuery.raw)}' : 'no query in ${jsonEncode(markedText)}'; test(description, () { - final controller = ComposeContentController(); + store = eg.store(); + final controller = ComposeContentController(store: store); final parsed = parseMarkedText(markedText); assert((expectedQuery == null) == (parsed.expectedSyntaxStart == null)); controller.value = parsed.value; diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 553eb8b54a..c6910473dd 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -1651,7 +1651,7 @@ void main() { required TextEditingValue valueBefore, required Message message, }) { - check(contentController).value.equals((ComposeContentController() + check(contentController).value.equals((ComposeContentController(store: store) ..value = valueBefore ..insertPadded(quoteAndReplyPlaceholder( GlobalLocalizations.zulipLocalizations, store, message: message)) @@ -1664,7 +1664,7 @@ void main() { required Message message, required String rawContent, }) { - final builder = ComposeContentController() + final builder = ComposeContentController(store: store) ..value = valueBefore ..insertPadded(quoteAndReply(store, message: message, rawContent: rawContent)); if (!valueBefore.selection.isValid) { diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index d27e374e3b..a847120691 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -255,7 +255,8 @@ void main() { /// In expectedValue, represent the collapsed selection as "^". void testInsertPadded(String description, String valueBefore, String textToInsert, String expectedValue) { test(description, () { - final controller = ComposeContentController(); + store = eg.store(); + final controller = ComposeContentController(store: store); controller.value = parseMarkedText(valueBefore); controller.insertPadded(textToInsert); check(controller.value).equals(parseMarkedText(expectedValue)); @@ -336,7 +337,8 @@ void main() { } testWidgets('requireNotEmpty: true (default)', (tester) async { - controller = ComposeContentController(); + store = eg.store(); + controller = ComposeContentController(store: store); addTearDown(controller.dispose); checkCountsAsEmpty('', true); checkCountsAsEmpty(' ', true); @@ -344,7 +346,8 @@ void main() { }); testWidgets('requireNotEmpty: false', (tester) async { - controller = ComposeContentController(requireNotEmpty: false); + store = eg.store(); + controller = ComposeContentController(store: store, requireNotEmpty: false); addTearDown(controller.dispose); checkCountsAsEmpty('', false); checkCountsAsEmpty(' ', false); From 1977e20fc41189a15386a3b002956e9016093210 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 8 Oct 2025 17:24:46 +0430 Subject: [PATCH 11/16] autocomplete: Identify when the user intends a channel link autocomplete 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. --- lib/model/autocomplete.dart | 57 +++++++++++++++- lib/widgets/autocomplete.dart | 3 +- test/model/autocomplete_test.dart | 105 ++++++++++++++++++++++++++++-- 3 files changed, 159 insertions(+), 6 deletions(-) diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 9216a93b3d..b366fa381e 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -18,6 +18,12 @@ import 'narrow.dart'; import 'store.dart'; extension ComposeContentAutocomplete on ComposeContentController { + int get _maxLookbackForAutocompleteIntent { + return 1 // intent character i.e., "#" + + 2 // some optional characters i.e., "_" for silent mention or "**" + + store.maxChannelNameLength; + } + AutocompleteIntent? autocompleteIntent() { if (!selection.isValid || !selection.isNormalized) { // We don't require [isCollapsed] to be true because we've seen that @@ -30,7 +36,7 @@ extension ComposeContentAutocomplete on ComposeContentController { // To avoid spending a lot of time searching for autocomplete intents // in long messages, we bound how far back we look for the intent's start. - final earliest = max(0, selection.end - 30); + final earliest = max(0, selection.end - _maxLookbackForAutocompleteIntent); if (selection.start < earliest) { // The selection extends to before any position we'd consider @@ -48,6 +54,9 @@ extension ComposeContentAutocomplete on ComposeContentController { } else if (charAtPos == ':') { final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos); if (match == null) continue; + } else if (charAtPos == '#') { + final match = _channelLinkIntentRegex.matchAsPrefix(textUntilCursor, pos); + if (match == null) continue; } else { continue; } @@ -66,6 +75,10 @@ extension ComposeContentAutocomplete on ComposeContentController { final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos); if (match == null) continue; query = EmojiAutocompleteQuery(match[1]!); + } else if (charAtPos == '#') { + final match = _channelLinkIntentRegex.matchAsPrefix(textUntilCursor, pos); + if (match == null) continue; + query = ChannelLinkAutocompleteQuery(match[1] ?? match[2]!); } else { continue; } @@ -165,6 +178,48 @@ final RegExp _emojiIntentRegex = (() { + r')$'); })(); +final RegExp _channelLinkIntentRegex = () { + // What's likely to come just before #channel syntax: the start of the string, + // whitespace, or punctuation. Letters are unlikely; in that case a GitHub- + // style "zulip/zulip-flutter#124" link might be intended (as on CZO where + // there's a custom linkifier for that). + // + // By punctuation, we mean *some* punctuation, like "(". We make "#" and "@" + // exceptions, to support typing "##channel" for the channel query "#channel", + // and typing "@#user" for the mention query "#user", because in 2025-11 + // channel and user name words can start with "#". (They can also contain "#" + // anywhere else in the name; we don't handle that specially.) + const before = r'(?<=^|\s|\p{Punctuation})(? { AutocompleteIntent({ diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index ab0299960b..619d41417a 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -45,7 +45,8 @@ class _AutocompleteFieldState MentionAutocompleteQuery(raw, silent: false); MentionAutocompleteQuery silentMention(String raw) => MentionAutocompleteQuery(raw, silent: true); + ChannelLinkAutocompleteQuery channelLink(String raw) => ChannelLinkAutocompleteQuery(raw); EmojiAutocompleteQuery emoji(String raw) => EmojiAutocompleteQuery(raw); doTest('', null); @@ -180,8 +184,101 @@ void main() { doTest('~@_Rodion Romanovich Raskolniko^', silentMention('Rodion Romanovich Raskolniko')); doTest('~@Родион Романович Раскольников^', mention('Родион Романович Раскольников')); doTest('~@_Родион Романович Раскольнико^', silentMention('Родион Романович Раскольнико')); - doTest('If @chris is around, please ask him.^', null); // @ sign is too far away from cursor - doTest('If @_chris is around, please ask him.^', null); // @ sign is too far away from cursor + + // @ sign can be (3 + maxChannelName) characters away to the left of cursor. + doTest('If ~@chris^ is around, please ask him.', mention('chris'), maxChannelName: 20); + doTest('If ~@_chris is^ around, please ask him.', silentMention('chris is'), maxChannelName: 20); + doTest('If @chris is around, please ask him.^', null, maxChannelName: 20); + doTest('If @_chris is around, please ask him.^', null, maxChannelName: 20); + + // #channel link. + + doTest('^#', null); + doTest('^#abc', null); + doTest('#abc', null); // (no cursor) + + doTest('~#^', channelLink('')); + doTest('~##^', channelLink('#')); + doTest('~#abc^', channelLink('abc')); + doTest('~#abc ^', channelLink('abc ')); + doTest('~#abc def^', channelLink('abc def')); + + // Accept space before channel link syntax. + doTest(' ~#abc^', channelLink('abc')); + doTest('xyz ~#abc^', channelLink('abc')); + + // Accept punctuations before channel link syntax. + doTest(':~#abc^', channelLink('abc')); + doTest('!~#abc^', channelLink('abc')); + doTest(',~#abc^', channelLink('abc')); + doTest('.~#abc^', channelLink('abc')); + doTest('(~#abc^', channelLink('abc')); doTest(')~#abc^', channelLink('abc')); + doTest('{~#abc^', channelLink('abc')); doTest('}~#abc^', channelLink('abc')); + doTest('[~#abc^', channelLink('abc')); doTest(']~#abc^', channelLink('abc')); + doTest('“~#abc^', channelLink('abc')); doTest('”~#abc^', channelLink('abc')); + doTest('«~#abc^', channelLink('abc')); doTest('»~#abc^', channelLink('abc')); + // … and other punctuations except '#' and '@': + doTest('~##abc^', channelLink('#abc')); + doTest('~@#abc^', mention('#abc')); + + // Avoid other characters before channel link syntax. + doTest('+#abc^', null); + doTest('=#abc^', null); + doTest('\$#abc^', null); + doTest('zulip/zulip-flutter#124^', null); + doTest('XYZ#abc^', null); + doTest('xyz#abc^', null); + // … but + doTest('~#xyz#abc^', channelLink('xyz#abc')); + + // Avoid leading space character in query. + doTest('# ^', null); + doTest('# abc^', null); + + // Avoid line-break characters in query. + doTest('#\n^', null); doTest('#a\n^', null); doTest('#\na^', null); doTest('#a\nb^', null); + doTest('#\r^', null); doTest('#a\r^', null); doTest('#\ra^', null); doTest('#a\rb^', null); + doTest('#\r\n^', null); doTest('#a\r\n^', null); doTest('#\r\na^', null); doTest('#a\r\nb^', null); + + // Allow all other sorts of characters in query. + doTest('~#\u0000^', channelLink('\u0000')); // control + doTest('~#\u061C^', channelLink('\u061C')); // format character + doTest('~#\u0600^', channelLink('\u0600')); // format + doTest('~#\uD834^', channelLink('\uD834')); // leading surrogate + doTest('~#`^', channelLink('`')); doTest('~#a`b^', channelLink('a`b')); + doTest('~#\\^', channelLink('\\')); doTest('~#a\\b^', channelLink('a\\b')); + doTest('~#"^', channelLink('"')); doTest('~#a"b^', channelLink('a"b')); + doTest('~#>^', channelLink('>')); doTest('~#a>b^', channelLink('a>b')); + doTest('~#&^', channelLink('&')); doTest('~#a&b^', channelLink('a&b')); + doTest('~#_^', channelLink('_')); doTest('~#a_b^', channelLink('a_b')); + doTest('~#*^', channelLink('*')); doTest('~#a*b^', channelLink('a*b')); + + // Two leading stars ('**') in the query are omitted. + doTest('~#**^', channelLink('')); + doTest('~#**abc^', channelLink('abc')); + doTest('~#**abc ^', channelLink('abc ')); + doTest('~#**abc def^', channelLink('abc def')); + doTest('#** ^', null); + doTest('#** abc^', null); + + doTest('~#**abc*^', channelLink('abc*')); + + // Query with leading '**' should not contain other '**'. + doTest('#**abc**^', null); + doTest('#**abc** ^', null); + doTest('#**abc** def^', null); + + // Query without leading '**' can contain other '**'. + doTest('~#abc**^', channelLink('abc**')); + doTest('~#abc** ^', channelLink('abc** ')); + doTest('~#abc** def^', channelLink('abc** def')); + doTest('~#*abc**^', channelLink('*abc**')); + + // "#" sign can be (3 + maxChannelName) characters away to the left of cursor. + doTest('check ~#**mobile dev^ team', channelLink('mobile dev'), maxChannelName: 10); + doTest('check ~#mobile dev t^eam', channelLink('mobile dev t'), maxChannelName: 10); + doTest('check #mobile dev te^am', null, maxChannelName: 10); + doTest('check #mobile dev team for more info^', null, maxChannelName: 10); // Emoji (":smile:"). From 33d9b46777503e423ec48fef55124df249c4f6fd Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 3 Nov 2025 11:09:18 +0430 Subject: [PATCH 12/16] autocomplete [nfc]: Add a TODO(#1967) for ignoring starting "**" after "#" --- lib/model/autocomplete.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index b366fa381e..3871f1be1b 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -112,6 +112,7 @@ final RegExp _mentionIntentRegex = (() { // full_name, find uses of UserProfile.NAME_INVALID_CHARS in zulip/zulip.) const fullNameAndEmailCharExclusions = r'\*`\\>"\p{Other}'; + // TODO(#1967): ignore immediate "**" after '@' sign return RegExp( beforeAtSign + r'@(_?)' // capture, so we can distinguish silent mentions From c7651f29144ea4685155495411a9604684f1f723 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 21 Oct 2025 20:35:55 +0430 Subject: [PATCH 13/16] autocomplete test: Make setupToComposeInput accept `channels` param --- test/widgets/autocomplete_test.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 982c6ee3f0..0b16e0b54f 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -32,7 +32,7 @@ late PerAccountStore store; /// Simulates loading a [MessageListPage] and tapping to focus the compose input. /// -/// Also adds [users] to the [PerAccountStore], +/// Also adds [users] and [channels] to the [PerAccountStore], /// so they can show up in autocomplete. /// /// Also sets [debugNetworkImageHttpClientProvider] to return a constant image. @@ -41,6 +41,7 @@ late PerAccountStore store; /// before the end of the test. Future setupToComposeInput(WidgetTester tester, { List users = const [], + List channels = const [], Narrow? narrow, }) async { assert(narrow is ChannelNarrow? || narrow is SendableNarrow?); @@ -52,6 +53,7 @@ Future setupToComposeInput(WidgetTester tester, { store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers([eg.selfUser, eg.otherUser]); await store.addUsers(users); + await store.addStreams(channels); final connection = store.connection as FakeApiConnection; narrow ??= DmNarrow( From 5fc6a997dbd5b3eb7f4c91d796a12fc741c9b670 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 24 Sep 2025 23:03:31 +0430 Subject: [PATCH 14/16] internal_link [nfc]: Factor out constructing fragment in its own method 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. --- lib/model/internal_link.dart | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/lib/model/internal_link.dart b/lib/model/internal_link.dart index bb40fb98fa..334e8bf048 100644 --- a/lib/model/internal_link.dart +++ b/lib/model/internal_link.dart @@ -58,6 +58,18 @@ String? decodeHashComponent(String str) { // When you want to point the server to a location in a message list, you // you do so by passing the `anchor` param. Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { + final fragment = narrowLinkFragment(store, narrow, nearMessageId: nearMessageId); + Uri result = store.realmUrl.replace(fragment: fragment); + if (result.path.isEmpty) { + // Always ensure that there is a '/' right after the hostname. + // A generated URL without '/' looks odd, + // and if used in a Zulip message does not get automatically linkified. + result = result.replace(path: '/'); + } + return result; +} + +String narrowLinkFragment(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { // TODO(server-7) final apiNarrow = resolveApiNarrowForServer( narrow.apiEncode(), store.zulipFeatureLevel); @@ -101,14 +113,7 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { fragment.write('/near/$nearMessageId'); } - Uri result = store.realmUrl.replace(fragment: fragment.toString()); - if (result.path.isEmpty) { - // Always ensure that there is a '/' right after the hostname. - // A generated URL without '/' looks odd, - // and if used in a Zulip message does not get automatically linkified. - result = result.replace(path: '/'); - } - return result; + return fragment.toString(); } /// The result of parsing some URL within a Zulip realm, From 032713d732f1679cbc582d1e13fb96a3c10505a1 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 3 Nov 2025 13:40:31 +0430 Subject: [PATCH 15/16] compose: Introduce `fallbackMarkdownLink` function --- lib/model/compose.dart | 50 ++++++++++++++++++++++++++++++++++++ test/model/compose_test.dart | 33 ++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/lib/model/compose.dart b/lib/model/compose.dart index 3a7a75976f..6fde286dca 100644 --- a/lib/model/compose.dart +++ b/lib/model/compose.dart @@ -185,6 +185,56 @@ String wildcardMention(WildcardMentionOption wildcardOption, { String userGroupMention(String userGroupName, {bool silent = false}) => '@${silent ? '_' : ''}*$userGroupName*'; +// Corresponds to `topic_link_util.escape_invalid_stream_topic_characters` +// in Zulip web: +// https://github.com/zulip/zulip/blob/b42d3e77e/web/src/topic_link_util.ts#L15-L34 +const _channelTopicFaultyCharsReplacements = { + '`': '`', + '>': '>', + '*': '*', + '&': '&', + '[': '[', + ']': ']', + r'$$': '$$', +}; + +final _channelTopicFaultyCharsRegex = RegExp(r'[`>*&[\]]|(?:\$\$)'); + +/// 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({ + required PerAccountStore store, + required ZulipStream channel, + TopicName? topic, + int? nearMessageId, +}) { + assert(nearMessageId == null || topic != null); + + String replaceFaultyChars(String str) { + return str.replaceAllMapped(_channelTopicFaultyCharsRegex, + (match) => _channelTopicFaultyCharsReplacements[match[0]]!); + } + + final text = StringBuffer(replaceFaultyChars(channel.name)); + if (topic != null) { + text.write(' > ${replaceFaultyChars(topic.displayName ?? store.realmEmptyTopicDisplayName)}'); + } + if (nearMessageId != null) { + text.write(' @ 💬'); + } + + final narrow = topic == null + ? ChannelNarrow(channel.streamId) : TopicNarrow(channel.streamId, topic); + final linkFragment = narrowLinkFragment(store, narrow, nearMessageId: nearMessageId); + + return inlineLink(text.toString(), '#$linkFragment'); +} + /// https://spec.commonmark.org/0.30/#inline-link /// /// The "link text" is made by enclosing [visibleText] in square brackets. diff --git a/test/model/compose_test.dart b/test/model/compose_test.dart index 7d6e81db33..1a007c7803 100644 --- a/test/model/compose_test.dart +++ b/test/model/compose_test.dart @@ -1,6 +1,7 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/compose.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/store.dart'; @@ -334,6 +335,38 @@ hello }); }); + test('fallbackMarkdownLink', () async { + final store = eg.store(); + final channels = [ + eg.stream(streamId: 1, name: '`code`'), + eg.stream(streamId: 2, name: 'score > 90'), + eg.stream(streamId: 3, name: 'A*'), + eg.stream(streamId: 4, name: 'R&D'), + eg.stream(streamId: 5, name: 'UI [v2]'), + eg.stream(streamId: 6, name: r'Save $$'), + ]; + await store.addStreams(channels); + + check(fallbackMarkdownLink(store: store, + channel: channels[1 - 1])) + .equals('[`code`](#narrow/channel/1-.60code.60)'); + check(fallbackMarkdownLink(store: store, + channel: channels[2 - 1], topic: TopicName('topic'))) + .equals('[score > 90 > topic](#narrow/channel/2-score-.3E-90/topic/topic)'); + check(fallbackMarkdownLink(store: store, + channel: channels[3 - 1], topic: TopicName('R&D'))) + .equals('[A* > R&D](#narrow/channel/3-A*/topic/R.26D)'); + check(fallbackMarkdownLink(store: store, + channel: channels[4 - 1], topic: TopicName('topic'), nearMessageId: 10)) + .equals('[R&D > topic @ 💬](#narrow/channel/4-R.26D/topic/topic/near/10)'); + check(fallbackMarkdownLink(store: store, + channel: channels[5 - 1], topic: TopicName(r'Save $$'), nearMessageId: 10)) + .equals('[UI [v2] > Save $$ @ 💬](#narrow/channel/5-UI-.5Bv2.5D/topic/Save.20.24.24/near/10)'); + check(() => fallbackMarkdownLink(store: store, + channel: channels[6 - 1], nearMessageId: 10)) + .throws(); + }); + test('inlineLink', () { check(inlineLink('CZO', 'https://chat.zulip.org/')).equals('[CZO](https://chat.zulip.org/)'); check(inlineLink('Uploading file.txt…', '')).equals('[Uploading file.txt…]()'); From d4e2458730d27d12fe9b614da6cf415cc305fde9 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 3 Nov 2025 13:42:05 +0430 Subject: [PATCH 16/16] channel: Finish channel link autocomplete for compose box Figma design: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=7952-30060&t=YfdW2W1p4ROsq9db-0 Fixes-partly: #124 --- lib/model/compose.dart | 11 +++++ lib/widgets/autocomplete.dart | 63 ++++++++++++++++++++++++++--- test/model/compose_test.dart | 42 +++++++++++++++++++ test/widgets/autocomplete_test.dart | 53 ++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 5 deletions(-) diff --git a/lib/model/compose.dart b/lib/model/compose.dart index 6fde286dca..6a6ae4f75b 100644 --- a/lib/model/compose.dart +++ b/lib/model/compose.dart @@ -235,6 +235,17 @@ String fallbackMarkdownLink({ return inlineLink(text.toString(), '#$linkFragment'); } +/// A #channel link syntax of a channel, like #**announce**. +/// +/// [fallbackMarkdownLink] will be used if the channel name includes some faulty +/// characters that will break normal #**channel** rendering. +String channelLink(ZulipStream channel, {required PerAccountStore store}) { + if (_channelTopicFaultyCharsRegex.hasMatch(channel.name)) { + return fallbackMarkdownLink(store: store, channel: channel); + } + return '#**${channel.name}**'; +} + /// https://spec.commonmark.org/0.30/#inline-link /// /// The "link text" is made by enclosing [visibleText] in square brackets. diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index 619d41417a..4701818d37 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -45,8 +45,7 @@ class _AutocompleteFieldState MentionAutocompleteItem( option: option, narrow: narrow), - ChannelLinkAutocompleteResult() => throw UnimplementedError(), // TODO(#124) + ChannelLinkAutocompleteResult() => _ChannelLinkAutocompleteItem(option: option), EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option), }; return InkWell( @@ -361,6 +369,51 @@ class MentionAutocompleteItem extends StatelessWidget { } } +class _ChannelLinkAutocompleteItem extends StatelessWidget { + const _ChannelLinkAutocompleteItem({required this.option}); + + final ChannelLinkAutocompleteResult option; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + + final channel = store.streams[option.channelId]; + + // A null [Icon.icon] makes a blank space. + IconData? icon; + Color? iconColor; + String label; + if (channel != null) { + icon = iconDataForStream(channel); + iconColor = colorSwatchFor(context, store.subscriptions[channel.streamId]) + .iconOnPlainBackground; + label = channel.name; + } else { + icon = null; + iconColor = null; + label = zulipLocalizations.unknownChannelName; + } + + final labelWidget = Text(label, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18, height: 20 / 18, + fontStyle: channel == null ? FontStyle.italic : FontStyle.normal, + color: designVariables.contextMenuItemLabel, + ).merge(weightVariableTextStyle(context, wght: 600))); + + return Padding( + padding: EdgeInsetsDirectional.fromSTEB(4, 4, 8, 4), + child: Row(spacing: 6, children: [ + SizedBox.square(dimension: 36, child: Icon(size: 18, color: iconColor, icon)), + Expanded(child: labelWidget), // TODO(#1945): show channel description + ])); + } +} + class _EmojiAutocompleteItem extends StatelessWidget { const _EmojiAutocompleteItem({required this.option}); diff --git a/test/model/compose_test.dart b/test/model/compose_test.dart index 1a007c7803..43009e12f0 100644 --- a/test/model/compose_test.dart +++ b/test/model/compose_test.dart @@ -367,6 +367,48 @@ hello .throws(); }); + group('channel link', () { + test('channels with normal names', () async { + final store = eg.store(); + final channels = [ + eg.stream(name: 'mobile'), + eg.stream(name: 'dev-ops'), + eg.stream(name: 'ui/ux'), + eg.stream(name: 'api_v3'), + eg.stream(name: 'build+test'), + eg.stream(name: 'init()'), + ]; + await store.addStreams(channels); + + check(channelLink(channels[0], store: store)).equals('#**mobile**'); + check(channelLink(channels[1], store: store)).equals('#**dev-ops**'); + check(channelLink(channels[2], store: store)).equals('#**ui/ux**'); + check(channelLink(channels[3], store: store)).equals('#**api_v3**'); + check(channelLink(channels[4], store: store)).equals('#**build+test**'); + check(channelLink(channels[5], store: store)).equals('#**init()**'); + }); + + test('channels with names containing faulty characters', () async { + final store = eg.store(); + final channels = [ + eg.stream(streamId: 1, name: '`code`'), + eg.stream(streamId: 2, name: 'score > 90'), + eg.stream(streamId: 3, name: 'A*'), + eg.stream(streamId: 4, name: 'R&D'), + eg.stream(streamId: 5, name: 'UI [v2]'), + eg.stream(streamId: 6, name: r'Save $$'), + ]; + await store.addStreams(channels); + + check(channelLink(channels[1 - 1], store: store)).equals('[`code`](#narrow/channel/1-.60code.60)'); + check(channelLink(channels[2 - 1], store: store)).equals('[score > 90](#narrow/channel/2-score-.3E-90)'); + check(channelLink(channels[3 - 1], store: store)).equals('[A*](#narrow/channel/3-A*)'); + check(channelLink(channels[4 - 1], store: store)).equals('[R&D](#narrow/channel/4-R.26D)'); + check(channelLink(channels[5 - 1], store: store)).equals('[UI [v2]](#narrow/channel/5-UI-.5Bv2.5D)'); + check(channelLink(channels[6 - 1], store: store)).equals('[Save $$](#narrow/channel/6-Save-.24.24)'); + }); + }); + test('inlineLink', () { check(inlineLink('CZO', 'https://chat.zulip.org/')).equals('[CZO](https://chat.zulip.org/)'); check(inlineLink('Uploading file.txt…', '')).equals('[Uploading file.txt…]()'); diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 0b16e0b54f..d6e6b0d4fc 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -16,6 +16,7 @@ import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; import 'package:zulip/widgets/autocomplete.dart'; import 'package:zulip/widgets/compose_box.dart'; +import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/image.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/user.dart'; @@ -355,6 +356,58 @@ void main() { }); }); + group('#channel link', () { + void checkChannelShown(ZulipStream channel, {required bool expected}) { + check(find.byIcon(iconDataForStream(channel))).findsAtLeast(expected ? 1 : 0); + check(find.text(channel.name)).findsExactly(expected ? 1 : 0); + } + + testWidgets('user options appear, disappear, and change correctly', (tester) async { + final channel1 = eg.stream(name: 'mobile'); + final channel2 = eg.stream(name: 'mobile design'); + final channel3 = eg.stream(name: 'mobile dev help'); + final composeInputFinder = await setupToComposeInput(tester, + channels: [channel1, channel2, channel3]); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + + // Options are filtered correctly for query. + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'check #mobile '); + await tester.enterText(composeInputFinder, 'check #mobile de'); + await tester.pumpAndSettle(); // async computation; options appear + + checkChannelShown(channel1, expected: false); + checkChannelShown(channel2, expected: true); + checkChannelShown(channel3, expected: true); + + // Finishing autocomplete updates compose box; causes options to disappear. + await tester.tap(find.text('mobile design')); + await tester.pump(); + check(tester.widget(composeInputFinder).controller!.text) + .contains(channelLink(channel2, store: store)); + checkChannelShown(channel1, expected: false); + checkChannelShown(channel2, expected: false); + checkChannelShown(channel3, expected: false); + + // Then a new autocomplete intent brings up options again. + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'check #mobile de'); + await tester.enterText(composeInputFinder, 'check #mobile dev'); + await tester.pumpAndSettle(); // async computation; options appear + checkChannelShown(channel3, expected: true); + + // Removing autocomplete intent causes options to disappear. + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'check '); + await tester.enterText(composeInputFinder, 'check'); + checkChannelShown(channel1, expected: false); + checkChannelShown(channel2, expected: false); + checkChannelShown(channel3, expected: false); + + debugNetworkImageHttpClientProvider = null; + }); + }); + group('emoji', () { void checkEmojiShown(ExpectedEmoji option, {required bool expected}) { final (label, display) = option;