Skip to content

Commit 3061353

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 147c702 commit 3061353

File tree

5 files changed

+799
-0
lines changed

5 files changed

+799
-0
lines changed

lib/model/autocomplete.dart

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
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;
67

78
import '../api/model/events.dart';
89
import '../api/model/model.dart';
910
import '../api/route/channels.dart';
11+
import '../basic.dart';
1012
import '../generated/l10n/zulip_localizations.dart';
1113
import '../widgets/compose_box.dart';
1214
import 'algorithms.dart';
15+
import 'channel.dart';
1316
import 'compose.dart';
1417
import 'emoji.dart';
1518
import '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+
}

lib/model/store.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,11 @@ class PerAccountStore extends PerAccountStoreBase with
827827
case ChannelEvent():
828828
assert(debugLog("server event: stream/${event.op}"));
829829
_channels.handleChannelEvent(event);
830+
if (event is ChannelDeleteEvent) {
831+
autocompleteViewManager.handleChannelDeleteEvent(event);
832+
} else if (event is ChannelUpdateEvent) {
833+
autocompleteViewManager.handleChannelUpdateEvent(event);
834+
}
830835
notifyListeners();
831836

832837
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(

0 commit comments

Comments
 (0)