Skip to content

Commit

Permalink
autocomplete: Support @-wildcard in user-mention autocomplete
Browse files Browse the repository at this point in the history
Fixes: zulip#234
  • Loading branch information
sm-sayedi committed Aug 22, 2024
1 parent 8b73cd4 commit 1664a2d
Show file tree
Hide file tree
Showing 6 changed files with 202 additions and 53 deletions.
35 changes: 35 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -533,5 +533,40 @@
"manyPeopleTyping": "Several people are typing…",
"@manyPeopleTyping": {
"description": "Text to display when there are multiple users typing."
},
"all": "all",
"@all": {
"description": "Text for \"@all\" wildcard mention."
},
"everyone": "everyone",
"@everyone": {
"description": "Text for \"@everyone\" wildcard mention."
},
"channel": "channel",
"@channel": {
"description": "Text for \"@channel\" wildcard mention."
},
"stream": "stream",
"@stream": {
"description": "Text for \"@stream\" wildcard mention."
},
"topic": "topic",
"@topic": {
"description": "Text for \"@topic\" wildcard mention."
},
"notifyChannel": "Notify {value, select, channel{channel} other{stream}}",
"@notifyChannel": {
"description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard mentions in a channel or topic narrow.",
"placeholders": {
"value": {"type": "String"}
}
},
"notifyRecipients": "Notify recipients",
"@notifyRecipients": {
"description": "Description for \"@all\" and \"@everyone\" wildcard mentions in a DM narrow."
},
"notifyTopic": "Notify topic",
"@notifyTopic": {
"description": "Description for \"@topic\" wildcard mention in a channel or topic narrow."
}
}
115 changes: 85 additions & 30 deletions lib/model/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ class AutocompleteViewManager {
/// * On reassemble, call [reassemble].
/// * When the object will no longer be used, call [dispose] to free
/// resources on the [PerAccountStore].
abstract class AutocompleteView<QueryT extends AutocompleteQuery, ResultT extends AutocompleteResult, CandidateT> extends ChangeNotifier {
abstract class AutocompleteView<QueryT extends AutocompleteQuery, ResultT extends AutocompleteResult> extends ChangeNotifier {
AutocompleteView({required this.store});

final PerAccountStore store;
Expand Down Expand Up @@ -284,46 +284,33 @@ abstract class AutocompleteView<QueryT extends AutocompleteQuery, ResultT extend
}
}

class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery, MentionAutocompleteResult, User> {
class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery, MentionAutocompleteResult> {
MentionAutocompleteView._({
required super.store,
required this.narrow,
required this.wildcards,
required this.sortedUsers,
});

factory MentionAutocompleteView.init({
required PerAccountStore store,
required Narrow narrow,
required List<Wildcard> wildcards,
}) {
final view = MentionAutocompleteView._(
store: store,
narrow: narrow,
wildcards: wildcards,
sortedUsers: _usersByRelevance(store: store, narrow: narrow),
);
store.autocompleteViewManager.registerMentionAutocomplete(view);
return view;
}

final Narrow narrow;
final List<Wildcard> wildcards;
final List<User> sortedUsers;

@override
Future<List<MentionAutocompleteResult>?> computeResults() async {
final results = <MentionAutocompleteResult>[];
if (await filterCandidates(filter: _testUser,
candidates: sortedUsers, results: results)) {
return null;
}
return results;
}

MentionAutocompleteResult? _testUser(MentionAutocompleteQuery query, User user) {
if (query.testUser(user, store.autocompleteViewManager.autocompleteDataCache)) {
return UserMentionAutocompleteResult(userId: user.userId);
}
return null;
}

static List<User> _usersByRelevance({
required PerAccountStore store,
required Narrow narrow,
Expand Down Expand Up @@ -377,8 +364,6 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
required String? topic,
required PerAccountStore store,
}) {
// TODO(#234): give preference to "all", "everyone" or "stream"

// TODO(#618): give preference to subscribed users first

if (streamId != null) {
Expand Down Expand Up @@ -483,6 +468,42 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
return userAName.compareTo(userBName); // TODO(i18n): add locale-aware sorting
}

bool _isChannelWildcardIncluded = false;

@override
Future<List<MentionAutocompleteResult>?> computeResults() async {
_isChannelWildcardIncluded = false;
final results = <MentionAutocompleteResult>[];
// give priority to wildcard mentions
if (await filterCandidates(filter: _testWildcard,
candidates: wildcards, results: results)) {
return null;
}
if (await filterCandidates(filter: _testUser,
candidates: sortedUsers, results: results)) {
return null;
}
return results;
}

MentionAutocompleteResult? _testWildcard(MentionAutocompleteQuery query, Wildcard wildcard) {
if (query.testWildcard(wildcard)) {
if (wildcard.type == WildcardType.channel) {
if (_isChannelWildcardIncluded) return null;
_isChannelWildcardIncluded = true;
}
return WildcardMentionAutocompleteResult(wildcardName: wildcard.name);
}
return null;
}

MentionAutocompleteResult? _testUser(MentionAutocompleteQuery query, User user) {
if (query.testUser(user, store.autocompleteViewManager.autocompleteDataCache)) {
return UserMentionAutocompleteResult(userId: user.userId);
}
return null;
}

@override
void dispose() {
store.autocompleteViewManager.unregisterMentionAutocomplete(this);
Expand All @@ -493,6 +514,37 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
}
}

class Wildcard {
Wildcard({
required this.name,
required this.value,
required this.fullDisplayName,
required this.type,
});

/// The name of the wildcard to be shown as part of [fullDisplayName] in autocomplete suggestions.
///
/// Ex: "channel", "stream", "topic", ...
final String name;

/// The value to be put at the compose box after choosing an option from autocomplete.
///
/// Same as the [name], except for "stream" it is "channel" in FL >= 247 (server-9).
final String value; // TODO(sever-9): remove, instead use [name]

/// The full name of the wildcard to be shown in autocomplete suggestions.
///
/// Ex: "all (Notify channel)" or "everyone (Notify recipients)".
final String fullDisplayName;

final WildcardType type;
}

enum WildcardType {
channel,
topic, // TODO(sever-8)
}

abstract class AutocompleteQuery {
AutocompleteQuery(this.raw)
: _lowercaseWords = raw.toLowerCase().split(' ');
Expand Down Expand Up @@ -529,15 +581,14 @@ class MentionAutocompleteQuery extends AutocompleteQuery {
/// Whether the user wants a silent mention (@_query, vs. @query).
final bool silent;

bool testUser(User user, AutocompleteDataCache cache) {
// TODO(#236) test email too, not just name
bool testWildcard(Wildcard wildcard) {
return wildcard.name.contains(raw.toLowerCase());
}

bool testUser(User user, AutocompleteDataCache cache) {
if (!user.isActive) return false;

return _testName(user, cache);
}

bool _testName(User user, AutocompleteDataCache cache) {
// TODO(#236) test email too, not just name
return _testContainsQueryWords(cache.nameWordsForUser(user));
}

Expand Down Expand Up @@ -585,11 +636,15 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult {
final int userId;
}

// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
WildcardMentionAutocompleteResult({required this.wildcardName});

final String wildcardName;
}

// TODO(#234): // class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {

class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, TopicAutocompleteResult, String> {
class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, TopicAutocompleteResult> {
TopicAutocompleteView._({required super.store, required this.streamId});

factory TopicAutocompleteView.init({required PerAccountStore store, required int streamId}) {
Expand Down
11 changes: 7 additions & 4 deletions lib/model/compose.dart
Original file line number Diff line number Diff line change
Expand Up @@ -101,18 +101,21 @@ String wrapWithBacktickFence({required String content, String? infoString}) {
return resultBuffer.toString();
}

/// An @-mention, like @**Chris Bobbe|13313**.
/// An @user-mention, like @**Chris Bobbe|13313**.
///
/// To omit the user ID part ("|13313") whenever the name part is unambiguous,
/// pass a Map of all users we know about. This means accepting a linear scan
/// through all users; avoid it in performance-sensitive codepaths.
String mention(User user, {bool silent = false, Map<int, User>? users}) {
String userMention(User user, {bool silent = false, Map<int, User>? users}) {
bool includeUserId = users == null
|| users.values.where((u) => u.fullName == user.fullName).take(2).length == 2;

return '@${silent ? '_' : ''}**${user.fullName}${includeUserId ? '|${user.userId}' : ''}**';
}

/// An @wildcard-mention, like @**channel**.
String wildcardMention(String wildcard) => '@**$wildcard**';

/// https://spec.commonmark.org/0.30/#inline-link
///
/// The "link text" is made by enclosing [visibleText] in square brackets.
Expand Down Expand Up @@ -145,7 +148,7 @@ String quoteAndReplyPlaceholder(PerAccountStore store, {
SendableNarrow.ofMessage(message, selfUserId: store.selfUserId),
nearMessageId: message.id);
// See note in [quoteAndReply] about asking `mention` to omit the |<id> part.
return '${mention(sender!, silent: true)} ${inlineLink('said', url)}: ' // TODO(i18n) ?
return '${userMention(sender!, silent: true)} ${inlineLink('said', url)}: ' // TODO(i18n) ?
'*(loading message ${message.id})*\n'; // TODO(i18n) ?
}

Expand All @@ -169,6 +172,6 @@ String quoteAndReply(PerAccountStore store, {
// Could ask `mention` to omit the |<id> part unless the mention is ambiguous…
// but that would mean a linear scan through all users, and the extra noise
// won't much matter with the already probably-long message link in there too.
return '${mention(sender!, silent: true)} ${inlineLink('said', url)}:\n' // TODO(i18n) ?
return '${userMention(sender!, silent: true)} ${inlineLink('said', url)}:\n' // TODO(i18n) ?
'${wrapWithBacktickFence(content: rawContent, infoString: 'quote')}';
}
Loading

0 comments on commit 1664a2d

Please sign in to comment.