diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 3b486b6ceb..8a7c6306f1 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/corner_down_right.svg b/assets/icons/corner_down_right.svg new file mode 100644 index 0000000000..ac5b587cf2 --- /dev/null +++ b/assets/icons/corner_down_right.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index b394916e33..5c173702c4 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -1388,6 +1388,14 @@ "@wildcardMentionTopicDescription": { "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message." }, + "topicAutocompleteChannelOptionLabel": "(link to channel)", + "@topicAutocompleteChannelOptionLabel": { + "description": "Label for a topic autocomplete option representing the channel." + }, + "topicAutocompleteNewOptionLabel": "New", + "@topicAutocompleteNewOptionLabel": { + "description": "Label for a topic autocomplete option matching the user's query when no exact topic exists." + }, "systemGroupNameEveryoneOnInternet": "Everyone on the internet", "@systemGroupNameEveryoneOnInternet": { "description": "Display name for the system group that includes everyone on the internet." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index afe5b70c1e..6a0723a296 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -2012,6 +2012,18 @@ abstract class ZulipLocalizations { /// **'Notify topic'** String get wildcardMentionTopicDescription; + /// Label for a topic autocomplete option representing the channel. + /// + /// In en, this message translates to: + /// **'(link to channel)'** + String get topicAutocompleteChannelOptionLabel; + + /// Label for a topic autocomplete option matching the user's query when no exact topic exists. + /// + /// In en, this message translates to: + /// **'New'** + String get topicAutocompleteNewOptionLabel; + /// Display name for the system group that includes everyone on the internet. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 8b1476727c..54d09d2b87 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -1168,6 +1168,12 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'إخطار الموضوع'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Everyone on the internet'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 5bb6cd755a..47cee01b5e 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -1194,6 +1194,12 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'Thema benachrichtigen'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Everyone on the internet'; diff --git a/lib/generated/l10n/zulip_localizations_el.dart b/lib/generated/l10n/zulip_localizations_el.dart index c0d7633707..d0874336f6 100644 --- a/lib/generated/l10n/zulip_localizations_el.dart +++ b/lib/generated/l10n/zulip_localizations_el.dart @@ -1168,6 +1168,12 @@ class ZulipLocalizationsEl extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'Notify topic'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Everyone on the internet'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index ec2ce2e1c2..16f88fd8fd 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -1168,6 +1168,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'Notify topic'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Everyone on the internet'; diff --git a/lib/generated/l10n/zulip_localizations_es.dart b/lib/generated/l10n/zulip_localizations_es.dart index fe29aa121a..8764ca397e 100644 --- a/lib/generated/l10n/zulip_localizations_es.dart +++ b/lib/generated/l10n/zulip_localizations_es.dart @@ -1168,6 +1168,12 @@ class ZulipLocalizationsEs extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'Notify topic'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Everyone on the internet'; diff --git a/lib/generated/l10n/zulip_localizations_et.dart b/lib/generated/l10n/zulip_localizations_et.dart index 7f3ffb7f9a..5cbd1c03af 100644 --- a/lib/generated/l10n/zulip_localizations_et.dart +++ b/lib/generated/l10n/zulip_localizations_et.dart @@ -1170,6 +1170,12 @@ class ZulipLocalizationsEt extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'Notify topic'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Everyone on the internet'; diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart index c40ecc2cb4..72f220de82 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -1205,6 +1205,12 @@ class ZulipLocalizationsFr extends ZulipLocalizations { String get wildcardMentionTopicDescription => 'Notifier les participants à cette conversation'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Tout le monde sur Internet'; diff --git a/lib/generated/l10n/zulip_localizations_he.dart b/lib/generated/l10n/zulip_localizations_he.dart index 84337c381f..b55669bf2d 100644 --- a/lib/generated/l10n/zulip_localizations_he.dart +++ b/lib/generated/l10n/zulip_localizations_he.dart @@ -1168,6 +1168,12 @@ class ZulipLocalizationsHe extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'Notify topic'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Everyone on the internet'; diff --git a/lib/generated/l10n/zulip_localizations_hu.dart b/lib/generated/l10n/zulip_localizations_hu.dart index 0cebf0c2a3..8a52ff5c5e 100644 --- a/lib/generated/l10n/zulip_localizations_hu.dart +++ b/lib/generated/l10n/zulip_localizations_hu.dart @@ -1168,6 +1168,12 @@ class ZulipLocalizationsHu extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'Notify topic'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Everyone on the internet'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 2433f9b3b8..0db1f7e3b2 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -1193,6 +1193,12 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'Notifica argomento'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Chiunque su internet'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 2ad07f703a..fccfb6db2c 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -1142,6 +1142,12 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'トピック参加者に通知'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'インターネット上の全員'; diff --git a/lib/generated/l10n/zulip_localizations_kk.dart b/lib/generated/l10n/zulip_localizations_kk.dart index a4b1a71b6a..7828d53adf 100644 --- a/lib/generated/l10n/zulip_localizations_kk.dart +++ b/lib/generated/l10n/zulip_localizations_kk.dart @@ -1168,6 +1168,12 @@ class ZulipLocalizationsKk extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'Notify topic'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Everyone on the internet'; diff --git a/lib/generated/l10n/zulip_localizations_lv.dart b/lib/generated/l10n/zulip_localizations_lv.dart index 679b2dc43d..d187f2bdec 100644 --- a/lib/generated/l10n/zulip_localizations_lv.dart +++ b/lib/generated/l10n/zulip_localizations_lv.dart @@ -1168,6 +1168,12 @@ class ZulipLocalizationsLv extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'Notify topic'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Everyone on the internet'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 516402903f..eb8ca32584 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -1168,6 +1168,12 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'Notify topic'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Everyone on the internet'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index c4a400f1d7..1335a7e480 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -1186,6 +1186,12 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'Powiadom w wątku'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Everyone on the internet'; diff --git a/lib/generated/l10n/zulip_localizations_pt.dart b/lib/generated/l10n/zulip_localizations_pt.dart index ef30202cb0..76f806e720 100644 --- a/lib/generated/l10n/zulip_localizations_pt.dart +++ b/lib/generated/l10n/zulip_localizations_pt.dart @@ -1168,6 +1168,12 @@ class ZulipLocalizationsPt extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'Notify topic'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Everyone on the internet'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 65d36a2b0f..1b5ed9de45 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -1198,6 +1198,12 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'Оповестить тему'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Все пользователи интернета'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 8ac7b7a949..1d715f1f74 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -1170,6 +1170,12 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'Notify topic'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Everyone on the internet'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index fbfe28d646..d9d9211211 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -1205,6 +1205,12 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'Obvesti udeležence teme'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Everyone on the internet'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 2887417edc..6b2bf7c4c9 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -1187,6 +1187,12 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'Повідомити канал'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Everyone on the internet'; diff --git a/lib/generated/l10n/zulip_localizations_vi.dart b/lib/generated/l10n/zulip_localizations_vi.dart index a0fd7199d8..9c016c2cef 100644 --- a/lib/generated/l10n/zulip_localizations_vi.dart +++ b/lib/generated/l10n/zulip_localizations_vi.dart @@ -1168,6 +1168,12 @@ class ZulipLocalizationsVi extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'Notify topic'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Everyone on the internet'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 343b2b36a0..ac2df13795 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -1168,6 +1168,12 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get wildcardMentionTopicDescription => 'Notify topic'; + @override + String get topicAutocompleteChannelOptionLabel => '(link to channel)'; + + @override + String get topicAutocompleteNewOptionLabel => 'New'; + @override String get systemGroupNameEveryoneOnInternet => 'Everyone on the internet'; diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 758d3b723c..4b850a0b24 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -20,13 +20,21 @@ extension ComposeContentAutocomplete on ComposeContentController { // To avoid spending a lot of time searching for autocomplete intents // in long messages, we bound how far back we look for the intent's start. int get _maxLookbackForAutocompleteIntent { - return 1 // intent character, e.g. "#" - + 2 // some optional characters e.g., "_" for silent mention or "**" - - // Per the API doc, maxChannelNameLength is in Unicode code points. + // Longest autocomplete syntax is the fallback topic link intent (as of 2026-02): + // [#escapedChannelName](#narrow/channel/channelId-slugifiedChannelName)>topicName + return 2 // [# + // Largest length of an escaped channel name (see `compose.escapeChannelTopicAvoidedChars`). + + 5 * store.maxChannelNameLength + + 18 // ](#narrow/channel/ + + 19 // largest channel ID (9223372036854775807 — largest int) length + + 1 // hyphen character (-) + // Largest length of a slugified channel name (see `internal_link.narrowLinkFragment`). + + 3 * store.maxChannelNameLength + + 2 // )> + // Per the API doc, maxTopicLength is in Unicode code points. // We walk the string by UTF-16 code units, and there might be one or two // of those encoding each Unicode code point. - + 2 * store.maxChannelNameLength; + + 2 * store.maxTopicLength; } AutocompleteIntent? autocompleteIntent() { @@ -48,24 +56,10 @@ extension ComposeContentAutocomplete on ComposeContentController { } final textUntilCursor = text.substring(0, selection.end); - int pos; - for (pos = selection.end - 1; pos > selection.start; pos--) { - final charAtPos = textUntilCursor[pos]; - if (charAtPos == '@') { - final match = _mentionIntentRegex.matchAsPrefix(textUntilCursor, pos); - if (match == null) continue; - } else if (charAtPos == ':') { - final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos); - if (match == null) continue; - } else if (charAtPos == '#') { - final match = _channelLinkIntentRegex.matchAsPrefix(textUntilCursor, pos); - if (match == null) continue; - } else { - continue; - } - // See comment about [TextSelection.isCollapsed] above. - return null; - } + + // See comment about [TextSelection.isCollapsed] above. + int pos = selection.start; + if (selection.isCollapsed) pos--; for (; pos >= earliest; pos--) { final charAtPos = textUntilCursor[pos]; @@ -79,9 +73,51 @@ extension ComposeContentAutocomplete on ComposeContentController { if (match == null) continue; query = EmojiAutocompleteQuery(match[1]!); } else if (charAtPos == '#') { - final match = _channelLinkIntentRegex.matchAsPrefix(textUntilCursor, pos); - if (match == null) continue; - query = ChannelLinkAutocompleteQuery(match[1] ?? match[2]!); + final channelIntentMatch = _channelLinkIntentRegex.matchAsPrefix(textUntilCursor, pos); + if (channelIntentMatch != null) { + query = ChannelLinkAutocompleteQuery(channelIntentMatch[1] ?? channelIntentMatch[2]!); + } else { + final channelLinkMatch = _channelLinkWithTopicDelimiterRegex.matchAsPrefix(textUntilCursor, pos); + if (channelLinkMatch != null) { + final channel = store.streamsByName[channelLinkMatch[1]]; + if (channel == null) break; + // Replace "#**…** >" with "#**…>" to trigger the topic autocomplete + // interaction for the channel. + value = value.replaced( + TextRange(start: pos, end: value.selection.end), + channelLink(channel, isComplete: false, store: store)); + break; + } else { + final topicIntentMatch = _topicLinkIntentRegex.matchAsPrefix(textUntilCursor, pos); + if (topicIntentMatch == null) continue; + final channelId = topicIntentMatch[1] != null + ? _channelIdFromNarrow + : store.streamsByName[topicIntentMatch[2]]?.streamId; + if (channelId == null) break; + query = TopicLinkAutocompleteQuery( + topicIntentMatch[1] ?? topicIntentMatch[3]!, channelId: channelId); + } + } + } else if (charAtPos == '[') { + final channelFallbackLinkMatch = _channelFallbackLinkWithTopicDelimiterRegex.matchAsPrefix(textUntilCursor, pos); + if (channelFallbackLinkMatch != null) { + final channelName = unescapeChannelTopicAvoidedChars(channelFallbackLinkMatch[1]!); + final channel = store.streamsByName[channelName]; + if (channel == null) break; + // Replace "[#…](#…) >" with "[#…](#…)>" to trigger the topic + // autocomplete interaction for the channel. + value = value.replaced( + TextRange(start: pos, end: value.selection.end), + channelLink(channel, isComplete: false, store: store)); + break; + } else { + final fallbackTopicIntentMatch = _fallbackTopicLinkIntentRegex.matchAsPrefix(textUntilCursor, pos); + if (fallbackTopicIntentMatch == null) continue; + final channelName = unescapeChannelTopicAvoidedChars(fallbackTopicIntentMatch[1]!); + final channelId = store.streamsByName[channelName]?.streamId; + if (channelId == null) break; + query = TopicLinkAutocompleteQuery(fallbackTopicIntentMatch[2]!, channelId: channelId); + } } else { continue; } @@ -91,6 +127,14 @@ extension ComposeContentAutocomplete on ComposeContentController { return null; } + + int? get _channelIdFromNarrow { + if (narrow case ChannelNarrow(:var streamId) || TopicNarrow(:var streamId)) { + return streamId; + } else { + return null; + } + } } extension ComposeTopicAutocomplete on ComposeTopicController { @@ -204,8 +248,8 @@ final RegExp _channelLinkIntentRegex = () { // namely the minor categories `\p{Cc}`, `\p{Cs}`, and part of `\p{Cn}`. // - https://github.com/zulip/zulip/blob/9467296e0/zerver/lib/string_validation.py#L8-L56 // - // TODO: match the server constraints - const nameCharExclusions = r'\r\n'; + // TODO: incorporate the server constraints + const nameCharExclusions = r'>\r\n'; // TODO(upstream): maybe use duplicate-named capture groups for better readability? // https://github.com/dart-lang/sdk/issues/61337 @@ -226,6 +270,92 @@ final RegExp _channelLinkIntentRegex = () { // otherwise '#**channel**' (which is a completed channel link syntax) and // any text followed by that will always match. + r'\*\*(?!\s)' + + r'((?:' + + r'[^*' + nameCharExclusions + r']' + + r'|' + + r'\*[^*' + nameCharExclusions + r']' + + r'|' + + r'[^*' + nameCharExclusions + r']\*$' + + r')*)' + + r')$'); +}(); + +/// Matches "#**…** >", a completed channel link syntax followed by an optional +/// space followed by ">". +/// +/// This indicates that the user wants to initiate a topic autocomplete +/// interaction for the channel. The match will then be replaced with "#**…>" +/// which will end up initiating the topic autocomplete interaction. +final RegExp _channelLinkWithTopicDelimiterRegex = () { + // What's likely to come just before "#**…** >" syntax: the start of the + // string, whitespace, or punctuation. Letters are unlikely. + // + // Only some punctuation, like "(", is actually likely here. We don't + // currently try to be specific about that. + const before = r'(?<=^|\s|\p{Punctuation})'; + + // In a channel name, the server accepts a wide range of characters. + // It excludes only portions of the `\p{C}` major category, + // namely the minor categories `\p{Cc}`, `\p{Cs}`, and part of `\p{Cn}`. + // - https://github.com/zulip/zulip/blob/e52f5afb7/zerver/lib/string_validation.py#L8-L56 + // + // TODO: incorporate the server constraints + const nameCharExclusions = r'*>\r\n'; + + return RegExp(unicode: true, + before + r'#\*\*([^' + nameCharExclusions + r']+)\*\*\s?>$'); +}(); + +/// Matches "[#…](#…) >", a channel fallback link followed by a space and ">". +/// +/// This indicates that the user wants to initiate a topic autocomplete +/// interaction for the channel. The match will then be replaced with "[#…](…)>" +/// which will end up initiating the topic autocomplete interaction. +final RegExp _channelFallbackLinkWithTopicDelimiterRegex = () { + // In a channel name, the server accepts a wide range of characters. + // It excludes only portions of the `\p{C}` major category, + // namely the minor categories `\p{Cc}`, `\p{Cs}`, and part of `\p{Cn}`. + // - https://github.com/zulip/zulip/blob/e52f5afb7/zerver/lib/string_validation.py#L8-L56 + // + // TODO: incorporate the server constraints + const nameCharExclusions = r'\r\n'; + + return RegExp(unicode: true, + r'\[#([^>' + nameCharExclusions + r']+)\]\(#[^)]+\)\s>$'); +}(); + +final RegExp _topicLinkIntentRegex = () { + // What's likely to come just before #channel>topic syntax: the start of the + // string, whitespace, or punctuation. Letters are unlikely. + // + // Only some punctuation, like "(", is actually likely here. We don't + // currently try to be specific about that. + const before = r'(?<=^|\s|\p{Punctuation})'; + + // In a channel/topic name, the server accepts a wide range of characters. + // It excludes only portions of the `\p{C}` major category, + // namely the minor categories `\p{Cc}`, `\p{Cs}`, and part of `\p{Cn}`. + // - https://github.com/zulip/zulip/blob/e52f5afb7/zerver/lib/string_validation.py#L8-L65 + // + // TODO: incorporate the server constraints + const nameCharExclusions = r'\r\n'; + + // TODO(dart-future): maybe use duplicate-named capture groups for better readability? + // https://github.com/dart-lang/sdk/issues/61337 + return RegExp(unicode: true, + before + + r'#' + // Match both '#>topic' (shortcut syntax) and '#**…>topic'. + + r'(?:' + // Case '#>topic'. + + r'>(?!\s)([^' + nameCharExclusions + r']*)' + + r'|' + // Case '#**…>topic'. + + r'\*\*([^*>' + nameCharExclusions + r']+)' + + r'>(?!\s)' + // Make sure that the query doesn't contain '**', otherwise '#**…>…**' + // (which is a completed topic link syntax) and any text followed by that + // will always match. + r'((?:' + r'[^*' + nameCharExclusions + r']' + r'|' @@ -236,6 +366,21 @@ final RegExp _channelLinkIntentRegex = () { + r')$'); }(); +final RegExp _fallbackTopicLinkIntentRegex = () { + // In a channel/topic name, the server accepts a wide range of characters. + // It excludes only portions of the `\p{C}` major category, + // namely the minor categories `\p{Cc}`, `\p{Cs}`, and part of `\p{Cn}`. + // - https://github.com/zulip/zulip/blob/e52f5afb7/zerver/lib/string_validation.py#L8-L65 + // + // TODO: incorporate the server constraints + const nameCharExclusions = r'\r\n'; + + return RegExp(unicode: true, + r'\[#([^>' + nameCharExclusions + r']+)\]\(#[^)]+\)' + + r'>' + + r'(?!\s)([^' + nameCharExclusions + r']*)$'); +}(); + /// The text controller's recognition that the user might want autocomplete UI. class AutocompleteIntent { AutocompleteIntent({ @@ -312,6 +457,7 @@ class AutocompleteViewManager { void handleChannelDeleteEvent(ChannelDeleteEvent event) { for (final channelId in event.channelIds) { autocompleteDataCache.invalidateChannel(channelId); + autocompleteDataCache.invalidateChannelTopic(channelId); } } @@ -319,6 +465,22 @@ class AutocompleteViewManager { autocompleteDataCache.invalidateChannel(event.streamId); } + void handleUpdateMessageEvent(UpdateMessageEvent event, {required PerAccountStore store}) { + if (event.moveData == null) return; + final UpdateMessageMoveData( + :origStreamId, :origTopic, :newStreamId, :newTopic, :propagateMode, + ) = event.moveData!; + + switch(propagateMode) { + case PropagateMode.changeOne: + case PropagateMode.changeLater: + return; + case PropagateMode.changeAll: + autocompleteDataCache.invalidateChannelTopic(origStreamId, + topic: origTopic.displayName ?? store.realmEmptyTopicDisplayName); + } + } + /// Called when the app is reassembled during debugging, e.g. for hot reload. /// /// Calls [AutocompleteView.reassemble] for all that are registered. @@ -1094,7 +1256,21 @@ class AutocompleteDataCache { List normalizedNameWordsForChannel(ZulipStream channel) { return _normalizedNameWordsByChannel[channel.streamId] - ?? normalizedNameForChannel(channel).split(' '); + ??= normalizedNameForChannel(channel).split(' '); + } + + final Map> _normalizedNamesByChannelTopic = {}; + + String normalizedNameForChannelTopic(int channelId, String topic) { + return (_normalizedNamesByChannelTopic[channelId] ??= {})[topic] + ??= AutocompleteQuery.lowercaseAndStripDiacritics(topic); + } + + final Map>> _normalizedNameWordsByChannelTopic = {}; + + List normalizedNameWordsForChannelTopic(int channelId, String topic) { + return (_normalizedNameWordsByChannelTopic[channelId] ?? {})[topic] + ??= normalizedNameForChannelTopic(channelId, topic).split(' '); } void invalidateUser(int userId) { @@ -1112,6 +1288,16 @@ class AutocompleteDataCache { _normalizedNamesByChannel.remove(channelId); _normalizedNameWordsByChannel.remove(channelId); } + + void invalidateChannelTopic(int channelId, {String? topic}) { + if (topic == null) { + _normalizedNamesByChannelTopic.remove(channelId); + _normalizedNameWordsByChannelTopic.remove(channelId); + } else { + _normalizedNamesByChannelTopic[channelId]?.remove(topic); + _normalizedNameWordsByChannelTopic[channelId]?.remove(topic); + } + } } /// A result the user chose, or might choose, from an autocomplete interaction. @@ -1581,3 +1767,201 @@ class ChannelLinkAutocompleteResult extends ComposeAutocompleteResult { // mentioned before) and also present in the description. final int rank; } + +/// An [AutocompleteView] for a #channel>topic autocomplete interaction, +/// an example of a [ComposeAutocompleteView]. +class TopicLinkAutocompleteView extends AutocompleteView { + TopicLinkAutocompleteView._({ + required super.store, + required super.query, + required this.channelId, + }); + + factory TopicLinkAutocompleteView.init({ + required PerAccountStore store, + required TopicLinkAutocompleteQuery query, + }) { + return TopicLinkAutocompleteView._(store: store, query: query, channelId: query.channelId) + .._fetch(); + } + + final int channelId; + + Iterable _topics = []; + + /// Fetches topics of the selected channel, if needed. + /// + /// When the results are fetched, this restarts the search to refresh UI + /// showing the newly fetched topics. + Future _fetch() async { + // TODO: handle fetch failure + // TODO(#2154): do not fetch topics for "only general chat" channel + _topics = (await store.topics.getChannelTopics(channelId)).map((e) => e.name); + return _startSearch(); + } + + @override + Future?> computeResults() async { + final unsorted = []; + + final channelResult = query.testChannel(channelId); + if (channelResult != null) unsorted.add(channelResult); + + if (await filterCandidates(filter: _testTopic, + candidates: _topics, results: unsorted)) { + return null; + } + + final queryTopicResult = query.testQueryTopic(TopicName(query.raw), + matchedTopics: unsorted.whereType() + .map((r) => r.topic)); + if (queryTopicResult != null) unsorted.add(queryTopicResult); + + return bucketSort(unsorted, (r) => r.rank, + numBuckets: TopicLinkAutocompleteQuery._numResultRanks); + } + + TopicLinkAutocompleteTopicResult? _testTopic(TopicLinkAutocompleteQuery query, TopicName topic) { + return query.testTopic(topic, store); + } +} + +/// A #channel>topic autocomplete query, used by [TopicLinkAutocompleteView]. +class TopicLinkAutocompleteQuery extends ComposeAutocompleteQuery { + TopicLinkAutocompleteQuery(super.raw, {required this.channelId}); + + final int channelId; + + @override + ComposeAutocompleteView initViewModel({ + required PerAccountStore store, + required ZulipLocalizations localizations, + required Narrow narrow, + }) { + return TopicLinkAutocompleteView.init(store: store, query: this); + } + + TopicLinkAutocompleteChannelResult? testChannel(int channelId) { + assert(this.channelId == channelId); + if (raw.isNotEmpty) return null; + return TopicLinkAutocompleteChannelResult( + channelId: channelId, rank: TopicLinkAutocompleteQuery._rankChannelResult); + } + + TopicLinkAutocompleteTopicResult? testTopic(TopicName topic, PerAccountStore store) { + final cache = store.autocompleteViewManager.autocompleteDataCache; + final userFacingName = topic.displayName ?? store.realmEmptyTopicDisplayName; + final matchQuality = _matchName( + normalizedName: cache.normalizedNameForChannelTopic(channelId, userFacingName), + normalizedNameWords: cache.normalizedNameWordsForChannelTopic(channelId, userFacingName)); + if (matchQuality == null) return null; + return TopicLinkAutocompleteTopicResult( + channelId: channelId, topic: topic, + rank: _rankTopicResult(matchQuality: matchQuality)); + } + + TopicLinkAutocompleteTopicResult? testQueryTopic(TopicName queryTopic, { + required Iterable matchedTopics, + }) { + assert(raw == queryTopic.apiName); + if (raw.isEmpty) return null; + final queryTopicExists = matchedTopics.any((t) => t.isSameAs(queryTopic)); + if (queryTopicExists) return null; + return TopicLinkAutocompleteTopicResult( + channelId: channelId, topic: queryTopic, isNew: true, + rank: TopicLinkAutocompleteQuery._rankTopicResult(isNewTopic: true)); + } + + /// A measure of the channel result's quality in the context of the query, + /// from 0 (best) to one less than [_numResultRanks]. + /// + /// See also [_rankTopicResult]. + static const _rankChannelResult = 0; + + /// A measure of a topic result's quality in the context of the query, + /// from 0 (best) to one less than [_numResultRanks]. + /// + /// See also [_rankChannelResult]. + static int _rankTopicResult({ + NameMatchQuality? matchQuality, + bool isNewTopic = false, + }) { + assert((matchQuality != null) ^ isNewTopic); + if (isNewTopic) return 1; + return switch(matchQuality!) { + NameMatchQuality.exact => 2, + NameMatchQuality.totalPrefix => 3, + NameMatchQuality.wordPrefixes => 4, + }; + } + + /// The number of possible values returned by [_rankResult]. + static const _numResultRanks = 5; + + @override + String toString() { + return '${objectRuntimeType(this, 'TopicLinkAutocompleteQuery')}(raw: $raw, channelId: $channelId)'; + } + + @override + bool operator ==(Object other) { + if (other is! TopicLinkAutocompleteQuery) return false; + return other.raw == raw && other.channelId == channelId; + } + + @override + int get hashCode => Object.hash('TopicLinkAutocompleteQuery', raw, channelId); +} + +/// An autocomplete result for a #channel>topic autocomplete interaction. +/// +/// This is abstract because there are two kind of results that can both be +/// offered in the same #channel>topic autocomplete interaction: one for the +/// channel and the other for its topics. +sealed class TopicLinkAutocompleteResult extends ComposeAutocompleteResult { + int get channelId; + + /// A measure of the result's quality in the context of the query. + /// + /// Used internally by [TopicLinkAutocompleteView] for ranking the results. + // Behavior we have that web doesn't and might like to follow: + // - A "word-prefixes" match quality on topic names: + // see [NameMatchQuality.wordPrefixes], which we rank on. + // + // Behavior web has that seems undesired, which we don't plan to follow: + // - A "word-boundary" match quality on topic names: + // special rank when the whole query appears contiguously + // right after a word-boundary character. + // Our [NameMatchQuality.wordPrefixes] seems smarter. + // - Ranking some case-sensitive matches differently from case-insensitive + // matches. Users will expect a lowercase query to be adequate. + int get rank; +} + +class TopicLinkAutocompleteChannelResult extends TopicLinkAutocompleteResult { + TopicLinkAutocompleteChannelResult({required this.channelId, required this.rank}); + + @override + final int channelId; + + @override + final int rank; +} + +class TopicLinkAutocompleteTopicResult extends TopicLinkAutocompleteResult { + TopicLinkAutocompleteTopicResult({ + required this.channelId, + required this.topic, + this.isNew = false, + required this.rank, + }); + + @override + final int channelId; + + final TopicName topic; + final bool isNew; + + @override + final int rank; +} diff --git a/lib/model/compose.dart b/lib/model/compose.dart index a57d7594db..c8b0078126 100644 --- a/lib/model/compose.dart +++ b/lib/model/compose.dart @@ -188,7 +188,7 @@ String userGroupMention(String userGroupName, {bool silent = false}) => // Corresponds to `topic_link_util.escape_invalid_stream_topic_characters` // in Zulip web: // https://github.com/zulip/zulip/blob/b42d3e77e/web/src/topic_link_util.ts#L15-L34 -const _channelAvoidedCharsReplacements = { +const _channelTopicAvoidedCharsReplacements = { '`': '`', '>': '>', '*': '*', @@ -198,29 +198,47 @@ const _channelAvoidedCharsReplacements = { r'$$': '$$', }; -final _channelAvoidedCharsRegex = RegExp(r'[`>*&[\]]|\$\$'); +final _channelTopicAvoidedCharsRegex = RegExp(r'[`>*&[\]]|\$\$'); +final _channelTopicAvoidedCharsReplacementsRegex = + RegExp(_channelTopicAvoidedCharsReplacements.values.join('|')); -/// Markdown link for channel when the channel name includes characters that +String escapeChannelTopicAvoidedChars(String str) { + return str.replaceAllMapped(_channelTopicAvoidedCharsRegex, + (match) => _channelTopicAvoidedCharsReplacements[match[0]]!); +} + +String unescapeChannelTopicAvoidedChars(String str) { + return str.replaceAllMapped(_channelTopicAvoidedCharsReplacementsRegex, + (match) => _channelTopicAvoidedCharsReplacements.map((k, v) => MapEntry(v, k))[match[0]]!); +} + +/// Markdown link for channel or topic whose name includes characters that /// will break normal markdown rendering. /// -/// Refer to [_channelAvoidedCharsReplacements] for a complete list of +/// Refer to [_channelTopicAvoidedCharsReplacements] for a complete list of /// these characters. // Adopted from `topic_link_util.get_fallback_markdown_link` in Zulip web; // https://github.com/zulip/zulip/blob/b42d3e77e/web/src/topic_link_util.ts#L96-L108 -String _channelFallbackMarkdownLink(ZulipStream channel, { - required PerAccountStore store, +String _channelTopicFallbackMarkdownLink(ZulipStream channel, PerAccountStore store, { + TopicName? topic, }) { + final text = StringBuffer('#${escapeChannelTopicAvoidedChars(channel.name)}'); + if (topic != null) { + text.write(' > ${escapeChannelTopicAvoidedChars(topic.displayName ?? store.realmEmptyTopicDisplayName)}'); + } + + final narrow = topic == null + ? ChannelNarrow(channel.streamId) : TopicNarrow(channel.streamId, topic); // Like Zulip web, we use a relative URL here, unlike [quoteAndReply] which // uses an absolute URL. There'd be little benefit to an absolute URL here // because this isn't a likely flow when a user wants something to copy-paste - // elsewhere: this flow normally produces `#**…**` syntax, which wouldn't work - // for that at all. And conversely, it's nice to keep reasonably short the - // markup that we put into the text box and which the user sees. Discussion: + // elsewhere: this flow normally produces `#**…**` or `#**…>…**` syntax, + // which wouldn't work for that at all. And conversely, it's nice to keep + // reasonably short the markup that we put into the text box and which the + // user sees. Discussion: // https://chat.zulip.org/#narrow/channel/101-design/topic/.22quote.20message.22.20uses.20absolute.20URL.20instead.20of.20realm-relative/near/2325588 - final relativeLink = '#${narrowLinkFragment(store, ChannelNarrow(channel.streamId))}'; + final relativeLink = '#${narrowLinkFragment(store, narrow)}'; - final text = '#${channel.name.replaceAllMapped(_channelAvoidedCharsRegex, - (match) => _channelAvoidedCharsReplacements[match[0]]!)}'; return inlineLink(text.toString(), relativeLink); } @@ -228,11 +246,30 @@ String _channelFallbackMarkdownLink(ZulipStream channel, { /// /// A plain Markdown link will be used if the channel name includes some /// characters that would break normal #**channel** rendering. -String channelLink(ZulipStream channel, {required PerAccountStore store}) { - if (_channelAvoidedCharsRegex.hasMatch(channel.name)) { - return _channelFallbackMarkdownLink(channel, store: store); +/// +/// Set [isComplete] to `false` for an incomplete syntax such as "#**…>"" +/// or "[#…](#…)>"; the one that will trigger a topic autocomplete +/// interaction for the channel. +String channelLink(ZulipStream channel, { + bool isComplete = true, + required PerAccountStore store, +}) { + if (_channelTopicAvoidedCharsRegex.hasMatch(channel.name)) { + return '${_channelTopicFallbackMarkdownLink(channel, store)}${isComplete ? '' : '>'}'; + } + return '#**${channel.name}${isComplete ? '**' : '>'}'; +} + +/// A #channel>topic link syntax of a topic, like #**announce>GSoC**. +/// +/// A plain Markdown link will be used if the channel or topic name includes +/// some characters that would break normal #**channel>topic** rendering. +String topicLink(ZulipStream channel, TopicName topic, {required PerAccountStore store}) { + if (_channelTopicAvoidedCharsRegex.hasMatch(channel.name) + || _channelTopicAvoidedCharsRegex.hasMatch(topic.apiName)) { + return _channelTopicFallbackMarkdownLink(channel, topic: topic, store); } - return '#**${channel.name}**'; + return '#**${channel.name}>${topic.apiName}**'; } /// https://spec.commonmark.org/0.30/#inline-link diff --git a/lib/model/store.dart b/lib/model/store.dart index 95478edbf9..ed24a9fb56 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -999,6 +999,7 @@ class PerAccountStore extends PerAccountStoreBase with _messages.handleUpdateMessageEvent(event); unreads.handleUpdateMessageEvent(event); topics.handleUpdateMessageEvent(event); + autocompleteViewManager.handleUpdateMessageEvent(event, store: this); case DeleteMessageEvent(): assert(debugLog("server event: delete_message ${event.messageIds}")); diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index d8561dd4a2..8a49a9ab63 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -234,6 +234,15 @@ class ComposeAutocomplete extends AutocompleteField MentionAutocompleteItem( option: option, narrow: narrow), ChannelLinkAutocompleteResult() => _ChannelLinkAutocompleteItem(option: option), + TopicLinkAutocompleteResult() => _TopicLinkAutocompleteItem(option: option), EmojiAutocompleteResult() => EmojiAutocompleteItem(option: option), }; return InkWell( @@ -404,7 +422,73 @@ class _ChannelLinkAutocompleteItem extends StatelessWidget { } } -@visibleForTesting +class _TopicLinkAutocompleteItem extends StatelessWidget { + const _TopicLinkAutocompleteItem({required this.option}); + + final TopicLinkAutocompleteResult option; + + static const _iconSize = 17.0; + static const _iconBoxSize = 24.0; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final channel = store.streams[option.channelId]; + + if (channel == null) return SizedBox.shrink(); + + final zulipLocalizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + + var labelStyle = TextStyle( + fontSize: 17, height: 20 / 17, + color: designVariables.contextMenuItemLabel, + ).merge(weightVariableTextStyle(context, wght: 500)); + + Icon icon; + String label; + String? trailingLabel; + switch (option) { + case TopicLinkAutocompleteChannelResult(): + icon = Icon(iconDataForStream(channel), size: _iconSize, + color: colorSwatchFor(context, store.subscriptions[channel.streamId])); + label = channel.name; + trailingLabel = zulipLocalizations.topicAutocompleteChannelOptionLabel; + case TopicLinkAutocompleteTopicResult(:var topic, :var isNew): + icon = Icon(ZulipIcons.corner_down_right, size: _iconSize, + color: designVariables.topicAutocompleteTopicOptionIcon); + if (topic.displayName != null) { + label = topic.displayName!; + } else { + label = store.realmEmptyTopicDisplayName; + labelStyle = labelStyle.copyWith(fontStyle: FontStyle.italic); + } + if (isNew) { + trailingLabel = zulipLocalizations.topicAutocompleteNewOptionLabel; + } + } + + return ConstrainedBox( + constraints: BoxConstraints(minHeight: 44), + child: Padding( + padding: EdgeInsetsDirectional.fromSTEB(12, 2, 10, 2), + child: Padding( + padding: option is TopicLinkAutocompleteChannelResult + ? EdgeInsets.zero + // Align the topic option icon to the center of the channel option icon. + : const EdgeInsetsDirectional.only(start: _iconSize / 2), + child: Row(spacing: 10, children: [ + SizedBox.square(dimension: _iconBoxSize, child: icon), + Expanded(child: Text(label, overflow: .ellipsis, style: labelStyle)), + if (trailingLabel != null) + Text(trailingLabel, + style: TextStyle(fontSize: 14, height: 16 / 14, fontStyle: .italic, + color: designVariables.contextMenuItemMeta)), + ]))), + ); + } +} + class EmojiAutocompleteItem extends StatelessWidget { const EmojiAutocompleteItem({super.key, required this.option}); diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 7766e2df2d..dc646934f2 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -237,11 +237,14 @@ class ComposeContentController extends ComposeController ComposeContentController({ super.text, required super.store, + required this.narrow, this.requireNotEmpty = true, }) { _update(); } + final Narrow narrow; + /// Whether to produce [ContentValidationError.empty]. final bool requireNotEmpty; @@ -1595,8 +1598,8 @@ class _EditMessageComposeBoxBody extends _ComposeBoxBody { } sealed class ComposeBoxController { - ComposeBoxController({required PerAccountStore store}) - : content = ComposeContentController(store: store); + ComposeBoxController({required PerAccountStore store, required Narrow narrow}) + : content = ComposeContentController(store: store, narrow: narrow); final ComposeContentController content; final contentFocusNode = FocusNode(); @@ -1691,7 +1694,7 @@ enum ComposeTopicInteractionStatus { } class StreamComposeBoxController extends ComposeBoxController { - StreamComposeBoxController({required super.store}) + StreamComposeBoxController({required super.store, required super.narrow}) : topic = ComposeTopicController(store: store); final ComposeTopicController topic; @@ -1722,24 +1725,26 @@ class StreamComposeBoxController extends ComposeBoxController { } class FixedDestinationComposeBoxController extends ComposeBoxController { - FixedDestinationComposeBoxController({required super.store}); + FixedDestinationComposeBoxController({required super.store, required super.narrow}); } class EditMessageComposeBoxController extends ComposeBoxController { EditMessageComposeBoxController({ required super.store, + required super.narrow, required this.messageId, required this.originalRawContent, required String? initialText, }) : _content = ComposeContentController( text: initialText, store: store, + narrow: narrow, // Editing to delete the content is a supported form of // deletion: https://zulip.com/help/delete-a-message#delete-message-content requireNotEmpty: false); - factory EditMessageComposeBoxController.empty(PerAccountStore store, int messageId) => - EditMessageComposeBoxController(store: store, messageId: messageId, + factory EditMessageComposeBoxController.empty(PerAccountStore store, Narrow narrow, int messageId) => + EditMessageComposeBoxController(store: store, narrow: narrow, messageId: messageId, originalRawContent: null, initialText: null); @override ComposeContentController get content => _content; @@ -2121,6 +2126,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM controller.dispose(); _controller = EditMessageComposeBoxController( store: store, + narrow: widget.narrow, messageId: messageId, originalRawContent: failedEdit.originalRawContent, initialText: failedEdit.newContent, @@ -2132,7 +2138,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM void _editFromRawContentFetch(int messageId) async { final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); - final emptyEditController = EditMessageComposeBoxController.empty(store, messageId); + final emptyEditController = EditMessageComposeBoxController.empty(store, widget.narrow, messageId); setState(() { controller.dispose(); _controller = emptyEditController; @@ -2210,10 +2216,10 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM _controller?.dispose(); // `?.` because this might be the first call switch (widget.narrow) { case ChannelNarrow(): - _controller = StreamComposeBoxController(store: store); + _controller = StreamComposeBoxController(store: store, narrow: widget.narrow); case TopicNarrow(): case DmNarrow(): - _controller = FixedDestinationComposeBoxController(store: store); + _controller = FixedDestinationComposeBoxController(store: store, narrow: widget.narrow); case CombinedFeedNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index b0708b1a18..88997a5cbb 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -82,131 +82,134 @@ abstract final class ZulipIcons { /// The Zulip custom icon "copy". static const IconData copy = IconData(0xf112, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "corner_down_right". + static const IconData corner_down_right = IconData(0xf113, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "edit". - static const IconData edit = IconData(0xf113, fontFamily: "Zulip Icons"); + static const IconData edit = IconData(0xf114, fontFamily: "Zulip Icons"); /// The Zulip custom icon "eye". - static const IconData eye = IconData(0xf114, fontFamily: "Zulip Icons"); + static const IconData eye = IconData(0xf115, fontFamily: "Zulip Icons"); /// The Zulip custom icon "eye_off". - static const IconData eye_off = IconData(0xf115, fontFamily: "Zulip Icons"); + static const IconData eye_off = IconData(0xf116, fontFamily: "Zulip Icons"); /// The Zulip custom icon "follow". - static const IconData follow = IconData(0xf116, fontFamily: "Zulip Icons"); + static const IconData follow = IconData(0xf117, fontFamily: "Zulip Icons"); /// The Zulip custom icon "format_quote". - static const IconData format_quote = IconData(0xf117, fontFamily: "Zulip Icons"); + static const IconData format_quote = IconData(0xf118, fontFamily: "Zulip Icons"); /// The Zulip custom icon "globe". - static const IconData globe = IconData(0xf118, fontFamily: "Zulip Icons"); + static const IconData globe = IconData(0xf119, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm". - static const IconData group_dm = IconData(0xf119, fontFamily: "Zulip Icons"); + static const IconData group_dm = IconData(0xf11a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_italic". - static const IconData hash_italic = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData hash_italic = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_sign". - static const IconData hash_sign = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData hash_sign = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "image". - static const IconData image = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData image = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inbox". - static const IconData inbox = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData inbox = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "info". - static const IconData info = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData info = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inherit". - static const IconData inherit = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData inherit = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "link". - static const IconData link = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData link = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_checked". - static const IconData message_checked = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData message_checked = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf125, fontFamily: "Zulip Icons", matchTextDirection: true); + static const IconData message_feed = IconData(0xf126, fontFamily: "Zulip Icons", matchTextDirection: true); /// The Zulip custom icon "more_horizontal". - static const IconData more_horizontal = IconData(0xf126, fontFamily: "Zulip Icons"); + static const IconData more_horizontal = IconData(0xf127, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf127, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf128, fontFamily: "Zulip Icons"); /// The Zulip custom icon "person". - static const IconData person = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData person = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "pin". - static const IconData pin = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData pin = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "pin_remove". - static const IconData pin_remove = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData pin_remove = IconData(0xf12b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "plus". - static const IconData plus = IconData(0xf12b, fontFamily: "Zulip Icons"); + static const IconData plus = IconData(0xf12c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf12c, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf12d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "remove". - static const IconData remove = IconData(0xf12d, fontFamily: "Zulip Icons"); + static const IconData remove = IconData(0xf12e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "search". - static const IconData search = IconData(0xf12e, fontFamily: "Zulip Icons"); + static const IconData search = IconData(0xf12f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "see_who_reacted". - static const IconData see_who_reacted = IconData(0xf12f, fontFamily: "Zulip Icons"); + static const IconData see_who_reacted = IconData(0xf130, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf130, fontFamily: "Zulip Icons", matchTextDirection: true); + static const IconData send = IconData(0xf131, fontFamily: "Zulip Icons", matchTextDirection: true); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf131, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf132, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf132, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf133, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf133, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf134, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf134, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf135, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf135, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf136, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf136, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf137, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf137, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf138, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf138, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf139, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topics". - static const IconData topics = IconData(0xf139, fontFamily: "Zulip Icons", matchTextDirection: true); + static const IconData topics = IconData(0xf13a, fontFamily: "Zulip Icons", matchTextDirection: true); /// The Zulip custom icon "trash". - static const IconData trash = IconData(0xf13a, fontFamily: "Zulip Icons"); + static const IconData trash = IconData(0xf13b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "two_person". - static const IconData two_person = IconData(0xf13b, fontFamily: "Zulip Icons"); + static const IconData two_person = IconData(0xf13c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf13c, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf13d, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index c96ffe84f4..ed31304db3 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -233,6 +233,7 @@ class DesignVariables extends ThemeExtension { star: const HSLColor.fromAHSL(0.5, 47, 1, 0.41).toColor(), subscriptionListHeaderLine: const HSLColor.fromAHSL(0.2, 240, 0.1, 0.5).toColor(), subscriptionListHeaderText: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.5).toColor(), + topicAutocompleteTopicOptionIcon: const HSLColor.fromAHSL(1.0, 0, 0.0, 0.58).toColor(), unreadCountBadgeTextForChannel: Colors.black.withValues(alpha: 0.9), userStatusText: const Color(0xff808080), ); @@ -344,6 +345,8 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: const HSLColor.fromAHSL(0.4, 240, 0.1, 0.75).toColor(), // TODO(design-dark) need proper dark-theme color (this is ad hoc) subscriptionListHeaderText: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.75).toColor(), + // TODO(design-dark) unchanged in dark theme? + topicAutocompleteTopicOptionIcon: const HSLColor.fromAHSL(1.0, 0, 0.0, 0.58).toColor(), unreadCountBadgeTextForChannel: Colors.white.withValues(alpha: 0.9), // TODO(design-dark) unchanged in dark theme? userStatusText: const Color(0xff808080), @@ -439,6 +442,7 @@ class DesignVariables extends ThemeExtension { required this.star, required this.subscriptionListHeaderLine, required this.subscriptionListHeaderText, + required this.topicAutocompleteTopicOptionIcon, required this.unreadCountBadgeTextForChannel, required this.userStatusText, }); @@ -547,6 +551,8 @@ class DesignVariables extends ThemeExtension { final Color star; final Color subscriptionListHeaderLine; final Color subscriptionListHeaderText; + // From the web repo: https://github.com/zulip/zulip/blob/e52f5afb7/web/styles/app_variables.css#L1313 + final Color topicAutocompleteTopicOptionIcon; final Color unreadCountBadgeTextForChannel; final Color userStatusText; // In Figma, but unnamed. @@ -641,6 +647,7 @@ class DesignVariables extends ThemeExtension { Color? star, Color? subscriptionListHeaderLine, Color? subscriptionListHeaderText, + Color? topicAutocompleteTopicOptionIcon, Color? unreadCountBadgeTextForChannel, Color? userStatusText, }) { @@ -734,6 +741,7 @@ class DesignVariables extends ThemeExtension { star: star ?? this.star, subscriptionListHeaderLine: subscriptionListHeaderLine ?? this.subscriptionListHeaderLine, subscriptionListHeaderText: subscriptionListHeaderText ?? this.subscriptionListHeaderText, + topicAutocompleteTopicOptionIcon: topicAutocompleteTopicOptionIcon ?? this.topicAutocompleteTopicOptionIcon, unreadCountBadgeTextForChannel: unreadCountBadgeTextForChannel ?? this.unreadCountBadgeTextForChannel, userStatusText: userStatusText ?? this.userStatusText, ); @@ -834,6 +842,7 @@ class DesignVariables extends ThemeExtension { star: Color.lerp(star, other.star, t)!, subscriptionListHeaderLine: Color.lerp(subscriptionListHeaderLine, other.subscriptionListHeaderLine, t)!, subscriptionListHeaderText: Color.lerp(subscriptionListHeaderText, other.subscriptionListHeaderText, t)!, + topicAutocompleteTopicOptionIcon: Color.lerp(topicAutocompleteTopicOptionIcon, other.topicAutocompleteTopicOptionIcon, t)!, unreadCountBadgeTextForChannel: Color.lerp(unreadCountBadgeTextForChannel, other.unreadCountBadgeTextForChannel, t)!, userStatusText: Color.lerp(userStatusText, other.userStatusText, t)!, ); diff --git a/test/model/autocomplete_checks.dart b/test/model/autocomplete_checks.dart index 336d166454..9a7e8cdd1a 100644 --- a/test/model/autocomplete_checks.dart +++ b/test/model/autocomplete_checks.dart @@ -37,3 +37,13 @@ extension TopicAutocompleteResultChecks on Subject { extension ChannelLinkAutocompleteResultChecks on Subject { Subject get channelId => has((r) => r.channelId, 'channelId'); } + +extension TopicLinkAutocompleteChannelResultChecks on Subject { + Subject get channelId => has((r) => r.channelId, 'channelId'); +} + +extension TopicLinkAutocompleteTopicResultChecks on Subject { + Subject get channelId => has((r) => r.channelId, 'channelId'); + Subject get topic => has((r) => r.topic, 'topic'); + Subject get isNew => has((r) => r.isNew, 'isNew'); +} diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart index d41c53b40b..a2aee7be73 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -68,6 +68,9 @@ void main() { } group('ComposeContentController.autocompleteIntent', () { + final narrowChannel = eg.stream(); + final channelNarrow = ChannelNarrow(narrowChannel.streamId); + /// Test the given input, in a convenient format. /// /// Represent selection handles as "^". For convenience, a single "^" can @@ -80,15 +83,21 @@ void main() { /// For example, "~@chris^" means the text is "@chris", the selection is /// collapsed at index 6, and we expect the syntax to start at index 0. void doTest(String markedText, ComposeAutocompleteQuery? expectedQuery, { + Narrow? narrow, + ZulipStream? channel, int? maxChannelName, + int? maxTopicName, }) { final description = expectedQuery != null ? 'in ${jsonEncode(markedText)}, query ${jsonEncode(expectedQuery.raw)}' : 'no query in ${jsonEncode(markedText)}'; test(description, () { - final store = eg.store(initialSnapshot: - eg.initialSnapshot(maxChannelNameLength: maxChannelName)); - final controller = ComposeContentController(store: store); + final store = eg.store(initialSnapshot: eg.initialSnapshot( + streams: [?channel, narrowChannel], + maxChannelNameLength: maxChannelName, + maxTopicLength: maxTopicName)); + narrow ??= channelNarrow; + final controller = ComposeContentController(store: store, narrow: narrow!); final parsed = parseMarkedText(markedText); assert((expectedQuery == null) == (parsed.expectedSyntaxStart == null)); controller.value = parsed.value; @@ -105,6 +114,7 @@ void main() { MentionAutocompleteQuery mention(String raw) => MentionAutocompleteQuery(raw, silent: false); MentionAutocompleteQuery silentMention(String raw) => MentionAutocompleteQuery(raw, silent: true); ChannelLinkAutocompleteQuery channelLink(String raw) => ChannelLinkAutocompleteQuery(raw); + TopicLinkAutocompleteQuery topicLink(String raw, {required int channelId}) => TopicLinkAutocompleteQuery(raw, channelId: channelId); EmojiAutocompleteQuery emoji(String raw) => EmojiAutocompleteQuery(raw); doTest('', null); @@ -187,12 +197,11 @@ void main() { doTest('~@Родион Романович Раскольников^', mention('Родион Романович Раскольников')); doTest('~@_Родион Романович Раскольнико^', silentMention('Родион Романович Раскольнико')); - // "@" sign can be (3 + 2 * maxChannelName) utf-16 code units - // away to the left of the cursor. - doTest('If ~@chris^ is around, please ask him.', mention('chris'), maxChannelName: 10); - doTest('If ~@_chris is^ around, please ask him.', silentMention('chris is'), maxChannelName: 10); - doTest('If @chris is around, please ask him.^', null, maxChannelName: 10); - doTest('If @_chris is around, please ask him.^', null, maxChannelName: 10); + // "@" sign can be (8 * maxChannelName + 2 * maxTopicName + 42) utf-16 + // code units away to the left of the cursor. + // See: ComposeContentAutocomplete._maxLookbackForAutocompleteIntent + doTest('If ~@chris is ${'is' * 21}^', mention('chris is ${'is' * 21}'), maxChannelName: 1, maxTopicName: 1); + doTest('If @chris is ${'is' * 21} ^', null, maxChannelName: 1, maxTopicName: 1); // Emoji (":smile:"). @@ -322,7 +331,6 @@ void main() { // Query can contain a wide range of characters. doTest('~#`^', channelLink('`')); doTest('~#a`b^', channelLink('a`b')); doTest('~#"^', channelLink('"')); doTest('~#a"b^', channelLink('a"b')); - doTest('~#>^', channelLink('>')); doTest('~#a>b^', channelLink('a>b')); doTest('~#&^', channelLink('&')); doTest('~#a&b^', channelLink('a&b')); doTest('~#_^', channelLink('_')); doTest('~#a_b^', channelLink('a_b')); doTest('~#*^', channelLink('*')); doTest('~#a*b^', channelLink('a*b')); @@ -346,15 +354,175 @@ void main() { doTest('#**a\r^', null); doTest('#**\ra^', null); doTest('#**a\rb^', null); doTest('#**a\r\n^', null); doTest('#**\r\na^', null); doTest('#**a\r\nb^', null); - // "#" sign can be (3 + 2 * maxChannelName) utf-16 code units - // away to the left of the cursor. - doTest('check ~#**mobile dev^ team', channelLink('mobile dev'), maxChannelName: 5); - doTest('check ~#mobile dev t^eam', channelLink('mobile dev t'), maxChannelName: 5); - doTest('check #mobile dev te^am', null, maxChannelName: 5); - doTest('check #mobile dev team for more info^', null, maxChannelName: 5); + // "#" sign can be (8 * maxChannelName + 2 * maxTopicName + 42) utf-16 + // code units away to the left of the cursor. + // See: ComposeContentAutocomplete._maxLookbackForAutocompleteIntent + doTest('check ~#**mobile ${'is' * 21}^', channelLink('mobile ${'is' * 21}'), + maxChannelName: 1, maxTopicName: 1); + doTest('check ~#mobile u ${'is' * 21}^', channelLink('mobile u ${'is' * 21}'), + maxChannelName: 1, maxTopicName: 1); + doTest('check #mobile ui ${'is' * 21}^', null, + maxChannelName: 1, maxTopicName: 1); // '🙂' is 2 utf-16 code units. - doTest('check ~#**🙂🙂🙂🙂🙂^', channelLink('🙂🙂🙂🙂🙂'), maxChannelName: 5); - doTest('check #**🙂🙂🙂🙂🙂🙂^', null, maxChannelName: 5); + doTest('check ~#${'🙂' * 25} ^', channelLink('${'🙂' * 25} '), + maxChannelName: 1, maxTopicName: 1); + doTest('check #${'🙂' * 26}^', null, maxChannelName: 1, maxTopicName: 1); + + // #channel>topic links. + + var channel = eg.stream(name: '…'); + // ignore: no_leading_underscores_for_local_identifiers + void _doTest(String markedText, ComposeAutocompleteQuery? expectedQuery, { + int? maxChannelName, int? maxTopicName, + }) => doTest(markedText, expectedQuery, channel: channel, maxChannelName: maxChannelName, maxTopicName: maxTopicName); + // ignore: no_leading_underscores_for_local_identifiers + TopicLinkAutocompleteQuery _topicLink(String raw, {bool shortcut = false}) => + topicLink(raw, channelId: shortcut ? narrowChannel.streamId : channel.streamId); + + _doTest('^#**…>', null); _doTest('^#>', null); _doTest('^[#…](#…)>', null); + _doTest('^#**…>abc', null); _doTest('^#>abc', null); _doTest('^[#…](#…)>abc', null); + _doTest('#**…>abc', null); _doTest('#>abc', null); _doTest('[#…](#…)>abc', null); // (no cursor) + + // Link syntax can be at the start of a string. + _doTest('~#**…>^', _topicLink('')); + _doTest('~#>^', _topicLink('', shortcut: true)); + _doTest('~[#…](#…)>^', _topicLink('')); + + _doTest('~#**…>abc^', _topicLink('abc')); + _doTest('~#>abc^', _topicLink('abc', shortcut: true)); + _doTest('~[#…](#…)>abc^', _topicLink('abc')); + + // Link syntax can contain multiple words. + _doTest('~#**…>abc ^', _topicLink('abc ')); + _doTest('~#>abc ^', _topicLink('abc ', shortcut: true)); + _doTest('~[#…](#…)>abc ^', _topicLink('abc ')); + + _doTest('~#**…>abc def^', _topicLink('abc def')); + _doTest('~#>abc def^', _topicLink('abc def', shortcut: true)); + _doTest('~[#…](#…)>abc def^', _topicLink('abc def')); + + // Link syntax can come after a word or space. + _doTest('xyz ~#**…>abc^', _topicLink('abc')); + _doTest('xyz ~#>abc^', _topicLink('abc', shortcut: true)); + _doTest('xyz ~[#…](#…)>abc^', _topicLink('abc')); + + _doTest(' ~#**…>abc^', _topicLink('abc')); + _doTest(' ~#>abc^', _topicLink('abc', shortcut: true)); + _doTest(' ~[#…](#…)>abc^', _topicLink('abc')); + + // Link syntax can come after punctuation… + _doTest(':~#**…>abc^', _topicLink('abc')); + _doTest(':~#>abc^', _topicLink('abc', shortcut: true)); + _doTest(':~[#…](#…)>abc^', _topicLink('abc')); + + _doTest('!~#**…>abc^', _topicLink('abc')); + _doTest('!~#>abc^', _topicLink('abc', shortcut: true)); + _doTest('!~[#…](#…)>abc^', _topicLink('abc')); + + _doTest(',~#**…>abc^', _topicLink('abc')); + _doTest(',~#>abc^', _topicLink('abc', shortcut: true)); + _doTest(',~[#…](#…)>abc^', _topicLink('abc')); + + _doTest('.~#**…>abc^', _topicLink('abc')); + _doTest('.~#>abc^', _topicLink('abc', shortcut: true)); + _doTest('.~[#…](#…)>abc^', _topicLink('abc')); + + _doTest('(~#**…>abc^', _topicLink('abc')); _doTest(')~#**…>abc^', _topicLink('abc')); + _doTest('(~#>abc^', _topicLink('abc', shortcut: true)); _doTest(')~#>abc^', _topicLink('abc', shortcut: true)); + _doTest('(~[#…](#…)>abc^', _topicLink('abc')); _doTest(')~[#…](#…)>abc^', _topicLink('abc')); + + _doTest('{~#**…>abc^', _topicLink('abc')); _doTest('}~#**…>abc^', _topicLink('abc')); + _doTest('{~#>abc^', _topicLink('abc', shortcut: true)); _doTest('}~#>abc^', _topicLink('abc', shortcut: true)); + _doTest('{~[#…](#…)>abc^', _topicLink('abc')); _doTest('}~[#…](#…)>abc^', _topicLink('abc')); + + _doTest('[~#**…>abc^', _topicLink('abc')); _doTest(']~#**…>abc^', _topicLink('abc')); + _doTest('[~#>abc^', _topicLink('abc', shortcut: true)); _doTest(']~#>abc^', _topicLink('abc', shortcut: true)); + _doTest('[~[#…](#…)>abc^', _topicLink('abc')); _doTest(']~[#…](#…)>abc^', _topicLink('abc')); + + _doTest('“~#**…>abc^', _topicLink('abc')); _doTest('”~#**…>abc^', _topicLink('abc')); + _doTest('“~#>abc^', _topicLink('abc', shortcut: true)); _doTest('”~#>abc^', _topicLink('abc', shortcut: true)); + _doTest('“~[#…](#…)>abc^', _topicLink('abc')); _doTest('”~[#…](#…)>abc^', _topicLink('abc')); + + _doTest('«~#**…>abc^', _topicLink('abc')); _doTest('»~#**…>abc^', _topicLink('abc')); + _doTest('«~#>abc^', _topicLink('abc', shortcut: true)); _doTest('»~#>abc^', _topicLink('abc', shortcut: true)); + _doTest('«~[#…](#…)>abc^', _topicLink('abc')); _doTest('»~[#…](#…)>abc^', _topicLink('abc')); + + // Query can't start with a space; topic names don't. + _doTest('#**…> ^', null); _doTest('#> ^', null); _doTest('[#…](#…)> ^', null); + _doTest('#**…> abc^', null); _doTest('#> abc^', null); _doTest('[#…](#…)> abc^', null); + + // Query shouldn't be multiple lines. + _doTest('#**…>\n^', null); _doTest('#**…>a\n^', null); _doTest('#**…>\na^', null); _doTest('#**…>a\nb^', null); + _doTest('#>\n^', null); _doTest('#>a\n^', null); _doTest('#>\na^', null); _doTest('#>a\nb^', null); + _doTest('[#…](#…)>\n^', null); _doTest('[#…](#…)>a\n^', null); _doTest('[#…](#…)>\na^', null); _doTest('[#…](#…)>a\nb^', null); + + _doTest('#**…>\r^', null); _doTest('#**…>a\r^', null); _doTest('#**…>\ra^', null); _doTest('#**…>a\rb^', null); + _doTest('#>\r^', null); _doTest('#>a\r^', null); _doTest('#>\ra^', null); _doTest('#>a\rb^', null); + _doTest('[#…](#…)>\r^', null); _doTest('[#…](#…)>a\r^', null); _doTest('[#…](#…)>\ra^', null); _doTest('[#…](#…)>a\rb^', null); + + _doTest('#**…>\r\n^', null); _doTest('#**…>a\r\n^', null); _doTest('#**…>\r\na^', null); _doTest('#**…>a\r\nb^', null); + _doTest('#>\r\n^', null); _doTest('#>a\r\n^', null); _doTest('#>\r\na^', null); _doTest('#>a\r\nb^', null); + _doTest('[#…](#…)>\r\n^', null); _doTest('[#…](#…)>a\r\n^', null); _doTest('[#…](#…)>\r\na^', null); _doTest('[#…](#…)>a\r\nb^', null); + + // Query can contain a wide range of characters. + _doTest('~#**…>`^', _topicLink('`')); _doTest('~#**…>a`b^', _topicLink('a`b')); + _doTest('~#>`^', _topicLink('`', shortcut: true)); _doTest('~#>a`b^', _topicLink('a`b', shortcut: true)); + _doTest('~[#…](#…)>`^', _topicLink('`')); _doTest('~[#…](#…)>a`b^', _topicLink('a`b')); + + _doTest('~#**…>"^', _topicLink('"')); _doTest('~#**…>a"b^', _topicLink('a"b')); + _doTest('~#>"^', _topicLink('"', shortcut: true)); _doTest('~#>a"b^', _topicLink('a"b', shortcut: true)); + _doTest('~[#…](#…)>"^', _topicLink('"')); _doTest('~[#…](#…)>a"b^', _topicLink('a"b')); + + _doTest('~#**…>>^', _topicLink('>')); _doTest('~#**…>a>b^', _topicLink('a>b')); + _doTest('~#>>^', _topicLink('>', shortcut: true)); _doTest('~#>a>b^', _topicLink('a>b', shortcut: true)); + _doTest('~[#…](#…)>>^', _topicLink('>')); _doTest('~[#…](#…)>a>b^', _topicLink('a>b')); + + _doTest('~#**…>&^', _topicLink('&')); _doTest('~#**…>a&b^', _topicLink('a&b')); + _doTest('~#>&^', _topicLink('&', shortcut: true)); _doTest('~#>a&b^', _topicLink('a&b', shortcut: true)); + _doTest('~[#…](#…)>&^', _topicLink('&')); _doTest('~[#…](#…)>a&b^', _topicLink('a&b')); + + _doTest('~#**…>_^', _topicLink('_')); _doTest('~#**…>a_b^', _topicLink('a_b')); + _doTest('~#>_^', _topicLink('_', shortcut: true)); _doTest('~#>a_b^', _topicLink('a_b', shortcut: true)); + _doTest('~[#…](#…)>_^', _topicLink('_')); _doTest('~[#…](#…)>a_b^', _topicLink('a_b')); + + _doTest('~#**…>*^', _topicLink('*')); _doTest('~#**…>a*b^', _topicLink('a*b')); + _doTest('~#>*^', _topicLink('*', shortcut: true)); _doTest('~#>a*b^', _topicLink('a*b', shortcut: true)); + _doTest('~[#…](#…)>*^', _topicLink('*')); _doTest('~[#…](#…)>a*b^', _topicLink('a*b')); + + // Avoid interpreting already-entered `#**foo>bar**` or `[#foo>bar](#…)` + // syntax as queries. + _doTest('#**…>…**^', null); + _doTest('#**…>…** ^', null); + _doTest('#**…>…** abc^', null); + _doTest('[#…>…](#…)>^', null); + _doTest('[#…>…](#…)>abc^', null); + + // Different placements of "*" syntax character in the query. + _doTest('~#**…>ab*c^', _topicLink('ab*c')); + _doTest('~#>ab*c^', _topicLink('ab*c', shortcut: true)); + _doTest('~[#…](#…)>ab*c^', _topicLink('ab*c')); + + _doTest('~#**…>abc*^', _topicLink('abc*')); + _doTest('~#>abc*^', _topicLink('abc*', shortcut: true)); + _doTest('~[#…](#…)>abc*^', _topicLink('abc*')); + + _doTest('#**…>abc**^', null); // `#**foo>bar**` case above. + _doTest('~#>abc**^', _topicLink('abc**', shortcut: true)); + _doTest('~[#…](#…)>abc**^', _topicLink('abc**')); + + // "#" or "[" sign can be (8 * maxChannelName + 2 * maxTopicName + 42) + // utf-16 code units away to the left of the cursor. + // See: ComposeContentAutocomplete._maxLookbackForAutocompleteIntent + _doTest('check ~#**…>topic${'is' * 21}^', _topicLink('topic${'is' * 21}'), + maxChannelName: 1, maxTopicName: 1); + _doTest('check #**…>topic ${'is' * 21}^', null, + maxChannelName: 1, maxTopicName: 1); + // '🙂' is 2 utf-16 code units. + channel = eg.stream(name: '&'); + _doTest('check ~[#&](#narrow/channel/9223372036854775807-.26)>🙂^', _topicLink('🙂'), + maxChannelName: 1, maxTopicName: 1); + _doTest('check [#&](#narrow/channel/9223372036854775807-.26)>🙂 ^', null, + maxChannelName: 1, maxTopicName: 1); }); test('MentionAutocompleteView misc', () async { @@ -1989,6 +2157,256 @@ void main() { }); }); }); + + group('TopicLinkAutocompleteView', () { + Condition isChannelResult(int channelId) { + return (it) => it.isA() + .channelId.equals(channelId); + } + + Condition isTopicResult(int channelId, TopicName topic, { + bool isNew = false, + }) { + return (it) => it.isA() + ..channelId.equals(channelId) + ..topic.equals(topic) + ..isNew.equals(isNew); + } + + test('misc', () async { + final store = eg.store(); + final connection = store.connection as FakeApiConnection; + final channel = eg.stream(); + final topic1 = eg.getChannelTopicsEntry(maxId: 20); + final topic2 = eg.getChannelTopicsEntry(maxId: 10); + connection.prepare(json: GetChannelTopicsResult( + topics: [topic1, topic2]).toJson()); + + final view = TopicLinkAutocompleteView.init(store: store, + query: TopicLinkAutocompleteQuery('', channelId: channel.streamId)); + bool done = false; + view.addListener(() { done = true; }); + + await Future(() {}); + await Future(() {}); + check(done).isTrue(); + check(view.results).deepEquals([ + isChannelResult(channel.streamId), + isTopicResult(channel.streamId, topic1.name), + isTopicResult(channel.streamId, topic2.name), + ]); + }); + + test('updates results when topics are loaded', () => awaitFakeAsync((async) async { + final store = eg.store(); + final connection = store.connection as FakeApiConnection; + final channel = eg.stream(); + final topic1 = eg.getChannelTopicsEntry(maxId: 20); + final topic2 = eg.getChannelTopicsEntry(maxId: 10); + connection.prepare(delay: Duration(milliseconds: 1), + json: GetChannelTopicsResult(topics: [topic1, topic2]).toJson()); + + final view = TopicLinkAutocompleteView.init(store: store, + query: TopicLinkAutocompleteQuery('', channelId: channel.streamId)); + bool done = false; + view.addListener(() { done = true; }); + + await Future(() {}); + check(done).isTrue(); + check(view.results).single.which(isChannelResult(channel.streamId)); + + async.elapse(Duration(milliseconds: 1)); + check(view.results).deepEquals([ + isChannelResult(channel.streamId), + isTopicResult(channel.streamId, topic1.name), + isTopicResult(channel.streamId, topic2.name), + ]); + })); + + test('non-empty query, no channel option in the results', () async { + final store = eg.store(); + final connection = store.connection as FakeApiConnection; + final channel = eg.stream(); + final topic1 = eg.getChannelTopicsEntry(maxId: 20, name: 'First'); + final topic2 = eg.getChannelTopicsEntry(maxId: 10, name: 'Second'); + connection.prepare(json: GetChannelTopicsResult( + topics: [topic1, topic2]).toJson()); + + final view = TopicLinkAutocompleteView.init(store: store, + query: TopicLinkAutocompleteQuery('first', channelId: channel.streamId)); + bool done = false; + view.addListener(() { done = true; }); + + await Future(() {}); + await Future(() {}); + check(done).isTrue(); + check(view.results).single.which(isTopicResult(channel.streamId, topic1.name)); + }); + + test("query doesn't exactly match any topic -> a new topic result is added to the results' start", () async { + final store = eg.store(); + final connection = store.connection as FakeApiConnection; + final channel = eg.stream(); + final topic1 = eg.getChannelTopicsEntry(maxId: 20, name: 'First'); + final topic2 = eg.getChannelTopicsEntry(maxId: 10, name: 'Second'); + connection.prepare(json: GetChannelTopicsResult( + topics: [topic1, topic2]).toJson()); + + final view = TopicLinkAutocompleteView.init(store: store, + query: TopicLinkAutocompleteQuery('fir', channelId: channel.streamId)); + bool done = false; + view.addListener(() { done = true; }); + + await Future(() {}); + await Future(() {}); + check(done).isTrue(); + check(view.results).deepEquals([ + isTopicResult(channel.streamId, eg.t('fir'), isNew: true), + isTopicResult(channel.streamId, topic1.name), + ]); + + view.query = TopicLinkAutocompleteQuery('first topic', channelId: channel.streamId); + await Future(() {}); + check(view.results).single.which( + isTopicResult(channel.streamId, eg.t('first topic'), isNew: true)); + }); + }); + + group('TopicLinkAutocompleteQuery', () { + late PerAccountStore store; + + void doCheck(String rawQuery, String topicApiName, bool expected) { + final result = TopicLinkAutocompleteQuery(rawQuery, channelId: 1) + .testTopic(eg.t(topicApiName), store); + expected + ? check(result).isA() + : check(result).isNull(); + } + + test('testTopic: topic is included if name words match the query', () { + store = eg.store(initialSnapshot: eg.initialSnapshot( + realmEmptyTopicDisplayName: 'general chat')); + + doCheck('', 'Topic Name', true); + doCheck('Topic Name', 'Topic Name', true); + doCheck('topic name', 'Topic Name', true); + doCheck('Topic Name', 'topic name', true); + doCheck('Topic', 'Topic Name', true); + doCheck('Name', 'Topic Name', true); + doCheck('Topic Name', 'Topics Names', true); + doCheck('Topic Four', 'Topic Name Four Words', true); + doCheck('Name Words', 'Topic Name Four Words', true); + doCheck('Topic F', 'Topic Name Four Words', true); + doCheck('T Four', 'Topic Name Four Words', true); + doCheck('topic topic', 'Topic Topic Name', true); + doCheck('topic topic', 'Topic Name Topic', true); + + // the "general chat" topic cases + doCheck('', '', true); + doCheck('General Chat', '', true); + doCheck('general chat', '', true); + doCheck('General', '', true); + doCheck('chat', '', true); + + doCheck('Topics Names', 'Topic Name', false); + doCheck('Topic Name', 'Topic', false); + doCheck('Topic Name', 'Name', false); + doCheck('pic ame', 'Topic Name', false); + doCheck('pic Name', 'Topic Name', false); + doCheck('Topic ame', 'Topic Name', false); + doCheck('Topic Topic', 'Topic Name', false); + doCheck('Name Name', 'Topic Name', false); + doCheck('Name Topic', 'Topic Name', false); + doCheck('Name Four Topic Words', 'Topic Name Four Words', false); + doCheck('F Topic', 'Topic Name Four Words', false); + doCheck('Four T', 'Topic Name Four Words', false); + // the "general chat" topic case + doCheck('generals chats', '', false); + }); + + group('ranking', () { + final queryChannel = eg.stream(); + + int rankOf(String queryStr, dynamic candidate, {List? matchedTopics}) { + final query = TopicLinkAutocompleteQuery(queryStr, channelId: queryChannel.streamId); + final TopicLinkAutocompleteResult? result; + switch(candidate) { + case ZulipStream(): + result = query.testChannel(candidate.streamId); + case TopicName(): + result = matchedTopics == null + ? query.testTopic(candidate, store) + : query.testQueryTopic(candidate, matchedTopics: matchedTopics); + default: + throw StateError('invalid candidate'); + } + // (i.e. throw here if it's not a match) + return result!.rank; + } + + void checkPrecedes(String query, TopicName a, TopicName b) { + check(rankOf(query, a)).isLessThan(rankOf(query, b)); + } + + void checkAllSameRank(String query, Iterable topics) { + final firstRank = rankOf(query, topics.first); + final remainingRanks = topics.skip(1).map((e) => rankOf(query, e)); + check(remainingRanks).every((it) => it.equals(firstRank)); + } + + test('topic name match is case- and diacritics-insensitive', () { + store = eg.store(); + final topics = [ + eg.t('Über Cars'), + eg.t('über cars'), + eg.t('Uber Cars'), + eg.t('uber cars'), + ]; + + checkAllSameRank('Über Cars', topics); // exact + checkAllSameRank('über cars', topics); // exact + checkAllSameRank('Uber Cars', topics); // exact + checkAllSameRank('uber cars', topics); // exact + + checkAllSameRank('Über Ca', topics); // total-prefix + checkAllSameRank('über ca', topics); // total-prefix + checkAllSameRank('Uber Ca', topics); // total-prefix + checkAllSameRank('uber ca', topics); // total-prefix + + checkAllSameRank('Üb Ca', topics); // word-prefixes + checkAllSameRank('üb ca', topics); // word-prefixes + checkAllSameRank('Ub Ca', topics); // word-prefixes + checkAllSameRank('ub ca', topics); // word-prefixes + }); + + test('topic name match: exact over total-prefix', () { + store = eg.store(); + final topic1 = eg.t('Resume'); + final topic2 = eg.t('Resume Tips'); + checkPrecedes('resume', topic1, topic2); + }); + + test('topic name match: total-prefix over word-prefixes', () { + store = eg.store(); + final topic1 = eg.t('So Many Ideas'); + final topic2 = eg.t('Some Modern Topic'); + checkPrecedes('so m', topic1, topic2); + }); + + test('full list of ranks', () { + store = eg.store(); + final topic = eg.t('some topic'); + check([ + rankOf('', queryChannel), // channel + // query topic is new + rankOf('some', eg.t('some'), matchedTopics: [topic]), + rankOf('some topic', topic), // exact name match + rankOf('some to', topic), // total-prefix name match + rankOf('so to', topic), // word-prefixes name match + ]).deepEquals([0, 1, 2, 3, 4]); + }); + }); + }); } typedef WildcardTester = void Function(String query, Narrow narrow, List expected); diff --git a/test/model/compose_test.dart b/test/model/compose_test.dart index 96c620f587..4d76bedf42 100644 --- a/test/model/compose_test.dart +++ b/test/model/compose_test.dart @@ -353,9 +353,10 @@ hello check(channelLink(channels[0], store: store)).equals('#**mobile**'); check(channelLink(channels[1], store: store)).equals('#**dev-ops**'); check(channelLink(channels[2], store: store)).equals('#**ui/ux**'); - check(channelLink(channels[3], store: store)).equals('#**api_v3**'); - check(channelLink(channels[4], store: store)).equals('#**build+test**'); - check(channelLink(channels[5], store: store)).equals('#**init()**'); + + check(channelLink(channels[3], isComplete: false, store: store)).equals('#**api_v3>'); + check(channelLink(channels[4], isComplete: false, store: store)).equals('#**build+test>'); + check(channelLink(channels[5], isComplete: false, store: store)).equals('#**init()>'); }); test('channels with names containing avoided characters', () async { @@ -373,9 +374,62 @@ hello check(channelLink(channels[1 - 1], store: store)).equals('[#`code`](#narrow/channel/1-.60code.60)'); check(channelLink(channels[2 - 1], store: store)).equals('[#score > 90](#narrow/channel/2-score-.3E-90)'); check(channelLink(channels[3 - 1], store: store)).equals('[#A*](#narrow/channel/3-A*)'); - check(channelLink(channels[4 - 1], store: store)).equals('[#R&D](#narrow/channel/4-R.26D)'); - check(channelLink(channels[5 - 1], store: store)).equals('[#UI [v2]](#narrow/channel/5-UI-.5Bv2.5D)'); - check(channelLink(channels[6 - 1], store: store)).equals('[#Save $$](#narrow/channel/6-Save-.24.24)'); + + check(channelLink(channels[4 - 1], isComplete: false, store: store)) + .equals('[#R&D](#narrow/channel/4-R.26D)>'); + check(channelLink(channels[5 - 1], isComplete: false, store: store)) + .equals('[#UI [v2]](#narrow/channel/5-UI-.5Bv2.5D)>'); + check(channelLink(channels[6 - 1], isComplete: false, store: store)) + .equals('[#Save $$](#narrow/channel/6-Save-.24.24)>'); + }); + }); + + group('topic link', () { + test('channel and topic with normal names', () async { + final store = eg.store(); + final channels = [ + eg.stream(name: 'mobile'), + eg.stream(name: 'dev-ops'), + eg.stream(name: 'ui/ux'), + ]; + final topics = [ + eg.t('api_v3'), + eg.t('build+test'), + eg.t('init()'), + ]; + + check(topicLink(channels[0], topics[0], store: store)) + .equals('#**mobile>api_v3**'); + check(topicLink(channels[1], topics[1], store: store)) + .equals('#**dev-ops>build+test**'); + check(topicLink(channels[2], topics[2], store: store)) + .equals('#**ui/ux>init()**'); + }); + + test('channel or topic with names containing avoided characters', () async { + final store = eg.store(); + final channels = [ + eg.stream(streamId: 1, name: 'mobile'), + eg.stream(streamId: 2, name: '`code`'), + eg.stream(streamId: 3, name: 'score > 90'), + eg.stream(streamId: 4, name: 'A*'), + ]; + final topics = [ + eg.t('R&D'), + eg.t('dev-ops'), + eg.t('UI [v2]'), + eg.t(r'Save $$'), + ]; + await store.addStreams(channels); + + check(topicLink(channels[1 - 1], topics[1 - 1], store: store)) + .equals('[#mobile > R&D](#narrow/channel/1-mobile/topic/R.26D)'); + check(topicLink(channels[2 - 1], topics[2 - 1], store: store)) + .equals('[#`code` > dev-ops](#narrow/channel/2-.60code.60/topic/dev-ops)'); + check(topicLink(channels[3 - 1], topics[3 - 1], store: store)) + .equals('[#score > 90 > UI [v2]](#narrow/channel/3-score-.3E-90/topic/UI.20.5Bv2.5D)'); + check(topicLink(channels[4 - 1], topics[4 - 1], store: store)) + .equals('[#A* > Save $$](#narrow/channel/4-A*/topic/Save.20.24.24)'); }); }); diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 96689691bd..0fa765a218 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -1757,11 +1757,11 @@ void main() { await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e } - void checkLoadingState(PerAccountStore store, ComposeContentController contentController, { + void checkLoadingState(PerAccountStore store, Narrow narrow, ComposeContentController contentController, { required TextEditingValue valueBefore, required Message message, }) { - check(contentController).value.equals((ComposeContentController(store: store) + check(contentController).value.equals((ComposeContentController(store: store, narrow: narrow) ..value = valueBefore ..insertPadded(quoteAndReplyPlaceholder( GlobalLocalizations.zulipLocalizations, store, message: message)) @@ -1769,12 +1769,12 @@ void main() { check(contentController).validationErrors.contains(ContentValidationError.quoteAndReplyInProgress); } - void checkSuccessState(PerAccountStore store, ComposeContentController contentController, { + void checkSuccessState(PerAccountStore store, Narrow narrow, ComposeContentController contentController, { required TextEditingValue valueBefore, required Message message, required String rawContent, }) { - final builder = ComposeContentController(store: store) + final builder = ComposeContentController(store: store, narrow: narrow) ..value = valueBefore ..insertPadded(quoteAndReply(store, message: message, rawContent: rawContent)); if (!valueBefore.selection.isValid) { @@ -1788,7 +1788,8 @@ void main() { testWidgets('in channel narrow with different, non-vacuous topic', (tester) async { final message = eg.streamMessage(topic: 'some topic'); - await setupToMessageActionSheet(tester, message: message, narrow: ChannelNarrow(message.streamId)); + final narrow = ChannelNarrow(message.streamId); + await setupToMessageActionSheet(tester, message: message, narrow: narrow); final composeBoxController = findComposeBoxController(tester) as StreamComposeBoxController; final contentController = composeBoxController.content; @@ -1802,10 +1803,10 @@ void main() { final valueBefore = contentController.value; prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); await tapQuoteAndReplyButton(tester); - checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); + checkLoadingState(store, narrow, contentController, valueBefore: valueBefore, message: message); await tester.pump(Duration.zero); // message is fetched; compose box updates check(composeBoxController.contentFocusNode.hasFocus).isTrue(); - checkSuccessState(store, contentController, + checkSuccessState(store, narrow, contentController, valueBefore: valueBefore, message: message, rawContent: 'Hello world'); check(topicController).textNormalized.equals('other topic'); }); @@ -1813,7 +1814,8 @@ void main() { testWidgets('in channel narrow with empty topic', (tester) async { // Regression test for https://github.com/zulip/zulip-flutter/issues/1469 final message = eg.streamMessage(topic: 'some topic'); - await setupToMessageActionSheet(tester, message: message, narrow: ChannelNarrow(message.streamId)); + final narrow = ChannelNarrow(message.streamId); + await setupToMessageActionSheet(tester, message: message, narrow: narrow); final composeBoxController = findComposeBoxController(tester) as StreamComposeBoxController; final contentController = composeBoxController.content; @@ -1827,10 +1829,10 @@ void main() { final valueBefore = contentController.value; prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); await tapQuoteAndReplyButton(tester); - checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); + checkLoadingState(store, narrow, contentController, valueBefore: valueBefore, message: message); await tester.pump(Duration.zero); // message is fetched; compose box updates check(composeBoxController.contentFocusNode.hasFocus).isTrue(); - checkSuccessState(store, contentController, + checkSuccessState(store, narrow, contentController, valueBefore: valueBefore, message: message, rawContent: 'Hello world'); check(topicController).textNormalized.equals('some topic'); }); @@ -1838,7 +1840,8 @@ void main() { group('in topic narrow', () { testWidgets('smoke', (tester) async { final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + final narrow = TopicNarrow.ofMessage(message); + await setupToMessageActionSheet(tester, message: message, narrow: narrow); final composeBoxController = findComposeBoxController(tester)!; final contentController = composeBoxController.content; @@ -1846,10 +1849,10 @@ void main() { final valueBefore = contentController.value; prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); await tapQuoteAndReplyButton(tester); - checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); + checkLoadingState(store, narrow, contentController, valueBefore: valueBefore, message: message); await tester.pump(Duration.zero); // message is fetched; compose box updates check(composeBoxController.contentFocusNode.hasFocus).isTrue(); - checkSuccessState(store, contentController, + checkSuccessState(store, narrow, contentController, valueBefore: valueBefore, message: message, rawContent: 'Hello world'); }); @@ -1875,8 +1878,8 @@ void main() { group('in DM narrow', () { testWidgets('smoke', (tester) async { final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); - await setupToMessageActionSheet(tester, - message: message, narrow: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + final narrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId); + await setupToMessageActionSheet(tester, message: message, narrow: narrow); final composeBoxController = findComposeBoxController(tester)!; final contentController = composeBoxController.content; @@ -1884,10 +1887,10 @@ void main() { final valueBefore = contentController.value; prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); await tapQuoteAndReplyButton(tester); - checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); + checkLoadingState(store, narrow, contentController, valueBefore: valueBefore, message: message); await tester.pump(Duration.zero); // message is fetched; compose box updates check(composeBoxController.contentFocusNode.hasFocus).isTrue(); - checkSuccessState(store, contentController, + checkSuccessState(store, narrow, contentController, valueBefore: valueBefore, message: message, rawContent: 'Hello world'); }); @@ -1916,7 +1919,8 @@ void main() { testWidgets('request has an error', (tester) async { final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + final narrow = TopicNarrow.ofMessage(message); + await setupToMessageActionSheet(tester, message: message, narrow: narrow); final composeBoxController = findComposeBoxController(tester)!; final contentController = composeBoxController.content; @@ -1924,7 +1928,7 @@ void main() { final valueBefore = contentController.value = TextEditingValue.empty; prepareRawContentResponseError(); await tapQuoteAndReplyButton(tester); - checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); + checkLoadingState(store, narrow, contentController, valueBefore: valueBefore, message: message); await tester.pump(Duration.zero); // error arrives; error dialog shows await tester.tap(find.byWidget(checkErrorDialog(tester, diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index d70e9d01c9..790bf5f12e 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -30,6 +30,7 @@ import '../test_images.dart'; import 'test_app.dart'; late PerAccountStore store; +late FakeApiConnection connection; /// Simulates loading a [MessageListPage] and tapping to focus the compose input. /// @@ -55,7 +56,7 @@ Future setupToComposeInput(WidgetTester tester, { await store.addUsers([eg.selfUser, eg.otherUser]); await store.addUsers(users); await store.addStreams(channels); - final connection = store.connection as FakeApiConnection; + connection = store.connection as FakeApiConnection; narrow ??= DmNarrow( allRecipientIds: [eg.selfUser.userId, eg.otherUser.userId], @@ -356,14 +357,14 @@ void main() { }); }); - group('#channel link', () { - void checkChannelShown(ZulipStream channel, {required bool expected}) { - check(find.ancestor(of: find.byIcon(iconDataForStream(channel)), - matching: find.ancestor(of: find.text(channel.name), - matching: find.byType(Row))) - ).findsExactly(expected ? 1 : 0); - } + void checkChannelShown(ZulipStream channel, {required bool expected}) { + check(find.ancestor(of: find.byIcon(iconDataForStream(channel)), + matching: find.ancestor(of: find.text(channel.name), + matching: find.byType(Row))) + ).findsExactly(expected ? 1 : 0); + } + group('#channel link', () { testWidgets('options appear, disappear, and change correctly', (tester) async { final channel1 = eg.stream(name: 'mobile'); final channel2 = eg.stream(name: 'mobile design'); @@ -382,11 +383,16 @@ void main() { checkChannelShown(channel2, expected: true); checkChannelShown(channel3, expected: true); + // Prepare a response for when choosing a channel triggers a fetch of its + // topics as a result of the "#**…>" syntax being inserted into the + // compose box. + connection.prepare(json: GetChannelTopicsResult(topics: []).toJson()); + // Finishing autocomplete updates compose box; causes options to disappear. await tester.tap(find.text('mobile design')); await tester.pump(); check(tester.widget(composeInputFinder).controller!.text) - .contains(channelLink(channel2, store: store)); + .contains(channelLink(channel2, isComplete: false, store: store)); checkChannelShown(channel1, expected: false); checkChannelShown(channel2, expected: false); checkChannelShown(channel3, expected: false); @@ -410,6 +416,139 @@ void main() { }); }); + group('#channel>topic link', () { + void checkTopicShown(TopicName topic, {required bool expected, bool isNew = false}) { + final topicNameFinder = find.text(topic.displayName ?? store.realmEmptyTopicDisplayName); + if (isNew) { + check(find.ancestor(of: topicNameFinder, + matching: find.ancestor(of: find.text('New'), + matching: find.byType(Row))) + ).findsExactly(expected ? 1 : 0); + } else { + check(topicNameFinder).findsExactly(expected ? 1 : 0); + } + } + + testWidgets('options appear, disappear, and change correctly', (tester) async { + final channel = eg.stream(name: 'mobile'); + final composeInputFinder = await setupToComposeInput(tester, channels: [channel]); + + final topic1 = eg.getChannelTopicsEntry(maxId: 30, name: 'team'); + final topic2 = eg.getChannelTopicsEntry(maxId: 20, name: 'design'); + final topic3 = eg.getChannelTopicsEntry(maxId: 10, name: 'dev help'); + connection.prepare(json: GetChannelTopicsResult(topics: [topic1, topic2, topic3]).toJson()); + + // Options are filtered correctly for query. + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'check #**mobile>d'); + await tester.enterText(composeInputFinder, 'check #**mobile>de'); + await tester.pumpAndSettle(); + + checkTopicShown(topic1.name, expected: false); + checkTopicShown(topic2.name, expected: true); + checkTopicShown(topic3.name, expected: true); + + // Finishing autocomplete updates compose box; causes options to disappear. + await tester.tap(find.text('design')); + await tester.pump(); + check(tester.widget(composeInputFinder).controller!.text) + .contains(topicLink(channel, topic2.name, store: store)); + checkTopicShown(topic1.name, expected: false); + checkTopicShown(topic2.name, expected: false); + checkTopicShown(topic3.name, expected: false); + + // Then a new autocomplete intent brings up options again. + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'check #**mobile>de'); + await tester.enterText(composeInputFinder, 'check #**mobile>dev'); + await tester.pumpAndSettle(); + checkTopicShown(topic3.name, expected: true); + + // Removing autocomplete intent causes options to disappear. + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'check '); + await tester.enterText(composeInputFinder, 'check'); + checkTopicShown(topic1.name, expected: false); + checkTopicShown(topic2.name, expected: false); + checkTopicShown(topic3.name, expected: false); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets("query doesn't exactly match any topic -> new option appears", (tester) async { + final channel = eg.stream(name: 'mobile'); + final composeInputFinder = await setupToComposeInput(tester, channels: [channel]); + + final topic1 = eg.getChannelTopicsEntry(maxId: 20, name: 'design'); + final topic2 = eg.getChannelTopicsEntry(maxId: 10, name: 'dev help'); + connection.prepare(json: GetChannelTopicsResult(topics: [topic1, topic2]).toJson()); + + // The query doesn't exactly match any of the topics. + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'check #**mobile>d'); + await tester.enterText(composeInputFinder, 'check #**mobile>dev'); + await tester.pumpAndSettle(); + + // The "new" topic option appears. + checkTopicShown(eg.t('dev'), expected: true, isNew: true); + checkTopicShown(topic1.name, expected: false); + checkTopicShown(topic2.name, expected: true); + + // Tapping on the "new" option, inserts the "new" topic link. + await tester.tap(find.text('dev')); + await tester.pump(); + check(tester.widget(composeInputFinder).controller!.text) + .contains(topicLink(channel, eg.t('dev'), store: store)); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('empty query -> an option for the channel appears', (tester) async { + final channel = eg.stream(name: 'mobile'); + final composeInputFinder = await setupToComposeInput(tester, channels: [channel]); + + final topic1 = eg.getChannelTopicsEntry(maxId: 20, name: 'design'); + final topic2 = eg.getChannelTopicsEntry(maxId: 10, name: 'dev help'); + connection.prepare(json: GetChannelTopicsResult(topics: [topic1, topic2]).toJson()); + + // Empty query. + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'check #**mobile>d'); + await tester.enterText(composeInputFinder, 'check #**mobile>'); + await tester.pumpAndSettle(); + + // The channel option appears. + checkChannelShown(channel, expected: true); + checkTopicShown(topic1.name, expected: true); + checkTopicShown(topic2.name, expected: true); + + // Tapping on the channel option, inserts the channel link. + await tester.tap(find.text('mobile')); + await tester.pump(); + check(tester.widget(composeInputFinder).controller!.text) + .contains(channelLink(channel, store: store)); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('"#**…** >" is replaced by "#**…>"', (tester) async { + final channel = eg.stream(name: 'mobile'); + final composeInputFinder = await setupToComposeInput(tester, channels: [channel]); + + connection.prepare(json: GetChannelTopicsResult(topics: []).toJson()); + + await tester.enterText(composeInputFinder, 'check #**mobile** >'); + check(tester.widget(composeInputFinder).controller!.text) + .contains(channelLink(channel, isComplete: false, store: store)); + + await tester.enterText(composeInputFinder, 'check #**mobile**>'); + check(tester.widget(composeInputFinder).controller!.text) + .contains(channelLink(channel, isComplete: false, store: store)); + + debugNetworkImageHttpClientProvider = null; + }); + }); + group('emoji', () { void checkEmojiShown(ExpectedEmoji option, {required bool expected}) { final (label, display) = option; diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 1d780271ae..56aed56fcf 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -260,7 +260,7 @@ void main() { void testInsertPadded(String description, String valueBefore, String textToInsert, String expectedValue) { test(description, () { store = eg.store(); - final controller = ComposeContentController(store: store); + final controller = ComposeContentController(store: store, narrow: ChannelNarrow(1)); controller.value = parseMarkedText(valueBefore); controller.insertPadded(textToInsert); check(controller.value).equals(parseMarkedText(expectedValue)); @@ -342,7 +342,7 @@ void main() { testWidgets('requireNotEmpty: true (default)', (tester) async { store = eg.store(); - controller = ComposeContentController(store: store); + controller = ComposeContentController(store: store, narrow: ChannelNarrow(1)); addTearDown(controller.dispose); checkCountsAsEmpty('', true); checkCountsAsEmpty(' ', true); @@ -351,7 +351,8 @@ void main() { testWidgets('requireNotEmpty: false', (tester) async { store = eg.store(); - controller = ComposeContentController(store: store, requireNotEmpty: false); + controller = ComposeContentController(store: store, narrow: ChannelNarrow(1), + requireNotEmpty: false); addTearDown(controller.dispose); checkCountsAsEmpty('', false); checkCountsAsEmpty(' ', false);