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 16, 2024
1 parent dde8747 commit 76fd1ac
Show file tree
Hide file tree
Showing 7 changed files with 184 additions and 60 deletions.
39 changes: 38 additions & 1 deletion lib/api/model/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,43 @@ enum Emojiset {
.map((key, value) => MapEntry(value, key));
}

sealed class MentionableUser {}

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

/// 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.
///
/// The same as [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;

/// An integer solely used for sorting purposes.
final int index;
}

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

/// As in [InitialSnapshot.realmUsers], [InitialSnapshot.realmNonActiveUsers], and [InitialSnapshot.crossRealmBots].
///
/// In the Zulip API, the items in realm_users, realm_non_active_users, and
Expand All @@ -187,7 +224,7 @@ enum Emojiset {
/// For docs, search for "realm_users:"
/// in <https://zulip.com/api/register-queue>.
@JsonSerializable(fieldRename: FieldRename.snake)
class User {
class User extends MentionableUser {
// When adding a field to this class:
// * If a [RealmUserUpdateEvent] can update it, be sure to add
// that case to [RealmUserUpdateEvent] and its handler.
Expand Down
120 changes: 74 additions & 46 deletions lib/model/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ abstract class AutocompleteView<QueryT extends AutocompleteQuery, ResultT extend
}
}

class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery, MentionAutocompleteResult, User> {
class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery, MentionAutocompleteResult, MentionableUser> {
MentionAutocompleteView._({
required super.store,
required this.narrow,
Expand All @@ -277,13 +277,13 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
}

final Narrow narrow;
final List<User> sortedUsers;
final List<MentionableUser> sortedUsers;

static List<User> _usersByRelevance({
static List<MentionableUser> _usersByRelevance({
required PerAccountStore store,
required Narrow narrow,
}) {
return store.users.values.toList()
return [...store.users.values, ...store.wildcardsForNarrow(narrow).values]
..sort(_comparator(store: store, narrow: narrow));
}

Expand All @@ -303,7 +303,7 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
return _comparator(store: store, narrow: narrow)(userA, userB);
}

static int Function(User, User) _comparator({
static int Function(MentionableUser, MentionableUser) _comparator({
required PerAccountStore store,
required Narrow narrow,
}) {
Expand All @@ -326,29 +326,36 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
store: store);
}

static int _compareByRelevance(User userA, User userB, {
static int _compareByRelevance(MentionableUser userA, MentionableUser userB, {
required int? streamId,
required String? topic,
required PerAccountStore store,
}) {
// TODO(#234): give preference to "all", "everyone" or "stream"
switch ((userA, userB)) {
case (Wildcard _, User _):
return -1;
case (User _, Wildcard _):
return 1;
case (Wildcard wildcardA, Wildcard wildcardB):
return wildcardA.index.compareTo(wildcardB.index);
case (User userA, User userB):
// TODO(#618): give preference to subscribed users first

if (streamId != null) {
final recencyResult = compareByRecency(userA, userB,
streamId: streamId,
topic: topic,
store: store);
if (recencyResult != 0) return recencyResult;
}
final dmsResult = compareByDms(userA, userB, store: store);
if (dmsResult != 0) return dmsResult;

// TODO(#618): give preference to subscribed users first
final botStatusResult = compareByBotStatus(userA, userB);
if (botStatusResult != 0) return botStatusResult;

if (streamId != null) {
final recencyResult = compareByRecency(userA, userB,
streamId: streamId,
topic: topic,
store: store);
if (recencyResult != 0) return recencyResult;
return compareByAlphabeticalOrder(userA, userB, store: store);
}
final dmsResult = compareByDms(userA, userB, store: store);
if (dmsResult != 0) return dmsResult;

final botStatusResult = compareByBotStatus(userA, userB);
if (botStatusResult != 0) return botStatusResult;

return compareByAlphabeticalOrder(userA, userB, store: store);
}

/// Determines which of the two users has more recent activity (messages sent
Expand Down Expand Up @@ -385,19 +392,6 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
streamId: streamId, senderId: userB.userId));
}

@override
Iterable<User> getSortedItemsToTest(MentionAutocompleteQuery query) {
return sortedUsers;
}

@override
MentionAutocompleteResult? testItem(MentionAutocompleteQuery query, User item) {
if (query.testUser(item, store.autocompleteViewManager.autocompleteDataCache)) {
return UserMentionAutocompleteResult(userId: item.userId);
}
return null;
}

/// Determines which of the two users is more recent in DM conversations.
///
/// Returns a negative number if [userA] is more recent than [userB],
Expand Down Expand Up @@ -450,6 +444,37 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
return userAName.compareTo(userBName); // TODO(i18n): add locale-aware sorting
}

@override
Iterable<MentionableUser> getSortedItemsToTest(MentionAutocompleteQuery query) {
return sortedUsers;
}

bool _isChannelWildcardIncluded = false;

@override
MentionAutocompleteResult? testItem(MentionAutocompleteQuery query, MentionableUser item) {
if (query.testUser(item, store.autocompleteViewManager.autocompleteDataCache)) {
switch (item) {
case User user:
return UserMentionAutocompleteResult(userId: user.userId);
case Wildcard wildcard:
final isChannelWildcard = wildcard.type == WildcardType.channel;
if (isChannelWildcard && _isChannelWildcardIncluded) break;
if (isChannelWildcard) {
_isChannelWildcardIncluded = true;
}
return WildcardMentionAutocompleteResult(wildcardName: wildcard.name);
}
}
return null;
}

@override
Future<void> _startSearch(MentionAutocompleteQuery query) async {
_isChannelWildcardIncluded = false;
return super._startSearch(query);
}

@override
void dispose() {
store.autocompleteViewManager.unregisterMentionAutocomplete(this);
Expand Down Expand Up @@ -496,16 +521,15 @@ 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

if (!user.isActive) return false;

return _testName(user, cache);
}

bool _testName(User user, AutocompleteDataCache cache) {
return _testContainsQueryWords(cache.nameWordsForUser(user));
bool testUser(MentionableUser user, AutocompleteDataCache cache) {
switch (user) {
case User():
if (!user.isActive) return false;
// TODO(#236) test email too, not just name
return _testContainsQueryWords(cache.nameWordsForUser(user));
case Wildcard():
return user.name.contains(raw.toLowerCase());
}
}

@override
Expand Down Expand Up @@ -552,9 +576,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, String> {
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')}';
}
49 changes: 49 additions & 0 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import 'autocomplete.dart';
import 'database.dart';
import 'message.dart';
import 'message_list.dart';
import 'narrow.dart';
import 'recent_dm_conversations.dart';
import 'recent_senders.dart';
import 'channel.dart';
Expand Down Expand Up @@ -341,6 +342,54 @@ class PerAccountStore extends ChangeNotifier with ChannelStore, MessageStore {

final Map<int, User> users;

Map<String, Wildcard> wildcardsForNarrow(Narrow narrow) => Map.fromEntries(
_wildcardsForNarrow(narrow).map((w) => MapEntry(w.name, w)));

List<Wildcard> _wildcardsForNarrow(Narrow narrow) {
final isDmNarrow = narrow is DmNarrow;
final isChannelWildcardAvailable = account.zulipFeatureLevel >= 247; // TODO(server-9)
final isTopicWildcardAvailable = account.zulipFeatureLevel >= 188; // TODO(sever-8)
return [
Wildcard(
name: 'all',
value: 'all',
fullDisplayName: 'all (Notify ${isDmNarrow ? 'recipients' : isChannelWildcardAvailable ? 'channel' : 'stream'})',
type: WildcardType.channel,
index: 0,
),
Wildcard(
name: 'everyone',
value: 'everyone',
fullDisplayName: 'everyone (Notify ${isDmNarrow ? 'recipients' : isChannelWildcardAvailable ? 'channel' : 'stream'})',
type: WildcardType.channel,
index: 1,
),
if (!isDmNarrow) ...[
if (isChannelWildcardAvailable) Wildcard(
name: 'channel',
value: 'channel',
fullDisplayName: 'channel (Notify channel)',
type: WildcardType.channel,
index: 2,
),
Wildcard(
name: 'stream',
value: isChannelWildcardAvailable ? 'channel' : 'stream',
fullDisplayName: 'stream (Notify ${isChannelWildcardAvailable ? 'channel' : 'stream'})',
type: WildcardType.channel,
index: 3,
),
if (isTopicWildcardAvailable) Wildcard(
name: 'topic',
value: 'topic',
fullDisplayName: 'topic (Notify topic)',
type: WildcardType.topic,
index: 4,
),
],
];
}

final TypingStatus typingStatus;

////////////////////////////////
Expand Down
13 changes: 10 additions & 3 deletions lib/widgets/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:flutter/material.dart';

import '../api/model/model.dart';
import 'content.dart';
import 'icons.dart';
import 'store.dart';
import '../model/autocomplete.dart';
import '../model/compose.dart';
Expand Down Expand Up @@ -145,7 +146,7 @@ class _AutocompleteFieldState<QueryT extends AutocompleteQuery, ResultT extends
}
}

class ComposeAutocomplete extends AutocompleteField<MentionAutocompleteQuery, MentionAutocompleteResult, User> {
class ComposeAutocomplete extends AutocompleteField<MentionAutocompleteQuery, MentionAutocompleteResult, MentionableUser> {
const ComposeAutocomplete({
super.key,
required this.narrow,
Expand Down Expand Up @@ -183,7 +184,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(store.wildcardsForNarrow(narrow)[wildcardName]!.value)} ';
}

controller.value = intent.textEditingValue.replaced(
Expand All @@ -196,12 +199,16 @@ 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;
label = store.users[userId]!.fullName;
case WildcardMentionAutocompleteResult(:var wildcardName):
avatar = const Icon(ZulipIcons.bullhorn, size: 32);
label = store.wildcardsForNarrow(narrow)[wildcardName]!.fullDisplayName;
}
return InkWell(
onTap: () {
Expand Down
Loading

0 comments on commit 76fd1ac

Please sign in to comment.