Skip to content

Commit 223e3fe

Browse files
committed
autocomplete: Implement targeted topic autocomplete
Fixes: #310
1 parent e2fa1eb commit 223e3fe

9 files changed

+408
-39
lines changed

lib/model/autocomplete.dart

+97
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/channels.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
}
@@ -492,3 +514,78 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult {
492514
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
493515

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

lib/widgets/autocomplete.dart

+41
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,44 @@ class ComposeAutocomplete extends AutocompleteField<MentionAutocompleteQuery, Me
217217
Text(label)])));
218218
}
219219
}
220+
221+
class TopicAutocomplete extends AutocompleteField<TopicAutocompleteQuery, TopicAutocompleteResult, String> {
222+
const TopicAutocomplete({
223+
super.key,
224+
required this.streamId,
225+
required ComposeTopicController controller,
226+
required super.focusNode,
227+
required this.contentFocusNode,
228+
required super.fieldViewBuilder,
229+
}) : super(controller: controller);
230+
231+
final FocusNode contentFocusNode;
232+
233+
final int streamId;
234+
235+
@override
236+
ComposeTopicController get controller => super.controller as ComposeTopicController;
237+
238+
@override
239+
AutocompleteIntent<TopicAutocompleteQuery>? autocompleteIntent() => controller.autocompleteIntent();
240+
241+
@override
242+
TopicAutocompleteView initViewModel(BuildContext context) {
243+
final store = PerAccountStoreWidget.of(context);
244+
return TopicAutocompleteView.init(store: store, streamId: streamId);
245+
}
246+
@override
247+
Widget buildItem(BuildContext context, int index, TopicAutocompleteResult option) => InkWell(
248+
onTap: () {
249+
final intent = autocompleteIntent();
250+
if (intent == null) return;
251+
final label = option.topic;
252+
controller.value = intent.textEditingValue.replaced(TextRange(
253+
start: intent.syntaxStart,
254+
end: intent.textEditingValue.text.length), label);
255+
contentFocusNode.requestFocus();
256+
},
257+
child: Padding(
258+
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
259+
child: Text(option.topic)));
260+
}

lib/widgets/compose_box.dart

+39-6
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,38 @@ class _StreamContentInputState extends State<_StreamContentInput> {
376376
}
377377
}
378378

379+
class _TopicInput extends StatelessWidget {
380+
const _TopicInput({
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 TopicAutocomplete(
397+
streamId: streamId,
398+
controller: controller,
399+
focusNode: focusNode,
400+
contentFocusNode: contentFocusNode,
401+
fieldViewBuilder: (context) => TextField(
402+
controller: controller,
403+
focusNode: focusNode,
404+
textInputAction: TextInputAction.next,
405+
style: TextStyle(color: colorScheme.onSurface),
406+
decoration: InputDecoration(hintText: zulipLocalizations.composeBoxTopicHintText),
407+
));
408+
}
409+
}
410+
379411
class _FixedDestinationContentInput extends StatelessWidget {
380412
const _FixedDestinationContentInput({
381413
required this.narrow,
@@ -942,6 +974,9 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
942974
@override FocusNode get contentFocusNode => _contentFocusNode;
943975
final _contentFocusNode = FocusNode();
944976

977+
FocusNode get topicFocusNode => _topicFocusNode;
978+
final _topicFocusNode = FocusNode();
979+
945980
@override
946981
void dispose() {
947982
_topicController.dispose();
@@ -952,16 +987,14 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
952987

953988
@override
954989
Widget build(BuildContext context) {
955-
final colorScheme = Theme.of(context).colorScheme;
956-
final zulipLocalizations = ZulipLocalizations.of(context);
957-
958990
return _ComposeBoxLayout(
959991
contentController: _contentController,
960992
contentFocusNode: _contentFocusNode,
961-
topicInput: TextField(
993+
topicInput: _TopicInput(
994+
streamId: widget.narrow.streamId,
962995
controller: _topicController,
963-
style: TextStyle(color: colorScheme.onSurface),
964-
decoration: InputDecoration(hintText: zulipLocalizations.composeBoxTopicHintText),
996+
focusNode: topicFocusNode,
997+
contentFocusNode: _contentFocusNode,
965998
),
966999
contentInput: _StreamContentInput(
9671000
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/channels.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+
GetStreamTopicsEntry getStreamTopicsEntry({int? maxId, String? name}) {
198+
maxId ??= 123;
199+
return GetStreamTopicsEntry(maxId: maxId, name: name ?? '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

+8
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,7 @@ 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<String> get topic => has((r) => r.topic, 'topic');
24+
}

0 commit comments

Comments
 (0)