Skip to content

Commit 046ceab

Browse files
Khader-1gnprice
authored andcommitted
autocomplete: Add autocomplete for topic to send to
The filtering logic (in [TopicAutocompleteQuery.testTopic]) differs from zulip-mobile but agrees with web. Details at: zulip#627 (comment) Fixes: zulip#310
1 parent 50a7609 commit 046ceab

9 files changed

+418
-39
lines changed

lib/model/autocomplete.dart

+100-1
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,15 @@ extension ComposeContentAutocomplete on ComposeContentController {
4344
}
4445
}
4546

47+
extension ComposeTopicAutocomplete on ComposeTopicController {
48+
AutocompleteIntent<TopicAutocompleteQuery>? autocompleteIntent() {
49+
return AutocompleteIntent(
50+
syntaxStart: 0,
51+
query: TopicAutocompleteQuery(value.text),
52+
textEditingValue: value);
53+
}
54+
}
55+
4656
final RegExp mentionAutocompleteMarkerRegex = (() {
4757
// What's likely to come before an @-mention: the start of the string,
4858
// whitespace, or punctuation. Letters are unlikely; in that case an email
@@ -112,6 +122,7 @@ class AutocompleteIntent<QueryT extends AutocompleteQuery> {
112122
/// On reassemble, call [reassemble].
113123
class AutocompleteViewManager {
114124
final Set<MentionAutocompleteView> _mentionAutocompleteViews = {};
125+
final Set<TopicAutocompleteView> _topicAutocompleteViews = {};
115126

116127
AutocompleteDataCache autocompleteDataCache = AutocompleteDataCache();
117128

@@ -125,6 +136,16 @@ class AutocompleteViewManager {
125136
assert(removed);
126137
}
127138

139+
void registerTopicAutocomplete(TopicAutocompleteView view) {
140+
final added = _topicAutocompleteViews.add(view);
141+
assert(added);
142+
}
143+
144+
void unregisterTopicAutocomplete(TopicAutocompleteView view) {
145+
final removed = _topicAutocompleteViews.remove(view);
146+
assert(removed);
147+
}
148+
128149
void handleRealmUserRemoveEvent(RealmUserRemoveEvent event) {
129150
autocompleteDataCache.invalidateUser(event.userId);
130151
}
@@ -135,12 +156,15 @@ class AutocompleteViewManager {
135156

136157
/// Called when the app is reassembled during debugging, e.g. for hot reload.
137158
///
138-
/// Calls [MentionAutocompleteView.reassemble] for all that are registered.
159+
/// Calls [AutocompleteView.reassemble] for all that are registered.
139160
///
140161
void reassemble() {
141162
for (final view in _mentionAutocompleteViews) {
142163
view.reassemble();
143164
}
165+
for (final view in _topicAutocompleteViews) {
166+
view.reassemble();
167+
}
144168
}
145169

146170
// No `dispose` method, because there's nothing for it to do.
@@ -531,3 +555,78 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult {
531555
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
532556

533557
// TODO(#234): // class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
558+
559+
class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, TopicAutocompleteResult, String> {
560+
TopicAutocompleteView._({required super.store, required this.streamId});
561+
562+
factory TopicAutocompleteView.init({required PerAccountStore store, required int streamId}) {
563+
final view = TopicAutocompleteView._(store: store, streamId: streamId);
564+
store.autocompleteViewManager.registerTopicAutocomplete(view);
565+
view._fetch();
566+
return view;
567+
}
568+
569+
final int streamId;
570+
Iterable<String> _topics = [];
571+
bool _isFetching = false;
572+
573+
/// Fetches topics of the current stream narrow, expected to fetch
574+
/// only once per lifecycle.
575+
///
576+
/// Starts fetching once the stream narrow is active, then when results
577+
/// are fetched it restarts search to refresh UI showing the newly
578+
/// fetched topics.
579+
Future<void> _fetch() async {
580+
assert(!_isFetching);
581+
_isFetching = true;
582+
final result = await getStreamTopics(store.connection, streamId: streamId);
583+
_topics = result.topics.map((e) => e.name);
584+
_isFetching = false;
585+
if (_query != null) _startSearch(_query!);
586+
}
587+
588+
@override
589+
Iterable<String> getSortedItemsToTest(TopicAutocompleteQuery query) => _topics;
590+
591+
@override
592+
TopicAutocompleteResult? testItem(TopicAutocompleteQuery query, String item) {
593+
if (query.testTopic(item)) {
594+
return TopicAutocompleteResult(topic: item);
595+
}
596+
return null;
597+
}
598+
599+
@override
600+
void dispose() {
601+
store.autocompleteViewManager.unregisterTopicAutocomplete(this);
602+
super.dispose();
603+
}
604+
}
605+
606+
class TopicAutocompleteQuery extends AutocompleteQuery {
607+
TopicAutocompleteQuery(super.raw);
608+
609+
bool testTopic(String topic) {
610+
// TODO(#881): Sort by match relevance, like web does.
611+
return topic != raw && topic.toLowerCase().contains(raw.toLowerCase());
612+
}
613+
614+
@override
615+
String toString() {
616+
return '${objectRuntimeType(this, 'TopicAutocompleteQuery')}(raw: $raw)';
617+
}
618+
619+
@override
620+
bool operator ==(Object other) {
621+
return other is TopicAutocompleteQuery && other.raw == raw;
622+
}
623+
624+
@override
625+
int get hashCode => Object.hash('TopicAutocompleteQuery', raw);
626+
}
627+
628+
class TopicAutocompleteResult extends AutocompleteResult {
629+
final String topic;
630+
631+
TopicAutocompleteResult({required this.topic});
632+
}

lib/widgets/autocomplete.dart

+51
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,54 @@ class ComposeAutocomplete extends AutocompleteField<MentionAutocompleteQuery, Me
217217
])));
218218
}
219219
}
220+
221+
class TopicAutocomplete extends AutocompleteField<TopicAutocompleteQuery, TopicAutocompleteResult, String> {
222+
const TopicAutocomplete({
223+
super.key,
224+
required this.streamId,
225+
required ComposeTopicController super.controller,
226+
required super.focusNode,
227+
required this.contentFocusNode,
228+
required super.fieldViewBuilder,
229+
});
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+
247+
void _onTapOption(BuildContext context, TopicAutocompleteResult option) {
248+
final intent = autocompleteIntent();
249+
if (intent == null) return;
250+
final replacementString = option.topic;
251+
controller.value = intent.textEditingValue.replaced(
252+
TextRange(
253+
start: intent.syntaxStart,
254+
end: intent.textEditingValue.text.length),
255+
replacementString,
256+
);
257+
contentFocusNode.requestFocus();
258+
}
259+
260+
@override
261+
Widget buildItem(BuildContext context, int index, TopicAutocompleteResult option) {
262+
return InkWell(
263+
onTap: () {
264+
_onTapOption(context, option);
265+
},
266+
child: Padding(
267+
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
268+
child: Text(option.topic)));
269+
}
270+
}

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,
@@ -956,6 +988,9 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
956988
@override FocusNode get contentFocusNode => _contentFocusNode;
957989
final _contentFocusNode = FocusNode();
958990

991+
FocusNode get topicFocusNode => _topicFocusNode;
992+
final _topicFocusNode = FocusNode();
993+
959994
@override
960995
void dispose() {
961996
_topicController.dispose();
@@ -966,16 +1001,14 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
9661001

9671002
@override
9681003
Widget build(BuildContext context) {
969-
final colorScheme = Theme.of(context).colorScheme;
970-
final zulipLocalizations = ZulipLocalizations.of(context);
971-
9721004
return _ComposeBoxLayout(
9731005
contentController: _contentController,
9741006
contentFocusNode: _contentFocusNode,
975-
topicInput: TextField(
1007+
topicInput: _TopicInput(
1008+
streamId: widget.narrow.streamId,
9761009
controller: _topicController,
977-
style: TextStyle(color: colorScheme.onSurface),
978-
decoration: InputDecoration(hintText: zulipLocalizations.composeBoxTopicHintText),
1010+
focusNode: topicFocusNode,
1011+
contentFocusNode: _contentFocusNode,
9791012
),
9801013
contentInput: _StreamContentInput(
9811014
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

@@ -204,6 +205,11 @@ ZulipStream stream({
204205
}
205206
const _stream = stream;
206207

208+
GetStreamTopicsEntry getStreamTopicsEntry({int? maxId, String? name}) {
209+
maxId ??= 123;
210+
return GetStreamTopicsEntry(maxId: maxId, name: name ?? 'Test Topic #$maxId');
211+
}
212+
207213
/// Construct an example subscription from a stream.
208214
///
209215
/// 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)