Skip to content

Commit b6c64d4

Browse files
committed
autocomplete: Implement targeted topic autocomplete
Fixes: #310
1 parent 552404e commit b6c64d4

9 files changed

+493
-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

+118
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
@@ -157,6 +169,12 @@ class AutocompleteViewManager {
157169
autocompleteDataCache.invalidateUser(event.userId);
158170
}
159171

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

393411
class AutocompleteDataCache {
394412
final Map<int, List<String>> _nameWordsByUser = {};
413+
final Map<int, List<String>> _nameWordsByTopic = {};
395414

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

405432
class AutocompleteResult {}
@@ -415,3 +442,94 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult {
415442
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
416443

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

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/example_data.dart

+11
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

@@ -175,6 +176,16 @@ ZulipStream stream({
175176
}
176177
const _stream = stream;
177178

179+
Topic topic({
180+
int? maxId,
181+
String? name,
182+
}) {
183+
return Topic(
184+
maxId: maxId ?? 123,
185+
name: name ?? 'Test Topic #$maxId'
186+
);
187+
}
188+
178189
/// Construct an example subscription from a stream.
179190
///
180191
/// We only allow overrides of values specific to the [Subscription], all

0 commit comments

Comments
 (0)