Skip to content

Commit a858ab3

Browse files
committed
autocomplete: Implement targeted topic autocomplete
Fixes: zulip#310
1 parent 29b866e commit a858ab3

7 files changed

+392
-6
lines changed

lib/model/autocomplete.dart

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
33

44
import '../api/model/events.dart';
55
import '../api/model/model.dart';
6+
import '../api/route/streams.dart';
67
import '../widgets/compose_box.dart';
78
import 'narrow.dart';
89
import 'store.dart';
@@ -43,6 +44,16 @@ extension ComposeContentAutocomplete on ComposeContentController {
4344
}
4445
}
4546

47+
extension ComposeTopicAutocomplete on ComposeTopicController {
48+
AutocompleteIntent<TopicAutocompleteQuery>? autocompleteIntent() {
49+
if (!selection.isValid || !selection.isNormalized) return null;
50+
return AutocompleteIntent(
51+
syntaxStart: 0,
52+
query: TopicAutocompleteQuery(value.text),
53+
textEditingValue: value);
54+
}
55+
}
56+
4657
final RegExp mentionAutocompleteMarkerRegex = (() {
4758
// What's likely to come before an @-mention: the start of the string,
4859
// whitespace, or punctuation. Letters are unlikely; in that case an email
@@ -112,6 +123,7 @@ class AutocompleteIntent<Q extends AutocompleteQuery> {
112123
/// On reassemble, call [reassemble].
113124
class AutocompleteViewManager {
114125
final Set<MentionAutocompleteView> _mentionAutocompleteViews = {};
126+
final Set<TopicAutocompleteView> _topicAutocompleteViews = {};
115127

116128
AutocompleteDataCache autocompleteDataCache = AutocompleteDataCache();
117129

@@ -125,6 +137,16 @@ class AutocompleteViewManager {
125137
assert(removed);
126138
}
127139

140+
void registerTopicAutocomplete(TopicAutocompleteView view) {
141+
final added = _topicAutocompleteViews.add(view);
142+
assert(added);
143+
}
144+
145+
void unregisterTopicAutocomplete(TopicAutocompleteView view) {
146+
final removed = _topicAutocompleteViews.remove(view);
147+
assert(removed);
148+
}
149+
128150
void handleRealmUserRemoveEvent(RealmUserRemoveEvent event) {
129151
autocompleteDataCache.invalidateUser(event.userId);
130152
}
@@ -367,6 +389,7 @@ class MentionAutocompleteQuery extends AutocompleteQuery {
367389

368390
class AutocompleteDataCache {
369391
final Map<int, List<String>> _nameWordsByUser = {};
392+
final Map<String, List<String>> _wordsOfTopics = {};
370393

371394
List<String> nameWordsForUser(User user) {
372395
return _nameWordsByUser[user.userId] ??= user.fullName.toLowerCase().split(' ');
@@ -375,6 +398,14 @@ class AutocompleteDataCache {
375398
void invalidateUser(int userId) {
376399
_nameWordsByUser.remove(userId);
377400
}
401+
402+
List<String> wordsOfTopic(Topic topic) {
403+
return _wordsOfTopics[topic.value] ??= topic.value.toLowerCase().split(' ');
404+
}
405+
406+
void invalidateTopic(String value) {
407+
_wordsOfTopics.remove(value);
408+
}
378409
}
379410

380411
class AutocompleteResult {}
@@ -390,3 +421,76 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult {
390421
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
391422

392423
// TODO(#234): // class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
424+
425+
class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, TopicAutocompleteResult, Topic> {
426+
final int streamId;
427+
Iterable<Topic> _topics = [];
428+
bool _isFetching = false;
429+
430+
TopicAutocompleteView.init({
431+
required super.store,
432+
required this.streamId,
433+
}) {
434+
store.autocompleteViewManager.registerTopicAutocomplete(this);
435+
fetch();
436+
}
437+
438+
/// Fetches topics of the current stream narrow, expected to fetch
439+
/// only once per lifecycle.
440+
///
441+
/// Starts fetching once the stream narrow is active, then when results
442+
/// are fetched it notifies `autocompleteViewManager` to refresh UI
443+
/// showing the newly fetched topics.
444+
Future<void> fetch() async {
445+
if (_isFetching) return;
446+
_isFetching = true;
447+
final result = await getStreamTopics(store.connection, streamId: streamId);
448+
_topics = result.topics;
449+
_isFetching = false;
450+
if (_query != null) _startSearch(_query!);
451+
}
452+
453+
@override
454+
Iterable<Topic> getSortedItemsToTest(TopicAutocompleteQuery query) => _topics;
455+
456+
@override
457+
TopicAutocompleteResult? testItem(TopicAutocompleteQuery query, Topic item) {
458+
if (query.testTopic(item, store.autocompleteViewManager.autocompleteDataCache)) {
459+
return TopicAutocompleteResult(topic: item);
460+
}
461+
return null;
462+
}
463+
464+
@override
465+
void dispose() {
466+
store.autocompleteViewManager.unregisterTopicAutocomplete(this);
467+
super.dispose();
468+
}
469+
}
470+
471+
class TopicAutocompleteQuery extends AutocompleteQuery {
472+
TopicAutocompleteQuery(super.raw);
473+
474+
bool testTopic(Topic topic, AutocompleteDataCache cache) {
475+
return _testContainsQueryWords(cache.wordsOfTopic(topic));
476+
}
477+
478+
@override
479+
String toString() {
480+
return '${objectRuntimeType(this, 'TopicAutocompleteQuery')}(raw: $raw)';
481+
}
482+
483+
@override
484+
bool operator ==(Object other) {
485+
return other is TopicAutocompleteQuery && other.raw == raw;
486+
}
487+
488+
@override
489+
int get hashCode => Object.hash('TopicAutocompleteQuery', raw);
490+
}
491+
492+
class TopicAutocompleteResult extends AutocompleteResult {
493+
final Topic topic;
494+
495+
TopicAutocompleteResult({required this.topic});
496+
}

lib/widgets/autocomplete.dart

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:flutter/material.dart';
22

33
import '../api/model/model.dart';
4+
import '../api/route/streams.dart';
45
import 'content.dart';
56
import 'store.dart';
67
import '../model/autocomplete.dart';
@@ -71,6 +72,36 @@ class ComposeAutocomplete extends AutocompleteField<MentionAutocompleteQuery, Me
7172
});
7273
}
7374

75+
class TopicAutocomplete extends AutocompleteField<TopicAutocompleteQuery, TopicAutocompleteResult, Topic> {
76+
TopicAutocomplete({
77+
super.key,
78+
required int streamId,
79+
required ComposeTopicController controller,
80+
required super.focusNode,
81+
required FocusNode contentFocusNode,
82+
required super.fieldViewBuilder,
83+
}) : super(
84+
controller: controller,
85+
getAutocompleteIntent: () => controller.autocompleteIntent(),
86+
viewModelBuilder: (context) {
87+
final store = PerAccountStoreWidget.of(context);
88+
return TopicAutocompleteView.init(store: store, streamId: streamId);
89+
},
90+
itemBuilder: (context, index, option) => InkWell(
91+
onTap: () {
92+
final intent = controller.autocompleteIntent();
93+
if (intent == null) return;
94+
final label = option.topic.value;
95+
controller.value = intent.textEditingValue.replaced(TextRange(
96+
start: intent.syntaxStart,
97+
end: intent.textEditingValue.text.length), label);
98+
contentFocusNode.requestFocus();
99+
},
100+
child: Padding(
101+
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
102+
child: Text(option.topic.value))));
103+
}
104+
74105
class AutocompleteField<Q extends AutocompleteQuery, R extends AutocompleteResult, T> extends StatefulWidget {
75106
const AutocompleteField({
76107
super.key,

lib/widgets/compose_box.dart

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,38 @@ class _StreamContentInputState extends State<_StreamContentInput> {
372372
}
373373
}
374374

375+
class _TopicInput extends StatelessWidget {
376+
const _TopicInput({
377+
required this.streamId,
378+
required this.controller,
379+
required this.focusNode,
380+
required this.contentFocusNode});
381+
382+
final int streamId;
383+
final ComposeTopicController controller;
384+
final FocusNode focusNode;
385+
final FocusNode contentFocusNode;
386+
387+
@override
388+
Widget build(BuildContext context) {
389+
final zulipLocalizations = ZulipLocalizations.of(context);
390+
ColorScheme colorScheme = Theme.of(context).colorScheme;
391+
392+
return TopicAutocomplete(
393+
streamId: streamId,
394+
controller: controller,
395+
focusNode: focusNode,
396+
contentFocusNode: contentFocusNode,
397+
fieldViewBuilder: (context) => TextField(
398+
controller: controller,
399+
focusNode: focusNode,
400+
textInputAction: TextInputAction.next,
401+
style: TextStyle(color: colorScheme.onSurface),
402+
decoration: InputDecoration(hintText: zulipLocalizations.composeBoxTopicHintText),
403+
));
404+
}
405+
}
406+
375407
class _FixedDestinationContentInput extends StatelessWidget {
376408
const _FixedDestinationContentInput({
377409
required this.narrow,
@@ -872,6 +904,9 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
872904
@override FocusNode get contentFocusNode => _contentFocusNode;
873905
final _contentFocusNode = FocusNode();
874906

907+
FocusNode get topicFocusNode => _topicFocusNode;
908+
final _topicFocusNode = FocusNode();
909+
875910
@override
876911
void dispose() {
877912
_topicController.dispose();
@@ -882,16 +917,14 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
882917

883918
@override
884919
Widget build(BuildContext context) {
885-
final colorScheme = Theme.of(context).colorScheme;
886-
final zulipLocalizations = ZulipLocalizations.of(context);
887-
888920
return _ComposeBoxLayout(
889921
contentController: _contentController,
890922
contentFocusNode: _contentFocusNode,
891-
topicInput: TextField(
923+
topicInput: _TopicInput(
924+
streamId: widget.narrow.streamId,
892925
controller: _topicController,
893-
style: TextStyle(color: colorScheme.onSurface),
894-
decoration: InputDecoration(hintText: zulipLocalizations.composeBoxTopicHintText),
926+
focusNode: topicFocusNode,
927+
contentFocusNode: _contentFocusNode,
895928
),
896929
contentInput: _StreamContentInput(
897930
narrow: widget.narrow,

test/example_data.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:zulip/api/model/events.dart';
44
import 'package:zulip/api/model/initial_snapshot.dart';
55
import 'package:zulip/api/model/model.dart';
66
import 'package:zulip/api/route/realm.dart';
7+
import 'package:zulip/api/route/streams.dart';
78
import 'package:zulip/model/narrow.dart';
89
import 'package:zulip/model/store.dart';
910

@@ -180,6 +181,14 @@ ZulipStream stream({
180181
}
181182
const _stream = stream;
182183

184+
Topic topic({
185+
int? maxId,
186+
String? value,
187+
}) {
188+
maxId ??= 123;
189+
return Topic(maxId: maxId, value: value ?? 'Test Topic #$maxId');
190+
}
191+
183192
/// Construct an example subscription from a stream.
184193
///
185194
/// We only allow overrides of values specific to the [Subscription], all

test/model/autocomplete_checks.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,25 @@ extension ComposeContentControllerChecks on Subject<ComposeContentController> {
66
Subject<AutocompleteIntent<MentionAutocompleteQuery>?> get autocompleteIntent => has((c) => c.autocompleteIntent(), 'autocompleteIntent');
77
}
88

9+
extension ComposeTopicControllerChecks on Subject<ComposeTopicController> {
10+
Subject<AutocompleteIntent<TopicAutocompleteQuery>?> get autocompleteIntent => has((c) => c.autocompleteIntent(), 'autocompleteIntent');
11+
}
12+
913
extension AutocompleteIntentChecks on Subject<AutocompleteIntent<MentionAutocompleteQuery>> {
1014
Subject<int> get syntaxStart => has((i) => i.syntaxStart, 'syntaxStart');
1115
Subject<MentionAutocompleteQuery> get query => has((i) => i.query, 'query');
1216
}
1317

18+
extension TopicAutocompleteIntentChecks on Subject<AutocompleteIntent<TopicAutocompleteQuery>> {
19+
Subject<int> get syntaxStart => has((i) => i.syntaxStart, 'syntaxStart');
20+
Subject<TopicAutocompleteQuery> get query => has((i) => i.query, 'query');
21+
}
22+
1423
extension UserMentionAutocompleteResultChecks on Subject<UserMentionAutocompleteResult> {
1524
Subject<int> get userId => has((r) => r.userId, 'userId');
1625
}
26+
27+
extension TopicAutocompleteResultChecks on Subject<TopicAutocompleteResult> {
28+
Subject<int> get maxId => has((r) => r.topic.maxId, 'maxId');
29+
Subject<String> get name => has((r) => r.topic.value, 'name');
30+
}

0 commit comments

Comments
 (0)