From bffaeed95507d203993eb4bf11bfc85ed49d44bb Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 29 Oct 2025 12:42:39 +0430 Subject: [PATCH 01/18] 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 6e7d85b679081b3d64879244305240eb89d2351a Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 29 Oct 2025 13:42:53 +0430 Subject: [PATCH 02/18] 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 975098c5a8ab171bbc1676df946717578140bfa4 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 6 Oct 2025 21:34:55 +0430 Subject: [PATCH 03/18] 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..bc5503402a 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..82b8449da5 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..1e76e2e1d8 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 b869d897da6b6fe88373d2a8097e5091daab56c3 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Thu, 9 Oct 2025 20:43:38 +0430 Subject: [PATCH 04/18] 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 bc5503402a..c3a2eb0126 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 82b8449da5..e5e3f3d87a 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.remove(channelId); + if (channel == null) continue; // TODO(log) + assert(channelId == channel.streamId); + assert(identical(channel, streamsByName[channel.name])); + assert(subscriptions[channelId] == null + || identical(subscriptions[channelId], channel)); + streamsByName.remove(channel.name); + subscriptions.remove(channelId); } 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 1652cb4e358b449f2ee335723e9219cb10dbbd2b Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 10 Nov 2025 12:18:30 +0430 Subject: [PATCH 05/18] autocomplete: Call EmojiAutocompleteView.reassemble where needed --- lib/model/autocomplete.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index cf5c6b86e0..ac49837c62 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -269,6 +269,9 @@ class AutocompleteViewManager { for (final view in _topicAutocompleteViews) { view.reassemble(); } + for (final view in _emojiAutocompleteViews) { + view.reassemble(); + } } // No `dispose` method, because there's nothing for it to do. From 999dc43f7854f07f370b2d1690e2dd525f68638e Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 10 Nov 2025 12:27:09 +0430 Subject: [PATCH 06/18] emoji: Add EmojiAutocompleteView.dispose So to call AutocompleteViewManager.unregisterEmojiAutocomplete. --- lib/model/emoji.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart index d5da2b34d9..fa087508fe 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -500,6 +500,12 @@ class EmojiAutocompleteView extends AutocompleteView Date: Fri, 24 Oct 2025 09:50:46 +0430 Subject: [PATCH 07/18] autocomplete [nfc]: Introduce `AutocompleteViewManager._autocompleteViews` This set replaces the three sets of different `AutocompleteView` subclasses, simplifying the code. --- lib/model/autocomplete.dart | 76 ++++++++++--------------------------- lib/model/emoji.dart | 10 +---- 2 files changed, 20 insertions(+), 66 deletions(-) diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index ac49837c62..85b4cd518d 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,13 +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) { - view.reassemble(); - } - for (final view in _emojiAutocompleteViews) { + for (final view in _autocompleteViews) { view.reassemble(); } } @@ -311,6 +283,7 @@ abstract class AutocompleteView Date: Thu, 9 Oct 2025 21:23:22 +0430 Subject: [PATCH 08/18] 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 43bea01e1074000f1b63661087af19eb22e6eb0b Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 7 Oct 2025 22:05:49 +0430 Subject: [PATCH 09/18] 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 85b4cd518d..580cb6f0e3 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 3de621ccfc40c974c39da616aee861fa79ce2c8f Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 8 Oct 2025 17:19:42 +0430 Subject: [PATCH 10/18] 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 | 310 +++++++++++++++ lib/model/store.dart | 5 + lib/widgets/autocomplete.dart | 3 + test/model/autocomplete_checks.dart | 4 + test/model/autocomplete_test.dart | 561 ++++++++++++++++++++++++++++ 5 files changed, 883 insertions(+) diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 580cb6f0e3..8bd744eb15 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,281 @@ 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. + + int? channelId; + switch (narrow) { + case ChannelNarrow(:var streamId): + case TopicNarrow(:var streamId): + channelId = streamId; + case DmNarrow(): + break; + case CombinedFeedNarrow(): + case MentionsNarrow(): + case StarredMessagesNarrow(): + case KeywordSearchNarrow(): + assert(false, 'No compose box, thus no autocomplete is available in ${narrow.runtimeType}.'); + } + return (a, b) => _compareByRelevance(a, b, composingToChannelId: channelId); + } + + static int _compareByRelevance(ZulipStream a, ZulipStream b, { + required int? composingToChannelId, + }) { + // Compare `typeahead_helper.compare_by_activity` in Zulip web; + // https://github.com/zulip/zulip/blob/c3fdee6ed/web/src/typeahead_helper.ts#L972-L988 + // + // Behavior difference that Web should probably fix, TODO(Web): + // * Web compares "recent activity" only for subscribed channels, + // but we do it for unsubscribed ones too. + // * We exclude archived channels from autocomplete results, + // but Web doesn't. + // See: [ChannelLinkAutocompleteQuery.testChannel] + + 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, + }) { + final composingToA = composingToChannelId == a.streamId; + final composingToB = composingToChannelId == b.streamId; + return switch((composingToA, composingToB)) { + (true, false) => -1, + (false, true) => 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) { + // Compare `stream_list_sort.has_recent_activity` in Zulip web: + // https://github.com/zulip/zulip/blob/925ae0f9b/web/src/stream_list_sort.ts#L84-L96 + // + // There are a few other criteria that Web considers for a channel being + // recently-active, for which we don't have all the data at the moment: + // * If the user don't want to filter out inactive streams to the + // bottom, then every channel is considered as recently-active. + // * A channel pinned to the top is also considered as recently-active, + // but we already favor that criterion before even reaching to this one. + // * If the channel is newly subscribed, then it's considered as + // recently-active. + + 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 weekly traffic defined, but zero and `null` + /// traffic are considered the same. + @visibleForTesting + static int compareByWeeklyTraffic(ZulipStream a, ZulipStream b) { + return -(a.streamWeeklyTraffic ?? 0).compareTo(b.streamWeeklyTraffic ?? 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) { + if (channel.isArchived) return null; + + 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..9a29855f61 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -10,6 +10,7 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/generated/l10n/zulip_localizations.dart'; import 'package:zulip/model/autocomplete.dart'; +import 'package:zulip/model/channel.dart'; import 'package:zulip/model/compose.dart'; import 'package:zulip/model/emoji.dart'; import 'package:zulip/model/localizations.dart'; @@ -1307,6 +1308,566 @@ 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 compare(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(compare(1, 2, composingToChannelId: 1)).isLessThan(0); + check(compare(1, 2, composingToChannelId: 2)).isGreaterThan(0); + }); + + test('none is the channel being composed to, favor none', () { + check(compare(1, 2, composingToChannelId: 3)).equals(0); + }); + + test('both are the channels being composed to (unlikely in practice), favor none', () { + check(compare(1, 1, composingToChannelId: 1)).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 compare(ZulipStream a, ZulipStream b) => + ChannelLinkAutocompleteView.compareByBeingSubscribed(a, b); + + test('favor subscribed channel over unsubscribed', () { + check(compare(subA(), channelB)).isLessThan(0); + check(compare(channelA, subB())).isGreaterThan(0); + }); + + test('both channels unsubscribed, favor none', () { + check(compare(channelA, channelB)).equals(0); + }); + + group('both channels subscribed', () { + test('favor unmuted over muted, regardless of pinned status', () { + check(compare( + subA(isMuted: false, pinToTop: true), + subB(isMuted: true, pinToTop: false), + )).isLessThan(0); + check(compare( + subA(isMuted: false, pinToTop: false), + subB(isMuted: true, pinToTop: true), + )).isLessThan(0); + + check(compare( + subA(isMuted: true, pinToTop: true), + subB(isMuted: false, pinToTop: false), + )).isGreaterThan(0); + check(compare( + subA(isMuted: true, pinToTop: false), + subB(isMuted: false, pinToTop: true), + )).isGreaterThan(0); + }); + + test('same muted status, favor pinned over unpinned', () { + check(compare( + subA(isMuted: false, pinToTop: true), + subB(isMuted: false, pinToTop: false), + )).isLessThan(0); + check(compare( + subA(isMuted: false, pinToTop: false), + subB(isMuted: false, pinToTop: true), + )).isGreaterThan(0); + + check(compare( + subA(isMuted: true, pinToTop: true), + subB(isMuted: true, pinToTop: false), + )).isLessThan(0); + check(compare( + subA(isMuted: true, pinToTop: false), + subB(isMuted: true, pinToTop: true), + )).isGreaterThan(0); + }); + + test('same muted and same pinned status, favor none', () { + check(compare( + subA(isMuted: false, pinToTop: false), + subB(isMuted: false, pinToTop: false), + )).equals(0); + check(compare( + subA(isMuted: false, pinToTop: true), + subB(isMuted: false, pinToTop: true), + )).equals(0); + + check(compare( + subA(isMuted: true, pinToTop: false), + subB(isMuted: true, pinToTop: false), + )).equals(0); + check(compare( + subA(isMuted: true, pinToTop: true), + subB(isMuted: true, pinToTop: true), + )).equals(0); + }); + }); + }); + + group('compareByRecentActivity', () { + int compare(bool a, bool b) => ChannelLinkAutocompleteView.compareByRecentActivity( + eg.stream(isRecentlyActive: a), eg.stream(isRecentlyActive: b)); + + test('favor recently-active channel over inactive', () { + check(compare(true, false)).isLessThan(0); + check(compare(false, true)).isGreaterThan(0); + }); + + test('both channels are the same, favor none', () { + check(compare(true, true)).equals(0); + check(compare(false, false)).equals(0); + }); + }); + + group('compareByWeeklyTraffic', () { + int compare(int? a, int? b) => ChannelLinkAutocompleteView.compareByWeeklyTraffic( + eg.stream(streamWeeklyTraffic: a), eg.stream(streamWeeklyTraffic: b)); + + test('favor channel with more traffic', () { + check(compare(100, 50)).isLessThan(0); + check(compare(50, 100)).isGreaterThan(0); + }); + + test('favor channel with traffic defined', () { + check(compare(100, null)).isLessThan(0); + check(compare(null, 100)).isGreaterThan(0); + }); + + test('zero vs undefined traffic, favor none', () { + check(compare(0, null)).equals(0); + check(compare(null, 0)).equals(0); + }); + + test('both channels are the same, favor none', () { + check(compare(100, 100)).equals(0); + check(compare(null, null)).equals(0); + }); + }); + + group('compareChannelsByName', () { + int compare(String a, String b) => ChannelStore.compareChannelsByName( + eg.stream(name: a), eg.stream(name: b)); + + test("favor channel with name coming first", () async { + check(compare('announce', 'backend')).isLessThan(0); + check(compare('announce', 'BACKEND')).isLessThan(0); + check(compare('backend', 'announce')).isGreaterThan(0); + check(compare('BACKEND', 'announce')).isGreaterThan(0); + + // TODO(i18n): add locale-aware sorting + // check(compare('čat', 'design')).isLessThan(0); + // check(compare('design', 'čat')).isGreaterThan(0); + }); + + test('both have identical name -> favor none', () async { + check(compare('announce', 'announce')).equals(0); + check(compare('BACKEND', 'backend')).equals(0); + + // TODO(i18n): add locale-aware sorting + // check(compare('čat', 'cat')).equals(0); + }); + }); + + late PerAccountStore store; + + void prepare({ + List channels = const [], + List subscriptions = const [], + }) { + store = eg.store(initialSnapshot: eg.initialSnapshot( + streams: channels, subscriptions: subscriptions)); + } + + group('ranking across signals', () { + 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(); + } + + // The composing-to channel ranks last on each of the other criteria, + // but comes out first in the end, showing that composing-to channel + // comes first. Then among the remaining channels, the subscribed ones + // rank last on each of the remaining criteria, but comes out top + // in the end; and so on. + final channels = [ + // Wins by being the composing-to channel. + eg.stream(name: 'Z', isRecentlyActive: false, streamWeeklyTraffic: null), + + // Next three are runners-up by being subscribed to. + // Runner-up by being unmuted. + eg.subscription(eg.stream(name: 'Y', + isRecentlyActive: false, streamWeeklyTraffic: null), + isMuted: false, pinToTop: false), + // Runner-up by being pinned. + eg.subscription(eg.stream(name: 'X', + isRecentlyActive: false, streamWeeklyTraffic: null), + isMuted: true, pinToTop: true), + // Last among subscribed ones by being unpinned. + eg.subscription(eg.stream(name: 'W', + isRecentlyActive: false, streamWeeklyTraffic: null), + isMuted: true, pinToTop: false), + + // The rest are runners-up by not being subscribed to. + // Runner-up by being recently active. + eg.stream(name: 'V', isRecentlyActive: true, streamWeeklyTraffic: null), + // Runner-up by having more weekly traffic. + eg.stream(name: 'U', isRecentlyActive: false, streamWeeklyTraffic: 100), + // Runner-up by having weekly traffic defined. + eg.stream(name: 'T', isRecentlyActive: false, streamWeeklyTraffic: 10), + // Runner-up by name. + eg.stream(name: 'A', isRecentlyActive: false, streamWeeklyTraffic: null), + // Next two are tied because no remaining criteria. + eg.stream(name: 'B', isRecentlyActive: false, streamWeeklyTraffic: null), + eg.stream(name: 'b', isRecentlyActive: false, streamWeeklyTraffic: null), + ]; + for (final narrow in [ + eg.topicNarrow(channels[0].streamId, 'this'), + ChannelNarrow(channels[0].streamId), + ]) { + test('${narrow.runtimeType}: composing-to channel > subscribed (unmuted > pinned) > recently active > weekly traffic > name', () { + prepare(); + 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('DmNarrow: subscribed (unmuted > pinned) > recently active > weekly traffic > name', () { + // Same principle as for ChannelNarrow and TopicNarrow; + // see that test case above. + final channels = [ + // Next three wins by being subscribed to. + // Wins by being unmuted. + eg.subscription(eg.stream(name: 'Z', + isRecentlyActive: false, streamWeeklyTraffic: null), + isMuted: false, pinToTop: false), + // Runner-up by being pinned. + eg.subscription(eg.stream(name: 'Y', + isRecentlyActive: false, streamWeeklyTraffic: null), + isMuted: true, pinToTop: true), + // Last among subscribed ones by being unpinned. + eg.subscription(eg.stream(name: 'X', + isRecentlyActive: false, streamWeeklyTraffic: null), + isMuted: true, pinToTop: false), + + // The rest are runners-up by not being subscribed to. + // Runner-up by being recently active. + eg.stream(name: 'W', isRecentlyActive: true, streamWeeklyTraffic: null), + // Runner-up by having more weekly traffic. + eg.stream(name: 'V', isRecentlyActive: false, streamWeeklyTraffic: 100), + // Runner-up by having weekly traffic defined. + eg.stream(name: 'U', isRecentlyActive: false, streamWeeklyTraffic: 10), + // Runner-up by name. + eg.stream(name: 'A', isRecentlyActive: false, streamWeeklyTraffic: null), + // Next two are tied because no remaining criteria. + eg.stream(name: 'B', isRecentlyActive: false, streamWeeklyTraffic: null), + eg.stream(name: 'b', isRecentlyActive: false, streamWeeklyTraffic: null), + ]; + prepare(); + 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)); + checkRankEqual(narrow, [channels[7], channels[8]]); + }); + + test('CombinedFeedNarrow gives error', () async { + prepare(); + const narrow = CombinedFeedNarrow(); + check(() => ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: ChannelLinkAutocompleteQuery(''))) + .throws(); + }); + + test('MentionsNarrow gives error', () async { + prepare(); + const narrow = MentionsNarrow(); + check(() => ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: ChannelLinkAutocompleteQuery(''))) + .throws(); + }); + + test('StarredMessagesNarrow gives error', () async { + prepare(); + const narrow = StarredMessagesNarrow(); + check(() => ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: ChannelLinkAutocompleteQuery(''))) + .throws(); + }); + + test('KeywordSearchNarrow gives error', () async { + prepare(); + 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: 10), + 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'), + ]; + + prepare(channels: 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', () { + late PerAccountStore store; + + void doCheck(String rawQuery, ZulipStream channel, bool expected) { + final result = ChannelLinkAutocompleteQuery(rawQuery).testChannel(channel, store); + expected + ? check(result).isA() + : check(result).isNull(); + } + + test('channel is always excluded when archived, regardless of other criteria', () { + store = eg.store(); + + doCheck('Channel Name', eg.stream(name: 'Channel Name', isArchived: true), false); + // When not archived, then other criteria will be checked. + doCheck('Channel Name', eg.stream(name: 'Channel Name', isArchived: false), true); + }); + + test('testChannel: channel is included if name words match the query', () { + 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', () { + // This gets filled here at the start of the group, but never reset. + // We're counting on this group's tests never doing anything to mutate it. + 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); + }); + + test('full list of ranks', () { + final channel = eg.stream(name: 'some channel'); + check([ + rankOf('some channel', channel), // exact name match + rankOf('some ch', channel), // total-prefix name match + rankOf('so ch', channel), // word-prefixes name match + ]).deepEquals([0, 1, 2]); + }); + }); + }); } typedef WildcardTester = void Function(String query, Narrow narrow, List expected); From 7f03226508e3a7bcc9492d255727e54d1e7c429e Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 20 Oct 2025 21:11:41 +0430 Subject: [PATCH 11/18] 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 9a29855f61..febbfe5db2 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 6b2ef9cff9b6908f31ee1ec28170f7e2fd1b5433 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 29 Oct 2025 18:46:52 +0430 Subject: [PATCH 12/18] 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 | 40 +++++++++++++++++++---------- test/model/autocomplete_test.dart | 3 ++- test/widgets/action_sheet_test.dart | 4 +-- test/widgets/compose_box_test.dart | 9 ++++--- 4 files changed, 37 insertions(+), 19 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 6e83356042..52faeaf151 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,10 @@ class _EditMessageComposeBoxBody extends _ComposeBoxBody { } sealed class ComposeBoxController { - final content = ComposeContentController(); + ComposeBoxController({required PerAccountStore store}) + : content = ComposeContentController(store: store); + + final ComposeContentController content; final contentFocusNode = FocusNode(); /// If no input is focused, requests focus on the appropriate input. @@ -1668,7 +1675,7 @@ enum ComposeTopicInteractionStatus { } class StreamComposeBoxController extends ComposeBoxController { - StreamComposeBoxController({required PerAccountStore store}) + StreamComposeBoxController({required super.store}) : topic = ComposeTopicController(store: store); final ComposeTopicController topic; @@ -1698,21 +1705,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 +2069,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM setState(() { controller.dispose(); _controller = EditMessageComposeBoxController( + store: store, messageId: messageId, originalRawContent: failedEdit.originalRawContent, initialText: failedEdit.newContent, @@ -2067,8 +2079,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; @@ -2134,10 +2147,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; } } @@ -2148,7 +2162,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 febbfe5db2..06125028a7 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(); + final 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 ed7fda076e9a22719e245a2b2e0f8fdf9fd1e2b8 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 8 Oct 2025 17:24:46 +0430 Subject: [PATCH 13/18] 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 | 61 ++++++++++++++++- lib/widgets/autocomplete.dart | 3 +- test/model/autocomplete_test.dart | 110 ++++++++++++++++++++++++++++-- 3 files changed, 168 insertions(+), 6 deletions(-) diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 8bd744eb15..060ac22c50 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -18,6 +18,16 @@ import 'narrow.dart'; import 'store.dart'; extension ComposeContentAutocomplete on ComposeContentController { + int get _maxLookbackForAutocompleteIntent { + return 1 // intent character e.g., "#" + + 2 // some optional characters e.g., "_" for silent mention or "**" + + // Per the API doc, maxChannelNameLength is in Unicode code points. + // We walk the string by UTF-16 code units, and there might be one or two + // of those encoding each Unicode code point. + + 2 * store.maxChannelNameLength; + } + AutocompleteIntent? autocompleteIntent() { if (!selection.isValid || !selection.isNormalized) { // We don't require [isCollapsed] to be true because we've seen that @@ -30,7 +40,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 +58,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 +79,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 +182,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,106 @@ 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 + 2 * maxChannelName) utf-16 code units + // away to the left of cursor. + doTest('If ~@chris^ is around, please ask him.', mention('chris'), maxChannelName: 10); + doTest('If ~@_chris is^ around, please ask him.', silentMention('chris is'), maxChannelName: 10); + doTest('If @chris is around, please ask him.^', null, maxChannelName: 10); + doTest('If @_chris is around, please ask him.^', null, maxChannelName: 10); + + // #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 + 2 * maxChannelName) utf-16 code units + // away to the left of cursor. + doTest('check ~#**mobile dev^ team', channelLink('mobile dev'), maxChannelName: 5); + doTest('check ~#mobile dev t^eam', channelLink('mobile dev t'), maxChannelName: 5); + doTest('check #mobile dev te^am', null, maxChannelName: 5); + doTest('check #mobile dev team for more info^', null, maxChannelName: 5); + // '🙂' is 2 utf-16 code units. + doTest('check ~#**🙂🙂🙂🙂🙂^', channelLink('🙂🙂🙂🙂🙂'), maxChannelName: 5); + doTest('check #**🙂🙂🙂🙂🙂🙂^', null, maxChannelName: 5); // Emoji (":smile:"). From c6383352a3a3fd6dd36338cb2afab60a955874f8 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 3 Nov 2025 11:09:18 +0430 Subject: [PATCH 14/18] 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 060ac22c50..66ae1a8e56 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -116,6 +116,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 3d2b9740252e9443481985db0b7a96a785ad94fd Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 21 Oct 2025 20:35:55 +0430 Subject: [PATCH 15/18] 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 1715d1b7c2e5fc9a4421b52cfae07a8f2ef2e063 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 24 Sep 2025 23:03:31 +0430 Subject: [PATCH 16/18] 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 36229c74e4867bab9aa382bab23b8a5965f5223f Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 3 Nov 2025 13:40:31 +0430 Subject: [PATCH 17/18] 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..a3755d473b 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..a9d4c88e6d 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 020c2bdf8d38f716817d338e241ee4c6e2e3aaf7 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 3 Nov 2025 13:42:05 +0430 Subject: [PATCH 18/18] 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 a3755d473b..12664f2990 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 a9d4c88e6d..8f156ba6ea 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;