Skip to content

Commit 4a34f33

Browse files
committed
autocomplete: Implement targeted topic autocomplete
Fixes: #310
1 parent e35da52 commit 4a34f33

9 files changed

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

+124
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
@@ -156,6 +167,12 @@ class AutocompleteViewManager {
156167
autocompleteDataCache.invalidateUser(event.userId);
157168
}
158169

170+
void handleTopicsFetchCompleted() {
171+
for (final view in _getViewsOfType<TopicAutocompleteView>()) {
172+
view.reassemble();
173+
}
174+
}
175+
159176
/// Called when the app is reassembled during debugging, e.g. for hot reload.
160177
///
161178
/// Calls [AutocompleteView.reassemble] for all that are registered.
@@ -431,6 +448,7 @@ class MentionAutocompleteQuery extends AutocompleteQuery {
431448

432449
class AutocompleteDataCache {
433450
final Map<int, List<String>> _nameWordsByUser = {};
451+
final Map<int, List<String>> _nameWordsByTopic = {};
434452

435453
List<String> nameWordsForUser(User user) {
436454
return _nameWordsByUser[user.userId] ??= user.fullName.toLowerCase().split(' ');
@@ -439,6 +457,14 @@ class AutocompleteDataCache {
439457
void invalidateUser(int userId) {
440458
_nameWordsByUser.remove(userId);
441459
}
460+
461+
List<String> nameWordsForTopic(Topic topic) {
462+
return _nameWordsByTopic[topic.maxId] ??= topic.name.toLowerCase().split(' ');
463+
}
464+
465+
void invalidateTopic(int topicMaxId) {
466+
_nameWordsByTopic.remove(topicMaxId);
467+
}
442468
}
443469

444470
class AutocompleteResult {}
@@ -454,3 +480,101 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult {
454480
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
455481

456482
// TODO(#234): // class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
483+
484+
class TopicAutocompleteDataProvider extends AutocompleteDataProvider<Topic, TopicAutocompleteQuery, TopicAutocompleteResult> {
485+
final PerAccountStore store;
486+
final StreamNarrow narrow;
487+
Iterable<Topic>? topics;
488+
bool isFetching = false;
489+
490+
TopicAutocompleteDataProvider({required this.store, required this.topics, required this.narrow});
491+
492+
/// Fetches topics of the current stream narrow, expected to fetch
493+
/// only once per lifecycle.
494+
///
495+
/// Starts fetching once the stream narrow is active, then when results
496+
/// are fetched it notifies `autocompleteViewManager` to refresh UI
497+
/// showing the newly fetched topics.
498+
Future<void> fetch() async {
499+
if (topics != null && !isFetching) return;
500+
isFetching = true;
501+
final result = await getStreamTopics(store.connection, streamId: narrow.streamId);
502+
topics = result.topics;
503+
store.autocompleteViewManager.handleTopicsFetchCompleted();
504+
isFetching = false;
505+
}
506+
507+
@override
508+
Iterable<Topic> getDataForQuery(TopicAutocompleteQuery query) {
509+
return topics ?? [];
510+
}
511+
512+
@override
513+
TopicAutocompleteResult? testItem(TopicAutocompleteQuery query, Topic item) {
514+
if (query.testTopic(item, store.autocompleteViewManager.autocompleteDataCache)) {
515+
return TopicAutocompleteResult(topic: item);
516+
}
517+
return null;
518+
}
519+
}
520+
521+
class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, TopicAutocompleteResult> {
522+
TopicAutocompleteView.init({
523+
required super.store,
524+
required StreamNarrow narrow,
525+
}) : super(dataProvider: TopicAutocompleteDataProvider(
526+
store: store,
527+
topics: null,
528+
narrow: narrow
529+
)..fetch());
530+
}
531+
532+
class TopicAutocompleteQuery extends AutocompleteQuery {
533+
TopicAutocompleteQuery(super.raw)
534+
: _lowercaseWords = raw.toLowerCase().split(' ');
535+
536+
final List<String> _lowercaseWords;
537+
538+
bool testTopic(Topic topic, AutocompleteDataCache cache) {
539+
return _testName(topic, cache);
540+
}
541+
542+
bool _testName(Topic topic, AutocompleteDataCache cache) {
543+
final List<String> nameWords = cache.nameWordsForTopic(topic);
544+
545+
int nameWordsIndex = 0;
546+
int queryWordsIndex = 0;
547+
while (true) {
548+
if (queryWordsIndex == _lowercaseWords.length) {
549+
return true;
550+
}
551+
if (nameWordsIndex == nameWords.length) {
552+
return false;
553+
}
554+
555+
if (nameWords[nameWordsIndex].startsWith(_lowercaseWords[queryWordsIndex])) {
556+
queryWordsIndex++;
557+
}
558+
nameWordsIndex++;
559+
}
560+
}
561+
562+
@override
563+
String toString() {
564+
return '${objectRuntimeType(this, 'TopicAutocompleteQuery')}(raw: $raw)';
565+
}
566+
567+
@override
568+
bool operator ==(Object other) {
569+
return other is TopicAutocompleteQuery && other.raw == raw;
570+
}
571+
572+
@override
573+
int get hashCode => Object.hash('TopicAutocompleteQuery', raw);
574+
}
575+
576+
class TopicAutocompleteResult extends AutocompleteResult {
577+
final Topic topic;
578+
579+
TopicAutocompleteResult({required this.topic});
580+
}

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 StreamNarrow narrow,
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, narrow: narrow);
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

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

379+
class _StreamTopicInput extends StatelessWidget {
380+
const _StreamTopicInput({
381+
required this.narrow,
382+
required this.controller,
383+
required this.focusNode,
384+
required this.contentFocusNode});
385+
386+
final StreamNarrow narrow;
387+
final ComposeTopicController controller;
388+
final FocusNode focusNode;
389+
final FocusNode contentFocusNode;
390+
391+
392+
@override
393+
Widget build(BuildContext context) {
394+
final zulipLocalizations = ZulipLocalizations.of(context);
395+
ColorScheme colorScheme = Theme.of(context).colorScheme;
396+
397+
return AutocompleteInputWrapper(
398+
child: TopicAutocomplete(
399+
narrow: narrow,
400+
controller: controller,
401+
focusNode: focusNode,
402+
contentFocusNode: contentFocusNode,
403+
fieldViewBuilder: (context) => TextField(
404+
controller: controller,
405+
focusNode: focusNode,
406+
textInputAction: TextInputAction.next,
407+
style: TextStyle(color: colorScheme.onSurface),
408+
decoration: InputDecoration.collapsed(hintText: zulipLocalizations.composeBoxTopicHintText),
409+
)));
410+
}
411+
}
412+
379413
class _FixedDestinationContentInput extends StatelessWidget {
380414
const _FixedDestinationContentInput({
381415
required this.narrow,
@@ -876,6 +910,9 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
876910
@override FocusNode get contentFocusNode => _contentFocusNode;
877911
final _contentFocusNode = FocusNode();
878912

913+
FocusNode get topicFocusNode => _topicFocusNode;
914+
final _topicFocusNode = FocusNode();
915+
879916
@override
880917
void dispose() {
881918
_topicController.dispose();
@@ -886,16 +923,14 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
886923

887924
@override
888925
Widget build(BuildContext context) {
889-
final colorScheme = Theme.of(context).colorScheme;
890-
final zulipLocalizations = ZulipLocalizations.of(context);
891-
892926
return _ComposeBoxLayout(
893927
contentController: _contentController,
894928
contentFocusNode: _contentFocusNode,
895-
topicInput: TextField(
929+
topicInput: _StreamTopicInput(
930+
narrow: widget.narrow,
896931
controller: _topicController,
897-
style: TextStyle(color: colorScheme.onSurface),
898-
decoration: InputDecoration(hintText: zulipLocalizations.composeBoxTopicHintText),
932+
focusNode: topicFocusNode,
933+
contentFocusNode: _contentFocusNode,
899934
),
900935
contentInput: _StreamContentInput(
901936
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)