11import 'dart:math' ;
22
3+ import 'package:collection/collection.dart' ;
34import 'package:flutter/foundation.dart' ;
45import 'package:flutter/services.dart' ;
56import 'package:unorm_dart/unorm_dart.dart' as unorm;
@@ -10,6 +11,7 @@ import '../api/route/channels.dart';
1011import '../generated/l10n/zulip_localizations.dart' ;
1112import '../widgets/compose_box.dart' ;
1213import 'algorithms.dart' ;
14+ import 'channel.dart' ;
1315import 'compose.dart' ;
1416import 'emoji.dart' ;
1517import 'narrow.dart' ;
@@ -236,6 +238,16 @@ class AutocompleteViewManager {
236238 autocompleteDataCache.invalidateUserGroup (event.groupId);
237239 }
238240
241+ void handleChannelDeleteEvent (ChannelDeleteEvent event) {
242+ for (final channelId in event.channelIds) {
243+ autocompleteDataCache.invalidateChannel (channelId);
244+ }
245+ }
246+
247+ void handleChannelUpdateEvent (ChannelUpdateEvent event) {
248+ autocompleteDataCache.invalidateChannel (event.streamId);
249+ }
250+
239251 /// Called when the app is reassembled during debugging, e.g. for hot reload.
240252 ///
241253 /// Calls [AutocompleteView.reassemble] for all that are registered.
@@ -1000,6 +1012,21 @@ class AutocompleteDataCache {
10001012 ?? = normalizedNameForUserGroup (userGroup).split (' ' );
10011013 }
10021014
1015+ final Map <int , String > _normalizedNamesByChannel = {};
1016+
1017+ /// The normalized `name` of [channel] .
1018+ String normalizedNameForChannel (ZulipStream channel) {
1019+ return _normalizedNamesByChannel[channel.streamId]
1020+ ?? = AutocompleteQuery .lowercaseAndStripDiacritics (channel.name);
1021+ }
1022+
1023+ final Map <int , List <String >> _normalizedNameWordsByChannel = {};
1024+
1025+ List <String > normalizedNameWordsForChannel (ZulipStream channel) {
1026+ return _normalizedNameWordsByChannel[channel.streamId]
1027+ ?? normalizedNameForChannel (channel).split (' ' );
1028+ }
1029+
10031030 void invalidateUser (int userId) {
10041031 _normalizedNamesByUser.remove (userId);
10051032 _normalizedNameWordsByUser.remove (userId);
@@ -1010,6 +1037,11 @@ class AutocompleteDataCache {
10101037 _normalizedNamesByUserGroup.remove (id);
10111038 _normalizedNameWordsByUserGroup.remove (id);
10121039 }
1040+
1041+ void invalidateChannel (int channelId) {
1042+ _normalizedNamesByChannel.remove (channelId);
1043+ _normalizedNameWordsByChannel.remove (channelId);
1044+ }
10131045}
10141046
10151047/// A result the user chose, or might choose, from an autocomplete interaction.
@@ -1208,3 +1240,262 @@ class TopicAutocompleteResult extends AutocompleteResult {
12081240
12091241 TopicAutocompleteResult ({required this .topic});
12101242}
1243+
1244+ /// An [AutocompleteView] for a #channel autocomplete interaction,
1245+ /// an example of a [ComposeAutocompleteView] .
1246+ class ChannelLinkAutocompleteView extends AutocompleteView <ChannelLinkAutocompleteQuery , ChannelLinkAutocompleteResult > {
1247+ ChannelLinkAutocompleteView ._({
1248+ required super .store,
1249+ required super .query,
1250+ required this .narrow,
1251+ required this .sortedChannels,
1252+ });
1253+
1254+ factory ChannelLinkAutocompleteView .init ({
1255+ required PerAccountStore store,
1256+ required Narrow narrow,
1257+ required ChannelLinkAutocompleteQuery query,
1258+ }) {
1259+ return ChannelLinkAutocompleteView ._(
1260+ store: store,
1261+ query: query,
1262+ narrow: narrow,
1263+ sortedChannels: _channelsByRelevance (store: store, narrow: narrow),
1264+ );
1265+ }
1266+
1267+ final Narrow narrow;
1268+ final List <ZulipStream > sortedChannels;
1269+
1270+ static List <ZulipStream > _channelsByRelevance ({
1271+ required PerAccountStore store,
1272+ required Narrow narrow,
1273+ }) {
1274+ return store.streams.values.sorted (_comparator (narrow: narrow));
1275+ }
1276+
1277+ /// Compare the channels the same way they would be sorted as
1278+ /// autocomplete candidates, given [query] .
1279+ ///
1280+ /// The channels must both match the query.
1281+ ///
1282+ /// This behaves the same as the comparator used for sorting in
1283+ /// [_channelsByRelevance] , combined with the ranking applied at the end
1284+ /// of [computeResults] .
1285+ ///
1286+ /// This is useful for tests in order to distinguish "A comes before B"
1287+ /// from "A ranks equal to B, and the sort happened to put A before B",
1288+ /// particularly because [List.sort] makes no guarantees about the order
1289+ /// of items that compare equal.
1290+ int debugCompareChannels (ZulipStream a, ZulipStream b) {
1291+ final rankA = query.testChannel (a, store)! .rank;
1292+ final rankB = query.testChannel (b, store)! .rank;
1293+ if (rankA != rankB) return rankA.compareTo (rankB);
1294+
1295+ return _comparator (narrow: narrow)(a, b);
1296+ }
1297+
1298+ static Comparator <ZulipStream > _comparator ({required Narrow narrow}) {
1299+ // See also [ChannelLinkAutocompleteQuery._rankResult];
1300+ // that ranking takes precedence over this.
1301+
1302+ final channelId = switch (narrow) {
1303+ ChannelNarrow (: var streamId) || TopicNarrow (: var streamId) => streamId,
1304+ DmNarrow () => null ,
1305+ CombinedFeedNarrow ()
1306+ || MentionsNarrow ()
1307+ || StarredMessagesNarrow ()
1308+ || KeywordSearchNarrow () => () {
1309+ assert (false , 'No compose box, thus no autocomplete is available in ${narrow .runtimeType }.' );
1310+ return null ;
1311+ }(),
1312+ };
1313+ return (a, b) => _compareByRelevance (a, b, composingToChannelId: channelId);
1314+ }
1315+
1316+ // Check `typeahead_helper.compare_by_activity` in Zulip web;
1317+ // We follow the behavior of Web but with a small difference in that Web
1318+ // compares "recent activity" only for subscribed channels, but we do it
1319+ // for unsubscribed ones too.
1320+ // https://github.com/zulip/zulip/blob/c3fdee6ed/web/src/typeahead_helper.ts#L972-L988
1321+ static int _compareByRelevance (ZulipStream a, ZulipStream b, {
1322+ required int ? composingToChannelId,
1323+ }) {
1324+ if (composingToChannelId != null ) {
1325+ final composingToResult = compareByComposingTo (a, b,
1326+ composingToChannelId: composingToChannelId);
1327+ if (composingToResult != 0 ) return composingToResult;
1328+ }
1329+
1330+ final beingSubscribedResult = compareByBeingSubscribed (a, b);
1331+ if (beingSubscribedResult != 0 ) return beingSubscribedResult;
1332+
1333+ final recentActivityResult = compareByRecentActivity (a, b);
1334+ if (recentActivityResult != 0 ) return recentActivityResult;
1335+
1336+ final weeklyTrafficResult = compareByWeeklyTraffic (a, b);
1337+ if (weeklyTrafficResult != 0 ) return weeklyTrafficResult;
1338+
1339+ return ChannelStore .compareChannelsByName (a, b);
1340+ }
1341+
1342+ /// Comparator that puts the channel being composed to, before other ones.
1343+ @visibleForTesting
1344+ static int compareByComposingTo (ZulipStream a, ZulipStream b, {
1345+ required int composingToChannelId,
1346+ }) {
1347+ return switch ((a.streamId, b.streamId)) {
1348+ (int id, _) when id == composingToChannelId => - 1 ,
1349+ (_, int id) when id == composingToChannelId => 1 ,
1350+ _ => 0 ,
1351+ };
1352+ }
1353+
1354+ /// Comparator that puts subscribed channels before unsubscribed ones.
1355+ ///
1356+ /// For subscribed channels, it puts them in the following order:
1357+ /// pinned unmuted > unpinned unmuted > pinned muted > unpinned muted
1358+ @visibleForTesting
1359+ static int compareByBeingSubscribed (ZulipStream a, ZulipStream b) {
1360+ if (a is Subscription && b is ! Subscription ) return - 1 ;
1361+ if (a is ! Subscription && b is Subscription ) return 1 ;
1362+
1363+ return switch ((a, b)) {
1364+ (Subscription (isMuted: false ), Subscription (isMuted: true )) => - 1 ,
1365+ (Subscription (isMuted: true ), Subscription (isMuted: false )) => 1 ,
1366+ (Subscription (pinToTop: true ), Subscription (pinToTop: false )) => - 1 ,
1367+ (Subscription (pinToTop: false ), Subscription (pinToTop: true )) => 1 ,
1368+ _ => 0 ,
1369+ };
1370+ }
1371+
1372+ /// Comparator that puts recently-active channels before inactive ones.
1373+ ///
1374+ /// Being recently-active is determined by [ZulipStream.isRecentlyActive] .
1375+ @visibleForTesting
1376+ static int compareByRecentActivity (ZulipStream a, ZulipStream b) {
1377+ return switch ((a.isRecentlyActive, b.isRecentlyActive)) {
1378+ (true , false ) => - 1 ,
1379+ (false , true ) => 1 ,
1380+ // The combination of `null` and `bool` is not possible as they're both
1381+ // either `null` or `bool`, before or after server-10, respectively.
1382+ // TODO(server-10): remove the preceding comment
1383+ _ => 0 ,
1384+ };
1385+ }
1386+
1387+ /// Comparator that puts channels with more [ZulipStream.streamWeeklyTraffic] first.
1388+ ///
1389+ /// A channel with undefined weekly traffic (`null` ) is put after the channel
1390+ /// with a weekly traffic defined (even if it is zero).
1391+ @visibleForTesting
1392+ static int compareByWeeklyTraffic (ZulipStream a, ZulipStream b) {
1393+ return switch ((a.streamWeeklyTraffic, b.streamWeeklyTraffic)) {
1394+ (int a, int b) => - a.compareTo (b),
1395+ (int (), null ) => - 1 ,
1396+ (null , int ()) => 1 ,
1397+ _ => 0 ,
1398+ };
1399+ }
1400+
1401+ @override
1402+ Future <List <ChannelLinkAutocompleteResult >?> computeResults () async {
1403+ final unsorted = < ChannelLinkAutocompleteResult > [];
1404+ if (await filterCandidates (filter: _testChannel,
1405+ candidates: sortedChannels, results: unsorted)) {
1406+ return null ;
1407+ }
1408+
1409+ return bucketSort (unsorted,
1410+ (r) => r.rank, numBuckets: ChannelLinkAutocompleteQuery ._numResultRanks);
1411+ }
1412+
1413+ ChannelLinkAutocompleteResult ? _testChannel (ChannelLinkAutocompleteQuery query, ZulipStream channel) {
1414+ return query.testChannel (channel, store);
1415+ }
1416+ }
1417+
1418+ /// A #channel autocomplete query, used by [ChannelLinkAutocompleteView] .
1419+ class ChannelLinkAutocompleteQuery extends ComposeAutocompleteQuery {
1420+ ChannelLinkAutocompleteQuery (super .raw);
1421+
1422+ @override
1423+ ChannelLinkAutocompleteView initViewModel ({
1424+ required PerAccountStore store,
1425+ required ZulipLocalizations localizations,
1426+ required Narrow narrow,
1427+ }) {
1428+ return ChannelLinkAutocompleteView .init (store: store, query: this , narrow: narrow);
1429+ }
1430+
1431+ ChannelLinkAutocompleteResult ? testChannel (ZulipStream channel, PerAccountStore store) {
1432+ final cache = store.autocompleteViewManager.autocompleteDataCache;
1433+ final matchQuality = _matchName (
1434+ normalizedName: cache.normalizedNameForChannel (channel),
1435+ normalizedNameWords: cache.normalizedNameWordsForChannel (channel));
1436+ if (matchQuality == null ) return null ;
1437+ return ChannelLinkAutocompleteResult (
1438+ channelId: channel.streamId, rank: _rankResult (matchQuality));
1439+ }
1440+
1441+ /// A measure of a channel result's quality in the context of the query,
1442+ /// from 0 (best) to one less than [_numResultRanks] .
1443+ static int _rankResult (NameMatchQuality matchQuality) {
1444+ return switch (matchQuality) {
1445+ NameMatchQuality .exact => 0 ,
1446+ NameMatchQuality .totalPrefix => 1 ,
1447+ NameMatchQuality .wordPrefixes => 2 ,
1448+ };
1449+ }
1450+
1451+ /// The number of possible values returned by [_rankResult] .
1452+ static const _numResultRanks = 3 ;
1453+
1454+ @override
1455+ String toString () {
1456+ return '${objectRuntimeType (this , 'ChannelLinkAutocompleteQuery' )}($raw )' ;
1457+ }
1458+
1459+ @override
1460+ bool operator == (Object other) {
1461+ return other is ChannelLinkAutocompleteQuery && other.raw == raw;
1462+ }
1463+
1464+ @override
1465+ int get hashCode => Object .hash ('ChannelLinkAutocompleteQuery' , raw);
1466+ }
1467+
1468+ /// An autocomplete result for a #channel.
1469+ class ChannelLinkAutocompleteResult extends ComposeAutocompleteResult {
1470+ ChannelLinkAutocompleteResult ({required this .channelId, required this .rank});
1471+
1472+ final int channelId;
1473+
1474+ /// A measure of the result's quality in the context of the query.
1475+ ///
1476+ /// Used internally by [ChannelLinkAutocompleteView] for ranking the results.
1477+ // See also [ChannelLinkAutocompleteView._channelsByRelevance];
1478+ // results with equal [rank] will appear in the order they were put in
1479+ // by that method.
1480+ //
1481+ // Compare sort_streams in Zulip web:
1482+ // https://github.com/zulip/zulip/blob/a5d25826b/web/src/typeahead_helper.ts#L998-L1008
1483+ //
1484+ // Behavior we have that web doesn't and might like to follow:
1485+ // - A "word-prefixes" match quality on channel names:
1486+ // see [NameMatchQuality.wordPrefixes], which we rank on.
1487+ //
1488+ // Behavior web has that seems undesired, which we don't plan to follow:
1489+ // - A "word-boundary" match quality on channel names:
1490+ // special rank when the whole query appears contiguously
1491+ // right after a word-boundary character.
1492+ // Our [NameMatchQuality.wordPrefixes] seems smarter.
1493+ // - Ranking some case-sensitive matches differently from case-insensitive
1494+ // matches. Users will expect a lowercase query to be adequate.
1495+ // - Matching and ranking on channel descriptions but only when the query
1496+ // is present (but not an exact match, total-prefix, or word-boundary match)
1497+ // in the channel name. This doesn't seem to be helpful in most cases,
1498+ // because it is hard for a query to be present in the name (the way
1499+ // mentioned before) and also present in the description.
1500+ final int rank;
1501+ }
0 commit comments