Skip to content

Commit 30631c5

Browse files
committed
autocomplete: Implement targeted topic autocomplete
Fixes: zulip#310
1 parent 520630d commit 30631c5

7 files changed

+401
-6
lines changed

lib/model/autocomplete.dart

Lines changed: 114 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
}
@@ -133,6 +155,12 @@ class AutocompleteViewManager {
133155
autocompleteDataCache.invalidateUser(event.userId);
134156
}
135157

158+
void handleTopicsFetchCompleted() {
159+
for (final view in _topicAutocompleteViews) {
160+
view.reassemble();
161+
}
162+
}
163+
136164
/// Called when the app is reassembled during debugging, e.g. for hot reload.
137165
///
138166
/// Calls [MentionAutocompleteView.reassemble] for all that are registered.
@@ -370,6 +398,7 @@ class MentionAutocompleteQuery extends AutocompleteQuery {
370398

371399
class AutocompleteDataCache {
372400
final Map<int, List<String>> _nameWordsByUser = {};
401+
final Map<String, List<String>> _nameWordsByTopic = {};
373402

374403
List<String> nameWordsForUser(User user) {
375404
return _nameWordsByUser[user.userId] ??= user.fullName.toLowerCase().split(' ');
@@ -378,6 +407,14 @@ class AutocompleteDataCache {
378407
void invalidateUser(int userId) {
379408
_nameWordsByUser.remove(userId);
380409
}
410+
411+
List<String> nameWordsForTopic(Topic topic) {
412+
return _nameWordsByTopic[topic.value] ??= topic.value.toLowerCase().split(' ');
413+
}
414+
415+
void invalidateTopic(String value) {
416+
_nameWordsByTopic.remove(value);
417+
}
381418
}
382419

383420
class AutocompleteResult {}
@@ -393,3 +430,80 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult {
393430
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
394431

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

lib/widgets/autocomplete.dart

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,36 @@ class ComposeAutocomplete extends AutocompleteField<MentionAutocompleteQuery, Me
7070
});
7171
}
7272

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

lib/widgets/compose_box.dart

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,39 @@ class _StreamContentInputState extends State<_StreamContentInput> {
376376
}
377377
}
378378

379+
class _StreamTopicInput extends StatelessWidget {
380+
const _StreamTopicInput({
381+
required this.streamId,
382+
required this.controller,
383+
required this.focusNode,
384+
required this.contentFocusNode});
385+
386+
final int streamId;
387+
final ComposeTopicController controller;
388+
final FocusNode focusNode;
389+
final FocusNode contentFocusNode;
390+
391+
@override
392+
Widget build(BuildContext context) {
393+
final zulipLocalizations = ZulipLocalizations.of(context);
394+
ColorScheme colorScheme = Theme.of(context).colorScheme;
395+
396+
return AutocompleteInputWrapper(
397+
child: TopicAutocomplete(
398+
streamId: streamId,
399+
controller: controller,
400+
focusNode: focusNode,
401+
contentFocusNode: contentFocusNode,
402+
fieldViewBuilder: (context) => TextField(
403+
controller: controller,
404+
focusNode: focusNode,
405+
textInputAction: TextInputAction.next,
406+
style: TextStyle(color: colorScheme.onSurface),
407+
decoration: InputDecoration.collapsed(hintText: zulipLocalizations.composeBoxTopicHintText),
408+
)));
409+
}
410+
}
411+
379412
class _FixedDestinationContentInput extends StatelessWidget {
380413
const _FixedDestinationContentInput({
381414
required this.narrow,
@@ -876,6 +909,9 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
876909
@override FocusNode get contentFocusNode => _contentFocusNode;
877910
final _contentFocusNode = FocusNode();
878911

912+
FocusNode get topicFocusNode => _topicFocusNode;
913+
final _topicFocusNode = FocusNode();
914+
879915
@override
880916
void dispose() {
881917
_topicController.dispose();
@@ -886,16 +922,14 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
886922

887923
@override
888924
Widget build(BuildContext context) {
889-
final colorScheme = Theme.of(context).colorScheme;
890-
final zulipLocalizations = ZulipLocalizations.of(context);
891-
892925
return _ComposeBoxLayout(
893926
contentController: _contentController,
894927
contentFocusNode: _contentFocusNode,
895-
topicInput: TextField(
928+
topicInput: _StreamTopicInput(
929+
streamId: widget.narrow.streamId,
896930
controller: _topicController,
897-
style: TextStyle(color: colorScheme.onSurface),
898-
decoration: InputDecoration(hintText: zulipLocalizations.composeBoxTopicHintText),
931+
focusNode: topicFocusNode,
932+
contentFocusNode: _contentFocusNode,
899933
),
900934
contentInput: _StreamContentInput(
901935
narrow: widget.narrow,

test/example_data.dart

Lines changed: 11 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,16 @@ ZulipStream stream({
180181
}
181182
const _stream = stream;
182183

184+
Topic topic({
185+
int? maxId,
186+
String? value,
187+
}) {
188+
maxId ??= 123;
189+
return Topic(
190+
maxId: maxId,
191+
value: value ?? 'Test Topic #$maxId');
192+
}
193+
183194
/// Construct an example subscription from a stream.
184195
///
185196
/// 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)