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;
67
78import '../api/model/events.dart' ;
89import '../api/model/model.dart' ;
910import '../api/route/channels.dart' ;
11+ import '../basic.dart' ;
1012import '../generated/l10n/zulip_localizations.dart' ;
1113import '../widgets/compose_box.dart' ;
1214import 'algorithms.dart' ;
15+ import 'channel.dart' ;
1316import 'compose.dart' ;
1417import 'emoji.dart' ;
1518import 'narrow.dart' ;
@@ -209,6 +212,7 @@ class AutocompleteViewManager {
209212 final Set <MentionAutocompleteView > _mentionAutocompleteViews = {};
210213 final Set <TopicAutocompleteView > _topicAutocompleteViews = {};
211214 final Set <EmojiAutocompleteView > _emojiAutocompleteViews = {};
215+ final Set <ChannelLinkAutocompleteView > _channelLinkAutocompleteViews = {};
212216
213217 AutocompleteDataCache autocompleteDataCache = AutocompleteDataCache ();
214218
@@ -242,6 +246,16 @@ class AutocompleteViewManager {
242246 assert (removed);
243247 }
244248
249+ void registerChannelLinkAutocomplete (ChannelLinkAutocompleteView view) {
250+ final added = _channelLinkAutocompleteViews.add (view);
251+ assert (added);
252+ }
253+
254+ void unregisterChannelLinkAutocomplete (ChannelLinkAutocompleteView view) {
255+ final removed = _channelLinkAutocompleteViews.remove (view);
256+ assert (removed);
257+ }
258+
245259 void handleRealmUserRemoveEvent (RealmUserRemoveEvent event) {
246260 autocompleteDataCache.invalidateUser (event.userId);
247261 }
@@ -258,6 +272,17 @@ class AutocompleteViewManager {
258272 autocompleteDataCache.invalidateUserGroup (event.groupId);
259273 }
260274
275+ void handleChannelDeleteEvent (ChannelDeleteEvent event) {
276+ final channelIds = event.streamIds ?? event.streams! .map ((e) => e.streamId);
277+ for (final channelId in channelIds) {
278+ autocompleteDataCache.invalidateChannel (channelId);
279+ }
280+ }
281+
282+ void handleChannelUpdateEvent (ChannelUpdateEvent event) {
283+ autocompleteDataCache.invalidateChannel (event.streamId);
284+ }
285+
261286 /// Called when the app is reassembled during debugging, e.g. for hot reload.
262287 ///
263288 /// Calls [AutocompleteView.reassemble] for all that are registered.
@@ -269,6 +294,9 @@ class AutocompleteViewManager {
269294 for (final view in _topicAutocompleteViews) {
270295 view.reassemble ();
271296 }
297+ for (final view in _channelLinkAutocompleteViews) {
298+ view.reassemble ();
299+ }
272300 }
273301
274302 // No `dispose` method, because there's nothing for it to do.
@@ -1021,6 +1049,20 @@ class AutocompleteDataCache {
10211049 ?? = normalizedNameForUserGroup (userGroup).split (' ' );
10221050 }
10231051
1052+ final Map <int , String > _normalizedNamesByChannel = {};
1053+
1054+ String normalizedNameForChannel (ZulipStream channel) {
1055+ return _normalizedNamesByChannel[channel.streamId]
1056+ ?? = AutocompleteQuery .lowercaseAndStripDiacritics (channel.name);
1057+ }
1058+
1059+ final Map <int , List <String >> _normalizedNameWordsByChannel = {};
1060+
1061+ List <String > normalizedNameWordsForChannel (ZulipStream channel) {
1062+ return _normalizedNameWordsByChannel[channel.streamId]
1063+ ?? normalizedNameForChannel (channel).split (' ' );
1064+ }
1065+
10241066 void invalidateUser (int userId) {
10251067 _normalizedNamesByUser.remove (userId);
10261068 _normalizedNameWordsByUser.remove (userId);
@@ -1031,6 +1073,11 @@ class AutocompleteDataCache {
10311073 _normalizedNamesByUserGroup.remove (id);
10321074 _normalizedNameWordsByUserGroup.remove (id);
10331075 }
1076+
1077+ void invalidateChannel (int channelId) {
1078+ _normalizedNamesByChannel.remove (channelId);
1079+ _normalizedNameWordsByChannel.remove (channelId);
1080+ }
10341081}
10351082
10361083/// A result the user chose, or might choose, from an autocomplete interaction.
@@ -1238,3 +1285,264 @@ class TopicAutocompleteResult extends AutocompleteResult {
12381285
12391286 TopicAutocompleteResult ({required this .topic});
12401287}
1288+
1289+ /// An [AutocompleteView] for a #channel autocomplete interaction,
1290+ /// an example of a [ComposeAutocompleteView] .
1291+ class ChannelLinkAutocompleteView extends AutocompleteView <ChannelLinkAutocompleteQuery , ChannelLinkAutocompleteResult > {
1292+ ChannelLinkAutocompleteView ._({
1293+ required super .store,
1294+ required super .query,
1295+ required this .narrow,
1296+ required this .sortedChannels,
1297+ });
1298+
1299+ factory ChannelLinkAutocompleteView .init ({
1300+ required PerAccountStore store,
1301+ required Narrow narrow,
1302+ required ChannelLinkAutocompleteQuery query,
1303+ }) {
1304+ final view = ChannelLinkAutocompleteView ._(
1305+ store: store,
1306+ query: query,
1307+ narrow: narrow,
1308+ sortedChannels: _channelsByRelevance (store: store, narrow: narrow),
1309+ );
1310+ store.autocompleteViewManager.registerChannelLinkAutocomplete (view);
1311+ return view;
1312+ }
1313+
1314+ final Narrow narrow;
1315+ final List <ZulipStream > sortedChannels;
1316+
1317+ static List <ZulipStream > _channelsByRelevance ({
1318+ required PerAccountStore store,
1319+ required Narrow narrow,
1320+ }) {
1321+ return store.streams.values.sorted (_comparator (narrow: narrow));
1322+ }
1323+
1324+ /// Compare the channels the same way they would be sorted as
1325+ /// autocomplete candidates, given [query] .
1326+ ///
1327+ /// The channels must both match the query.
1328+ ///
1329+ /// This behaves the same as the comparator used for sorting in
1330+ /// [_channelsByRelevance] , combined with the ranking applied at the end
1331+ /// of [computeResults] .
1332+ ///
1333+ /// This is useful for tests in order to distinguish "A comes before B"
1334+ /// from "A ranks equal to B, and the sort happened to put A before B",
1335+ /// particularly because [List.sort] makes no guarantees about the order
1336+ /// of items that compare equal.
1337+ int debugCompareChannels (ZulipStream a, ZulipStream b) {
1338+ final rankA = query.testChannel (a, store)! .rank;
1339+ final rankB = query.testChannel (b, store)! .rank;
1340+ if (rankA != rankB) return rankA.compareTo (rankB);
1341+
1342+ return _comparator (narrow: narrow)(a, b);
1343+ }
1344+
1345+ static Comparator <ZulipStream > _comparator ({required Narrow narrow}) {
1346+ // See also [ChannelLinkAutocompleteQuery._rankResult];
1347+ // that ranking takes precedence over this.
1348+
1349+ final channelId = switch (narrow) {
1350+ ChannelNarrow (: var streamId) || TopicNarrow (: var streamId) => streamId,
1351+ DmNarrow () => null ,
1352+ CombinedFeedNarrow ()
1353+ || MentionsNarrow ()
1354+ || StarredMessagesNarrow ()
1355+ || KeywordSearchNarrow () => () {
1356+ assert (false , 'No compose box, thus no autocomplete is available in ${narrow .runtimeType }.' );
1357+ return null ;
1358+ }(),
1359+ };
1360+ return (a, b) => _compareByRelevance (a, b, currentChannelId: channelId);
1361+ }
1362+
1363+ // Check `typeahead_helper.compare_by_activity` in Zulip web;
1364+ // We follow the behavior of Web but with a small difference in that Web
1365+ // compares "recent activity" only for subscribed channels, but we do it
1366+ // for unsubscribed ones too.
1367+ // https://github.com/zulip/zulip/blob/c3fdee6ed/web/src/typeahead_helper.ts#L972-L988
1368+ static int _compareByRelevance (ZulipStream a, ZulipStream b, {
1369+ required int ? currentChannelId,
1370+ }) {
1371+ // The order of each comparator element in the list is important; the first
1372+ // one having the highest priority and the last one having the least priority.
1373+ final comparators = [
1374+ if (currentChannelId != null )
1375+ () => compareByBeingCurrent (a, b, currentChannelId: currentChannelId),
1376+ () => compareByBeingSubscribed (a, b),
1377+ () => compareByRecentActivity (a, b),
1378+ () => compareByWeeklyTraffic (a, b),
1379+ () => ChannelStore .compareChannelsByName (a, b),
1380+ ];
1381+ return comparators.map ((compare) => compare ())
1382+ .firstWhere ((result) => result != 0 , orElse: () => 0 );
1383+ }
1384+
1385+ /// Comparator that puts the channel being composed to, before other ones.
1386+ @visibleForTesting
1387+ static int compareByBeingCurrent (ZulipStream a, ZulipStream b, {
1388+ required int currentChannelId,
1389+ }) {
1390+ return switch ((a.streamId, b.streamId)) {
1391+ (int id, _) when id == currentChannelId => - 1 ,
1392+ (_, int id) when id == currentChannelId => 1 ,
1393+ _ => 0 ,
1394+ };
1395+ }
1396+
1397+ /// Comparator that puts subscribed channels before unsubscribed ones.
1398+ ///
1399+ /// For subscribed channels, it puts them in the following way:
1400+ /// pinned unmuted > unpinned unmuted > pinned muted > unpinned muted
1401+ @visibleForTesting
1402+ static int compareByBeingSubscribed (ZulipStream a, ZulipStream b) {
1403+ return switch ((tryCast <Subscription >(a), tryCast <Subscription >(b))) {
1404+ (Subscription (), null ) => - 1 ,
1405+ (null , Subscription ()) => 1 ,
1406+ (Subscription (isMuted: false ), Subscription (isMuted: true )) => - 1 ,
1407+ (Subscription (isMuted: true ), Subscription (isMuted: false )) => 1 ,
1408+ (Subscription (pinToTop: true ), Subscription (pinToTop: false )) => - 1 ,
1409+ (Subscription (pinToTop: false ), Subscription (pinToTop: true )) => 1 ,
1410+ _ => 0 ,
1411+ };
1412+ }
1413+
1414+ /// Comparator that puts recently-active channels before inactive ones.
1415+ ///
1416+ /// A channel is recently active if there are messages sent to it recently,
1417+ /// which is determined by [ZulipStream.isRecentlyActive] .
1418+ @visibleForTesting
1419+ static int compareByRecentActivity (ZulipStream a, ZulipStream b) {
1420+ return switch ((a.isRecentlyActive, b.isRecentlyActive)) {
1421+ (true , false ) => - 1 ,
1422+ (false , true ) => 1 ,
1423+ // The combination of `null` and `bool` is not possible as they're both
1424+ // either `null` or `bool`, before or after server-10, respectively.
1425+ // TODO(server-10): remove the preceding comment
1426+ _ => 0 ,
1427+ };
1428+ }
1429+
1430+ /// Comparator that puts channels with more weekly traffic first.
1431+ ///
1432+ /// A channel with undefined weekly traffic (`null` ) is put after the channel
1433+ /// with a weekly traffic defined (even if it is zero).
1434+ ///
1435+ /// Weekly traffic is the average number of messages sent to the channel per
1436+ /// week, which is determined by [ZulipStream.streamWeeklyTraffic] .
1437+ @visibleForTesting
1438+ static int compareByWeeklyTraffic (ZulipStream a, ZulipStream b) {
1439+ return switch ((a.streamWeeklyTraffic, b.streamWeeklyTraffic)) {
1440+ (int a, int b) => - a.compareTo (b),
1441+ (int (), null ) => - 1 ,
1442+ (null , int ()) => 1 ,
1443+ _ => 0 ,
1444+ };
1445+ }
1446+
1447+ @override
1448+ Future <List <ChannelLinkAutocompleteResult >?> computeResults () async {
1449+ final unsorted = < ChannelLinkAutocompleteResult > [];
1450+ if (await filterCandidates (filter: _testChannel,
1451+ candidates: sortedChannels, results: unsorted)) {
1452+ return null ;
1453+ }
1454+
1455+ return bucketSort (unsorted,
1456+ (r) => r.rank, numBuckets: ChannelLinkAutocompleteQuery ._numResultRanks);
1457+ }
1458+
1459+ ChannelLinkAutocompleteResult ? _testChannel (ChannelLinkAutocompleteQuery query, ZulipStream channel) {
1460+ return query.testChannel (channel, store);
1461+ }
1462+
1463+ @override
1464+ void dispose () {
1465+ store.autocompleteViewManager.unregisterChannelLinkAutocomplete (this );
1466+ super .dispose ();
1467+ }
1468+ }
1469+
1470+ /// A #channel autocomplete query, used by [ChannelLinkAutocompleteView] .
1471+ class ChannelLinkAutocompleteQuery extends ComposeAutocompleteQuery {
1472+ ChannelLinkAutocompleteQuery (super .raw);
1473+
1474+ @override
1475+ ChannelLinkAutocompleteView initViewModel ({
1476+ required PerAccountStore store,
1477+ required ZulipLocalizations localizations,
1478+ required Narrow narrow,
1479+ }) {
1480+ return ChannelLinkAutocompleteView .init (store: store, query: this , narrow: narrow);
1481+ }
1482+
1483+ ChannelLinkAutocompleteResult ? testChannel (ZulipStream channel, PerAccountStore store) {
1484+ final cache = store.autocompleteViewManager.autocompleteDataCache;
1485+ final matchQuality = _matchName (
1486+ normalizedName: cache.normalizedNameForChannel (channel),
1487+ normalizedNameWords: cache.normalizedNameWordsForChannel (channel));
1488+ if (matchQuality == null ) return null ;
1489+ return ChannelLinkAutocompleteResult (
1490+ channelId: channel.streamId, rank: _rankResult (matchQuality));
1491+ }
1492+
1493+ /// A measure of a channel result's quality in the context of the query,
1494+ /// from 0 (best) to one less than [_numResultRanks] .
1495+ static int _rankResult (NameMatchQuality matchQuality) {
1496+ return switch (matchQuality) {
1497+ NameMatchQuality .exact => 0 ,
1498+ NameMatchQuality .totalPrefix => 1 ,
1499+ NameMatchQuality .wordPrefixes => 2 ,
1500+ };
1501+ }
1502+
1503+ /// The number of possible values returned by [_rankResult] .
1504+ static const _numResultRanks = 3 ;
1505+
1506+ @override
1507+ String toString () {
1508+ return '${objectRuntimeType (this , 'ChannelLinkAutocompleteQuery' )}($raw )' ;
1509+ }
1510+
1511+ @override
1512+ bool operator == (Object other) {
1513+ return other is ChannelLinkAutocompleteQuery && other.raw == raw;
1514+ }
1515+
1516+ @override
1517+ int get hashCode => Object .hash ('ChannelLinkAutocompleteQuery' , raw);
1518+ }
1519+
1520+ /// An autocomplete result for a #channel.
1521+ class ChannelLinkAutocompleteResult extends ComposeAutocompleteResult {
1522+ ChannelLinkAutocompleteResult ({required this .channelId, required this .rank});
1523+
1524+ final int channelId;
1525+
1526+ /// A measure of the result's quality in the context of the query.
1527+ ///
1528+ /// Used internally by [ChannelLinkAutocompleteView] for ranking the results.
1529+ // See also [ChannelLinkAutocompleteView._channelsByRelevance];
1530+ // results with equal [rank] will appear in the order they were put in
1531+ // by that method.
1532+ //
1533+ // Compare sort_streams in Zulip web:
1534+ // https://github.com/zulip/zulip/blob/a5d25826b/web/src/typeahead_helper.ts#L998-L1008
1535+ //
1536+ // Behavior we have that web doesn't and might like to follow:
1537+ // - A "word-prefixes" match quality on channel names:
1538+ // see [NameMatchQuality.wordPrefixes], which we rank on.
1539+ //
1540+ // Behavior web has that seems undesired, which we don't plan to follow:
1541+ // - A "word-boundary" match quality on channel names:
1542+ // special rank when the whole query appears contiguously
1543+ // right after a word-boundary character.
1544+ // Our [NameMatchQuality.wordPrefixes] seems smarter.
1545+ // - Ranking some case-sensitive matches differently from case-insensitive
1546+ // matches. Users will expect a lowercase query to be adequate.
1547+ final int rank;
1548+ }
0 commit comments