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' ;
@@ -209,6 +211,7 @@ class AutocompleteViewManager {
209211 final Set <MentionAutocompleteView > _mentionAutocompleteViews = {};
210212 final Set <TopicAutocompleteView > _topicAutocompleteViews = {};
211213 final Set <EmojiAutocompleteView > _emojiAutocompleteViews = {};
214+ final Set <ChannelLinkAutocompleteView > _channelLinkAutocompleteViews = {};
212215
213216 AutocompleteDataCache autocompleteDataCache = AutocompleteDataCache ();
214217
@@ -242,6 +245,16 @@ class AutocompleteViewManager {
242245 assert (removed);
243246 }
244247
248+ void registerChannelLinkAutocomplete (ChannelLinkAutocompleteView view) {
249+ final added = _channelLinkAutocompleteViews.add (view);
250+ assert (added);
251+ }
252+
253+ void unregisterChannelLinkAutocomplete (ChannelLinkAutocompleteView view) {
254+ final removed = _channelLinkAutocompleteViews.remove (view);
255+ assert (removed);
256+ }
257+
245258 void handleRealmUserRemoveEvent (RealmUserRemoveEvent event) {
246259 autocompleteDataCache.invalidateUser (event.userId);
247260 }
@@ -258,6 +271,16 @@ class AutocompleteViewManager {
258271 autocompleteDataCache.invalidateUserGroup (event.groupId);
259272 }
260273
274+ void handleChannelDeleteEvent (ChannelDeleteEvent event) {
275+ for (final channelId in event.channelIds) {
276+ autocompleteDataCache.invalidateChannel (channelId);
277+ }
278+ }
279+
280+ void handleChannelUpdateEvent (ChannelUpdateEvent event) {
281+ autocompleteDataCache.invalidateChannel (event.streamId);
282+ }
283+
261284 /// Called when the app is reassembled during debugging, e.g. for hot reload.
262285 ///
263286 /// Calls [AutocompleteView.reassemble] for all that are registered.
@@ -272,6 +295,9 @@ class AutocompleteViewManager {
272295 for (final view in _emojiAutocompleteViews) {
273296 view.reassemble ();
274297 }
298+ for (final view in _channelLinkAutocompleteViews) {
299+ view.reassemble ();
300+ }
275301 }
276302
277303 // No `dispose` method, because there's nothing for it to do.
@@ -1029,6 +1055,21 @@ class AutocompleteDataCache {
10291055 ?? = normalizedNameForUserGroup (userGroup).split (' ' );
10301056 }
10311057
1058+ final Map <int , String > _normalizedNamesByChannel = {};
1059+
1060+ /// The normalized `name` of [channel] .
1061+ String normalizedNameForChannel (ZulipStream channel) {
1062+ return _normalizedNamesByChannel[channel.streamId]
1063+ ?? = AutocompleteQuery .lowercaseAndStripDiacritics (channel.name);
1064+ }
1065+
1066+ final Map <int , List <String >> _normalizedNameWordsByChannel = {};
1067+
1068+ List <String > normalizedNameWordsForChannel (ZulipStream channel) {
1069+ return _normalizedNameWordsByChannel[channel.streamId]
1070+ ?? normalizedNameForChannel (channel).split (' ' );
1071+ }
1072+
10321073 void invalidateUser (int userId) {
10331074 _normalizedNamesByUser.remove (userId);
10341075 _normalizedNameWordsByUser.remove (userId);
@@ -1039,6 +1080,11 @@ class AutocompleteDataCache {
10391080 _normalizedNamesByUserGroup.remove (id);
10401081 _normalizedNameWordsByUserGroup.remove (id);
10411082 }
1083+
1084+ void invalidateChannel (int channelId) {
1085+ _normalizedNamesByChannel.remove (channelId);
1086+ _normalizedNameWordsByChannel.remove (channelId);
1087+ }
10421088}
10431089
10441090/// A result the user chose, or might choose, from an autocomplete interaction.
@@ -1246,3 +1292,267 @@ class TopicAutocompleteResult extends AutocompleteResult {
12461292
12471293 TopicAutocompleteResult ({required this .topic});
12481294}
1295+
1296+ /// An [AutocompleteView] for a #channel autocomplete interaction,
1297+ /// an example of a [ComposeAutocompleteView] .
1298+ class ChannelLinkAutocompleteView extends AutocompleteView <ChannelLinkAutocompleteQuery , ChannelLinkAutocompleteResult > {
1299+ ChannelLinkAutocompleteView ._({
1300+ required super .store,
1301+ required super .query,
1302+ required this .narrow,
1303+ required this .sortedChannels,
1304+ });
1305+
1306+ factory ChannelLinkAutocompleteView .init ({
1307+ required PerAccountStore store,
1308+ required Narrow narrow,
1309+ required ChannelLinkAutocompleteQuery query,
1310+ }) {
1311+ final view = ChannelLinkAutocompleteView ._(
1312+ store: store,
1313+ query: query,
1314+ narrow: narrow,
1315+ sortedChannels: _channelsByRelevance (store: store, narrow: narrow),
1316+ );
1317+ store.autocompleteViewManager.registerChannelLinkAutocomplete (view);
1318+ return view;
1319+ }
1320+
1321+ final Narrow narrow;
1322+ final List <ZulipStream > sortedChannels;
1323+
1324+ static List <ZulipStream > _channelsByRelevance ({
1325+ required PerAccountStore store,
1326+ required Narrow narrow,
1327+ }) {
1328+ return store.streams.values.sorted (_comparator (narrow: narrow));
1329+ }
1330+
1331+ /// Compare the channels the same way they would be sorted as
1332+ /// autocomplete candidates, given [query] .
1333+ ///
1334+ /// The channels must both match the query.
1335+ ///
1336+ /// This behaves the same as the comparator used for sorting in
1337+ /// [_channelsByRelevance] , combined with the ranking applied at the end
1338+ /// of [computeResults] .
1339+ ///
1340+ /// This is useful for tests in order to distinguish "A comes before B"
1341+ /// from "A ranks equal to B, and the sort happened to put A before B",
1342+ /// particularly because [List.sort] makes no guarantees about the order
1343+ /// of items that compare equal.
1344+ int debugCompareChannels (ZulipStream a, ZulipStream b) {
1345+ final rankA = query.testChannel (a, store)! .rank;
1346+ final rankB = query.testChannel (b, store)! .rank;
1347+ if (rankA != rankB) return rankA.compareTo (rankB);
1348+
1349+ return _comparator (narrow: narrow)(a, b);
1350+ }
1351+
1352+ static Comparator <ZulipStream > _comparator ({required Narrow narrow}) {
1353+ // See also [ChannelLinkAutocompleteQuery._rankResult];
1354+ // that ranking takes precedence over this.
1355+
1356+ final channelId = switch (narrow) {
1357+ ChannelNarrow (: var streamId) || TopicNarrow (: var streamId) => streamId,
1358+ DmNarrow () => null ,
1359+ CombinedFeedNarrow ()
1360+ || MentionsNarrow ()
1361+ || StarredMessagesNarrow ()
1362+ || KeywordSearchNarrow () => () {
1363+ assert (false , 'No compose box, thus no autocomplete is available in ${narrow .runtimeType }.' );
1364+ return null ;
1365+ }(),
1366+ };
1367+ return (a, b) => _compareByRelevance (a, b, composingToChannelId: channelId);
1368+ }
1369+
1370+ // Check `typeahead_helper.compare_by_activity` in Zulip web;
1371+ // We follow the behavior of Web but with a small difference in that Web
1372+ // compares "recent activity" only for subscribed channels, but we do it
1373+ // for unsubscribed ones too.
1374+ // https://github.com/zulip/zulip/blob/c3fdee6ed/web/src/typeahead_helper.ts#L972-L988
1375+ static int _compareByRelevance (ZulipStream a, ZulipStream b, {
1376+ required int ? composingToChannelId,
1377+ }) {
1378+ // The order of each comparator element in the list is important;
1379+ // the first one having the highest priority and the last one having
1380+ // the least priority.
1381+ final comparators = [
1382+ if (composingToChannelId != null )
1383+ () => compareByComposingTo (a, b, composingToChannelId: composingToChannelId),
1384+ () => compareByBeingSubscribed (a, b),
1385+ () => compareByRecentActivity (a, b),
1386+ () => compareByWeeklyTraffic (a, b),
1387+ () => ChannelStore .compareChannelsByName (a, b),
1388+ ];
1389+ return comparators.map ((compare) => compare ())
1390+ .firstWhere ((result) => result != 0 , orElse: () => 0 );
1391+ }
1392+
1393+ /// Comparator that puts the channel being composed to, before other ones.
1394+ @visibleForTesting
1395+ static int compareByComposingTo (ZulipStream a, ZulipStream b, {
1396+ required int composingToChannelId,
1397+ }) {
1398+ return switch ((a.streamId, b.streamId)) {
1399+ (int id, _) when id == composingToChannelId => - 1 ,
1400+ (_, int id) when id == composingToChannelId => 1 ,
1401+ _ => 0 ,
1402+ };
1403+ }
1404+
1405+ /// Comparator that puts subscribed channels before unsubscribed ones.
1406+ ///
1407+ /// For subscribed channels, it puts them in the following order:
1408+ /// pinned unmuted > unpinned unmuted > pinned muted > unpinned muted
1409+ @visibleForTesting
1410+ static int compareByBeingSubscribed (ZulipStream a, ZulipStream b) {
1411+ if (a is Subscription && b is ! Subscription ) return - 1 ;
1412+ if (a is ! Subscription && b is Subscription ) return 1 ;
1413+
1414+ return switch ((a, b)) {
1415+ (Subscription (isMuted: false ), Subscription (isMuted: true )) => - 1 ,
1416+ (Subscription (isMuted: true ), Subscription (isMuted: false )) => 1 ,
1417+ (Subscription (pinToTop: true ), Subscription (pinToTop: false )) => - 1 ,
1418+ (Subscription (pinToTop: false ), Subscription (pinToTop: true )) => 1 ,
1419+ _ => 0 ,
1420+ };
1421+ }
1422+
1423+ /// Comparator that puts recently-active channels before inactive ones.
1424+ ///
1425+ /// Being recently-active is determined by [ZulipStream.isRecentlyActive] .
1426+ @visibleForTesting
1427+ static int compareByRecentActivity (ZulipStream a, ZulipStream b) {
1428+ return switch ((a.isRecentlyActive, b.isRecentlyActive)) {
1429+ (true , false ) => - 1 ,
1430+ (false , true ) => 1 ,
1431+ // The combination of `null` and `bool` is not possible as they're both
1432+ // either `null` or `bool`, before or after server-10, respectively.
1433+ // TODO(server-10): remove the preceding comment
1434+ _ => 0 ,
1435+ };
1436+ }
1437+
1438+ /// Comparator that puts channels with more [ZulipStream.streamWeeklyTraffic] first.
1439+ ///
1440+ /// A channel with undefined weekly traffic (`null` ) is put after the channel
1441+ /// with a weekly traffic defined (even if it is zero).
1442+ @visibleForTesting
1443+ static int compareByWeeklyTraffic (ZulipStream a, ZulipStream b) {
1444+ return switch ((a.streamWeeklyTraffic, b.streamWeeklyTraffic)) {
1445+ (int a, int b) => - a.compareTo (b),
1446+ (int (), null ) => - 1 ,
1447+ (null , int ()) => 1 ,
1448+ _ => 0 ,
1449+ };
1450+ }
1451+
1452+ @override
1453+ Future <List <ChannelLinkAutocompleteResult >?> computeResults () async {
1454+ final unsorted = < ChannelLinkAutocompleteResult > [];
1455+ if (await filterCandidates (filter: _testChannel,
1456+ candidates: sortedChannels, results: unsorted)) {
1457+ return null ;
1458+ }
1459+
1460+ return bucketSort (unsorted,
1461+ (r) => r.rank, numBuckets: ChannelLinkAutocompleteQuery ._numResultRanks);
1462+ }
1463+
1464+ ChannelLinkAutocompleteResult ? _testChannel (ChannelLinkAutocompleteQuery query, ZulipStream channel) {
1465+ return query.testChannel (channel, store);
1466+ }
1467+
1468+ @override
1469+ void dispose () {
1470+ store.autocompleteViewManager.unregisterChannelLinkAutocomplete (this );
1471+ super .dispose ();
1472+ }
1473+ }
1474+
1475+ /// A #channel autocomplete query, used by [ChannelLinkAutocompleteView] .
1476+ class ChannelLinkAutocompleteQuery extends ComposeAutocompleteQuery {
1477+ ChannelLinkAutocompleteQuery (super .raw);
1478+
1479+ @override
1480+ ChannelLinkAutocompleteView initViewModel ({
1481+ required PerAccountStore store,
1482+ required ZulipLocalizations localizations,
1483+ required Narrow narrow,
1484+ }) {
1485+ return ChannelLinkAutocompleteView .init (store: store, query: this , narrow: narrow);
1486+ }
1487+
1488+ ChannelLinkAutocompleteResult ? testChannel (ZulipStream channel, PerAccountStore store) {
1489+ final cache = store.autocompleteViewManager.autocompleteDataCache;
1490+ final matchQuality = _matchName (
1491+ normalizedName: cache.normalizedNameForChannel (channel),
1492+ normalizedNameWords: cache.normalizedNameWordsForChannel (channel));
1493+ if (matchQuality == null ) return null ;
1494+ return ChannelLinkAutocompleteResult (
1495+ channelId: channel.streamId, rank: _rankResult (matchQuality));
1496+ }
1497+
1498+ /// A measure of a channel result's quality in the context of the query,
1499+ /// from 0 (best) to one less than [_numResultRanks] .
1500+ static int _rankResult (NameMatchQuality matchQuality) {
1501+ return switch (matchQuality) {
1502+ NameMatchQuality .exact => 0 ,
1503+ NameMatchQuality .totalPrefix => 1 ,
1504+ NameMatchQuality .wordPrefixes => 2 ,
1505+ };
1506+ }
1507+
1508+ /// The number of possible values returned by [_rankResult] .
1509+ static const _numResultRanks = 3 ;
1510+
1511+ @override
1512+ String toString () {
1513+ return '${objectRuntimeType (this , 'ChannelLinkAutocompleteQuery' )}($raw )' ;
1514+ }
1515+
1516+ @override
1517+ bool operator == (Object other) {
1518+ return other is ChannelLinkAutocompleteQuery && other.raw == raw;
1519+ }
1520+
1521+ @override
1522+ int get hashCode => Object .hash ('ChannelLinkAutocompleteQuery' , raw);
1523+ }
1524+
1525+ /// An autocomplete result for a #channel.
1526+ class ChannelLinkAutocompleteResult extends ComposeAutocompleteResult {
1527+ ChannelLinkAutocompleteResult ({required this .channelId, required this .rank});
1528+
1529+ final int channelId;
1530+
1531+ /// A measure of the result's quality in the context of the query.
1532+ ///
1533+ /// Used internally by [ChannelLinkAutocompleteView] for ranking the results.
1534+ // See also [ChannelLinkAutocompleteView._channelsByRelevance];
1535+ // results with equal [rank] will appear in the order they were put in
1536+ // by that method.
1537+ //
1538+ // Compare sort_streams in Zulip web:
1539+ // https://github.com/zulip/zulip/blob/a5d25826b/web/src/typeahead_helper.ts#L998-L1008
1540+ //
1541+ // Behavior we have that web doesn't and might like to follow:
1542+ // - A "word-prefixes" match quality on channel names:
1543+ // see [NameMatchQuality.wordPrefixes], which we rank on.
1544+ //
1545+ // Behavior web has that seems undesired, which we don't plan to follow:
1546+ // - A "word-boundary" match quality on channel names:
1547+ // special rank when the whole query appears contiguously
1548+ // right after a word-boundary character.
1549+ // Our [NameMatchQuality.wordPrefixes] seems smarter.
1550+ // - Ranking some case-sensitive matches differently from case-insensitive
1551+ // matches. Users will expect a lowercase query to be adequate.
1552+ // - Matching and ranking on channel descriptions but only when the query
1553+ // is present (but not an exact match, total-prefix, or word-boundary match)
1554+ // in the channel name. This doesn't seem to be helpful in most cases,
1555+ // because it is hard for a query to be present in the name (the way
1556+ // mentioned before) and also present in the description.
1557+ final int rank;
1558+ }
0 commit comments