Skip to content

Commit fa64db7

Browse files
committed
autocomplete: Implement targeted topic autocomplete
Fixes: #310
1 parent 005ca86 commit fa64db7

File tree

6 files changed

+283
-6
lines changed

6 files changed

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

+126
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ 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';
8+
import 'narrow.dart';
79
import 'store.dart';
810

911
extension ComposeContentAutocomplete on ComposeContentController {
@@ -42,6 +44,16 @@ extension ComposeContentAutocomplete on ComposeContentController {
4244
}
4345
}
4446

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+
4557
final RegExp mentionAutocompleteMarkerRegex = (() {
4658
// What's likely to come before an @-mention: the start of the string,
4759
// whitespace, or punctuation. Letters are unlikely; in that case an email
@@ -158,6 +170,12 @@ class AutocompleteViewManager {
158170
autocompleteDataCache.invalidateUser(event.userId);
159171
}
160172

173+
void handleTopicsFetchCompleted() {
174+
for (final view in getTypedViews<TopicAutocompleteView>()) {
175+
view.reassemble();
176+
}
177+
}
178+
161179
/// Called when the app is reassembled during debugging, e.g. for hot reload.
162180
///
163181
/// Calls [AutocompleteView.reassemble] for all that are registered.
@@ -393,6 +411,7 @@ class MentionAutocompleteQuery extends AutocompleteQuery {
393411

394412
class AutocompleteDataCache {
395413
final Map<int, List<String>> _nameWordsByUser = {};
414+
final Map<int, List<String>> _nameWordsByTopic = {};
396415

397416
List<String> nameWordsForUser(User user) {
398417
return _nameWordsByUser[user.userId] ??= user.fullName.toLowerCase().split(' ');
@@ -401,6 +420,14 @@ class AutocompleteDataCache {
401420
void invalidateUser(int userId) {
402421
_nameWordsByUser.remove(userId);
403422
}
423+
424+
List<String> nameWordsForTopic(Topic topic) {
425+
return _nameWordsByTopic[topic.maxId] ??= topic.name.toLowerCase().split(' ');
426+
}
427+
428+
void invalidateTopic(int topicMaxId) {
429+
_nameWordsByTopic.remove(topicMaxId);
430+
}
404431
}
405432

406433
class AutocompleteResult {}
@@ -416,3 +443,102 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult {
416443
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
417444

418445
// TODO(#234): // class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
446+
447+
class TopicAutocompleteDataProvider extends AutocompleteDataProvider<Topic, TopicAutocompleteQuery, TopicAutocompleteResult> {
448+
final PerAccountStore store;
449+
final StreamNarrow narrow;
450+
451+
TopicAutocompleteDataProvider({required this.store, required this.topics, required this.narrow});
452+
453+
Iterable<Topic>? topics;
454+
455+
Future<void> fetch() async {
456+
if (topics == null) {
457+
final result = await getStreamTopics(store.connection, streamId: narrow.streamId);
458+
topics = result.topics;
459+
store.autocompleteViewManager.handleTopicsFetchCompleted();
460+
}
461+
}
462+
463+
@override
464+
Iterable<Topic> getDataForQuery(TopicAutocompleteQuery query) {
465+
return topics ?? [];
466+
}
467+
468+
@override
469+
TopicAutocompleteResult? testItem(TopicAutocompleteQuery query, Topic item) {
470+
if (query.testTopic(item, store.autocompleteViewManager.autocompleteDataCache)) {
471+
return TopicAutocompleteResult(topic: item);
472+
}
473+
return null;
474+
}
475+
}
476+
477+
class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, TopicAutocompleteResult> {
478+
TopicAutocompleteView._({
479+
required Iterable<Topic>? topics,
480+
required super.store,
481+
required StreamNarrow narrow,
482+
}) : super(dataProvider: TopicAutocompleteDataProvider(
483+
store: store,
484+
topics: topics,
485+
narrow: narrow
486+
)..fetch());
487+
488+
TopicAutocompleteView.init({
489+
required PerAccountStore store,
490+
required StreamNarrow narrow,
491+
}) : this._(topics: [], store: store, narrow: narrow);
492+
}
493+
494+
class TopicAutocompleteQuery extends AutocompleteQuery {
495+
TopicAutocompleteQuery(super.raw)
496+
: _lowercaseWords = raw.toLowerCase().split(' ');
497+
498+
final List<String> _lowercaseWords;
499+
500+
bool testTopic(Topic topic, AutocompleteDataCache cache) {
501+
return _testName(topic, cache);
502+
}
503+
504+
bool _testName(Topic topic, AutocompleteDataCache cache) {
505+
// TODO(#237) test with diacritics stripped, where appropriate
506+
507+
final List<String> nameWords = cache.nameWordsForTopic(topic);
508+
509+
int nameWordsIndex = 0;
510+
int queryWordsIndex = 0;
511+
while (true) {
512+
if (queryWordsIndex == _lowercaseWords.length) {
513+
return true;
514+
}
515+
if (nameWordsIndex == nameWords.length) {
516+
return false;
517+
}
518+
519+
if (nameWords[nameWordsIndex].startsWith(_lowercaseWords[queryWordsIndex])) {
520+
queryWordsIndex++;
521+
}
522+
nameWordsIndex++;
523+
}
524+
}
525+
526+
@override
527+
String toString() {
528+
return '${objectRuntimeType(this, 'TopicAutocompleteQuery')}(raw: $raw)';
529+
}
530+
531+
@override
532+
bool operator ==(Object other) {
533+
return other is TopicAutocompleteQuery && other.raw == raw;
534+
}
535+
536+
@override
537+
int get hashCode => Object.hash('TopicAutocompleteQuery', raw);
538+
}
539+
540+
class TopicAutocompleteResult extends AutocompleteResult {
541+
final Topic topic;
542+
543+
TopicAutocompleteResult({required this.topic});
544+
}

lib/widgets/autocomplete.dart

+36
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,42 @@ class ComposeAutocomplete extends AutocompleteField<MentionAutocompleteQuery, Me
7171
},);
7272
}
7373

74+
class TopicAutocomplete extends AutocompleteField<TopicAutocompleteQuery, TopicAutocompleteResult> {
75+
TopicAutocomplete({
76+
super.key,
77+
required StreamNarrow narrow,
78+
required ComposeTopicController controller,
79+
required super.focusNode,
80+
required FocusNode contentFocusNode,
81+
required super.fieldViewBuilder,
82+
}) : super(
83+
controller: controller,
84+
getAutocompleteIntent: () => controller.autocompleteIntent(),
85+
viewModelBuilder: (context) {
86+
final store = PerAccountStoreWidget.of(context);
87+
return TopicAutocompleteView.init(store: store, narrow: narrow);
88+
},
89+
itemBuilder: (context, index, option) {
90+
return InkWell(
91+
onTap: () {
92+
final intent = controller.autocompleteIntent();
93+
if (intent == null) return;
94+
final label = option.topic.name;
95+
controller.value = intent.textEditingValue.replaced(
96+
TextRange(
97+
start: intent.syntaxStart,
98+
end: intent.textEditingValue.selection.end),
99+
label,
100+
);
101+
contentFocusNode.requestFocus();
102+
},
103+
child: Padding(
104+
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
105+
child: Text(option.topic.name)
106+
));
107+
},);
108+
}
109+
74110
class AutocompleteField<Q extends AutocompleteQuery, R extends AutocompleteResult> extends StatefulWidget {
75111
const AutocompleteField({
76112
super.key,

lib/widgets/compose_box.dart

+44-6
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,43 @@ class _StreamContentInputState extends State<_StreamContentInput> {
383383
}
384384
}
385385

386+
class _StreamTopicInput extends StatelessWidget {
387+
const _StreamTopicInput({
388+
required this.narrow,
389+
required this.controller,
390+
required this.focusNode,
391+
required this.contentFocusNode,
392+
});
393+
394+
final StreamNarrow narrow;
395+
final ComposeTopicController controller;
396+
final FocusNode focusNode;
397+
final FocusNode contentFocusNode;
398+
399+
400+
@override
401+
Widget build(BuildContext context) {
402+
final zulipLocalizations = ZulipLocalizations.of(context);
403+
ColorScheme colorScheme = Theme.of(context).colorScheme;
404+
405+
return AutocompleteInputWrapper(
406+
child: TopicAutocomplete(
407+
narrow: narrow,
408+
controller: controller,
409+
focusNode: focusNode,
410+
contentFocusNode: contentFocusNode,
411+
fieldViewBuilder: (context) {
412+
return TextField(
413+
controller: controller,
414+
focusNode: focusNode,
415+
textInputAction: TextInputAction.next,
416+
style: TextStyle(color: colorScheme.onSurface),
417+
decoration: InputDecoration.collapsed(hintText: zulipLocalizations.composeBoxTopicHintText),
418+
);
419+
},));
420+
}
421+
}
422+
386423
class _FixedDestinationContentInput extends StatelessWidget {
387424
const _FixedDestinationContentInput({
388425
required this.narrow,
@@ -882,6 +919,9 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
882919
@override FocusNode get contentFocusNode => _contentFocusNode;
883920
final _contentFocusNode = FocusNode();
884921

922+
FocusNode get topicFocusNode => _topicFocusNode;
923+
final _topicFocusNode = FocusNode();
924+
885925
@override
886926
void dispose() {
887927
_topicController.dispose();
@@ -892,16 +932,14 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
892932

893933
@override
894934
Widget build(BuildContext context) {
895-
final colorScheme = Theme.of(context).colorScheme;
896-
final zulipLocalizations = ZulipLocalizations.of(context);
897-
898935
return _ComposeBoxLayout(
899936
contentController: _contentController,
900937
contentFocusNode: _contentFocusNode,
901-
topicInput: TextField(
938+
topicInput: _StreamTopicInput(
939+
narrow: widget.narrow,
902940
controller: _topicController,
903-
style: TextStyle(color: colorScheme.onSurface),
904-
decoration: InputDecoration(hintText: zulipLocalizations.composeBoxTopicHintText),
941+
focusNode: topicFocusNode,
942+
contentFocusNode: _contentFocusNode,
905943
),
906944
contentInput: _StreamContentInput(
907945
narrow: widget.narrow,

test/model/autocomplete_checks.dart

+4
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<MentionAutocompleteQuery>> {
1014
Subject<int> get syntaxStart => has((i) => i.syntaxStart, 'syntaxStart');
1115
Subject<MentionAutocompleteQuery> get query => has((i) => i.query, 'query');

0 commit comments

Comments
 (0)