Skip to content

Commit

Permalink
autocomplete: Support @-wildcard in user-mention autocomplete
Browse files Browse the repository at this point in the history
  • Loading branch information
sm-sayedi committed Dec 9, 2024
1 parent e310276 commit 0af384f
Show file tree
Hide file tree
Showing 12 changed files with 410 additions and 54 deletions.
5 changes: 4 additions & 1 deletion assets/l10n/app_ar.arb
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
{

"notifyChannel": "إخطار القناة",
"notifyStream": "إخطار الدفق",
"notifyRecipients": "إخطار المستلمين",
"notifyTopic": "إخطار الموضوع"
}
16 changes: 16 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,22 @@
"@manyPeopleTyping": {
"description": "Text to display when there are multiple users typing."
},
"notifyChannel": "Notify channel",
"@notifyChannel": {
"description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard mentions in a channel or topic narrow."
},
"notifyStream": "Notify stream",
"@notifyStream": {
"description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard mentions in a stream or topic narrow."
},
"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
24 changes: 24 additions & 0 deletions lib/generated/l10n/zulip_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,30 @@ abstract class ZulipLocalizations {
/// **'Several people are typing…'**
String get manyPeopleTyping;

/// Description for "@all", "@everyone", "@channel", and "@stream" wildcard mentions in a channel or topic narrow.
///
/// In en, this message translates to:
/// **'Notify channel'**
String get notifyChannel;

/// Description for "@all", "@everyone", and "@stream" wildcard mentions in a stream or topic narrow.
///
/// In en, this message translates to:
/// **'Notify stream'**
String get notifyStream;

/// Description for "@all" and "@everyone" wildcard mentions in a DM narrow.
///
/// In en, this message translates to:
/// **'Notify recipients'**
String get notifyRecipients;

/// Description for "@topic" wildcard mention in a channel or topic narrow.
///
/// In en, this message translates to:
/// **'Notify topic'**
String get notifyTopic;

/// Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)
///
/// In en, this message translates to:
Expand Down
12 changes: 12 additions & 0 deletions lib/generated/l10n/zulip_localizations_ar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,18 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
@override
String get manyPeopleTyping => 'Several people are typing…';

@override
String get notifyChannel => 'إخطار القناة';

@override
String get notifyStream => 'إخطار الدفق';

@override
String get notifyRecipients => 'إخطار المستلمين';

@override
String get notifyTopic => 'إخطار الموضوع';

@override
String get messageIsEditedLabel => 'EDITED';

Expand Down
12 changes: 12 additions & 0 deletions lib/generated/l10n/zulip_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,18 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
@override
String get manyPeopleTyping => 'Several people are typing…';

@override
String get notifyChannel => 'Notify channel';

@override
String get notifyStream => 'Notify stream';

@override
String get notifyRecipients => 'Notify recipients';

@override
String get notifyTopic => 'Notify topic';

@override
String get messageIsEditedLabel => 'EDITED';

Expand Down
12 changes: 12 additions & 0 deletions lib/generated/l10n/zulip_localizations_ja.dart
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,18 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
@override
String get manyPeopleTyping => 'Several people are typing…';

@override
String get notifyChannel => 'Notify channel';

@override
String get notifyStream => 'Notify stream';

@override
String get notifyRecipients => 'Notify recipients';

@override
String get notifyTopic => 'Notify topic';

@override
String get messageIsEditedLabel => 'EDITED';

Expand Down
56 changes: 50 additions & 6 deletions lib/model/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -423,8 +423,8 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,

factory MentionAutocompleteView.init({
required PerAccountStore store,
required Narrow narrow,
required MentionAutocompleteQuery query,
required Narrow narrow,
}) {
final view = MentionAutocompleteView._(
store: store,
Expand Down Expand Up @@ -492,8 +492,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 @@ -598,9 +596,45 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
return userAName.compareTo(userBName); // TODO(i18n): add locale-aware sorting
}

List<WildcardMentionAutocompleteResult> get wildcardMentionResults {
final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9)
final isChannelOrTopicNarrow = narrow is ChannelNarrow || narrow is TopicNarrow;

final wildcardMentions = <WildcardMentionAutocompleteResult>[];
// Only one of the (all, everyone, channel, stream) channel wildcards are
// shown.
if (query.testWildcard(Wildcard.all)) {
wildcardMentions.add(WildcardMentionAutocompleteResult(
wildcard: Wildcard.all));
} else if (query.testWildcard(Wildcard.everyone)) {
wildcardMentions.add(WildcardMentionAutocompleteResult(
wildcard: Wildcard.everyone));
} else if (isChannelOrTopicNarrow) {
if (query.testWildcard(Wildcard.channel) && isChannelWildcardAvailable) {
wildcardMentions.add(WildcardMentionAutocompleteResult(
wildcard: Wildcard.channel));
} else if (query.testWildcard(Wildcard.stream)) {
wildcardMentions.add(WildcardMentionAutocompleteResult(
wildcard: Wildcard.stream));
}
}

final isTopicWildcardAvailable = store.account.zulipFeatureLevel >= 224; // TODO(sever-8)
if (isChannelOrTopicNarrow
&& isTopicWildcardAvailable
&& query.testWildcard(Wildcard.topic)) {
wildcardMentions.add(WildcardMentionAutocompleteResult(
wildcard: Wildcard.topic));
}
return wildcardMentions;
}

@override
Future<List<MentionAutocompleteResult>?> computeResults() async {
final results = <MentionAutocompleteResult>[];
// Give priority to wildcard mentions.
results.addAll(wildcardMentionResults);

if (await filterCandidates(filter: _testUser,
candidates: sortedUsers, results: results)) {
return null;
Expand All @@ -625,6 +659,9 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
}
}

// The available user wildcard mention options.
enum Wildcard { all, everyone, channel, stream, topic }

/// A query the user has entered into some form of autocomplete.
///
/// Subclasses correspond to different types of autocomplete interaction
Expand Down Expand Up @@ -694,9 +731,12 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
return MentionAutocompleteView.init(store: store, narrow: narrow, query: this);
}

bool testWildcard(Wildcard wildcard) {
return wildcard.name.contains(raw.toLowerCase());
}

bool testUser(User user, AutocompleteDataCache cache) {
// TODO(#236) test email too, not just name

if (!user.isActive) return false;

return _testName(user, cache);
Expand Down Expand Up @@ -778,9 +818,13 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult {
final int userId;
}

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

final Wildcard wildcard;
}

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

/// An autocomplete interaction for choosing a topic for a message.
class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, TopicAutocompleteResult> {
Expand Down
24 changes: 20 additions & 4 deletions lib/model/compose.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:math';

import '../api/model/model.dart';
import 'autocomplete.dart';
import 'internal_link.dart';
import 'narrow.dart';
import 'store.dart';
Expand Down Expand Up @@ -101,18 +102,33 @@ 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(Wildcard wildcard, {
required PerAccountStore store,
}) {
final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9)
assert(isChannelWildcardAvailable || wildcard != Wildcard.channel);
final isTopicWildcardAvailable = store.account.zulipFeatureLevel >= 224; // TODO(sever-8)
assert(isTopicWildcardAvailable || wildcard != Wildcard.topic);

final name = wildcard == Wildcard.stream && isChannelWildcardAvailable
? Wildcard.channel.name
: wildcard.name;
return '@**$name**';
}

/// 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 +161,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 +185,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')}';
}
54 changes: 47 additions & 7 deletions lib/widgets/autocomplete.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import 'package:flutter/material.dart';

import '../generated/l10n/zulip_localizations.dart';
import '../model/emoji.dart';
import '../model/store.dart';
import 'content.dart';
import 'emoji.dart';
import 'icons.dart';
import 'store.dart';
import '../model/autocomplete.dart';
import '../model/compose.dart';
Expand Down Expand Up @@ -197,7 +200,9 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
}
// TODO(i18n) language-appropriate space character; check active keyboard?
// (maybe handle centrally in `controller`)
replacementString = '${mention(store.users[userId]!, silent: query.silent, users: store.users)} ';
replacementString = '${userMention(store.users[userId]!, silent: query.silent, users: store.users)} ';
case WildcardMentionAutocompleteResult(:var wildcard):
replacementString = '${wildcardMention(wildcard, store: store)} ';
}

controller.value = intent.textEditingValue.replaced(
Expand All @@ -211,7 +216,8 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
@override
Widget buildItem(BuildContext context, int index, ComposeAutocompleteResult option) {
final child = switch (option) {
MentionAutocompleteResult() => _MentionAutocompleteItem(option: option),
MentionAutocompleteResult() => _MentionAutocompleteItem(
option: option, narrow: narrow),
EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option),
};
return InkWell(
Expand All @@ -223,28 +229,62 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
}

class _MentionAutocompleteItem extends StatelessWidget {
const _MentionAutocompleteItem({required this.option});
const _MentionAutocompleteItem({required this.option, required this.narrow});

final MentionAutocompleteResult option;
final Narrow narrow;

@override
Widget build(BuildContext context) {
final store = PerAccountStoreWidget.of(context);
Widget avatar;
String label;
Widget 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 = Text(store.users[userId]!.fullName);
case WildcardMentionAutocompleteResult(:var wildcard):
avatar = const Icon(ZulipIcons.three_person, size: 29); // web uses 19px
label = wildcardLabel(wildcard, context: context, store: store);
}

return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(children: [
avatar,
const SizedBox(width: 8),
Text(label),
label,
]));
}

Widget wildcardLabel(Wildcard wildcard, {
required BuildContext context,
required PerAccountStore store,
}) {
final isDmNarrow = narrow is DmNarrow;
final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9)
final localizations = ZulipLocalizations.of(context);
final description = switch (wildcard) {
Wildcard.all => isDmNarrow
? localizations.notifyRecipients
: isChannelWildcardAvailable
? localizations.notifyChannel
: localizations.notifyStream,
Wildcard.everyone => isDmNarrow
? localizations.notifyRecipients
: isChannelWildcardAvailable
? localizations.notifyChannel
: localizations.notifyStream,
Wildcard.channel => localizations.notifyChannel,
Wildcard.stream => isChannelWildcardAvailable
? localizations.notifyChannel
: localizations.notifyStream,
Wildcard.topic => localizations.notifyTopic,
};
return Text.rich(TextSpan(text: '${wildcard.name} ', children: [
TextSpan(text: description, style: TextStyle(fontSize: 12,
color: Colors.black.withValues(alpha: 0.8)))]));
}
}

class _EmojiAutocompleteItem extends StatelessWidget {
Expand Down
Loading

0 comments on commit 0af384f

Please sign in to comment.