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 authored and gnprice committed Nov 7, 2024
1 parent 97ed6d5 commit a24bddb
Show file tree
Hide file tree
Showing 6 changed files with 191 additions and 41 deletions.
35 changes: 35 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,41 @@
"@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."
},
"messageIsEditedLabel": "EDITED",
"@messageIsEditedLabel": {
"description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)"
Expand Down
109 changes: 82 additions & 27 deletions lib/model/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -288,42 +288,29 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
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,9 +636,13 @@ 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> {
TopicAutocompleteView._({required super.store, required this.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')}';
}
65 changes: 61 additions & 4 deletions lib/widgets/autocomplete.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';

import '../model/store.dart';
import 'content.dart';
import 'icons.dart';
import 'store.dart';
import '../model/autocomplete.dart';
import '../model/compose.dart';
Expand Down Expand Up @@ -164,7 +167,54 @@ class ComposeAutocomplete extends AutocompleteField<MentionAutocompleteQuery, Me
@override
MentionAutocompleteView initViewModel(BuildContext context) {
final store = PerAccountStoreWidget.of(context);
return MentionAutocompleteView.init(store: store, narrow: narrow);
return MentionAutocompleteView.init(store: store, narrow: narrow, wildcards: _wildcards(context, store));
}

List<Wildcard> _wildcards(BuildContext context, PerAccountStore store) {
final isDmNarrow = narrow is DmNarrow;
final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9)
final isTopicWildcardAvailable = store.account.zulipFeatureLevel >= 188; // TODO(sever-8)
final zulipLocalizations = ZulipLocalizations.of(context);
return [
Wildcard(
name: zulipLocalizations.all,
value: 'all',
fullDisplayName: 'all (${isDmNarrow
? zulipLocalizations.notifyRecipients
: zulipLocalizations.notifyChannel(isChannelWildcardAvailable
? "channel" : "stream")})',
type: WildcardType.channel,
),
Wildcard(
name: zulipLocalizations.everyone,
value: 'everyone',
fullDisplayName: 'everyone (${isDmNarrow
? zulipLocalizations.notifyRecipients
: zulipLocalizations.notifyChannel(isChannelWildcardAvailable
? "channel" : "stream")})',
type: WildcardType.channel,
),
if (!isDmNarrow) ...[
if (isChannelWildcardAvailable) Wildcard(
name: zulipLocalizations.channel,
value: 'channel',
fullDisplayName: 'channel (${zulipLocalizations.notifyChannel('channel')})',
type: WildcardType.channel,
),
Wildcard(
name: zulipLocalizations.stream,
value: isChannelWildcardAvailable ? 'channel' : 'stream',
fullDisplayName: 'stream (${zulipLocalizations.notifyChannel(isChannelWildcardAvailable ? 'channel' : 'stream')})',
type: WildcardType.channel,
),
if (isTopicWildcardAvailable) Wildcard(
name: zulipLocalizations.topic,
value: 'topic',
fullDisplayName: 'topic (${zulipLocalizations.notifyTopic})',
type: WildcardType.topic,
),
],
];
}

void _onTapOption(BuildContext context, MentionAutocompleteResult option) {
Expand All @@ -182,7 +232,9 @@ class ComposeAutocomplete extends AutocompleteField<MentionAutocompleteQuery, Me
case UserMentionAutocompleteResult(:var userId):
// TODO(i18n) language-appropriate space character; check active keyboard?
// (maybe handle centrally in `controller`)
replacementString = '${mention(store.users[userId]!, silent: intent.query.silent, users: store.users)} ';
replacementString = '${userMention(store.users[userId]!, silent: intent.query.silent, users: store.users)} ';
case WildcardMentionAutocompleteResult(:var wildcardName):
replacementString = '${wildcardMention(_wildcards(context, store).singleWhere((w) => w.name == wildcardName).value)} ';
}

controller.value = intent.textEditingValue.replaced(
Expand All @@ -195,12 +247,17 @@ class ComposeAutocomplete extends AutocompleteField<MentionAutocompleteQuery, Me

@override
Widget buildItem(BuildContext context, int index, MentionAutocompleteResult option) {
final store = PerAccountStoreWidget.of(context);
Widget avatar;
String label;
switch (option) {
case UserMentionAutocompleteResult(:var userId):
avatar = Avatar(userId: userId, size: 32, borderRadius: 3);
label = PerAccountStoreWidget.of(context).users[userId]!.fullName;
avatar = Avatar(userId: userId, size: 32, borderRadius: 3); // web uses 21px
label = store.users[userId]!.fullName;
case WildcardMentionAutocompleteResult(:var wildcardName):
avatar = const Icon(ZulipIcons.bullhorn, size: 29); // web uses 19px
print('wildcard name: $wildcardName');
label = _wildcards(context, store).singleWhere((w) => w.name == wildcardName).fullDisplayName;
}
return InkWell(
onTap: () {
Expand Down
10 changes: 5 additions & 5 deletions test/model/compose_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -319,25 +319,25 @@ hello
group('mention', () {
final user = eg.user(userId: 123, fullName: 'Full Name');
test('not silent', () {
check(mention(user, silent: false)).equals('@**Full Name|123**');
check(userMention(user, silent: false)).equals('@**Full Name|123**');
});
test('silent', () {
check(mention(user, silent: true)).equals('@_**Full Name|123**');
check(userMention(user, silent: true)).equals('@_**Full Name|123**');
});
test('`users` passed; has two users with same fullName', () async {
final store = eg.store();
await store.addUsers([user, eg.user(userId: 5), eg.user(userId: 234, fullName: user.fullName)]);
check(mention(user, silent: true, users: store.users)).equals('@_**Full Name|123**');
check(userMention(user, silent: true, users: store.users)).equals('@_**Full Name|123**');
});
test('`users` passed; has two same-name users but one of them is deactivated', () async {
final store = eg.store();
await store.addUsers([user, eg.user(userId: 5), eg.user(userId: 234, fullName: user.fullName, isActive: false)]);
check(mention(user, silent: true, users: store.users)).equals('@_**Full Name|123**');
check(userMention(user, silent: true, users: store.users)).equals('@_**Full Name|123**');
});
test('`users` passed; user has unique fullName', () async {
final store = eg.store();
await store.addUsers([user, eg.user(userId: 234, fullName: 'Another Name')]);
check(mention(user, silent: true, users: store.users)).equals('@_**Full Name**');
check(userMention(user, silent: true, users: store.users)).equals('@_**Full Name**');
});
});

Expand Down
2 changes: 1 addition & 1 deletion test/widgets/autocomplete_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ void main() {
await tester.tap(find.text('User Three'));
await tester.pump();
check(tester.widget<TextField>(composeInputFinder).controller!.text)
.contains(mention(user3, users: store.users));
.contains(userMention(user3, users: store.users));
checkUserShown(user1, store, expected: false);
checkUserShown(user2, store, expected: false);
checkUserShown(user3, store, expected: false);
Expand Down

0 comments on commit a24bddb

Please sign in to comment.