Skip to content

Commit f8d95ff

Browse files
chrisbobbegnprice
andcommitted
autocomplete: Add and test MentionAutocompleteView
Co-authored-by: Greg Price <[email protected]>
1 parent c1d609d commit f8d95ff

File tree

4 files changed

+358
-0
lines changed

4 files changed

+358
-0
lines changed

lib/model/autocomplete.dart

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,176 @@
1+
import 'package:flutter/foundation.dart';
2+
3+
import '../api/model/events.dart';
14
import '../api/model/model.dart';
5+
import 'narrow.dart';
6+
import 'store.dart';
7+
8+
/// A per-account manager for the view-models of autocomplete interactions.
9+
///
10+
/// There should be exactly one of these per PerAccountStore.
11+
///
12+
/// Since this manages a cache of user data, the handleRealmUser…Event functions
13+
/// must be called as appropriate.
14+
///
15+
/// On reassemble, call [reassemble].
16+
class AutocompleteViewManager {
17+
final Set<MentionAutocompleteView> _mentionAutocompleteViews = {};
18+
19+
AutocompleteDataCache autocompleteDataCache = AutocompleteDataCache();
20+
21+
void registerMentionAutocomplete(MentionAutocompleteView view) {
22+
final added = _mentionAutocompleteViews.add(view);
23+
assert(added);
24+
}
25+
26+
void unregisterMentionAutocomplete(MentionAutocompleteView view) {
27+
final removed = _mentionAutocompleteViews.remove(view);
28+
assert(removed);
29+
}
30+
31+
void handleRealmUserAddEvent(RealmUserAddEvent event) {
32+
for (final view in _mentionAutocompleteViews) {
33+
view.refreshStaleUserResults();
34+
}
35+
}
36+
37+
void handleRealmUserRemoveEvent(RealmUserRemoveEvent event) {
38+
for (final view in _mentionAutocompleteViews) {
39+
view.refreshStaleUserResults();
40+
}
41+
autocompleteDataCache.invalidateUser(event.userId);
42+
}
43+
44+
void handleRealmUserUpdateEvent(RealmUserUpdateEvent event) {
45+
for (final view in _mentionAutocompleteViews) {
46+
view.refreshStaleUserResults();
47+
}
48+
autocompleteDataCache.invalidateUser(event.userId);
49+
}
50+
51+
/// Called when the app is reassembled during debugging, e.g. for hot reload.
52+
///
53+
/// Calls [MentionAutocompleteView.reassemble] for all that are registered.
54+
///
55+
void reassemble() {
56+
for (final view in _mentionAutocompleteViews) {
57+
view.reassemble();
58+
}
59+
}
60+
}
61+
62+
/// A view-model for a mention-autocomplete interaction.
63+
///
64+
/// The owner of one of these objects must call [dispose] when the object
65+
/// will no longer be used, in order to free resources on the [PerAccountStore].
66+
///
67+
/// Lifecycle:
68+
/// * Create with [init].
69+
/// * Add listeners with [addListener].
70+
/// * Use the [query] setter to start a search for a query.
71+
/// * On reassemble, call [reassemble].
72+
/// * When the object will no longer be used, call [dispose] to free
73+
/// resources on the [PerAccountStore].
74+
class MentionAutocompleteView extends ChangeNotifier {
75+
MentionAutocompleteView._({required this.store, required this.narrow});
76+
77+
factory MentionAutocompleteView.init({
78+
required PerAccountStore store,
79+
required Narrow narrow,
80+
}) {
81+
final view = MentionAutocompleteView._(store: store, narrow: narrow);
82+
store.autocompleteViewManager.registerMentionAutocomplete(view);
83+
return view;
84+
}
85+
86+
@override
87+
void dispose() {
88+
store.autocompleteViewManager.unregisterMentionAutocomplete(this);
89+
// TODO cancel in-progress computations if possible
90+
super.dispose();
91+
}
92+
93+
final PerAccountStore store;
94+
final Narrow narrow;
95+
96+
MentionAutocompleteQuery? _currentQuery;
97+
set query(MentionAutocompleteQuery query) {
98+
_currentQuery = query;
99+
_startSearch(query);
100+
}
101+
102+
/// Recompute user results for the current query, if any.
103+
///
104+
/// Called in particular when we get a [RealmUserEvent].
105+
void refreshStaleUserResults() {
106+
if (_currentQuery != null) {
107+
_startSearch(_currentQuery!);
108+
}
109+
}
110+
111+
/// Called when the app is reassembled during debugging, e.g. for hot reload.
112+
///
113+
/// This will redo the search from scratch for the current query, if any.
114+
void reassemble() {
115+
if (_currentQuery != null) {
116+
_startSearch(_currentQuery!);
117+
}
118+
}
119+
120+
Iterable<MentionAutocompleteResult> get results => _results;
121+
List<MentionAutocompleteResult> _results = [];
122+
123+
_startSearch(MentionAutocompleteQuery query) async {
124+
List<MentionAutocompleteResult>? newResults;
125+
126+
while (true) {
127+
try {
128+
newResults = await _computeResults(query);
129+
break;
130+
} on ConcurrentModificationError {
131+
// Retry
132+
// TODO backoff?
133+
}
134+
}
135+
136+
if (newResults == null) {
137+
// Query was old; new search is in progress.
138+
return;
139+
}
140+
141+
_results = newResults;
142+
notifyListeners();
143+
}
144+
145+
Future<List<MentionAutocompleteResult>?> _computeResults(MentionAutocompleteQuery query) async {
146+
final List<MentionAutocompleteResult> results = [];
147+
final Iterable<User> users = store.users.values;
148+
149+
final iterator = users.iterator;
150+
bool isDone = false;
151+
while (!isDone) {
152+
// CPU perf: End this task; enqueue a new one for resuming this work
153+
await Future(() {});
154+
155+
if (query != _currentQuery) {
156+
return null;
157+
}
158+
159+
for (int i = 0; i < 1000; i++) {
160+
if (!iterator.moveNext()) { // Can throw ConcurrentModificationError
161+
isDone = true;
162+
break;
163+
}
164+
165+
final User user = iterator.current;
166+
if (query.testUser(user, store.autocompleteViewManager.autocompleteDataCache)) {
167+
results.add(UserMentionAutocompleteResult(userId: user.userId));
168+
}
169+
}
170+
}
171+
return results;
172+
}
173+
}
2174

3175
class MentionAutocompleteQuery {
4176
MentionAutocompleteQuery(this.raw)
@@ -51,3 +223,30 @@ class AutocompleteDataCache {
51223
_nameWordsByUser.remove(userId);
52224
}
53225
}
226+
227+
abstract class MentionAutocompleteResult {}
228+
229+
class UserMentionAutocompleteResult extends MentionAutocompleteResult {
230+
UserMentionAutocompleteResult({required this.userId});
231+
232+
final int userId;
233+
}
234+
235+
enum WildcardMentionType {
236+
all,
237+
everyone,
238+
stream,
239+
}
240+
241+
class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
242+
WildcardMentionAutocompleteResult({required this.type});
243+
244+
final WildcardMentionType type;
245+
}
246+
247+
248+
class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
249+
UserGroupMentionAutocompleteResult({required this.userGroupId});
250+
251+
final int userGroupId;
252+
}

lib/model/store.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import '../api/model/model.dart';
1313
import '../api/route/events.dart';
1414
import '../api/route/messages.dart';
1515
import '../log.dart';
16+
import 'autocomplete.dart';
1617
import 'database.dart';
1718
import 'message_list.dart';
1819

@@ -178,6 +179,8 @@ class PerAccountStore extends ChangeNotifier {
178179
assert(removed);
179180
}
180181

182+
final AutocompleteViewManager autocompleteViewManager = AutocompleteViewManager();
183+
181184
/// Called when the app is reassembled during debugging, e.g. for hot reload.
182185
///
183186
/// This will redo from scratch any computations we can, such as parsing
@@ -186,6 +189,7 @@ class PerAccountStore extends ChangeNotifier {
186189
for (final view in _messageListViews) {
187190
view.reassemble();
188191
}
192+
autocompleteViewManager.reassemble();
189193
}
190194

191195
void handleEvent(Event event) {
@@ -197,10 +201,12 @@ class PerAccountStore extends ChangeNotifier {
197201
} else if (event is RealmUserAddEvent) {
198202
assert(debugLog("server event: realm_user/add"));
199203
users[event.person.userId] = event.person;
204+
autocompleteViewManager.handleRealmUserAddEvent(event);
200205
notifyListeners();
201206
} else if (event is RealmUserRemoveEvent) {
202207
assert(debugLog("server event: realm_user/remove"));
203208
users.remove(event.userId);
209+
autocompleteViewManager.handleRealmUserRemoveEvent(event);
204210
notifyListeners();
205211
} else if (event is RealmUserUpdateEvent) {
206212
assert(debugLog("server event: realm_user/update"));
@@ -226,6 +232,7 @@ class PerAccountStore extends ChangeNotifier {
226232
profileData.remove(update.id);
227233
}
228234
}
235+
autocompleteViewManager.handleRealmUserUpdateEvent(event);
229236
notifyListeners();
230237
} else if (event is MessageEvent) {
231238
assert(debugLog("server event: message ${jsonEncode(event.message.toJson())}"));

test/model/autocomplete_checks.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import 'package:checks/checks.dart';
2+
import 'package:zulip/model/autocomplete.dart';
3+
4+
extension UserMentionAutocompleteResultChecks on Subject<UserMentionAutocompleteResult> {
5+
Subject<int> get userId => has((r) => r.userId, 'userId');
6+
}

0 commit comments

Comments
 (0)