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 }
@@ -1021,6 +1034,20 @@ class AutocompleteDataCache {
10211034 ?? = normalizedNameForUserGroup (userGroup).split (' ' );
10221035 }
10231036
1037+ final Map <int , String > _normalizedNamesByChannel = {};
1038+
1039+ String normalizedNameForChannel (ZulipStream channel) {
1040+ return _normalizedNamesByChannel[channel.streamId]
1041+ ?? = AutocompleteQuery .lowercaseAndStripDiacritics (channel.name);
1042+ }
1043+
1044+ final Map <int , List <String >> _normalizedNameWordsByChannel = {};
1045+
1046+ List <String > normalizedNameWordsForChannel (ZulipStream channel) {
1047+ return _normalizedNameWordsByChannel[channel.streamId]
1048+ ?? normalizedNameForChannel (channel).split (' ' );
1049+ }
1050+
10241051 void invalidateUser (int userId) {
10251052 _normalizedNamesByUser.remove (userId);
10261053 _normalizedNameWordsByUser.remove (userId);
@@ -1238,3 +1265,195 @@ class TopicAutocompleteResult extends AutocompleteResult {
12381265
12391266 TopicAutocompleteResult ({required this .topic});
12401267}
1268+
1269+ class ChannelLinkAutocompleteView extends AutocompleteView <ChannelLinkAutocompleteQuery , ChannelLinkAutocompleteResult > {
1270+ ChannelLinkAutocompleteView ._({
1271+ required super .store,
1272+ required super .query,
1273+ required this .narrow,
1274+ required this .sortedChannels,
1275+ });
1276+
1277+ factory ChannelLinkAutocompleteView .init ({
1278+ required PerAccountStore store,
1279+ required Narrow narrow,
1280+ required ChannelLinkAutocompleteQuery query,
1281+ }) {
1282+ final view = ChannelLinkAutocompleteView ._(
1283+ store: store,
1284+ query: query,
1285+ narrow: narrow,
1286+ sortedChannels: _channelsByRelevance (store: store, narrow: narrow),
1287+ );
1288+ store.autocompleteViewManager.registerChannelLinkAutocomplete (view);
1289+ return view;
1290+ }
1291+
1292+ final Narrow narrow;
1293+ final List <ZulipStream > sortedChannels;
1294+
1295+ static List <ZulipStream > _channelsByRelevance ({
1296+ required PerAccountStore store,
1297+ required Narrow narrow,
1298+ }) {
1299+ return store.streams.values.sorted (_comparator (store: store, narrow: narrow));
1300+ }
1301+
1302+ static Comparator <ZulipStream > _comparator ({
1303+ required PerAccountStore store,
1304+ required Narrow narrow,
1305+ }) {
1306+ // See also [ChannelLinkAutocompleteQuery._rankChannelResult];
1307+ // that ranking takes precedence over this.
1308+
1309+ final channelId = switch (narrow) {
1310+ ChannelNarrow (: var streamId) || TopicNarrow (: var streamId) => streamId,
1311+ DmNarrow () => null ,
1312+ CombinedFeedNarrow ()
1313+ || MentionsNarrow ()
1314+ || StarredMessagesNarrow ()
1315+ || KeywordSearchNarrow () => () {
1316+ assert (false , 'No compose box, thus no autocomplete is available in ${narrow .runtimeType }.' );
1317+ return null ;
1318+ }(),
1319+ };
1320+ return (a, b) => _compareByRelevance (a, b, currentChannelId: channelId, store: store);
1321+ }
1322+
1323+ // Check `typeahead_helper.compare_by_activity` in Zulip web;
1324+ // We follow the behavior of Web but with a small difference in that Web
1325+ // compares "recent activity" only for subscribed channels, but we do it
1326+ // for unsubscribed ones too.
1327+ // https://github.com/zulip/zulip/blob/c3fdee6ed/web/src/typeahead_helper.ts#L972-L988
1328+ static int _compareByRelevance (ZulipStream a, ZulipStream b, {
1329+ required int ? currentChannelId,
1330+ required PerAccountStore store,
1331+ }) {
1332+ final comparators = [
1333+ if (currentChannelId != null )
1334+ () => _compareByBeingCurrent (a, b, currentChannelId: currentChannelId),
1335+ () => _compareByBeingSubscribed (a, b, store: store),
1336+ () => _compareByRecentActivity (a, b),
1337+ () => _compareByWeeklyTraffic (a, b, store: store),
1338+ () => ChannelStore .compareChannelsByName (a, b),
1339+ ];
1340+ return comparators.map ((compare) => compare ())
1341+ .firstWhere ((result) => result != 0 , orElse: () => 0 );
1342+ }
1343+
1344+ static int _compareByBeingCurrent (ZulipStream a, ZulipStream b, {
1345+ required int currentChannelId,
1346+ }) {
1347+ return switch ((a.streamId, b.streamId)) {
1348+ (int id, _) when id == currentChannelId => - 1 ,
1349+ (_, int id) when id == currentChannelId => 1 ,
1350+ _ => 0 ,
1351+ };
1352+ }
1353+
1354+ static int _compareByBeingSubscribed (ZulipStream a, ZulipStream b, {
1355+ required PerAccountStore store,
1356+ }) {
1357+ return switch ((store.subscriptions[a.streamId], store.subscriptions[b.streamId])) {
1358+ (Subscription (), null ) => - 1 ,
1359+ (null , Subscription ()) => 1 ,
1360+ (Subscription (isMuted: false ), Subscription (isMuted: true )) => - 1 ,
1361+ (Subscription (isMuted: true ), Subscription (isMuted: false )) => 1 ,
1362+ (Subscription (pinToTop: true ), Subscription (pinToTop: false )) => - 1 ,
1363+ (Subscription (pinToTop: false ), Subscription (pinToTop: true )) => 1 ,
1364+ _ => 0 ,
1365+ };
1366+ }
1367+
1368+ static int _compareByRecentActivity (ZulipStream a, ZulipStream b) {
1369+ return switch ((a.isRecentlyActive, b.isRecentlyActive)) {
1370+ (true , false ) => - 1 ,
1371+ (false , true ) => 1 ,
1372+ // The combination of `null` and `bool` is not possible as they're both
1373+ // either `null` or `bool`, before or after server-10, respectively.
1374+ // TODO(server-10): remove the preceding comment
1375+ _ => 0 ,
1376+ };
1377+ }
1378+
1379+ static int _compareByWeeklyTraffic (ZulipStream a, ZulipStream b, {
1380+ required PerAccountStore store,
1381+ }) {
1382+ // TODO(server-8)
1383+ // Remove getting fallback `streamWeeklyTraffic` from [Subscription]
1384+ // objects as it will always be present in [ZulipStream] objects anyways.
1385+ final trafficA = a.streamWeeklyTraffic ?? store.subscriptions[a.streamId]? .streamWeeklyTraffic;
1386+ final trafficB = b.streamWeeklyTraffic ?? store.subscriptions[b.streamId]? .streamWeeklyTraffic;
1387+ return switch ((trafficA, trafficB)) {
1388+ (int a, int b) => - a.compareTo (b),
1389+ (int (), null ) => - 1 ,
1390+ (null , int ()) => 1 ,
1391+ _ => 0 ,
1392+ };
1393+ }
1394+
1395+ @override
1396+ Future <List <ChannelLinkAutocompleteResult >?> computeResults () async {
1397+ final unsorted = < ChannelLinkAutocompleteResult > [];
1398+ if (await filterCandidates (filter: _testChannel,
1399+ candidates: sortedChannels, results: unsorted)) {
1400+ return null ;
1401+ }
1402+
1403+ return bucketSort (unsorted,
1404+ (r) => r.rank, numBuckets: ChannelLinkAutocompleteQuery ._numResultRanks);
1405+ }
1406+
1407+ ChannelLinkAutocompleteResult ? _testChannel (ChannelLinkAutocompleteQuery query, ZulipStream channel) {
1408+ return query.testChannel (channel, store);
1409+ }
1410+
1411+ @override
1412+ void dispose () {
1413+ store.autocompleteViewManager.unregisterChannelLinkAutocomplete (this );
1414+ super .dispose ();
1415+ }
1416+ }
1417+
1418+ class ChannelLinkAutocompleteQuery extends ComposeAutocompleteQuery {
1419+ ChannelLinkAutocompleteQuery (super .raw);
1420+
1421+ @override
1422+ ComposeAutocompleteView initViewModel ({
1423+ required PerAccountStore store,
1424+ required ZulipLocalizations localizations,
1425+ required Narrow narrow,
1426+ }) {
1427+ return ChannelLinkAutocompleteView .init (store: store, query: this , narrow: narrow);
1428+ }
1429+
1430+ ChannelLinkAutocompleteResult ? testChannel (ZulipStream channel, PerAccountStore store) {
1431+ final cache = store.autocompleteViewManager.autocompleteDataCache;
1432+ final matchQuality = _matchName (
1433+ normalizedName: cache.normalizedNameForChannel (channel),
1434+ normalizedNameWords: cache.normalizedNameWordsForChannel (channel));
1435+ if (matchQuality == null ) return null ;
1436+ return ChannelLinkAutocompleteResult (
1437+ channelId: channel.streamId, rank: _rankResult (matchQuality));
1438+ }
1439+
1440+ /// A measure of a channel result's quality in the context of the query,
1441+ /// from 0 (best) to one less than [_numResultRanks] .
1442+ static int _rankResult (NameMatchQuality matchQuality) {
1443+ return switch (matchQuality) {
1444+ NameMatchQuality .exact => 0 ,
1445+ NameMatchQuality .totalPrefix => 1 ,
1446+ NameMatchQuality .wordPrefixes => 2 ,
1447+ };
1448+ }
1449+
1450+ /// The number of possible values returned by [_rankResult] .
1451+ static const _numResultRanks = 3 ;
1452+ }
1453+
1454+ class ChannelLinkAutocompleteResult extends ComposeAutocompleteResult {
1455+ ChannelLinkAutocompleteResult ({required this .channelId, required this .rank});
1456+
1457+ final int channelId;
1458+ final int rank;
1459+ }
0 commit comments