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.
@@ -1021,6 +1046,20 @@ class AutocompleteDataCache {
10211046 ?? = normalizedNameForUserGroup (userGroup).split (' ' );
10221047 }
10231048
1049+ final Map <int , String > _normalizedNamesByChannel = {};
1050+
1051+ String normalizedNameForChannel (ZulipStream channel) {
1052+ return _normalizedNamesByChannel[channel.streamId]
1053+ ?? = AutocompleteQuery .lowercaseAndStripDiacritics (channel.name);
1054+ }
1055+
1056+ final Map <int , List <String >> _normalizedNameWordsByChannel = {};
1057+
1058+ List <String > normalizedNameWordsForChannel (ZulipStream channel) {
1059+ return _normalizedNameWordsByChannel[channel.streamId]
1060+ ?? normalizedNameForChannel (channel).split (' ' );
1061+ }
1062+
10241063 void invalidateUser (int userId) {
10251064 _normalizedNamesByUser.remove (userId);
10261065 _normalizedNameWordsByUser.remove (userId);
@@ -1031,6 +1070,11 @@ class AutocompleteDataCache {
10311070 _normalizedNamesByUserGroup.remove (id);
10321071 _normalizedNameWordsByUserGroup.remove (id);
10331072 }
1073+
1074+ void invalidateChannel (int channelId) {
1075+ _normalizedNamesByChannel.remove (channelId);
1076+ _normalizedNameWordsByChannel.remove (channelId);
1077+ }
10341078}
10351079
10361080/// A result the user chose, or might choose, from an autocomplete interaction.
@@ -1238,3 +1282,243 @@ class TopicAutocompleteResult extends AutocompleteResult {
12381282
12391283 TopicAutocompleteResult ({required this .topic});
12401284}
1285+
1286+ /// An [AutocompleteView] for a #channel autocomplete interaction,
1287+ /// an example of a [ComposeAutocompleteView] .
1288+ class ChannelLinkAutocompleteView extends AutocompleteView <ChannelLinkAutocompleteQuery , ChannelLinkAutocompleteResult > {
1289+ ChannelLinkAutocompleteView ._({
1290+ required super .store,
1291+ required super .query,
1292+ required this .narrow,
1293+ required this .sortedChannels,
1294+ });
1295+
1296+ factory ChannelLinkAutocompleteView .init ({
1297+ required PerAccountStore store,
1298+ required Narrow narrow,
1299+ required ChannelLinkAutocompleteQuery query,
1300+ }) {
1301+ final view = ChannelLinkAutocompleteView ._(
1302+ store: store,
1303+ query: query,
1304+ narrow: narrow,
1305+ sortedChannels: _channelsByRelevance (store: store, narrow: narrow),
1306+ );
1307+ store.autocompleteViewManager.registerChannelLinkAutocomplete (view);
1308+ return view;
1309+ }
1310+
1311+ final Narrow narrow;
1312+ final List <ZulipStream > sortedChannels;
1313+
1314+ static List <ZulipStream > _channelsByRelevance ({
1315+ required PerAccountStore store,
1316+ required Narrow narrow,
1317+ }) {
1318+ return store.streams.values.sorted (_comparator (narrow: narrow));
1319+ }
1320+
1321+ static Comparator <ZulipStream > _comparator ({required Narrow narrow}) {
1322+ // See also [ChannelLinkAutocompleteQuery._rankResult];
1323+ // that ranking takes precedence over this.
1324+
1325+ final channelId = switch (narrow) {
1326+ ChannelNarrow (: var streamId) || TopicNarrow (: var streamId) => streamId,
1327+ DmNarrow () => null ,
1328+ CombinedFeedNarrow ()
1329+ || MentionsNarrow ()
1330+ || StarredMessagesNarrow ()
1331+ || KeywordSearchNarrow () => () {
1332+ assert (false , 'No compose box, thus no autocomplete is available in ${narrow .runtimeType }.' );
1333+ return null ;
1334+ }(),
1335+ };
1336+ return (a, b) => _compareByRelevance (a, b, currentChannelId: channelId);
1337+ }
1338+
1339+ // Check `typeahead_helper.compare_by_activity` in Zulip web;
1340+ // We follow the behavior of Web but with a small difference in that Web
1341+ // compares "recent activity" only for subscribed channels, but we do it
1342+ // for unsubscribed ones too.
1343+ // https://github.com/zulip/zulip/blob/c3fdee6ed/web/src/typeahead_helper.ts#L972-L988
1344+ static int _compareByRelevance (ZulipStream a, ZulipStream b, {
1345+ required int ? currentChannelId,
1346+ }) {
1347+ // The order of each comparator element in the list is important; top having
1348+ // the highest priority and bottom having the least priority.
1349+ final comparators = [
1350+ if (currentChannelId != null )
1351+ () => compareByBeingCurrent (a, b, currentChannelId: currentChannelId),
1352+ () => compareByBeingSubscribed (a, b),
1353+ () => compareByRecentActivity (a, b),
1354+ () => compareByWeeklyTraffic (a, b),
1355+ () => ChannelStore .compareChannelsByName (a, b),
1356+ ];
1357+ return comparators.map ((compare) => compare ())
1358+ .firstWhere ((result) => result != 0 , orElse: () => 0 );
1359+ }
1360+
1361+ /// Comparator that puts the channel being composed to, before other ones.
1362+ @visibleForTesting
1363+ static int compareByBeingCurrent (ZulipStream a, ZulipStream b, {
1364+ required int currentChannelId,
1365+ }) {
1366+ return switch ((a.streamId, b.streamId)) {
1367+ (int id, _) when id == currentChannelId => - 1 ,
1368+ (_, int id) when id == currentChannelId => 1 ,
1369+ _ => 0 ,
1370+ };
1371+ }
1372+
1373+ /// Comparator that puts subscribed channels before unsubscribed ones.
1374+ ///
1375+ /// For subscribed channels, it puts them in the following way:
1376+ /// pinned unmuted > unpinned unmuted > pinned muted > unpinned muted
1377+ @visibleForTesting
1378+ static int compareByBeingSubscribed (ZulipStream a, ZulipStream b) {
1379+ return switch ((tryCast <Subscription >(a), tryCast <Subscription >(b))) {
1380+ (Subscription (), null ) => - 1 ,
1381+ (null , Subscription ()) => 1 ,
1382+ (Subscription (isMuted: false ), Subscription (isMuted: true )) => - 1 ,
1383+ (Subscription (isMuted: true ), Subscription (isMuted: false )) => 1 ,
1384+ (Subscription (pinToTop: true ), Subscription (pinToTop: false )) => - 1 ,
1385+ (Subscription (pinToTop: false ), Subscription (pinToTop: true )) => 1 ,
1386+ _ => 0 ,
1387+ };
1388+ }
1389+
1390+ /// Comparator that puts recently-active channels before inactive ones.
1391+ ///
1392+ /// A channel is recently active if there are messages sent to it recently,
1393+ /// which is determined by [ZulipStream.isRecentlyActive] .
1394+ @visibleForTesting
1395+ static int compareByRecentActivity (ZulipStream a, ZulipStream b) {
1396+ return switch ((a.isRecentlyActive, b.isRecentlyActive)) {
1397+ (true , false ) => - 1 ,
1398+ (false , true ) => 1 ,
1399+ // The combination of `null` and `bool` is not possible as they're both
1400+ // either `null` or `bool`, before or after server-10, respectively.
1401+ // TODO(server-10): remove the preceding comment
1402+ _ => 0 ,
1403+ };
1404+ }
1405+
1406+ /// Comparator that puts channels with more weekly traffic first.
1407+ ///
1408+ /// A channel with undefined weekly traffic (`null` ) is put after the channel
1409+ /// with a weekly traffic defined (even if it is zero).
1410+ ///
1411+ /// Weekly traffic is the average number of messages sent to the channel per
1412+ /// week, which is determined by [ZulipStream.streamWeeklyTraffic] .
1413+ @visibleForTesting
1414+ static int compareByWeeklyTraffic (ZulipStream a, ZulipStream b) {
1415+ return switch ((a.streamWeeklyTraffic, b.streamWeeklyTraffic)) {
1416+ (int a, int b) => - a.compareTo (b),
1417+ (int (), null ) => - 1 ,
1418+ (null , int ()) => 1 ,
1419+ _ => 0 ,
1420+ };
1421+ }
1422+
1423+ @override
1424+ Future <List <ChannelLinkAutocompleteResult >?> computeResults () async {
1425+ final unsorted = < ChannelLinkAutocompleteResult > [];
1426+ if (await filterCandidates (filter: _testChannel,
1427+ candidates: sortedChannels, results: unsorted)) {
1428+ return null ;
1429+ }
1430+
1431+ return bucketSort (unsorted,
1432+ (r) => r.rank, numBuckets: ChannelLinkAutocompleteQuery ._numResultRanks);
1433+ }
1434+
1435+ ChannelLinkAutocompleteResult ? _testChannel (ChannelLinkAutocompleteQuery query, ZulipStream channel) {
1436+ return query.testChannel (channel, store);
1437+ }
1438+
1439+ @override
1440+ void dispose () {
1441+ store.autocompleteViewManager.unregisterChannelLinkAutocomplete (this );
1442+ super .dispose ();
1443+ }
1444+ }
1445+
1446+ /// A #channel autocomplete query, used by [ChannelLinkAutocompleteView] .
1447+ class ChannelLinkAutocompleteQuery extends ComposeAutocompleteQuery {
1448+ ChannelLinkAutocompleteQuery (super .raw);
1449+
1450+ @override
1451+ ChannelLinkAutocompleteView initViewModel ({
1452+ required PerAccountStore store,
1453+ required ZulipLocalizations localizations,
1454+ required Narrow narrow,
1455+ }) {
1456+ return ChannelLinkAutocompleteView .init (store: store, query: this , narrow: narrow);
1457+ }
1458+
1459+ ChannelLinkAutocompleteResult ? testChannel (ZulipStream channel, PerAccountStore store) {
1460+ final cache = store.autocompleteViewManager.autocompleteDataCache;
1461+ final matchQuality = _matchName (
1462+ normalizedName: cache.normalizedNameForChannel (channel),
1463+ normalizedNameWords: cache.normalizedNameWordsForChannel (channel));
1464+ if (matchQuality == null ) return null ;
1465+ return ChannelLinkAutocompleteResult (
1466+ channelId: channel.streamId, rank: _rankResult (matchQuality));
1467+ }
1468+
1469+ /// A measure of a channel result's quality in the context of the query,
1470+ /// from 0 (best) to one less than [_numResultRanks] .
1471+ static int _rankResult (NameMatchQuality matchQuality) {
1472+ return switch (matchQuality) {
1473+ NameMatchQuality .exact => 0 ,
1474+ NameMatchQuality .totalPrefix => 1 ,
1475+ NameMatchQuality .wordPrefixes => 2 ,
1476+ };
1477+ }
1478+
1479+ /// The number of possible values returned by [_rankResult] .
1480+ static const _numResultRanks = 3 ;
1481+
1482+ @override
1483+ String toString () {
1484+ return '${objectRuntimeType (this , 'ChannelLinkAutocompleteQuery' )}($raw )' ;
1485+ }
1486+
1487+ @override
1488+ bool operator == (Object other) {
1489+ return other is ChannelLinkAutocompleteQuery && other.raw == raw;
1490+ }
1491+
1492+ @override
1493+ int get hashCode => Object .hash ('ChannelLinkAutocompleteQuery' , raw);
1494+ }
1495+
1496+ /// An autocomplete result for a #channel.
1497+ class ChannelLinkAutocompleteResult extends ComposeAutocompleteResult {
1498+ ChannelLinkAutocompleteResult ({required this .channelId, required this .rank});
1499+
1500+ final int channelId;
1501+
1502+ /// A measure of the result's quality in the context of the query.
1503+ ///
1504+ /// Used internally by [ChannelLinkAutocompleteView] for ranking the results.
1505+ // See also [ChannelLinkAutocompleteView._channelsByRelevance];
1506+ // results with equal [rank] will appear in the order they were put in
1507+ // by that method.
1508+ //
1509+ // Compare sort_streams in Zulip web:
1510+ // https://github.com/zulip/zulip/blob/a5d25826b/web/src/typeahead_helper.ts#L998-L1008
1511+ //
1512+ // Behavior we have that web doesn't and might like to follow:
1513+ // - A "word-prefixes" match quality on channel names:
1514+ // see [NameMatchQuality.wordPrefixes], which we rank on.
1515+ //
1516+ // Behavior web has that seems undesired, which we don't plan to follow:
1517+ // - A "word-boundary" match quality on channel names:
1518+ // special rank when the whole query appears contiguously
1519+ // right after a word-boundary character.
1520+ // Our [NameMatchQuality.wordPrefixes] seems smarter.
1521+ // - Ranking some case-sensitive matches differently from case-insensitive
1522+ // matches. Users will expect a lowercase query to be adequate.
1523+ final int rank;
1524+ }
0 commit comments