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