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,281 @@ 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+ int ? channelId;
1303+ switch (narrow) {
1304+ case ChannelNarrow (: var streamId):
1305+ case TopicNarrow (: var streamId):
1306+ channelId = streamId;
1307+ case DmNarrow ():
1308+ break ;
1309+ case CombinedFeedNarrow ():
1310+ case MentionsNarrow ():
1311+ case StarredMessagesNarrow ():
1312+ case KeywordSearchNarrow ():
1313+ assert (false , 'No compose box, thus no autocomplete is available in ${narrow .runtimeType }.' );
1314+ }
1315+ return (a, b) => _compareByRelevance (a, b, composingToChannelId: channelId);
1316+ }
1317+
1318+ static int _compareByRelevance (ZulipStream a, ZulipStream b, {
1319+ required int ? composingToChannelId,
1320+ }) {
1321+ // Compare `typeahead_helper.compare_by_activity` in Zulip web;
1322+ // https://github.com/zulip/zulip/blob/c3fdee6ed/web/src/typeahead_helper.ts#L972-L988
1323+ //
1324+ // Behavior difference that Web should probably fix, TODO(Web):
1325+ // * Web compares "recent activity" only for subscribed channels,
1326+ // but we do it for unsubscribed ones too.
1327+ // * We exclude archived channels from autocomplete results,
1328+ // but Web doesn't.
1329+ // See: [ChannelLinkAutocompleteQuery.testChannel]
1330+
1331+ if (composingToChannelId != null ) {
1332+ final composingToResult = compareByComposingTo (a, b,
1333+ composingToChannelId: composingToChannelId);
1334+ if (composingToResult != 0 ) return composingToResult;
1335+ }
1336+
1337+ final beingSubscribedResult = compareByBeingSubscribed (a, b);
1338+ if (beingSubscribedResult != 0 ) return beingSubscribedResult;
1339+
1340+ final recentActivityResult = compareByRecentActivity (a, b);
1341+ if (recentActivityResult != 0 ) return recentActivityResult;
1342+
1343+ final weeklyTrafficResult = compareByWeeklyTraffic (a, b);
1344+ if (weeklyTrafficResult != 0 ) return weeklyTrafficResult;
1345+
1346+ return ChannelStore .compareChannelsByName (a, b);
1347+ }
1348+
1349+ /// Comparator that puts the channel being composed to, before other ones.
1350+ @visibleForTesting
1351+ static int compareByComposingTo (ZulipStream a, ZulipStream b, {
1352+ required int composingToChannelId,
1353+ }) {
1354+ final composingToA = composingToChannelId == a.streamId;
1355+ final composingToB = composingToChannelId == b.streamId;
1356+ return switch ((composingToA, composingToB)) {
1357+ (true , false ) => - 1 ,
1358+ (false , true ) => 1 ,
1359+ _ => 0 ,
1360+ };
1361+ }
1362+
1363+ /// Comparator that puts subscribed channels before unsubscribed ones.
1364+ ///
1365+ /// For subscribed channels, it puts them in the following order:
1366+ /// pinned unmuted > unpinned unmuted > pinned muted > unpinned muted
1367+ @visibleForTesting
1368+ static int compareByBeingSubscribed (ZulipStream a, ZulipStream b) {
1369+ if (a is Subscription && b is ! Subscription ) return - 1 ;
1370+ if (a is ! Subscription && b is Subscription ) return 1 ;
1371+
1372+ return switch ((a, b)) {
1373+ (Subscription (isMuted: false ), Subscription (isMuted: true )) => - 1 ,
1374+ (Subscription (isMuted: true ), Subscription (isMuted: false )) => 1 ,
1375+ (Subscription (pinToTop: true ), Subscription (pinToTop: false )) => - 1 ,
1376+ (Subscription (pinToTop: false ), Subscription (pinToTop: true )) => 1 ,
1377+ _ => 0 ,
1378+ };
1379+ }
1380+
1381+ /// Comparator that puts recently-active channels before inactive ones.
1382+ ///
1383+ /// Being recently-active is determined by [ZulipStream.isRecentlyActive] .
1384+ @visibleForTesting
1385+ static int compareByRecentActivity (ZulipStream a, ZulipStream b) {
1386+ // Compare `stream_list_sort.has_recent_activity` in Zulip web:
1387+ // https://github.com/zulip/zulip/blob/925ae0f9b/web/src/stream_list_sort.ts#L84-L96
1388+ //
1389+ // There are a few other criteria that Web considers for a channel being
1390+ // recently-active, for which we don't have all the data at the moment:
1391+ // * If the user don't want to filter out inactive streams to the
1392+ // bottom, then every channel is considered as recently-active.
1393+ // * A channel pinned to the top is also considered as recently-active,
1394+ // but we already favor that criterion before even reaching to this one.
1395+ // * If the channel is newly subscribed, then it's considered as
1396+ // recently-active.
1397+
1398+ return switch ((a.isRecentlyActive, b.isRecentlyActive)) {
1399+ (true , false ) => - 1 ,
1400+ (false , true ) => 1 ,
1401+ // The combination of `null` and `bool` is not possible as they're both
1402+ // either `null` or `bool`, before or after server-10, respectively.
1403+ // TODO(server-10): remove the preceding comment
1404+ _ => 0 ,
1405+ };
1406+ }
1407+
1408+ /// Comparator that puts channels with more [ZulipStream.streamWeeklyTraffic] first.
1409+ ///
1410+ /// A channel with undefined weekly traffic (`null` ) is put after
1411+ /// the channel with weekly traffic defined, but zero and `null`
1412+ /// traffic are considered the same.
1413+ @visibleForTesting
1414+ static int compareByWeeklyTraffic (ZulipStream a, ZulipStream b) {
1415+ return - (a.streamWeeklyTraffic ?? 0 ).compareTo (b.streamWeeklyTraffic ?? 0 );
1416+ }
1417+
1418+ @override
1419+ Future <List <ChannelLinkAutocompleteResult >?> computeResults () async {
1420+ final unsorted = < ChannelLinkAutocompleteResult > [];
1421+ if (await filterCandidates (filter: _testChannel,
1422+ candidates: sortedChannels, results: unsorted)) {
1423+ return null ;
1424+ }
1425+
1426+ return bucketSort (unsorted,
1427+ (r) => r.rank, numBuckets: ChannelLinkAutocompleteQuery ._numResultRanks);
1428+ }
1429+
1430+ ChannelLinkAutocompleteResult ? _testChannel (ChannelLinkAutocompleteQuery query, ZulipStream channel) {
1431+ return query.testChannel (channel, store);
1432+ }
1433+ }
1434+
1435+ /// A #channel autocomplete query, used by [ChannelLinkAutocompleteView] .
1436+ class ChannelLinkAutocompleteQuery extends ComposeAutocompleteQuery {
1437+ ChannelLinkAutocompleteQuery (super .raw);
1438+
1439+ @override
1440+ ChannelLinkAutocompleteView initViewModel ({
1441+ required PerAccountStore store,
1442+ required ZulipLocalizations localizations,
1443+ required Narrow narrow,
1444+ }) {
1445+ return ChannelLinkAutocompleteView .init (store: store, query: this , narrow: narrow);
1446+ }
1447+
1448+ ChannelLinkAutocompleteResult ? testChannel (ZulipStream channel, PerAccountStore store) {
1449+ if (channel.isArchived) return null ;
1450+
1451+ final cache = store.autocompleteViewManager.autocompleteDataCache;
1452+ final matchQuality = _matchName (
1453+ normalizedName: cache.normalizedNameForChannel (channel),
1454+ normalizedNameWords: cache.normalizedNameWordsForChannel (channel));
1455+ if (matchQuality == null ) return null ;
1456+ return ChannelLinkAutocompleteResult (
1457+ channelId: channel.streamId, rank: _rankResult (matchQuality));
1458+ }
1459+
1460+ /// A measure of a channel result's quality in the context of the query,
1461+ /// from 0 (best) to one less than [_numResultRanks] .
1462+ static int _rankResult (NameMatchQuality matchQuality) {
1463+ return switch (matchQuality) {
1464+ NameMatchQuality .exact => 0 ,
1465+ NameMatchQuality .totalPrefix => 1 ,
1466+ NameMatchQuality .wordPrefixes => 2 ,
1467+ };
1468+ }
1469+
1470+ /// The number of possible values returned by [_rankResult] .
1471+ static const _numResultRanks = 3 ;
1472+
1473+ @override
1474+ String toString () {
1475+ return '${objectRuntimeType (this , 'ChannelLinkAutocompleteQuery' )}($raw )' ;
1476+ }
1477+
1478+ @override
1479+ bool operator == (Object other) {
1480+ return other is ChannelLinkAutocompleteQuery && other.raw == raw;
1481+ }
1482+
1483+ @override
1484+ int get hashCode => Object .hash ('ChannelLinkAutocompleteQuery' , raw);
1485+ }
1486+
1487+ /// An autocomplete result for a #channel.
1488+ class ChannelLinkAutocompleteResult extends ComposeAutocompleteResult {
1489+ ChannelLinkAutocompleteResult ({required this .channelId, required this .rank});
1490+
1491+ final int channelId;
1492+
1493+ /// A measure of the result's quality in the context of the query.
1494+ ///
1495+ /// Used internally by [ChannelLinkAutocompleteView] for ranking the results.
1496+ // See also [ChannelLinkAutocompleteView._channelsByRelevance];
1497+ // results with equal [rank] will appear in the order they were put in
1498+ // by that method.
1499+ //
1500+ // Compare sort_streams in Zulip web:
1501+ // https://github.com/zulip/zulip/blob/a5d25826b/web/src/typeahead_helper.ts#L998-L1008
1502+ //
1503+ // Behavior we have that web doesn't and might like to follow:
1504+ // - A "word-prefixes" match quality on channel names:
1505+ // see [NameMatchQuality.wordPrefixes], which we rank on.
1506+ //
1507+ // Behavior web has that seems undesired, which we don't plan to follow:
1508+ // - A "word-boundary" match quality on channel names:
1509+ // special rank when the whole query appears contiguously
1510+ // right after a word-boundary character.
1511+ // Our [NameMatchQuality.wordPrefixes] seems smarter.
1512+ // - Ranking some case-sensitive matches differently from case-insensitive
1513+ // matches. Users will expect a lowercase query to be adequate.
1514+ // - Matching and ranking on channel descriptions but only when the query
1515+ // is present (but not an exact match, total-prefix, or word-boundary match)
1516+ // in the channel name. This doesn't seem to be helpful in most cases,
1517+ // because it is hard for a query to be present in the name (the way
1518+ // mentioned before) and also present in the description.
1519+ final int rank;
1520+ }
0 commit comments