Show last-active time on profile page

Fixes #1647.

There are probably ways the logic here can be improved.  But we can do that in tandem with the relative times on the lightbox (#45), once we have ready access to CLDR data via FFI.

~~Draft because I want to write some tests before merging; my manual testing hasn't reached all the different cases in this logic.~~

profile: Show when the user was last active
On a user's profile, we should show the time they were last known to be active, e.g., "5 hours ago". For how to format the time, see #45.

Hi team 👋,

I'd love to work on this issue! I have experience with Flutter and handling datetime formatting. I can implement a user-friendly "last active" display using libraries like `timeago` or `intl` to show values such as:

Example implementaion code using `timeago`:-
```
import 'package:timeago/timeago.dart' as timeago;

DateTime lastActiveTime = DateTime.parse("2025-07-20T16:47:00"); // Example timestamp
String lastSeen = timeago.format(lastActiveTime);

Text("Last active: $lastSeen");
```

or if using a formatted date and time such as "Yesterday at 4:47 PM", best to go with `intl`

- "Just now"
- "5 minutes ago"
- "Yesterday at 4:47 PM"
- "Last Monday at 2:30 PM"

If assigned, I can get started right away and submit a PR soon.
Thanks for your interest in this issue! Please see the issue description for how we'd like this to be implemented, and why we don't want to use `timeago` or `intl`:

> For how to format the time, see https://github.com/zulip/zulip-flutter/issues/45.

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

diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index 66ef1ee004..dedffe9b47 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -874,6 +874,50 @@
   "@yesterday": {
     "description": "Term to use to reference the previous day."
   },
+  "userActiveNow": "Active now",
+  "@userActiveNow": {
+    "description": "Indicates a user is currently active on Zulip (not idle or offline)"
+  },
+  "userIdle": "Idle",
+  "@userIdle": {
+    "description": "Indicates a user is currently idle on Zulip (not active, but not offline)"
+  },
+  "userActiveMinutesAgo": "Active {minutes, plural, =1{1 minute} other{{minutes} minutes}} ago",
+  "@userActiveMinutesAgo": {
+    "description": "Indicates when a user was last active on Zulip (who is currently offline)",
+    "placeholders": {
+      "minutes": {"type": "int", "example": "5"}
+    }
+  },
+  "userActiveHoursAgo": "Active {hours, plural, =1{1 hour} other{{hours} hours}} ago",
+  "@userActiveHoursAgo": {
+    "description": "Indicates when a user was last active on Zulip (who is currently offline)",
+    "placeholders": {
+      "hours": {"type": "int", "example": "5"}
+    }
+  },
+  "userActiveYesterday": "Active yesterday",
+  "@userActiveYesterday": {
+    "description": "Indicates when a user was last active on Zulip (who is currently offline)"
+  },
+  "userActiveDaysAgo": "Active {days, plural, =1{1 day} other{{days} days}} ago",
+  "@userActiveDaysAgo": {
+    "description": "Indicates when a user was last active on Zulip (who is currently offline)",
+    "placeholders": {
+      "days": {"type": "int", "example": "5"}
+    }
+  },
+  "userActiveDate": "Active {date}",
+  "@userActiveDate": {
+    "description": "Indicates the date when a user was last active on Zulip (who is currently offline).\n\nThe date might be day and month if recent, or day, month, and year if less recent.",
+    "placeholders": {
+      "date": {"type": "String", "example": "Aug 1, 2024"}
+    }
+  },
+  "userNotActiveInYear": "Not active in the last year",
+  "@userNotActiveInYear": {
+    "description": "Indicates when a user was last active on Zulip (who is currently offline)"
+  },
   "invisibleMode": "Invisible mode",
   "@invisibleMode": {
     "description": "Label for the 'Invisible mode' switch on the profile page."
diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index 51166b29aa..8b0de0b1b3 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -1301,6 +1301,56 @@ abstract class ZulipLocalizations {
   /// **'Yesterday'**
   String get yesterday;

+  /// Indicates a user is currently active on Zulip (not idle or offline)
+  ///
+  /// In en, this message translates to:
+  /// **'Active now'**
+  String get userActiveNow;
+
+  /// Indicates a user is currently idle on Zulip (not active, but not offline)
+  ///
+  /// In en, this message translates to:
+  /// **'Idle'**
+  String get userIdle;
+
+  /// Indicates when a user was last active on Zulip (who is currently offline)
+  ///
+  /// In en, this message translates to:
+  /// **'Active {minutes, plural, =1{1 minute} other{{minutes} minutes}} ago'**
+  String userActiveMinutesAgo(int minutes);
+
+  /// Indicates when a user was last active on Zulip (who is currently offline)
+  ///
+  /// In en, this message translates to:
+  /// **'Active {hours, plural, =1{1 hour} other{{hours} hours}} ago'**
+  String userActiveHoursAgo(int hours);
+
+  /// Indicates when a user was last active on Zulip (who is currently offline)
+  ///
+  /// In en, this message translates to:
+  /// **'Active yesterday'**
+  String get userActiveYesterday;
+
+  /// Indicates when a user was last active on Zulip (who is currently offline)
+  ///
+  /// In en, this message translates to:
+  /// **'Active {days, plural, =1{1 day} other{{days} days}} ago'**
+  String userActiveDaysAgo(int days);
+
+  /// Indicates the date when a user was last active on Zulip (who is currently offline).
+  ///
+  /// The date might be day and month if recent, or day, month, and year if less recent.
+  ///
+  /// In en, this message translates to:
+  /// **'Active {date}'**
+  String userActiveDate(String date);
+
+  /// Indicates when a user was last active on Zulip (who is currently offline)
+  ///
+  /// In en, this message translates to:
+  /// **'Not active in the last year'**
+  String get userNotActiveInYear;
+
   /// Label for the 'Invisible mode' switch on the profile page.
   ///
   /// In en, this message translates to:
diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart
index bcf71ff75d..7c63614c82 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -723,6 +723,56 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
   @override
   String get yesterday => 'Yesterday';

+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';

diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart
index 5989434931..5d44111e4e 100644
--- a/lib/generated/l10n/zulip_localizations_de.dart
+++ b/lib/generated/l10n/zulip_localizations_de.dart
@@ -745,6 +745,56 @@ class ZulipLocalizationsDe extends ZulipLocalizations {
   @override
   String get yesterday => 'Gestern';

+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';

diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart
index 1dc66ac100..3c96f8bdd2 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -723,6 +723,56 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
   @override
   String get yesterday => 'Yesterday';

+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';

diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart
index 84ddde19b4..31849002e9 100644
--- a/lib/generated/l10n/zulip_localizations_fr.dart
+++ b/lib/generated/l10n/zulip_localizations_fr.dart
@@ -737,6 +737,56 @@ class ZulipLocalizationsFr extends ZulipLocalizations {
   @override
   String get yesterday => 'Yesterday';

+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';

diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart
index 43accf333c..c9cff2ec10 100644
--- a/lib/generated/l10n/zulip_localizations_it.dart
+++ b/lib/generated/l10n/zulip_localizations_it.dart
@@ -738,6 +738,56 @@ class ZulipLocalizationsIt extends ZulipLocalizations {
   @override
   String get yesterday => 'Ieri';

+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';

diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart
index 01cfdfc1c7..19051577a2 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -720,6 +720,56 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
   @override
   String get yesterday => 'Yesterday';

+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';

diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart
index 1ee9cc4304..e1923091e7 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -723,6 +723,56 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
   @override
   String get yesterday => 'Yesterday';

+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';

diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart
index dae4675941..a56afc6c1c 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -734,6 +734,56 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
   @override
   String get yesterday => 'Wczoraj';

+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Tryb ukrycia';

diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart
index ce7d41533a..bf986a9da3 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -739,6 +739,56 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
   @override
   String get yesterday => 'Вчера';

+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Режим невидимости';

diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart
index a9cb388332..ee0840259f 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -725,6 +725,56 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
   @override
   String get yesterday => 'Včera';

+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';

diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart
index 0f45bd9e28..ec7e3c7c22 100644
--- a/lib/generated/l10n/zulip_localizations_sl.dart
+++ b/lib/generated/l10n/zulip_localizations_sl.dart
@@ -749,6 +749,56 @@ class ZulipLocalizationsSl extends ZulipLocalizations {
   @override
   String get yesterday => 'Včeraj';

+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';

diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart
index 7950acfb15..4d8fc8ab9e 100644
--- a/lib/generated/l10n/zulip_localizations_uk.dart
+++ b/lib/generated/l10n/zulip_localizations_uk.dart
@@ -738,6 +738,56 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
   @override
   String get yesterday => 'Учора';

+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Невидимий режим';

diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart
index 39e86f6960..6d6ba8579b 100644
--- a/lib/generated/l10n/zulip_localizations_zh.dart
+++ b/lib/generated/l10n/zulip_localizations_zh.dart
@@ -723,6 +723,56 @@ class ZulipLocalizationsZh extends ZulipLocalizations {
   @override
   String get yesterday => 'Yesterday';

+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';

diff --git a/lib/model/presence.dart b/lib/model/presence.dart
index 590c2d3ceb..882f12031c 100644
--- a/lib/model/presence.dart
+++ b/lib/model/presence.dart
@@ -96,8 +96,7 @@ class Presence extends HasRealmStore with ChangeNotifier {
           newUserInput: false);
     }
     if (!pingOnly) {
-      _map = result.presences!;
-      notifyListeners();
+      _handlePresenceResponse(result.presences!);
     }
   }

@@ -124,6 +123,16 @@ class Presence extends HasRealmStore with ChangeNotifier {
     super.dispose();
   }

+  @visibleForTesting
+  void debugHandlePresenceResponse(Map<int, PerUserPresence> presences) {
+    _handlePresenceResponse(presences);
+  }
+
+  void _handlePresenceResponse(Map<int, PerUserPresence> presences) {
+    _map = presences;
+    notifyListeners();
+  }
+
   /// The [PresenceStatus] for [userId], or null if the user is offline.
   PresenceStatus? presenceStatusForUser(int userId, {required DateTime utcNow}) {
     final now = utcNow.millisecondsSinceEpoch ~/ 1000;
@@ -143,6 +152,31 @@ class Presence extends HasRealmStore with ChangeNotifier {
     }
   }

+  /// The timestamp when the given user was "last active", if any.
+  ///
+  /// This is meaningful only when [presenceStatusForUser] is null.
+  /// When that method returns active or idle, the user should be displayed
+  /// with a description like "Active now" or "Idle" rather than
+  /// one like "Last active $duration ago" that uses this timestamp.
+  int? userLastActive(int userId) {
+    // The corresponding implementation on web is complicated;
+    // but the actual behavior seems to be this simple.
+    //
+    // In web, see buddy_data.user_last_seen_time_status; the last-active time
+    // is used only when the status is offline (vs active or idle).
+    // The timestamp comes via presence.last_active_date from the data structure
+    // fed by presence.status_from_raw.
+    //
+    // That status_from_raw function sometimes uses idle_timestamp;
+    // but only when status idle, where the timestamp will be ignored anyway.
+    // It also consults the equivalent of [User.dateJoined] as a fallback,
+    // for when processing a user who has "never logged in"... but it's
+    // not clear that function ever gets called in such a case.
+    // Those wrinkles aside, it always uses active_timestamp.
+
+    return _map[userId]?.activeTimestamp;
+  }
+
   void handlePresenceEvent(PresenceEvent event) {
     // TODO(#1618)
   }
diff --git a/lib/model/store.dart b/lib/model/store.dart
index 4f3e72442f..070c20fb27 100644
--- a/lib/model/store.dart
+++ b/lib/model/store.dart
@@ -814,7 +814,8 @@ class PerAccountStore extends PerAccountStoreBase with
         typingStatus.handleTypingEvent(event);

       case PresenceEvent():
-        // TODO handle
+        assert(debugLog("server event: presence ${event.userId}"));
+        // TODO(#1618) handle
         break;

       case ReactionEvent():
diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart
index 461b2ec1b7..3748e39280 100644
--- a/lib/widgets/profile.dart
+++ b/lib/widgets/profile.dart
@@ -1,13 +1,16 @@
 import 'dart:convert';

 import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';

 import '../api/model/model.dart';
 import '../api/route/settings.dart';
 import '../generated/l10n/zulip_localizations.dart';
 import '../log.dart';
+import '../model/binding.dart';
 import '../model/content.dart';
 import '../model/narrow.dart';
+import '../model/presence.dart';
 import 'app_bar.dart';
 import 'button.dart';
 import 'content.dart';
@@ -42,7 +45,6 @@ class ProfilePage extends StatelessWidget {
       page: ProfilePage(userId: userId));
   }

-
   @override
   Widget build(BuildContext context) {
     final zulipLocalizations = ZulipLocalizations.of(context);
@@ -56,7 +58,9 @@ class ProfilePage extends StatelessWidget {
       .merge(weightVariableTextStyle(context, wght: 700));

     final userStatus = store.getUserStatus(userId);
+
     final displayEmail = user.deliveryEmail;
+
     final items = [
       Center(
         child: Avatar(
@@ -93,6 +97,10 @@ class ProfilePage extends StatelessWidget {
           textAlign: TextAlign.center,
           style: TextStyle(fontSize: 18, height: 22 / 18,
             color: DesignVariables.of(context).userStatusText)),
+      if (!user.isBot)
+        _LastActiveTime(userId: userId),
+
+      const SizedBox(height: 8),
       if (displayEmail != null)
         Text(displayEmail,
           textAlign: TextAlign.center,
@@ -139,6 +147,106 @@ class ProfilePage extends StatelessWidget {
   }
 }

+class _LastActiveTime extends StatefulWidget {
+  const _LastActiveTime({required this.userId});
+
+  final int userId;
+
+  @override
+  State<_LastActiveTime> createState() => _LastActiveTimeState();
+}
+
+class _LastActiveTimeState extends State<_LastActiveTime> with PerAccountStoreAwareStateMixin {
+  Presence? model;
+
+  @override
+  void onNewStore() {
+    model?.removeListener(_modelChanged);
+    model = PerAccountStoreWidget.of(context).presence
+      ..addListener(_modelChanged);
+  }
+
+  @override
+  void dispose() {
+    model!.removeListener(_modelChanged);
+    super.dispose();
+  }
+
+  void _modelChanged() {
+    setState(() {
+      // The actual state lives in [model].
+      // This method was called because that just changed.
+    });
+  }
+
+  String _lastActiveText(ZulipLocalizations zulipLocalizations) {
+    // TODO(#45): revise this relative-time logic in light of a future solution
+    //   for the lightbox, e.g. using ICU/CLDR via FFI.  See discussion:
+    //     https://github.com/zulip/zulip-flutter/pull/1793#issuecomment-3169228753
+
+    // TODO(#293), TODO(#891): auto-rebuild as relative time changes
+    final nowDate = ZulipBinding.instance.utcNow();
+
+    final status = model!.presenceStatusForUser(widget.userId,
+      utcNow: nowDate);
+    switch (status) {
+      case PresenceStatus.active: return zulipLocalizations.userActiveNow;
+      case PresenceStatus.idle:   return zulipLocalizations.userIdle;
+      case null:                  break; // handle below
+    }
+
+    final timestamp = model!.userLastActive(widget.userId);
+    if (timestamp == null) return zulipLocalizations.userNotActiveInYear;
+
+    // Compare web's timerender.last_seen_status_from_date.
+    final now = nowDate.millisecondsSinceEpoch ~/ 1000;
+    final ageSeconds = now - timestamp;
+    if (ageSeconds <= 0) {
+      // TODO or perhaps show full time, to help user in case of clock skew
+      return zulipLocalizations.userActiveNow;
+    } else if (ageSeconds < 60 * 60) {
+      return zulipLocalizations.userActiveMinutesAgo(ageSeconds ~/ 60);
+    } else if (ageSeconds < 24 * 60 * 60) {
+      return zulipLocalizations.userActiveHoursAgo(ageSeconds ~/ (60 * 60));
+    }
+
+    final todayNoon = nowDate.toLocal()
+      .copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0);
+    final presenceNoon = DateTime.fromMillisecondsSinceEpoch(
+        timestamp * 1000, isUtc: false)
+      .copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0);
+    final ageCalendarDays = (todayNoon.difference(presenceNoon)
+      .inSeconds / (24 * 60 * 60)).round();
+    if (ageCalendarDays <= 0) {
+      // The timestamp was at least 24 hours ago.
+      // If it's somehow the same or a future calendar day, then this must be a
+      // really messy time zone.  Hopefully no real time zone makes this possible.
+      return zulipLocalizations.userActiveYesterday;
+    } else if (ageCalendarDays == 1) {
+      return zulipLocalizations.userActiveYesterday;
+    } else if (ageCalendarDays < 90) {
+      return zulipLocalizations.userActiveDaysAgo(ageCalendarDays);
+    }
+
+    final DateFormat format;
+    if (presenceNoon.year == todayNoon.year) {
+      format = DateFormat.MMMd();
+    } else {
+      format = DateFormat.yMMMd();
+    }
+    return zulipLocalizations.userActiveDate(format.format(presenceNoon));
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final zulipLocalizations = ZulipLocalizations.of(context);
+    return Text(_lastActiveText(zulipLocalizations),
+      textAlign: TextAlign.center,
+      style: TextStyle(fontSize: 18, height: 22 / 18,
+        color: DesignVariables.of(context).userStatusText));
+  }
+}
+
 class _SetStatusButton extends StatelessWidget {
   const _SetStatusButton();

diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart
index a7b74356ab..c79e3fb5b9 100644
--- a/test/widgets/profile_test.dart
+++ b/test/widgets/profile_test.dart
@@ -1,6 +1,7 @@
 import 'dart:io';

 import 'package:checks/checks.dart';
+import 'package:clock/clock.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_checks/flutter_checks.dart';
 import 'package:flutter_test/flutter_test.dart';
@@ -119,6 +120,87 @@ void main() {
     check(find.text(longString).evaluate()).isNotEmpty();
   });

+  group('_LastActiveTime', () {
+    Future<void> update(WidgetTester tester, User user, {
+      required int activeSeconds,
+      int? idleSeconds,
+    }) async {
+      idleSeconds ??= activeSeconds + 3600;
+      final now = clock.now().millisecondsSinceEpoch ~/ 1000;
+      final presence = PerUserPresence(
+        activeTimestamp: now - activeSeconds,
+        idleTimestamp: now - idleSeconds,
+      );
+      store.presence.debugHandlePresenceResponse({user.userId: presence});
+      await tester.pump();
+    }
+
+    testWidgets('active, idle, never', (tester) async {
+      final user = eg.user();
+      await setupPage(tester, users: [user], pageUserId: user.userId);
+      check(find.text('Not active in the last year')).findsOne();
+
+      await update(tester, user, activeSeconds: 3600, idleSeconds: 30);
+      check(find.text('Idle')).findsOne();
+
+      await update(tester, user, activeSeconds: 30);
+      check(find.text('Active now')).findsOne();
+    });
+
+    testWidgets('various ages', (tester) async {
+      final user = eg.user();
+      await setupPage(tester, users: [user], pageUserId: user.userId);
+
+      // These tests could be more detailed in making sure the behavior is
+      // exactly the way it currently is.  But save that for a future where
+      // we've made a pass over the logic to ensure we're happy with that spec.
+
+      await update(tester, user, activeSeconds: 10 * 60);
+      check(find.text('Active 10 minutes ago')).findsOne();
+
+      await update(tester, user, activeSeconds: 61 * 60);
+      check(find.text('Active 1 hour ago')).findsOne();
+
+      await update(tester, user, activeSeconds: 20 * 60 * 60);
+      check(find.text('Active 20 hours ago')).findsOne();
+
+      await update(tester, user, activeSeconds: 24 * 60 * 60);
+      check(find.text('Active yesterday')).findsOne();
+
+      await update(tester, user, activeSeconds: 2 * 24 * 60 * 60);
+      check(find.text('Active 2 days ago')).findsOne();
+
+      await update(tester, user, activeSeconds: 80 * 24 * 60 * 60);
+      check(find.text('Active 80 days ago')).findsOne();
+    });
+
+    testWidgets('dates', (tester) async {
+      final user = eg.user();
+      await setupPage(tester, users: [user], pageUserId: user.userId);
+
+      final now = DateTime.parse('2025-08-01 12:00');
+      await withClock(Clock.fixed(now), () async {
+        await update(tester, user, activeSeconds: now.difference(
+          DateTime.parse('2025-04-01 12:00')).inSeconds);
+        check(find.text('Active Apr 1')).findsOne();
+
+        await update(tester, user, activeSeconds: now.difference(
+          DateTime.parse('2024-04-01 12:00')).inSeconds);
+        check(find.text('Active Apr 1, 2024')).findsOne();
+      });
+    });
+
+    testWidgets('omit for bots', (tester) async {
+      final user = eg.user(isBot: true);
+      await setupPage(tester, users: [user], pageUserId: user.userId);
+      await update(tester, user, activeSeconds: 30);
+      check(find.text('Active now')).findsNothing();
+      check(find.textContaining('Active')).findsNothing();
+      check(find.textContaining('Idle')).findsNothing();
+      check(find.textContaining('Not active')).findsNothing();
+    });
+  });
+
   group('custom profile fields', () {
     testWidgets('page builds; profile page renders with profileData', (tester) async {
       await setupPage(tester,


Fixes #1709.

## Commit messages

#### 314c83c09 api [nfc]: Rename field UploadFileResult.url from "uri"

Like elsewhere in our codebase, use the standard term "URL":
  https://github.com/zulip/zulip-mobile/blob/main/docs/style.md#url-not-uri

#### c7f336c1e compose [nfc]: Take a URL string at inlineLink, rather than a Uri object

This way this function is a more faithful wrapper of constructing
the actual Markdown syntax.  The Markdown will have a URL string,
after all, so it's for the best to put this function's caller in
control of exactly what string that is.

#### 2e06a67ff compose: For uploads, use verbatim URL string from server

Fixes #1709.

Round-tripping through `Uri.parse` and `.toString()` had the effect
of percent-encoding any non-ASCII characters in the given URL string.
The server expects, reasonably enough, that the client will refer to
the upload using the same URL string the server provided at upload
time; so do that.

#### 6a35cc47f compose [nfc]: Simplify inlineLink by dropping null destination

Now that the caller is passing a string instead of a Uri object, the
caller that wants an empty string here can just pass an empty string.

Uploaded file's URL gets percent-encoded, breaking permissions
If someone sends a message with an uploaded file whose filename contains non-ASCII characters, we end up percent-encoding those characters in the URL in the message's link to the file.

The resulting behavior is that the file is visible to the person who originally sent it, but not to others who received the message, because the server doesn't recognize it as the same file when assigning permissions based on the message.

The URL comes from https://zulip.com/api/upload-file (i.e. `UploadFileResult.uri` — which BTW we should rename to `url`). Currently we call `Uri.parse` on it, and then `.toString()` on the resulting `Uri` object, which has the effect of percent-encoding it. Instead, we should pass the original `uri`/`url` value through verbatim as a String.

### References
* Original report: [#issues > unable to download files contain Korean letters @ 💬](https://chat.zulip.org/#narrow/channel/9-issues/topic/unable.20to.20download.20files.20contain.20Korean.20letters/near/2215859)
* Diagnosis: [#issues > unable to download files contain Korean letters @ 💬](https://chat.zulip.org/#narrow/channel/9-issues/topic/unable.20to.20download.20files.20contain.20Korean.20letters/near/2223217), and later [#issues > unable to download files contain Korean letters @ 💬](https://chat.zulip.org/#narrow/channel/9-issues/topic/unable.20to.20download.20files.20contain.20Korean.20letters/near/2223661)

flutter test test/api/route/messages_test.dart test/model/compose_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 05364951cd..44053bcc4a 100644
--- a/lib/api/route/messages.dart
+++ b/lib/api/route/messages.dart
@@ -315,10 +315,11 @@ Future<UploadFileResult> uploadFile(

 @JsonSerializable(fieldRename: FieldRename.snake)
 class UploadFileResult {
-  final String uri;
+  @JsonKey(name: 'uri')
+  final String url;

   UploadFileResult({
-    required this.uri,
+    required this.url,
   });

   factory UploadFileResult.fromJson(Map<String, dynamic> json) =>
diff --git a/lib/api/route/messages.g.dart b/lib/api/route/messages.g.dart
index 0df3e678e6..306a58ca4d 100644
--- a/lib/api/route/messages.g.dart
+++ b/lib/api/route/messages.g.dart
@@ -50,10 +50,10 @@ Map<String, dynamic> _$UpdateMessageResultToJson(
 ) => <String, dynamic>{};

 UploadFileResult _$UploadFileResultFromJson(Map<String, dynamic> json) =>
-    UploadFileResult(uri: json['uri'] as String);
+    UploadFileResult(url: json['uri'] as String);

 Map<String, dynamic> _$UploadFileResultToJson(UploadFileResult instance) =>
-    <String, dynamic>{'uri': instance.uri};
+    <String, dynamic>{'uri': instance.url};

 UpdateMessageFlagsResult _$UpdateMessageFlagsResultFromJson(
   Map<String, dynamic> json,
diff --git a/lib/model/compose.dart b/lib/model/compose.dart
index f1145e555f..09b02cb08c 100644
--- a/lib/model/compose.dart
+++ b/lib/model/compose.dart
@@ -188,8 +188,8 @@ String wildcardMention(WildcardMentionOption wildcardOption, {
 /// result may be surprising.
 ///
 /// The part between "(" and ")" is just a "link destination" (no "link title").
-/// That destination is simply the stringified [destination], if provided.
-/// If that has parentheses in it, the result may be surprising.
+/// That destination is the string [destination].
+/// If [destination] has parentheses in it, the result may be surprising.
 // TODO: Try harder to guarantee output that creates an inline link,
 //   and in particular, the intended one. We could help with this by escaping
 //   square brackets, perhaps with HTML character references:
@@ -199,8 +199,8 @@ String wildcardMention(WildcardMentionOption wildcardOption, {
 //   > Backtick code spans, autolinks, and raw HTML tags bind more tightly
 //   > than the brackets in link text. Thus, for example, [foo`]` could not be
 //   > a link text, since the second ] is part of a code span.
-String inlineLink(String visibleText, Uri? destination) {
-  return '[$visibleText](${destination?.toString() ?? ''})';
+String inlineLink(String visibleText, String destination) {
+  return '[$visibleText]($destination)';
 }

 /// What we show while fetching the target message's raw Markdown.
@@ -213,7 +213,7 @@ String quoteAndReplyPlaceholder(
     SendableNarrow.ofMessage(message, selfUserId: store.selfUserId),
     nearMessageId: message.id);
   return '${userMentionFromMessage(message, silent: true, users: store)} '
-    '${inlineLink('said', url)}: ' // TODO(#1285)
+    '${inlineLink('said', url.toString())}: ' // TODO(#1285)
     '*${zulipLocalizations.composeBoxLoadingMessage(message.id)}*\n';
 }

@@ -237,6 +237,6 @@ String quoteAndReply(PerAccountStore store, {
   // and the extra noise won't much matter with the already probably-long
   // message link in there too.
   return '${userMentionFromMessage(message, silent: true, users: store)} '
-    '${inlineLink('said', url)}:\n' // TODO(#1285)
+    '${inlineLink('said', url.toString())}:\n' // TODO(#1285)
     '${wrapWithBacktickFence(content: rawContent, infoString: 'quote')}';
 }
diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart
index 6514631955..99eeef3b0d 100644
--- a/lib/widgets/compose_box.dart
+++ b/lib/widgets/compose_box.dart
@@ -350,7 +350,7 @@ class ComposeContentController extends ComposeController<ContentValidationError>
     final tag = _nextUploadTag;
     _nextUploadTag += 1;
     final linkText = zulipLocalizations.composeBoxUploadingFilename(filename);
-    final placeholder = inlineLink(linkText, null);
+    final placeholder = inlineLink(linkText, '');
     _uploads[tag] = (filename: filename, placeholder: placeholder);
     notifyListeners(); // _uploads change could affect validationErrors
     value = value.replaced(insertionIndex(), '$placeholder\n\n');
@@ -359,9 +359,9 @@ class ComposeContentController extends ComposeController<ContentValidationError>

   /// Tells the controller that a file upload has ended, with success or error.
   ///
-  /// To indicate success, pass the URL to be used for the Markdown link.
+  /// To indicate success, pass the URL string to be used for the Markdown link.
   /// If `url` is null, failure is assumed.
-  void registerUploadEnd(int tag, Uri? url) {
+  void registerUploadEnd(int tag, String? url) {
     final val = _uploads[tag];
     assert(val != null, 'registerUploadEnd called twice for same tag');
     final (:filename, :placeholder) = val!;
@@ -971,7 +971,7 @@ Future<void> _uploadFiles({

   for (final (tag, file) in uploadsInProgress) {
     final _File(:content, :length, :filename, :mimeType) = file;
-    Uri? url;
+    String? url;
     try {
       final result = await uploadFile(store.connection,
         content: content,
@@ -979,7 +979,7 @@ Future<void> _uploadFiles({
         filename: filename,
         contentType: mimeType,
       );
-      url = Uri.parse(result.uri);
+      url = result.url;
     } catch (e) {
       if (!context.mounted) return;
       // TODO(#741): Specifically handle `413 Payload Too Large`

diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart
index f00bf4428f..a862b512b6 100644
--- a/test/api/route/messages_test.dart
+++ b/test/api/route/messages_test.dart
@@ -567,7 +567,7 @@ void main() {
       required String? contentType,
     }) async {
       connection.prepare(json:
-        UploadFileResult(uri: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/$filename').toJson());
+        UploadFileResult(url: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/$filename').toJson());
       await uploadFile(connection,
         content: Stream.fromIterable(content),
         length: length,
diff --git a/test/model/compose_test.dart b/test/model/compose_test.dart
index 2031b69d28..d7479f8ec5 100644
--- a/test/model/compose_test.dart
+++ b/test/model/compose_test.dart
@@ -319,9 +319,9 @@ hello
   });

   test('inlineLink', () {
-    check(inlineLink('CZO', Uri.parse('https://chat.zulip.org/'))).equals('[CZO](https://chat.zulip.org/)');
-    check(inlineLink('Uploading file.txt…', null)).equals('[Uploading file.txt…]()');
-    check(inlineLink('IMG_2488.png', Uri.parse('/user_uploads/2/a3/ucEMyjxk90mcNF0y9rmW5XKO/IMG_2488.png')))
+    check(inlineLink('CZO', 'https://chat.zulip.org/')).equals('[CZO](https://chat.zulip.org/)');
+    check(inlineLink('Uploading file.txt…', '')).equals('[Uploading file.txt…]()');
+    check(inlineLink('IMG_2488.png', '/user_uploads/2/a3/ucEMyjxk90mcNF0y9rmW5XKO/IMG_2488.png'))
       .equals('[IMG_2488.png](/user_uploads/2/a3/ucEMyjxk90mcNF0y9rmW5XKO/IMG_2488.png)');
   });

diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart
index f39e74c138..d79acce925 100644
--- a/test/widgets/compose_box_test.dart
+++ b/test/widgets/compose_box_test.dart
@@ -1070,7 +1070,7 @@ void main() {
           size: 12345,
         )]);
         connection.prepare(delay: const Duration(seconds: 1), json:
-          UploadFileResult(uri: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson());
+          UploadFileResult(url: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson());

         await tester.tap(find.byIcon(ZulipIcons.image));
         await tester.pump();
@@ -1129,7 +1129,7 @@ void main() {
           path: '/private/var/mobile/Containers/Data/Application/foo/tmp/image.jpg',
         );
         connection.prepare(delay: const Duration(seconds: 1), json:
-          UploadFileResult(uri: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson());
+          UploadFileResult(url: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson());

         await tester.tap(find.byIcon(ZulipIcons.camera));
         await tester.pump();
@@ -1168,6 +1168,34 @@ void main() {
     // target platform the test is simulating.
     // TODO(upstream): unskip after fix to https://github.com/flutter/flutter/issues/161073
     skip: Platform.isWindows);
+
+    testWidgets('use verbatim URL string from server, not re-encoded', (tester) async {
+      // Regression test for: https://github.com/zulip/zulip-flutter/issues/1709
+      TypingNotifier.debugEnable = false;
+      addTearDown(TypingNotifier.debugReset);
+
+      final channel = eg.stream();
+      final narrow = eg.topicNarrow(channel.streamId, 'a topic');
+      await prepareComposeBox(tester, narrow: narrow, streams: [channel]);
+
+      testBinding.pickFilesResult = FilePickerResult([PlatformFile(
+        readStream: Stream.fromIterable(['asdf'.codeUnits]),
+        path: '/some/path/한국어 파일.txt',
+        name: '한국어 파일.txt',
+        size: 4,
+      )]);
+      connection.prepare(json: UploadFileResult(url:
+        '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/한국어 파일.txt').toJson());
+      await tester.tap(find.byIcon(ZulipIcons.image));
+      await tester.pump();
+      check(controller!.content.text)
+        .equals('[Uploading 한국어 파일.txt…]()\n\n');
+
+      await tester.pump(Duration.zero);
+      check(controller!.content.text)
+        .equals('[한국어 파일.txt]('
+          '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/한국어 파일.txt)\n\n');
+    });
   });

   group('error banner', () {
@@ -1833,7 +1861,7 @@ void main() {
         testBinding.pickFilesResult = FilePickerResult([
           PlatformFile(name: 'file.jpg', size: 1000, readStream: Stream.fromIterable(['asdf'.codeUnits]))]);
         connection.prepare(json:
-          UploadFileResult(uri: '/path/file.jpg').toJson());
+          UploadFileResult(url: '/path/file.jpg').toJson());
         await tester.tap(find.byIcon(ZulipIcons.attach_file), warnIfMissed: false);
         await tester.pump(Duration.zero);
         checkNoErrorDialog(tester);


autocomplete: Support user-group mentions; match users by email; rank results by match quality

Fixes #236.
Fixes #233.

Screenshots coming soon.

autocomplete: Support user-group @-mentions
With #207 being merged, we now have an @-mention autocomplete UI for user mentions! 🎉

We should extend it to support autocompleting user-_group_ mentions, like `@*mobile*`.

### Related issues

- Prerequisite: #662
autocomplete: Filter user-autocomplete results according to email match, not just name
Currently, typing `@cbobbe` in the compose box gives no user-mention autocomplete results, even though my user's email address starts with `cbobbe`. We should include a user in the results if the query matches the user's email address.

When implementing this, we should be sure to exclude deactivated groups from the results.

Until recently, such groups weren't included in the data the server gave us in the first place. Now they are:
* https://chat.zulip.org/#narrow/stream/378-api-design/topic/deactivated.20groups.20in.20.60GET.60.20endpoints/near/1940339
* https://github.com/zulip/zulip-flutter/issues/662#issuecomment-2344818170
* https://github.com/zulip/zulip/pull/30504

Requested on CZO as a "mini-blocker" for a user's team: [#mobile > Flutter app: group tags @ 💬](https://chat.zulip.org/#narrow/channel/48-mobile/topic/Flutter.20app.3A.20group.20tags/near/2196872)
Requested [in the Rust community](https://rust-lang.zulipchat.com/#narrow/channel/122653-zulip/topic/new.20mobile.20app.20feedback/near/526415430).
We should show the email in the popup as well, at least when the user's email matches the query but the name doesn't

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

diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart
index fe344189de..0b17f50b27 100644
--- a/lib/model/autocomplete.dart
+++ b/lib/model/autocomplete.dart
@@ -8,11 +8,11 @@ import '../api/model/model.dart';
 import '../api/route/channels.dart';
 import '../generated/l10n/zulip_localizations.dart';
 import '../widgets/compose_box.dart';
+import 'algorithms.dart';
 import 'compose.dart';
 import 'emoji.dart';
 import 'narrow.dart';
 import 'store.dart';
-import 'user.dart';

 extension ComposeContentAutocomplete on ComposeContentController {
   AutocompleteIntent<ComposeAutocompleteQuery>? autocompleteIntent() {
@@ -249,6 +249,14 @@ class AutocompleteViewManager {
     autocompleteDataCache.invalidateUser(event.userId);
   }

+  void handleUserGroupRemoveEvent(UserGroupRemoveEvent event) {
+    autocompleteDataCache.invalidateUserGroup(event.groupId);
+  }
+
+  void handleUserGroupUpdateEvent(UserGroupUpdateEvent event) {
+    autocompleteDataCache.invalidateUserGroup(event.groupId);
+  }
+
   /// Called when the app is reassembled during debugging, e.g. for hot reload.
   ///
   /// Calls [AutocompleteView.reassemble] for all that are registered.
@@ -423,6 +431,7 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
     required this.localizations,
     required this.narrow,
     required this.sortedUsers,
+    required this.sortedUserGroups,
   });

   factory MentionAutocompleteView.init({
@@ -437,6 +446,7 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
       localizations: localizations,
       narrow: narrow,
       sortedUsers: _usersByRelevance(store: store, narrow: narrow),
+      sortedUserGroups: _userGroupsByRelevance(store: store),
     );
     store.autocompleteViewManager.registerMentionAutocomplete(view);
     return view;
@@ -444,6 +454,7 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,

   final Narrow narrow;
   final List<User> sortedUsers;
+  final List<UserGroup> sortedUserGroups;
   final ZulipLocalizations localizations;

   static List<User> _usersByRelevance({
@@ -455,18 +466,23 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
   }

   /// Compare the users the same way they would be sorted as
-  /// autocomplete candidates.
+  /// autocomplete candidates, given [query].
+  ///
+  /// The users must both match the query.
   ///
   /// This behaves the same as the comparator used for sorting in
-  /// [_usersByRelevance], but calling this for each comparison would be a bit
-  /// less efficient because some of the logic is independent of the users and
-  /// can be precomputed.
+  /// [_usersByRelevance], combined with the ranking applied at the end
+  /// of [computeResults].
   ///
   /// This is useful for tests in order to distinguish "A comes before B"
   /// from "A ranks equal to B, and the sort happened to put A before B",
   /// particularly because [List.sort] makes no guarantees about the order
   /// of items that compare equal.
   int debugCompareUsers(User userA, User userB) {
+    final rankA = query.testUser(userA, store)!.rank;
+    final rankB = query.testUser(userB, store)!.rank;
+    if (rankA != rankB) return rankA.compareTo(rankB);
+
     return _comparator(store: store, narrow: narrow)(userA, userB);
   }

@@ -474,6 +490,9 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
     required PerAccountStore store,
     required Narrow narrow,
   }) {
+    // See also [MentionAutocompleteQuery._rankUserResult];
+    // that ranking takes precedence over this.
+
     int? streamId;
     TopicName? topic;
     switch (narrow) {
@@ -603,6 +622,33 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
     return userAName.compareTo(userBName); // TODO(i18n): add locale-aware sorting
   }

+  static List<UserGroup> _userGroupsByRelevance({required PerAccountStore store}) {
+    return store.activeGroups
+      // TODO(#1776) Follow new "Who can mention this group" setting instead
+      .where((userGroup) => !userGroup.isSystemGroup)
+      .toList()
+      ..sort(_userGroupComparator(store: store));
+  }
+
+  static int Function(UserGroup, UserGroup) _userGroupComparator({
+    required PerAccountStore store,
+  }) {
+    // See also [MentionAutocompleteQuery._rankUserGroupResult];
+    // that ranking takes precedence over this.
+
+    return (userGroupA, userGroupB) =>
+      compareGroupsByAlphabeticalOrder(userGroupA, userGroupB, store: store);
+  }
+
+  static int compareGroupsByAlphabeticalOrder(UserGroup userGroupA, UserGroup userGroupB,
+      {required PerAccountStore store}) {
+    final groupAName = store.autocompleteViewManager.autocompleteDataCache
+      .lowercaseNameForUserGroup(userGroupA);
+    final groupBName = store.autocompleteViewManager.autocompleteDataCache
+      .lowercaseNameForUserGroup(userGroupB);
+    return groupAName.compareTo(groupBName); // TODO(i18n): add locale-aware sorting
+  }
+
   void computeWildcardMentionResults({
     required List<MentionAutocompleteResult> results,
     required bool isComposingChannelMessage,
@@ -610,11 +656,10 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
     if (query.silent) return;

     bool tryOption(WildcardMentionOption option) {
-      if (query.testWildcardOption(option, localizations: localizations)) {
-        results.add(WildcardMentionAutocompleteResult(wildcardOption: option));
-        return true;
-      }
-      return false;
+      final result = query.testWildcardOption(option, localizations: localizations);
+      if (result == null) return false;
+      results.add(result);
+      return true;
     }

     // Only one of the (all, everyone, channel, stream) channel wildcards are
@@ -637,23 +682,31 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,

   @override
   Future<List<MentionAutocompleteResult>?> computeResults() async {
-    final results = <MentionAutocompleteResult>[];
+    final unsorted = <MentionAutocompleteResult>[];
     // Give priority to wildcard mentions.
-    computeWildcardMentionResults(results: results,
+    computeWildcardMentionResults(results: unsorted,
       isComposingChannelMessage: narrow is ChannelNarrow || narrow is TopicNarrow);

     if (await filterCandidates(filter: _testUser,
-        candidates: sortedUsers, results: results)) {
+        candidates: sortedUsers, results: unsorted)) {
       return null;
     }
-    return results;
+
+    if (await filterCandidates(filter: _testUserGroup,
+        candidates: sortedUserGroups, results: unsorted)) {
+      return null;
+    }
+
+    return bucketSort(unsorted,
+      (r) => r.rank, numBuckets: MentionAutocompleteQuery._numResultRanks);
   }

   MentionAutocompleteResult? _testUser(MentionAutocompleteQuery query, User user) {
-    if (query.testUser(user, store.autocompleteViewManager.autocompleteDataCache, store)) {
-      return UserMentionAutocompleteResult(userId: user.userId);
-    }
-    return null;
+    return query.testUser(user, store);
+  }
+
+  MentionAutocompleteResult? _testUserGroup(MentionAutocompleteQuery query, UserGroup userGroup) {
+    return query.testUserGroup(userGroup, store);
   }

   @override
@@ -695,11 +748,15 @@ abstract class AutocompleteQuery {

   late final List<String> _lowercaseWords;

-  /// Whether all of this query's words have matches in [words] that appear in order.
+  /// Whether all of this query's words have matches in [words],
+  /// modulo case, that appear in order.
   ///
   /// A "match" means the word in [words] starts with the query word.
+  ///
+  /// [words] must all be lowercased.
   bool _testContainsQueryWords(List<String> words) {
-    // TODO(#237) test with diacritics stripped, where appropriate
+    // TODO(#237) test with diacritics stripped, where appropriate,
+    //   and update dartdoc's summary line and its restriction about [words].
     int wordsIndex = 0;
     int queryWordsIndex = 0;
     while (true) {
@@ -718,6 +775,24 @@ abstract class AutocompleteQuery {
   }
 }

+/// The match quality of a [User.fullName] or [UserGroup.name]
+/// to a mention autocomplete query.
+///
+/// All matches are case-insensitive.
+enum NameMatchQuality {
+  /// The query matches the whole name exactly.
+  exact,
+
+  /// The name starts with the query.
+  totalPrefix,
+
+  /// All of the query's words have matches in the words of the name
+  /// that appear in order.
+  ///
+  /// A "match" means the word in the name starts with the query word.
+  wordPrefixes,
+}
+
 /// Any autocomplete query in the compose box's content input.
 abstract class ComposeAutocompleteQuery extends AutocompleteQuery {
   ComposeAutocompleteQuery(super.raw);
@@ -748,25 +823,122 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
       store: store, localizations: localizations, narrow: narrow, query: this);
   }

-  bool testWildcardOption(WildcardMentionOption wildcardOption, {
+  WildcardMentionAutocompleteResult? testWildcardOption(WildcardMentionOption wildcardOption, {
       required ZulipLocalizations localizations}) {
     // TODO(#237): match insensitively to diacritics
-    return wildcardOption.canonicalString.contains(_lowercase)
+    final matches = wildcardOption.canonicalString.contains(_lowercase)
       || wildcardOption.localizedCanonicalString(localizations).contains(_lowercase);
+    if (!matches) return null;
+    return WildcardMentionAutocompleteResult(
+      wildcardOption: wildcardOption, rank: _rankWildcardResult);
   }

-  bool testUser(User user, AutocompleteDataCache cache, UserStore store) {
-    if (!user.isActive) return false;
-    if (store.isUserMuted(user.userId)) return false;
+  MentionAutocompleteResult? testUser(User user, PerAccountStore store) {
+    if (!user.isActive) return null;
+    if (store.isUserMuted(user.userId)) return null;
+
+    final cache = store.autocompleteViewManager.autocompleteDataCache;
+    final nameMatchQuality = _matchName(
+      normalizedName: cache.normalizedNameForUser(user),
+      normalizedNameWords: cache.normalizedNameWordsForUser(user));
+    bool? matchesEmail;
+    if (nameMatchQuality == null) {
+      matchesEmail = _matchEmail(user, cache);
+      if (!matchesEmail) return null;
+    }

-    // TODO(#236) test email too, not just name
-    return _testName(user, cache);
+    return UserMentionAutocompleteResult(
+      userId: user.userId,
+      rank: _rankUserResult(user,
+        nameMatchQuality: nameMatchQuality, matchesEmail: matchesEmail));
   }

-  bool _testName(User user, AutocompleteDataCache cache) {
-    return _testContainsQueryWords(cache.nameWordsForUser(user));
+  NameMatchQuality? _matchName({
+    required String normalizedName,
+    required List<String> normalizedNameWords,
+  }) {
+    if (normalizedName.startsWith(_lowercase)) {
+      if (normalizedName.length == _lowercase.length) {
+        return NameMatchQuality.exact;
+      } else {
+        return NameMatchQuality.totalPrefix;
+      }
+    }
+
+    if (_testContainsQueryWords(normalizedNameWords)) {
+      return NameMatchQuality.wordPrefixes;
+    }
+
+    return null;
   }

+  bool _matchEmail(User user, AutocompleteDataCache cache) {
+    final lowercaseEmail = cache.normalizedEmailForUser(user);
+    if (lowercaseEmail == null) return false; // Email not known
+    return lowercaseEmail.startsWith(_lowercase);
+  }
+
+  MentionAutocompleteResult? testUserGroup(UserGroup userGroup, PerAccountStore store) {
+    final cache = store.autocompleteViewManager.autocompleteDataCache;
+
+    final nameMatchQuality = _matchName(
+      normalizedName: cache.lowercaseNameForUserGroup(userGroup),
+      normalizedNameWords: cache.lowercaseNameWordsForUserGroup(userGroup));
+
+    if (nameMatchQuality == null) return null;
+
+    return UserGroupMentionAutocompleteResult(
+      groupId: userGroup.id,
+      rank: _rankUserGroupResult(userGroup, nameMatchQuality: nameMatchQuality));
+  }
+
+  /// A measure of a wildcard result's quality in the context of the query,
+  /// from 0 (best) to one less than [_numResultRanks].
+  ///
+  /// See also [_rankUserResult] and [_rankUserGroupResult].
+  static const _rankWildcardResult = 0;
+
+  /// A measure of a user result's quality in the context of the query,
+  /// from 0 (best) to one less than [_numResultRanks].
+  ///
+  /// When [nameMatchQuality] is non-null (the name matches),
+  /// callers should skip computing [matchesEmail] and pass null for that.
+  ///
+  /// See also [_rankWildcardResult] and [_rankUserGroupResult].
+  static int _rankUserResult(User user, {
+    required NameMatchQuality? nameMatchQuality,
+    required bool? matchesEmail,
+  }) {
+    if (nameMatchQuality != null) {
+      assert(matchesEmail == null);
+      return switch (nameMatchQuality) {
+        NameMatchQuality.exact =>        1,
+        NameMatchQuality.totalPrefix =>  2,
+        NameMatchQuality.wordPrefixes => 3,
+      };
+    }
+    assert(matchesEmail == true);
+    return 7;
+  }
+
+  /// A measure of a user-group result's quality in the context of the query,
+  /// from 0 (best) to one less than [_numResultRanks].
+  ///
+  /// See also [_rankWildcardResult] and [_rankUserResult].
+  static int _rankUserGroupResult(UserGroup userGroup, {
+    required NameMatchQuality nameMatchQuality,
+  }) {
+    return switch (nameMatchQuality) {
+      NameMatchQuality.exact =>        4,
+      NameMatchQuality.totalPrefix =>  5,
+      NameMatchQuality.wordPrefixes => 6,
+    };
+  }
+
+  /// The number of possible values returned by
+  /// [_rankWildcardResult], [_rankUserResult], and [_rankUserGroupResult]..
+  static const _numResultRanks = 8;
+
   @override
   String toString() {
     return '${objectRuntimeType(this, 'MentionAutocompleteQuery')}(raw: $raw, silent: $silent})';
@@ -801,20 +973,48 @@ extension WildcardMentionOptionExtension on WildcardMentionOption {
 class AutocompleteDataCache {
   final Map<int, String> _normalizedNamesByUser = {};

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

-  final Map<int, List<String>> _nameWordsByUser = {};
+  final Map<int, List<String>> _normalizedNameWordsByUser = {};
+
+  List<String> normalizedNameWordsForUser(User user) {
+    return _normalizedNameWordsByUser[user.userId]
+      ??= normalizedNameForUser(user).split(' ');
+  }
+
+  final Map<int, String?> _normalizedEmailsByUser = {};
+
+  /// The normalized `deliveryEmail` of [user], or null if that's null.
+  String? normalizedEmailForUser(User user) {
+    return _normalizedEmailsByUser[user.userId] ??= user.deliveryEmail?.toLowerCase();
+  }
+
+  final Map<int, String> _lowercaseNamesByUserGroup = {};
+
+  /// The normalized `name` of [userGroup].
+  String lowercaseNameForUserGroup(UserGroup userGroup) {
+    return _lowercaseNamesByUserGroup[userGroup.id] ??= userGroup.name.toLowerCase();
+  }
+
+  final Map<int, List<String>> _lowercaseNameWordsByUserGroup = {};

-  List<String> nameWordsForUser(User user) {
-    return _nameWordsByUser[user.userId] ??= normalizedNameForUser(user).split(' ');
+  List<String> lowercaseNameWordsForUserGroup(UserGroup userGroup) {
+    return _lowercaseNameWordsByUserGroup[userGroup.id]
+      ??= lowercaseNameForUserGroup(userGroup).split(' ');
   }

   void invalidateUser(int userId) {
     _normalizedNamesByUser.remove(userId);
-    _nameWordsByUser.remove(userId);
+    _normalizedNameWordsByUser.remove(userId);
+    _normalizedEmailsByUser.remove(userId);
+  }
+
+  void invalidateUserGroup(int id) {
+    _lowercaseNamesByUserGroup.remove(id);
+    _lowercaseNameWordsByUserGroup.remove(id);
   }
 }

@@ -853,23 +1053,72 @@ class EmojiAutocompleteResult extends ComposeAutocompleteResult {
 /// This is abstract because there are several kinds of result
 /// that can all be offered in the same @-mention autocomplete interaction:
 /// a user, a wildcard, or a user group.
-sealed class MentionAutocompleteResult extends ComposeAutocompleteResult {}
+sealed class MentionAutocompleteResult extends ComposeAutocompleteResult {
+  /// A measure of the result's quality in the context of the query.
+  ///
+  /// Used internally by [MentionAutocompleteView] for ranking the results.
+  // See also [MentionAutocompleteView._usersByRelevance];
+  // results with equal [rank] will appear in the order they were put in
+  // by that method.
+  //
+  // Compare sort_recipients in Zulip web:
+  //   https://github.com/zulip/zulip/blob/afdf20c67/web/src/typeahead_helper.ts#L472
+  //
+  // Behavior we have that web doesn't and might like to follow:
+  // - A "word-prefixes" match quality on user and user-group names:
+  //   see [NameMatchQuality.wordPrefixes], which we rank on.
+  //
+  // Behavior web has that seems undesired, which we don't plan to follow:
+  // - Ranking humans above bots, even when the bots have higher relevance
+  //   and better match quality. If there's a bot participating in the
+  //   current conversation and I start typing its name, why wouldn't we want
+  //   that as a top result? Issue: https://github.com/zulip/zulip/issues/35467
+  // - A "word-boundary" match quality on user and user-group names:
+  //   special rank when the whole query appears contiguously
+  //   right after a word-boundary character.
+  //   Our [NameMatchQuality.wordPrefixes] seems smarter.
+  // - An "exact" match quality on emails: probably not worth its complexity.
+  //   Emails are much more uniform in their endings than users' names are,
+  //   so a prefix match should be adequate. (If I've typed "email@example.co",
+  //   that'll probably be the only result. There might be an "email@example.com",
+  //   and an "exact" match would downrank that, but still that's just two items
+  //   to scan through.)
+  // - A "word-boundary" match quality on user emails:
+  //   "words" is a wrong abstraction when matching on emails.
+  // - Ranking some case-sensitive matches differently from case-insensitive
+  //   matches. Users will expect a lowercase query to be adequate.
+  int get rank;
+}

 /// An autocomplete result for an @-mention of an individual user.
 class UserMentionAutocompleteResult extends MentionAutocompleteResult {
-  UserMentionAutocompleteResult({required this.userId});
+  UserMentionAutocompleteResult({required this.userId, required this.rank});

   final int userId;
+
+  @override
+  final int rank;
 }

 /// An autocomplete result for an @-mention of all the users in a conversation.
 class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
-  WildcardMentionAutocompleteResult({required this.wildcardOption});
+  WildcardMentionAutocompleteResult({required this.wildcardOption, required this.rank});

   final WildcardMentionOption wildcardOption;
+
+  @override
+  final int rank;
 }

-// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
+/// An autocomplete result for an @-mention of a user group.
+class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
+  UserGroupMentionAutocompleteResult({required this.groupId, required this.rank});
+
+  final int groupId;
+
+  @override
+  final int rank;
+}

 /// An autocomplete interaction for choosing a topic for a message.
 class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, TopicAutocompleteResult> {
diff --git a/lib/model/compose.dart b/lib/model/compose.dart
index 09b02cb08c..3a7a75976f 100644
--- a/lib/model/compose.dart
+++ b/lib/model/compose.dart
@@ -181,6 +181,10 @@ String wildcardMention(WildcardMentionOption wildcardOption, {
   return '@**$name**';
 }

+/// An @-mention of a user group, like @*mobile*.
+String userGroupMention(String userGroupName, {bool silent = false}) =>
+  '@${silent ? '_' : ''}*$userGroupName*';
+
 /// https://spec.commonmark.org/0.30/#inline-link
 ///
 /// The "link text" is made by enclosing [visibleText] in square brackets.
diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart
index 350eb86d56..5c853d2da0 100644
--- a/lib/widgets/autocomplete.dart
+++ b/lib/widgets/autocomplete.dart
@@ -202,12 +202,30 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
         if (query is! MentionAutocompleteQuery) {
           return; // Shrug; similar to `intent == null` case above.
         }
-        final user = store.getUser(userId)!; // must exist because UserMentionAutocompleteResult
+        final user = store.getUser(userId);
+        if (user == null) {
+          // Don't crash on theoretical race between async results-filtering
+          // and losing data for the user.
+          return;
+        }
         // TODO(i18n) language-appropriate space character; check active keyboard?
         //   (maybe handle centrally in `controller`)
         replacementString = '${userMention(user, silent: query.silent, users: store)} ';
       case WildcardMentionAutocompleteResult(:var wildcardOption):
         replacementString = '${wildcardMention(wildcardOption, store: store)} ';
+      case UserGroupMentionAutocompleteResult(:final groupId):
+        if (query is! MentionAutocompleteQuery) {
+          return; // Shrug; similar to `intent == null` case above.
+        }
+        final userGroup = store.getGroup(groupId);
+        if (userGroup == null) {
+          // Don't crash on theoretical race between async results-filtering
+          // and losing data for the group.
+          return;
+        }
+        // TODO(i18n) language-appropriate space character; check active keyboard?
+        //   (maybe handle centrally in `controller`)
+        replacementString = '${userGroupMention(userGroup.name, silent: query.silent)} ';
     }

     controller.value = intent.textEditingValue.replaced(
@@ -286,6 +304,16 @@ class MentionAutocompleteItem extends StatelessWidget {
         emoji = UserStatusEmoji(userId: userId, size: 18,
           padding: const EdgeInsetsDirectional.only(start: 5.0));
         sublabel = store.getUser(userId)?.deliveryEmail;
+      case UserGroupMentionAutocompleteResult(:final groupId):
+        final group = store.getGroup(groupId);
+        avatar = SizedBox.square(dimension: 36,
+          child: const Icon(ZulipIcons.three_person, size: 24));
+        label = group?.name
+          // Don't crash on theoretical race between async results-filtering
+          // and losing data for the group.
+          ?? '';
+        emoji = null;
+        sublabel = group?.description;
       case WildcardMentionAutocompleteResult(:var wildcardOption):
         avatar = SizedBox.square(dimension: 36,
           child: const Icon(ZulipIcons.three_person, size: 24));

diff --git a/test/model/autocomplete_checks.dart b/test/model/autocomplete_checks.dart
index ec8acbe500..cb08378e77 100644
--- a/test/model/autocomplete_checks.dart
+++ b/test/model/autocomplete_checks.dart
@@ -1,6 +1,7 @@
 import 'package:checks/checks.dart';
 import 'package:zulip/api/model/model.dart';
 import 'package:zulip/model/autocomplete.dart';
+import 'package:zulip/model/compose.dart';
 import 'package:zulip/widgets/compose_box.dart';

 extension ComposeContentControllerChecks on Subject<ComposeContentController> {
@@ -20,6 +21,14 @@ extension UserMentionAutocompleteResultChecks on Subject<UserMentionAutocomplete
   Subject<int> get userId => has((r) => r.userId, 'userId');
 }

+extension WildcardMentionAutocompleteResultChecks on Subject<WildcardMentionAutocompleteResult> {
+  Subject<WildcardMentionOption> get wildcardOption => has((x) => x.wildcardOption, 'wildcardOption');
+}
+
+extension UserGroupMentionAutocompleteResultChecks on Subject<UserGroupMentionAutocompleteResult> {
+  Subject<int> get groupId => has((r) => r.groupId, 'groupId');
+}
+
 extension TopicAutocompleteResultChecks on Subject<TopicAutocompleteResult> {
   Subject<TopicName> get topic => has((r) => r.topic, 'topic');
 }
diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart
index b95bb58ccb..92e5a81d9a 100644
--- a/test/model/autocomplete_test.dart
+++ b/test/model/autocomplete_test.dart
@@ -320,21 +320,39 @@ void main() {
     for (int i = 1; i <= 2500; i++) {
       await store.addUser(eg.user(userId: i, email: 'user$i@example.com', fullName: 'User $i'));
     }
+    for (int i = 1; i <= 2500; i++) {
+      await store.addUserGroup(eg.userGroup(id: i, name: 'User Group $i'));
+    }

     bool done = false;
     final view = MentionAutocompleteView.init(store: store, localizations: zulipLocalizations,
       narrow: narrow, query: MentionAutocompleteQuery('User 2222'));
     view.addListener(() { done = true; });

+    // three batches for users
+    await Future(() {});
+    check(done).isFalse();
+    await Future(() {});
+    check(done).isFalse();
+    await Future(() {});
+    check(done).isFalse();
+
+    // three batches for user groups
     await Future(() {});
     check(done).isFalse();
     await Future(() {});
     check(done).isFalse();
     await Future(() {});
     check(done).isTrue();
-    check(view.results).single
-      .isA<UserMentionAutocompleteResult>()
-      .userId.equals(2222);
+
+    check(view.results).deepEquals(<Condition<Object?>>[
+      (it) => it
+        .isA<UserMentionAutocompleteResult>()
+        .userId.equals(2222),
+      (it) => it
+        .isA<UserGroupMentionAutocompleteResult>()
+        .groupId.equals(2222),
+    ]);
   });

   test('MentionAutocompleteView new query during computation replaces old', () async {
@@ -343,6 +361,9 @@ void main() {
     for (int i = 1; i <= 1500; i++) {
       await store.addUser(eg.user(userId: i, email: 'user$i@example.com', fullName: 'User $i'));
     }
+    for (int i = 1; i <= 1500; i++) {
+      await store.addUserGroup(eg.userGroup(id: i, name: 'User Group $i'));
+    }

     bool done = false;
     final view = MentionAutocompleteView.init(store: store, localizations: zulipLocalizations,
@@ -353,21 +374,33 @@ void main() {
     check(done).isFalse();
     view.query = MentionAutocompleteQuery('User 234');

-    // …new query goes through all batches
+    // …new query goes through all user batches
+    await Future(() {});
+    check(done).isFalse();
+    await Future(() {});
+    check(done).isFalse();
+    // …and all user-group batches
     await Future(() {});
     check(done).isFalse();
     await Future(() {});
     check(done).isTrue(); // new result is set
-    check(view.results).single
-      .isA<UserMentionAutocompleteResult>()
-      .userId.equals(234);
+
+    void checkResult() {
+      check(view.results).deepEquals(<Condition<Object?>>[
+        (it) => it
+          .isA<UserMentionAutocompleteResult>()
+          .userId.equals(234),
+        (it) => it
+          .isA<UserGroupMentionAutocompleteResult>()
+          .groupId.equals(234),
+      ]);
+    }
+    checkResult();

     // new result sticks; it isn't clobbered with old query's result
     for (int i = 0; i < 10; i++) { // for good measure
       await Future(() {});
-      check(view.results).single
-        .isA<UserMentionAutocompleteResult>()
-        .userId.equals(234);
+      checkResult();
     }
   });

@@ -403,76 +436,19 @@ void main() {
       ..not((results) => results.contains(11000));
   });

-  group('MentionAutocompleteQuery.testUser', () {
-    late PerAccountStore store;
-
-    void doCheck(String rawQuery, User user, bool expected) {
-      final result = MentionAutocompleteQuery(rawQuery)
-        .testUser(user, AutocompleteDataCache(), store);
-      expected ? check(result).isTrue() : check(result).isFalse();
-    }
-
-    test('user is always excluded when not active regardless of other criteria', () {
-      store = eg.store();
-
-      doCheck('Full Name', eg.user(fullName: 'Full Name', isActive: false), false);
-      // When active then other criteria will be checked
-      doCheck('Full Name', eg.user(fullName: 'Full Name', isActive: true), true);
-    });
-
-    test('user is always excluded when muted, regardless of other criteria', () async {
-      store = eg.store();
-      await store.setMutedUsers([1]);
-      doCheck('Full Name', eg.user(userId: 1, fullName: 'Full Name'), false);
-      // When not muted, then other criteria will be checked
-      doCheck('Full Name', eg.user(userId: 2, fullName: 'Full Name'), true);
-    });
-
-    test('user is included if fullname words match the query', () {
-      store = eg.store();
-
-      doCheck('', eg.user(fullName: 'Full Name'), true);
-      doCheck('', eg.user(fullName: ''), true); // Unlikely case, but should not crash
-      doCheck('Full Name', eg.user(fullName: 'Full Name'), true);
-      doCheck('full name', eg.user(fullName: 'Full Name'), true);
-      doCheck('Full Name', eg.user(fullName: 'full name'), true);
-      doCheck('Full', eg.user(fullName: 'Full Name'), true);
-      doCheck('Name', eg.user(fullName: 'Full Name'), true);
-      doCheck('Full Name', eg.user(fullName: 'Fully Named'), true);
-      doCheck('Full Four', eg.user(fullName: 'Full Name Four Words'), true);
-      doCheck('Name Words', eg.user(fullName: 'Full Name Four Words'), true);
-      doCheck('Full F', eg.user(fullName: 'Full Name Four Words'), true);
-      doCheck('F Four', eg.user(fullName: 'Full Name Four Words'), true);
-      doCheck('full full', eg.user(fullName: 'Full Full Name'), true);
-      doCheck('full full', eg.user(fullName: 'Full Name Full'), true);
-
-      doCheck('F', eg.user(fullName: ''), false); // Unlikely case, but should not crash
-      doCheck('Fully Named', eg.user(fullName: 'Full Name'), false);
-      doCheck('Full Name', eg.user(fullName: 'Full'), false);
-      doCheck('Full Name', eg.user(fullName: 'Name'), false);
-      doCheck('ull ame', eg.user(fullName: 'Full Name'), false);
-      doCheck('ull Name', eg.user(fullName: 'Full Name'), false);
-      doCheck('Full ame', eg.user(fullName: 'Full Name'), false);
-      doCheck('Full Full', eg.user(fullName: 'Full Name'), false);
-      doCheck('Name Name', eg.user(fullName: 'Full Name'), false);
-      doCheck('Name Full', eg.user(fullName: 'Full Name'), false);
-      doCheck('Name Four Full Words', eg.user(fullName: 'Full Name Four Words'), false);
-      doCheck('F Full', eg.user(fullName: 'Full Name Four Words'), false);
-      doCheck('Four F', eg.user(fullName: 'Full Name Four Words'), false);
-    });
-  });
-
-  group('MentionAutocompleteView sorting users results', () {
+  group('MentionAutocompleteView sorting results', () {
     late PerAccountStore store;

     Future<void> prepare({
       List<User> users = const [],
+      List<UserGroup> userGroups = const [],
       List<RecentDmConversation> dmConversations = const [],
       List<Message> messages = const [],
     }) async {
       store = eg.store(initialSnapshot: eg.initialSnapshot(
         recentPrivateConversations: dmConversations));
       await store.addUsers(users);
+      await store.addUserGroups(userGroups);
       await store.addMessages(messages);
     }

@@ -805,18 +781,28 @@ void main() {
         final view = MentionAutocompleteView.init(store: store,
           localizations: zulipLocalizations, narrow: narrow, query: query);
         view.addListener(() { done = true; });
-        await Future(() {});
+        await Future(() {}); // users
+        await Future(() {}); // groups
         check(done).isTrue();
         final results = view.results;
         view.dispose();
         return results;
       }

-      Iterable<int> getUsersFromResults(Iterable<MentionAutocompleteResult> results)
-        => results.map((e) => (e as UserMentionAutocompleteResult).userId);
+      Condition<Object?> isUser(int userId) {
+        return (it) => it.isA<UserMentionAutocompleteResult>()
+          .userId.equals(userId);
+      }
+
+      Condition<Object?> isUserGroup(int id) {
+        return (it) => it.isA<UserGroupMentionAutocompleteResult>()
+          .groupId.equals(id);
+      }

-      Iterable<WildcardMentionOption> getWildcardOptionsFromResults(Iterable<MentionAutocompleteResult> results)
-        => results.map((e) => (e as WildcardMentionAutocompleteResult).wildcardOption);
+      Condition<Object?> isWildcard(WildcardMentionOption option) {
+        return (it) => it.isA<WildcardMentionAutocompleteResult>()
+          .wildcardOption.equals(option);
+      }

       final stream = eg.stream();
       const topic = 'topic';
@@ -832,36 +818,48 @@ void main() {
         eg.user(userId: 7, fullName: 'User Seven'),
       ];

-      await prepare(users: users, messages: [
-        eg.streamMessage(id: 50, sender: users[1-1], stream: stream, topic: topic),
-        eg.streamMessage(id: 60, sender: users[5-1], stream: stream, topic: 'other $topic'),
-      ], dmConversations: [
-        RecentDmConversation(userIds: [4],    maxMessageId: 300),
-        RecentDmConversation(userIds: [1],    maxMessageId: 200),
-        RecentDmConversation(userIds: [1, 2], maxMessageId: 100),
+      final userGroups = [
+        eg.userGroup(id: 1, name: 'User Group One'),
+        eg.userGroup(id: 2, name: 'User Group Two'),
+        eg.userGroup(id: 3, name: 'User Group Three'),
+        eg.userGroup(id: 4, name: 'User Group Four'),
+      ];
+
+      await prepare(users: users, userGroups: userGroups, messages: [
+        eg.streamMessage(sender: users[1-1], stream: stream, topic: topic),
+        eg.streamMessage(sender: users[5-1], stream: stream, topic: 'other $topic'),
+        eg.dmMessage(from: users[1-1], to: [users[2-1], eg.selfUser]),
+        eg.dmMessage(from: users[1-1], to: [eg.selfUser]),
+        eg.dmMessage(from: users[4-1], to: [eg.selfUser]),
       ]);

-      // Check the ranking of the full list of mentions.
+      // Check the ranking of the full list of mentions,
+      // i.e. the results for an empty query.
       // The order should be:
-      // 1. Wildcards before individual users.
+      // 1. Wildcards before individual users; user groups (alphabetically) after.
       // 2. Users most recent in the current topic/stream.
       // 3. Users most recent in the DM conversations.
       // 4. Human vs. Bot users (human users come first).
       // 5. Users by name alphabetical order.
-      final results1 = await getResults(topicNarrow, MentionAutocompleteQuery(''));
-      check(getWildcardOptionsFromResults(results1.take(2)))
-        .deepEquals([WildcardMentionOption.all, WildcardMentionOption.topic]);
-      check(getUsersFromResults(results1.skip(2)))
-        .deepEquals([1, 5, 4, 2, 7, 3, 6]);
+      // 6. User groups by name alphabetical order.
+      check(await getResults(topicNarrow, MentionAutocompleteQuery(''))).deepEquals([
+        isWildcard(WildcardMentionOption.all),
+        isWildcard(WildcardMentionOption.topic),
+        ...[1, 5, 4, 2, 7, 3, 6].map(isUser),
+        ...[4, 1, 3, 2].map(isUserGroup),
+      ]);

       // Check the ranking applies also to results filtered by a query.
-      final results2 = await getResults(topicNarrow, MentionAutocompleteQuery('t'));
-      check(getWildcardOptionsFromResults(results2.take(2)))
-        .deepEquals([WildcardMentionOption.stream, WildcardMentionOption.topic]);
-      check(getUsersFromResults(results2.skip(2))).deepEquals([2, 3]);
-      final results3 = await getResults(topicNarrow, MentionAutocompleteQuery('f'));
-      check(getWildcardOptionsFromResults(results3.take(0))).deepEquals([]);
-      check(getUsersFromResults(results3.skip(0))).deepEquals([5, 4]);
+      check(await getResults(topicNarrow, MentionAutocompleteQuery('t'))).deepEquals([
+        isWildcard(WildcardMentionOption.stream),
+        isWildcard(WildcardMentionOption.topic),
+        isUser(2), isUser(3), // 2 before 3 by DM recency
+        isUserGroup(3), isUserGroup(2), // 3 before 2 by alphabet ("…Three" before "…Two")
+      ]);
+      check(await getResults(topicNarrow, MentionAutocompleteQuery('f'))).deepEquals([
+        isUser(5), isUser(4),
+        isUserGroup(4),
+      ]);
     });
   });

@@ -971,6 +969,187 @@ void main() {
     });
   });

+  group('MentionAutocompleteQuery.testUser', () {
+    late PerAccountStore store;
+
+    void doCheck(String rawQuery, User user, bool expected) {
+      final result = MentionAutocompleteQuery(rawQuery).testUser(user, store);
+      expected
+        ? check(result).isA<UserMentionAutocompleteResult>()
+        : check(result).isNull();
+    }
+
+    test('user is always excluded when not active regardless of other criteria', () {
+      store = eg.store();
+
+      doCheck('Full Name', eg.user(fullName: 'Full Name', isActive: false), false);
+      // When active then other criteria will be checked
+      doCheck('Full Name', eg.user(fullName: 'Full Name', isActive: true), true);
+    });
+
+    test('user is always excluded when muted, regardless of other criteria', () async {
+      store = eg.store();
+      await store.setMutedUsers([1]);
+      doCheck('Full Name', eg.user(userId: 1, fullName: 'Full Name'), false);
+      // When not muted, then other criteria will be checked
+      doCheck('Full Name', eg.user(userId: 2, fullName: 'Full Name'), true);
+    });
+
+    test('user is included if fullname words match the query', () {
+      store = eg.store();
+
+      doCheck('', eg.user(fullName: 'Full Name'), true);
+      doCheck('', eg.user(fullName: ''), true); // Unlikely case, but should not crash
+      doCheck('Full Name', eg.user(fullName: 'Full Name'), true);
+      doCheck('full name', eg.user(fullName: 'Full Name'), true);
+      doCheck('Full Name', eg.user(fullName: 'full name'), true);
+      doCheck('Full', eg.user(fullName: 'Full Name'), true);
+      doCheck('Name', eg.user(fullName: 'Full Name'), true);
+      doCheck('Full Name', eg.user(fullName: 'Fully Named'), true);
+      doCheck('Full Four', eg.user(fullName: 'Full Name Four Words'), true);
+      doCheck('Name Words', eg.user(fullName: 'Full Name Four Words'), true);
+      doCheck('Full F', eg.user(fullName: 'Full Name Four Words'), true);
+      doCheck('F Four', eg.user(fullName: 'Full Name Four Words'), true);
+      doCheck('full full', eg.user(fullName: 'Full Full Name'), true);
+      doCheck('full full', eg.user(fullName: 'Full Name Full'), true);
+
+      doCheck('F', eg.user(fullName: ''), false); // Unlikely case, but should not crash
+      doCheck('Fully Named', eg.user(fullName: 'Full Name'), false);
+      doCheck('Full Name', eg.user(fullName: 'Full'), false);
+      doCheck('Full Name', eg.user(fullName: 'Name'), false);
+      doCheck('ull ame', eg.user(fullName: 'Full Name'), false);
+      doCheck('ull Name', eg.user(fullName: 'Full Name'), false);
+      doCheck('Full ame', eg.user(fullName: 'Full Name'), false);
+      doCheck('Full Full', eg.user(fullName: 'Full Name'), false);
+      doCheck('Name Name', eg.user(fullName: 'Full Name'), false);
+      doCheck('Name Full', eg.user(fullName: 'Full Name'), false);
+      doCheck('Name Four Full Words', eg.user(fullName: 'Full Name Four Words'), false);
+      doCheck('F Full', eg.user(fullName: 'Full Name Four Words'), false);
+      doCheck('Four F', eg.user(fullName: 'Full Name Four Words'), false);
+    });
+  });
+
+  group('MentionAutocompleteQuery ranking', () {
+    // This gets filled lazily, but never reset.
+    // We're counting on this group's tests never doing anything to mutate it.
+    PerAccountStore? store;
+
+    int? rankOf(String queryStr, Object candidate) {
+      final query = MentionAutocompleteQuery(queryStr);
+      final result = switch (candidate) {
+        WildcardMentionOption() => query.testWildcardOption(candidate,
+          localizations: GlobalLocalizations.zulipLocalizations),
+        User() => query.testUser(candidate, (store ??= eg.store())),
+        UserGroup() => query.testUserGroup(candidate, (store ??= eg.store())),
+        _ => throw StateError('invalid candidate'),
+      };
+      return result?.rank;
+    }
+
+    void checkPrecedes(String query, Object a, Object b) {
+      check(rankOf(query, a)!).isLessThan(rankOf(query, b)!);
+    }
+
+    void checkSameRank(String query, Object a, Object b) {
+      check(rankOf(query, a)!).equals(rankOf(query, b)!);
+    }
+
+    test('wildcards, then users', () {
+      checkSameRank('', WildcardMentionOption.all, WildcardMentionOption.topic);
+      checkPrecedes('', WildcardMentionOption.topic, eg.user());
+      checkSameRank('', eg.user(), eg.user());
+    });
+
+    test('wildcard-vs-user more significant than match quality', () {
+      // Make the query an exact match for the user's name.
+      final user = eg.user(fullName: 'Ann');
+      checkPrecedes(user.fullName, WildcardMentionOption.channel, user);
+    });
+
+    test('user name matched case-insensitively', () {
+      final user1 = eg.user(fullName: 'Chris Bobbe');
+      final user2 = eg.user(fullName: 'chris bobbe');
+
+      checkSameRank('chris bobbe', user1, user2); // exact
+      checkSameRank('chris bo',    user1, user2); // total-prefix
+      checkSameRank('chr bo',      user1, user2); // word-prefixes
+    });
+
+    test('user name match: exact over total-prefix', () {
+      final user1 = eg.user(fullName: 'Chris');
+      final user2 = eg.user(fullName: 'Chris Bobbe');
+
+      checkPrecedes('chris', user1, user2);
+    });
+
+    test('user name match: total-prefix over word-prefixes', () {
+      final user1 = eg.user(fullName: 'So Many Ideas');
+      final user2 = eg.user(fullName: 'Some Merry User');
+
+      checkPrecedes('so m', user1, user2);
+    });
+
+    test('group name matched case-insensitively', () {
+      final userGroup1 = eg.userGroup(name: 'Mobile Team');
+      final userGroup2 = eg.userGroup(name: 'mobile team');
+
+      checkSameRank('mobile team', userGroup1, userGroup2); // exact
+      checkSameRank('mobile te',   userGroup1, userGroup2); // total-prefix
+      checkSameRank('mob te',      userGroup1, userGroup2); // word-prefixes
+    });
+
+    test('group name match: exact over total-prefix', () {
+      final userGroup1 = eg.userGroup(name: 'Mobile');
+      final userGroup2 = eg.userGroup(name: 'Mobile Team');
+
+      checkPrecedes('mobile', userGroup1, userGroup2);
+    });
+
+    test('group name match: total-prefix over word-prefixes', () {
+      final userGroup1 = eg.userGroup(name: 'So Many Ideas');
+      final userGroup2 = eg.userGroup(name: 'Some Merry Group');
+
+      checkPrecedes('so m', userGroup1, userGroup2);
+    });
+
+    test('email matched case-insensitively', () {
+      // "z" name to prevent accidental name match with example data
+      final user1 = eg.user(fullName: 'z', deliveryEmail: 'email@example.com');
+      final user2 = eg.user(fullName: 'z', deliveryEmail: 'EmAiL@ExAmPlE.com');
+
+      checkSameRank('email@example.com', user1, user2);
+      checkSameRank('email@e',           user1, user2);
+      checkSameRank('email@',            user1, user2);
+      checkSameRank('email',             user1, user2);
+      checkSameRank('ema',               user1, user2);
+    });
+
+    test('email match is by prefix only', () {
+      // "z" name to prevent accidental name match with example data
+      final user = eg.user(fullName: 'z', deliveryEmail: 'email@example.com');
+
+      check(rankOf('e',           user)).isNotNull();
+      check(rankOf('mail',        user)).isNull();
+      check(rankOf('example',     user)).isNull();
+      check(rankOf('example.com', user)).isNull();
+    });
+
+    test('full list of ranks', () {
+      final user1 = eg.user(fullName: 'some user', deliveryEmail: 'email@example.com');
+      final userGroup1 = eg.userGroup(name: 'some user group');
+      check([
+        rankOf('', WildcardMentionOption.all), // wildcard
+        rankOf('some user', user1),            // user, exact name match
+        rankOf('some us', user1),              // user, total-prefix name match
+        rankOf('so us', user1),                // user, word-prefixes name match
+        rankOf('some user group', userGroup1), // user group, exact name match
+        rankOf('some us', userGroup1),         // user group, total-prefix name match
+        rankOf('so us gr', userGroup1),        // user group, word-prefixes name match
+        rankOf('email', user1),                // user, no name match, email match
+      ]).deepEquals([0, 1, 2, 3, 4, 5, 6, 7]);
+    });
+  });
+
   group('ComposeTopicAutocomplete.autocompleteIntent', () {
     void doTest(String markedText, TopicAutocompleteQuery? expectedQuery) {
       final parsed = parseMarkedText(markedText);
diff --git a/test/model/compose_test.dart b/test/model/compose_test.dart
index d7479f8ec5..23a2fe8971 100644
--- a/test/model/compose_test.dart
+++ b/test/model/compose_test.dart
@@ -316,6 +316,22 @@ hello
       check(wildcardMention(WildcardMentionOption.topic, store: store()))
         .equals('@**topic**');
     });
+
+    group('user group', () {
+      final userGroup = eg.userGroup(name: 'Group Name');
+      test('not silent', () async {
+        final store = eg.store();
+        await store.addUserGroup(userGroup);
+        check(userGroupMention(userGroup.name, silent: false))
+          .equals('@*Group Name*');
+      });
+      test('silent', () async {
+        final store = eg.store();
+        await store.addUserGroup(userGroup);
+        check(userGroupMention(userGroup.name, silent: true))
+          .equals('@_*Group Name*');
+      });
+    });
   });

   test('inlineLink', () {
diff --git a/test/model/test_store.dart b/test/model/test_store.dart
index 18e41bcfb5..2d9b42e8d8 100644
--- a/test/model/test_store.dart
+++ b/test/model/test_store.dart
@@ -267,6 +267,16 @@ extension PerAccountStoreTestExtension on PerAccountStore {
     }
   }

+  Future<void> addUserGroup(UserGroup userGroup) async {
+    await handleEvent(UserGroupAddEvent(id: 1, group: userGroup));
+  }
+
+  Future<void> addUserGroups(Iterable<UserGroup> userGroups) async {
+    for (final userGroup in userGroups) {
+      await addUserGroup(userGroup);
+    }
+  }
+
   Future<void> setMutedUsers(List<int> userIds) async {
     await handleEvent(eg.mutedUsersEvent(userIds));
   }