Skip to content

Commit 3de621c

Browse files
committed
autocomplete: Add view-model ChannelLinkAutocompleteView
As of this commit, it's not yet possible in the app to initiate a channel link autocomplete interaction. So in the widgets code that would consume the results of such an interaction, we just throw for now, leaving that to be implemented in a later commit.
1 parent 43bea01 commit 3de621c

File tree

5 files changed

+883
-0
lines changed

5 files changed

+883
-0
lines changed

lib/model/autocomplete.dart

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'dart:math';
22

3+
import 'package:collection/collection.dart';
34
import 'package:flutter/foundation.dart';
45
import 'package:flutter/services.dart';
56
import 'package:unorm_dart/unorm_dart.dart' as unorm;
@@ -10,6 +11,7 @@ import '../api/route/channels.dart';
1011
import '../generated/l10n/zulip_localizations.dart';
1112
import '../widgets/compose_box.dart';
1213
import 'algorithms.dart';
14+
import 'channel.dart';
1315
import 'compose.dart';
1416
import 'emoji.dart';
1517
import '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+
}

lib/model/store.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,6 +830,11 @@ class PerAccountStore extends PerAccountStoreBase with
830830
_messages.handleChannelDeleteEvent(event);
831831
}
832832
_channels.handleChannelEvent(event);
833+
if (event is ChannelDeleteEvent) {
834+
autocompleteViewManager.handleChannelDeleteEvent(event);
835+
} else if (event is ChannelUpdateEvent) {
836+
autocompleteViewManager.handleChannelUpdateEvent(event);
837+
}
833838
notifyListeners();
834839

835840
case SubscriptionEvent():

lib/widgets/autocomplete.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,8 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
226226
// TODO(#1805) language-appropriate space character; check active keyboard?
227227
// (maybe handle centrally in `controller`)
228228
replacementString = '${userGroupMention(userGroup.name, silent: query.silent)} ';
229+
case ChannelLinkAutocompleteResult():
230+
throw UnimplementedError(); // TODO(#124)
229231
}
230232

231233
controller.value = intent.textEditingValue.replaced(
@@ -243,6 +245,7 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
243245
final child = switch (option) {
244246
MentionAutocompleteResult() => MentionAutocompleteItem(
245247
option: option, narrow: narrow),
248+
ChannelLinkAutocompleteResult() => throw UnimplementedError(), // TODO(#124)
246249
EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option),
247250
};
248251
return InkWell(

test/model/autocomplete_checks.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,7 @@ extension UserGroupMentionAutocompleteResultChecks on Subject<UserGroupMentionAu
3333
extension TopicAutocompleteResultChecks on Subject<TopicAutocompleteResult> {
3434
Subject<TopicName> get topic => has((r) => r.topic, 'topic');
3535
}
36+
37+
extension ChannelLinkAutocompleteResultChecks on Subject<ChannelLinkAutocompleteResult> {
38+
Subject<int> get channelId => has((r) => r.channelId, 'channelId');
39+
}

0 commit comments

Comments
 (0)