compose: Enforce max topic/content length by code points, following API

Fixes #1238.

Done by computing `String.runes` (the number of Unicode code points) unless we know that the result can't exceed the threshold number of code points. In particular, we don't compute it unless `String.length` (the number of UTF-16 code units) exceeds the threshold number of code points.

compose: Enforce max topic/content length by Unicode code points, not UTF-16 code units
We have this comment where we decide if the content input is too long:

```dart
      // normalized.length is the number of UTF-16 code units, while the server
      // API expresses the max in Unicode code points. So this comparison will
      // be conservative and may cut the user off shorter than necessary.
      if (textNormalized.length > kMaxMessageLengthCodePoints)
```

This issue is to stop cutting the user off shorter than necessary, while not regressing on performance in the common case where the length isn't approaching the limit.

This applies equally to topic lengths, where the server limit is also expressed in Unicode code points. For example, on CZO where the topic limit is 60 code points, we're not accepting '👨👩👦👨👩👦👨👩👦👨👩👦👨👩👦👨👩👦👨👩👦👨👩👦👨👩👦👨👩👦👨👩👦👨👩👦' (60 code points), but we should accept it because the server does.

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

diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart
index 3374741c51..be6728c790 100644
--- a/lib/api/route/messages.dart
+++ b/lib/api/route/messages.dart
@@ -156,7 +156,7 @@ class GetMessagesResult {
 }

 // https://zulip.com/api/send-message#parameter-topic
-const int kMaxTopicLength = 60;
+const int kMaxTopicLengthCodePoints = 60;

 // https://zulip.com/api/send-message#parameter-content
 const int kMaxMessageLengthCodePoints = 10000;
diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart
index 83d923b6b8..d279c404ad 100644
--- a/lib/widgets/compose_box.dart
+++ b/lib/widgets/compose_box.dart
@@ -28,10 +28,31 @@ const double _composeButtonSize = 44;
 ///
 /// Subclasses must ensure that [_update] is called in all exposed constructors.
 abstract class ComposeController<ErrorT> extends TextEditingController {
+  int get maxLengthUnicodeCodePoints;
+
   String get textNormalized => _textNormalized;
   late String _textNormalized;
   String _computeTextNormalized();

+  /// Length of [textNormalized] in Unicode code points
+  /// if it might exceed [maxLengthUnicodeCodePoints], else null.
+  ///
+  /// Use this instead of [String.length]
+  /// to enforce a max length expressed in code points.
+  /// [String.length] is conservative and may cut the user off too short.
+  ///
+  /// Counting code points ([String.runes])
+  /// is more expensive than getting the number of UTF-16 code units
+  /// ([String.length]), so we avoid it when the result definitely won't exceed
+  /// [maxLengthUnicodeCodePoints].
+  late int? _lengthUnicodeCodePointsIfLong;
+  @visibleForTesting
+  int? get debugLengthUnicodeCodePointsIfLong => _lengthUnicodeCodePointsIfLong;
+  int? _computeLengthUnicodeCodePointsIfLong() =>
+    _textNormalized.length > maxLengthUnicodeCodePoints
+      ? _textNormalized.runes.length
+      : null;
+
   List<ErrorT> get validationErrors => _validationErrors;
   late List<ErrorT> _validationErrors;
   List<ErrorT> _computeValidationErrors();
@@ -40,6 +61,8 @@ abstract class ComposeController<ErrorT> extends TextEditingController {

   void _update() {
     _textNormalized = _computeTextNormalized();
+    // uses _textNormalized, so comes after _computeTextNormalized()
+    _lengthUnicodeCodePointsIfLong = _computeLengthUnicodeCodePointsIfLong();
     _validationErrors = _computeValidationErrors();
     hasValidationErrors.value = _validationErrors.isNotEmpty;
   }
@@ -74,6 +97,9 @@ class ComposeTopicController extends ComposeController<TopicValidationError> {
   //   https://zulip.com/help/require-topics
   final mandatory = true;

+  // TODO(#307) use `max_topic_length` instead of hardcoded limit
+  @override final maxLengthUnicodeCodePoints = kMaxTopicLengthCodePoints;
+
   @override
   String _computeTextNormalized() {
     String trimmed = text.trim();
@@ -85,7 +111,11 @@ class ComposeTopicController extends ComposeController<TopicValidationError> {
     return [
       if (mandatory && textNormalized == kNoTopicTopic)
         TopicValidationError.mandatoryButEmpty,
-      if (textNormalized.length > kMaxTopicLength)
+
+      if (
+        _lengthUnicodeCodePointsIfLong != null
+        && _lengthUnicodeCodePointsIfLong! > maxLengthUnicodeCodePoints
+      )
         TopicValidationError.tooLong,
     ];
   }
@@ -120,6 +150,9 @@ class ComposeContentController extends ComposeController<ContentValidationError>
     _update();
   }

+  // TODO(#1237) use `max_message_length` instead of hardcoded limit
+  @override final maxLengthUnicodeCodePoints = kMaxMessageLengthCodePoints;
+
   int _nextQuoteAndReplyTag = 0;
   int _nextUploadTag = 0;

@@ -261,10 +294,10 @@ class ComposeContentController extends ComposeController<ContentValidationError>
       if (textNormalized.isEmpty)
         ContentValidationError.empty,

-      // normalized.length is the number of UTF-16 code units, while the server
-      // API expresses the max in Unicode code points. So this comparison will
-      // be conservative and may cut the user off shorter than necessary.
-      if (textNormalized.length > kMaxMessageLengthCodePoints)
+      if (
+        _lengthUnicodeCodePointsIfLong != null
+        && _lengthUnicodeCodePointsIfLong! > maxLengthUnicodeCodePoints
+      )
         ContentValidationError.tooLong,

       if (_quoteAndReplies.isNotEmpty)

diff --git a/test/widgets/app_test.dart b/test/widgets/app_test.dart
index 8c992533ee..af386bfdf1 100644
--- a/test/widgets/app_test.dart
+++ b/test/widgets/app_test.dart
@@ -280,14 +280,14 @@ void main() {
       check(ZulipApp.ready).value.isFalse();
       await tester.pump();
       check(findSnackBarByText(message).evaluate()).isEmpty();
-      check(find.byType(AlertDialog).evaluate()).isEmpty();
+      checkNoErrorDialog(tester);

       check(ZulipApp.ready).value.isTrue();
       // After app startup, reportErrorToUserBriefly displays a SnackBar.
       reportErrorToUserBriefly(message, details: details);
       await tester.pumpAndSettle();
       check(findSnackBarByText(message).evaluate()).single;
-      check(find.byType(AlertDialog).evaluate()).isEmpty();
+      checkNoErrorDialog(tester);

       // Open the error details dialog.
       await tester.tap(find.text('Details'));
diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart
index cde7133d74..96bc6cb837 100644
--- a/test/widgets/compose_box_test.dart
+++ b/test/widgets/compose_box_test.dart
@@ -41,9 +41,6 @@ void main() {
   late FakeApiConnection connection;
   late ComposeBoxController? controller;

-  final contentInputFinder = find.byWidgetPredicate(
-    (widget) => widget is TextField && widget.controller is ComposeContentController);
-
   Future<void> prepareComposeBox(WidgetTester tester, {
     required Narrow narrow,
     User? selfUser,
@@ -80,6 +77,7 @@ void main() {
     controller = tester.state<ComposeBoxState>(find.byType(ComposeBox)).controller;
   }

+  /// Set the topic input's text to [topic], using [WidgetTester.enterText].
   Future<void> enterTopic(WidgetTester tester, {
     required ChannelNarrow narrow,
     required String topic,
@@ -95,6 +93,23 @@ void main() {
       ..url.path.equals('/api/v1/users/me/${narrow.streamId}/topics');
   }

+  /// A [Finder] for the content input.
+  ///
+  /// To enter some text, use [enterContent].
+  final contentInputFinder = find.byWidgetPredicate(
+    (widget) => widget is TextField && widget.controller is ComposeContentController);
+
+  /// Set the content input's text to [content], using [WidgetTester.enterText].
+  Future<void> enterContent(WidgetTester tester, String content) async {
+    await tester.enterText(contentInputFinder, content);
+  }
+
+  Future<void> tapSendButton(WidgetTester tester) async {
+    connection.prepare(json: SendMessageResult(id: 123).toJson());
+    await tester.tap(find.byIcon(ZulipIcons.send));
+    await tester.pump(Duration.zero);
+  }
+
   group('ComposeContentController', () {
     group('insertPadded', () {
       // Like `parseMarkedText` in test/model/autocomplete_test.dart,
@@ -197,6 +212,102 @@ void main() {
     });
   });

+  group('length validation', () {
+    final channel = eg.stream();
+
+    /// String where there are [n] Unicode code points,
+    /// >[n] UTF-16 code units, and <[n] "characters" a.k.a. grapheme clusters.
+    String makeStringWithCodePoints(int n) {
+      assert(n >= 5);
+      const graphemeCluster = '👨👩👦';
+      assert(graphemeCluster.runes.length == 5);
+      assert(graphemeCluster.length == 8);
+      assert(graphemeCluster.characters.length == 1);
+
+      final result =
+        graphemeCluster * (n ~/ 5)
+        + 'a' * (n % 5);
+      assert(result.runes.length == n);
+
+      return result;
+    }
+
+    group('content', () {
+      Future<void> prepareWithContent(WidgetTester tester, String content) async {
+        TypingNotifier.debugEnable = false;
+        addTearDown(TypingNotifier.debugReset);
+
+        final narrow = ChannelNarrow(channel.streamId);
+        await prepareComposeBox(tester, narrow: narrow, streams: [channel]);
+        await enterTopic(tester, narrow: narrow, topic: 'some topic');
+        await enterContent(tester, content);
+      }
+
+      Future<void> checkErrorResponse(WidgetTester tester) async {
+        await tester.tap(find.byWidget(checkErrorDialog(tester,
+          expectedTitle: 'Message not sent',
+          expectedMessage: 'Message length shouldn\'t be greater than 10000 characters.')));
+      }
+
+      testWidgets('too-long content is rejected', (tester) async {
+        await prepareWithContent(tester,
+          makeStringWithCodePoints(kMaxMessageLengthCodePoints + 1));
+        await tapSendButton(tester);
+        await checkErrorResponse(tester);
+      });
+
+      testWidgets('max-length content not rejected', (tester) async {
+        await prepareWithContent(tester,
+          makeStringWithCodePoints(kMaxMessageLengthCodePoints));
+        await tapSendButton(tester);
+        checkNoErrorDialog(tester);
+      });
+
+      testWidgets('code points not counted unnecessarily', (tester) async {
+        await prepareWithContent(tester, 'a' * kMaxMessageLengthCodePoints);
+        check(controller!.content.debugLengthUnicodeCodePointsIfLong).isNull();
+      });
+    });
+
+    group('topic', () {
+      Future<void> prepareWithTopic(WidgetTester tester, String topic) async {
+        TypingNotifier.debugEnable = false;
+        addTearDown(TypingNotifier.debugReset);
+
+        final narrow = ChannelNarrow(channel.streamId);
+        await prepareComposeBox(tester, narrow: narrow, streams: [channel]);
+        await enterTopic(tester, narrow: narrow, topic: topic);
+        await enterContent(tester, 'some content');
+      }
+
+      Future<void> checkErrorResponse(WidgetTester tester) async {
+        await tester.tap(find.byWidget(checkErrorDialog(tester,
+          expectedTitle: 'Message not sent',
+          expectedMessage: 'Topic length shouldn\'t be greater than 60 characters.')));
+      }
+
+      testWidgets('too-long topic is rejected', (tester) async {
+        await prepareWithTopic(tester,
+          makeStringWithCodePoints(kMaxTopicLengthCodePoints + 1));
+        await tapSendButton(tester);
+        await checkErrorResponse(tester);
+      });
+
+      testWidgets('max-length topic not rejected', (tester) async {
+        await prepareWithTopic(tester,
+          makeStringWithCodePoints(kMaxTopicLengthCodePoints));
+        await tapSendButton(tester);
+        checkNoErrorDialog(tester);
+      });
+
+      testWidgets('code points not counted unnecessarily', (tester) async {
+        await prepareWithTopic(tester, 'a' * kMaxTopicLengthCodePoints);
+        check((controller as StreamComposeBoxController)
+          .topic.debugLengthUnicodeCodePointsIfLong).isNull();
+      });
+    });
+  });
+
   group('ComposeBox textCapitalization', () {
     void checkComposeBoxTextFields(WidgetTester tester, {
       required bool expectTopicTextField,
@@ -244,7 +355,7 @@ void main() {

     Future<void> checkStartTyping(WidgetTester tester, SendableNarrow narrow) async {
       connection.prepare(json: {});
-      await tester.enterText(contentInputFinder, 'hello world');
+      await enterContent(tester, 'hello world');
       checkTypingRequest(TypingOp.start, narrow);
     }

@@ -289,7 +400,7 @@ void main() {
       await checkStartTyping(tester, narrow);

       connection.prepare(json: {});
-      await tester.enterText(contentInputFinder, '');
+      await enterContent(tester, '');
       checkTypingRequest(TypingOp.stop, narrow);
     });

@@ -405,7 +516,7 @@ void main() {
       await prepareComposeBox(tester, narrow: eg.topicNarrow(123, 'some topic'),
         streams: [eg.stream(streamId: 123)]);

-      await tester.enterText(contentInputFinder, 'hello world');
+      await enterContent(tester, 'hello world');

       prepareResponse(456);
       await tester.tap(find.byTooltip(zulipLocalizations.composeBoxSendTooltip));
@@ -427,8 +538,7 @@ void main() {
       await setupAndTapSend(tester, prepareResponse: (int messageId) {
         connection.prepare(json: SendMessageResult(id: messageId).toJson());
       });
-      final errorDialogs = tester.widgetList(find.byType(AlertDialog));
-      check(errorDialogs).isEmpty();
+      checkNoErrorDialog(tester);
     });

     testWidgets('ZulipApiException', (tester) async {
@@ -497,8 +607,7 @@ void main() {
         check(call.allowMultiple).equals(true);
         check(call.type).equals(FileType.media);

-        final errorDialogs = tester.widgetList(find.byType(AlertDialog));
-        check(errorDialogs).isEmpty();
+        checkNoErrorDialog(tester);

         check(controller!.content.text)
           .equals('see image: [Uploading image.jpg…]()\n\n');
@@ -557,8 +666,7 @@ void main() {
         check(call.source).equals(ImageSource.camera);
         check(call.requestFullMetadata).equals(false);

-        final errorDialogs = tester.widgetList(find.byType(AlertDialog));
-        check(errorDialogs).isEmpty();
+        checkNoErrorDialog(tester);

         check(controller!.content.text)
           .equals('see image: [Uploading image.jpg…]()\n\n');
@@ -816,7 +924,7 @@ void main() {
       double? height;
       for (numLines = 2; numLines <= 1000; numLines++) {
         final content = List.generate(numLines, (_) => 'foo').join('\n');
-        await tester.enterText(contentInputFinder, content);
+        await enterContent(tester, content);
         await tester.pump();
         final newHeight = tester.getRect(contentInputFinder).height;
         if (newHeight == height) {
diff --git a/test/widgets/dialog_checks.dart b/test/widgets/dialog_checks.dart
index 504042d07e..af0c6e2963 100644
--- a/test/widgets/dialog_checks.dart
+++ b/test/widgets/dialog_checks.dart
@@ -1,4 +1,6 @@
+import 'package:checks/checks.dart';
 import 'package:flutter/material.dart';
+import 'package:flutter_checks/flutter_checks.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:zulip/widgets/dialog.dart';

@@ -26,6 +28,11 @@ Widget checkErrorDialog(WidgetTester tester, {
       matching: find.widgetWithText(TextButton, 'OK')));
 }

+// TODO(#996) update this to check for per-platform flavors of alert dialog
+void checkNoErrorDialog(WidgetTester tester) {
+  check(find.byType(AlertDialog)).findsNothing();
+}
+
 /// In a widget test, check that [showSuggestedActionDialog] was called
 /// with the right text.
 ///


Rank emoji results in autocomplete

Fixes #1068.

As is, this PR applies to the results of autocomplete in the compose box (when you type a colon `:`). In combination with #1103 adding an emoji picker, it applies to the results in the emoji picker too, because that PR reuses the same autocomplete machinery.

This mimics the ranking in the web client (from [shared/src/typeahead.ts](https://github.com/zulip/zulip/blob/83a121c7e4f365d8253db251b072d0a034b87d4d/web/shared/src/typeahead.ts)) with a handful of small discrepancies. Almost all are (minor) bugs in web:
```dart
    // Behavior differences we might copy, TODO:
    //  * Web ranks each name of a Unicode emoji separately.
    //  * Web recognizes a word-aligned match starting after [ /-] as well as [_].
    //
    // Behavior differences that web should probably fix, TODO(web):
    //  * Among popular emoji with non-exact matches,
    //    web doesn't prioritize prefix over word-aligned; we do.
    //    (This affects just one case: for query "o",
    //    we put :octopus: before :working_on_it:.)
    //  * Web only counts an emoji as "popular" for ranking if the query
    //    is a prefix of a single word in the name; so "thumbs_" or "working_on_i"
    //    lose the ranking boost for :thumbs_up: and :working_on_it: respectively.
    //  * Web starts with only case-sensitive exact matches ("perfect matches"),
    //    and puts case-insensitive exact matches just ahead of prefix matches;
    //    it also distinguishes prefix matches by case-sensitive vs. not.
    //    We use case-insensitive matches throughout;
    //    case seems unhelpful for emoji search.
    //  * Web suppresses Unicode emoji names shadowed by a realm emoji
    //    only if the latter is also a match for the query.  That mostly works,
    //    because emoji with the same name will mostly both match or both not;
    //    but it breaks if the Unicode emoji was a literal match.
```

(There may also be differences in the base ordering within custom emoji or within Unicode emoji, seen when they match a given query equally well; I haven't yet studied those to pin them down.)

The details of this implementation work a bit differently from those in web. I think this one is easier to follow, and it's certainly more efficient. Rather than iterate through the list of candidates again and again to evaluate various criteria, partitioning into sub-lists and sub-sub-lists, for each query we
* give each result a single ranking score once, based on all the criteria;
* then sort by rank, using a stable sort.

Similarly, rather than check for different kinds of matches [once](https://github.com/zulip/zulip/blob/83a121c7e4f365d8253db251b072d0a034b87d4d/web/shared/src/typeahead.ts#L97-L119) to decide if the emoji matches the query at all, and [separately later](https://github.com/zulip/zulip/blob/83a121c7e4f365d8253db251b072d0a034b87d4d/web/shared/src/typeahead.ts#L225-L280) to help decide how to rank it among matching results, we check for matches just once and return an EmojiMatchQuality enum rather than just a boolean, which records the information we need for ranking too.

I think the duplication of logic within the web implementation, in each of those areas, is the root cause of the bugs listed above — they're discrepancies between one part of web's logic and another, as tends to naturally arise once the same logic is repeated in multiple places in different ways.

# Selected commit messages

algorithms: Add bucketSort, for stable, linear-time sorting

Specifically the sort takes linear time so long as there aren't
more than linearly-many buckets.  For our immediate use case of
ranking emoji-autocomplete results, we'll in fact stick to a
constant number of buckets.

---

emoji [nfc]: Add ranking framework for emoji autocomplete results

---

emoji test [nfc]: Make the EmojiMatchQuality values explicit

This actually has a pretty nice effect on the readability of these
tests, even at this stage where the enum isn't doing anything!

Separate from the parent commit just because this one is a
bigger, and almost entirely mechanical and boring, diff.

---

emoji: Rank by quality of match (exact, prefix, other)

Fixes part of #1068.

---

emoji: Add list of the "popular" emoji

---

emoji: Rank "popular" > custom > other emoji

Fixes part of #1068.

---

emoji: Recognize word-aligned matches in ranking

Fixes #1068.

---

emoji: Order "popular" emoji canonically amongst themselves

As a bonus, this provides the popular emoji as candidates
even when we haven't yet fetched the server's emoji data.

Rank emoji autocomplete results

Rank emoji autocomplete results
For the initial implementation of #670, there won't be a useful ordering to the results. This means a user might have to type a longer, more specific query to find their desired emoji than they might with good ranking.

This issue is for ranking the results. The ranking should match what the web app does:
https://github.com/zulip/zulip/blob/65f05794e/web/shared/src/typeahead.ts#L322-L382
(We might make exceptions and depart from that ordering if there are areas where we see an alternative that's substantially better in either quality of results or clarity of code, without being particularly worse on the other axis.)

Broadly, that ranking looks like:
* "Popular emoji", before realm emoji, before (other) Unicode emoji, where "popular emoji" means a hard-coded list of six emoji like :+1:.
* Names that exactly match the query, before names that have it as a prefix, before other matches.

For details, see the web implementation.

### Related issues
* Prerequisite:
  #670
* Similar features for other types of autocomplete:
  #228
  #618
  #881

flutter test test/model/algorithms_test.dart test/model/emoji_test.dart --reporter expanded 2>&1

diff --git a/lib/model/algorithms.dart b/lib/model/algorithms.dart
index 7c03a88d80..93bcb188ee 100644
--- a/lib/model/algorithms.dart
+++ b/lib/model/algorithms.dart
@@ -116,3 +116,67 @@ QueueList<int> setUnion(Iterable<int> xs, Iterable<int> ys) {
   }
   return result;
 }
+
+/// Sort the items by bucket, stably,
+/// and if the buckets are few then in linear time.
+///
+/// The returned list will have the same elements as [xs], ordered by bucket,
+/// and elements in each bucket will appear in the same order as in [xs].
+/// In other words, the list is the result of a stable sort of [xs] by bucket.
+/// (By contrast, Dart's [List.sort] is not guaranteed to be stable.)
+///
+/// For each element of [xs], the bucket identified by [bucketOf]
+/// must be in the range `0 <= bucket < numBuckets`.
+/// Repeated calls to [bucketOf] on the same element must return the same value.
+///
+/// If [bucketOf] returns different answers when called twice for some element,
+/// this function's behavior is undefined:
+/// it may throw, or may return an arbitrary list.
+///
+/// The cost of this function is linear in `xs.length` plus [numBuckets].
+/// In particular if [numBuckets] is a constant
+/// (or more generally is at most a constant multiple of `xs.length`),
+/// then this function sorts the items in linear time, O(n).
+/// On the other hand if there are many more buckets than elements,
+/// consider using a different sorting algorithm.
+List<T> bucketSort<T>(Iterable<T> xs, int Function(T) bucketOf, {
+  required int numBuckets,
+}) {
+  if (xs.isEmpty) return [];
+  if (numBuckets <= 0) throw StateError("bucketSort: non-positive numBuckets");
+
+  final counts = List.generate(numBuckets, (_) => 0);
+  for (final x in xs) {
+    final key = bucketOf(x);
+    _checkBucket(key, numBuckets);
+    counts[key]++;
+  }
+  // Now counts[k] is the number of values with key k.
+
+  var partialSum = 0;
+  for (var k = 0; k < numBuckets; k++) {
+    final count = counts[k];
+    counts[k] = partialSum;
+    partialSum += count;
+  }
+  assert(partialSum == xs.length);
+  // Now counts[k] is the index where the first value with key k should go.
+
+  final result = List.generate(xs.length, (_) => xs.first);
+  for (final x in xs) {
+    // Each counts[k] is the index where the next value with key k should go.
+    final key = bucketOf(x);
+    _checkBucket(key, numBuckets);
+    final index = counts[key]++;
+    if (index >= result.length) {
+      throw StateError("bucketSort: bucketOf gave varying answers on same value");
+    }
+    result[index] = x;
+  }
+  return result;
+}
+
+void _checkBucket(int key, int numBuckets) {
+  if (key < 0) throw StateError("bucketSort: negative bucket");
+  if (key >= numBuckets) throw StateError("bucketSort: bucket out of range");
+}
diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart
index a6285a6ab3..5b66a6d52a 100644
--- a/lib/model/autocomplete.dart
+++ b/lib/model/autocomplete.dart
@@ -758,10 +758,15 @@ sealed class ComposeAutocompleteResult extends AutocompleteResult {}

 /// An emoji chosen in an autocomplete interaction, via [EmojiAutocompleteView].
 class EmojiAutocompleteResult extends ComposeAutocompleteResult {
-  EmojiAutocompleteResult(this.candidate);
+  EmojiAutocompleteResult(this.candidate, this.rank);

   final EmojiCandidate candidate;

+  /// A measure of the result's quality in the context of the query.
+  ///
+  /// Used internally by [EmojiAutocompleteView] for ranking the results.
+  final int rank;
+
   @override
   String toString() {
     return 'EmojiAutocompleteResult(${candidate.description()})';
diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart
index 07591abd34..324d259351 100644
--- a/lib/model/emoji.dart
+++ b/lib/model/emoji.dart
@@ -5,6 +5,7 @@ import '../api/model/events.dart';
 import '../api/model/initial_snapshot.dart';
 import '../api/model/model.dart';
 import '../api/route/realm.dart';
+import 'algorithms.dart';
 import 'autocomplete.dart';
 import 'narrow.dart';
 import 'store.dart';
@@ -108,6 +109,19 @@ mixin EmojiStore {
     required String emojiName,
   });

+  /// Zulip's list of "popular" emoji, to be given precedence in
+  /// offering to users.
+  ///
+  /// See description in the web code:
+  ///   https://github.com/zulip/zulip/blob/83a121c7e/web/shared/src/typeahead.ts#L3-L21
+  // Someday this list may start varying rather than being hard-coded,
+  // and then this will become a non-static member on EmojiStore.
+  // For now, though, the fact it's constant is convenient when writing
+  // tests of the logic that uses this data; so we guarantee it in the API.
+  static Iterable<EmojiCandidate> get popularEmojiCandidates {
+    return EmojiStoreImpl._popularCandidates;
+  }
+
   Iterable<EmojiCandidate> allEmojiCandidates();

   // TODO cut debugServerEmojiData once we can query for lists of emoji;
@@ -206,7 +220,40 @@ class EmojiStoreImpl with EmojiStore {
   /// retrieving the data.
   Map<String, List<String>>? _serverEmojiData;

-  List<EmojiCandidate>? _allEmojiCandidates;
+  static final _popularCandidates = _generatePopularCandidates();
+
+  static List<EmojiCandidate> _generatePopularCandidates() {
+    EmojiCandidate candidate(String emojiCode, String emojiUnicode,
+        List<String> names) {
+      final emojiName = names.removeAt(0);
+      assert(emojiUnicode == tryParseEmojiCodeToUnicode(emojiCode));
+      return EmojiCandidate(emojiType: ReactionType.unicodeEmoji,
+        emojiCode: emojiCode, emojiName: emojiName, aliases: names,
+        emojiDisplay: UnicodeEmojiDisplay(
+          emojiName: emojiName, emojiUnicode: emojiUnicode));
+    }
+    return [
+      // This list should match web:
+      //   https://github.com/zulip/zulip/blob/83a121c7e/web/shared/src/typeahead.ts#L22-L29
+      candidate('1f44d', '👍', ['+1', 'thumbs_up', 'like']),
+      candidate('1f389', '🎉', ['tada']),
+      candidate('1f642', '🙂', ['smile']),
+      candidate( '2764', '❤', ['heart', 'love', 'love_you']),
+      candidate('1f6e0', '🛠', ['working_on_it', 'hammer_and_wrench', 'tools']),
+      candidate('1f419', '🐙', ['octopus']),
+    ];
+  }
+
+  static final _popularEmojiCodes = (() {
+    assert(_popularCandidates.every((c) =>
+      c.emojiType == ReactionType.unicodeEmoji));
+    return Set.of(_popularCandidates.map((c) => c.emojiCode));
+  })();
+
+  static bool _isPopularEmoji(EmojiCandidate candidate) {
+    return candidate.emojiType == ReactionType.unicodeEmoji
+      && _popularEmojiCodes.contains(candidate.emojiCode);
+  }

   EmojiCandidate _emojiCandidateFor({
     required ReactionType emojiType,
@@ -222,6 +269,9 @@ class EmojiStoreImpl with EmojiStore {
   }

   List<EmojiCandidate> _generateAllCandidates() {
+    // See also [EmojiAutocompleteQuery._rankResult];
+    // that ranking takes precedence over the order of this list.
+    //
     // Compare `emoji_picker.rebuild_catalog` in Zulip web;
     // `composebox_typeahead.update_emoji_data` which receives its output;
     // and `emoji.update_emojis` which builds part of its input.
@@ -245,7 +295,7 @@ class EmojiStoreImpl with EmojiStore {
     //    but then ranks them first, after only the six "popular" emoji,
     //    once there's a non-empty query.
     //  * Web gives the six "popular" emoji a set order amongst themselves,
-    //    like we do after #1112; but in web, this order appears only in the
+    //    like we do here; but in web, this order appears only in the
     //    emoji picker on an empty query, and is otherwise lost even when the
     //    emoji are taken out of their home categories and shown instead
     //    together at the front.
@@ -257,12 +307,18 @@ class EmojiStoreImpl with EmojiStore {

     final results = <EmojiCandidate>[];

+    // Include the "popular" emoji, in their canonical order
+    // relative to each other.
+    results.addAll(_popularCandidates);
+
     final namesOverridden = {
       for (final emoji in activeRealmEmoji) emoji.name,
       'zulip',
     };
     // TODO(log) if _serverEmojiData missing
     for (final entry in (_serverEmojiData ?? {}).entries) {
+      if (_popularEmojiCodes.contains(entry.key)) continue;
+
       final allNames = entry.value;
       final String emojiName;
       final List<String>? aliases;
@@ -302,6 +358,8 @@ class EmojiStoreImpl with EmojiStore {
     return results;
   }

+  List<EmojiCandidate>? _allEmojiCandidates;
+
   @override
   Iterable<EmojiCandidate> allEmojiCandidates() {
     return _allEmojiCandidates ??= _generateAllCandidates();
@@ -319,6 +377,42 @@ class EmojiStoreImpl with EmojiStore {
   }
 }

+/// The quality of an emoji's match to an autocomplete query.
+enum EmojiMatchQuality {
+  /// The query matches the whole emoji name (or the literal emoji itself).
+  exact,
+
+  /// The query matches a prefix of the emoji name, but not the whole name.
+  prefix,
+
+  /// The query matches starting at the start of a word in the emoji name,
+  /// but not the start of the whole name.
+  ///
+  /// For example a name "ab_cd_ef" would match queries "c" or "cd_e"
+  /// at this level, but not a query "b_cd_ef".
+  wordAligned,
+
+  /// The query matches somewhere in the emoji name,
+  /// but not at the start of any word.
+  other;
+
+  /// The best possible quality of match.
+  static const best = exact;
+
+  /// The better of the two given qualities of match,
+  /// where null represents no match at all.
+  static EmojiMatchQuality? bestOf(EmojiMatchQuality? a, EmojiMatchQuality? b) {
+    if (b == null) return a;
+    if (a == null) return b;
+    return compare(a, b) <= 0 ? a : b;
+  }
+
+  /// Comparator that puts better matches first.
+  static int compare(EmojiMatchQuality a, EmojiMatchQuality b) {
+    return Enum.compareByIndex(a, b);
+  }
+}
+
 class EmojiAutocompleteView extends AutocompleteView<EmojiAutocompleteQuery, EmojiAutocompleteResult> {
   EmojiAutocompleteView._({required super.store, required super.query});

@@ -333,26 +427,39 @@ class EmojiAutocompleteView extends AutocompleteView<EmojiAutocompleteQuery, Emo

   @override
   Future<List<EmojiAutocompleteResult>?> computeResults() async {
-    // TODO(#1068): rank emoji results (popular, realm, other; exact match, prefix, other)
-    final results = <EmojiAutocompleteResult>[];
+    final unsorted = <EmojiAutocompleteResult>[];
     if (await filterCandidates(filter: _testCandidate,
-          candidates: store.allEmojiCandidates(), results: results)) {
+          candidates: store.allEmojiCandidates(), results: unsorted)) {
       return null;
     }
-    return results;
+    return bucketSort(unsorted,
+      (r) => r.rank, numBuckets: EmojiAutocompleteQuery._numResultRanks);
   }

-  EmojiAutocompleteResult? _testCandidate(EmojiAutocompleteQuery query, EmojiCandidate candidate) {
-    return query.matches(candidate) ? EmojiAutocompleteResult(candidate) : null;
+  static EmojiAutocompleteResult? _testCandidate(EmojiAutocompleteQuery query, EmojiCandidate candidate) {
+    return query.testCandidate(candidate);
   }
 }

 class EmojiAutocompleteQuery extends ComposeAutocompleteQuery {
-  EmojiAutocompleteQuery(super.raw)
-    : _adjusted = _adjustQuery(raw);
+  factory EmojiAutocompleteQuery(String raw)
+    => EmojiAutocompleteQuery._(raw, _adjustQuery(raw));
+
+  EmojiAutocompleteQuery._(super.raw, String adjusted)
+    : _adjusted = adjusted,
+      _sepAdjusted = _separator + adjusted;

+  /// The query string as adjusted for comparing to emoji names,
+  /// via [_adjustQuery].
   final String _adjusted;

+  /// The concatenation of [_separator] with [_adjusted].
+  ///
+  /// Useful for finding word-aligned matches in an emoji name.
+  final String _sepAdjusted;
+
+  static const _separator = '_';
+
   static String _adjustQuery(String raw) {
     return raw.toLowerCase().replaceAll(' ', '_'); // TODO(#1067) remove diacritics too
   }
@@ -362,34 +469,105 @@ class EmojiAutocompleteQuery extends ComposeAutocompleteQuery {
     return EmojiAutocompleteView.init(store: store, query: this);
   }

+  @visibleForTesting
+  EmojiAutocompleteResult? testCandidate(EmojiCandidate candidate) {
+    final matchQuality = match(candidate);
+    if (matchQuality == null) return null;
+    return EmojiAutocompleteResult(candidate,
+      _rankResult(matchQuality, candidate));
+  }
+
   // Compare get_emoji_matcher in Zulip web:shared/src/typeahead.ts .
-  bool matches(EmojiCandidate candidate) {
+  @visibleForTesting
+  EmojiMatchQuality? match(EmojiCandidate candidate) {
+    if (_adjusted == '') return EmojiMatchQuality.prefix;
+
     if (candidate.emojiDisplay case UnicodeEmojiDisplay(:var emojiUnicode)) {
-      if (_adjusted == emojiUnicode) return true;
+      if (_adjusted == emojiUnicode) {
+        return EmojiMatchQuality.exact;
+      }
+    }
+
+    EmojiMatchQuality? result = _matchName(candidate.emojiName);
+    for (final alias in candidate.aliases) {
+      if (result == EmojiMatchQuality.best) return result;
+      result = EmojiMatchQuality.bestOf(result, _matchName(alias));
     }
-    return _nameMatches(candidate.emojiName)
-      || candidate.aliases.any((alias) => _nameMatches(alias));
+    return result;
   }

-  // Compare query_matches_string_in_order in Zulip web:shared/src/typeahead.ts .
-  bool _nameMatches(String emojiName) {
-    // TODO(#1067) this assumes emojiName is already lower-case (and no diacritics)
-    const String separator = '_';
+  EmojiMatchQuality? _matchName(String emojiName) {
+    // 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].

-    if (!_adjusted.contains(separator)) {
+    // 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 (!_adjusted.contains(_separator)) {
       // If the query is a single token (doesn't contain a separator),
-      // the match can be anywhere in the string.
-      return emojiName.contains(_adjusted);
+      // allow a match anywhere in the string, too.
+      if (emojiName.contains(_adjusted))  return EmojiMatchQuality.other;
+    } else {
+      // Otherwise, require at least a word-aligned match.
     }
+    return null;
+  }

-    // If there is a separator in the query, then we
-    // require the match to start at the start of a token.
-    // (E.g. for 'ab_cd_ef', query could be 'ab_c' or 'cd_ef',
-    // but not 'b_cd_ef'.)
-    return emojiName.startsWith(_adjusted)
-      || emojiName.contains(separator + _adjusted);
+  /// A measure of the result's quality in the context of the query,
+  /// ranked from 0 (best) to one less than [_numResultRanks].
+  static int _rankResult(EmojiMatchQuality matchQuality, EmojiCandidate candidate) {
+    // See also [EmojiStoreImpl._generateAllCandidates];
+    // emoji which this function ranks equally
+    // will appear in the order they were put in by that method.
+    //
+    // Compare sort_emojis in Zulip web:
+    //   https://github.com/zulip/zulip/blob/83a121c7e/web/shared/src/typeahead.ts#L322-L382
+    //
+    // Behavior differences we might copy, TODO:
+    //  * Web ranks each name of a Unicode emoji separately.
+    //  * Web recognizes a word-aligned match starting after [ /-] as well as [_].
+    //
+    // Behavior differences that web should probably fix, TODO(web):
+    //  * Among popular emoji with non-exact matches,
+    //    web doesn't prioritize prefix over word-aligned; we do.
+    //    (This affects just one case: for query "o",
+    //    we put :octopus: before :working_on_it:.)
+    //  * Web only counts an emoji as "popular" for ranking if the query
+    //    is a prefix of a single word in the name; so "thumbs_" or "working_on_i"
+    //    lose the ranking boost for :thumbs_up: and :working_on_it: respectively.
+    //  * Web starts with only case-sensitive exact matches ("perfect matches"),
+    //    and puts case-insensitive exact matches just ahead of prefix matches;
+    //    it also distinguishes prefix matches by case-sensitive vs. not.
+    //    We use case-insensitive matches throughout;
+    //    case seems unhelpful for emoji search.
+    //  * Web suppresses Unicode emoji names shadowed by a realm emoji
+    //    only if the latter is also a match for the query.  That mostly works,
+    //    because emoji with the same name will mostly both match or both not;
+    //    but it breaks if the Unicode emoji was a literal match.
+
+    final isPopular = EmojiStoreImpl._isPopularEmoji(candidate);
+    final isCustomEmoji = switch (candidate.emojiType) {
+      // The web implementation calls this condition `is_realm_emoji`,
+      // but its actual semantics is it's true for the Zulip extra emoji too.
+      // See `zulip_emoji` in web:src/emoji.ts .
+      ReactionType.realmEmoji || ReactionType.zulipExtraEmoji => true,
+      ReactionType.unicodeEmoji => false,
+    };
+    return switch (matchQuality) {
+      EmojiMatchQuality.exact       => 0,
+      EmojiMatchQuality.prefix      => isPopular ? 1 : isCustomEmoji ? 3 : 5,
+      EmojiMatchQuality.wordAligned => isPopular ? 2 : isCustomEmoji ? 4 : 6,
+      EmojiMatchQuality.other       =>                 isCustomEmoji ? 7 : 8,
+    };
   }

+  /// The number of possible values returned by [_rankResult].
+  static const _numResultRanks = 9;
+
   @override
   String toString() {
     return '${objectRuntimeType(this, 'EmojiAutocompleteQuery')}($raw)';

diff --git a/test/model/algorithms_test.dart b/test/model/algorithms_test.dart
index b6def6393e..4dc3772105 100644
--- a/test/model/algorithms_test.dart
+++ b/test/model/algorithms_test.dart
@@ -1,5 +1,7 @@
+import 'dart:math';

 import 'package:checks/checks.dart';
+import 'package:collection/collection.dart';
 import 'package:test/scaffolding.dart';
 import 'package:zulip/model/algorithms.dart';

@@ -55,4 +57,95 @@ void main() {
       });
     }
   });
+
+  group('bucketSort', () {
+    /// Same spec as [bucketSort], except slow: N * B time instead of N + B.
+    List<T> simpleBucketSort<T>(Iterable<T> xs, int Function(T) bucketOf, {
+      required int numBuckets,
+    }) {
+      return Iterable.generate(numBuckets,
+        (k) => xs.where((s) => bucketOf(s) == k)).flattenedToList;
+    }
+
+    void checkBucketSort<T>(Iterable<T> xs, {
+      required int Function(T) bucketOf, required int numBuckets,
+    }) {
+      check(bucketSort(xs, bucketOf, numBuckets: numBuckets)).deepEquals(
+        simpleBucketSort<T>(xs, bucketOf, numBuckets: numBuckets));
+    }
+
+    int stringBucket(String s) => s.codeUnits.last - '0'.codeUnits.single;
+
+    test('explicit result, interleaved: 4 elements, 2 buckets', () {
+      check(bucketSort(['a1', 'd0', 'c1', 'b0'], stringBucket, numBuckets: 2))
+        .deepEquals(['d0', 'b0', 'a1', 'c1']);
+    });
+
+    List<_SortablePair> generatePairs(Iterable<int> keys) {
+      var token = 0;
+      return keys.map((k) => _SortablePair(k, "${token++}")).toList();
+    }
+
+    void checkSortPairs(int numBuckets, Iterable<int> keys) {
+      checkBucketSort(numBuckets: numBuckets, bucketOf: (p) => p.key,
+        generatePairs(keys));
+    }
+
+    test('empty list, zero buckets', () {
+      checkSortPairs(0, []);
+    });
+
+    test('empty, some buckets', () {
+      checkSortPairs(3, []);
+    });
+
+    test('interleaved: 4 elements, 2 buckets', () {
+      checkSortPairs(2, [1, 0, 1, 0]);
+    });
+
+    test('some buckets empty: 10 elements in 3 of 10 buckets', () {
+      checkSortPairs(10, [9, 9, 9, 5, 5, 5, 1, 1, 1, 1]);
+    });
+
+    test('one big bucket', () {
+      checkSortPairs(1, Iterable.generate(100, (_) => 0));
+    });
+
+    const seed = 4321;
+
+    Iterable<int> randomKeys({required int numBuckets, required int length}) {
+      final rand = Random(seed);
+      return Iterable.generate(length, (_) => rand.nextInt(numBuckets));
+    }
+
+    test('long random list, 1000 in 2 buckets', () {
+      checkSortPairs(2, randomKeys(numBuckets: 2, length: 1000));
+    });
+
+    test('long random list, 1000 in 1000 buckets', () {
+      checkSortPairs(1000, randomKeys(numBuckets: 1000, length: 1000));
+    });
+
+    test('sparse random list, 100 in 1000 buckets', () {
+      checkSortPairs(1000, randomKeys(numBuckets: 1000, length: 100));
+    });
+  });
+}
+
+class _SortablePair {
+  _SortablePair(this.key, this.tag);
+
+  final int key;
+  final String tag;
+
+  @override
+  bool operator ==(Object other) {
+    return other is _SortablePair && key == other.key && tag == other.tag;
+  }
+
+  @override
+  int get hashCode => Object.hash(key, tag);
+
+  @override
+  String toString() => "$tag:$key";
 }
diff --git a/test/model/emoji_test.dart b/test/model/emoji_test.dart
index 953d986774..a55f2dc36b 100644
--- a/test/model/emoji_test.dart
+++ b/test/model/emoji_test.dart
@@ -78,6 +78,8 @@ void main() {
     });
   });

+  final popularCandidates = EmojiStore.popularEmojiCandidates;
+
   Condition<Object?> isUnicodeCandidate(String? emojiCode, List<String>? names) {
     return (it_) {
       final it = it_.isA<EmojiCandidate>();
@@ -108,6 +110,9 @@ void main() {
       ..aliases.isEmpty();
   }

+  List<Condition<Object?>> arePopularCandidates = popularCandidates.map(
+    (c) => isUnicodeCandidate(c.emojiCode, null)).toList();
+
   group('allEmojiCandidates', () {
     // TODO test emojiDisplay of candidates matches emojiDisplayFor

@@ -123,12 +128,47 @@ void main() {
       return store;
     }

+    test('popular emoji appear even when no server emoji data', () {
+      final store = prepare(unicodeEmoji: null);
+      check(store.allEmojiCandidates()).deepEquals([
+        ...arePopularCandidates,
+        isZulipCandidate(),
+      ]);
+    });
+
+    test('popular emoji appear in their canonical order', () {
+      // In the server's emoji data, have the popular emoji in a permuted order,
+      // and interspersed with other emoji.
+      final store = prepare(unicodeEmoji: {
+        '1f603': ['smiley'],
+        for (final candidate in popularCandidates.skip(3))
+          candidate.emojiCode: [candidate.emojiName, ...candidate.aliases],
+        '1f34a': ['orange', 'tangerine', 'mandarin'],
+        for (final candidate in popularCandidates.take(3))
+          candidate.emojiCode: [candidate.emojiName, ...candidate.aliases],
+        '1f516': ['bookmark'],
+      });
+      // In the allEmojiCandidates result, the popular emoji come first
+      // and are in their canonical order, even though the other Unicode emoji
+      // are in the same order they were given in.
+      check(store.allEmojiCandidates()).deepEquals([
+        for (final candidate in popularCandidates)
+          isUnicodeCandidate(candidate.emojiCode,
+            [candidate.emojiName, ...candidate.aliases]),
+        isUnicodeCandidate('1f603', ['smiley']),
+        isUnicodeCandidate('1f34a', ['orange', 'tangerine', 'mandarin']),
+        isUnicodeCandidate('1f516', ['bookmark']),
+        isZulipCandidate(),
+      ]);
+    });
+
     test('realm emoji included only when active', () {
       final store = prepare(realmEmoji: {
         '1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'abc', deactivated: true),
         '2': eg.realmEmojiItem(emojiCode: '2', emojiName: 'abcd'),
       });
       check(store.allEmojiCandidates()).deepEquals([
+        ...arePopularCandidates,
         isRealmCandidate(emojiCode: '2', emojiName: 'abcd'),
         isZulipCandidate(),
       ]);
@@ -143,6 +183,7 @@ void main() {
         '5': eg.realmEmojiItem(emojiCode: '5', emojiName: 'test', deactivated: true),
       });
       check(store.allEmojiCandidates()).deepEquals([
+        ...arePopularCandidates,
         isRealmCandidate(emojiCode: '4', emojiName: 'try'),
         isZulipCandidate(),
       ]);
@@ -152,11 +193,12 @@ void main() {
       final store = prepare(realmEmoji: {
         '1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'smiley'),
       }, unicodeEmoji: {
-        '1f642': ['smile'],
+        '1f516': ['bookmark'],
         '1f603': ['smiley'],
       });
       check(store.allEmojiCandidates()).deepEquals([
-        isUnicodeCandidate('1f642', ['smile']),
+        ...arePopularCandidates,
+        isUnicodeCandidate('1f516', ['bookmark']),
         isRealmCandidate(emojiCode: '1', emojiName: 'smiley'),
         isZulipCandidate(),
       ]);
@@ -169,6 +211,7 @@ void main() {
         '1f41c': ['ant'],
       });
       check(store.allEmojiCandidates()).deepEquals([
+        ...arePopularCandidates,
         isUnicodeCandidate('1f41c', ['ant']),
         isZulipCandidate(),
       ]);
@@ -181,6 +224,7 @@ void main() {
         '1f34a': ['orange', 'tangerine', 'mandarin'],
       });
       check(store.allEmojiCandidates()).deepEquals([
+        ...arePopularCandidates,
         isUnicodeCandidate('1f34a', ['orange', 'mandarin']),
         isRealmCandidate(emojiCode: '1', emojiName: 'tangerine'),
         isZulipCandidate(),
@@ -194,6 +238,7 @@ void main() {
         '1f34a': ['orange', 'tangerine', 'mandarin'],
       });
       check(store.allEmojiCandidates()).deepEquals([
+        ...arePopularCandidates,
         isUnicodeCandidate('1f34a', ['tangerine', 'mandarin']),
         isRealmCandidate(emojiCode: '1', emojiName: 'orange'),
         isZulipCandidate(),
@@ -203,14 +248,16 @@ void main() {
     test('updates on setServerEmojiData', () {
       final store = prepare();
       check(store.allEmojiCandidates()).deepEquals([
+        ...arePopularCandidates,
         isZulipCandidate(),
       ]);

       store.setServerEmojiData(ServerEmojiData(codeToNames: {
-        '1f642': ['smile'],
+        '1f516': ['bookmark'],
       }));
       check(store.allEmojiCandidates()).deepEquals([
-        isUnicodeCandidate('1f642', ['smile']),
+        ...arePopularCandidates,
+        isUnicodeCandidate('1f516', ['bookmark']),
         isZulipCandidate(),
       ]);
     });
@@ -218,6 +265,7 @@ void main() {
     test('updates on RealmEmojiUpdateEvent', () {
       final store = prepare();
       check(store.allEmojiCandidates()).deepEquals([
+        ...arePopularCandidates,
         isZulipCandidate(),
       ]);

@@ -225,6 +273,7 @@ void main() {
         '1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'happy'),
       }));
       check(store.allEmojiCandidates()).deepEquals([
+        ...arePopularCandidates,
         isRealmCandidate(emojiCode: '1', emojiName: 'happy'),
         isZulipCandidate(),
       ]);
@@ -234,7 +283,7 @@ void main() {
       final store = prepare(realmEmoji: {
         '1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'happy'),
       }, unicodeEmoji: {
-        '1f642': ['smile'],
+        '1f516': ['bookmark'],
       });
       final candidates = store.allEmojiCandidates();
       check(store.allEmojiCandidates()).identicalTo(candidates);
@@ -257,6 +306,9 @@ void main() {
         isZulipCandidate());
     }

+    List<Condition<Object?>> arePopularResults = popularCandidates.map(
+      (c) => isUnicodeResult(emojiCode: c.emojiCode)).toList();
+
     PerAccountStore prepare({
       Map<String, String> realmEmoji = const {},
       Map<String, List<String>>? unicodeEmoji,
@@ -274,7 +326,7 @@ void main() {

     test('results can include all three emoji types', () async {
       final store = prepare(
-        realmEmoji: {'1': 'happy'}, unicodeEmoji: {'1f642': ['smile']});
+        realmEmoji: {'1': 'happy'}, unicodeEmoji: {'1f516': ['bookmark']});
       final view = EmojiAutocompleteView.init(store: store,
         query: EmojiAutocompleteQuery(''));
       bool done = false;
@@ -282,9 +334,10 @@ void main() {
       await Future(() {});
       check(done).isTrue();
       check(view.results).deepEquals([
-        isUnicodeResult(names: ['smile']),
+        ...arePopularResults,
         isRealmResult(emojiName: 'happy'),
         isZulipResult(),
+        isUnicodeResult(names: ['bookmark']),
       ]);
     });

@@ -292,7 +345,7 @@ void main() {
       final store = prepare(
         realmEmoji: {'1': 'happy'}, unicodeEmoji: {'1f642': ['smile']});
       final view = EmojiAutocompleteView.init(store: store,
-        query: EmojiAutocompleteQuery('h'));
+        query: EmojiAutocompleteQuery('hap'));
       bool done = false;
       view.addListener(() { done = true; });
       await Future(() {});
@@ -301,15 +354,92 @@ void main() {
         isRealmResult(emojiName: 'happy'));

       done = false;
-      view.query = EmojiAutocompleteQuery('s');
+      view.query = EmojiAutocompleteQuery('sm');
       await Future(() {});
       check(done).isTrue();
       check(view.results).single.which(
         isUnicodeResult(names: ['smile']));
     });
+
+    Future<Iterable<EmojiAutocompleteResult>> resultsOf(
+      String query, {
+      Map<String, String> realmEmoji = const {},
+      Map<String, List<String>>? unicodeEmoji,
+    }) async {
+      final store = prepare(realmEmoji: realmEmoji, unicodeEmoji: unicodeEmoji);
+      final view = EmojiAutocompleteView.init(store: store,
+        query: EmojiAutocompleteQuery(query));
+      bool done = false;
+      view.addListener(() { done = true; });
+      await Future(() {});
+      check(done).isTrue();
+      return view.results;
+    }
+
+    test('results preserve order of popular emoji within each rank', () async {
+      // In other words, the sorting by rank is a stable sort.
+
+      // Full results list matches allEmojiCandidates.
+      check(prepare().allEmojiCandidates())
+        .deepEquals([...arePopularCandidates, isZulipCandidate()]);
+      check(await resultsOf(''))
+        .deepEquals([...arePopularResults, isZulipResult()]);
+
+      // Same list written out explicitly, for comparison with the cases below.
+      check(await resultsOf('')).deepEquals([
+        isUnicodeResult(names: ['+1', 'thumbs_up', 'like']),
+        isUnicodeResult(names: ['tada']),
+        isUnicodeResult(names: ['smile']),
+        isUnicodeResult(names: ['heart', 'love', 'love_you']),
+        isUnicodeResult(names: ['working_on_it', 'hammer_and_wrench', 'tools']),
+        isUnicodeResult(names: ['octopus']),
+        isZulipResult(),
+      ]);
+
+      check(await resultsOf('t')).deepEquals([
+        // prefix
+        isUnicodeResult(names: ['+1', 'thumbs_up', 'like']),
+        isUnicodeResult(names: ['tada']),
+        isUnicodeResult(names: ['working_on_it', 'hammer_and_wrench', 'tools']),
+        // other
+        isUnicodeResult(names: ['heart', 'love', 'love_you']),
+        isUnicodeResult(names: ['octopus']),
+      ]);
+
+      check(await resultsOf('h')).deepEquals([
+        // prefix
+        isUnicodeResult(names: ['heart', 'love', 'love_you']),
+        isUnicodeResult(names: ['working_on_it', 'hammer_and_wrench', 'tools']),
+        // other
+        isUnicodeResult(names: ['+1', 'thumbs_up', 'like']),
+      ]);
+    });
+
+    test('results end-to-end', () async {
+      // (See more detailed rank tests below, on EmojiAutocompleteQuery.)
+
+      final unicodeEmoji = {
+        '1f4d3': ['notebook'], '1f516': ['bookmark'], '1f4d6': ['book']};
+
+      // Empty query -> base ordering.
+      check(await resultsOf('', unicodeEmoji: unicodeEmoji)).deepEquals([
+        ...arePopularResults,
+        isZulipResult(),
+        isUnicodeResult(names: ['notebook']),
+        isUnicodeResult(names: ['bookmark']),
+        isUnicodeResult(names: ['book']),
+      ]);
+
+      // With query, exact match precedes prefix match precedes other.
+      check(await resultsOf('book', unicodeEmoji: unicodeEmoji)).deepEquals([
+        isUnicodeResult(names: ['book']),
+        isUnicodeResult(names: ['bookmark']),
+        isUnicodeResult(names: ['notebook']),
+      ]);
+    });
   });

-  group('EmojiAutocompleteQuery.matches', () {
+  group('EmojiAutocompleteQuery', () {
     EmojiCandidate unicode(List<String> names, {String? emojiCode}) {
       emojiCode ??= '10ffff';
       return EmojiCandidate(emojiType: ReactionType.unicodeEmoji,
@@ -320,137 +450,235 @@ void main() {
           emojiUnicode: tryParseEmojiCodeToUnicode(emojiCode)!));
     }

-    bool matchesName(String query, String emojiName) {
-      return EmojiAutocompleteQuery(query).matches(unicode([emojiName]));
+    EmojiMatchQuality? matchOf(String query, EmojiCandidate candidate) {
+      return EmojiAutocompleteQuery(query).match(candidate);
     }

-    test('one-word query matches anywhere in name', () {
-      check(matchesName('', 'smile')).isTrue();
-      check(matchesName('s', 'smile')).isTrue();
-      check(matchesName('sm', 'smile')).isTrue();
-      check(matchesName('smile', 'smile')).isTrue();
-      check(matchesName('m', 'smile')).isTrue();
-      check(matchesName('mile', 'smile')).isTrue();
-      check(matchesName('e', 'smile')).isTrue();
+    EmojiMatchQuality? matchOfNames(String query, List<String> names) {
+      return matchOf(query, unicode(names));
+    }

-      check(matchesName('smiley', 'smile')).isFalse();
-      check(matchesName('a', 'smile')).isFalse();
+    EmojiMatchQuality? matchOfName(String query, String emojiName) {
+      return matchOfNames(query, [emojiName]);
+    }

-      check(matchesName('o', 'open_book')).isTrue();
-      check(matchesName('open', 'open_book')).isTrue();
-      check(matchesName('pe', 'open_book')).isTrue();
-      check(matchesName('boo', 'open_book')).isTrue();
-      check(matchesName('ok', 'open_book')).isTrue();
+    test('one-word query matches anywhere in name', () {
+      check(matchOfName('', 'smile')).prefix;
+      check(matchOfName('s', 'smile')).prefix;
+      check(matchOfName('sm', 'smile')).prefix;
+      check(matchOfName('smile', 'smile')).exact;
+      check(matchOfName('m', 'smile')).other;
+      check(matchOfName('mile', 'smile')).other;
+      check(matchOfName('e', 'smile')).other;
+
+      check(matchOfName('smiley', 'smile')).none;
+      check(matchOfName('a', 'smile')).none;
+
+      check(matchOfName('o', 'open_book')).prefix;
+      check(matchOfName('open', 'open_book')).prefix;
+      check(matchOfName('pe', 'open_book')).other;
+      check(matchOfName('boo', 'open_book')).wordAligned;
+      check(matchOfName('ok', 'open_book')).other;
     });

     test('multi-word query matches from start of a word', () {
-      check(matchesName('open_', 'open_book')).isTrue();
-      check(matchesName('open_b', 'open_book')).isTrue();
-      check(matchesName('open_book', 'open_book')).isTrue();
+      check(matchOfName('open_', 'open_book')).prefix;
+      check(matchOfName('open_b', 'open_book')).prefix;
+      check(matchOfName('open_book', 'open_book')).exact;

-      check(matchesName('pen_', 'open_book')).isFalse();
-      check(matchesName('n_b', 'open_book')).isFalse();
+      check(matchOfName('pen_', 'open_book')).none;
+      check(matchOfName('n_b', 'open_book')).none;

-      check(matchesName('blue_dia', 'large_blue_diamond')).isTrue();
+      check(matchOfName('blue_dia', 'large_blue_diamond')).wordAligned;
     });

     test('spaces in query behave as underscores', () {
-      check(matchesName('open ', 'open_book')).isTrue();
-      check(matchesName('open b', 'open_book')).isTrue();
-      check(matchesName('open book', 'open_book')).isTrue();
+      check(matchOfName('open ', 'open_book')).prefix;
+      check(matchOfName('open b', 'open_book')).prefix;
+      check(matchOfName('open book', 'open_book')).exact;

-      check(matchesName('pen ', 'open_book')).isFalse();
-      check(matchesName('n b', 'open_book')).isFalse();
+      check(matchOfName('pen ', 'open_book')).none;
+      check(matchOfName('n b', 'open_book')).none;

-      check(matchesName('blue dia', 'large_blue_diamond')).isTrue();
+      check(matchOfName('blue dia', 'large_blue_diamond')).wordAligned;
     });

     test('query is lower-cased', () {
-      check(matchesName('Smi', 'smile')).isTrue();
+      check(matchOfName('Smi', 'smile')).prefix;
     });

     test('query matches aliases same way as primary name', () {
-      bool matchesNames(String query, List<String> names) {
-        return EmojiAutocompleteQuery(query).matches(unicode(names));
-      }
+      check(matchOfNames('a', ['a', 'b'])).exact;
+      check(matchOfNames('b', ['a', 'b'])).exact;
+      check(matchOfNames('c', ['a', 'b'])).none;
+
+      check(matchOfNames('pe', ['x', 'open_book'])).other;
+      check(matchOfNames('ok', ['x', 'open_book'])).other;

-      check(matchesNames('a', ['a', 'b'])).isTrue();
-      check(matchesNames('b', ['a', 'b'])).isTrue();
-      check(matchesNames('c', ['a', 'b'])).isFalse();
+      check(matchOfNames('open_', ['x', 'open_book'])).prefix;
+      check(matchOfNames('open b', ['x', 'open_book'])).prefix;
+      check(matchOfNames('pen_', ['x', 'open_book'])).none;

-      check(matchesNames('pe', ['x', 'open_book'])).isTrue();
-      check(matchesNames('ok', ['x', 'open_book'])).isTrue();
+      check(matchOfNames('blue_dia', ['x', 'large_blue_diamond'])).wordAligned;

-      check(matchesNames('open_', ['x', 'open_book'])).isTrue();
-      check(matchesNames('open b', ['x', 'open_book'])).isTrue();
-      check(matchesNames('pen_', ['x', 'open_book'])).isFalse();
+      check(matchOfNames('Smi', ['x', 'smile'])).prefix;
+    });

-      check(matchesNames('Smi', ['x', 'smile'])).isTrue();
+    test('best match among name and aliases prevails', () {
+      check(matchOfNames('a', ['ab', 'a', 'b_a', 'ba', 'x'])).exact;
+      check(matchOfNames('a', ['ba', 'ab', 'b_a', 'x'])).prefix;
+      check(matchOfNames('a', ['ba', 'ab', 'b_a'])).prefix;
+      check(matchOfNames('a', ['ba', 'b_a', 'x'])).wordAligned;
+      check(matchOfNames('a', ['b_a', 'ba'])).wordAligned;
+      check(matchOfNames('a', ['ba', 'x'])).other;
+      check(matchOfNames('a', ['x', 'y', 'z'])).none;
     });

     test('query matches literal Unicode value', () {
-      bool matchesLiteral(String query, String emojiCode, {required String aka}) {
+      EmojiMatchQuality? matchOfLiteral(String query, String emojiCode, {
+          required String aka}) {
         assert(aka == query);
-        return EmojiAutocompleteQuery(query)
-          .matches(unicode(['asdf'], emojiCode: emojiCode));
+        return matchOf(query, unicode(['asdf'], emojiCode: emojiCode));
       }

       // Matching the code, in hex, doesn't count.
-      check(matchesLiteral('1f642', aka: '1f642', '1f642')).isFalse();
+      check(matchOfLiteral('1f642', aka: '1f642', '1f642')).none;

       // Matching the Unicode value the code describes does count…
-      check(matchesLiteral('🙂', aka: '\u{1f642}', '1f642')).isTrue();
+      check(matchOfLiteral('🙂', aka: '\u{1f642}', '1f642')).exact;
       // … and failing to match it doesn't make a match.
-      check(matchesLiteral('🙁', aka: '\u{1f641}', '1f642')).isFalse();
+      check(matchOfLiteral('🙁', aka: '\u{1f641}', '1f642')).none;

       // Multi-code-point emoji work fine.
-      check(matchesLiteral('🏳🌈', aka: '\u{1f3f3}\u{200d}\u{1f308}',
-        '1f3f3-200d-1f308')).isTrue();
+      check(matchOfLiteral('🏳🌈', aka: '\u{1f3f3}\u{200d}\u{1f308}',
+        '1f3f3-200d-1f308')).exact;
       // Only exact matches count; no partial matches.
-      check(matchesLiteral('🏳', aka: '\u{1f3f3}',
-        '1f3f3-200d-1f308')).isFalse();
-      check(matchesLiteral('🌈', aka: '\u{200d}\u{1f308}',
-        '1f3f3-200d-1f308')).isFalse();
-      check(matchesLiteral('🏳🌈', aka: '\u{1f3f3}\u{200d}\u{1f308}',
-        '1f3f3')).isFalse();
+      check(matchOfLiteral('🏳', aka: '\u{1f3f3}',
+        '1f3f3-200d-1f308')).none;
+      check(matchOfLiteral('🌈', aka: '\u{200d}\u{1f308}',
+        '1f3f3-200d-1f308')).none;
+      check(matchOfLiteral('🏳🌈', aka: '\u{1f3f3}\u{200d}\u{1f308}',
+        '1f3f3')).none;
     });

-    test('can match realm emoji', () {
-      EmojiCandidate realmCandidate(String emojiName) {
-        return EmojiCandidate(
-          emojiType: ReactionType.realmEmoji,
-          emojiCode: '1', emojiName: emojiName, aliases: null,
-          emojiDisplay: ImageEmojiDisplay(
-            emojiName: emojiName,
-            resolvedUrl: eg.realmUrl.resolve('/emoji/1.png'),
-            resolvedStillUrl: eg.realmUrl.resolve('/emoji/1-still.png')));
-      }
+    EmojiCandidate realmCandidate(String emojiName) {
+      return EmojiCandidate(
+        emojiType: ReactionType.realmEmoji,
+        emojiCode: '1', emojiName: emojiName, aliases: null,
+        emojiDisplay: ImageEmojiDisplay(
+          emojiName: emojiName,
+          resolvedUrl: eg.realmUrl.resolve('/emoji/1.png'),
+          resolvedStillUrl: eg.realmUrl.resolve('/emoji/1-still.png')));
+    }

-      check(EmojiAutocompleteQuery('eqeq')
-        .matches(realmCandidate('eqeq'))).isTrue();
-      check(EmojiAutocompleteQuery('open_')
-        .matches(realmCandidate('open_book'))).isTrue();
-      check(EmojiAutocompleteQuery('n_b')
-        .matches(realmCandidate('open_book'))).isFalse();
-      check(EmojiAutocompleteQuery('blue dia')
-        .matches(realmCandidate('large_blue_diamond'))).isTrue();
-      check(EmojiAutocompleteQuery('Smi')
-        .matches(realmCandidate('smile'))).isTrue();
+    test('can match realm emoji', () {
+      check(matchOf('eqeq', realmCandidate('eqeq'))).exact;
+      check(matchOf('open_', realmCandidate('open_book'))).prefix;
+      check(matchOf('n_b', realmCandidate('open_book'))).none;
+      check(matchOf('blue dia', realmCandidate('large_blue_diamond'))).wordAligned;
+      check(matchOf('Smi', realmCandidate('smile'))).prefix;
     });

-    test('can match Zulip extra emoji', () {
+    EmojiCandidate zulipCandidate() {
       final store = eg.store();
-      final zulipCandidate = EmojiCandidate(
+      return EmojiCandidate(
         emojiType: ReactionType.zulipExtraEmoji,
         emojiCode: 'zulip', emojiName: 'zulip', aliases: null,
         emojiDisplay: store.emojiDisplayFor(
           emojiType: ReactionType.zulipExtraEmoji,
           emojiCode: 'zulip', emojiName: 'zulip'));
+    }

-      check(EmojiAutocompleteQuery('z').matches(zulipCandidate)).isTrue();
-      check(EmojiAutocompleteQuery('Zulip').matches(zulipCandidate)).isTrue();
-      check(EmojiAutocompleteQuery('p').matches(zulipCandidate)).isTrue();
-      check(EmojiAutocompleteQuery('x').matches(zulipCandidate)).isFalse();
+    test('can match Zulip extra emoji', () {
+      check(matchOf('z', zulipCandidate())).prefix;
+      check(matchOf('Zulip', zulipCandidate())).exact;
+      check(matchOf('p', zulipCandidate())).other;
+      check(matchOf('x', zulipCandidate())).none;
+    });
+
+    int? rankOf(String query, EmojiCandidate candidate) {
+      return EmojiAutocompleteQuery(query).testCandidate(candidate)?.rank;
+    }
+
+    void checkPrecedes(String query, EmojiCandidate a, EmojiCandidate b) {
+      check(rankOf(query, a)!).isLessThan(rankOf(query, b)!);
+    }
+
+    void checkSameRank(String query, EmojiCandidate a, EmojiCandidate b) {
+      check(rankOf(query, a)!).equals(rankOf(query, b)!);
+    }
+
+    final octopus = unicode(['octopus'], emojiCode: '1f419');
+    final workingOnIt = unicode(['working_on_it'], emojiCode: '1f6e0');
+    final love = unicode(['love'], emojiCode: '2764'); // aka :heart:
+
+    test('ranks match quality exact/prefix/word-aligned/other', () {
+      checkPrecedes('o', unicode(['o']), unicode(['onion']));
+      checkPrecedes('o', unicode(['onion']), unicode(['squared_ok']));
+      checkPrecedes('o', unicode(['squared_ok']), unicode(['book']));
+    });
+
+    test('ranks popular before realm before other Unicode', () {
+      checkPrecedes('o', octopus, realmCandidate('open_book'));
+      checkPrecedes('o', realmCandidate('open_book'), unicode(['ok']));
+    });
+
+    test('ranks Zulip extra emoji same as realm emoji', () {
+      checkSameRank('z', zulipCandidate(), realmCandidate('zounds'));
+    });
+
+    test('ranks exact-vs-not more significant than popular/custom/other', () {
+      // Generic Unicode exact beats popular prefix…
+      checkPrecedes('o', unicode(['o']), octopus);
+      // … which really does count as popular, beating realm prefix.
+      checkPrecedes('o', octopus, realmCandidate('open_book'));
+    });
+
+    test('ranks popular-vs-not more significant than prefix/word-aligned', () {
+      // Popular word-aligned beats realm prefix.
+      checkPrecedes('o', workingOnIt, realmCandidate('open_book'));
+    });
+
+    test('ranks popular as if generic when non-word-aligned', () {
+      // Generic word-aligned beats popular other.
+      checkPrecedes('o', unicode(['squared_ok']), love);
+      // Popular other ranks below even custom other…
+      checkPrecedes('o', realmCandidate('yo'), love);
+      // … and same as generic Unicode other.
+      checkSameRank('o', love, unicode(['book']));
+
+      // And that emoji really does count as popular,
+      // beating custom emoji when both have a prefix match.
+      checkPrecedes('l', love, realmCandidate('logs'));
+    });
+
+    test('ranks custom/other more significant than prefix/word-aligned', () {
+      // Custom word-aligned beats generic prefix.
+      checkPrecedes('o', realmCandidate('laughing_blue_octopus'),
+                         unicode(['ok']));
+    });
+
+    test('ranks word-aligned/other more significant than custom/other', () {
+      // Generic Unicode word-aligned beats realm other.
+      checkPrecedes('o', unicode(['squared_ok']), realmCandidate('yo'));
+    });
+
+    test('full list of ranks', () {
+      check([
+        rankOf('o', unicode(['o'])),              // exact (generic)
+        rankOf('o', octopus),                     // prefix popular
+        rankOf('o', workingOnIt),                 // word-aligned popular
+        rankOf('o', realmCandidate('open_book')), // prefix realm
+        rankOf('z', zulipCandidate()),            //  == prefix :zulip:
+        rankOf('y', realmCandidate('thank_you')), // word-aligned realm
+            // (word-aligned :zulip: is impossible because the name is one word)
+        rankOf('o', unicode(['ok'])),             // prefix generic
+        rankOf('o', unicode(['squared_ok'])),     // word-aligned generic
+        rankOf('o', realmCandidate('yo')),        // other realm
+        rankOf('p', zulipCandidate()),            //  == other :zulip:
+        rankOf('o', unicode(['book'])),           // other generic
+        rankOf('o', love),                        //  == other popular
+      ]).deepEquals([0, 1, 2, 3, 3, 4, 5, 6, 7, 7, 8, 8]);
     });
   });
 }
@@ -476,6 +704,14 @@ extension EmojiCandidateChecks on Subject<EmojiCandidate> {
   Subject<EmojiDisplay> get emojiDisplay => has((x) => x.emojiDisplay, 'emojiDisplay');
 }

+extension EmojiMatchQualityChecks on Subject<EmojiMatchQuality?> {
+  void get exact => equals(EmojiMatchQuality.exact);
+  void get prefix => equals(EmojiMatchQuality.prefix);
+  void get wordAligned => equals(EmojiMatchQuality.wordAligned);
+  void get other => equals(EmojiMatchQuality.other);
+  void get none => isNull();
+}
+
 extension EmojiAutocompleteResultChecks on Subject<EmojiAutocompleteResult> {
   Subject<EmojiCandidate> get candidate => has((x) => x.candidate, 'candidate');
 }


Make topic headers case-insensitive

Topics in Zulip are case-insensitive. This change makes the message list's topic headers match that behavior, so messages whose topics differ only in case (like "missing string" and "Missing string") share a single header.  This brings the behavior in line with Zulip web.

### What is the change?

The change modifies the topic comparison logic in `haveSameRecipient()` to use case-insensitive comparison when determining whether to show a new recipient header.

### Screenshots

| Before                                                                                | After                                                                                |
|---------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------|
|  |  |

Fixes #739

Extra recipient header when topic changes case
If you visit the thread `#translation > Missing string` on chat.zulip.org, around this message (the first one in today's thread):
https://chat.zulip.org/#narrow/stream/58-translation/topic/Missing.20string/near/1828362

you'll find it gets its own recipient header, even in a topic narrow, even though there are previous messages. Specifically there's a recipient header "missing string" for the older messages (which are from 2021), and another one "Missing string" for that message and the ones after it.

In general Zulip topics are case-insensitive. Specifically, if you look at that conversation in Zulip web, there's just one recipient header; it reflects the older messages, so "missing string". So we should do the same.

Related issues:
* #980
* #1205

We had a second report of the issue today, with a screenshot:
https://chat.zulip.org/#narrow/stream/48-mobile/topic/Case.20sensitivity.20in.20topics/near/1954248

flutter test test/model/message_list_test.dart --reporter expanded 2>&1

diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart
index 440d12f2b8..ecfb16c2b9 100644
--- a/lib/model/message_list.dart
+++ b/lib/model/message_list.dart
@@ -352,7 +352,7 @@ mixin _MessageSequence {
 bool haveSameRecipient(Message prevMessage, Message message) {
   if (prevMessage is StreamMessage && message is StreamMessage) {
     if (prevMessage.streamId != message.streamId) return false;
-    if (prevMessage.topic != message.topic) return false;
+    if (prevMessage.topic.toLowerCase() != message.topic.toLowerCase()) return false;
   } else if (prevMessage is DmMessage && message is DmMessage) {
     if (!_equalIdSequences(prevMessage.allRecipientIds, message.allRecipientIds)) {
       return false;

diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart
index e7b6cd7834..e92b2489b3 100644
--- a/test/model/message_list_test.dart
+++ b/test/model/message_list_test.dart
@@ -1803,6 +1803,26 @@ void main() {
         }
       }
     });
+
+    group('topics compared case-insensitively', () {
+      void doTest(String description, String topicA, String topicB, bool expected) {
+        test(description, () {
+          final stream = eg.stream();
+          final messageA = eg.streamMessage(stream: stream, topic: topicA);
+          final messageB = eg.streamMessage(stream: stream, topic: topicB);
+          check(haveSameRecipient(messageA, messageB)).equals(expected);
+        });
+      }
+
+      doTest('same case, all lower',               'abc',  'abc',  true);
+      doTest('same case, all upper',               'ABC',  'ABC',  true);
+      doTest('same case, mixed',                   'AbC',  'AbC',  true);
+      doTest('same non-cased chars',               '嗎',    '嗎',    true);
+      doTest('different case',                     'aBc',  'ABC',  true);
+      doTest('different case, same diacritics',    'AbÇ',  'aBç',  true);
+      doTest('same letters, different diacritics', 'ma',   'mǎ',   false);
+      doTest('having different CJK characters',    '嗎', '馬', false);
+    });
   });

   test('messagesSameDay', () {


This pull request closes #996.

In `dialog.dart`:

Switched `alertDialog(` to `alertDialog.adaptive(`, as per #996.

Defined new private widget `_adaptiveAction` which displays a  `CupertinoDialogAction` for  IOS and a `TextButton` otherwise.  `_adaptiveAction(` is used in place of `TextButton(` when stating dialog actions.

The new result for IOS:

`dialog-checks.dart` has been updated to check the text content of the respective dialog types depending on the platform

dialog: Use Cupertino-flavored alert dialogs on iOS
We should experiment with the [`AlertDialog.adaptive`](https://api.flutter.dev/flutter/material/AlertDialog/AlertDialog.adaptive.html) constructor, instead of [`AlertDialog`](https://api.flutter.dev/flutter/material/AlertDialog/AlertDialog.html), which forces a Material-style dialog on iOS:

| Current | Should be more like |
| --- | --- |
|  |  |

The "after" screenshot was made by simply changing `AlertDialog(` to `AlertDialog.adaptive(`. A complete fix will be a little more complicated than that, though. See the code sample on the [`AlertDialog.adaptive` doc](https://api.flutter.dev/flutter/material/AlertDialog/AlertDialog.adaptive.html), which shows a platform switch for the dialog's action buttons, to style those appropriately (including the buttons' on-press appearance):

```dart
  Widget adaptiveAction(
      {required BuildContext context,
      required VoidCallback onPressed,
      required Widget child}) {
    final ThemeData theme = Theme.of(context);
    switch (theme.platform) {
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
      case TargetPlatform.linux:
      case TargetPlatform.windows:
        return TextButton(onPressed: onPressed, child: child);
      case TargetPlatform.iOS:
      case TargetPlatform.macOS:
        return CupertinoDialogAction(onPressed: onPressed, child: child);
    }
  }
```

Relatedly, I think the `TextAlign.end` in our `_dialogActionText` helper should not be applied on iOS because on iOS the button text is meant to be center-aligned.

I also notice that our "Source Sans 3" font isn't being applied in the Cupertino-style dialog in the screenshot. ~~I think we want it to be, so we should debug and fix this.~~ Probably should leave this be, actually; it's supposed to mimic the native iOS alert, which uses a system font, not an app's chosen font.

Hi I'm keen to get involved with this project and this issue looks like a good place to start, can I have a go at it?
@u7088495 Please take a look at the Zulip project's guide to getting involved with the code:
https://zulip.readthedocs.io/en/latest/contributing/contributing.html#your-first-codebase-contribution
https://zulip.readthedocs.io/en/latest/contributing/contributing.html#picking-an-issue-to-work-on

As demonstrated, in `dialog.dart` I'd replace `AlertDialog(` with `AlertDialog.adaptive(`. I'd also implement the widget `adaptiveAction` to return a `CupertinoDialogAction(` for OS platforms and the regular textButton otherwise. As for the font, I am unsure how to fix that rather than by setting the textStyle property on the `Text` widgets directly - I tried defining cupertino theme data but with no luck.
Sounds good!

> As for the font, I am unsure how to fix that rather than by setting the textStyle property on the `Text` widgets directly - I tried defining cupertino theme data but with no luck.

I think it may be fine, actually, to accept the default styling, instead of overriding it with our custom styles with "Source Sans 3".

If we were using a native iOS API to show these alerts, we'd get whatever system-defined styles Apple chooses—like what's illustrated in [this interface guidelines doc from Apple](https://developer.apple.com/design/human-interface-guidelines/alerts)—and Flutter's defaults understandably mimic those.
Also, on the text alignment, with the `CupertinoDialogAction`, the label doesn't wrap and so setting `TextAlign.end` has no effect.
<img width="384" alt="Screenshot 2024-10-22 at 1 59 04 pm" src="https://github.com/user-attachments/assets/fa04c4fa-3329-48f0-93de-2779e682d5b6">

But I could still update this if its worth being explicit?
@u7088495 I think the most effective venue for getting that question answered would be in chat, in #mobile-dev-help. That will also help keep this issue thread focused.

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

diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart
index e635071cc9..61a6462592 100644
--- a/lib/widgets/dialog.dart
+++ b/lib/widgets/dialog.dart
@@ -1,3 +1,5 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';

 import '../generated/l10n/zulip_localizations.dart';
@@ -7,18 +9,29 @@ import 'app.dart';
 import 'content.dart';
 import 'store.dart';

-Widget _dialogActionText(String text) {
-  return Text(
-    text,
-
-    // As suggested by
-    //   https://api.flutter.dev/flutter/material/AlertDialog/actions.html :
-    // > It is recommended to set the Text.textAlign to TextAlign.end
-    // > for the Text within the TextButton, so that buttons whose
-    // > labels wrap to an extra line align with the overall
-    // > OverflowBar's alignment within the dialog.
-    textAlign: TextAlign.end,
-  );
+/// A platform-appropriate action for [AlertDialog.adaptive]'s [actions] param.
+Widget _adaptiveAction({required VoidCallback onPressed, required String text}) {
+  switch (defaultTargetPlatform) {
+    case TargetPlatform.android:
+    case TargetPlatform.fuchsia:
+    case TargetPlatform.linux:
+    case TargetPlatform.windows:
+      return TextButton(
+        onPressed: onPressed,
+        child: Text(
+          text,
+          // As suggested by
+          //   https://api.flutter.dev/flutter/material/AlertDialog/actions.html :
+          // > It is recommended to set the Text.textAlign to TextAlign.end
+          // > for the Text within the TextButton, so that buttons whose
+          // > labels wrap to an extra line align with the overall
+          // > OverflowBar's alignment within the dialog.
+          textAlign: TextAlign.end));
+
+    case TargetPlatform.iOS:
+    case TargetPlatform.macOS:
+      return CupertinoDialogAction(onPressed: onPressed, child: Text(text));
+  }
 }

 /// Tracks the status of a dialog, in being still open or already closed.
@@ -71,17 +84,17 @@ DialogStatus<void> showErrorDialog({
   final zulipLocalizations = ZulipLocalizations.of(context);
   final future = showDialog<void>(
     context: context,
-    builder: (BuildContext context) => AlertDialog(
+    builder: (BuildContext context) => AlertDialog.adaptive(
       title: Text(title),
       content: message != null ? SingleChildScrollView(child: Text(message)) : null,
       actions: [
         if (learnMoreButtonUrl != null)
-          TextButton(
+          _adaptiveAction(
             onPressed: () => PlatformActions.launchUrl(context, learnMoreButtonUrl),
-            child: _dialogActionText(zulipLocalizations.errorDialogLearnMore)),
-        TextButton(
+            text: zulipLocalizations.errorDialogLearnMore),
+        _adaptiveAction(
           onPressed: () => Navigator.pop(context),
-          child: _dialogActionText(zulipLocalizations.errorDialogContinue)),
+          text: zulipLocalizations.errorDialogContinue),
       ]));
   return DialogStatus(future);
 }
@@ -103,16 +116,16 @@ DialogStatus<bool> showSuggestedActionDialog({
   final zulipLocalizations = ZulipLocalizations.of(context);
   final future = showDialog<bool>(
     context: context,
-    builder: (BuildContext context) => AlertDialog(
+    builder: (BuildContext context) => AlertDialog.adaptive(
       title: Text(title),
       content: SingleChildScrollView(child: Text(message)),
       actions: [
-        TextButton(
+        _adaptiveAction(
           onPressed: () => Navigator.pop<bool>(context, null),
-          child: _dialogActionText(zulipLocalizations.dialogCancel)),
-        TextButton(
+          text: zulipLocalizations.dialogCancel),
+        _adaptiveAction(
           onPressed: () => Navigator.pop<bool>(context, true),
-          child: _dialogActionText(actionButtonText ?? zulipLocalizations.dialogContinue)),
+          text: actionButtonText ?? zulipLocalizations.dialogContinue),
       ]));
   return DialogStatus(future);
 }
@@ -164,7 +177,7 @@ class UpgradeWelcomeDialog extends StatelessWidget {
   @override
   Widget build(BuildContext context) {
     final zulipLocalizations = ZulipLocalizations.of(context);
-    return AlertDialog(
+    return AlertDialog.adaptive(
       title: Text(zulipLocalizations.upgradeWelcomeDialogTitle),
       content: SingleChildScrollView(
         child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
@@ -177,8 +190,9 @@ class UpgradeWelcomeDialog extends StatelessWidget {
               zulipLocalizations.upgradeWelcomeDialogLinkText)),
         ])),
       actions: [
-        TextButton(onPressed: () => Navigator.pop(context),
-          child: Text(zulipLocalizations.upgradeWelcomeDialogDismiss)),
+        _adaptiveAction(
+          onPressed: () => Navigator.pop(context),
+          text: zulipLocalizations.upgradeWelcomeDialogDismiss)
       ]);
   }
 }

diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart
index d65ac20507..f673ebc0d8 100644
--- a/test/widgets/action_sheet_test.dart
+++ b/test/widgets/action_sheet_test.dart
@@ -335,7 +335,7 @@ void main() {
         await tester.tap(findButtonForLabel('Mark channel as read'));
         await tester.pumpAndSettle();
         checkRequest(someChannel.streamId);
-        checkNoErrorDialog(tester);
+        checkNoDialog(tester);
       });

       testWidgets('request fails', (tester) async {
@@ -776,7 +776,7 @@ void main() {
         await tester.tap(findButtonForLabel('Mark as resolved'));
         await tester.pumpAndSettle();

-        checkNoErrorDialog(tester);
+        checkNoDialog(tester);
         checkRequest(message.id, '✔ zulip');
       });

@@ -791,7 +791,7 @@ void main() {
         await tester.tap(findButtonForLabel('Mark as resolved'));
         await tester.pumpAndSettle();

-        checkNoErrorDialog(tester);
+        checkNoDialog(tester);
         checkRequest(message.id, '✔ zulip');
       });

@@ -805,7 +805,7 @@ void main() {
         await tester.tap(findButtonForLabel('Mark as unresolved'));
         await tester.pumpAndSettle();

-        checkNoErrorDialog(tester);
+        checkNoDialog(tester);
         checkRequest(message.id, 'zulip');
       });

@@ -819,7 +819,7 @@ void main() {
         await tester.tap(findButtonForLabel('Mark as unresolved'));
         await tester.pumpAndSettle();

-        checkNoErrorDialog(tester);
+        checkNoDialog(tester);
         checkRequest(message.id, 'zulip');
       });

diff --git a/test/widgets/app_test.dart b/test/widgets/app_test.dart
index 53c959321e..eca2f95cab 100644
--- a/test/widgets/app_test.dart
+++ b/test/widgets/app_test.dart
@@ -401,14 +401,14 @@ void main() {
       check(ZulipApp.ready).value.isFalse();
       await tester.pump();
       check(findSnackBarByText(message).evaluate()).isEmpty();
-      checkNoErrorDialog(tester);
+      checkNoDialog(tester);

       check(ZulipApp.ready).value.isTrue();
       // After app startup, reportErrorToUserBriefly displays a SnackBar.
       reportErrorToUserBriefly(message, details: details);
       await tester.pumpAndSettle();
       check(findSnackBarByText(message).evaluate()).single;
-      checkNoErrorDialog(tester);
+      checkNoDialog(tester);

       // Open the error details dialog.
       await tester.tap(find.text('Details'));
@@ -493,7 +493,7 @@ void main() {
       reportErrorToUserModally(title, message: message);
       check(ZulipApp.ready).value.isFalse();
       await tester.pump();
-      checkNoErrorDialog(tester);
+      checkNoDialog(tester);

       check(ZulipApp.ready).value.isTrue();
       // After app startup, reportErrorToUserModally displays an [AlertDialog].
diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart
index d79acce925..e6b3cf6760 100644
--- a/test/widgets/compose_box_test.dart
+++ b/test/widgets/compose_box_test.dart
@@ -395,7 +395,7 @@ void main() {
         await prepareWithContent(tester,
           makeStringWithCodePoints(kMaxMessageLengthCodePoints));
         await tapSendButton(tester);
-        checkNoErrorDialog(tester);
+        checkNoDialog(tester);
       });

       testWidgets('code points not counted unnecessarily', (tester) async {
@@ -434,7 +434,7 @@ void main() {
         await prepareWithTopic(tester,
           makeStringWithCodePoints(kMaxTopicLengthCodePoints));
         await tapSendButton(tester);
-        checkNoErrorDialog(tester);
+        checkNoDialog(tester);
       });

       testWidgets('code points not counted unnecessarily', (tester) async {
@@ -938,7 +938,7 @@ void main() {
       await setupAndTapSend(tester, prepareResponse: (int messageId) {
         connection.prepare(json: SendMessageResult(id: messageId).toJson());
       });
-      checkNoErrorDialog(tester);
+      checkNoDialog(tester);
     });

     testWidgets('ZulipApiException', (tester) async {
@@ -1078,7 +1078,7 @@ void main() {
         check(call.allowMultiple).equals(true);
         check(call.type).equals(FileType.media);

-        checkNoErrorDialog(tester);
+        checkNoDialog(tester);

         check(controller!.content.text)
           .equals('see image: [Uploading image.jpg…]()\n\n');
@@ -1137,7 +1137,7 @@ void main() {
         check(call.source).equals(ImageSource.camera);
         check(call.requestFullMetadata).equals(false);

-        checkNoErrorDialog(tester);
+        checkNoDialog(tester);

         check(controller!.content.text)
           .equals('see image: [Uploading image.jpg…]()\n\n');
@@ -1864,7 +1864,7 @@ void main() {
           UploadFileResult(url: '/path/file.jpg').toJson());
         await tester.tap(find.byIcon(ZulipIcons.attach_file), warnIfMissed: false);
         await tester.pump(Duration.zero);
-        checkNoErrorDialog(tester);
+        checkNoDialog(tester);
         check(testBinding.takePickFilesCalls()).length.equals(1);
         connection.takeRequests(); // upload request

diff --git a/test/widgets/dialog_checks.dart b/test/widgets/dialog_checks.dart
index 86acec96ce..20ff0effd2 100644
--- a/test/widgets/dialog_checks.dart
+++ b/test/widgets/dialog_checks.dart
@@ -1,10 +1,12 @@
 import 'package:checks/checks.dart';
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_checks/flutter_checks.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:zulip/widgets/dialog.dart';

-/// In a widget test, check that showErrorDialog was called with the right text.
+/// In a widget test, check that [showErrorDialog] was called with the right text.
 ///
 /// Checks for an error dialog matching an expected title
 /// and, optionally, matching an expected message. Fails if none is found.
@@ -15,24 +17,41 @@ Widget checkErrorDialog(WidgetTester tester, {
   required String expectedTitle,
   String? expectedMessage,
 }) {
-  final dialog = tester.widget<AlertDialog>(find.byType(AlertDialog));
-  tester.widget(find.descendant(matchRoot: true,
-    of: find.byWidget(dialog.title!), matching: find.text(expectedTitle)));
-  if (expectedMessage != null) {
-    tester.widget(find.descendant(matchRoot: true,
-      of: find.byWidget(dialog.content!), matching: find.text(expectedMessage)));
-  }
-
-  // TODO check "Learn more" button?
+  switch (defaultTargetPlatform) {
+    case TargetPlatform.android:
+    case TargetPlatform.fuchsia:
+    case TargetPlatform.linux:
+    case TargetPlatform.windows:
+      final dialog = tester.widget<AlertDialog>(find.bySubtype<AlertDialog>());
+      tester.widget(find.descendant(matchRoot: true,
+        of: find.byWidget(dialog.title!), matching: find.text(expectedTitle)));
+      if (expectedMessage != null) {
+        tester.widget(find.descendant(matchRoot: true,
+          of: find.byWidget(dialog.content!), matching: find.text(expectedMessage)));
+      }
+      return tester.widget(find.descendant(of: find.byWidget(dialog),
+        matching: find.widgetWithText(TextButton, 'OK')));

-  return tester.widget(
-    find.descendant(of: find.byWidget(dialog),
-      matching: find.widgetWithText(TextButton, 'OK')));
+    case TargetPlatform.iOS:
+    case TargetPlatform.macOS:
+      final dialog = tester.widget<CupertinoAlertDialog>(find.byType(CupertinoAlertDialog));
+      tester.widget(find.descendant(matchRoot: true,
+        of: find.byWidget(dialog.title!), matching: find.text(expectedTitle)));
+      if (expectedMessage != null) {
+        tester.widget(find.descendant(matchRoot: true,
+          of: find.byWidget(dialog.content!), matching: find.text(expectedMessage)));
+      }
+      return tester.widget(find.descendant(of: find.byWidget(dialog),
+        matching: find.widgetWithText(CupertinoDialogAction, 'OK')));
+  }
 }

-// TODO(#996) update this to check for per-platform flavors of alert dialog
-void checkNoErrorDialog(WidgetTester tester) {
-  check(find.byType(AlertDialog)).findsNothing();
+/// Checks that there is no dialog.
+/// Fails if one is found.
+void checkNoDialog(WidgetTester tester) {
+  check(find.byType(Dialog)).findsNothing();
+  check(find.bySubtype<AlertDialog>()).findsNothing();
+  check(find.byType(CupertinoAlertDialog)).findsNothing();
 }

 /// In a widget test, check that [showSuggestedActionDialog] was called
@@ -49,19 +68,35 @@ void checkNoErrorDialog(WidgetTester tester) {
   required String expectedMessage,
   String? expectedActionButtonText,
 }) {
-  final dialog = tester.widget<AlertDialog>(find.byType(AlertDialog));
-  tester.widget(find.descendant(matchRoot: true,
-    of: find.byWidget(dialog.title!), matching: find.text(expectedTitle)));
-  tester.widget(find.descendant(matchRoot: true,
-    of: find.byWidget(dialog.content!), matching: find.text(expectedMessage)));
+  switch (defaultTargetPlatform) {
+    case TargetPlatform.android:
+    case TargetPlatform.fuchsia:
+    case TargetPlatform.linux:
+    case TargetPlatform.windows:
+      final dialog = tester.widget<AlertDialog>(find.bySubtype<AlertDialog>());
+      tester.widget(find.descendant(matchRoot: true,
+        of: find.byWidget(dialog.title!), matching: find.text(expectedTitle)));
+      tester.widget(find.descendant(matchRoot: true,
+        of: find.byWidget(dialog.content!), matching: find.text(expectedMessage)));

-  final actionButton = tester.widget(
-    find.descendant(of: find.byWidget(dialog),
-      matching: find.widgetWithText(TextButton, expectedActionButtonText ?? 'Continue')));
+      final actionButton = tester.widget(find.descendant(of: find.byWidget(dialog),
+        matching: find.widgetWithText(TextButton, expectedActionButtonText ?? 'Continue')));
+      final cancelButton = tester.widget(find.descendant(of: find.byWidget(dialog),
+        matching: find.widgetWithText(TextButton, 'Cancel')));
+      return (actionButton, cancelButton);

-  final cancelButton = tester.widget(
-    find.descendant(of: find.byWidget(dialog),
-      matching: find.widgetWithText(TextButton, 'Cancel')));
+    case TargetPlatform.iOS:
+    case TargetPlatform.macOS:
+      final dialog = tester.widget<CupertinoAlertDialog>(find.byType(CupertinoAlertDialog));
+      tester.widget(find.descendant(matchRoot: true,
+        of: find.byWidget(dialog.title!), matching: find.text(expectedTitle)));
+      tester.widget(find.descendant(matchRoot: true,
+        of: find.byWidget(dialog.content!), matching: find.text(expectedMessage)));

-  return (actionButton, cancelButton);
+      final actionButton = tester.widget(find.descendant(of: find.byWidget(dialog),
+        matching: find.widgetWithText(CupertinoDialogAction, expectedActionButtonText ?? 'Continue')));
+      final cancelButton = tester.widget(find.descendant(of: find.byWidget(dialog),
+        matching: find.widgetWithText(CupertinoDialogAction, 'Cancel')));
+      return (actionButton, cancelButton);
+  }
 }
diff --git a/test/widgets/dialog_test.dart b/test/widgets/dialog_test.dart
index 1980f619f3..855affbb3f 100644
--- a/test/widgets/dialog_test.dart
+++ b/test/widgets/dialog_test.dart
@@ -1,30 +1,69 @@
 import 'package:checks/checks.dart';
-import 'package:flutter/widgets.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:url_launcher/url_launcher.dart';
 import 'package:zulip/widgets/dialog.dart';

 import '../model/binding.dart';
+import 'dialog_checks.dart';
 import 'test_app.dart';

 void main() {
   TestZulipBinding.ensureInitialized();

+  late BuildContext context;
+
+  const title = "Dialog Title";
+  const message = "Dialog message.";
+
+  Future<void> prepare(WidgetTester tester) async {
+    addTearDown(testBinding.reset);
+
+    await tester.pumpWidget(const TestZulipApp(
+      child: Scaffold(body: Placeholder())));
+    await tester.pump();
+    context = tester.element(find.byType(Placeholder));
+  }
+
   group('showErrorDialog', () {
-    testWidgets('tap "Learn more" button', (tester) async {
-      addTearDown(testBinding.reset);
-      await tester.pumpWidget(TestZulipApp());
+    testWidgets('show error dialog', (tester) async {
+      await prepare(tester);
+
+      showErrorDialog(context: context, title: title, message: message);
       await tester.pump();
-      final element = tester.element(find.byType(Placeholder));
+      checkErrorDialog(tester, expectedTitle: title, expectedMessage: message);
+    }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));

-      showErrorDialog(context: element, title: 'hello',
-        learnMoreButtonUrl: Uri.parse('https://foo.example'));
+    testWidgets('user closes error dialog', (tester) async {
+      await prepare(tester);
+
+      showErrorDialog(context: context, title: title, message: message);
+      await tester.pump();
+
+      final button = checkErrorDialog(tester, expectedTitle: title);
+      await tester.tap(find.byWidget(button));
+      await tester.pump();
+      checkNoDialog(tester);
+    }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
+
+    testWidgets('tap "Learn more" button', (tester) async {
+      await prepare(tester);
+
+      final learnMoreButtonUrl = Uri.parse('https://foo.example');
+      showErrorDialog(context: context, title: title, learnMoreButtonUrl: learnMoreButtonUrl);
       await tester.pump();
+      checkErrorDialog(tester, expectedTitle: title);
+
       await tester.tap(find.text('Learn more'));
-      check(testBinding.takeLaunchUrlCalls()).single.equals((
-        url: Uri.parse('https://foo.example'),
-        mode: LaunchMode.inAppBrowserView));
-    });
+      final expectedMode = switch (defaultTargetPlatform) {
+        TargetPlatform.android => LaunchMode.inAppBrowserView,
+        TargetPlatform.iOS =>     LaunchMode.externalApplication,
+        _ => throw StateError('attempted to test with $defaultTargetPlatform'),
+      };
+      check(testBinding.takeLaunchUrlCalls()).single
+        .equals((url: learnMoreButtonUrl, mode: expectedMode));
+    }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
   });

   group('showSuggestedActionDialog', () {
@@ -41,7 +80,7 @@ void main() {
       await tester.pump();
       await tester.tap(find.text('Sure'));
       await check(dialog.result).completes((it) => it.equals(true));
-    });
+    }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));

     testWidgets('tap cancel', (tester) async {
       addTearDown(testBinding.reset);
@@ -56,7 +95,7 @@ void main() {
       await tester.pump();
       await tester.tap(find.text('Cancel'));
       await check(dialog.result).completes((it) => it.equals(null));
-    });
+    }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));

     testWidgets('tap outside dialog area', (tester) async {
       addTearDown(testBinding.reset);
@@ -71,7 +110,7 @@ void main() {
       await tester.pump();
       await tester.tapAt(tester.getTopLeft(find.byType(TestZulipApp)));
       await check(dialog.result).completes((it) => it.equals(null));
-    });
+    }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
   });

   // TODO(#1594): test UpgradeWelcomeDialog