Match insensitively to diacritics (autocomplete and new-DMs)

Fixes #237.
Fixes #1067.

autocomplete: In @-mention results, match insensitively to diacritics
If there's a user with `Étienne` in their name, we should still include it in the results for the query `etienne`.

The matching is already case-insensitive, but it should be insensitive to diacritics too.
For emoji autocomplete, match insensitively to diacritics
For emoji autocomplete, if the emoji's name has `é` where the user's query has `e` or vice versa, we should treat that as a match. The semantics should match what the web app does.

This doesn't much arise for Unicode emoji, because for all the (small number of) Unicode emoji where a name has a diacritic, we also give the emoji an alternate name by removing the diacritics. It might come up for custom emoji in some realms.

### Related issues
* Similar but for @-mentions: #237
* Prerequisite: #670

(In principle this should also apply to user-group mentions, not just user mentions. User-group mention autocomplete hasn't been implemented yet: #233.)
Can we use something like [diacritic](https://github.com/agilord/diacritic)?
BTW I'm new here (and new to open source in general) so what is the policy when it comes to packages and dependencies?
It looks like that package consists of a hand-maintained set of mappings:
https://github.com/agilord/diacritic/blob/8cc02fb838f69bfffd25a7629787267814669b28/lib/src/replacement_map.dart#L59-L60
with no particular documentation of where the list comes from. So that isn't something I'd be eager to rely on.

The Zulip web client and the legacy zulip-mobile app have functionality like this. So let's implement the same behavior they have.

That behavior has been stable for a long time and I'm not aware of any bug reports we've gotten about it. If we later learn about ways the behavior can be improved, we can make the improvement for web too at the same time.

> The Zulip web client and the legacy zulip-mobile app have functionality like this. So let's implement the same behavior they have.

After taking a look back at the mobile app [implementation for this feature](https://github.com/zulip/zulip-mobile/pull/5292/files) I found that it depends on [@zulip/shared](https://www.npmjs.com/package/@zulip/shared) to handle this. check:
file: [zulip-mobile/src/users/userHelpers.js](https://github.com/Fingel/zulip-mobile/blob/d73e7644f9a2cf3b61b827f10c9e10a38d35c9ae/src/users/userHelpers.js)
```js
import * as typeahead from '@zulip/shared/js/typeahead';

const loweredFilter = filter.toLowerCase();
const isAscii = /^[a-z]+$/.test(loweredFilter);
return users.filter(user => {
  const full_name = isAscii ? typeahead.remove_diacritics(user.full_name) : user.full_name;
  return user.user_id !== ownUserId && full_name.toLowerCase().startsWith(loweredFilter);
});
```

Which uses the convenient Js String built in method `normalize`

file: [@zulip/shared/lib/typeahead.js](https://www.npmjs.com/package/@zulip/shared?activeTab=code)
```js
const unicode_marks = /\p{M}/gu;
export function remove_diacritics(s) {
    return s.normalize("NFKD").replace(unicode_marks, "");
}
```
Which unfortunately dart does not have *for now at least* so we might have to consider third party packages, I am still searching and evaluating the options but I found several candidates:
* [unorm-dart](https://pub.dev/packages/unorm_dart)
* [normalizer](https://pub.dev/packages/normalizer)

So I will do some research to be able make an educated suggestion
Updating this thread with a conclusion from [chat](https://chat.zulip.org/#narrow/channel/48-mobile/topic/deps.3A.20Add.20new.20package.20to.20handle.20diacritics/near/1770345) and the PR thread https://github.com/zulip/zulip-flutter/pull/588#issuecomment-2080022746 :

We'd use [`package:unorm_dart`](https://pub.dev/packages/unorm_dart) for this.

When making the search "fuzzy" like this, ideally we'd prioritize non-fuzzy matches over fuzzy matches. (zulip/zulip#32634 gave me the idea to comment here; we can split this to a new issue if helpful.)
This came up in early post-launch feedback today: #1623
@chrisbobbe just pinging this issue. We're a paying customer and this is disrespectful to some of our colleagues. Please don't mess with peoples names.

I also find it disrespectful to be asked to upvote to prioritise - this is a very basic feature. "Zulip is not able to mention people by name proper" is a clear defect, not a future feature.
As part of the same work, let's also handle the possibility that the emoji's name has an upper-case letter where the query has lower case.

This one doesn't come up at all for Unicode emoji: all our names are in lower case. But again it might come up for custom emoji in some realms.

When making the search "fuzzy" like this, ideally we'd prioritize non-fuzzy matches over fuzzy matches. (zulip/zulip#32634 gave me the idea to comment here; we can split this to a new issue if helpful.)
Hello @gnprice ,
This would be solution for the issue right?

https://github.com/user-attachments/assets/28b02c43-ba54-48a8-b40a-40a0d13e94c1
From the snippet in that demo, that looks like broadly the right sort of behavior. To tell whether it's an accurate solution would require more detail, like an implementation and its tests.

This is a post-launch issue, though — in fact it's in the M7 Future milestone, beyond M6 Post-launch. So we won't be able to spend time giving feedback on PRs for it until after the app has been launched.

To get clear idea of implementation I had created a PR.

flutter test test/model/autocomplete_test.dart test/model/emoji_test.dart test/widgets/new_dm_sheet_test.dart --reporter expanded 2>&1

diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart
index 0b17f50b27..cf5c6b86e0 100644
--- a/lib/model/autocomplete.dart
+++ b/lib/model/autocomplete.dart
@@ -2,6 +2,7 @@ import 'dart:math';

 import 'package:flutter/foundation.dart';
 import 'package:flutter/services.dart';
+import 'package:unorm_dart/unorm_dart.dart' as unorm;

 import '../api/model/events.dart';
 import '../api/model/model.dart';
@@ -643,9 +644,9 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
   static int compareGroupsByAlphabeticalOrder(UserGroup userGroupA, UserGroup userGroupB,
       {required PerAccountStore store}) {
     final groupAName = store.autocompleteViewManager.autocompleteDataCache
-      .lowercaseNameForUserGroup(userGroupA);
+      .normalizedNameForUserGroup(userGroupA);
     final groupBName = store.autocompleteViewManager.autocompleteDataCache
-      .lowercaseNameForUserGroup(userGroupB);
+      .normalizedNameForUserGroup(userGroupB);
     return groupAName.compareTo(groupBName); // TODO(i18n): add locale-aware sorting
   }

@@ -732,42 +733,53 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
 /// An [AutocompleteQuery] object stores the user's actual query string
 /// as [raw].
 /// It may also store processed forms of the query
-/// (for example, converted to lowercase or split on whitespace)
+/// (for example, normalized by case and diacritics, or split on whitespace)
 /// to prepare for whatever particular form of searching will be done
 /// for the given type of autocomplete interaction.
 abstract class AutocompleteQuery {
   AutocompleteQuery(this.raw) {
-    _lowercase = raw.toLowerCase();
-    _lowercaseWords = _lowercase.split(' ');
+    _normalized = lowercaseAndStripDiacritics(raw);
+    // TODO(#1805) split on space characters that the user is actually using
+    //   (e.g. U+3000 IDEOGRAPHIC SPACE);
+    //   could check active keyboard or just split on all kinds of spaces
+    _normalizedWords = _normalized.split(' ');
   }

   /// The actual string the user entered.
   final String raw;

-  late final String _lowercase;
+  late final String _normalized;

-  late final List<String> _lowercaseWords;
+  late final List<String> _normalizedWords;
+
+  static final RegExp _regExpStripMarkCharacters = RegExp(r'\p{M}', unicode: true);
+
+  static String lowercaseAndStripDiacritics(String input) {
+    // Anders reports that this is what web does; see discussion:
+    //   https://chat.zulip.org/#narrow/channel/48-mobile/topic/deps.3A.20Add.20new.20package.20to.20handle.20diacritics/near/2244487
+    final lowercase = input.toLowerCase();
+    final compatibilityNormalized = unorm.nfkd(lowercase);
+    return compatibilityNormalized.replaceAll(_regExpStripMarkCharacters, '');
+  }

   /// Whether all of this query's words have matches in [words],
-  /// modulo case, that appear in order.
+  /// insensitively to case and diacritics, that appear in order.
   ///
   /// A "match" means the word in [words] starts with the query word.
   ///
-  /// [words] must all be lowercased.
+  /// [words] must all have been passed through [lowercaseAndStripDiacritics].
   bool _testContainsQueryWords(List<String> words) {
-    // TODO(#237) test with diacritics stripped, where appropriate,
-    //   and update dartdoc's summary line and its restriction about [words].
     int wordsIndex = 0;
     int queryWordsIndex = 0;
     while (true) {
-      if (queryWordsIndex == _lowercaseWords.length) {
+      if (queryWordsIndex == _normalizedWords.length) {
         return true;
       }
       if (wordsIndex == words.length) {
         return false;
       }

-      if (words[wordsIndex].startsWith(_lowercaseWords[queryWordsIndex])) {
+      if (words[wordsIndex].startsWith(_normalizedWords[queryWordsIndex])) {
         queryWordsIndex++;
       }
       wordsIndex++;
@@ -825,9 +837,9 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {

   WildcardMentionAutocompleteResult? testWildcardOption(WildcardMentionOption wildcardOption, {
       required ZulipLocalizations localizations}) {
-    // TODO(#237): match insensitively to diacritics
-    final matches = wildcardOption.canonicalString.contains(_lowercase)
-      || wildcardOption.localizedCanonicalString(localizations).contains(_lowercase);
+    final localized = wildcardOption.localizedCanonicalString(localizations);
+    final matches = wildcardOption.canonicalString.contains(_normalized)
+      || AutocompleteQuery.lowercaseAndStripDiacritics(localized).contains(_normalized);
     if (!matches) return null;
     return WildcardMentionAutocompleteResult(
       wildcardOption: wildcardOption, rank: _rankWildcardResult);
@@ -857,8 +869,8 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
     required String normalizedName,
     required List<String> normalizedNameWords,
   }) {
-    if (normalizedName.startsWith(_lowercase)) {
-      if (normalizedName.length == _lowercase.length) {
+    if (normalizedName.startsWith(_normalized)) {
+      if (normalizedName.length == _normalized.length) {
         return NameMatchQuality.exact;
       } else {
         return NameMatchQuality.totalPrefix;
@@ -873,17 +885,17 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
   }

   bool _matchEmail(User user, AutocompleteDataCache cache) {
-    final lowercaseEmail = cache.normalizedEmailForUser(user);
-    if (lowercaseEmail == null) return false; // Email not known
-    return lowercaseEmail.startsWith(_lowercase);
+    final normalizedEmail = cache.normalizedEmailForUser(user);
+    if (normalizedEmail == null) return false; // Email not known
+    return normalizedEmail.startsWith(_normalized);
   }

   MentionAutocompleteResult? testUserGroup(UserGroup userGroup, PerAccountStore store) {
     final cache = store.autocompleteViewManager.autocompleteDataCache;

     final nameMatchQuality = _matchName(
-      normalizedName: cache.lowercaseNameForUserGroup(userGroup),
-      normalizedNameWords: cache.lowercaseNameWordsForUserGroup(userGroup));
+      normalizedName: cache.normalizedNameForUserGroup(userGroup),
+      normalizedNameWords: cache.normalizedNameWordsForUserGroup(userGroup));

     if (nameMatchQuality == null) return null;

@@ -970,12 +982,16 @@ extension WildcardMentionOptionExtension on WildcardMentionOption {
 /// but kept around in between autocomplete interactions.
 ///
 /// An instance of this class is managed by [AutocompleteViewManager].
+// TODO(#1805) when splitting words, split on space characters that are likely
+//   to be used (e.g. U+3000 IDEOGRAPHIC SPACE);
+//   could check server language or just split on all kinds of spaces
 class AutocompleteDataCache {
   final Map<int, String> _normalizedNamesByUser = {};

   /// The normalized `fullName` of [user].
   String normalizedNameForUser(User user) {
-    return _normalizedNamesByUser[user.userId] ??= user.fullName.toLowerCase();
+    return _normalizedNamesByUser[user.userId]
+      ??= AutocompleteQuery.lowercaseAndStripDiacritics(user.fullName);
   }

   final Map<int, List<String>> _normalizedNameWordsByUser = {};
@@ -989,21 +1005,25 @@ class AutocompleteDataCache {

   /// The normalized `deliveryEmail` of [user], or null if that's null.
   String? normalizedEmailForUser(User user) {
-    return _normalizedEmailsByUser[user.userId] ??= user.deliveryEmail?.toLowerCase();
+    return _normalizedEmailsByUser[user.userId]
+      ??= (user.deliveryEmail != null
+            ? AutocompleteQuery.lowercaseAndStripDiacritics(user.deliveryEmail!)
+            : null);
   }

-  final Map<int, String> _lowercaseNamesByUserGroup = {};
+  final Map<int, String> _normalizedNamesByUserGroup = {};

   /// The normalized `name` of [userGroup].
-  String lowercaseNameForUserGroup(UserGroup userGroup) {
-    return _lowercaseNamesByUserGroup[userGroup.id] ??= userGroup.name.toLowerCase();
+  String normalizedNameForUserGroup(UserGroup userGroup) {
+    return _normalizedNamesByUserGroup[userGroup.id]
+      ??= AutocompleteQuery.lowercaseAndStripDiacritics(userGroup.name);
   }

-  final Map<int, List<String>> _lowercaseNameWordsByUserGroup = {};
+  final Map<int, List<String>> _normalizedNameWordsByUserGroup = {};

-  List<String> lowercaseNameWordsForUserGroup(UserGroup userGroup) {
-    return _lowercaseNameWordsByUserGroup[userGroup.id]
-      ??= lowercaseNameForUserGroup(userGroup).split(' ');
+  List<String> normalizedNameWordsForUserGroup(UserGroup userGroup) {
+    return _normalizedNameWordsByUserGroup[userGroup.id]
+      ??= normalizedNameForUserGroup(userGroup).split(' ');
   }

   void invalidateUser(int userId) {
@@ -1013,8 +1033,8 @@ class AutocompleteDataCache {
   }

   void invalidateUserGroup(int id) {
-    _lowercaseNamesByUserGroup.remove(id);
-    _lowercaseNameWordsByUserGroup.remove(id);
+    _normalizedNamesByUserGroup.remove(id);
+    _normalizedNameWordsByUserGroup.remove(id);
   }
 }

@@ -1196,11 +1216,11 @@ class TopicAutocompleteQuery extends AutocompleteQuery {
     // TODO(#881): Sort by match relevance, like web does.

     if (topic.displayName == null) {
-      return store.realmEmptyTopicDisplayName.toLowerCase()
-        .contains(raw.toLowerCase());
+      return AutocompleteQuery.lowercaseAndStripDiacritics(store.realmEmptyTopicDisplayName)
+        .contains(_normalized);
     }
     return topic.displayName != raw
-      && topic.displayName!.toLowerCase().contains(raw.toLowerCase());
+      && AutocompleteQuery.lowercaseAndStripDiacritics(topic.displayName!).contains(_normalized);
   }

   @override
diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart
index 12424104bf..8d6a1cab0e 100644
--- a/lib/model/emoji.dart
+++ b/lib/model/emoji.dart
@@ -74,11 +74,23 @@ final class EmojiCandidate {
   /// This might not be the only name this emoji has; see [aliases].
   final String emojiName;

+  /// [emojiName], but via [AutocompleteQuery.lowercaseAndStripDiacritics]
+  /// to support fuzzy matching.
+  String get normalizedEmojiName => _normalizedEmojiName
+    ??= AutocompleteQuery.lowercaseAndStripDiacritics(emojiName);
+  String? _normalizedEmojiName;
+
   /// Additional Zulip "emoji name" values for this emoji,
   /// to show in the emoji picker UI.
   Iterable<String> get aliases => _aliases ?? const [];
   final List<String>? _aliases;

+  /// [aliases], but via [AutocompleteQuery.lowercaseAndStripDiacritics]
+  /// to support fuzzy matching.
+  Iterable<String> get normalizedAliases => _normalizedAliases
+    ??= aliases.map((alias) => AutocompleteQuery.lowercaseAndStripDiacritics(alias));
+  Iterable<String>? _normalizedAliases;
+
   final EmojiDisplay emojiDisplay;

   EmojiCandidate({
@@ -501,9 +513,8 @@ class EmojiAutocompleteQuery extends ComposeAutocompleteQuery {

   static const _separator = '_';

-  static String _adjustQuery(String raw) {
-    return raw.toLowerCase().replaceAll(' ', '_'); // TODO(#1067) remove diacritics too
-  }
+  static String _adjustQuery(String raw) =>
+    AutocompleteQuery.lowercaseAndStripDiacritics(raw.replaceAll(' ', '_'));

   @override
   EmojiAutocompleteView initViewModel({
@@ -533,29 +544,28 @@ class EmojiAutocompleteQuery extends ComposeAutocompleteQuery {
       }
     }

-    EmojiMatchQuality? result = _matchName(candidate.emojiName);
-    for (final alias in candidate.aliases) {
+    EmojiMatchQuality? result = _matchName(candidate.normalizedEmojiName);
+    for (final normalizedAlias in candidate.normalizedAliases) {
       if (result == EmojiMatchQuality.best) return result;
-      result = EmojiMatchQuality.bestOf(result, _matchName(alias));
+      result = EmojiMatchQuality.bestOf(result, _matchName(normalizedAlias));
     }
     return result;
   }

-  EmojiMatchQuality? _matchName(String emojiName) {
+  EmojiMatchQuality? _matchName(String normalizedName) {
     // Compare query_matches_string_in_order in Zulip web:shared/src/typeahead.ts
     // for a Boolean version of this logic (match vs. no match),
     // and triage_raw in the same file web:shared/src/typeahead.ts
     // for the finer distinctions.
     // See also commentary in [_rankResult].

-    // TODO(#1067) this assumes emojiName is already lower-case (and no diacritics)
-    if (emojiName == _adjusted)           return EmojiMatchQuality.exact;
-    if (emojiName.startsWith(_adjusted))  return EmojiMatchQuality.prefix;
-    if (emojiName.contains(_sepAdjusted)) return EmojiMatchQuality.wordAligned;
+    if (normalizedName == _adjusted)           return EmojiMatchQuality.exact;
+    if (normalizedName.startsWith(_adjusted))  return EmojiMatchQuality.prefix;
+    if (normalizedName.contains(_sepAdjusted)) return EmojiMatchQuality.wordAligned;
     if (!_adjusted.contains(_separator)) {
       // If the query is a single token (doesn't contain a separator),
       // allow a match anywhere in the string, too.
-      if (emojiName.contains(_adjusted))  return EmojiMatchQuality.other;
+      if (normalizedName.contains(_adjusted))  return EmojiMatchQuality.other;
     } else {
       // Otherwise, require at least a word-aligned match.
     }
diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart
index 5c853d2da0..e345969189 100644
--- a/lib/widgets/autocomplete.dart
+++ b/lib/widgets/autocomplete.dart
@@ -208,7 +208,7 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
           // and losing data for the user.
           return;
         }
-        // TODO(i18n) language-appropriate space character; check active keyboard?
+        // TODO(#1805) language-appropriate space character; check active keyboard?
         //   (maybe handle centrally in `controller`)
         replacementString = '${userMention(user, silent: query.silent, users: store)} ';
       case WildcardMentionAutocompleteResult(:var wildcardOption):
@@ -223,7 +223,7 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
           // and losing data for the group.
           return;
         }
-        // TODO(i18n) language-appropriate space character; check active keyboard?
+        // TODO(#1805) language-appropriate space character; check active keyboard?
         //   (maybe handle centrally in `controller`)
         replacementString = '${userGroupMention(userGroup.name, silent: query.silent)} ';
     }
diff --git a/lib/widgets/new_dm_sheet.dart b/lib/widgets/new_dm_sheet.dart
index 05e682e849..0ab0dc41b5 100644
--- a/lib/widgets/new_dm_sheet.dart
+++ b/lib/widgets/new_dm_sheet.dart
@@ -87,12 +87,14 @@ class _NewDmPickerState extends State<NewDmPicker> with PerAccountStoreAwareStat
   void _updateFilteredUsers(PerAccountStore store) {
     final excludeSelfUser = selectedUserIds.isNotEmpty
       && !selectedUserIds.contains(store.selfUserId);
-    final searchTextLower = searchController.text.toLowerCase();
+    final normalizedQuery =
+      AutocompleteQuery.lowercaseAndStripDiacritics(searchController.text);

     final result = <User>[];
     for (final user in sortedUsers) {
       if (excludeSelfUser && user.userId == store.selfUserId) continue;
-      if (user.fullName.toLowerCase().contains(searchTextLower)) {
+      final normalizedName = AutocompleteQuery.lowercaseAndStripDiacritics(user.fullName);
+      if (normalizedName.contains(normalizedQuery)) {
         result.add(user);
       }
     }
diff --git a/pubspec.lock b/pubspec.lock
index c52f5e6c1e..bc3cb33f00 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -1115,6 +1115,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.4.0"
+  unorm_dart:
+    dependency: "direct main"
+    description:
+      name: unorm_dart
+      sha256: "8e3870a1caa60bde8352f9597dd3535d8068613269444f8e35ea8925ec84c1f5"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.3.1+1"
   url_launcher:
     dependency: "direct main"
     description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 9b1c3566c5..64b5a6b111 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -65,6 +65,7 @@ dependencies:
   wakelock_plus: ^1.2.8
   zulip_plugin:
     path: ./packages/zulip_plugin
+  unorm_dart: ^0.3.1+1
   # Keep list sorted when adding dependencies; it helps prevent merge conflicts.

 dependency_overrides:

diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart
index 20f8adb6be..a560512553 100644
--- a/test/model/autocomplete_test.dart
+++ b/test/model/autocomplete_test.dart
@@ -27,9 +27,6 @@ import 'autocomplete_checks.dart';
 typedef MarkedTextParse = ({int? expectedSyntaxStart, TextEditingValue value});

 final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
-final zulipLocalizationsArabic =
-  lookupZulipLocalizations(ZulipLocalizations.supportedLocales
-    .firstWhere((locale) => locale.languageCode == 'ar'));

 void main() {
   ({int? expectedSyntaxStart, TextEditingValue value}) parseMarkedText(String markedText) {
@@ -922,26 +919,49 @@ void main() {
       });
     }

-    final localizedTestCases = [
-      ('ال',        channelNarrow, [WildcardMentionOption.all, WildcardMentionOption.topic]),
-      ('الجميع',    topicNarrow,   [WildcardMentionOption.all]),
-      ('الموضوع',   channelNarrow, [WildcardMentionOption.topic]),
-      ('ق',         topicNarrow,   [WildcardMentionOption.channel]),
-      ('دفق',       channelNarrow, [WildcardMentionOption.stream]),
-      ('الكل',      dmNarrow,      [WildcardMentionOption.everyone]),
+    WildcardTester wildcardTesterForLocale(bool Function(Locale) localePredicate) {
+      final locale = ZulipLocalizations.supportedLocales.firstWhere(localePredicate);
+      final localizations = lookupZulipLocalizations(locale);

-      ('top',       channelNarrow, [WildcardMentionOption.topic]),
-      ('channel',   topicNarrow,   [WildcardMentionOption.channel]),
-      ('every',     dmNarrow,      [WildcardMentionOption.everyone]),
-    ];
+      return (String query, Narrow narrow, List<WildcardMentionOption> expected) {
+        test('locale "$locale" -> query "$query" in ${narrow.runtimeType} -> $expected', () {
+          check(getWildcardOptionsFor(query, narrow: narrow,
+            localizations: localizations)).deepEquals(expected);
+        });
+      };
+    }

-    for (final (String localizedQuery, Narrow narrow, List<WildcardMentionOption> wildcardOptions) in localizedTestCases) {
-      test('different locale -> query "$localizedQuery" in ${narrow.runtimeType} -> $wildcardOptions', () async {
-        check(getWildcardOptionsFor(localizedQuery, narrow: narrow,
-          localizations: zulipLocalizationsArabic)).deepEquals(wildcardOptions);
-      });
+    for (final option in WildcardMentionOption.values) {
+      // These are hard-coded, and they happened to be lowercase and without
+      // diacritics when written.
+      // Throw if that changes, to not accidentally break fuzzy matching.
+      check(option.canonicalString).equals(
+        AutocompleteQuery.lowercaseAndStripDiacritics(option.canonicalString));
     }

+    final testArabic = wildcardTesterForLocale((locale) => locale.languageCode == 'ar');
+    testArabic('ال',        channelNarrow, [WildcardMentionOption.all, WildcardMentionOption.topic]);
+    testArabic('الجميع',    topicNarrow,   [WildcardMentionOption.all]);
+    testArabic('الموضوع',   channelNarrow, [WildcardMentionOption.topic]);
+    testArabic('ق',         topicNarrow,   [WildcardMentionOption.channel]);
+    testArabic('دفق',       channelNarrow, [WildcardMentionOption.stream]);
+    testArabic('الكل',      dmNarrow,      [WildcardMentionOption.everyone]);
+    testArabic('top',       channelNarrow, [WildcardMentionOption.topic]);
+    testArabic('channel',   topicNarrow,   [WildcardMentionOption.channel]);
+    testArabic('every',     dmNarrow,      [WildcardMentionOption.everyone]);
+
+    final testEnglish = wildcardTesterForLocale((locale) => locale.languageCode == 'en');
+    testEnglish('topic',     topicNarrow,   [WildcardMentionOption.topic]);
+    testEnglish('Topic',     topicNarrow,   [WildcardMentionOption.topic]);
+
+    final testGerman = wildcardTesterForLocale((locale) => locale.languageCode == 'de');
+    testGerman('Thema',     topicNarrow,   [WildcardMentionOption.topic]);
+    testGerman('thema',     topicNarrow,   [WildcardMentionOption.topic]);
+
+    final testPolish = wildcardTesterForLocale((locale) => locale.languageCode == 'pl');
+    testPolish('wątek',     topicNarrow,   [WildcardMentionOption.topic]);
+    testPolish('watek',     topicNarrow,   [WildcardMentionOption.topic]);
+
     test('no wildcards for a silent mention', () {
       check(getWildcardOptionsFor('', isSilent: true, narrow: channelNarrow))
         .isEmpty();
@@ -1061,6 +1081,14 @@ void main() {
       check(rankOf(query, a)!).equals(rankOf(query, b)!);
     }

+    void checkAllSameRank(String query, Iterable<Object> candidates) {
+      // (i.e. throw here if it's not a match)
+      final firstCandidateRank = rankOf(query, candidates.first)!;
+
+      final ranks = candidates.skip(1).map((candidate) => rankOf(query, candidate));
+      check(ranks).every((it) => it.equals(firstCandidateRank));
+    }
+
     test('wildcards, then users', () {
       checkSameRank('', WildcardMentionOption.all, WildcardMentionOption.topic);
       checkPrecedes('', WildcardMentionOption.topic, eg.user());
@@ -1073,13 +1101,28 @@ void main() {
       checkPrecedes(user.fullName, WildcardMentionOption.channel, user);
     });

-    test('user name matched case-insensitively', () {
-      final user1 = eg.user(fullName: 'Chris Bobbe');
-      final user2 = eg.user(fullName: 'chris bobbe');
+    test('user name match is case- and diacritics-insensitive', () {
+      final users = [
+        eg.user(fullName: 'Édith Piaf'),
+        eg.user(fullName: 'édith piaf'),
+        eg.user(fullName: 'Edith Piaf'),
+        eg.user(fullName: 'edith piaf'),
+      ];
+
+      checkAllSameRank('Édith Piaf', users); // exact
+      checkAllSameRank('Edith Piaf', users); // exact
+      checkAllSameRank('édith piaf', users); // exact
+      checkAllSameRank('edith piaf', users); // exact
+
+      checkAllSameRank('Édith Pi',   users); // total-prefix
+      checkAllSameRank('Edith Pi',   users); // total-prefix
+      checkAllSameRank('édith pi',   users); // total-prefix
+      checkAllSameRank('edith pi',   users); // total-prefix

-      checkSameRank('chris bobbe', user1, user2); // exact
-      checkSameRank('chris bo',    user1, user2); // total-prefix
-      checkSameRank('chr bo',      user1, user2); // word-prefixes
+      checkAllSameRank('Éd Pi',      users); // word-prefixes
+      checkAllSameRank('Ed Pi',      users); // word-prefixes
+      checkAllSameRank('éd pi',      users); // word-prefixes
+      checkAllSameRank('ed pi',      users); // word-prefixes
     });

     test('user name match: exact over total-prefix', () {
@@ -1096,13 +1139,16 @@ void main() {
       checkPrecedes('so m', user1, user2);
     });

-    test('group name matched case-insensitively', () {
-      final userGroup1 = eg.userGroup(name: 'Mobile Team');
-      final userGroup2 = eg.userGroup(name: 'mobile team');
+    test('group name is case- and diacritics-insensitive', () {
+      final userGroups = [
+        eg.userGroup(name: 'Mobile Team'),
+        eg.userGroup(name: 'mobile team'),
+        eg.userGroup(name: 'möbile team'),
+      ];

-      checkSameRank('mobile team', userGroup1, userGroup2); // exact
-      checkSameRank('mobile te',   userGroup1, userGroup2); // total-prefix
-      checkSameRank('mob te',      userGroup1, userGroup2); // word-prefixes
+      checkAllSameRank('mobile team', userGroups); // exact
+      checkAllSameRank('mobile te',   userGroups); // total-prefix
+      checkAllSameRank('mob te',      userGroups); // word-prefixes
     });

     test('group name match: exact over total-prefix', () {
@@ -1119,16 +1165,19 @@ void main() {
       checkPrecedes('so m', userGroup1, userGroup2);
     });

-    test('email matched case-insensitively', () {
+    test('email match is case- and diacritics-insensitive', () {
       // "z" name to prevent accidental name match with example data
-      final user1 = eg.user(fullName: 'z', deliveryEmail: 'email@example.com');
-      final user2 = eg.user(fullName: 'z', deliveryEmail: 'EmAiL@ExAmPlE.com');
-
-      checkSameRank('email@example.com', user1, user2);
-      checkSameRank('email@e',           user1, user2);
-      checkSameRank('email@',            user1, user2);
-      checkSameRank('email',             user1, user2);
-      checkSameRank('ema',               user1, user2);
+      final users = [
+        eg.user(fullName: 'z', deliveryEmail: 'email@example.com'),
+        eg.user(fullName: 'z', deliveryEmail: 'EmAiL@ExAmPlE.com'),
+        eg.user(fullName: 'z', deliveryEmail: 'ēmail@example.com'),
+      ];
+
+      checkAllSameRank('email@example.com', users);
+      checkAllSameRank('email@e',           users);
+      checkAllSameRank('email@',            users);
+      checkAllSameRank('email',             users);
+      checkAllSameRank('ema',               users);
     });

     test('email match is by prefix only', () {
@@ -1259,3 +1308,5 @@ void main() {
     });
   });
 }
+
+typedef WildcardTester = void Function(String query, Narrow narrow, List<WildcardMentionOption> expected);
diff --git a/test/model/emoji_test.dart b/test/model/emoji_test.dart
index 5a89722d6e..6ce03854b7 100644
--- a/test/model/emoji_test.dart
+++ b/test/model/emoji_test.dart
@@ -558,8 +558,10 @@ void main() {
       check(matchOfName('blue dia', 'large_blue_diamond')).wordAligned;
     });

-    test('query is lower-cased', () {
+    test('case-insensitive', () {
       check(matchOfName('Smi', 'smile')).prefix;
+      check(matchOfName('smi', 'SMILE')).prefix;
+      check(matchOfName('SmI', 'sMiLe')).prefix;
     });

     test('query matches aliases same way as primary name', () {
@@ -577,6 +579,8 @@ void main() {
       check(matchOfNames('blue_dia', ['x', 'large_blue_diamond'])).wordAligned;

       check(matchOfNames('Smi', ['x', 'smile'])).prefix;
+      check(matchOfNames('smi', ['x', 'SMILE'])).prefix;
+      check(matchOfNames('SmI', ['x', 'sMiLe'])).prefix;
     });

     test('best match among name and aliases prevails', () {
diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart
index a11e862b48..aff2e8ba6e 100644
--- a/test/widgets/new_dm_sheet_test.dart
+++ b/test/widgets/new_dm_sheet_test.dart
@@ -123,6 +123,7 @@ void main() {
       eg.user(fullName: 'Alice Anderson'),
       eg.user(fullName: 'Bob Brown'),
       eg.user(fullName: 'Charlie Carter'),
+      eg.user(fullName: 'Édith Piaf'),
     ];

     testWidgets('shows full list initially', (tester) async {
@@ -130,7 +131,7 @@ void main() {
       check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne();
       check(findText(includePlaceholders: false, 'Bob Brown')).findsOne();
       check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne();
-      check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3);
+      check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(testUsers.length);
       check(find.byIcon(ZulipIcons.check_circle_checked)).findsNothing();
     });

@@ -150,7 +151,7 @@ void main() {
         users: [...testUsers, deactivatedUser]);
       check(findText(includePlaceholders: false, 'Impostor Charlie')).findsNothing();
       check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne();
-      check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3);
+      check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(testUsers.length);

       // … and after a query that would match their name.
       await tester.enterText(find.byType(TextField), 'Charlie');
@@ -168,7 +169,7 @@ void main() {
       check(findText(includePlaceholders: false, 'Someone Muted')).findsNothing();
       check(findText(includePlaceholders: false, 'Muted user')).findsNothing();
       check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne();
-      check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3);
+      check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(testUsers.length);

       // … and after a query.  One which matches both the user's actual name and
       // the replacement text "Muted user", for good measure.
@@ -178,13 +179,14 @@ void main() {
       check(findText(includePlaceholders: false, 'Muted user')).findsNothing();
       check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne();
       check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne();
-      check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(2);
+      check(findText(includePlaceholders: false, 'Édith Piaf')).findsOne();
+      check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3);
     });

     // TODO test sorting by recent-DMs
     // TODO test that scroll position resets on query change

-    testWidgets('search is case-insensitive', (tester) async {
+    testWidgets('search is case- and diacritics-insensitive', (tester) async {
       await setupSheet(tester, users: testUsers);
       await tester.enterText(find.byType(TextField), 'alice');
       await tester.pump();
@@ -193,6 +195,14 @@ void main() {
       await tester.enterText(find.byType(TextField), 'ALICE');
       await tester.pump();
       check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne();
+
+      await tester.enterText(find.byType(TextField), 'alicé');
+      await tester.pump();
+      check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne();
+
+      await tester.enterText(find.byType(TextField), 'edith');
+      await tester.pump();
+      check(findText(includePlaceholders: false, 'Édith Piaf')).findsOne();
     });

     testWidgets('partial name and last name search handling', (tester) async {


startup: Add beta-complete dialog

Fixes #1603.

cc @alya
-----

Update to old beta channel saying app has moved
Now that the app [has launched](https://blog.zulip.com/2025/06/17/flutter-mobile-app-launched/), we're publishing future releases to the main Zulip app's listings in the Play Store and App Store, and no longer to the listings (in the Play Store and TestFlight) that were for the Flutter app as a beta.

People who were using the Flutter beta will therefore want to install the main Zulip app and switch to that, in order to keep getting new features and bugfixes. For those who don't see the announcement through other channels, it would be good to post it in the app itself.

### Implementation

I think the shape this would take would be a small branch atop the last pre-launch beta version, v0.0.33, adding a dialog box using code similar to that in #1590.

### Spec

Two questions we'll need to pin down before shipping this:
* What should the text of the dialog box be?
* When should the dialog appear? Options include just once; or on every startup of the app; or some more-complicated description in between.

> * When should the dialog appear? Options include just once; or on every startup of the app; or some more-complicated description in between.

I vote "on every startup of the app". It's easiest to implement, and I think the goal is to be assertive but stop short of a rug-pull where we just disable all the app's features.
Yep, that plan sounds good to me.

flutter test test/notifications/open_test.dart test/widgets/app_test.dart test/widgets/home_test.dart test/widgets/login_test.dart --reporter expanded 2>&1

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6ae781c17d..3adbb17987 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,8 +22,14 @@ jobs:
       # so that Flutter knows its version and sees the constraint in our
       # pubspec is satisfied.  It's uncommon for flutter/flutter to go
       # more than 100 commits between tags.  Fetch 1000 for good measure.
+      # TODO(upstream): Around 2025-05, Flutter upstream stopped making
+      #   tags within the main/master branch.  Get that fixed:
+      #     https://github.com/zulip/zulip-flutter/issues/1710
+      #   Pending that, fetch more than 1000 commits.
       run: |
-        git clone --depth=1000 -b main https://github.com/flutter/flutter ~/flutter
+        git clone --depth=3000 -b main https://github.com/flutter/flutter ~/flutter
+        cd ~/flutter
+        git --git-dir ~/flutter/.git checkout ee089d09b21ec3ccc20d179c5be100d2a9d9f866
         TZ=UTC git --git-dir ~/flutter/.git log -1 --format='%h | %ci | %s' --date=iso8601-local
         echo ~/flutter/bin >> "$GITHUB_PATH"

diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml
index fb676042ff..7703fd6ec1 100644
--- a/.github/workflows/update-translations.yml
+++ b/.github/workflows/update-translations.yml
@@ -29,8 +29,9 @@ jobs:
         # so that Flutter knows its version and sees the constraint in our
         # pubspec is satisfied.  It's uncommon for flutter/flutter to go
         # more than 100 commits between tags.  Fetch 1000 for good measure.
+        # TODO(upstream): See ci.yml for why we fetch more than 1000.
         run: |
-          git clone --depth=1000 -b main https://github.com/flutter/flutter ~/flutter
+          git clone --depth=3000 -b main https://github.com/flutter/flutter ~/flutter
           TZ=UTC git --git-dir ~/flutter/.git log -1 --format='%h | %ci | %s' --date=iso8601-local
           echo ~/flutter/bin >> "$GITHUB_PATH"

diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart
index b1aa763ac8..d3e729f87b 100644
--- a/lib/widgets/app.dart
+++ b/lib/widgets/app.dart
@@ -160,6 +160,10 @@ class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver {
   void initState() {
     super.initState();
     WidgetsBinding.instance.addObserver(this);
+
+    // On every startup is fine; the goal is to be assertive but stop short of a
+    // rug-pull where we just disable all the app's features.
+    BetaCompleteDialog.show();
   }

   @override
diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart
index 4d269cddba..4d6d3e8675 100644
--- a/lib/widgets/dialog.dart
+++ b/lib/widgets/dialog.dart
@@ -1,7 +1,11 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';

 import '../generated/l10n/zulip_localizations.dart';
 import 'actions.dart';
+import 'app.dart';

 Widget _dialogActionText(String text) {
   return Text(
@@ -112,3 +116,87 @@ DialogStatus<bool> showSuggestedActionDialog({
       ]));
   return DialogStatus(future);
 }
+
+bool debugDisableBetaCompleteDialog = false;
+
+/// A brief dialog box saying that this beta channel has ended,
+/// offering a way to get the app from prod.
+///
+/// Shown on every startup.
+class BetaCompleteDialog extends StatelessWidget {
+  const BetaCompleteDialog._();
+
+  static void show() async {
+    if (debugDisableBetaCompleteDialog) return;
+
+    final navigator = await ZulipApp.navigator;
+    final context = navigator.context;
+    assert(context.mounted);
+    if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that
+
+    switch (defaultTargetPlatform) {
+      case TargetPlatform.android:
+      case TargetPlatform.iOS:
+        break;
+      case TargetPlatform.macOS:
+      case TargetPlatform.fuchsia:
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+        // Do nothing on these unsupported platforms.
+        return;
+    }
+
+    unawaited(showDialog(
+      context: context,
+      builder: (BuildContext context) => BetaCompleteDialog._()));
+  }
+
+  Widget _linkButton(BuildContext context, {
+    required String url,
+    required String label,
+  }) {
+    return TextButton(
+      onPressed: () {
+        Navigator.pop(context);
+        PlatformActions.launchUrl(context,
+          Uri.parse(url));
+      },
+      child: _dialogActionText(label));
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final message = 'Thanks for being a beta tester of the new Zulip app!'
+      ' This app became the main Zulip mobile app in June 2025,'
+      ' and this beta version is no longer maintained.'
+      ' We recommend uninstalling this beta after switching'
+      ' to the main Zulip app, in order to get the latest features'
+      ' and bug fixes.';
+
+    return AlertDialog(
+      title: Text('Time to switch to the new app'),
+      content: SingleChildScrollView(child: Text(message)),
+      actions: [
+        TextButton(
+          onPressed: () => Navigator.pop(context),
+          child: _dialogActionText('Got it')),
+        ...(switch (defaultTargetPlatform) {
+            TargetPlatform.android => [
+              _linkButton(context,
+                url: 'https://github.com/zulip/zulip-flutter/releases/latest',
+                label: 'Download official APKs (less common)'),
+              _linkButton(context,
+                url: 'https://play.google.com/store/apps/details?id=com.zulipmobile',
+                label: 'Open Google Play Store'),
+            ],
+            TargetPlatform.iOS => [
+              _linkButton(context,
+                url: 'https://apps.apple.com/app/zulip/id1203036395',
+                label: 'Open App Store'),
+            ],
+            TargetPlatform.macOS || TargetPlatform.fuchsia
+              || TargetPlatform.linux || TargetPlatform.windows => throw UnimplementedError(),
+          }),
+      ]);
+  }
+}

diff --git a/test/notifications/open_test.dart b/test/notifications/open_test.dart
index a2c14ca20a..2e2eddc0b2 100644
--- a/test/notifications/open_test.dart
+++ b/test/notifications/open_test.dart
@@ -13,6 +13,7 @@ import 'package:zulip/model/narrow.dart';
 import 'package:zulip/notifications/open.dart';
 import 'package:zulip/notifications/receive.dart';
 import 'package:zulip/widgets/app.dart';
+import 'package:zulip/widgets/dialog.dart';
 import 'package:zulip/widgets/home.dart';
 import 'package:zulip/widgets/message_list.dart';
 import 'package:zulip/widgets/page.dart';
@@ -76,6 +77,8 @@ void main() {
   final zulipLocalizations = GlobalLocalizations.zulipLocalizations;

   Future<void> init({bool addSelfAccount = true}) async {
+    debugDisableBetaCompleteDialog = true;
+    addTearDown(() => debugDisableBetaCompleteDialog = false);
     if (addSelfAccount) {
       await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
     }
diff --git a/test/widgets/app_test.dart b/test/widgets/app_test.dart
index 7b1388dbec..ccbed9cd5a 100644
--- a/test/widgets/app_test.dart
+++ b/test/widgets/app_test.dart
@@ -7,6 +7,7 @@ import 'package:zulip/log.dart';
 import 'package:zulip/model/actions.dart';
 import 'package:zulip/model/database.dart';
 import 'package:zulip/widgets/app.dart';
+import 'package:zulip/widgets/dialog.dart';
 import 'package:zulip/widgets/home.dart';
 import 'package:zulip/widgets/page.dart';

@@ -27,6 +28,8 @@ void main() {
     late List<Route<dynamic>> pushedRoutes = [];

     Future<void> prepare(WidgetTester tester) async {
+      debugDisableBetaCompleteDialog = true;
+      addTearDown(() => debugDisableBetaCompleteDialog = false);
       addTearDown(testBinding.reset);

       pushedRoutes = [];
@@ -64,6 +67,8 @@ void main() {
     late List<Route<void>> poppedRoutes;

     Future<void> prepare(WidgetTester tester) async {
+      debugDisableBetaCompleteDialog = true;
+      addTearDown(() => debugDisableBetaCompleteDialog = false);
       addTearDown(testBinding.reset);

       pushedRoutes = [];
@@ -279,6 +284,8 @@ void main() {
     });

     testWidgets('choosing an account clears the navigator stack', (tester) async {
+      debugDisableBetaCompleteDialog = true;
+      addTearDown(() => debugDisableBetaCompleteDialog = false);
       addTearDown(testBinding.reset);
       await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
       await testBinding.globalStore.add(eg.otherAccount, eg.initialSnapshot());
@@ -391,6 +398,8 @@ void main() {
     });

     testWidgets('reportErrorToUserBriefly with details', (tester) async {
+      debugDisableBetaCompleteDialog = true;
+      addTearDown(() => debugDisableBetaCompleteDialog = false);
       addTearDown(testBinding.reset);
       await tester.pumpWidget(const ZulipApp());
       const message = 'test error message';
@@ -418,6 +427,8 @@ void main() {
     });

     Future<void> prepareSnackBarWithDetails(WidgetTester tester, String message, String details) async {
+      debugDisableBetaCompleteDialog = true;
+      addTearDown(() => debugDisableBetaCompleteDialog = false);
       addTearDown(testBinding.reset);
       await tester.pumpWidget(const ZulipApp());
       await tester.pump();
@@ -484,6 +495,8 @@ void main() {
     });

     testWidgets('reportErrorToUserModally', (tester) async {
+      debugDisableBetaCompleteDialog = true;
+      addTearDown(() => debugDisableBetaCompleteDialog = false);
       addTearDown(testBinding.reset);
       await tester.pumpWidget(const ZulipApp());
       const title = 'test title';
diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart
index 1b5c8ad8b5..86a33cb699 100644
--- a/test/widgets/home_test.dart
+++ b/test/widgets/home_test.dart
@@ -8,6 +8,7 @@ import 'package:zulip/model/store.dart';
 import 'package:zulip/widgets/about_zulip.dart';
 import 'package:zulip/widgets/app.dart';
 import 'package:zulip/widgets/app_bar.dart';
+import 'package:zulip/widgets/dialog.dart';
 import 'package:zulip/widgets/home.dart';
 import 'package:zulip/widgets/icons.dart';
 import 'package:zulip/widgets/inbox.dart';
@@ -48,6 +49,8 @@ void main () {
     ..onPopped = ((route, prevRoute) => lastPoppedRoute = route);

   Future<void> prepare(WidgetTester tester) async {
+    debugDisableBetaCompleteDialog = true;
+    addTearDown(() => debugDisableBetaCompleteDialog = false);
     addTearDown(testBinding.reset);
     topRoute = null;
     previousTopRoute = null;
@@ -272,6 +275,8 @@ void main () {
     });

     testWidgets('menu buttons dismiss the menu', (tester) async {
+      debugDisableBetaCompleteDialog = true;
+      addTearDown(() => debugDisableBetaCompleteDialog = false);
       addTearDown(testBinding.reset);
       topRoute = null;
       previousTopRoute = null;
@@ -328,6 +333,8 @@ void main () {
     }

     Future<void> prepare(WidgetTester tester) async {
+      debugDisableBetaCompleteDialog = true;
+      addTearDown(() => debugDisableBetaCompleteDialog = false);
       addTearDown(testBinding.reset);
       topRoute = null;
       previousTopRoute = null;
@@ -521,6 +528,8 @@ void main () {
   });

   testWidgets('logging out while still loading', (tester) async {
+    debugDisableBetaCompleteDialog = true;
+    addTearDown(() => debugDisableBetaCompleteDialog = false);
     // Regression test for: https://github.com/zulip/zulip-flutter/issues/1219
     addTearDown(testBinding.reset);
     await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
@@ -537,6 +546,8 @@ void main () {
   });

   testWidgets('logging out after fully loaded', (tester) async {
+    debugDisableBetaCompleteDialog = true;
+    addTearDown(() => debugDisableBetaCompleteDialog = false);
     // Regression test for: https://github.com/zulip/zulip-flutter/issues/1219
     addTearDown(testBinding.reset);
     await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
diff --git a/test/widgets/login_test.dart b/test/widgets/login_test.dart
index a5109ba5db..9cb646f11b 100644
--- a/test/widgets/login_test.dart
+++ b/test/widgets/login_test.dart
@@ -12,6 +12,7 @@ import 'package:zulip/model/binding.dart';
 import 'package:zulip/model/database.dart';
 import 'package:zulip/model/localizations.dart';
 import 'package:zulip/widgets/app.dart';
+import 'package:zulip/widgets/dialog.dart';
 import 'package:zulip/widgets/home.dart';
 import 'package:zulip/widgets/login.dart';
 import 'package:zulip/widgets/page.dart';
@@ -83,6 +84,8 @@ void main() {

     Future<void> prepare(WidgetTester tester,
         GetServerSettingsResult serverSettings) async {
+      debugDisableBetaCompleteDialog = true;
+      addTearDown(() => debugDisableBetaCompleteDialog = false);
       addTearDown(testBinding.reset);

       connection = testBinding.globalStore.apiConnection(


action_sheet: Add 'Channel feed' button to channel action sheet

Fixes #1705.

## Screenshots

### From channel feed (no button)

### Not from channel feed (button)

Add channel feed button to bottom sheet

Add channel feed button to bottom sheet
To improve navigation, we should add a "Channel feed" link to the bottom sheet for channels.

CZO: [#mobile > channel feed link in bottom sheet?](https://chat.zulip.org/#narrow/channel/48-mobile/topic/channel.20feed.20link.20in.20bottom.20sheet.3F/with/2219497)

It should probably be omitted if you're already on the channel's channel feed.
On web, we also omit it when just clicking on the channel name would take you to the channel feed. Might be fine to include the link on mobile, though, at least while the menu is short like it currently is.

flutter test test/widgets/action_sheet_test.dart --reporter expanded 2>&1

diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index 56d906e233..776a866d3b 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -116,6 +116,10 @@
   "@actionSheetOptionListOfTopics": {
     "description": "Label for navigating to a channel's topic-list page."
   },
+  "actionSheetOptionChannelFeed": "Channel feed",
+  "@actionSheetOptionChannelFeed": {
+    "description": "Label for navigating to a channel's channel-feed page."
+  },
   "actionSheetOptionUnsubscribe": "Unsubscribe",
   "@actionSheetOptionUnsubscribe": {
     "description": "Label in the channel action sheet for unsubscribing from the channel."
diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index 6ec90bba8c..238d336f39 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -305,6 +305,12 @@ abstract class ZulipLocalizations {
   /// **'List of topics'**
   String get actionSheetOptionListOfTopics;

+  /// Label for navigating to a channel's channel-feed page.
+  ///
+  /// In en, this message translates to:
+  /// **'Channel feed'**
+  String get actionSheetOptionChannelFeed;
+
   /// Label in the channel action sheet for unsubscribing from the channel.
   ///
   /// 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 1037cd6cdd..0ff35c4b5b 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -102,6 +102,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
   @override
   String get actionSheetOptionListOfTopics => 'List of topics';

+  @override
+  String get actionSheetOptionChannelFeed => 'Channel feed';
+
   @override
   String get actionSheetOptionUnsubscribe => 'Unsubscribe';

diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart
index 3a4c7da9a5..fb47405921 100644
--- a/lib/generated/l10n/zulip_localizations_de.dart
+++ b/lib/generated/l10n/zulip_localizations_de.dart
@@ -104,6 +104,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations {
   @override
   String get actionSheetOptionListOfTopics => 'Themenliste';

+  @override
+  String get actionSheetOptionChannelFeed => 'Channel feed';
+
   @override
   String get actionSheetOptionUnsubscribe => 'Deabonnieren';

diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart
index e3d201ba4f..2e94965a87 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -102,6 +102,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
   @override
   String get actionSheetOptionListOfTopics => 'List of topics';

+  @override
+  String get actionSheetOptionChannelFeed => 'Channel feed';
+
   @override
   String get actionSheetOptionUnsubscribe => 'Unsubscribe';

diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart
index e0244f4b43..3766c7051e 100644
--- a/lib/generated/l10n/zulip_localizations_fr.dart
+++ b/lib/generated/l10n/zulip_localizations_fr.dart
@@ -104,6 +104,9 @@ class ZulipLocalizationsFr extends ZulipLocalizations {
   @override
   String get actionSheetOptionListOfTopics => 'Liste des sujets';

+  @override
+  String get actionSheetOptionChannelFeed => 'Channel feed';
+
   @override
   String get actionSheetOptionUnsubscribe => 'Unsubscribe';

diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart
index 0f1b6b350e..028f8680fb 100644
--- a/lib/generated/l10n/zulip_localizations_it.dart
+++ b/lib/generated/l10n/zulip_localizations_it.dart
@@ -103,6 +103,9 @@ class ZulipLocalizationsIt extends ZulipLocalizations {
   @override
   String get actionSheetOptionListOfTopics => 'Elenco degli argomenti';

+  @override
+  String get actionSheetOptionChannelFeed => 'Channel feed';
+
   @override
   String get actionSheetOptionUnsubscribe => 'Unsubscribe';

diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart
index 2bd2d6c68f..647b1f155d 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -101,6 +101,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
   @override
   String get actionSheetOptionListOfTopics => 'トピック一覧';

+  @override
+  String get actionSheetOptionChannelFeed => 'Channel feed';
+
   @override
   String get actionSheetOptionUnsubscribe => 'Unsubscribe';

diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart
index 4e7b862eeb..36411b5274 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -102,6 +102,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
   @override
   String get actionSheetOptionListOfTopics => 'List of topics';

+  @override
+  String get actionSheetOptionChannelFeed => 'Channel feed';
+
   @override
   String get actionSheetOptionUnsubscribe => 'Unsubscribe';

diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart
index db93e8f9ba..e4fdc40e60 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -104,6 +104,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
   @override
   String get actionSheetOptionListOfTopics => 'Lista wątków';

+  @override
+  String get actionSheetOptionChannelFeed => 'Channel feed';
+
   @override
   String get actionSheetOptionUnsubscribe => 'Unsubscribe';

diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart
index 30bb44aa98..3b0d3e8cd8 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -104,6 +104,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
   @override
   String get actionSheetOptionListOfTopics => 'Список тем';

+  @override
+  String get actionSheetOptionChannelFeed => 'Channel feed';
+
   @override
   String get actionSheetOptionUnsubscribe => 'Отписаться';

diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart
index 3da8dbfa25..26be66502e 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -102,6 +102,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
   @override
   String get actionSheetOptionListOfTopics => 'List of topics';

+  @override
+  String get actionSheetOptionChannelFeed => 'Channel feed';
+
   @override
   String get actionSheetOptionUnsubscribe => 'Unsubscribe';

diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart
index 86a7c52b06..782288eb49 100644
--- a/lib/generated/l10n/zulip_localizations_sl.dart
+++ b/lib/generated/l10n/zulip_localizations_sl.dart
@@ -102,6 +102,9 @@ class ZulipLocalizationsSl extends ZulipLocalizations {
   @override
   String get actionSheetOptionListOfTopics => 'Seznam tem';

+  @override
+  String get actionSheetOptionChannelFeed => 'Channel feed';
+
   @override
   String get actionSheetOptionUnsubscribe => 'Unsubscribe';

diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart
index 1c9bbe8f93..d263a8a9c0 100644
--- a/lib/generated/l10n/zulip_localizations_uk.dart
+++ b/lib/generated/l10n/zulip_localizations_uk.dart
@@ -105,6 +105,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
   @override
   String get actionSheetOptionListOfTopics => 'Список тем';

+  @override
+  String get actionSheetOptionChannelFeed => 'Channel feed';
+
   @override
   String get actionSheetOptionUnsubscribe => 'Скасувати підписку';

diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart
index 3133bc58f4..59fbc4b5d5 100644
--- a/lib/generated/l10n/zulip_localizations_zh.dart
+++ b/lib/generated/l10n/zulip_localizations_zh.dart
@@ -102,6 +102,9 @@ class ZulipLocalizationsZh extends ZulipLocalizations {
   @override
   String get actionSheetOptionListOfTopics => 'List of topics';

+  @override
+  String get actionSheetOptionChannelFeed => 'Channel feed';
+
   @override
   String get actionSheetOptionUnsubscribe => 'Unsubscribe';

diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart
index cc56dedf08..56729e8bfc 100644
--- a/lib/widgets/action_sheet.dart
+++ b/lib/widgets/action_sheet.dart
@@ -334,12 +334,19 @@ enum BottomSheetDismissButtonStyle {
 /// Show a sheet of actions you can take on a channel.
 ///
 /// Needs a [PageRoot] ancestor.
+/// May or may not have a [MessageListPage] ancestor;
+/// some callers are on that page and some aren't.
 void showChannelActionSheet(BuildContext context, {
   required int channelId,
   bool showTopicListButton = true,
 }) {
   final pageContext = PageRoot.contextOf(context);
   final store = PerAccountStoreWidget.of(pageContext);
+  final messageListPageState = MessageListPage.maybeAncestorOf(pageContext);
+
+  final messageListPageNarrow = messageListPageState?.narrow;
+  final isOnChannelFeed = messageListPageNarrow is ChannelNarrow
+    && messageListPageNarrow.streamId == channelId;

   final unreadCount = store.unreads.countInChannelNarrow(channelId);
   final isSubscribed = store.subscriptions[channelId] != null;
@@ -352,6 +359,8 @@ void showChannelActionSheet(BuildContext context, {
         MarkChannelAsReadButton(pageContext: pageContext, channelId: channelId),
       if (showTopicListButton)
         TopicListButton(pageContext: pageContext, channelId: channelId),
+      if (!isOnChannelFeed)
+        ChannelFeedButton(pageContext: pageContext, channelId: channelId),
       CopyChannelLinkButton(channelId: channelId, pageContext: pageContext)
     ],
     if (isSubscribed)
@@ -452,6 +461,30 @@ class TopicListButton extends ActionSheetMenuItemButton {
   }
 }

+class ChannelFeedButton extends ActionSheetMenuItemButton {
+  const ChannelFeedButton({
+    super.key,
+    required this.channelId,
+    required super.pageContext,
+  });
+
+  final int channelId;
+
+  @override
+  IconData get icon => ZulipIcons.message_feed;
+
+  @override
+  String label(ZulipLocalizations zulipLocalizations) {
+    return zulipLocalizations.actionSheetOptionChannelFeed;
+  }
+
+  @override
+  void onPressed() {
+    Navigator.push(pageContext,
+      MessageListPage.buildRoute(context: pageContext, narrow: ChannelNarrow(channelId)));
+  }
+}
+
 class CopyChannelLinkButton extends ActionSheetMenuItemButton {
   const CopyChannelLinkButton({
     super.key,
diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart
index dab793392b..6316655705 100644
--- a/lib/widgets/message_list.dart
+++ b/lib/widgets/message_list.dart
@@ -206,14 +206,30 @@ class MessageListPage extends StatefulWidget {
   ///
   /// Uses the inefficient [BuildContext.findAncestorStateOfType];
   /// don't call this in a build method.
-  // If we do find ourselves wanting this in a build method, it won't be hard
-  // to enable that: we'd just need to add an [InheritedWidget] here.
+  ///
+  /// See also:
+  ///  * [maybeAncestorOf], which returns null instead of throwing
+  ///    when an ancestor [MessageListPageState] is not found.
   static MessageListPageState ancestorOf(BuildContext context) {
-    final state = context.findAncestorStateOfType<_MessageListPageState>();
+    final state = maybeAncestorOf(context);
     assert(state != null, 'No MessageListPage ancestor');
     return state!;
   }

+  /// The [MessageListPageState] above this context in the tree, if any.
+  ///
+  /// Uses the inefficient [BuildContext.findAncestorStateOfType];
+  /// don't call this in a build method.
+  ///
+  /// See also:
+  ///  * [ancestorOf], which throws instead of returning null
+  ///    when an ancestor [MessageListPageState] is not found.
+  // If we do find ourselves wanting this in a build method, it won't be hard
+  // to enable that: we'd just need to add an [InheritedWidget] here.
+  static MessageListPageState? maybeAncestorOf(BuildContext context) {
+    return context.findAncestorStateOfType<_MessageListPageState>();
+  }
+
   final Narrow initNarrow;
   final int? initAnchorMessageId; // TODO(#1564) highlight target upon load

diff --git a/lib/widgets/topic_list.dart b/lib/widgets/topic_list.dart
index 61e16df6ec..16111fc678 100644
--- a/lib/widgets/topic_list.dart
+++ b/lib/widgets/topic_list.dart
@@ -103,7 +103,10 @@ class _TopicListAppBarTitle extends StatelessWidget {
       child: GestureDetector(
         behavior: HitTestBehavior.translucent,
         onLongPress: () {
-          showChannelActionSheet(context, channelId: streamId);
+          showChannelActionSheet(context,
+            channelId: streamId,
+            // We're already on the topic list.
+            showTopicListButton: false);
         },
         child: Align(alignment: alignment,
           child: _buildStreamRow(context))));

diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart
index 83574786a7..8f4d614220 100644
--- a/test/widgets/action_sheet_test.dart
+++ b/test/widgets/action_sheet_test.dart
@@ -34,6 +34,7 @@ import 'package:zulip/widgets/message_list.dart';
 import 'package:share_plus_platform_interface/method_channel/method_channel_share.dart';
 import 'package:zulip/widgets/read_receipts.dart';
 import 'package:zulip/widgets/subscription_list.dart';
+import 'package:zulip/widgets/topic_list.dart';
 import 'package:zulip/widgets/user.dart';
 import '../api/fake_api.dart';

@@ -180,7 +181,9 @@ void main() {
     }

     Future<void> showFromInbox(WidgetTester tester) async {
+      transitionDurationObserver = TransitionDurationObserver();
       await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
+        navigatorObservers: [transitionDurationObserver],
         child: const HomePage()));
       await tester.pump();
       check(find.byType(InboxPageBody)).findsOne();
@@ -201,16 +204,19 @@ void main() {
       await tester.pump(const Duration(milliseconds: 250));
     }

-    Future<void> showFromAppBar(WidgetTester tester, {
+    Future<void> showFromMsglistAppBar(WidgetTester tester, {
       ZulipStream? channel,
-      List<StreamMessage>? messages,
       required Narrow narrow,
     }) async {
       channel ??= someChannel;
-      messages ??= [someMessage];

       connection.prepare(json: eg.newestGetMessagesResult(
-        foundOldest: true, messages: messages).toJson());
+        foundOldest: true, messages: []).toJson());
+      if (narrow case ChannelNarrow()) {
+        // We auto-focus the topic input when there are no messages;
+        // this is for topic autocomplete.
+        connection.prepare(json: GetStreamTopicsResult(topics: []).toJson());
+      }
       await tester.pumpWidget(TestZulipApp(
         accountId: eg.selfAccount.id,
         child: MessageListPage(
@@ -240,6 +246,22 @@ void main() {
       await tester.pump(const Duration(milliseconds: 250));
     }

+    Future<void> showFromTopicListAppBar(WidgetTester tester) async {
+      final transitionDurationObserver = TransitionDurationObserver();
+
+      connection.prepare(json: GetStreamTopicsResult(topics: []).toJson());
+      await tester.pumpWidget(TestZulipApp(
+        navigatorObservers: [transitionDurationObserver],
+        accountId: eg.selfAccount.id,
+        child: TopicListPage(streamId: someChannel.streamId)));
+      await tester.pump();
+
+      await tester.longPress(find.descendant(
+        of: find.byType(ZulipAppBar),
+        matching: find.text(someChannel.name)));
+      await transitionDurationObserver.pumpPastTransition(tester);
+    }
+
     final actionSheetFinder = find.byType(BottomSheet);
     Finder findButtonForLabel(String label) =>
       find.descendant(of: actionSheetFinder, matching: find.text(label));
@@ -256,7 +278,6 @@ void main() {
       void checkButtons() {
         check(actionSheetFinder).findsOne();
         checkButton('Mark channel as read');
-        checkButton('List of topics');
         checkButton('Copy link to channel');
       }

@@ -278,17 +299,17 @@ void main() {
         check(findButtonForLabel('Mark channel as read')).findsNothing();
       });

-      testWidgets('show from app bar in channel narrow', (tester) async {
+      testWidgets('show from message-list app bar in channel narrow', (tester) async {
         await prepare();
         final narrow = ChannelNarrow(someChannel.streamId);
-        await showFromAppBar(tester, narrow: narrow);
+        await showFromMsglistAppBar(tester, narrow: narrow);
         checkButtons();
       });

-      testWidgets('show from app bar in topic narrow', (tester) async {
+      testWidgets('show from message-list app bar in topic narrow', (tester) async {
         await prepare();
         final narrow = eg.topicNarrow(someChannel.streamId, someTopic);
-        await showFromAppBar(tester, narrow: narrow);
+        await showFromMsglistAppBar(tester, narrow: narrow);
         checkButtons();
       });

@@ -297,6 +318,12 @@ void main() {
         await showFromRecipientHeader(tester, message: someMessage);
         checkButtons();
       });
+
+      testWidgets('show from topic-list app bar', (tester) async {
+        await prepare();
+        await showFromTopicListAppBar(tester);
+        checkButtons();
+      });
     });

     group('SubscribeButton', () {
@@ -309,7 +336,7 @@ void main() {
         await prepare();
         final narrow = ChannelNarrow(someChannel.streamId);
         await store.removeSubscription(narrow.streamId);
-        await showFromAppBar(tester, narrow: narrow);
+        await showFromMsglistAppBar(tester, narrow: narrow);
         checkButton('Subscribe');
       });

@@ -317,7 +344,7 @@ void main() {
         await prepare();
         final narrow = ChannelNarrow(someChannel.streamId);
         check(store.subscriptions[narrow.streamId]).isNotNull();
-        await showFromAppBar(tester, narrow: narrow);
+        await showFromMsglistAppBar(tester, narrow: narrow);
         checkNoButton('Subscribe');
       });

@@ -325,7 +352,7 @@ void main() {
         await prepare();
         final narrow = ChannelNarrow(someChannel.streamId);
         await store.removeSubscription(narrow.streamId);
-        await showFromAppBar(tester, narrow: narrow);
+        await showFromMsglistAppBar(tester, narrow: narrow);

         connection.prepare(json: {});
         await tapButton(tester);
@@ -386,17 +413,80 @@ void main() {
       });
     });

-    testWidgets('TopicListButton', (tester) async {
-      await prepare();
-      await showFromAppBar(tester,
-        narrow: ChannelNarrow(someChannel.streamId));
+    group('TopicListButton', () {
+      testWidgets('not visible from app bar on topic list', (tester) async {
+        await prepare();
+        await showFromTopicListAppBar(tester);
+        checkNoButton('List of topics');
+      });

-      connection.prepare(json: GetStreamTopicsResult(topics: [
-        eg.getStreamTopicsEntry(name: 'some topic foo'),
-      ]).toJson());
-      await tester.tap(findButtonForLabel('List of topics'));
-      await tester.pumpAndSettle();
-      check(find.text('some topic foo')).findsOne();
+      testWidgets('happy path from msglist app bar', (tester) async {
+        await prepare();
+        await showFromMsglistAppBar(tester,
+          narrow: ChannelNarrow(someChannel.streamId));
+
+        connection.prepare(json: GetStreamTopicsResult(topics: [
+          eg.getStreamTopicsEntry(name: 'some topic foo'),
+        ]).toJson());
+        await tester.tap(findButtonForLabel('List of topics'));
+        await tester.pumpAndSettle();
+        check(find.text('some topic foo')).findsOne();
+      });
+    });
+
+    group('ChannelFeedButton', () {
+      Future<void> tapButtonAndPump(WidgetTester tester) async {
+        await tester.tap(findButtonForLabel('Channel feed'));
+        await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e
+      }
+
+      testWidgets('from inbox: visible', (tester) async {
+        await prepare();
+        await showFromInbox(tester);
+        checkButton('Channel feed');
+      });
+
+      testWidgets('from subscription list: visible', (tester) async {
+        await prepare();
+        await showFromSubscriptionList(tester);
+        checkButton('Channel feed');
+      });
+
+      testWidgets('from recipient header in combined feed: visible', (tester) async {
+        await prepare();
+        await showFromRecipientHeader(tester);
+        checkButton('Channel feed');
+      });
+
+      testWidgets('from app bar on topic list: visible', (tester) async {
+        await prepare();
+        await showFromTopicListAppBar(tester);
+        checkButton('Channel feed');
+      });
+
+      testWidgets('from msglist app bar on channel feed: not visible', (tester) async {
+        await prepare();
+        await showFromMsglistAppBar(tester, narrow: ChannelNarrow(someChannel.streamId));
+        checkNoButton('Channel feed');
+      });
+
+      // (The channel action sheet isn't reached from a recipient header
+      // in the channel feed.)
+
+      testWidgets('navigates to channel feed', (tester) async {
+        await prepare();
+        await showFromInbox(tester);
+
+        connection.prepare(json: eg.newestGetMessagesResult(
+          foundOldest: true, messages: []).toJson());
+        // for topic autocomplete
+        connection.prepare(json: GetStreamTopicsResult(topics: []).toJson());
+        await tapButtonAndPump(tester);
+        await transitionDurationObserver.pumpPastTransition(tester);
+
+        final appBar = tester.widget(find.byType(MessageListAppBarTitle)) as MessageListAppBarTitle;
+        check(appBar.narrow).equals(ChannelNarrow(someChannel.streamId));
+      });
     });

     group('CopyChannelLinkButton', () {
@@ -416,7 +506,7 @@ void main() {
       testWidgets('copies channel link to clipboard', (tester) async {
         await prepare();
         final narrow = ChannelNarrow(someChannel.streamId);
-        await showFromAppBar(tester, narrow: narrow);
+        await showFromMsglistAppBar(tester, narrow: narrow);

         await tapCopyChannelLinkButton(tester);
         await tester.pump(Duration.zero);
@@ -436,7 +526,7 @@ void main() {
         await prepare();
         final narrow = ChannelNarrow(someChannel.streamId);
         check(store.subscriptions[narrow.streamId]).isNotNull();
-        await showFromAppBar(tester, narrow: narrow);
+        await showFromMsglistAppBar(tester, narrow: narrow);
         checkButton('Unsubscribe');
       });

@@ -444,19 +534,17 @@ void main() {
         await prepare();
         final narrow = ChannelNarrow(someChannel.streamId);
         await store.removeSubscription(narrow.streamId);
-        await showFromAppBar(tester, narrow: narrow);
+        await showFromMsglistAppBar(tester, narrow: narrow);
         checkNoButton('Unsubscribe');
       });

       testWidgets('smoke, public channel', (tester) async {
         final channel = eg.stream(inviteOnly: false);
-        final message = eg.streamMessage(stream: channel);
         await prepare();
         await store.addStream(channel);
         await store.addSubscription(eg.subscription(channel));
         final narrow = ChannelNarrow(channel.streamId);
-        await showFromAppBar(tester,
-          channel: channel, narrow: narrow, messages: [message]);
+        await showFromMsglistAppBar(tester, channel: channel, narrow: narrow);

         connection.prepare(json: {});
         await tapButton(tester);
@@ -474,13 +562,11 @@ void main() {

       testWidgets('smoke, private channel', (tester) async {
         final channel = eg.stream(inviteOnly: true);
-        final message = eg.streamMessage(stream: channel);
         await prepare();
         await store.addStream(channel);
         await store.addSubscription(eg.subscription(channel));
         final narrow = ChannelNarrow(channel.streamId);
-        await showFromAppBar(tester,
-          channel: channel, narrow: narrow, messages: [message]);
+        await showFromMsglistAppBar(tester, channel: channel, narrow: narrow);
         connection.takeRequests();

         connection.prepare(json: {});