diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index e095c5360b..8768cedb17 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -616,9 +616,21 @@ class ChannelDeleteEvent extends ChannelEvent { @JsonKey(includeToJson: true) String get op => 'delete'; - final List streams; + @JsonKey(name: 'stream_ids', readValue: _readChannelIds) + final List channelIds; + + // TODO(server-10) simplify away; rely on stream_ids + static List _readChannelIds(Map json, String key) { + final channelIds = json['stream_ids'] as List?; + if (channelIds != null) return channelIds.map((id) => id as int).toList(); + + final channels = json['streams'] as List; + return channels + .map((c) => (c as Map)['stream_id'] as int) + .toList(); + } - ChannelDeleteEvent({required super.id, required this.streams}); + ChannelDeleteEvent({required super.id, required this.channelIds}); factory ChannelDeleteEvent.fromJson(Map json) => _$ChannelDeleteEventFromJson(json); @@ -691,6 +703,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..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) => @@ -468,6 +470,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/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/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/autocomplete.dart b/lib/model/autocomplete.dart index cf5c6b86e0..3871f1be1b 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,12 +11,19 @@ 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'; import 'store.dart'; extension ComposeContentAutocomplete on ComposeContentController { + int get _maxLookbackForAutocompleteIntent { + return 1 // intent character i.e., "#" + + 2 // some optional characters i.e., "_" for silent mention or "**" + + store.maxChannelNameLength; + } + AutocompleteIntent? autocompleteIntent() { if (!selection.isValid || !selection.isNormalized) { // We don't require [isCollapsed] to be true because we've seen that @@ -28,7 +36,7 @@ extension ComposeContentAutocomplete on ComposeContentController { // To avoid spending a lot of time searching for autocomplete intents // in long messages, we bound how far back we look for the intent's start. - final earliest = max(0, selection.end - 30); + final earliest = max(0, selection.end - _maxLookbackForAutocompleteIntent); if (selection.start < earliest) { // The selection extends to before any position we'd consider @@ -46,6 +54,9 @@ extension ComposeContentAutocomplete on ComposeContentController { } else if (charAtPos == ':') { final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos); if (match == null) continue; + } else if (charAtPos == '#') { + final match = _channelLinkIntentRegex.matchAsPrefix(textUntilCursor, pos); + if (match == null) continue; } else { continue; } @@ -64,6 +75,10 @@ extension ComposeContentAutocomplete on ComposeContentController { final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos); if (match == null) continue; query = EmojiAutocompleteQuery(match[1]!); + } else if (charAtPos == '#') { + final match = _channelLinkIntentRegex.matchAsPrefix(textUntilCursor, pos); + if (match == null) continue; + query = ChannelLinkAutocompleteQuery(match[1] ?? match[2]!); } else { continue; } @@ -97,6 +112,7 @@ final RegExp _mentionIntentRegex = (() { // full_name, find uses of UserProfile.NAME_INVALID_CHARS in zulip/zulip.) const fullNameAndEmailCharExclusions = r'\*`\\>"\p{Other}'; + // TODO(#1967): ignore immediate "**" after '@' sign return RegExp( beforeAtSign + r'@(_?)' // capture, so we can distinguish silent mentions @@ -163,6 +179,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({ @@ -206,39 +264,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); - 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); + void registerAutocomplete(AutocompleteView view) { + final added = _autocompleteViews.add(view); assert(added); } - void unregisterEmojiAutocomplete(EmojiAutocompleteView view) { - final removed = _emojiAutocompleteViews.remove(view); + void unregisterAutocomplete(AutocompleteView view) { + final removed = _autocompleteViews.remove(view); assert(removed); } @@ -258,15 +294,22 @@ 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. /// void reassemble() { - for (final view in _mentionAutocompleteViews) { - view.reassemble(); - } - for (final view in _topicAutocompleteViews) { + for (final view in _autocompleteViews) { view.reassemble(); } } @@ -307,6 +350,7 @@ abstract class AutocompleteView 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. /// @@ -787,8 +848,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 { @@ -865,25 +926,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 @@ -1026,6 +1068,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); @@ -1036,6 +1093,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. @@ -1153,11 +1215,8 @@ class TopicAutocompleteView extends AutocompleteView { + ChannelLinkAutocompleteView._({ + required super.store, + required super.query, + required this.narrow, + required this.sortedChannels, + }); + + factory ChannelLinkAutocompleteView.init({ + required PerAccountStore store, + required Narrow narrow, + required ChannelLinkAutocompleteQuery query, + }) { + return ChannelLinkAutocompleteView._( + store: store, + query: query, + narrow: narrow, + sortedChannels: _channelsByRelevance(store: store, narrow: narrow), + ); + } + + final Narrow narrow; + final List sortedChannels; + + static List _channelsByRelevance({ + required PerAccountStore store, + required Narrow narrow, + }) { + return store.streams.values.sorted(_comparator(narrow: narrow)); + } + + /// Compare the channels the same way they would be sorted as + /// autocomplete candidates, given [query]. + /// + /// The channels must both match the query. + /// + /// This behaves the same as the comparator used for sorting in + /// [_channelsByRelevance], combined with the ranking applied at the end + /// of [computeResults]. + /// + /// This is useful for tests in order to distinguish "A comes before B" + /// from "A ranks equal to B, and the sort happened to put A before B", + /// particularly because [List.sort] makes no guarantees about the order + /// of items that compare equal. + int debugCompareChannels(ZulipStream a, ZulipStream b) { + final rankA = query.testChannel(a, store)!.rank; + final rankB = query.testChannel(b, store)!.rank; + if (rankA != rankB) return rankA.compareTo(rankB); + + return _comparator(narrow: narrow)(a, b); + } + + static Comparator _comparator({required Narrow narrow}) { + // See also [ChannelLinkAutocompleteQuery._rankResult]; + // that ranking takes precedence over this. + + final channelId = switch (narrow) { + ChannelNarrow(:var streamId) || TopicNarrow(:var streamId) => streamId, + DmNarrow() => null, + CombinedFeedNarrow() + || MentionsNarrow() + || StarredMessagesNarrow() + || KeywordSearchNarrow() => () { + assert(false, 'No compose box, thus no autocomplete is available in ${narrow.runtimeType}.'); + return null; + }(), + }; + return (a, b) => _compareByRelevance(a, b, composingToChannelId: channelId); + } + + // Check `typeahead_helper.compare_by_activity` in Zulip web; + // We follow the behavior of Web but with a small difference in that Web + // compares "recent activity" only for subscribed channels, but we do it + // for unsubscribed ones too. + // https://github.com/zulip/zulip/blob/c3fdee6ed/web/src/typeahead_helper.ts#L972-L988 + static int _compareByRelevance(ZulipStream a, ZulipStream b, { + required int? composingToChannelId, + }) { + if (composingToChannelId != null) { + final composingToResult = compareByComposingTo(a, b, + composingToChannelId: composingToChannelId); + if (composingToResult != 0) return composingToResult; + } + + final beingSubscribedResult = compareByBeingSubscribed(a, b); + if (beingSubscribedResult != 0) return beingSubscribedResult; + + final recentActivityResult = compareByRecentActivity(a, b); + if (recentActivityResult != 0) return recentActivityResult; + + final weeklyTrafficResult = compareByWeeklyTraffic(a, b); + if (weeklyTrafficResult != 0) return weeklyTrafficResult; + + return ChannelStore.compareChannelsByName(a, b); + } + + /// Comparator that puts the channel being composed to, before other ones. + @visibleForTesting + static int compareByComposingTo(ZulipStream a, ZulipStream b, { + required int composingToChannelId, + }) { + return switch ((a.streamId, b.streamId)) { + (int id, _) when id == composingToChannelId => -1, + (_, int id) when id == composingToChannelId => 1, + _ => 0, + }; + } + + /// Comparator that puts subscribed channels before unsubscribed ones. + /// + /// For subscribed channels, it puts them in the following order: + /// pinned unmuted > unpinned unmuted > pinned muted > unpinned muted + @visibleForTesting + static int compareByBeingSubscribed(ZulipStream a, ZulipStream b) { + if (a is Subscription && b is! Subscription) return -1; + if (a is! Subscription && b is Subscription) return 1; + + return switch((a, b)) { + (Subscription(isMuted: false), Subscription(isMuted: true)) => -1, + (Subscription(isMuted: true), Subscription(isMuted: false)) => 1, + (Subscription(pinToTop: true), Subscription(pinToTop: false)) => -1, + (Subscription(pinToTop: false), Subscription(pinToTop: true)) => 1, + _ => 0, + }; + } + + /// Comparator that puts recently-active channels before inactive ones. + /// + /// Being recently-active is determined by [ZulipStream.isRecentlyActive]. + @visibleForTesting + static int compareByRecentActivity(ZulipStream a, ZulipStream b) { + return switch((a.isRecentlyActive, b.isRecentlyActive)) { + (true, false) => -1, + (false, true) => 1, + // The combination of `null` and `bool` is not possible as they're both + // either `null` or `bool`, before or after server-10, respectively. + // TODO(server-10): remove the preceding comment + _ => 0, + }; + } + + /// Comparator that puts channels with more [ZulipStream.streamWeeklyTraffic] first. + /// + /// A channel with undefined weekly traffic (`null`) is put after the channel + /// with a weekly traffic defined (even if it is zero). + @visibleForTesting + static int compareByWeeklyTraffic(ZulipStream a, ZulipStream b) { + return switch((a.streamWeeklyTraffic, b.streamWeeklyTraffic)) { + (int a, int b) => -a.compareTo(b), + (int(), null) => -1, + (null, int()) => 1, + _ => 0, + }; + } + + @override + Future?> computeResults() async { + final unsorted = []; + if (await filterCandidates(filter: _testChannel, + candidates: sortedChannels, results: unsorted)) { + return null; + } + + return bucketSort(unsorted, + (r) => r.rank, numBuckets: ChannelLinkAutocompleteQuery._numResultRanks); + } + + ChannelLinkAutocompleteResult? _testChannel(ChannelLinkAutocompleteQuery query, ZulipStream channel) { + return query.testChannel(channel, store); + } +} + +/// A #channel autocomplete query, used by [ChannelLinkAutocompleteView]. +class ChannelLinkAutocompleteQuery extends ComposeAutocompleteQuery { + ChannelLinkAutocompleteQuery(super.raw); + + @override + ChannelLinkAutocompleteView initViewModel({ + required PerAccountStore store, + required ZulipLocalizations localizations, + required Narrow narrow, + }) { + return ChannelLinkAutocompleteView.init(store: store, query: this, narrow: narrow); + } + + ChannelLinkAutocompleteResult? testChannel(ZulipStream channel, PerAccountStore store) { + final cache = store.autocompleteViewManager.autocompleteDataCache; + final matchQuality = _matchName( + normalizedName: cache.normalizedNameForChannel(channel), + normalizedNameWords: cache.normalizedNameWordsForChannel(channel)); + if (matchQuality == null) return null; + return ChannelLinkAutocompleteResult( + channelId: channel.streamId, rank: _rankResult(matchQuality)); + } + + /// A measure of a channel result's quality in the context of the query, + /// from 0 (best) to one less than [_numResultRanks]. + static int _rankResult(NameMatchQuality matchQuality) { + return switch(matchQuality) { + NameMatchQuality.exact => 0, + NameMatchQuality.totalPrefix => 1, + NameMatchQuality.wordPrefixes => 2, + }; + } + + /// The number of possible values returned by [_rankResult]. + static const _numResultRanks = 3; + + @override + String toString() { + return '${objectRuntimeType(this, 'ChannelLinkAutocompleteQuery')}($raw)'; + } + + @override + bool operator ==(Object other) { + return other is ChannelLinkAutocompleteQuery && other.raw == raw; + } + + @override + int get hashCode => Object.hash('ChannelLinkAutocompleteQuery', raw); +} + +/// An autocomplete result for a #channel. +class ChannelLinkAutocompleteResult extends ComposeAutocompleteResult { + ChannelLinkAutocompleteResult({required this.channelId, required this.rank}); + + final int channelId; + + /// A measure of the result's quality in the context of the query. + /// + /// Used internally by [ChannelLinkAutocompleteView] for ranking the results. + // See also [ChannelLinkAutocompleteView._channelsByRelevance]; + // results with equal [rank] will appear in the order they were put in + // by that method. + // + // Compare sort_streams in Zulip web: + // https://github.com/zulip/zulip/blob/a5d25826b/web/src/typeahead_helper.ts#L998-L1008 + // + // Behavior we have that web doesn't and might like to follow: + // - A "word-prefixes" match quality on channel names: + // see [NameMatchQuality.wordPrefixes], which we rank on. + // + // Behavior web has that seems undesired, which we don't plan to follow: + // - A "word-boundary" match quality on channel names: + // special rank when the whole query appears contiguously + // right after a word-boundary character. + // Our [NameMatchQuality.wordPrefixes] seems smarter. + // - Ranking some case-sensitive matches differently from case-insensitive + // matches. Users will expect a lowercase query to be adequate. + // - Matching and ranking on channel descriptions but only when the query + // is present (but not an exact match, total-prefix, or word-boundary match) + // in the channel name. This doesn't seem to be helpful in most cases, + // because it is hard for a query to be present in the name (the way + // mentioned before) and also present in the description. + final int rank; +} diff --git a/lib/model/channel.dart b/lib/model/channel.dart index 10e9596eed..db4bfc4316 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -398,13 +398,15 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore { // details will come in a later `subscription` event.) case ChannelDeleteEvent(): - for (final stream in event.streams) { - assert(identical(streams[stream.streamId], streamsByName[stream.name])); - assert(subscriptions[stream.streamId] == null - || identical(subscriptions[stream.streamId], streams[stream.streamId])); - streams.remove(stream.streamId); - streamsByName.remove(stream.name); - subscriptions.remove(stream.streamId); + for (final channelId in event.channelIds) { + final channel = streams[channelId]; + if (channel == null) break; + assert(identical(streams[channel.streamId], streamsByName[channel.name])); + assert(subscriptions[channelId] == null + || identical(subscriptions[channelId], streams[channelId])); + streams.remove(channel.streamId); + streamsByName.remove(channel.name); + subscriptions.remove(channel.streamId); } case ChannelUpdateEvent(): @@ -458,6 +460,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/lib/model/compose.dart b/lib/model/compose.dart index 3a7a75976f..6a6ae4f75b 100644 --- a/lib/model/compose.dart +++ b/lib/model/compose.dart @@ -185,6 +185,67 @@ 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'); +} + +/// 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/model/emoji.dart b/lib/model/emoji.dart index d5da2b34d9..a41be45130 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -481,9 +481,7 @@ class EmojiAutocompleteView extends AutocompleteView channel.streamId); - _handleSubscriptionsRemoved(channelIds); + _handleSubscriptionsRemoved(event.channelIds); } void handleSubscriptionRemoveEvent(SubscriptionRemoveEvent event) { 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; diff --git a/lib/model/store.dart b/lib/model/store.dart index fda6e5b695..132dd9b37b 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(): @@ -825,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..4701818d37 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -226,6 +226,17 @@ class ComposeAutocomplete extends AutocompleteField MentionAutocompleteItem( option: option, narrow: narrow), + ChannelLinkAutocompleteResult() => _ChannelLinkAutocompleteItem(option: option), EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option), }; return InkWell( @@ -357,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/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 1b00654b8f..a4bc81e56a 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -89,7 +89,9 @@ const double _composeButtonSize = 44; /// /// Subclasses must ensure that [_update] is called in all exposed constructors. abstract class ComposeController extends TextEditingController { - ComposeController({super.text}); + ComposeController({super.text, required this.store}); + + PerAccountStore store; int get maxLengthUnicodeCodePoints; @@ -152,12 +154,10 @@ enum TopicValidationError { } class ComposeTopicController extends ComposeController { - ComposeTopicController({super.text, required this.store}) { + ComposeTopicController({super.text, required super.store}) { _update(); } - PerAccountStore store; - // TODO(#668): listen to [PerAccountStore] once we subscribe to this value bool get mandatory => store.realmMandatoryTopics; @@ -234,7 +234,11 @@ enum ContentValidationError { } class ComposeContentController extends ComposeController { - ComposeContentController({super.text, this.requireNotEmpty = true}) { + ComposeContentController({ + super.text, + required super.store, + this.requireNotEmpty = true, + }) { _update(); } @@ -1575,7 +1579,11 @@ class _EditMessageComposeBoxBody extends _ComposeBoxBody { } sealed class ComposeBoxController { - final content = ComposeContentController(); + ComposeBoxController({required this.store}) + : content = ComposeContentController(store: store); + + final PerAccountStore store; + final ComposeContentController content; final contentFocusNode = FocusNode(); /// If no input is focused, requests focus on the appropriate input. @@ -1668,7 +1676,7 @@ enum ComposeTopicInteractionStatus { } class StreamComposeBoxController extends ComposeBoxController { - StreamComposeBoxController({required PerAccountStore store}) + StreamComposeBoxController({required super.store}) : topic = ComposeTopicController(store: store); final ComposeTopicController topic; @@ -1698,21 +1706,25 @@ class StreamComposeBoxController extends ComposeBoxController { } } -class FixedDestinationComposeBoxController extends ComposeBoxController {} +class FixedDestinationComposeBoxController extends ComposeBoxController { + FixedDestinationComposeBoxController({required super.store}); +} class EditMessageComposeBoxController extends ComposeBoxController { EditMessageComposeBoxController({ + required super.store, required this.messageId, required this.originalRawContent, required String? initialText, }) : _content = ComposeContentController( text: initialText, + store: store, // Editing to delete the content is a supported form of // deletion: https://zulip.com/help/delete-a-message#delete-message-content requireNotEmpty: false); - factory EditMessageComposeBoxController.empty(int messageId) => - EditMessageComposeBoxController(messageId: messageId, + factory EditMessageComposeBoxController.empty(PerAccountStore store, int messageId) => + EditMessageComposeBoxController(store: store, messageId: messageId, originalRawContent: null, initialText: null); @override ComposeContentController get content => _content; @@ -2058,6 +2070,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM setState(() { controller.dispose(); _controller = EditMessageComposeBoxController( + store: store, messageId: messageId, originalRawContent: failedEdit.originalRawContent, initialText: failedEdit.newContent, @@ -2067,8 +2080,9 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM } void _editFromRawContentFetch(int messageId) async { + final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); - final emptyEditController = EditMessageComposeBoxController.empty(messageId); + final emptyEditController = EditMessageComposeBoxController.empty(store, messageId); setState(() { controller.dispose(); _controller = emptyEditController; @@ -2136,10 +2150,11 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM switch (controller) { case StreamComposeBoxController(): + controller.content.store = newStore; controller.topic.store = newStore; case FixedDestinationComposeBoxController(): case EditMessageComposeBoxController(): - // no reference to the store that needs updating + controller.content.store = newStore; } } @@ -2149,7 +2164,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM _controller = StreamComposeBoxController(store: store); case TopicNarrow(): case DmNarrow(): - _controller = FixedDestinationComposeBoxController(); + _controller = FixedDestinationComposeBoxController(store: store); case CombinedFeedNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): diff --git a/test/example_data.dart b/test/example_data.dart index a6e3e9655d..04450883db 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -471,6 +471,7 @@ ZulipStream stream({ GroupSettingValue? canDeleteOwnMessageGroup, GroupSettingValue? canSendMessageGroup, GroupSettingValue? canSubscribeGroup, + bool? isRecentlyActive, int? streamWeeklyTraffic, }) { if (channelPostPolicy == null) { @@ -503,6 +504,7 @@ ZulipStream stream({ canDeleteOwnMessageGroup: canDeleteOwnMessageGroup ?? GroupSettingValueNamed(nobodyGroup.id), canSendMessageGroup: canSendMessageGroup, canSubscribeGroup: canSubscribeGroup ?? GroupSettingValueNamed(nobodyGroup.id), + isRecentlyActive: isRecentlyActive ?? true, streamWeeklyTraffic: streamWeeklyTraffic, ); } @@ -548,6 +550,7 @@ Subscription subscription( canDeleteOwnMessageGroup: stream.canDeleteOwnMessageGroup, canSendMessageGroup: stream.canSendMessageGroup, canSubscribeGroup: stream.canSubscribeGroup, + isRecentlyActive: stream.isRecentlyActive, streamWeeklyTraffic: stream.streamWeeklyTraffic, desktopNotifications: desktopNotifications ?? false, emailNotifications: emailNotifications ?? false, @@ -1271,6 +1274,8 @@ ChannelUpdateEvent channelUpdateEvent( case ChannelPropertyName.canSendMessageGroup: case ChannelPropertyName.canSubscribeGroup: assert(value is GroupSettingValue); + case ChannelPropertyName.isRecentlyActive: + assert(value is bool?); case ChannelPropertyName.streamWeeklyTraffic: assert(value is int?); } @@ -1312,6 +1317,7 @@ InitialSnapshot initialSnapshot({ String? zulipMergeBase, List? alertWords, List? customProfileFields, + int? maxChannelNameLength, int? maxTopicLength, int? serverPresencePingIntervalSeconds, int? serverPresenceOfflineThresholdSeconds, @@ -1367,6 +1373,7 @@ InitialSnapshot initialSnapshot({ zulipMergeBase: zulipMergeBase ?? recentZulipVersion, alertWords: alertWords ?? ['klaxon'], customProfileFields: customProfileFields ?? [], + maxChannelNameLength: maxChannelNameLength ?? 60, maxTopicLength: maxTopicLength ?? 60, serverPresencePingIntervalSeconds: serverPresencePingIntervalSeconds ?? 60, serverPresenceOfflineThresholdSeconds: serverPresenceOfflineThresholdSeconds ?? 140, 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..d348d981b8 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -21,6 +21,7 @@ import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../fake_async.dart'; import '../stdlib_checks.dart'; +import '../widgets/action_sheet_test.dart'; import 'test_store.dart'; import 'autocomplete_checks.dart'; @@ -29,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(); @@ -76,12 +77,16 @@ void main() { /// /// For example, "~@chris^" means the text is "@chris", the selection is /// collapsed at index 6, and we expect the syntax to start at index 0. - void doTest(String markedText, ComposeAutocompleteQuery? expectedQuery) { + void doTest(String markedText, ComposeAutocompleteQuery? expectedQuery, { + int? maxChannelName, + }) { final description = expectedQuery != null ? 'in ${jsonEncode(markedText)}, query ${jsonEncode(expectedQuery.raw)}' : 'no query in ${jsonEncode(markedText)}'; test(description, () { - final controller = ComposeContentController(); + store = eg.store(initialSnapshot: + eg.initialSnapshot(maxChannelNameLength: maxChannelName)); + final controller = ComposeContentController(store: store); final parsed = parseMarkedText(markedText); assert((expectedQuery == null) == (parsed.expectedSyntaxStart == null)); controller.value = parsed.value; @@ -97,6 +102,7 @@ void main() { MentionAutocompleteQuery mention(String raw) => 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); @@ -178,8 +184,101 @@ void main() { doTest('~@_Rodion Romanovich Raskolniko^', silentMention('Rodion Romanovich Raskolniko')); doTest('~@Π ΠΎΠ΄ΠΈΠΎΠ½ Π ΠΎΠΌΠ°Π½ΠΎΠ²ΠΈΡ‡ Раскольников^', mention('Π ΠΎΠ΄ΠΈΠΎΠ½ Π ΠΎΠΌΠ°Π½ΠΎΠ²ΠΈΡ‡ Раскольников')); doTest('~@_Π ΠΎΠ΄ΠΈΠΎΠ½ Π ΠΎΠΌΠ°Π½ΠΎΠ²ΠΈΡ‡ Раскольнико^', silentMention('Π ΠΎΠ΄ΠΈΠΎΠ½ Π ΠΎΠΌΠ°Π½ΠΎΠ²ΠΈΡ‡ Раскольнико')); - doTest('If @chris is around, please ask him.^', null); // @ sign is too far away from cursor - doTest('If @_chris is around, please ask him.^', null); // @ sign is too far away from cursor + + // @ sign can be (3 + maxChannelName) characters away to the left of cursor. + doTest('If ~@chris^ is around, please ask him.', mention('chris'), maxChannelName: 20); + doTest('If ~@_chris is^ around, please ask him.', silentMention('chris is'), maxChannelName: 20); + doTest('If @chris is around, please ask him.^', null, maxChannelName: 20); + doTest('If @_chris is around, please ask him.^', null, maxChannelName: 20); + + // #channel link. + + doTest('^#', null); + doTest('^#abc', null); + doTest('#abc', null); // (no cursor) + + doTest('~#^', channelLink('')); + doTest('~##^', channelLink('#')); + doTest('~#abc^', channelLink('abc')); + doTest('~#abc ^', channelLink('abc ')); + doTest('~#abc def^', channelLink('abc def')); + + // Accept space before channel link syntax. + doTest(' ~#abc^', channelLink('abc')); + doTest('xyz ~#abc^', channelLink('abc')); + + // Accept punctuations before channel link syntax. + doTest(':~#abc^', channelLink('abc')); + doTest('!~#abc^', channelLink('abc')); + doTest(',~#abc^', channelLink('abc')); + doTest('.~#abc^', channelLink('abc')); + doTest('(~#abc^', channelLink('abc')); doTest(')~#abc^', channelLink('abc')); + doTest('{~#abc^', channelLink('abc')); doTest('}~#abc^', channelLink('abc')); + doTest('[~#abc^', channelLink('abc')); doTest(']~#abc^', channelLink('abc')); + doTest('β€œ~#abc^', channelLink('abc')); doTest('”~#abc^', channelLink('abc')); + doTest('Β«~#abc^', channelLink('abc')); doTest('Β»~#abc^', channelLink('abc')); + // … and other punctuations except '#' and '@': + doTest('~##abc^', channelLink('#abc')); + doTest('~@#abc^', mention('#abc')); + + // Avoid other characters before channel link syntax. + doTest('+#abc^', null); + doTest('=#abc^', null); + doTest('\$#abc^', null); + doTest('zulip/zulip-flutter#124^', null); + doTest('XYZ#abc^', null); + doTest('xyz#abc^', null); + // … but + doTest('~#xyz#abc^', channelLink('xyz#abc')); + + // Avoid leading space character in query. + doTest('# ^', null); + doTest('# abc^', null); + + // Avoid line-break characters in query. + doTest('#\n^', null); doTest('#a\n^', null); doTest('#\na^', null); doTest('#a\nb^', null); + doTest('#\r^', null); doTest('#a\r^', null); doTest('#\ra^', null); doTest('#a\rb^', null); + doTest('#\r\n^', null); doTest('#a\r\n^', null); doTest('#\r\na^', null); doTest('#a\r\nb^', null); + + // Allow all other sorts of characters in query. + doTest('~#\u0000^', channelLink('\u0000')); // control + doTest('~#\u061C^', channelLink('\u061C')); // format character + doTest('~#\u0600^', channelLink('\u0600')); // format + doTest('~#\uD834^', channelLink('\uD834')); // leading surrogate + doTest('~#`^', channelLink('`')); doTest('~#a`b^', channelLink('a`b')); + doTest('~#\\^', channelLink('\\')); doTest('~#a\\b^', channelLink('a\\b')); + doTest('~#"^', channelLink('"')); doTest('~#a"b^', channelLink('a"b')); + doTest('~#>^', channelLink('>')); doTest('~#a>b^', channelLink('a>b')); + doTest('~#&^', channelLink('&')); doTest('~#a&b^', channelLink('a&b')); + doTest('~#_^', channelLink('_')); doTest('~#a_b^', channelLink('a_b')); + doTest('~#*^', channelLink('*')); doTest('~#a*b^', channelLink('a*b')); + + // Two leading stars ('**') in the query are omitted. + doTest('~#**^', channelLink('')); + doTest('~#**abc^', channelLink('abc')); + doTest('~#**abc ^', channelLink('abc ')); + doTest('~#**abc def^', channelLink('abc def')); + doTest('#** ^', null); + doTest('#** abc^', null); + + doTest('~#**abc*^', channelLink('abc*')); + + // Query with leading '**' should not contain other '**'. + doTest('#**abc**^', null); + doTest('#**abc** ^', null); + doTest('#**abc** def^', null); + + // Query without leading '**' can contain other '**'. + doTest('~#abc**^', channelLink('abc**')); + doTest('~#abc** ^', channelLink('abc** ')); + doTest('~#abc** def^', channelLink('abc** def')); + doTest('~#*abc**^', channelLink('*abc**')); + + // "#" sign can be (3 + maxChannelName) characters away to the left of cursor. + doTest('check ~#**mobile dev^ team', channelLink('mobile dev'), maxChannelName: 10); + doTest('check ~#mobile dev t^eam', channelLink('mobile dev t'), maxChannelName: 10); + doTest('check #mobile dev te^am', null, maxChannelName: 10); + doTest('check #mobile dev team for more info^', null, maxChannelName: 10); // Emoji (":smile:"). @@ -1307,6 +1406,489 @@ void main() { doCheck('nam', 'Name', true); }); }); + + group('ChannelLinkAutocompleteView', () { + Condition isChannel(int channelId) { + return (it) => it.isA() + .channelId.equals(channelId); + } + + test('misc', () async { + const narrow = ChannelNarrow(1); + final channel1 = eg.stream(streamId: 1, name: 'First'); + final channel2 = eg.stream(streamId: 2, name: 'Second'); + final store = eg.store(initialSnapshot: + eg.initialSnapshot(streams: [channel1, channel2])); + + final view = ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: ChannelLinkAutocompleteQuery('')); + bool done = false; + view.addListener(() { done = true; }); + await Future(() {}); + check(done).isTrue(); + // Based on alphabetical order. For how the ordering works, see the + // dedicated test group "sorting results" below. + check(view.results).deepEquals([1, 2].map(isChannel)); + }); + + test('results update after query change', () async { + const narrow = ChannelNarrow(1); + final channel1 = eg.stream(streamId: 1, name: 'First'); + final channel2 = eg.stream(streamId: 2, name: 'Second'); + final store = eg.store(initialSnapshot: + eg.initialSnapshot(streams: [channel1, channel2])); + + final view = ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: ChannelLinkAutocompleteQuery('Fir')); + bool done = false; + view.addListener(() { done = true; }); + await Future(() {}); + check(done).isTrue(); + check(view.results).single.which(isChannel(1)); + + done = false; + view.query = ChannelLinkAutocompleteQuery('sec'); + await Future(() {}); + check(done).isTrue(); + check(view.results).single.which(isChannel(2)); + }); + + group('sorting results', () { + group('compareByComposingTo', () { + int compareAB(int a, int b, {required int composingToChannelId}) => + ChannelLinkAutocompleteView.compareByComposingTo( + eg.stream(streamId: a), eg.stream(streamId: b), + composingToChannelId: composingToChannelId); + + test('favor the channel being composed to', () { + check(compareAB(1, 2, composingToChannelId: 1)).isLessThan(0); + check(compareAB(1, 2, composingToChannelId: 2)).isGreaterThan(0); + }); + + test('none is the channel being composed to, favor none', () { + check(compareAB(1, 2, composingToChannelId: 3)).equals(0); + }); + }); + + group('compareByBeingSubscribed', () { + final channelA = eg.stream(); + final channelB = eg.stream(); + + Subscription subA({bool? isMuted, bool? pinToTop}) => + eg.subscription(channelA, isMuted: isMuted, pinToTop: pinToTop); + Subscription subB({bool? isMuted, bool? pinToTop}) => + eg.subscription(channelB, isMuted: isMuted, pinToTop: pinToTop); + + int compareAB(ZulipStream a, ZulipStream b) => + ChannelLinkAutocompleteView.compareByBeingSubscribed(a, b); + + test('favor subscribed channel over unsubscribed', () { + check(compareAB(subA(), channelB)).isLessThan(0); + check(compareAB(channelA, subB())).isGreaterThan(0); + }); + + test('both channels unsubscribed, favor none', () { + check(compareAB(channelA, channelB)).equals(0); + }); + + group('both channels subscribed', () { + test('favor unmuted over muted, regardless of pinned status', () { + check(compareAB( + subA(isMuted: false, pinToTop: true), + subB(isMuted: true, pinToTop: false), + )).isLessThan(0); + check(compareAB( + subA(isMuted: false, pinToTop: false), + subB(isMuted: true, pinToTop: true), + )).isLessThan(0); + + check(compareAB( + subA(isMuted: true, pinToTop: true), + subB(isMuted: false, pinToTop: false), + )).isGreaterThan(0); + check(compareAB( + subA(isMuted: true, pinToTop: false), + subB(isMuted: false, pinToTop: true), + )).isGreaterThan(0); + }); + + test('same muted status, favor pinned over unpinned', () { + check(compareAB( + subA(isMuted: false, pinToTop: true), + subB(isMuted: false, pinToTop: false), + )).isLessThan(0); + check(compareAB( + subA(isMuted: false, pinToTop: false), + subB(isMuted: false, pinToTop: true), + )).isGreaterThan(0); + + check(compareAB( + subA(isMuted: true, pinToTop: true), + subB(isMuted: true, pinToTop: false), + )).isLessThan(0); + check(compareAB( + subA(isMuted: true, pinToTop: false), + subB(isMuted: true, pinToTop: true), + )).isGreaterThan(0); + }); + + test('same muted and same pinned status, favor none', () { + check(compareAB( + subA(isMuted: false, pinToTop: false), + subB(isMuted: false, pinToTop: false), + )).equals(0); + check(compareAB( + subA(isMuted: false, pinToTop: true), + subB(isMuted: false, pinToTop: true), + )).equals(0); + + check(compareAB( + subA(isMuted: true, pinToTop: false), + subB(isMuted: true, pinToTop: false), + )).equals(0); + check(compareAB( + subA(isMuted: true, pinToTop: true), + subB(isMuted: true, pinToTop: true), + )).equals(0); + }); + }); + }); + + group('compareByRecentActivity', () { + int compareAB(bool a, bool b) => ChannelLinkAutocompleteView.compareByRecentActivity( + eg.stream(isRecentlyActive: a), eg.stream(isRecentlyActive: b)); + + test('favor recently-active channel over inactive', () { + check(compareAB(true, false)).isLessThan(0); + check(compareAB(false, true)).isGreaterThan(0); + }); + + test('both channels are the same, favor none', () { + check(compareAB(true, true)).equals(0); + check(compareAB(false, false)).equals(0); + }); + }); + + group('compareByWeeklyTraffic', () { + int compareAB(int? a, int? b) => ChannelLinkAutocompleteView.compareByWeeklyTraffic( + eg.stream(streamWeeklyTraffic: a), eg.stream(streamWeeklyTraffic: b)); + + test('favor channel with more traffic', () { + check(compareAB(100, 50)).isLessThan(0); + check(compareAB(50, 100)).isGreaterThan(0); + }); + + test('favor channel with traffic defined', () { + check(compareAB(100, null)).isLessThan(0); + check(compareAB(0, null)).isLessThan(0); + check(compareAB(null, 100)).isGreaterThan(0); + check(compareAB(null, 0)).isGreaterThan(0); + }); + + test('both channels are the same, favor none', () { + check(compareAB(100, 100)).equals(0); + check(compareAB(null, null)).equals(0); + }); + }); + + group('ranking across signals', () { + store = eg.store(); + + void checkPrecedes(Narrow narrow, ZulipStream a, Iterable bs) { + final view = ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: ChannelLinkAutocompleteQuery('')); + for (final b in bs) { + check(view.debugCompareChannels(a, b)).isLessThan(0); + check(view.debugCompareChannels(b, a)).isGreaterThan(0); + } + view.dispose(); + } + + void checkRankEqual(Narrow narrow, List channels) { + final view = ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: ChannelLinkAutocompleteQuery('')); + for (int i = 0; i < channels.length; i++) { + for (int j = i + 1; j < channels.length; j++) { + check(view.debugCompareChannels(channels[i], channels[j])).equals(0); + check(view.debugCompareChannels(channels[j], channels[i])).equals(0); + } + } + view.dispose(); + } + + final channels = [ + // Wins by being the composing-to channel. + eg.stream(streamId: 1, name: 'Z'), + + // Next four are runners-up by being subscribed to. + // Runner-up by being unmuted pinned. + eg.subscription(eg.stream(streamId: 2, name: 'Y'), isMuted: false, pinToTop: true), + // Runner-up by being unmuted unpinned. + eg.subscription(eg.stream(streamId: 3, name: 'X'), isMuted: false, pinToTop: false), + // Runner-up by being muted pinned. + eg.subscription(eg.stream(streamId: 4, name: 'W'), isMuted: true, pinToTop: true), + // Runner-up by being muted unpinned. + eg.subscription(eg.stream(streamId: 5, name: 'V'), isMuted: true, pinToTop: false), + + // The rest are runners-up by not being subscribed to. + // Runner-up by being recently active. + eg.stream(streamId: 6, name: 'U', isRecentlyActive: true), + // Runner-up by having more weekly traffic. + eg.stream(streamId: 7, name: 'T', isRecentlyActive: false, streamWeeklyTraffic: 100), + // Runner-up by having weekly traffic defined. + eg.stream(streamId: 8, name: 'S', isRecentlyActive: false, streamWeeklyTraffic: 0), + // Runner-up by name. + eg.stream(streamId: 9, name: 'A', isRecentlyActive: false), + // Next two are tied because no remaining criteria. + eg.stream(streamId: 10, name: 'B', isRecentlyActive: false), + eg.stream(streamId: 11, name: 'b', isRecentlyActive: false), + ]; + for (final narrow in [ + eg.topicNarrow(1, 'this'), + ChannelNarrow(1), + ]) { + test('${narrow.runtimeType}: composing-to channel > subscribed (unmuted > pinned) > recently active > weekly traffic > name', () { + checkPrecedes(narrow, channels[0], channels.skip(1)); + checkPrecedes(narrow, channels[1], channels.skip(2)); + checkPrecedes(narrow, channels[2], channels.skip(3)); + checkPrecedes(narrow, channels[3], channels.skip(4)); + checkPrecedes(narrow, channels[4], channels.skip(5)); + checkPrecedes(narrow, channels[5], channels.skip(6)); + checkPrecedes(narrow, channels[6], channels.skip(7)); + checkPrecedes(narrow, channels[7], channels.skip(8)); + checkPrecedes(narrow, channels[8], channels.skip(9)); + checkRankEqual(narrow, [channels[9], channels[10]]); + }); + } + + test('DmNarrow: subscribed (unmuted > pinned) > recently active > weekly traffic > name', () { + final channels = [ + // Next four are runners-up by being subscribed to. + // Runner-up by being unmuted pinned. + eg.subscription(eg.stream(streamId: 1, name: 'Y'), isMuted: false, pinToTop: true), + // Runner-up by being unmuted unpinned. + eg.subscription(eg.stream(streamId: 2, name: 'X'), isMuted: false, pinToTop: false), + // Runner-up by being muted pinned. + eg.subscription(eg.stream(streamId: 3, name: 'W'), isMuted: true, pinToTop: true), + // Runner-up by being muted unpinned. + eg.subscription(eg.stream(streamId: 4, name: 'V'), isMuted: true, pinToTop: false), + + // The rest are runners-up by not being subscribed to. + // Runner-up by being recently active. + eg.stream(streamId: 5, name: 'U', isRecentlyActive: true), + // Runner-up by having more weekly traffic. + eg.stream(streamId: 6, name: 'T', isRecentlyActive: false, streamWeeklyTraffic: 100), + // Runner-up by having weekly traffic defined. + eg.stream(streamId: 7, name: 'S', isRecentlyActive: false, streamWeeklyTraffic: 0), + // Runner-up by name. + eg.stream(streamId: 8, name: 'A', isRecentlyActive: false), + // Next two are tied because no remaining criteria. + eg.stream(streamId: 9, name: 'B', isRecentlyActive: false), + eg.stream(streamId: 10, name: 'b', isRecentlyActive: false), + ]; + final narrow = DmNarrow.withUser(1, selfUserId: 10); + checkPrecedes(narrow, channels[0], channels.skip(1)); + checkPrecedes(narrow, channels[1], channels.skip(2)); + checkPrecedes(narrow, channels[2], channels.skip(3)); + checkPrecedes(narrow, channels[3], channels.skip(4)); + checkPrecedes(narrow, channels[4], channels.skip(5)); + checkPrecedes(narrow, channels[5], channels.skip(6)); + checkPrecedes(narrow, channels[6], channels.skip(7)); + checkPrecedes(narrow, channels[7], channels.skip(8)); + checkRankEqual(narrow, [channels[8], channels[9]]); + }); + + test('CombinedFeedNarrow gives error', () async { + const narrow = CombinedFeedNarrow(); + check(() => ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: ChannelLinkAutocompleteQuery(''))) + .throws(); + }); + + test('MentionsNarrow gives error', () async { + const narrow = MentionsNarrow(); + check(() => ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: ChannelLinkAutocompleteQuery(''))) + .throws(); + }); + + test('StarredMessagesNarrow gives error', () async { + const narrow = StarredMessagesNarrow(); + check(() => ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: ChannelLinkAutocompleteQuery(''))) + .throws(); + }); + + test('KeywordSearchNarrow gives error', () async { + final narrow = KeywordSearchNarrow(''); + check(() => ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: ChannelLinkAutocompleteQuery(''))) + .throws(); + }); + }); + + test('final results end-to-end', () async { + Future> getResults( + Narrow narrow, ChannelLinkAutocompleteQuery query) async { + bool done = false; + final view = ChannelLinkAutocompleteView.init(store: store, + narrow: narrow, query: query); + view.addListener(() { done = true; }); + await Future(() {}); + check(done).isTrue(); + final results = view.results; + view.dispose(); + return results; + } + + final channels = [ + eg.stream(streamId: 1, name: 'Channel One', isRecentlyActive: false, + streamWeeklyTraffic: 0), + eg.stream(streamId: 2, name: 'Channel Two', isRecentlyActive: true), + eg.stream(streamId: 3, name: 'Channel Three', isRecentlyActive: false, + streamWeeklyTraffic: 100), + eg.stream(streamId: 4, name: 'Channel Four', isRecentlyActive: false), + eg.stream(streamId: 5, name: 'Channel Five', isRecentlyActive: false), + eg.stream(streamId: 6, name: 'Channel Six'), + eg.stream(streamId: 7, name: 'Channel Seven'), + eg.stream(streamId: 8, name: 'Channel Eight'), + eg.stream(streamId: 9, name: 'Channel Nine'), + eg.stream(streamId: 10, name: 'Channel Ten'), + ]; + store = eg.store(initialSnapshot: eg.initialSnapshot( + streams: channels, + subscriptions: [ + eg.subscription(channels[6 - 1], isMuted: false, pinToTop: true), + eg.subscription(channels[7 - 1], isMuted: false, pinToTop: false), + eg.subscription(channels[8 - 1], isMuted: true, pinToTop: true), + eg.subscription(channels[9 - 1], isMuted: true, pinToTop: false), + ], + )); + + final narrow = eg.topicNarrow(10, 'this'); + + // The order should be: + // 1. composing-to channel + // 2. subscribed channels + // 1. unmuted pinned + // 2. unmuted unpinned + // 3. muted pinned + // 4. muted unpinned + // 3. recently-active channels + // 4. channels with more traffic + // 5. channels by name alphabetical order + + // Check the ranking of the full list of options, + // i.e. the results for an empty query. + check(await getResults(narrow, ChannelLinkAutocompleteQuery(''))) + .deepEquals([10, 6, 7, 8, 9, 2, 3, 1, 5, 4].map(isChannel)); + + // Check the ranking applies also to results filtered by a query. + check(await getResults(narrow, ChannelLinkAutocompleteQuery('t'))) + .deepEquals([10, 2, 3].map(isChannel)); + check(await getResults(narrow, ChannelLinkAutocompleteQuery('F'))) + .deepEquals([5, 4].map(isChannel)); + }); + }); + }); + + group('ChannelLinkAutocompleteQuery', () { + test('testChannel: channel is included if name words match the query', () { + void doCheck(String rawQuery, ZulipStream channel, bool expected) { + final result = ChannelLinkAutocompleteQuery(rawQuery).testChannel(channel, store); + expected + ? check(result).isA() + : check(result).isNull(); + } + + store = eg.store(); + + doCheck('', eg.stream(name: 'Channel Name'), true); + doCheck('', eg.stream(name: ''), true); // unlikely case, but should not crash + doCheck('Channel Name', eg.stream(name: 'Channel Name'), true); + doCheck('channel name', eg.stream(name: 'Channel Name'), true); + doCheck('Channel Name', eg.stream(name: 'channel name'), true); + doCheck('Channel', eg.stream(name: 'Channel Name'), true); + doCheck('Name', eg.stream(name: 'Channel Name'), true); + doCheck('Channel Name', eg.stream(name: 'Channels Names'), true); + doCheck('Channel Four', eg.stream(name: 'Channel Name Four Words'), true); + doCheck('Name Words', eg.stream(name: 'Channel Name Four Words'), true); + doCheck('Channel F', eg.stream(name: 'Channel Name Four Words'), true); + doCheck('C Four', eg.stream(name: 'Channel Name Four Words'), true); + doCheck('channel channel', eg.stream(name: 'Channel Channel Name'), true); + doCheck('channel channel', eg.stream(name: 'Channel Name Channel'), true); + + doCheck('C', eg.stream(name: ''), false); // unlikely case, but should not crash + doCheck('Channels Names', eg.stream(name: 'Channel Name'), false); + doCheck('Channel Name', eg.stream(name: 'Channel'), false); + doCheck('Channel Name', eg.stream(name: 'Name'), false); + doCheck('nnel ame', eg.stream(name: 'Channel Name'), false); + doCheck('nnel Name', eg.stream(name: 'Channel Name'), false); + doCheck('Channel ame', eg.stream(name: 'Channel Name'), false); + doCheck('Channel Channel', eg.stream(name: 'Channel Name'), false); + doCheck('Name Name', eg.stream(name: 'Channel Name'), false); + doCheck('Name Channel', eg.stream(name: 'Channel Name'), false); + doCheck('Name Four Channel Words', eg.stream(name: 'Channel Name Four Words'), false); + doCheck('F Channel', eg.stream(name: 'Channel Name Four Words'), false); + doCheck('Four C', eg.stream(name: 'Channel Name Four Words'), false); + }); + + group('ranking', () { + store = eg.store(); + + int rankOf(String query, ZulipStream channel) { + // (i.e. throw here if it's not a match) + return ChannelLinkAutocompleteQuery(query) + .testChannel(channel, store)!.rank; + } + + void checkPrecedes(String query, ZulipStream a, ZulipStream b) { + check(rankOf(query, a)).isLessThan(rankOf(query, b)); + } + + void checkAllSameRank(String query, Iterable channels) { + final firstRank = rankOf(query, channels.first); + final remainingRanks = channels.skip(1).map((e) => rankOf(query, e)); + check(remainingRanks).every((it) => it.equals(firstRank)); + } + + test('channel name is case- and diacritics-insensitive', () { + final channels = [ + eg.stream(name: 'Über Cars'), + eg.stream(name: 'ΓΌber cars'), + eg.stream(name: 'Uber Cars'), + eg.stream(name: 'uber cars'), + ]; + + checkAllSameRank('Über Cars', channels); // exact + checkAllSameRank('ΓΌber cars', channels); // exact + checkAllSameRank('Uber Cars', channels); // exact + checkAllSameRank('uber cars', channels); // exact + + checkAllSameRank('Über Ca', channels); // total-prefix + checkAllSameRank('ΓΌber ca', channels); // total-prefix + checkAllSameRank('Uber Ca', channels); // total-prefix + checkAllSameRank('uber ca', channels); // total-prefix + + checkAllSameRank('Üb Ca', channels); // word-prefixes + checkAllSameRank('ΓΌb ca', channels); // word-prefixes + checkAllSameRank('Ub Ca', channels); // word-prefixes + checkAllSameRank('ub ca', channels); // word-prefixes + }); + + test('channel name match: exact over total-prefix', () { + final channel1 = eg.stream(name: 'Resume'); + final channel2 = eg.stream(name: 'Resume Tips'); + checkPrecedes('resume', channel1, channel2); + }); + + test('channel name match: total-prefix over word-prefixes', () { + final channel1 = eg.stream(name: 'So Many Ideas'); + final channel2 = eg.stream(name: 'Some Media Channel'); + checkPrecedes('so m', channel1, channel2); + }); + }); + }); } typedef WildcardTester = void Function(String query, Narrow narrow, List expected); diff --git a/test/model/compose_test.dart b/test/model/compose_test.dart index 7d6e81db33..43009e12f0 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,80 @@ 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(); + }); + + 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/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..c6910473dd 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(); @@ -1650,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)) @@ -1663,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/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 982c6ee3f0..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'; @@ -32,7 +33,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 +42,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 +54,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( @@ -353,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; 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);