Skip to content

Commit a33ea7b

Browse files
committed
autocomplete: Implement targeted topic autocomplete
Fixes: #310
1 parent 42f6cff commit a33ea7b

9 files changed

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

+100
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';
@@ -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
@@ -150,6 +161,12 @@ class AutocompleteViewManager {
150161
autocompleteDataCache.invalidateUser(event.userId);
151162
}
152163

164+
void handleTopicsFetchCompleted() {
165+
for (final view in _getViewsOfType<TopicAutocompleteView>()) {
166+
view.reassemble();
167+
}
168+
}
169+
153170
/// Called when the app is reassembled during debugging, e.g. for hot reload.
154171
///
155172
/// Calls [AutocompleteView.reassemble] for all that are registered.
@@ -418,6 +435,7 @@ class MentionAutocompleteQuery extends AutocompleteQuery {
418435

419436
class AutocompleteDataCache {
420437
final Map<int, List<String>> _nameWordsByUser = {};
438+
final Map<int, List<String>> _nameWordsByTopic = {};
421439

422440
List<String> nameWordsForUser(User user) {
423441
return _nameWordsByUser[user.userId] ??= user.fullName.toLowerCase().split(' ');
@@ -426,6 +444,14 @@ class AutocompleteDataCache {
426444
void invalidateUser(int userId) {
427445
_nameWordsByUser.remove(userId);
428446
}
447+
448+
List<String> nameWordsForTopic(Topic topic) {
449+
return _nameWordsByTopic[topic.maxId] ??= topic.name.toLowerCase().split(' ');
450+
}
451+
452+
void invalidateTopic(int topicMaxId) {
453+
_nameWordsByTopic.remove(topicMaxId);
454+
}
429455
}
430456

431457
class AutocompleteResult {}
@@ -441,3 +467,77 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult {
441467
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
442468

443469
// TODO(#234): // class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
470+
471+
class TopicAutocompleteDataProvider extends AutocompleteDataProvider<Topic, TopicAutocompleteQuery, TopicAutocompleteResult> {
472+
final PerAccountStore store;
473+
final int streamId;
474+
Iterable<Topic>? topics;
475+
bool isFetching = false;
476+
477+
TopicAutocompleteDataProvider({required this.store, required this.streamId});
478+
479+
/// Fetches topics of the current stream narrow, expected to fetch
480+
/// only once per lifecycle.
481+
///
482+
/// Starts fetching once the stream narrow is active, then when results
483+
/// are fetched it notifies `autocompleteViewManager` to refresh UI
484+
/// showing the newly fetched topics.
485+
Future<void> fetch() async {
486+
if (topics != null && !isFetching) return;
487+
isFetching = true;
488+
final result = await getStreamTopics(store.connection, streamId: streamId);
489+
topics = result.topics;
490+
store.autocompleteViewManager.handleTopicsFetchCompleted();
491+
isFetching = false;
492+
}
493+
494+
@override
495+
Iterable<Topic> getDataForQuery(TopicAutocompleteQuery query) {
496+
return topics ?? [];
497+
}
498+
499+
@override
500+
TopicAutocompleteResult? testItem(TopicAutocompleteQuery query, Topic item) {
501+
if (query.testTopic(item, store.autocompleteViewManager.autocompleteDataCache)) {
502+
return TopicAutocompleteResult(topic: item);
503+
}
504+
return null;
505+
}
506+
}
507+
508+
class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, TopicAutocompleteResult> {
509+
TopicAutocompleteView.init({
510+
required super.store,
511+
required int streamId,
512+
}) : super(dataProvider: TopicAutocompleteDataProvider(
513+
store: store,
514+
streamId: streamId
515+
)..fetch());
516+
}
517+
518+
class TopicAutocompleteQuery extends AutocompleteQuery {
519+
TopicAutocompleteQuery(super.raw);
520+
521+
bool testTopic(Topic topic, AutocompleteDataCache cache) {
522+
return _testContainsQueryWords(cache.nameWordsForTopic(topic));
523+
}
524+
525+
@override
526+
String toString() {
527+
return '${objectRuntimeType(this, 'TopicAutocompleteQuery')}(raw: $raw)';
528+
}
529+
530+
@override
531+
bool operator ==(Object other) {
532+
return other is TopicAutocompleteQuery && other.raw == raw;
533+
}
534+
535+
@override
536+
int get hashCode => Object.hash('TopicAutocompleteQuery', raw);
537+
}
538+
539+
class TopicAutocompleteResult extends AutocompleteResult {
540+
final Topic topic;
541+
542+
TopicAutocompleteResult({required this.topic});
543+
}

lib/widgets/autocomplete.dart

+30
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,36 @@ class ComposeAutocomplete extends AutocompleteField<MentionAutocompleteQuery, Me
7070
});
7171
}
7272

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

lib/widgets/compose_box.dart

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

379+
class _StreamTopicInput extends StatelessWidget {
380+
const _StreamTopicInput({
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 AutocompleteInputWrapper(
397+
child: TopicAutocomplete(
398+
streamId: streamId,
399+
controller: controller,
400+
focusNode: focusNode,
401+
contentFocusNode: contentFocusNode,
402+
fieldViewBuilder: (context) => TextField(
403+
controller: controller,
404+
focusNode: focusNode,
405+
textInputAction: TextInputAction.next,
406+
style: TextStyle(color: colorScheme.onSurface),
407+
decoration: InputDecoration.collapsed(hintText: zulipLocalizations.composeBoxTopicHintText),
408+
)));
409+
}
410+
}
411+
379412
class _FixedDestinationContentInput extends StatelessWidget {
380413
const _FixedDestinationContentInput({
381414
required this.narrow,
@@ -876,6 +909,9 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
876909
@override FocusNode get contentFocusNode => _contentFocusNode;
877910
final _contentFocusNode = FocusNode();
878911

912+
FocusNode get topicFocusNode => _topicFocusNode;
913+
final _topicFocusNode = FocusNode();
914+
879915
@override
880916
void dispose() {
881917
_topicController.dispose();
@@ -886,16 +922,14 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
886922

887923
@override
888924
Widget build(BuildContext context) {
889-
final colorScheme = Theme.of(context).colorScheme;
890-
final zulipLocalizations = ZulipLocalizations.of(context);
891-
892925
return _ComposeBoxLayout(
893926
contentController: _contentController,
894927
contentFocusNode: _contentFocusNode,
895-
topicInput: TextField(
928+
topicInput: _StreamTopicInput(
929+
streamId: widget.narrow.streamId,
896930
controller: _topicController,
897-
style: TextStyle(color: colorScheme.onSurface),
898-
decoration: InputDecoration(hintText: zulipLocalizations.composeBoxTopicHintText),
931+
focusNode: topicFocusNode,
932+
contentFocusNode: _contentFocusNode,
899933
),
900934
contentInput: _StreamContentInput(
901935
narrow: widget.narrow,

test/example_data.dart

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

@@ -180,6 +181,13 @@ ZulipStream stream({
180181
}
181182
const _stream = stream;
182183

184+
Topic topic({
185+
int? maxId,
186+
String? name,
187+
}) => Topic(
188+
maxId: maxId ?? 123,
189+
name: name ?? 'Test Topic #$maxId');
190+
183191
/// Construct an example subscription from a stream.
184192
///
185193
/// We only allow overrides of values specific to the [Subscription], all

test/model/autocomplete_checks.dart

+14
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,25 @@ 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');
1216
}
1317

18+
extension TopicAutocompleteIntentChecks on Subject<AutocompleteIntent<TopicAutocompleteQuery>> {
19+
Subject<int> get syntaxStart => has((i) => i.syntaxStart, 'syntaxStart');
20+
Subject<TopicAutocompleteQuery> get query => has((i) => i.query, 'query');
21+
}
22+
1423
extension UserMentionAutocompleteResultChecks on Subject<UserMentionAutocompleteResult> {
1524
Subject<int> get userId => has((r) => r.userId, 'userId');
1625
}
26+
27+
extension TopicAutocompleteResultChecks on Subject<TopicAutocompleteResult> {
28+
Subject<int> get maxId => has((r) => r.topic.maxId, 'maxId');
29+
Subject<String> get name => has((r) => r.topic.name, 'name');
30+
}

0 commit comments

Comments
 (0)