Skip to content

Commit b34f5c4

Browse files
committed
autocomplete: Implement targeted topic autocomplete
Fixes: #310
1 parent beaf4e2 commit b34f5c4

7 files changed

+416
-39
lines changed

lib/model/autocomplete.dart

+106
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<QueryT 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
}
@@ -468,6 +490,7 @@ class MentionAutocompleteQuery extends AutocompleteQuery {
468490

469491
class AutocompleteDataCache {
470492
final Map<int, List<String>> _nameWordsByUser = {};
493+
final Map<String, List<String>> _wordsOfTopics = {};
471494

472495
List<String> nameWordsForUser(User user) {
473496
return _nameWordsByUser[user.userId] ??= user.fullName.toLowerCase().split(' ');
@@ -476,6 +499,14 @@ class AutocompleteDataCache {
476499
void invalidateUser(int userId) {
477500
_nameWordsByUser.remove(userId);
478501
}
502+
503+
List<String> wordsOfTopic(Topic topic) {
504+
return _wordsOfTopics[topic.value] ??= topic.value.toLowerCase().split(' ');
505+
}
506+
507+
void invalidateTopic(String value) {
508+
_wordsOfTopics.remove(value);
509+
}
479510
}
480511

481512
class AutocompleteResult {}
@@ -491,3 +522,78 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult {
491522
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
492523

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

lib/widgets/autocomplete.dart

+31
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<QueryT extends AutocompleteQuery, ResultT extends AutocompleteResult, CandidateT> extends StatefulWidget {
75106
const AutocompleteField({
76107
super.key,

lib/widgets/compose_box.dart

+39-6
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

+6
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,11 @@ ZulipStream stream({
193194
}
194195
const _stream = stream;
195196

197+
Topic topic({int? maxId, String? value}) {
198+
maxId ??= 123;
199+
return Topic(maxId: maxId, value: value ?? 'Test Topic #$maxId');
200+
}
201+
196202
/// Construct an example subscription from a stream.
197203
///
198204
/// We only allow overrides of values specific to the [Subscription], all

test/model/autocomplete_checks.dart

+9
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ 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<AutocompleteQuery>> {
1014
Subject<int> get syntaxStart => has((i) => i.syntaxStart, 'syntaxStart');
1115
Subject<AutocompleteQuery> get query => has((i) => i.query, 'query');
@@ -14,3 +18,8 @@ extension AutocompleteIntentChecks on Subject<AutocompleteIntent<AutocompleteQue
1418
extension UserMentionAutocompleteResultChecks on Subject<UserMentionAutocompleteResult> {
1519
Subject<int> get userId => has((r) => r.userId, 'userId');
1620
}
21+
22+
extension TopicAutocompleteResultChecks on Subject<TopicAutocompleteResult> {
23+
Subject<int> get maxId => has((r) => r.topic.maxId, 'maxId');
24+
Subject<String> get name => has((r) => r.topic.value, 'name');
25+
}

0 commit comments

Comments
 (0)