diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index e095c5360b..6a0d9ffa4d 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -616,15 +616,38 @@ class ChannelDeleteEvent extends ChannelEvent { @JsonKey(includeToJson: true) String get op => 'delete'; - final List streams; + @JsonKey(readValue: _readChannelIds, includeToJson: false) + 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) 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); @override - Map toJson() => _$ChannelDeleteEventToJson(this); + Map toJson() { + // TODO(server-10) simplify away; rely on stream_ids + final result = _$ChannelDeleteEventToJson(this); + result['stream_ids'] = channelIds; + result['streams'] = [ + for (final id in channelIds) {'stream_id': id} + ]; + return result; + } } /// A [ChannelEvent] with op `update`: https://zulip.com/api/get-events#stream-update @@ -683,6 +706,8 @@ class ChannelUpdateEvent extends ChannelEvent { return value as int?; case ChannelPropertyName.channelPostPolicy: return ChannelPostPolicy.fromApiValue(value as int); + case ChannelPropertyName.isRecentlyActive: + return value as bool?; case ChannelPropertyName.folderId: return value as int?; case ChannelPropertyName.canAddSubscribersGroup: diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index dff0d21fa2..9204141f3b 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, 'channel_ids') + as List) + .map((e) => (e as num).toInt()) + .toList(), ); Map _$ChannelDeleteEventToJson(ChannelDeleteEvent instance) => @@ -420,7 +422,6 @@ Map _$ChannelDeleteEventToJson(ChannelDeleteEvent instance) => 'id': instance.id, 'type': instance.type, 'op': instance.op, - 'streams': instance.streams, }; ChannelUpdateEvent _$ChannelUpdateEventFromJson(Map json) => @@ -468,6 +469,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 24ca2a8919..c1a5fa84f7 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -659,6 +659,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; @@ -681,6 +682,7 @@ class ZulipStream { required this.canDeleteOwnMessageGroup, required this.canSendMessageGroup, required this.canSubscribeGroup, + required this.isRecentlyActive, required this.streamWeeklyTraffic, }); @@ -705,6 +707,7 @@ class ZulipStream { canDeleteOwnMessageGroup: subscription.canDeleteOwnMessageGroup, canSendMessageGroup: subscription.canSendMessageGroup, canSubscribeGroup: subscription.canSubscribeGroup, + isRecentlyActive: subscription.isRecentlyActive, streamWeeklyTraffic: subscription.streamWeeklyTraffic, ); } @@ -742,6 +745,7 @@ enum ChannelPropertyName { canDeleteOwnMessageGroup, canSendMessageGroup, canSubscribeGroup, + isRecentlyActive, streamWeeklyTraffic; /// Get a [ChannelPropertyName] from a raw, snake-case string we recognize, else null. @@ -827,6 +831,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..bca2124cb7 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'; @@ -46,6 +48,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 +69,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; } @@ -163,6 +172,35 @@ final RegExp _emojiIntentRegex = (() { + r')$'); })(); +final RegExp _channelLinkIntentRegex = () { + // Similar reasoning as in _mentionIntentRegex. + const before = r'(?<=^|\s|\p{Punctuation})'; + + // TODO(upstream): maybe use duplicate-named capture groups for better readability? + // https://github.com/dart-lang/sdk/issues/61337 + return RegExp(unicode: true, + before + + r'#' + // As Web, match both '#channel' and '#**channel'. In both cases, the raw + // query is going to be 'channel'. Matching the second case ('#**channel') + // is useful when the user selects a channel from the autocomplete list, but + // then starts pressing "backspace" to edit the query and choose another + // option, instead of clearing the entire query and starting from scratch. + + // Also, web doesn't seem to have any sort of limitations for the type of + // characters the channel name can contain. + + r'(?:' + // Case '#channel': right after '#', reject whitespace as well as '**'. + + r'(?!\s|\*\*)(.*)' + + r'|' + // Case '#**channel': right after '#**', reject whitespace. + // Also, make sure that the remaining query doesn't contain '**', + // otherwise '#**channel**' (which is a complete channel link syntax) and + // any text followed by that will always match. + + r'\*\*(?!\s)((?:(?!\*\*).)*)' + + r')$'); +}(); + /// The text controller's recognition that the user might want autocomplete UI. class AutocompleteIntent { AutocompleteIntent({ @@ -209,6 +247,7 @@ class AutocompleteViewManager { final Set _mentionAutocompleteViews = {}; final Set _topicAutocompleteViews = {}; final Set _emojiAutocompleteViews = {}; + final Set _channelLinkAutocompleteViews = {}; AutocompleteDataCache autocompleteDataCache = AutocompleteDataCache(); @@ -242,6 +281,16 @@ class AutocompleteViewManager { assert(removed); } + void registerChannelLinkAutocomplete(ChannelLinkAutocompleteView view) { + final added = _channelLinkAutocompleteViews.add(view); + assert(added); + } + + void unregisterChannelLinkAutocomplete(ChannelLinkAutocompleteView view) { + final removed = _channelLinkAutocompleteViews.remove(view); + assert(removed); + } + void handleRealmUserRemoveEvent(RealmUserRemoveEvent event) { autocompleteDataCache.invalidateUser(event.userId); } @@ -258,6 +307,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. @@ -269,6 +328,12 @@ class AutocompleteViewManager { for (final view in _topicAutocompleteViews) { view.reassemble(); } + for (final view in _emojiAutocompleteViews) { + view.reassemble(); + } + for (final view in _channelLinkAutocompleteViews) { + view.reassemble(); + } } // No `dispose` method, because there's nothing for it to do. @@ -762,6 +827,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. /// @@ -787,8 +871,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 +949,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 +1091,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 +1116,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. @@ -1243,3 +1328,267 @@ 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, + }) { + final view = ChannelLinkAutocompleteView._( + store: store, + query: query, + narrow: narrow, + sortedChannels: _channelsByRelevance(store: store, narrow: narrow), + ); + store.autocompleteViewManager.registerChannelLinkAutocomplete(view); + return view; + } + + 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, + }) { + // The order of each comparator element in the list is important; + // the first one having the highest priority and the last one having + // the least priority. + final comparators = [ + if (composingToChannelId != null) + () => compareByComposingTo(a, b, composingToChannelId: composingToChannelId), + () => compareByBeingSubscribed(a, b), + () => compareByRecentActivity(a, b), + () => compareByWeeklyTraffic(a, b), + () => ChannelStore.compareChannelsByName(a, b), + ]; + return comparators.map((compare) => compare()) + .firstWhere((result) => result != 0, orElse: () => 0); + } + + /// 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); + } + + @override + void dispose() { + store.autocompleteViewManager.unregisterChannelLinkAutocomplete(this); + super.dispose(); + } +} + +/// 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..7f6cbb2aa6 100644 --- a/lib/model/compose.dart +++ b/lib/model/compose.dart @@ -185,6 +185,69 @@ 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 channelLinkSyntax(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/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, diff --git a/lib/model/store.dart b/lib/model/store.dart index 88ca778100..e804c76b11 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(): @@ -822,6 +827,11 @@ class PerAccountStore extends PerAccountStoreBase with case ChannelEvent(): assert(debugLog("server event: stream/${event.op}")); _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 59b70a11d6..2090998f35 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,52 @@ 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, + color: designVariables.contextMenuItemLabel, + ).merge(weightVariableTextStyle(context, wght: 600))); + + return Padding( + padding: EdgeInsetsDirectional.fromSTEB(12, 4, 10, 4), + child: Row(spacing: 10, children: [ + SizedBox.square(dimension: 24, + child: Icon(size: 18, color: iconColor, icon)), + // TODO(#1945): show channel description + Expanded(child: labelWidget), + ])); + } +} + class _EmojiAutocompleteItem extends StatelessWidget { const _EmojiAutocompleteItem({required this.option}); diff --git a/test/example_data.dart b/test/example_data.dart index e894882225..3ab9e6370a 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, @@ -1263,6 +1266,8 @@ ChannelUpdateEvent channelUpdateEvent( assert(value is int?); case ChannelPropertyName.channelPostPolicy: assert(value is ChannelPostPolicy); + case ChannelPropertyName.isRecentlyActive: + assert(value is bool?); case ChannelPropertyName.folderId: assert(value is int?); case ChannelPropertyName.canAddSubscribersGroup: diff --git a/test/model/autocomplete_checks.dart b/test/model/autocomplete_checks.dart index cb08378e77..0c794c494a 100644 --- a/test/model/autocomplete_checks.dart +++ b/test/model/autocomplete_checks.dart @@ -32,3 +32,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..75f8866b6e 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(); @@ -97,6 +98,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); @@ -181,6 +183,83 @@ void main() { 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 + // #channel link. + + doTest('^#', null); + doTest('^#abc', null); + doTest('#abc', null); // (no cursor) + + 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')); + // … and other punctuations. + + // Avoid other characters before channel link syntax. + doTest('\$#abc^', null); + doTest('+#abc^', null); + doTest('=#abc^', 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 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')); + // Emoji (":smile:"). // Basic positive examples, to contrast with all the negative examples below. @@ -1307,6 +1386,486 @@ 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 remainings 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 remainings 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..9e010cc8d0 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 syntax', () { + 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(channelLinkSyntax(channels[0], store: store)).equals('#**mobile**'); + check(channelLinkSyntax(channels[1], store: store)).equals('#**dev-ops**'); + check(channelLinkSyntax(channels[2], store: store)).equals('#**ui/ux**'); + check(channelLinkSyntax(channels[3], store: store)).equals('#**api_v3**'); + check(channelLinkSyntax(channels[4], store: store)).equals('#**build+test**'); + check(channelLinkSyntax(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(channelLinkSyntax(channels[1 - 1], store: store)).equals('[`code`](#narrow/channel/1-.60code.60)'); + check(channelLinkSyntax(channels[2 - 1], store: store)).equals('[score > 90](#narrow/channel/2-score-.3E-90)'); + check(channelLinkSyntax(channels[3 - 1], store: store)).equals('[A*](#narrow/channel/3-A*)'); + check(channelLinkSyntax(channels[4 - 1], store: store)).equals('[R&D](#narrow/channel/4-R.26D)'); + check(channelLinkSyntax(channels[5 - 1], store: store)).equals('[UI [v2]](#narrow/channel/5-UI-.5Bv2.5D)'); + check(channelLinkSyntax(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/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 621f759f28..9d59a673f1 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -340,7 +340,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(); diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index ddcfc26036..f06f053c5d 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/message_list.dart'; import 'package:zulip/widgets/user.dart'; @@ -31,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. @@ -40,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?); @@ -51,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( @@ -352,6 +355,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(channelLinkSyntax(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 one of these edits 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;