Skip to content

Commit 9b465c3

Browse files
committed
autocomplete: Implement targeted topic autocomplete
Fixes: 310
1 parent 033e3b7 commit 9b465c3

File tree

4 files changed

+296
-8
lines changed

4 files changed

+296
-8
lines changed

lib/api/route/streams.dart

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import 'package:json_annotation/json_annotation.dart';
2+
3+
import '../core.dart';
4+
part 'streams.g.dart';
5+
6+
/// https://zulip.com/api/get-stream-topics
7+
Future<GetTopicsResult> getStreamTopics(
8+
ApiConnection connection, {
9+
required int streamId,
10+
}) {
11+
return connection.get('getStreamTopics', GetTopicsResult.fromJson,
12+
'users/me/$streamId/topics', {});
13+
}
14+
15+
@JsonSerializable(fieldRename: FieldRename.snake)
16+
class GetTopicsResult {
17+
final List<Topic>? topics;
18+
19+
GetTopicsResult({
20+
required this.topics,
21+
});
22+
23+
factory GetTopicsResult.fromJson(Map<String, dynamic> json) =>
24+
_$GetTopicsResultFromJson(json);
25+
26+
Map<String, dynamic> toJson() => _$GetTopicsResultToJson(this);
27+
}
28+
29+
@JsonSerializable(fieldRename: FieldRename.snake)
30+
class Topic {
31+
final int maxId;
32+
final String name;
33+
34+
Topic({
35+
required this.maxId,
36+
required this.name,
37+
});
38+
39+
factory Topic.fromJson(Map<String, dynamic> json) => _$TopicFromJson(json);
40+
41+
Map<String, dynamic> toJson() => _$TopicToJson(this);
42+
}

lib/api/route/streams.g.dart

+31
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/model/autocomplete.dart

+145-2
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';
@@ -112,8 +113,10 @@ class AutocompleteIntent {
112113
/// On reassemble, call [reassemble].
113114
class AutocompleteViewManager {
114115
final Set<MentionAutocompleteView> _mentionAutocompleteViews = {};
116+
final Set<TopicAutocompleteView> _topicAutocompleteViews = {};
115117

116118
AutocompleteDataCache autocompleteDataCache = AutocompleteDataCache();
119+
TopicAutocompleteDataCache topicAutocompleteDataCache = TopicAutocompleteDataCache();
117120

118121
void registerMentionAutocomplete(MentionAutocompleteView view) {
119122
final added = _mentionAutocompleteViews.add(view);
@@ -145,6 +148,16 @@ class AutocompleteViewManager {
145148
autocompleteDataCache.invalidateUser(event.userId);
146149
}
147150

151+
void registerTopicAutocomplete(TopicAutocompleteView view) {
152+
final added = _topicAutocompleteViews.add(view);
153+
assert(added);
154+
}
155+
156+
void unregisterTopicAutocomplete(TopicAutocompleteView view) {
157+
final removed = _topicAutocompleteViews.remove(view);
158+
assert(removed);
159+
}
160+
148161
/// Called when the app is reassembled during debugging, e.g. for hot reload.
149162
///
150163
/// Calls [MentionAutocompleteView.reassemble] for all that are registered.
@@ -183,7 +196,7 @@ abstract class AutocompleteView<Q, R> extends ChangeNotifier {
183196
/// Called in particular when we get a [RealmUserEvent].
184197
void refreshStaleUserResults() {
185198
if (_query != null) {
186-
_startSearch(_query!);
199+
_startSearch(_query as Q);
187200
}
188201
}
189202

@@ -192,7 +205,7 @@ abstract class AutocompleteView<Q, R> extends ChangeNotifier {
192205
/// This will redo the search from scratch for the current query, if any.
193206
void reassemble() {
194207
if (_query != null) {
195-
_startSearch(_query!);
208+
_startSearch(_query as Q);
196209
}
197210
}
198211

@@ -363,3 +376,133 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult {
363376
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
364377

365378
// TODO(#234): // class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
379+
380+
/// A per-account manager for stream topics autocomplete interactions.
381+
class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, Topic> {
382+
TopicAutocompleteView._({
383+
required this.topics,
384+
required this.store,
385+
required this.narrow,
386+
});
387+
388+
List<Topic>? topics;
389+
final PerAccountStore store;
390+
final StreamNarrow narrow;
391+
392+
static Future<TopicAutocompleteView> init({
393+
required PerAccountStore store,
394+
required StreamNarrow narrow
395+
}) async {
396+
final view = TopicAutocompleteView._(
397+
store: store,
398+
narrow: narrow,
399+
topics: null,
400+
);
401+
store.autocompleteViewManager.registerTopicAutocomplete(view);
402+
return view;
403+
}
404+
405+
@override
406+
void dispose() {
407+
store.autocompleteViewManager.unregisterTopicAutocomplete(this);
408+
super.dispose();
409+
}
410+
411+
Future<void> fetch() async {
412+
final result = await getStreamTopics(store.connection, streamId: narrow.streamId);
413+
topics = result.topics;
414+
notifyListeners();
415+
}
416+
417+
@override
418+
Future<List<Topic>?> _computeResults(TopicAutocompleteQuery query) async {
419+
if (topics == null) return [];
420+
421+
final List<Topic> results = [];
422+
423+
final iterator = topics!.iterator;
424+
bool isDone = false;
425+
while (!isDone) {
426+
// CPU perf: End this task; enqueue a new one for resuming this work
427+
await Future(() {});
428+
429+
if (query != _query || !hasListeners) { // false if [dispose] has been called.
430+
return null;
431+
}
432+
433+
for (int i = 0; i < 1000; i++) {
434+
if (!iterator.moveNext()) { // Can throw ConcurrentModificationError
435+
isDone = true;
436+
break;
437+
}
438+
439+
final Topic topic = iterator.current;
440+
if (query.testTopic(topic, store.autocompleteViewManager.topicAutocompleteDataCache)) {
441+
results.add(topic);
442+
}
443+
}
444+
}
445+
return results; // TODO(#228) sort for most relevant first
446+
}
447+
}
448+
449+
class TopicAutocompleteQuery {
450+
TopicAutocompleteQuery(this.raw)
451+
: _lowercaseWords = raw.toLowerCase().split(' ');
452+
453+
final String raw;
454+
455+
456+
final List<String> _lowercaseWords;
457+
458+
bool testTopic(Topic topic, TopicAutocompleteDataCache cache) {
459+
return _testName(topic, cache);
460+
}
461+
462+
bool _testName(Topic topic, TopicAutocompleteDataCache cache) {
463+
// TODO(#237) test with diacritics stripped, where appropriate
464+
465+
final List<String> nameWords = cache.nameWordsForTopic(topic);
466+
467+
int nameWordsIndex = 0;
468+
int queryWordsIndex = 0;
469+
while (true) {
470+
if (queryWordsIndex == _lowercaseWords.length) {
471+
return true;
472+
}
473+
if (nameWordsIndex == nameWords.length) {
474+
return false;
475+
}
476+
477+
if (nameWords[nameWordsIndex].startsWith(_lowercaseWords[queryWordsIndex])) {
478+
queryWordsIndex++;
479+
}
480+
nameWordsIndex++;
481+
}
482+
}
483+
484+
@override
485+
String toString() {
486+
return '${objectRuntimeType(this, 'MentionAutocompleteQuery')}(raw: $raw)';
487+
}
488+
489+
@override
490+
bool operator ==(Object other) {
491+
return other is TopicAutocompleteQuery && other.raw == raw;
492+
}
493+
494+
@override
495+
int get hashCode => Object.hash('MentionAutocompleteQuery', raw);
496+
}
497+
498+
class TopicAutocompleteDataCache {
499+
final Map<int, List<String>> _nameWordsByTopic = {};
500+
501+
List<String> nameWordsForTopic(Topic topic) {
502+
return _nameWordsByTopic[topic.maxId] ??= topic.name.toLowerCase().split(' ');
503+
}
504+
505+
void invalidateTopic(int topicMaxId) {
506+
_nameWordsByTopic.remove(topicMaxId);
507+
}
508+
}

lib/widgets/compose_box.dart

+78-6
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import 'package:image_picker/image_picker.dart';
77

88
import '../api/model/model.dart';
99
import '../api/route/messages.dart';
10+
import '../api/route/streams.dart';
11+
import '../model/autocomplete.dart' as autocomplete;
1012
import '../model/compose.dart';
1113
import '../model/narrow.dart';
1214
import '../model/store.dart';
@@ -372,6 +374,79 @@ class _StreamContentInputState extends State<_StreamContentInput> {
372374
}
373375
}
374376

377+
/// The content input for _StreamComposeBox.
378+
class _StreamTopicInput extends StatefulWidget {
379+
const _StreamTopicInput({
380+
required this.narrow,
381+
required this.controller,
382+
required this.focusNode,
383+
});
384+
385+
final StreamNarrow narrow;
386+
final ComposeTopicController controller;
387+
final FocusNode focusNode;
388+
389+
@override
390+
State<_StreamTopicInput> createState() => _StreamTopicInputState();
391+
}
392+
393+
class _StreamTopicInputState extends State<_StreamTopicInput> with PerAccountStoreAwareStateMixin<_StreamTopicInput> {
394+
autocomplete.TopicAutocompleteView? model;
395+
396+
@override
397+
void dispose() {
398+
model?.dispose();
399+
super.dispose();
400+
}
401+
402+
403+
@override
404+
void onNewStore() {
405+
_initModel(PerAccountStoreWidget.of(context));
406+
}
407+
408+
void _initModel(PerAccountStore store)async {
409+
model = await autocomplete.TopicAutocompleteView.init(store: store, narrow: widget.narrow);
410+
model!.addListener(_modelChanged);
411+
model!.fetch();
412+
}
413+
414+
415+
void _modelChanged() {
416+
setState(() {
417+
// The actual state lives in the [StreamTopicsView] model.
418+
// This method was called because that just changed.
419+
});
420+
}
421+
422+
@override
423+
Widget build(BuildContext context) {
424+
final zulipLocalizations = ZulipLocalizations.of(context);
425+
final colorScheme = Theme.of(context).colorScheme;
426+
return Autocomplete<Topic>(
427+
optionsViewOpenDirection: OptionsViewOpenDirection.up,
428+
optionsBuilder: (textEditingValue) => model?.results ?? [],
429+
initialValue: widget.controller.value,
430+
displayStringForOption: (option) {
431+
return option.name;
432+
},
433+
onSelected: (option) => widget.controller.text = option.name,
434+
fieldViewBuilder: (context, textEditingController, focusNode, onFieldSubmitted) => TextField(
435+
controller: textEditingController,
436+
onChanged: (value) {
437+
widget.controller.text = value;
438+
model?.query = autocomplete.TopicAutocompleteQuery(value);
439+
},
440+
focusNode: focusNode,
441+
onSubmitted: (value) => onFieldSubmitted(),
442+
onEditingComplete: onFieldSubmitted,
443+
style: TextStyle(color: colorScheme.onSurface),
444+
decoration: InputDecoration(hintText: zulipLocalizations.composeBoxTopicHintText),
445+
),
446+
);
447+
}
448+
}
449+
375450
class _FixedDestinationContentInput extends StatelessWidget {
376451
const _FixedDestinationContentInput({
377452
required this.narrow,
@@ -881,16 +956,13 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
881956

882957
@override
883958
Widget build(BuildContext context) {
884-
final colorScheme = Theme.of(context).colorScheme;
885-
final zulipLocalizations = ZulipLocalizations.of(context);
886-
887959
return _ComposeBoxLayout(
888960
contentController: _contentController,
889961
contentFocusNode: _contentFocusNode,
890-
topicInput: TextField(
962+
topicInput: _StreamTopicInput(
963+
narrow: widget.narrow,
891964
controller: _topicController,
892-
style: TextStyle(color: colorScheme.onSurface),
893-
decoration: InputDecoration(hintText: zulipLocalizations.composeBoxTopicHintText),
965+
focusNode: _contentFocusNode,
894966
),
895967
contentInput: _StreamContentInput(
896968
narrow: widget.narrow,

0 commit comments

Comments
 (0)