Skip to content

Commit 26f0b49

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 99de04f commit 26f0b49

File tree

2 files changed

+222
-0
lines changed

2 files changed

+222
-0
lines changed

lib/model/autocomplete.dart

Lines changed: 219 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';
@@ -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+
}

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)