Skip to content

Commit 591b2e1

Browse files
committed
autocomplete: Implement targeted topic autocomplete
Fixes: #310
1 parent d15dec2 commit 591b2e1

7 files changed

+422
-39
lines changed

lib/model/autocomplete.dart

Lines changed: 101 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
}
@@ -464,6 +486,7 @@ class MentionAutocompleteQuery extends AutocompleteQuery {
464486

465487
class AutocompleteDataCache {
466488
final Map<int, List<String>> _nameWordsByUser = {};
489+
final Map<String, List<String>> _wordsOfTopics = {};
467490

468491
List<String> nameWordsForUser(User user) {
469492
return _nameWordsByUser[user.userId] ??= user.fullName.toLowerCase().split(' ');
@@ -472,6 +495,14 @@ class AutocompleteDataCache {
472495
void invalidateUser(int userId) {
473496
_nameWordsByUser.remove(userId);
474497
}
498+
499+
List<String> wordsOfTopic(Topic topic) {
500+
return _wordsOfTopics[topic.value] ??= topic.value.toLowerCase().split(' ');
501+
}
502+
503+
void invalidateTopic(String value) {
504+
_wordsOfTopics.remove(value);
505+
}
475506
}
476507

477508
class AutocompleteResult {}
@@ -487,3 +518,73 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult {
487518
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
488519

489520
// TODO(#234): // class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
521+
522+
class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, TopicAutocompleteResult, Topic> {
523+
final int streamId;
524+
Iterable<Topic> _topics = [];
525+
bool _isFetching = false;
526+
527+
TopicAutocompleteView.init({required super.store, required this.streamId}) {
528+
store.autocompleteViewManager.registerTopicAutocomplete(this);
529+
fetch();
530+
}
531+
532+
/// Fetches topics of the current stream narrow, expected to fetch
533+
/// only once per lifecycle.
534+
///
535+
/// Starts fetching once the stream narrow is active, then when results
536+
/// are fetched it notifies `autocompleteViewManager` to refresh UI
537+
/// showing the newly fetched topics.
538+
Future<void> fetch() async {
539+
if (_isFetching) return;
540+
_isFetching = true;
541+
final result = await getStreamTopics(store.connection, streamId: streamId);
542+
_topics = result.topics;
543+
_isFetching = false;
544+
if (_query != null) _startSearch(_query!);
545+
}
546+
547+
@override
548+
Iterable<Topic> getSortedItemsToTest(TopicAutocompleteQuery query) => _topics;
549+
550+
@override
551+
TopicAutocompleteResult? testItem(TopicAutocompleteQuery query, Topic item) {
552+
if (query.testTopic(item, store.autocompleteViewManager.autocompleteDataCache)) {
553+
return TopicAutocompleteResult(topic: item);
554+
}
555+
return null;
556+
}
557+
558+
@override
559+
void dispose() {
560+
store.autocompleteViewManager.unregisterTopicAutocomplete(this);
561+
super.dispose();
562+
}
563+
}
564+
565+
class TopicAutocompleteQuery extends AutocompleteQuery {
566+
TopicAutocompleteQuery(super.raw);
567+
568+
bool testTopic(Topic topic, AutocompleteDataCache cache) {
569+
return _testContainsQueryWords(cache.wordsOfTopic(topic));
570+
}
571+
572+
@override
573+
String toString() {
574+
return '${objectRuntimeType(this, 'TopicAutocompleteQuery')}(raw: $raw)';
575+
}
576+
577+
@override
578+
bool operator ==(Object other) {
579+
return other is TopicAutocompleteQuery && other.raw == raw;
580+
}
581+
582+
@override
583+
int get hashCode => Object.hash('TopicAutocompleteQuery', raw);
584+
}
585+
586+
class TopicAutocompleteResult extends AutocompleteResult {
587+
final Topic topic;
588+
589+
TopicAutocompleteResult({required this.topic});
590+
}

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
@@ -375,6 +375,38 @@ class _StreamContentInputState extends State<_StreamContentInput> {
375375
}
376376
}
377377

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

972+
FocusNode get topicFocusNode => _topicFocusNode;
973+
final _topicFocusNode = FocusNode();
974+
940975
@override
941976
void dispose() {
942977
_topicController.dispose();
@@ -947,16 +982,14 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
947982

948983
@override
949984
Widget build(BuildContext context) {
950-
final colorScheme = Theme.of(context).colorScheme;
951-
final zulipLocalizations = ZulipLocalizations.of(context);
952-
953985
return _ComposeBoxLayout(
954986
contentController: _contentController,
955987
contentFocusNode: _contentFocusNode,
956-
topicInput: TextField(
988+
topicInput: _TopicInput(
989+
streamId: widget.narrow.streamId,
957990
controller: _topicController,
958-
style: TextStyle(color: colorScheme.onSurface),
959-
decoration: InputDecoration(hintText: zulipLocalizations.composeBoxTopicHintText),
991+
focusNode: topicFocusNode,
992+
contentFocusNode: _contentFocusNode,
960993
),
961994
contentInput: _StreamContentInput(
962995
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

@@ -193,6 +194,14 @@ ZulipStream stream({
193194
}
194195
const _stream = stream;
195196

197+
Topic topic({
198+
int? maxId,
199+
String? value,
200+
}) {
201+
maxId ??= 123;
202+
return Topic(maxId: maxId, value: value ?? 'Test Topic #$maxId');
203+
}
204+
196205
/// Construct an example subscription from a stream.
197206
///
198207
/// 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)