new-dm: Exclude deactivated users

Fixes #1743.

Exclude deactivated users in new-DM screen
In the "New DM" screen, if you type a query that matches some deactivated users, they'll appear in the list.

If you then select them and go to the message list, the actual compose box is absent, replaced with an error banner — because you can't actually send a DM to a deactivated user. So we should leave them out of the "New DM" screen's list of users in the first place.

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

diff --git a/lib/model/user.dart b/lib/model/user.dart
index aa4666c359..7d195e3197 100644
--- a/lib/model/user.dart
+++ b/lib/model/user.dart
@@ -35,7 +35,10 @@ mixin UserStore on PerAccountStoreBase, RealmStore {
   /// Consider using [userDisplayName].
   User? getUser(int userId);

-  /// All known users in the realm.
+  /// All known users in the realm, including deactivated users.
+  ///
+  /// Before presenting these users in the UI, consider whether to exclude
+  /// users who are deactivated (see [User.isActive]) or muted ([isUserMuted]).
   ///
   /// This may have a large number of elements, like tens of thousands.
   /// Consider [getUser] or other alternatives to iterating through this.
diff --git a/lib/widgets/new_dm_sheet.dart b/lib/widgets/new_dm_sheet.dart
index 682adea91c..93fbf5c354 100644
--- a/lib/widgets/new_dm_sheet.dart
+++ b/lib/widgets/new_dm_sheet.dart
@@ -1,4 +1,3 @@
-import 'package:collection/collection.dart';
 import 'package:flutter/material.dart';
 import '../api/model/model.dart';
 import '../generated/l10n/zulip_localizations.dart';
@@ -69,9 +68,9 @@ class _NewDmPickerState extends State<NewDmPicker> with PerAccountStoreAwareStat
   }

   void _initSortedUsers(PerAccountStore store) {
-    final sansMuted = store.allUsers
-      .whereNot((user) => store.isUserMuted(user.userId));
-    sortedUsers = List<User>.from(sansMuted)
+    final users = store.allUsers
+      .where((user) => user.isActive && !store.isUserMuted(user.userId));
+    sortedUsers = List<User>.from(users)
       ..sort((a, b) => MentionAutocompleteView.compareByDms(a, b, store: store));
     _updateFilteredUsers(store);
   }

diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart
index ae77e9643f..a95d42dc1a 100644
--- a/test/widgets/new_dm_sheet_test.dart
+++ b/test/widgets/new_dm_sheet_test.dart
@@ -116,24 +116,19 @@ void main() {
   });

   group('user filtering', () {
-    final mutedUser = eg.user(fullName: 'Someone Muted');
     final testUsers = [
       eg.user(fullName: 'Alice Anderson'),
       eg.user(fullName: 'Bob Brown'),
       eg.user(fullName: 'Charlie Carter'),
-      mutedUser,
     ];

-    testWidgets('shows all non-muted users initially', (tester) async {
-      await setupSheet(tester, users: testUsers, mutedUserIds: [mutedUser.userId]);
+    testWidgets('shows full list initially', (tester) async {
+      await setupSheet(tester, users: testUsers);
       check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne();
       check(findText(includePlaceholders: false, 'Bob Brown')).findsOne();
       check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne();
-
       check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3);
       check(find.byIcon(ZulipIcons.check_circle_checked)).findsNothing();
-      check(findText(includePlaceholders: false, 'Someone Muted')).findsNothing();
-      check(findText(includePlaceholders: false, 'Muted user')).findsNothing();
     });

     testWidgets('shows filtered users based on search', (tester) async {
@@ -145,6 +140,43 @@ void main() {
       check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing();
     });

+    testWidgets('deactivated users excluded', (tester) async {
+      // Omit a deactivated user both before there's a query…
+      final deactivatedUser = eg.user(fullName: 'Impostor Charlie', isActive: false);
+      await setupSheet(tester, users: [...testUsers, deactivatedUser]);
+      check(findText(includePlaceholders: false, 'Impostor Charlie')).findsNothing();
+      check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne();
+      check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3);
+
+      // … and after a query that would match their name.
+      await tester.enterText(find.byType(TextField), 'Charlie');
+      await tester.pump();
+      check(findText(includePlaceholders: false, 'Impostor Charlie')).findsNothing();
+      check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne();
+      check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(1);
+    });
+
+    testWidgets('muted users excluded', (tester) async {
+      // Omit muted users both before there's a query…
+      final mutedUser = eg.user(fullName: 'Someone Muted');
+      await setupSheet(tester,
+        users: [...testUsers, mutedUser], mutedUserIds: [mutedUser.userId]);
+      check(findText(includePlaceholders: false, 'Someone Muted')).findsNothing();
+      check(findText(includePlaceholders: false, 'Muted user')).findsNothing();
+      check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne();
+      check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3);
+
+      // … and after a query.  One which matches both the user's actual name and
+      // the replacement text "Muted user", for good measure.
+      await tester.enterText(find.byType(TextField), 'e');
+      await tester.pump();
+      check(findText(includePlaceholders: false, 'Someone Muted')).findsNothing();
+      check(findText(includePlaceholders: false, 'Muted user')).findsNothing();
+      check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne();
+      check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne();
+      check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(2);
+    });
+
     // TODO test sorting by recent-DMs
     // TODO test that scroll position resets on query change


Follow user's time format setting (12 or 24 hour)

Fixes #1015.

cc @alya for screenshots below.

ui: Follow `user_settings.twenty_four_hour_time`
When we show timestamps, for example on messages in the message list, we should format them according to the user's setting:

- `user_settings.twenty_four_hour_time` true: "13:52"
- `user_settings.twenty_four_hour_time` false: "1:52 PM"

This is something we did in the zulip-mobile legacy app.

In addition to the timestamps on messages in the message list (which is the most visible and most important place), this should apply to everywhere else that we show a time of day to the user. To find other such places, try `git grep DateFormat`.

### References

- [Help center doc](https://zulip.com/help/change-the-time-format)
- `UserSettings.twentyFourHourTime` in lib/api/model/initial_snapshot.dart
- `UserSettingsUpdateEvent` in lib/api/model/events.dart

I would like to work on this issue if it has not been assigned to anyone else.
Please see the Zulip project's guide to claiming an issue: https://zulip.readthedocs.io/en/latest/contributing/contributing.html#in-other-zulip-repositories

In particular:

> In your comment, describe what part of the code you’re modifying and how you plan to approach the problem, based on what you learned in steps 1–4 [above](https://zulip.readthedocs.io/en/latest/contributing/contributing.html#picking-an-issue-to-work-on).
Hi @chrisbobbe,

I would like to take up this issue.

I have started working on it and have achieved some successful results.
You can review my changes here: [Compare Changes](https://github.com/zulip/zulip-flutter/compare/main...Gaurav-Kushwaha-1225:zulip-flutter:Follow_UserSettings_Time).
Please permit me to continue working on this issue.

Additionally, I have taken screenshots after changing the `twenty_four_hour_time` preference in my account via the web app:

| 12-Hour Format | 24-Hour Format |
| -------------- | -------------- |
| ![12-Hour](https://github.com/user-attachments/assets/a79a2511-0468-46b9-ba21-19b46966859f) | ![24-Hour](https://github.com/user-attachments/assets/bb02bc4c-3a19-4851-b8e7-e66a62246c92) |

Currently, some checks are failing. I will look into them once the issue is assigned to me.

Looking forward to your feedback! 🚀
Requested in launch feedback: https://chat.zulip.org/#narrow/stream/48-mobile/topic/12-hour.20time.20format/near/2216815

flutter test test/api/route/settings_test.dart test/model/store_test.dart test/widgets/content_test.dart test/widgets/lightbox_test.dart test/widgets/message_list_test.dart --reporter expanded 2>&1

diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart
index bb3d7245e0..6070616387 100644
--- a/lib/api/model/events.dart
+++ b/lib/api/model/events.dart
@@ -175,6 +175,7 @@ class UserSettingsUpdateEvent extends Event {
     final value = json['value'];
     switch (UserSettingName.fromRawString(json['property'] as String)) {
       case UserSettingName.twentyFourHourTime:
+        return TwentyFourHourTimeMode.fromApiValue(value as bool?);
       case UserSettingName.displayEmojiReactionUsers:
         return value as bool;
       case UserSettingName.emojiset:
diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart
index 45b97745d6..d9734582c6 100644
--- a/lib/api/model/initial_snapshot.dart
+++ b/lib/api/model/initial_snapshot.dart
@@ -259,7 +259,12 @@ class RecentDmConversation {
 /// in <https://zulip.com/api/register-queue>.
 @JsonSerializable(fieldRename: FieldRename.snake, createFieldMap: true)
 class UserSettings {
-  bool twentyFourHourTime;
+  @JsonKey(
+    fromJson: TwentyFourHourTimeMode.fromApiValue,
+    toJson: TwentyFourHourTimeMode.staticToJson,
+  )
+  TwentyFourHourTimeMode twentyFourHourTime;
+
   bool? displayEmojiReactionUsers; // TODO(server-6)
   Emojiset emojiset;
   bool presenceEnabled;
diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart
index c8b50a7b78..32af6a1867 100644
--- a/lib/api/model/initial_snapshot.g.dart
+++ b/lib/api/model/initial_snapshot.g.dart
@@ -230,7 +230,9 @@ Map<String, dynamic> _$RecentDmConversationToJson(
 };

 UserSettings _$UserSettingsFromJson(Map<String, dynamic> json) => UserSettings(
-  twentyFourHourTime: json['twenty_four_hour_time'] as bool,
+  twentyFourHourTime: TwentyFourHourTimeMode.fromApiValue(
+    json['twenty_four_hour_time'] as bool?,
+  ),
   displayEmojiReactionUsers: json['display_emoji_reaction_users'] as bool?,
   emojiset: $enumDecode(_$EmojisetEnumMap, json['emojiset']),
   presenceEnabled: json['presence_enabled'] as bool,
@@ -245,7 +247,9 @@ const _$UserSettingsFieldMap = <String, String>{

 Map<String, dynamic> _$UserSettingsToJson(UserSettings instance) =>
     <String, dynamic>{
-      'twenty_four_hour_time': instance.twentyFourHourTime,
+      'twenty_four_hour_time': TwentyFourHourTimeMode.staticToJson(
+        instance.twentyFourHourTime,
+      ),
       'display_emoji_reaction_users': instance.displayEmojiReactionUsers,
       'emojiset': instance.emojiset,
       'presence_enabled': instance.presenceEnabled,
diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart
index 4302a82c11..1009418d08 100644
--- a/lib/api/model/model.dart
+++ b/lib/api/model/model.dart
@@ -298,6 +298,35 @@ enum UserSettingName {
   String toJson() => _$UserSettingNameEnumMap[this]!;
 }

+/// A value from [UserSettings.twentyFourHourTime].
+enum TwentyFourHourTimeMode {
+  twelveHour(apiValue: false),
+  twentyFourHour(apiValue: true),
+
+  /// The locale's default format (12-hour for en_US, 24-hour for fr_FR, etc.).
+  // TODO(#1727) actually follow this
+  // Not sent by current servers, but planned when most client installs accept it:
+  //   https://chat.zulip.org/#narrow/channel/378-api-design/topic/.60user_settings.2Etwenty_four_hour_time.60/near/2220696
+  // TODO(server-future) Write down what server N starts sending null;
+  //   adjust the comment; leave a TODO(server-N) to delete the comment
+  localeDefault(apiValue: null),
+  ;
+
+  const TwentyFourHourTimeMode({required this.apiValue});
+
+  final bool? apiValue;
+
+  static bool? staticToJson(TwentyFourHourTimeMode instance) => instance.apiValue;
+
+  bool? toJson() => TwentyFourHourTimeMode.staticToJson(this);
+
+  static TwentyFourHourTimeMode fromApiValue(bool? value) => switch (value) {
+    false => twelveHour,
+    true => twentyFourHour,
+    null => localeDefault,
+  };
+}
+
 /// As in [UserSettings.emojiset].
 @JsonEnum(fieldRename: FieldRename.kebab, alwaysCreate: true)
 enum Emojiset {
diff --git a/lib/api/route/settings.dart b/lib/api/route/settings.dart
index 929a199d0b..4e98140d76 100644
--- a/lib/api/route/settings.dart
+++ b/lib/api/route/settings.dart
@@ -9,12 +9,20 @@ Future<void> updateSettings(ApiConnection connection, {
   for (final entry in newSettings.entries) {
     final name = entry.key;
     final valueRaw = entry.value;
-    final value = switch (name) {
-      UserSettingName.twentyFourHourTime => valueRaw as bool,
-      UserSettingName.displayEmojiReactionUsers => valueRaw as bool,
-      UserSettingName.emojiset => RawParameter((valueRaw as Emojiset).toJson()),
-      UserSettingName.presenceEnabled => valueRaw as bool,
-    };
+    final Object? value;
+    switch (name) {
+      case UserSettingName.twentyFourHourTime:
+        final mode = (valueRaw as TwentyFourHourTimeMode);
+        // TODO(server-future) allow localeDefault for servers that support it
+        assert(mode != TwentyFourHourTimeMode.localeDefault);
+        value = mode.toJson();
+      case UserSettingName.displayEmojiReactionUsers:
+        value = valueRaw as bool;
+      case UserSettingName.emojiset:
+        value = RawParameter((valueRaw as Emojiset).toJson());
+      case UserSettingName.presenceEnabled:
+        value = valueRaw as bool;
+    }
     params[name.toJson()] = value;
   }

diff --git a/lib/model/store.dart b/lib/model/store.dart
index 03ff13022a..a24550c406 100644
--- a/lib/model/store.dart
+++ b/lib/model/store.dart
@@ -707,7 +707,7 @@ class PerAccountStore extends PerAccountStoreBase with
         }
         switch (event.property!) {
           case UserSettingName.twentyFourHourTime:
-            userSettings.twentyFourHourTime        = event.value as bool;
+            userSettings.twentyFourHourTime        = event.value as TwentyFourHourTimeMode;
           case UserSettingName.displayEmojiReactionUsers:
             userSettings.displayEmojiReactionUsers = event.value as bool;
           case UserSettingName.emojiset:
diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart
index 5d6dfa5084..5851222a1c 100644
--- a/lib/widgets/content.dart
+++ b/lib/widgets/content.dart
@@ -1304,13 +1304,26 @@ class GlobalTime extends StatelessWidget {
   final GlobalTimeNode node;
   final TextStyle ambientTextStyle;

-  static final _dateFormat = intl.DateFormat('EEE, MMM d, y, h:mm a'); // TODO(i18n): localize date
+  static final _format12 =
+    intl.DateFormat('EEE, MMM d, y').addPattern('h:mm aa', ', ');
+  static final _format24 =
+    intl.DateFormat('EEE, MMM d, y').addPattern('Hm', ', ');
+  static final _formatLocaleDefault =
+    intl.DateFormat('EEE, MMM d, y').addPattern('jm', ', ');

   @override
   Widget build(BuildContext context) {
+    final store = PerAccountStoreWidget.of(context);
+    final twentyFourHourTimeMode = store.userSettings.twentyFourHourTime;
     // Design taken from css for `.rendered_markdown & time` in web,
     //   see zulip:web/styles/rendered_markdown.css .
-    final text = _dateFormat.format(node.datetime.toLocal());
+    // TODO(i18n): localize; see plan with ffi in #45
+    final format = switch (twentyFourHourTimeMode) {
+      TwentyFourHourTimeMode.twelveHour => _format12,
+      TwentyFourHourTimeMode.twentyFourHour => _format24,
+      TwentyFourHourTimeMode.localeDefault => _formatLocaleDefault,
+    };
+    final text = format.format(node.datetime.toLocal());
     final contentTheme = ContentTheme.of(context);
     return Padding(
       padding: const EdgeInsets.symmetric(horizontal: 2),
diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart
index bf11522036..0a1fe78feb 100644
--- a/lib/widgets/lightbox.dart
+++ b/lib/widgets/lightbox.dart
@@ -1,7 +1,6 @@
 import 'package:flutter/material.dart';
 import 'package:flutter/scheduler.dart';
 import 'package:flutter/services.dart';
-import 'package:intl/intl.dart';
 import 'package:video_player/video_player.dart';

 import '../api/core.dart';
@@ -12,6 +11,7 @@ import '../model/binding.dart';
 import 'actions.dart';
 import 'content.dart';
 import 'dialog.dart';
+import 'message_list.dart';
 import 'page.dart';
 import 'store.dart';
 import 'user.dart';
@@ -167,6 +167,7 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> {

   @override
   Widget build(BuildContext context) {
+    final zulipLocalizations = ZulipLocalizations.of(context);
     final store = PerAccountStoreWidget.of(context);
     final themeData = Theme.of(context);

@@ -176,11 +177,11 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> {

     PreferredSizeWidget? appBar;
     if (_headerFooterVisible) {
-      // TODO(#45): Format with e.g. "Yesterday at 4:47 PM"
-      final timestampText = DateFormat
-        .yMMMd(/* TODO(#278): Pass selected language here, I think? */)
-        .add_Hms()
-        .format(DateTime.fromMillisecondsSinceEpoch(widget.message.timestamp * 1000));
+      final timestampText = MessageTimestampStyle.lightbox
+        .format(widget.message.timestamp,
+          now: DateTime.now(),
+          twentyFourHourTimeMode: store.userSettings.twentyFourHourTime,
+          zulipLocalizations: zulipLocalizations);

       // We use plain [AppBar] instead of [ZulipAppBar], even though this page
       // has a [PerAccountStore], because:
diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart
index eccdf8c6a6..e3b1d69d6e 100644
--- a/lib/widgets/message_list.dart
+++ b/lib/widgets/message_list.dart
@@ -8,6 +8,7 @@ import 'package:intl/intl.dart' hide TextDirection;

 import '../api/model/model.dart';
 import '../generated/l10n/zulip_localizations.dart';
+import '../model/binding.dart';
 import '../model/database.dart';
 import '../model/message.dart';
 import '../model/message_list.dart';
@@ -1840,8 +1841,14 @@ class DateText extends StatelessWidget {

   @override
   Widget build(BuildContext context) {
+    final store = PerAccountStoreWidget.of(context);
     final messageListTheme = MessageListTheme.of(context);
     final zulipLocalizations = ZulipLocalizations.of(context);
+    final formattedTimestamp = MessageTimestampStyle.dateOnlyRelative.format(
+      timestamp,
+      now: ZulipBinding.instance.utcNow().toLocal(),
+      twentyFourHourTimeMode: store.userSettings.twentyFourHourTime,
+      zulipLocalizations: zulipLocalizations)!;
     return Text(
       style: TextStyle(
         color: messageListTheme.labelTime,
@@ -1851,46 +1858,7 @@ class DateText extends StatelessWidget {
         //   https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-caps#all-small-caps
         fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')],
       ),
-      formatHeaderDate(
-        zulipLocalizations,
-        DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
-        now: DateTime.now()));
-  }
-}
-
-@visibleForTesting
-String formatHeaderDate(
-  ZulipLocalizations zulipLocalizations,
-  DateTime dateTime, {
-  required DateTime now,
-}) {
-  assert(!dateTime.isUtc && !now.isUtc,
-    '`dateTime` and `now` need to be in local time.');
-
-  if (dateTime.year == now.year &&
-      dateTime.month == now.month &&
-      dateTime.day == now.day) {
-    return zulipLocalizations.today;
-  }
-
-  final yesterday = now
-    .copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0)
-    .add(const Duration(days: -1));
-  if (dateTime.year == yesterday.year &&
-      dateTime.month == yesterday.month &&
-      dateTime.day == yesterday.day) {
-    return zulipLocalizations.yesterday;
-  }
-
-  // If it is Dec 1 and you see a label that says `Dec 2`
-  // it could be misinterpreted as Dec 2 of the previous
-  // year. For times in the future, those still on the
-  // current day will show as today (handled above) and
-  // any dates beyond that show up with the year.
-  if (dateTime.year == now.year && dateTime.isBefore(now)) {
-    return DateFormat.MMMd().format(dateTime);
-  } else {
-    return DateFormat.yMMMd().format(dateTime);
+      formattedTimestamp);
   }
 }

@@ -1915,12 +1883,17 @@ class SenderRow extends StatelessWidget {

   @override
   Widget build(BuildContext context) {
+    final zulipLocalizations = ZulipLocalizations.of(context);
     final store = PerAccountStoreWidget.of(context);
     final messageListTheme = MessageListTheme.of(context);
     final designVariables = DesignVariables.of(context);

     final sender = store.getUser(message.senderId);
-    final timestamp = timestampStyle.format(message.timestamp);
+    final timestamp = timestampStyle
+      .format(message.timestamp,
+        now: DateTime.now(),
+        twentyFourHourTimeMode: store.userSettings.twentyFourHourTime,
+        zulipLocalizations: zulipLocalizations);

     final showAsMuted = _showAsMuted(context, store);

@@ -1982,11 +1955,14 @@ class SenderRow extends StatelessWidget {
   }
 }

-// TODO centralize on this for wherever we show message timestamps
 enum MessageTimestampStyle {
   none,
+  dateOnlyRelative,
   timeOnly,

+  // TODO(#45): E.g. "Yesterday at 4:47 PM"; see details in #45
+  lightbox,
+
   /// The longest format, with full date and time as numbers, not "Today"/etc.
   ///
   /// For UI contexts focused just on the one message,
@@ -2001,19 +1977,88 @@ enum MessageTimestampStyle {
   full,
   ;

-  static final _timeOnlyFormat = DateFormat('h:mm aa', 'en_US');
-  static final _fullFormat = DateFormat.yMMMd().add_jm();
+  static String _formatDateOnlyRelative(
+    DateTime dateTime, {
+    required DateTime now,
+    required ZulipLocalizations zulipLocalizations,
+  }) {
+    assert(!dateTime.isUtc && !now.isUtc,
+      '`dateTime` and `now` need to be in local time.');
+
+    if (dateTime.year == now.year &&
+        dateTime.month == now.month &&
+        dateTime.day == now.day) {
+      return zulipLocalizations.today;
+    }
+
+    final yesterday = now
+      .copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0)
+      .add(const Duration(days: -1));
+    if (dateTime.year == yesterday.year &&
+        dateTime.month == yesterday.month &&
+        dateTime.day == yesterday.day) {
+      return zulipLocalizations.yesterday;
+    }
+
+    // If it is Dec 1 and you see a label that says `Dec 2`
+    // it could be misinterpreted as Dec 2 of the previous
+    // year. For times in the future, those still on the
+    // current day will show as today (handled above) and
+    // any dates beyond that show up with the year.
+    if (dateTime.year == now.year && dateTime.isBefore(now)) {
+      return DateFormat.MMMd().format(dateTime);
+    } else {
+      return DateFormat.yMMMd().format(dateTime);
+    }
+  }
+
+  static final _timeFormat12 =                       DateFormat('h:mm aa');
+  static final _timeFormat24 =                       DateFormat('Hm');
+  static final _timeFormatLocaleDefault =            DateFormat('jm');
+  static final _timeFormat12WithSeconds =            DateFormat('h:mm:ss aa');
+  static final _timeFormat24WithSeconds =            DateFormat('Hms');
+  static final _timeFormatLocaleDefaultWithSeconds = DateFormat('jms');
+
+  static DateFormat _resolveTimeFormat(TwentyFourHourTimeMode mode) => switch (mode) {
+    TwentyFourHourTimeMode.twelveHour => _timeFormat12,
+    TwentyFourHourTimeMode.twentyFourHour => _timeFormat24,
+    TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefault,
+  };
+
+  static DateFormat _resolveTimeFormatWithSeconds(TwentyFourHourTimeMode mode) => switch (mode) {
+    TwentyFourHourTimeMode.twelveHour => _timeFormat12WithSeconds,
+    TwentyFourHourTimeMode.twentyFourHour => _timeFormat24WithSeconds,
+    TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefaultWithSeconds,
+  };

   /// Format a [Message.timestamp] for this mode.
   // TODO(i18n): locale-specific formatting (see #45 for a plan with ffi)
-  String? format(int messageTimestamp) {
+  String? format(
+    int messageTimestamp, {
+    required DateTime now,
+    required ZulipLocalizations zulipLocalizations,
+    required TwentyFourHourTimeMode twentyFourHourTimeMode,
+  }) {
     final asDateTime =
       DateTime.fromMillisecondsSinceEpoch(1000 * messageTimestamp);

     switch (this) {
       case none:     return null;
-      case timeOnly: return _timeOnlyFormat.format(asDateTime);
-      case full: return _fullFormat.format(asDateTime);
+      case dateOnlyRelative:
+        return _formatDateOnlyRelative(asDateTime,
+          now: now, zulipLocalizations: zulipLocalizations);
+      case timeOnly:
+        return _resolveTimeFormat(twentyFourHourTimeMode).format(asDateTime);
+      case lightbox:
+        return DateFormat
+          .yMMMd()
+          .addPattern(_resolveTimeFormatWithSeconds(twentyFourHourTimeMode).pattern)
+          .format(asDateTime);
+      case full:
+        return DateFormat
+          .yMMMd()
+          .addPattern(_resolveTimeFormat(twentyFourHourTimeMode).pattern)
+          .format(asDateTime);
     }
   }
 }

diff --git a/test/api/route/settings_test.dart b/test/api/route/settings_test.dart
index d2808ece02..8b31caa646 100644
--- a/test/api/route/settings_test.dart
+++ b/test/api/route/settings_test.dart
@@ -17,8 +17,8 @@ void main() {
       for (final name in UserSettingName.values) {
         switch (name) {
           case UserSettingName.twentyFourHourTime:
-            newSettings[name] = true;
-            expectedBodyFields['twenty_four_hour_time'] = 'true';
+            newSettings[name] = TwentyFourHourTimeMode.twelveHour;
+            expectedBodyFields['twenty_four_hour_time'] = 'false';
           case UserSettingName.displayEmojiReactionUsers:
             newSettings[name] = false;
             expectedBodyFields['display_emoji_reaction_users'] = 'false';
@@ -38,4 +38,16 @@ void main() {
         ..bodyFields.deepEquals(expectedBodyFields);
     });
   });
+
+  test('TwentyFourHourTime.localeDefault', () async {
+    return FakeApiConnection.with_((connection) async {
+      connection.prepare(json: {});
+
+      // TODO(server-future) instead, check for twenty_four_hour_time: null
+      //   (could be an error-prone part of the JSONification)
+      check(() => updateSettings(connection,
+        newSettings: {UserSettingName.twentyFourHourTime: TwentyFourHourTimeMode.localeDefault})
+      ).throws<AssertionError>();
+    });
+  });
 }
diff --git a/test/example_data.dart b/test/example_data.dart
index c33c5fdfdc..b44108eb21 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -1262,7 +1262,7 @@ InitialSnapshot initialSnapshot({
     streams: streams ?? [], // TODO add streams to default
     userStatuses: userStatuses ?? {},
     userSettings: userSettings ?? UserSettings(
-      twentyFourHourTime: false,
+      twentyFourHourTime: TwentyFourHourTimeMode.twelveHour,
       displayEmojiReactionUsers: true,
       emojiset: Emojiset.google,
       presenceEnabled: true,
diff --git a/test/model/store_test.dart b/test/model/store_test.dart
index 240f2cbffd..c90b45a25f 100644
--- a/test/model/store_test.dart
+++ b/test/model/store_test.dart
@@ -698,14 +698,16 @@ void main() {
       await preparePoll();

       // Pick some arbitrary event and check it gets processed on the store.
-      check(store.userSettings.twentyFourHourTime).isFalse();
+      check(store.userSettings.twentyFourHourTime)
+        .equals(TwentyFourHourTimeMode.twelveHour);
       connection.prepare(json: GetEventsResult(events: [
         UserSettingsUpdateEvent(id: 2,
           property: UserSettingName.twentyFourHourTime, value: true),
       ], queueId: null).toJson());
       updateMachine.debugAdvanceLoop();
       async.elapse(Duration.zero);
-      check(store.userSettings.twentyFourHourTime).isTrue();
+      check(store.userSettings.twentyFourHourTime)
+        .equals(TwentyFourHourTimeMode.twentyFourHour);
     }));

     void checkReload(FutureOr<void> Function() prepareError, {
@@ -735,14 +737,16 @@ void main() {
         // The new UpdateMachine updates the new store.
         updateMachine.debugPauseLoop();
         updateMachine.poll();
-        check(store.userSettings.twentyFourHourTime).isFalse();
+        check(store.userSettings.twentyFourHourTime)
+          .equals(TwentyFourHourTimeMode.twelveHour);
         connection.prepare(json: GetEventsResult(events: [
           UserSettingsUpdateEvent(id: 2,
             property: UserSettingName.twentyFourHourTime, value: true),
         ], queueId: null).toJson());
         updateMachine.debugAdvanceLoop();
         async.elapse(Duration.zero);
-        check(store.userSettings.twentyFourHourTime).isTrue();
+        check(store.userSettings.twentyFourHourTime)
+          .equals(TwentyFourHourTimeMode.twentyFourHour);
       });
     }

diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart
index 0075f50b11..2b7eb45180 100644
--- a/test/widgets/content_test.dart
+++ b/test/widgets/content_test.dart
@@ -7,6 +7,8 @@ import 'package:flutter_checks/flutter_checks.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:url_launcher/url_launcher.dart';
 import 'package:zulip/api/core.dart';
+import 'package:zulip/api/model/initial_snapshot.dart';
+import 'package:zulip/api/model/model.dart';
 import 'package:zulip/model/content.dart';
 import 'package:zulip/model/narrow.dart';
 import 'package:zulip/model/settings.dart';
@@ -118,9 +120,13 @@ Widget plainContent(String html) {
 Future<void> prepareContent(WidgetTester tester, Widget child, {
   List<NavigatorObserver> navObservers = const [],
   bool wrapWithPerAccountStoreWidget = false,
+  InitialSnapshot? initialSnapshot,
 }) async {
   if (wrapWithPerAccountStoreWidget) {
-    await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
+    initialSnapshot ??= eg.initialSnapshot();
+    await testBinding.globalStore.add(eg.selfAccount, initialSnapshot);
+  } else {
+    assert(initialSnapshot == null);
   }

   addTearDown(testBinding.reset);
@@ -598,10 +604,12 @@ void main() {
   Future<void> checkFontSizeRatio(WidgetTester tester, {
     required String targetHtml,
     required TargetFontSizeFinder targetFontSizeFinder,
+    bool wrapWithPerAccountStoreWidget = false,
   }) async {
-    await prepareContent(tester, plainContent(
-      '
header-plain $targetHtml
\n'
-      '
paragraph-plain $targetHtml
'));
+    await prepareContent(tester, wrapWithPerAccountStoreWidget: wrapWithPerAccountStoreWidget,
+      plainContent(
+        '
header-plain $targetHtml
\n'
+        '
paragraph-plain $targetHtml
'));

     final headerRootSpan = tester.renderObject<RenderParagraph>(find.textContaining('header')).text;
     final headerPlainStyle = mergedStyleOfSubstring(headerRootSpan, 'header-plain ');
@@ -1071,16 +1079,52 @@ void main() {
     // the timezone of the environment running these tests. Accept here a wide
     // range of times. See comments in "show dates" test in
     // `test/widgets/message_list_test.dart`.
-    final renderedTextRegexp = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d [AP]M$');
+    final renderedTextRegexp = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d(?: [AP]M)?$');
+    final renderedTextRegexpTwelveHour = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d [AP]M$');
+    final renderedTextRegexpTwentyFourHour = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d$');
+
+    Future<void> prepare(
+      WidgetTester tester,
+      [TwentyFourHourTimeMode twentyFourHourTimeMode = TwentyFourHourTimeMode.localeDefault]
+    ) async {
+      final initialSnapshot = eg.initialSnapshot()
+        ..userSettings.twentyFourHourTime = twentyFourHourTimeMode;
+      await prepareContent(tester,
+        // We use the self-account's time-format setting.
+        wrapWithPerAccountStoreWidget: true,
+        initialSnapshot: initialSnapshot,
+        plainContent('
$timeSpanHtml
'));
+    }

     testWidgets('smoke', (tester) async {
-      await prepareContent(tester, plainContent('
$timeSpanHtml
'));
+      await prepare(tester);
       tester.widget(find.textContaining(renderedTextRegexp));
     });

+    testWidgets('TwentyFourHourTimeMode.twelveHour', (tester) async {
+      await prepare(tester, TwentyFourHourTimeMode.twelveHour);
+      check(find.textContaining(renderedTextRegexpTwelveHour)).findsOne();
+    });
+
+    testWidgets('TwentyFourHourTimeMode.twentyFourHour', (tester) async {
+      await prepare(tester, TwentyFourHourTimeMode.twentyFourHour);
+      check(find.textContaining(renderedTextRegexpTwentyFourHour)).findsOne();
+    });
+
+    testWidgets('TwentyFourHourTimeMode.localeDefault', (tester) async {
+      await prepare(tester, TwentyFourHourTimeMode.localeDefault);
+      // This expectation holds as long as we're always formatting in en_US,
+      // the default locale, which uses the twelve-hour format.
+      // TODO(#1727) follow the actual locale; test with different locales
+      check(find.textContaining(renderedTextRegexpTwelveHour)).findsOne();
+    });
+
     void testIconAndTextSameColor(String description, String html) {
       testWidgets('clock icon and text are the same color: $description', (tester) async {
-        await prepareContent(tester, plainContent(html));
+        await prepareContent(tester,
+          // We use the self-account's time-format setting.
+          wrapWithPerAccountStoreWidget: true,
+          plainContent(html));

         final icon = tester.widget<Icon>(
           find.descendant(of: find.byType(GlobalTime),
@@ -1100,6 +1144,8 @@ void main() {
     group('maintains font-size ratio with surrounding text', () {
       Future<void> doCheck(WidgetTester tester, double Function(GlobalTime widget) sizeFromWidget) async {
         await checkFontSizeRatio(tester,
+          // We use the self-account's time-format setting.
+          wrapWithPerAccountStoreWidget: true,
           targetHtml: '<time datetime="2024-01-30T17:33:00Z">2024-01-30T17:33:00Z</time>',
           targetFontSizeFinder: (rootSpan) {
             late final double result;
diff --git a/test/widgets/lightbox_test.dart b/test/widgets/lightbox_test.dart
index fc013173e7..a7f6240852 100644
--- a/test/widgets/lightbox_test.dart
+++ b/test/widgets/lightbox_test.dart
@@ -382,12 +382,12 @@ void main() {
       await setupPage(tester, message: message, thumbnailUrl: null, users: [sender]);
       check(store.getUser(sender.userId)).isNotNull();

-      checkAppBarNameAndDate(tester, 'Old name', 'Jul 23, 2024 23:12:24');
+      checkAppBarNameAndDate(tester, 'Old name', 'Jul 23, 2024 11:12:24 PM');

       await store.handleEvent(RealmUserUpdateEvent(id: 1,
         userId: sender.userId, fullName: 'New name'));
       await tester.pump();
-      checkAppBarNameAndDate(tester, 'New name', 'Jul 23, 2024 23:12:24');
+      checkAppBarNameAndDate(tester, 'New name', 'Jul 23, 2024 11:12:24 PM');

       debugNetworkImageHttpClientProvider = null;
     });
@@ -400,7 +400,7 @@ void main() {
       await setupPage(tester, message: message, thumbnailUrl: null, users: []);
       check(store.getUser(sender.userId)).isNull();

-      checkAppBarNameAndDate(tester, 'Sender name', 'Jul 23, 2024 23:12:24');
+      checkAppBarNameAndDate(tester, 'Sender name', 'Jul 23, 2024 11:12:24 PM');

       debugNetworkImageHttpClientProvider = null;
     });
diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart
index a7c4b14dbf..3636861a2e 100644
--- a/test/widgets/message_list_test.dart
+++ b/test/widgets/message_list_test.dart
@@ -2,6 +2,7 @@ import 'dart:convert';
 import 'dart:io';

 import 'package:checks/checks.dart';
+import 'package:clock/clock.dart';
 import 'package:collection/collection.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/rendering.dart';
@@ -1637,26 +1638,80 @@ void main() {
     });
   });

-  group('formatHeaderDate', () {
-    final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
-    final now = DateTime.parse("2023-01-10 12:00");
-    final testCases = [
-      ("2023-01-10 12:00", zulipLocalizations.today),
-      ("2023-01-10 00:00", zulipLocalizations.today),
-      ("2023-01-10 23:59", zulipLocalizations.today),
-      ("2023-01-09 23:59", zulipLocalizations.yesterday),
-      ("2023-01-09 00:00", zulipLocalizations.yesterday),
-      ("2023-01-08 00:00", "Jan 8"),
-      ("2022-12-31 00:00", "Dec 31, 2022"),
-      // Future times
-      ("2023-01-10 19:00", zulipLocalizations.today),
-      ("2023-01-11 00:00", "Jan 11, 2023"),
-    ];
-    for (final (dateTime, expected) in testCases) {
-      test('$dateTime returns $expected', () {
-        check(formatHeaderDate(zulipLocalizations, DateTime.parse(dateTime), now: now))
-          .equals(expected);
-      });
+  group('MessageTimestampStyle', () {
+    void doTests(
+      MessageTimestampStyle style,
+      List<(
+        String timestampStr,
+        String? expectedTwelveHour,
+        String? expectedTwentyFourHour,
+      )> cases, {
+      DateTime? now,
+    }) {
+      now ??= DateTime.parse("2023-01-10 12:00");
+      for (final (timestampStr, expectedTwelveHour, expectedTwentyFourHour) in cases) {
+        for (final mode in TwentyFourHourTimeMode.values) {
+          final expected = switch (mode) {
+            TwentyFourHourTimeMode.twelveHour => expectedTwelveHour,
+            TwentyFourHourTimeMode.twentyFourHour => expectedTwentyFourHour,
+            // This expectation will hold as long as we're always using the
+            // default locale, en_US, which uses the twelve-hour format.
+            // TODO(#1727) test with other locales
+            TwentyFourHourTimeMode.localeDefault => expectedTwelveHour,
+          };
+
+          test('${style.name} in ${mode.name}: $timestampStr returns $expected', () {
+            addTearDown(testBinding.reset);
+            final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
+
+            withClock(Clock.fixed(now!), () {
+              final timestamp = DateTime.parse(timestampStr)
+                .millisecondsSinceEpoch ~/ 1000;
+              final result = style.format(
+                timestamp,
+                now: testBinding.utcNow().toLocal(),
+                twentyFourHourTimeMode: mode,
+                zulipLocalizations: zulipLocalizations);
+              check(result).equals(expected);
+            });
+          });
+        }
+      }
+    }
+
+    for (final style in MessageTimestampStyle.values) {
+      switch (style) {
+        case MessageTimestampStyle.none:
+          doTests(style, [('2023-01-10 12:00', null, null)]);
+        case MessageTimestampStyle.dateOnlyRelative:
+          final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
+          doTests(style,
+            now: DateTime.parse("2023-01-10 12:00"),
+            [
+              ("2023-01-10 12:00", zulipLocalizations.today,     zulipLocalizations.today),
+              ("2023-01-10 00:00", zulipLocalizations.today,     zulipLocalizations.today),
+              ("2023-01-10 23:59", zulipLocalizations.today,     zulipLocalizations.today),
+              ("2023-01-09 23:59", zulipLocalizations.yesterday, zulipLocalizations.yesterday),
+              ("2023-01-09 00:00", zulipLocalizations.yesterday, zulipLocalizations.yesterday),
+              ("2023-01-08 00:00", "Jan 8", "Jan 8"),
+              ("2022-12-31 00:00", "Dec 31, 2022", "Dec 31, 2022"),
+              // Future times
+              ("2023-01-10 19:00", zulipLocalizations.today, zulipLocalizations.today),
+              ("2023-01-11 00:00", "Jan 11, 2023", "Jan 11, 2023"),
+            ]);
+        case MessageTimestampStyle.timeOnly:
+          doTests(style, [('2023-01-10 12:00', '12:00 PM', '12:00')]);
+        case MessageTimestampStyle.lightbox:
+          doTests(style,
+            [('2023-01-10 12:00',
+              'Jan 10, 2023 12:00:00 PM',
+              'Jan 10, 2023 12:00:00')]);
+        case MessageTimestampStyle.full:
+          doTests(style,
+            [('2023-01-10 12:00',
+              'Jan 10, 2023 12:00 PM',
+              'Jan 10, 2023 12:00')]);
+      }
     }
   });


Render KaTeX by default

Fixes #46. (By enabling the TeX support whose substance was added over a number of preceding PRs.)

Here's a version of the commit I've included in each release recently, atop (previous versions of) #1559, to enable our KaTeX support. Now that #1559 and its preceding PRs (#1698, and others before that) are merged to main, this is ready to go to main too.

This was a minimal version for the release, just flipping the flag's default to true. But in general we should remove experimental flags from the codebase as soon as they're no longer relevant, to keep complexity from building up. (See the doc on [GlobalSettingType.experimentalFeatureFlag].) @rajveermalviya would you write a follow-up commit that removes this flag entirely, and cleans up the logic elsewhere which that makes possible to clean up?

#### 5287d39b9 content: Render KaTeX by default, the way we have in recent releases

This is cherry-picked from commits we included in each of the last
several releases, starting with 8f3723768 in v0.0.31.

TeX in the message list

TeX in the message list
Zulip's Markdown flavor supports TeX for mathematical notation, using [KaTeX](https://katex.org/). This will be one of the more complex message-content features for us to support.

The way this works for web and for the existing RN-based mobile client is:
* KaTeX consists of some JavaScript code to convert TeX into HTML, and then some CSS to style that HTML. The KaTeX-generated HTML also has some per-element style attributes.
* The server takes care of the conversion to HTML, by running the KaTeX JS code on Node. That HTML goes into the message's HTML content.
* Then the web and RN-mobile clients ship with KaTeX's CSS, and include that in their styles.
* Those clients also ship with the KaTeX JS code. They use that for local echo.

What I think we'll want to do in Flutter is:
* Skip TeX for local echo, and just show the source TeX. That seems fine.
  * If we implement a "preview" feature for before sending a message, then it'll be important to get the actual rendering there… but even the web app relies on the server for its preview feature. So we'll do the same.
* Then we'll consume KaTeX HTML and convert it into Flutter widgets, just like we consume other kinds of Zulip content HTML and convert it into Flutter widgets, in `lib/model/content.dart` and `lib/widgets/content.dart`.

Converting the HTML to Flutter widgets will involve having code in `lib/widgets/content.dart` that is written to correspond to the KaTeX CSS, just like much of the existing code there is written to correspond to the Zulip markdown-content CSS.

That CSS is [a few hundred lines of code](https://github.com/KaTeX/KaTeX/blob/c5ac4c769588761fb68d89890998234e83fc1c1a/src/katex.less). It's written in Less; but looking at the compiled output, it's not much longer than that source, and most of the difference is highly repetitive. So the real amount of information there is about what's in that one source file. That file is also highly stable over time — just as one might hope, given that TeX itself is famously extremely stable — with the last non-NFC change 20 months ago, in 2021-07.

So while this won't be a small subproject, it's one where I'm pretty confident we can do it well.

We got a request for this as beta feedback in the Google Play Console:

> Looks like there's no Latex support yet. This makes it a non-starter for our purposes unfortunately. Are there plans to bring that in soon?
And again today:

> Math doesn't work, glitches with links. I'll try again another time (love Zulip generally).
In the Lean community we [asked](https://leanprover.zulipchat.com/#narrow/channel/236604-Zulip-meta/topic/LaTeX.20display.20in.20mobile.20app/near/489857078) how disappointed people would be if the app were rolled out to all users before full support for TeX.

One user answered "heavily disappointed"; others reacted to that message suggesting they disagree.

A different user "wouldn't mind" the current state, and another added:
> I mean, I'm old enough to remember when people wrote latex in `sci.math.research` in their posts and it was just displayed like that anyway.

See discussion with an [idea](https://chat.zulip.org/#narrow/channel/48-mobile/topic/TeX.20formatting.20.23F46/near/2048201) from Tim:
> So I think it would be possible to write the Flutter app logic to convert MathML into display rather than working with the HTML.
It's looking like we won't have all the features of KaTeX covered before we launch the new app in a few weeks ([#mobile-team > Flutter beta timeline @ 💬](https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/Flutter.20beta.20timeline/near/2174273)). I think we can still cover most usage of it, though, with a respectable fallback behavior. Then we can pursue the remaining KaTeX features as follow-up issues.

Details in chat: [#mobile > TeX formatting #F46 @ 💬](https://chat.zulip.org/#narrow/channel/48-mobile/topic/TeX.20formatting.20.23F46/near/2182738)

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

diff --git a/lib/model/settings.dart b/lib/model/settings.dart
index e8a309ac29..4e71224444 100644
--- a/lib/model/settings.dart
+++ b/lib/model/settings.dart
@@ -178,7 +178,7 @@ enum BoolGlobalSetting {
   upgradeWelcomeDialogShown(GlobalSettingType.internal, false),

   /// An experimental flag to toggle rendering KaTeX content in messages.
-  renderKatex(GlobalSettingType.experimentalFeatureFlag, false),
+  renderKatex(GlobalSettingType.experimentalFeatureFlag, true),

   /// An experimental flag to enable rendering KaTeX even when some
   /// errors are encountered.

diff --git a/test/model/content_test.dart b/test/model/content_test.dart
index e4dd622b19..88bd11c66c 100644
--- a/test/model/content_test.dart
+++ b/test/model/content_test.dart
@@ -512,7 +512,7 @@ class ContentExample {
   static final mathInline = ContentExample.inline(
     'inline math',
     r"$$ \lambda $$",
-    expectedText: r'\lambda',
+    expectedText: r'λ',
     '
'
       '<math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>λ</mi></mrow>'
         '<annotation encoding="application/x-tex"> \\lambda </annotation></semantics></math>'
@@ -532,7 +532,7 @@ class ContentExample {
   static const mathBlock = ContentExample(
     'math block',
     "```math\n\\lambda\n```",
-    expectedText: r'\lambda',
+    expectedText: r'λ',
     '
'
       '<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mi>λ</mi></mrow>'
         '<annotation encoding="application/x-tex">\\lambda</annotation></semantics></math>'
diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart
index 964d7e1208..46ed10079e 100644
--- a/test/widgets/content_test.dart
+++ b/test/widgets/content_test.dart
@@ -558,7 +558,11 @@ void main() {
   group('MathBlock', () {
     testContentSmoke(ContentExample.mathBlock);

-    testWidgets('displays KaTeX source; experimental flag default', (tester) async {
+    testWidgets('displays KaTeX source; experimental flag disabled', (tester) async {
+      addTearDown(testBinding.reset);
+      final globalSettings = testBinding.globalStore.settings;
+      await globalSettings.setBool(BoolGlobalSetting.renderKatex, false);
+
       await prepareContent(tester, plainContent(ContentExample.mathBlock.html));
       tester.widget(find.text(r'\lambda', findRichText: true));
     });
@@ -1102,6 +1106,23 @@ void main() {
     });

     testWidgets('maintains font-size ratio with surrounding text, when showing TeX source', (tester) async {
+      const html = ''
+        '<math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>λ</mi></mrow>'
+          '<annotation encoding="application/x-tex"> \\lambda </annotation></semantics></math>'
+        'λ';
+      await checkFontSizeRatio(tester,
+        targetHtml: html,
+        targetFontSizeFinder: mkTargetFontSizeFinderFromPattern(r'λ'));
+    }, skip: true // TODO(#46): adapt this test
+                  //   (it needs a more complex targetFontSizeFinder;
+                  //    see other uses in this file for examples.)
+    );
+
+    testWidgets('maintains font-size ratio with surrounding text, when showing TeX source', (tester) async {
+      addTearDown(testBinding.reset);
+      final globalSettings = testBinding.globalStore.settings;
+      await globalSettings.setBool(BoolGlobalSetting.renderKatex, false);
+
       const html = ''
         '<math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>λ</mi></mrow>'
           '<annotation encoding="application/x-tex"> \\lambda </annotation></semantics></math>'
@@ -1111,7 +1132,11 @@ void main() {
         targetFontSizeFinder: mkTargetFontSizeFinderFromPattern(r'\lambda'));
     });

-    testWidgets('displays KaTeX source; experimental flag default', (tester) async {
+    testWidgets('displays KaTeX source; experimental flag disabled', (tester) async {
+      addTearDown(testBinding.reset);
+      final globalSettings = testBinding.globalStore.settings;
+      await globalSettings.setBool(BoolGlobalSetting.renderKatex, false);
+
       await prepareContent(tester, plainContent(ContentExample.mathInline.html));
       tester.widget(find.text(r'\lambda', findRichText: true));
     });


Add user groups to store and API binding

Fixes #662, toward #233 user-group @-mentions.

Store user groups

Store user groups
We should store the list of user groups in the realm, with their metadata ~~and their memberships~~, and keep the data up to date.

(Memberships will be a separate issue: #1687.)

This data originates as `realm_user_groups` in the initial snapshot at https://zulip.com/api/register-queue#response , and is updated by events.

As a preparatory refactor, we'll move `PerAccountStore.users` (which is a `Map<int, User>`, indexed by user ID) to live on a new type `UserStore`. This will closely follow the example of `streams` and `StreamStore`.

Then the user groups will be added as an additional data structure on `UserStore`.

This is a prerequisite for features involving user groups, in particular:
* #233

We're likely about to add a [client capability](https://zulip.com/api/register-queue#parameter-client_capabilities) on the server related to user groups, with a name like `allow_deactivated`:
https://chat.zulip.org/#narrow/stream/378-api-design/topic/deactivated.20groups.20in.20.60GET.60.20endpoints/near/1940349

So when implementing this part of the API, we should start sending that client capability.

(In general, we should always be sending all [client capabilities](https://zulip.com/api/register-queue#parameter-client_capabilities) that have been defined so far. That way we get the latest version of the API, rather than an API that's been adapted for older clients.)

For current servers since https://github.com/zulip/zulip/pull/29498 (server-10, ZFL 303), correctly maintaining user groups will also involve acting on RealmUserUpdateEvent.isActive. See the API docs for that property.

Chat thread: https://chat.zulip.org/#narrow/stream/378-api-design/topic/Groups.20events.20for.20deactivated.20users/near/1958907

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

diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart
index 6fd21e5c9a..b3f3844366 100644
--- a/lib/api/model/events.dart
+++ b/lib/api/model/events.dart
@@ -30,6 +30,14 @@ sealed class Event {
           default: return UnexpectedEvent.fromJson(json);
         }
       case 'custom_profile_fields': return CustomProfileFieldsEvent.fromJson(json);
+      case 'user_group':
+        switch (json['op'] as String) {
+          case 'add': return UserGroupAddEvent.fromJson(json);
+          case 'update': return UserGroupUpdateEvent.fromJson(json);
+          // TODO(#1687): add_members, remove_members, add_subgroups, remove_subgroups
+          case 'remove': return UserGroupRemoveEvent.fromJson(json);
+          default: return UnexpectedEvent.fromJson(json);
+        }
       case 'realm_user':
         switch (json['op'] as String) {
           case 'add': return RealmUserAddEvent.fromJson(json);
@@ -206,6 +214,85 @@ class CustomProfileFieldsEvent extends Event {
   Map<String, dynamic> toJson() => _$CustomProfileFieldsEventToJson(this);
 }

+/// A Zulip event of type `user_group`.
+///
+/// See API docs starting at:
+///   https://zulip.com/api/get-events#user_group-add
+sealed class UserGroupEvent extends Event {
+  @override
+  @JsonKey(includeToJson: true)
+  String get type => 'user_group';
+
+  String get op;
+
+  UserGroupEvent({required super.id});
+}
+
+/// A [UserGroupEvent] with op `add`: https://zulip.com/api/get-events#user_group-add
+@JsonSerializable(fieldRename: FieldRename.snake)
+class UserGroupAddEvent extends UserGroupEvent {
+  @override
+  @JsonKey(includeToJson: true)
+  String get op => 'add';
+
+  final UserGroup group;
+
+  UserGroupAddEvent({required super.id, required this.group});
+
+  factory UserGroupAddEvent.fromJson(Map<String, dynamic> json) => _$UserGroupAddEventFromJson(json);
+
+  @override
+  Map<String, dynamic> toJson() => _$UserGroupAddEventToJson(this);
+}
+
+/// A [UserGroupEvent] with op `update`: https://zulip.com/api/get-events#user_group-update
+@JsonSerializable(fieldRename: FieldRename.snake)
+class UserGroupUpdateEvent extends UserGroupEvent {
+  @override
+  @JsonKey(includeToJson: true)
+  String get op => 'update';
+
+  final int groupId;
+  final UserGroupUpdateData data;
+
+  UserGroupUpdateEvent({required super.id, required this.groupId, required this.data});
+
+  factory UserGroupUpdateEvent.fromJson(Map<String, dynamic> json) => _$UserGroupUpdateEventFromJson(json);
+
+  @override
+  Map<String, dynamic> toJson() => _$UserGroupUpdateEventToJson(this);
+}
+
+@JsonSerializable(fieldRename: FieldRename.snake)
+class UserGroupUpdateData {
+  final String? name;
+  final String? description;
+  final bool? deactivated;
+
+  UserGroupUpdateData({required this.name, required this.description, required this.deactivated});
+
+  factory UserGroupUpdateData.fromJson(Map<String, dynamic> json) => _$UserGroupUpdateDataFromJson(json);
+
+  Map<String, dynamic> toJson() => _$UserGroupUpdateDataToJson(this);
+}
+
+/// A [UserGroupEvent] with op `remove`: https://zulip.com/api/get-events#user_group-remove
+@JsonSerializable(fieldRename: FieldRename.snake)
+class UserGroupRemoveEvent extends UserGroupEvent {
+  @override
+  @JsonKey(includeToJson: true)
+  String get op => 'remove';
+
+  final int groupId;
+
+  UserGroupRemoveEvent({required super.id, required this.groupId});
+
+  factory UserGroupRemoveEvent.fromJson(Map<String, dynamic> json) => _$UserGroupRemoveEventFromJson(json);
+
+  @override
+  Map<String, dynamic> toJson() => _$UserGroupRemoveEventToJson(this);
+}
+
 /// A Zulip event of type `realm_user`.
 ///
 /// The corresponding API docs are in several places for
diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart
index 2203b2d9df..31016c67ce 100644
--- a/lib/api/model/events.g.dart
+++ b/lib/api/model/events.g.dart
@@ -86,6 +86,69 @@ Map<String, dynamic> _$CustomProfileFieldsEventToJson(
   'fields': instance.fields,
 };

+UserGroupAddEvent _$UserGroupAddEventFromJson(Map<String, dynamic> json) =>
+    UserGroupAddEvent(
+      id: (json['id'] as num).toInt(),
+      group: UserGroup.fromJson(json['group'] as Map<String, dynamic>),
+    );
+
+Map<String, dynamic> _$UserGroupAddEventToJson(UserGroupAddEvent instance) =>
+    <String, dynamic>{
+      'id': instance.id,
+      'type': instance.type,
+      'op': instance.op,
+      'group': instance.group,
+    };
+
+UserGroupUpdateEvent _$UserGroupUpdateEventFromJson(
+  Map<String, dynamic> json,
+) => UserGroupUpdateEvent(
+  id: (json['id'] as num).toInt(),
+  groupId: (json['group_id'] as num).toInt(),
+  data: UserGroupUpdateData.fromJson(json['data'] as Map<String, dynamic>),
+);
+
+Map<String, dynamic> _$UserGroupUpdateEventToJson(
+  UserGroupUpdateEvent instance,
+) => <String, dynamic>{
+  'id': instance.id,
+  'type': instance.type,
+  'op': instance.op,
+  'group_id': instance.groupId,
+  'data': instance.data,
+};
+
+UserGroupUpdateData _$UserGroupUpdateDataFromJson(Map<String, dynamic> json) =>
+    UserGroupUpdateData(
+      name: json['name'] as String?,
+      description: json['description'] as String?,
+      deactivated: json['deactivated'] as bool?,
+    );
+
+Map<String, dynamic> _$UserGroupUpdateDataToJson(
+  UserGroupUpdateData instance,
+) => <String, dynamic>{
+  'name': instance.name,
+  'description': instance.description,
+  'deactivated': instance.deactivated,
+};
+
+UserGroupRemoveEvent _$UserGroupRemoveEventFromJson(
+  Map<String, dynamic> json,
+) => UserGroupRemoveEvent(
+  id: (json['id'] as num).toInt(),
+  groupId: (json['group_id'] as num).toInt(),
+);
+
+Map<String, dynamic> _$UserGroupRemoveEventToJson(
+  UserGroupRemoveEvent instance,
+) => <String, dynamic>{
+  'id': instance.id,
+  'type': instance.type,
+  'op': instance.op,
+  'group_id': instance.groupId,
+};
+
 RealmUserAddEvent _$RealmUserAddEventFromJson(Map<String, dynamic> json) =>
     RealmUserAddEvent(
       id: (json['id'] as num).toInt(),
diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart
index a9efdabdd5..d56c25b599 100644
--- a/lib/api/model/initial_snapshot.dart
+++ b/lib/api/model/initial_snapshot.dart
@@ -56,6 +56,8 @@ class InitialSnapshot {

   final Map<String, RealmEmojiItem> realmEmoji;

+  final List<UserGroup> realmUserGroups;
+
   final List<RecentDmConversation> recentPrivateConversations;

   final List<SavedSnippet>? savedSnippets; // TODO(server-10)
@@ -149,6 +151,7 @@ class InitialSnapshot {
     required this.mutedUsers,
     required this.presences,
     required this.realmEmoji,
+    required this.realmUserGroups,
     required this.recentPrivateConversations,
     required this.savedSnippets,
     required this.subscriptions,
diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart
index c33c457226..fa3601bdd0 100644
--- a/lib/api/model/initial_snapshot.g.dart
+++ b/lib/api/model/initial_snapshot.g.dart
@@ -54,6 +54,9 @@ InitialSnapshot _$InitialSnapshotFromJson(
   realmEmoji: (json['realm_emoji'] as Map<String, dynamic>).map(
     (k, e) => MapEntry(k, RealmEmojiItem.fromJson(e as Map<String, dynamic>)),
   ),
+  realmUserGroups: (json['realm_user_groups'] as List<dynamic>)
+      .map((e) => UserGroup.fromJson(e as Map<String, dynamic>))
+      .toList(),
   recentPrivateConversations:
       (json['recent_private_conversations'] as List<dynamic>)
           .map((e) => RecentDmConversation.fromJson(e as Map<String, dynamic>))
@@ -143,6 +146,7 @@ Map<String, dynamic> _$InitialSnapshotToJson(InitialSnapshot instance) =>
       'muted_users': instance.mutedUsers,
       'presences': instance.presences.map((k, e) => MapEntry(k.toString(), e)),
       'realm_emoji': instance.realmEmoji,
+      'realm_user_groups': instance.realmUserGroups,
       'recent_private_conversations': instance.recentPrivateConversations,
       'saved_snippets': instance.savedSnippets,
       'subscriptions': instance.subscriptions,
diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart
index 6bb34c89f4..db490b0d34 100644
--- a/lib/api/model/model.dart
+++ b/lib/api/model/model.dart
@@ -199,6 +199,42 @@ enum Emojiset {
     .map((key, value) => MapEntry(value, key));
 }

+/// As in [InitialSnapshot.realmUserGroups] or [UserGroupAddEvent].
+@JsonSerializable(fieldRename: FieldRename.snake)
+class UserGroup {
+  final int id;
+
+  // TODO(#1687) to maintain members, also act on user deactivation: https://github.com/zulip/zulip-flutter/issues/662#issuecomment-2405845356
+  // List<int> members; // TODO(#1687) track group members
+  // List<int> directSubgroupIds; // TODO(#1687) track group members
+
+  String name;
+  String description;
+
+  // final int? dateCreated; // not using; ignore
+  // final int? creatorId; // not using; ignore
+
+  final bool isSystemGroup;
+
+  // TODO(server-10): [deactivated] new in FL 290; previously no groups were deactivated
+  @JsonKey(defaultValue: false)
+  bool deactivated;
+
+  // TODO(#814): GroupSettingValue canAddMembersGroup, etc.; add to update event too
+
+  UserGroup({
+    required this.id,
+    required this.name,
+    required this.description,
+    required this.isSystemGroup,
+    required this.deactivated,
+  });
+
+  factory UserGroup.fromJson(Map<String, dynamic> json) => _$UserGroupFromJson(json);
+
+  Map<String, dynamic> toJson() => _$UserGroupToJson(this);
+}
+
 /// As in [InitialSnapshot.realmUsers], [InitialSnapshot.realmNonActiveUsers], and [InitialSnapshot.crossRealmBots].
 ///
 /// In the Zulip API, the items in realm_users, realm_non_active_users, and
diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart
index eff1019cda..ff01aba57f 100644
--- a/lib/api/model/model.g.dart
+++ b/lib/api/model/model.g.dart
@@ -94,6 +94,22 @@ Map<String, dynamic> _$RealmEmojiItemToJson(RealmEmojiItem instance) =>
       'author_id': instance.authorId,
     };

+UserGroup _$UserGroupFromJson(Map<String, dynamic> json) => UserGroup(
+  id: (json['id'] as num).toInt(),
+  name: json['name'] as String,
+  description: json['description'] as String,
+  isSystemGroup: json['is_system_group'] as bool,
+  deactivated: json['deactivated'] as bool? ?? false,
+);
+
+Map<String, dynamic> _$UserGroupToJson(UserGroup instance) => <String, dynamic>{
+  'id': instance.id,
+  'name': instance.name,
+  'description': instance.description,
+  'is_system_group': instance.isSystemGroup,
+  'deactivated': instance.deactivated,
+};
+
 User _$UserFromJson(Map<String, dynamic> json) => User(
   userId: (json['user_id'] as num).toInt(),
   deliveryEmail: json['delivery_email'] as String?,
diff --git a/lib/api/route/events.dart b/lib/api/route/events.dart
index bd14521c74..589a9b8cbf 100644
--- a/lib/api/route/events.dart
+++ b/lib/api/route/events.dart
@@ -18,6 +18,7 @@ Future<InitialSnapshot> registerQueue(ApiConnection connection) {
       'user_avatar_url_field_optional': false, // TODO(#254): turn on
       'stream_typing_notifications': true,
       'user_settings_object': true,
+      'include_deactivated_groups': true,
       'empty_topic_name': true,
     },
   });
diff --git a/lib/model/store.dart b/lib/model/store.dart
index 483cbb1662..09729bad7b 100644
--- a/lib/model/store.dart
+++ b/lib/model/store.dart
@@ -35,6 +35,7 @@ import 'settings.dart';
 import 'typing_status.dart';
 import 'unreads.dart';
 import 'user.dart';
+import 'user_group.dart';

 export 'package:drift/drift.dart' show Value;
 export 'database.dart' show Account, AccountsCompanion, AccountAlreadyExistsException;
@@ -433,7 +434,14 @@ Uri? tryResolveUrl(Uri baseUrl, String reference) {
 /// This class does not attempt to poll an event queue
 /// to keep the data up to date.  For that behavior, see
 /// [UpdateMachine].
-class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStore, SavedSnippetStore, UserStore, ChannelStore, MessageStore {
+class PerAccountStore extends PerAccountStoreBase with
+    ChangeNotifier,
+    UserGroupStore, ProxyUserGroupStore,
+    EmojiStore,
+    SavedSnippetStore,
+    UserStore,
+    ChannelStore,
+    MessageStore {
   /// Construct a store for the user's data, starting from the given snapshot.
   ///
   /// The global store must already have been updated with
@@ -475,6 +483,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
     final channels = ChannelStoreImpl(initialSnapshot: initialSnapshot);
     return PerAccountStore._(
       core: core,
+      groups: UserGroupStoreImpl(core: core,
+        groups: initialSnapshot.realmUserGroups),
       serverPresencePingIntervalSeconds: initialSnapshot.serverPresencePingIntervalSeconds,
       serverPresenceOfflineThresholdSeconds: initialSnapshot.serverPresenceOfflineThresholdSeconds,
       realmWildcardMentionPolicy: initialSnapshot.realmWildcardMentionPolicy,
@@ -524,6 +534,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor

   PerAccountStore._({
     required super.core,
+    required UserGroupStoreImpl groups,
     required this.serverPresencePingIntervalSeconds,
     required this.serverPresenceOfflineThresholdSeconds,
     required this.realmWildcardMentionPolicy,
@@ -549,7 +560,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
     required this.unreads,
     required this.recentDmConversationsView,
     required this.recentSenders,
-  }) : _realmEmptyTopicDisplayName = realmEmptyTopicDisplayName,
+  }) : _groups = groups,
+       _realmEmptyTopicDisplayName = realmEmptyTopicDisplayName,
        _emoji = emoji,
        _savedSnippets = savedSnippets,
        _users = users,
@@ -582,6 +594,13 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
   ////////////////////////////////
   // Data attached to the realm or the server.

+  // (User groups come before even realm settings,
+  // because they'll be used for interpreting many realm settings.)
+  @protected
+  @override
+  UserGroupStore get userGroupStore => _groups;
+  final UserGroupStoreImpl _groups;
+
   final int serverPresencePingIntervalSeconds;
   final int serverPresenceOfflineThresholdSeconds;

@@ -901,6 +920,11 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
         customProfileFields = _sortCustomProfileFields(event.fields);
         notifyListeners();

+      case UserGroupEvent():
+        assert(debugLog("server event: user_group/${event.op}"));
+        _groups.handleUserGroupEvent(event);
+        notifyListeners();
+
       case RealmUserAddEvent():
         assert(debugLog("server event: realm_user/add"));
         _users.handleRealmUserEvent(event);
diff --git a/lib/model/user_group.dart b/lib/model/user_group.dart
new file mode 100644
index 0000000000..2079b323ec
--- /dev/null
+++ b/lib/model/user_group.dart
@@ -0,0 +1,79 @@
+import 'package:flutter/foundation.dart';
+
+import '../api/model/events.dart';
+import '../api/model/model.dart';
+import 'store.dart';
+
+/// The portion of [PerAccountStore] describing user groups.
+mixin UserGroupStore on PerAccountStoreBase {
+  /// The user group with the given ID, if any.
+  UserGroup? getGroup(int userGroupId);
+
+  /// All non-deactivated user groups in the realm.
+  ///
+  /// For when deactivated groups are desired too, see [allGroups].
+  Iterable<UserGroup> get activeGroups;
+
+  /// All user groups in the realm, even those deactivated.
+  ///
+  /// Consider using [activeGroups] instead.
+  Iterable<UserGroup> get allGroups;
+}
+
+mixin ProxyUserGroupStore on UserGroupStore {
+  @protected
+  UserGroupStore get userGroupStore;
+
+  @override
+  UserGroup? getGroup(int userGroupId) => userGroupStore.getGroup(userGroupId);
+  @override
+  Iterable<UserGroup> get activeGroups => userGroupStore.activeGroups;
+  @override
+  Iterable<UserGroup> get allGroups => userGroupStore.allGroups;
+}
+
+/// The implementation of [UserGroupStore] that does the work.
+class UserGroupStoreImpl extends PerAccountStoreBase with UserGroupStore {
+  UserGroupStoreImpl({required super.core, required List<UserGroup> groups})
+    : _groups = {
+        for (final group in groups)
+          group.id: group,
+      };
+
+  @override
+  UserGroup? getGroup(int userGroupId) {
+    return _groups[userGroupId];
+  }
+
+  @override
+  Iterable<UserGroup> get activeGroups {
+    return _groups.values.where((group) => !group.deactivated);
+  }
+
+  @override
+  Iterable<UserGroup> get allGroups {
+    return _groups.values;
+  }
+
+  final Map<int, UserGroup> _groups;
+
+  void handleUserGroupEvent(UserGroupEvent event) {
+    switch (event) {
+      case UserGroupAddEvent():
+        _groups[event.group.id] = event.group;
+
+      case UserGroupRemoveEvent():
+        _groups.remove(event.groupId);
+
+      case UserGroupUpdateEvent():
+        final group = _groups[event.groupId];
+        if (group == null) {
+          return; // TODO log
+        }
+        final data = event.data;
+        if (data.name != null)        group.name        = data.name!;
+        if (data.description != null) group.description = data.description!;
+        if (data.deactivated != null) group.deactivated = data.deactivated!;
+    }
+  }
+}

diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart
index 262395d955..36b6396c97 100644
--- a/test/api/model/model_checks.dart
+++ b/test/api/model/model_checks.dart
@@ -2,6 +2,14 @@ import 'package:checks/checks.dart';
 import 'package:zulip/api/model/model.dart';
 import 'package:zulip/api/model/submessage.dart';

+extension UserGroupChecks on Subject<UserGroup> {
+  Subject<int> get id => has((x) => x.id, 'id');
+  Subject<String> get name => has((x) => x.name, 'name');
+  Subject<String> get description => has((x) => x.description, 'description');
+  Subject<bool> get isSystemGroup => has((x) => x.isSystemGroup, 'isSystemGroup');
+  Subject<bool> get deactivated => has((x) => x.deactivated, 'deactivated');
+}
+
 extension UserChecks on Subject<User> {
   Subject<int> get userId => has((x) => x.userId, 'userId');
   Subject<String?> get deliveryEmail => has((x) => x.deliveryEmail, 'deliveryEmail');
diff --git a/test/example_data.dart b/test/example_data.dart
index ad4b8bb6fe..d46652506a 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -166,6 +166,26 @@ ServerEmojiData serverEmojiDataPopularLegacy = ServerEmojiData(codeToNames: {
   '1f419': ['octopus'],
 });

+/// A fresh user-group ID, from a random but always strictly increasing sequence.
+int _nextUserGroupId() => (_lastUserGroupId += 1 + Random().nextInt(10));
+int _lastUserGroupId = 100;
+
+UserGroup userGroup({
+  int? id,
+  String? name,
+  String? description,
+  bool isSystemGroup = false,
+  bool deactivated = false,
+}) {
+  return UserGroup(
+    id: id ??= _nextUserGroupId(),
+    name: name ??= 'group-$id',
+    description: description ?? 'A group named $name',
+    isSystemGroup: isSystemGroup,
+    deactivated: deactivated,
+  );
+}
+
 RealmEmojiItem realmEmojiItem({
   required String emojiCode,
   required String emojiName,
@@ -1112,6 +1132,7 @@ InitialSnapshot initialSnapshot({
   List<MutedUserItem>? mutedUsers,
   Map<int, PerUserPresence>? presences,
   Map<String, RealmEmojiItem>? realmEmoji,
+  List<UserGroup>? realmUserGroups,
   List<RecentDmConversation>? recentPrivateConversations,
   List<SavedSnippet>? savedSnippets,
   List<Subscription>? subscriptions,
@@ -1153,6 +1174,7 @@ InitialSnapshot initialSnapshot({
     mutedUsers: mutedUsers ?? [],
     presences: presences ?? {},
     realmEmoji: realmEmoji ?? {},
+    realmUserGroups: realmUserGroups ?? [],
     recentPrivateConversations: recentPrivateConversations ?? [],
     savedSnippets: savedSnippets ?? [],
     subscriptions: subscriptions ?? [], // TODO add subscriptions to default
diff --git a/test/model/user_group_test.dart b/test/model/user_group_test.dart
new file mode 100644
index 0000000000..f05b3c3c32
--- /dev/null
+++ b/test/model/user_group_test.dart
@@ -0,0 +1,119 @@
+import 'package:checks/checks.dart';
+import 'package:test_api/scaffolding.dart';
+import 'package:zulip/api/model/events.dart';
+import 'package:zulip/api/model/model.dart';
+import 'package:zulip/model/user_group.dart';
+
+import '../api/model/model_checks.dart';
+import '../example_data.dart' as eg;
+import '../stdlib_checks.dart';
+
+void main() {
+  List<UserGroup> sorted(Iterable<UserGroup> groups) {
+    return groups.toList()..sort((a, b) => a.id.compareTo(b.id));
+  }
+
+  void checkGroupsEqual(UserGroupStore store, Iterable<Object?> expected) {
+    check(sorted(store.allGroups)).jsonEquals(expected);
+  }
+
+  test('initialize', () {
+    final groups = [eg.userGroup(), eg.userGroup()];
+    final store = eg.store(initialSnapshot: eg.initialSnapshot(
+      realmUserGroups: groups));
+    checkGroupsEqual(store, groups);
+  });
+
+  test('getGroup', () {
+    final group1 = eg.userGroup();
+    final group2 = eg.userGroup();
+    final store = eg.store(initialSnapshot: eg.initialSnapshot(
+      realmUserGroups: [group1, group2]));
+    check(store.getGroup(group1.id)).jsonEquals(group1);
+    check(store.getGroup(group2.id)).jsonEquals(group2);
+    check(store.getGroup(eg.userGroup().id)).isNull();
+  });
+
+  test('activeGroups, allGroups', () async {
+    final group1 = eg.userGroup(deactivated: false);
+    final group2 = eg.userGroup(deactivated: true);
+    final group3 = eg.userGroup(deactivated: false);
+    final store = eg.store(initialSnapshot: eg.initialSnapshot(
+      realmUserGroups: [group1, group2, group3]));
+    check(sorted(store.allGroups)).jsonEquals([group1, group2, group3]);
+    check(sorted(store.activeGroups)).jsonEquals([group1, group3]);
+
+    await store.handleEvent(UserGroupUpdateEvent(id: 1, groupId: group1.id,
+      data: UserGroupUpdateData(name: null, description: null, deactivated: true)));
+    check(sorted(store.activeGroups)).jsonEquals([group3]);
+  });
+
+  test('UserGroupAddEvent, UserGroupRemoveEvent', () async {
+    final group1 = eg.userGroup();
+    final store = eg.store(initialSnapshot: eg.initialSnapshot(
+      realmUserGroups: [group1]));
+    checkGroupsEqual(store, [group1]);
+
+    final group2 = eg.userGroup();
+    await store.handleEvent(UserGroupAddEvent(id: 1, group: group2));
+    checkGroupsEqual(store, [group1, group2]);
+
+    await store.handleEvent(UserGroupRemoveEvent(id: 2, groupId: group1.id));
+    checkGroupsEqual(store, [group2]);
+  });
+
+  test('UserGroupUpdateEvent', () async {
+    final store = eg.store();
+    final group = eg.userGroup(
+      name: 'a group', description: 'is a group', deactivated: false);
+    await store.handleEvent(UserGroupAddEvent(id: 1, group: group));
+    checkGroupsEqual(store, [group]);
+
+    // Handles all the properties being updated at once.
+    await store.handleEvent(UserGroupUpdateEvent(id: 2, groupId: group.id,
+      data: UserGroupUpdateData(name: 'revised group',
+        description: 'different description', deactivated: true)));
+    checkGroupsEqual(store, [{
+      ...group.toJson(),
+      'name': 'revised group',
+      'description': 'different description',
+      'deactivated': true,
+    }]);
+
+    // Handles some properties being null, still updating the one that's present.
+    await store.handleEvent(UserGroupUpdateEvent(id: 2, groupId: group.id,
+      data: UserGroupUpdateData(name: null,
+        description: null, deactivated: false)));
+    checkGroupsEqual(store, [{
+      ...group.toJson(),
+      'name': 'revised group',
+      'description': 'different description',
+      'deactivated': false,
+    }]);
+  });
+
+  test('various fields make it through', () async {
+    final store = eg.store(initialSnapshot: eg.initialSnapshot(
+      realmUserGroups: [
+        eg.userGroup(id: 3, name: 'some group', description: 'this is a group',
+          isSystemGroup: true, deactivated: false),
+      ]));
+    await store.handleEvent(UserGroupAddEvent(id: 1, group: eg.userGroup(
+      id: 5, name: 'a different group', description: 'also a group',
+      isSystemGroup: false, deactivated: true)));
+    check(sorted(store.allGroups)).deepEquals(<Condition<Object?>>[
+      (it) => it.isA<UserGroup>()
+        ..id.equals(3)
+        ..name.equals('some group')
+        ..description.equals('this is a group')
+        ..isSystemGroup.isTrue()
+        ..deactivated.isFalse(),
+      (it) => it.isA<UserGroup>()
+        ..id.equals(5)
+        ..name.equals('a different group')
+        ..description.equals('also a group')
+        ..isSystemGroup.isFalse()
+        ..deactivated.isTrue(),
+    ]);
+  });
+}


store: Pass `dont_block` in first poll attempt after a failure

Fixes #979.

Discussion:
  https://github.com/zulip/zulip-flutter/issues/979#issuecomment-2993296322

------

Tested manually, with and without this change:

1. Log out from CZO (too active; events coming in frequently); connect to a not-very-active org

2. Make an artificial network error in the `getEvents` binding:

```diff
diff --git lib/api/route/events.dart lib/api/route/events.dart
index bd14521c7..da7bf4fea 100644
--- lib/api/route/events.dart
+++ lib/api/route/events.dart
@@ -1,6 +1,7 @@
 import 'package:json_annotation/json_annotation.dart';

 import '../core.dart';
+import '../exception.dart';
 import '../model/events.dart';
 import '../model/initial_snapshot.dart';

@@ -27,6 +28,7 @@ Future<InitialSnapshot> registerQueue(ApiConnection connection) {
 Future<GetEventsResult> getEvents(ApiConnection connection, {
   required String queueId, int? lastEventId, bool? dontBlock,
 }) {
+  throw NetworkException(routeName: 'foo', message: 'foo', cause: 'foo');
   return connection.get('getEvents', GetEventsResult.fromJson, 'events', {
     'queue_id': RawParameter(queueId),
     if (lastEventId != null) 'last_event_id': lastEventId,
```

3. Watch the logs to see retries going by
4. Comment out the `throw NetworkException` line and do a hot-reload
5. Watch the logs for `dont_block` in the `GET /events`, and watch the app to see if the loading indicator goes away quickly
   - Without the change, I don't see `dont_block`, and the loading indicator doesn't go away until the next event comes in
   - With the change, I see `dont_block`, and the loading indicator goes away quickly

Loading indicator slow to go away when returning to app, though in fact up to date
We added a loading indicator in #852 that appears when even queue polling fails with any error.

However, it seems that the loading indicator can stay on even the event queue is doing fine, and does not go away until you get a new event.

I don't have a consistent way to reproduce this, but what I did was basically:

1. Leave the app on and lock the phone
2. After a while, unlock the phone
3. Observe if the loading indicator stays there longer than usual
4. Try to do something (like starring a message), and the loading indicator should go away promptly

My guess is that when the event queue goes stale, `isLoading` gets set to true. When we do get the new event queue, we do not set `isLoading` back to false.

Thanks for the report!

I don't think I've seen this symptom myself yet. It sounds reminiscent of this other event-queue-related symptom I saw once back in July:
* #809

but I haven't seen that one since then either.

I think this won't be actionable until we hear more reports of it happening, so I'll push it out to the post-launch milestone. If we do see or hear of it again, this thread will help us aggregate those reports.

I happened to see this symptom yesterday during development, and thanks to seeing the debug log I think I have a diagnosis. This was on my Pixel 8 running Android 15, while connected by USB to my desktop for debugging.

What happens is:
* The device goes to sleep, perhaps while the app was in the foreground.
* The app actually keeps running — one can tell because it keeps printing log messages to the console.
* But it isn't able to make network requests.
  * The symptom of this is that the requests fail with SocketException, generally with DNS errors that it can't find `chat.zulip.org` (or whatever the host is).
    * We'd previously seen that those errors are common; that's why they're the one type of transient failure we silenced from reporting to the user, in #868.
  * In retrospect this makes sense as a compromise for the OS to choose: let the foreground app keep running, at least for a little while, in case the user wakes the device right back up again; but disable the network, because the wifi radio and especially the cell radio are expensive for the battery.
* So our event-poll loop keeps getting failures, and backs off farther and farther. So far, so good.
* Now the user wakes the device, and the app is able to use the network again. Two things go wrong that both contribute to this symptom.
* First, we may be in the middle of a long backoff. So it could be up to 10 seconds (typically more like 3 seconds) before we make the next request. During the remaining backoff time, the loading indicator will continue; and we won't learn of new events, even events that already happened while we were offline.
  * This first issue, I was basically already aware of. It'd be good to fix but isn't the main cause of the symptom this thread is about.
* Then, once the current backoff expires and we do make a new request… _that request may take up to a minute to return_, even if the network and server are functioning ideally. Until the request completes, we keep the loading indicator going.
  * This is the new point which I hadn't thought of before I saw it happening yesterday.
  * Specifically this happens if there are no events waiting for us, and no new events show up. After all, our event-polling is a long-poll — the point of which is that the server may sit on the request a long time, so that when it does have something to say it can send a response immediately.
    * The only reason this ends even at a minute is because the server sends a "heartbeat" event just for the sake of making a response, as a workaround for some network intermediaries breaking connections that sit silently for longer than that.
  * That's why starring a message stops the loading indicator: it generates an event, causing the server to send a response.
  * While this issue is active, the app's data is actually up to date — if there were news for us, the server would have responded with it already. So the only problem is that we're showing the loading indicator, making it look out of date. In that sense it's milder than the first issue above.
  * On the other hand, this one can last much longer — it'll routinely be almost one minute, if the realm isn't busy. So in that sense it's more severe.

So, now that we have a diagnosis, moving this issue earlier. I think it's still a post-launch issue, though — I wouldn't hold up shipping the app for it.

This behavior continues to bug me sometimes, even though I know exactly what's happening.

It also came up today from a user trying the beta: https://github.com/zulip/zulip-flutter/issues/1525#issuecomment-2920818667
I just discovered the `dont_block` param of `GET /events`: https://zulip.com/api/get-events#parameter-dont_block

From reading its doc, I think the fix is to pass `true` for that, in the first poll request after a failure? (Currently on mobile; I can explain more later if needed.)
Interesting! Yeah, that might be all that's needed.

flutter test test/model/store_test.dart test/widgets/app_bar_test.dart --reporter expanded 2>&1

diff --git a/lib/model/store.dart b/lib/model/store.dart
index 13a999aa37..9973cbc33e 100644
--- a/lib/model/store.dart
+++ b/lib/model/store.dart
@@ -582,12 +582,12 @@ class PerAccountStore extends PerAccountStoreBase with
     _updateMachine = value;
   }

-  bool get isLoading => _isLoading;
-  bool _isLoading = false;
+  bool get isRecoveringEventStream => _isRecoveringEventStream;
+  bool _isRecoveringEventStream = false;
   @visibleForTesting
-  set isLoading(bool value) {
-    if (_isLoading == value) return;
-    _isLoading = value;
+  set isRecoveringEventStream(bool value) {
+    if (_isRecoveringEventStream == value) return;
+    _isRecoveringEventStream = value;
     notifyListeners();
   }

@@ -1437,7 +1437,13 @@ class UpdateMachine {
         final GetEventsResult result;
         try {
           result = await getEvents(store.connection,
-            queueId: store.queueId, lastEventId: lastEventId);
+            queueId: store.queueId,
+            lastEventId: lastEventId,
+            // If the UI shows we're busy getting event-polling to work again,
+            // ask the server to tell us immediately that it's working again,
+            // rather than waiting for an event, which could take up to a minute
+            // in the case of a heartbeat event. See #979.
+            dontBlock: store.isRecoveringEventStream ? true : null);
           if (_disposed) return;
         } catch (e, stackTrace) {
           if (_disposed) return;
@@ -1504,14 +1510,14 @@ class UpdateMachine {
     // if we stayed at the max backoff interval; partway toward what would
     // happen if we weren't backing off at all.
     //
-    // But at least for [getEvents] requests, as here, it should be OK,
-    // because this is a long-poll.  That means a typical successful request
-    // takes a long time to come back; in fact longer than our max backoff
-    // duration (which is 10 seconds).  So if we're getting a mix of successes
-    // and failures, the successes themselves should space out the requests.
+    // Successful retries won't actually space out the requests, because retries
+    // are done with the `dont_block` param, asking the server to respond
+    // immediately instead of waiting through the long-poll period.
+    // (See comments on that code for why this behavior is helpful.)
+    // If server logs show pressure from too many requests, we can investigate.
     _pollBackoffMachine = null;

-    store.isLoading = false;
+    store.isRecoveringEventStream = false;
     _accumulatedTransientFailureCount = 0;
     reportErrorToUserBriefly(null);
   }
@@ -1530,7 +1536,7 @@ class UpdateMachine {
   ///  * [_handlePollError], which handles errors from the rest of [poll]
   ///    and errors this method rethrows.
   Future<void> _handlePollRequestError(Object error, StackTrace stackTrace) async {
-    store.isLoading = true;
+    store.isRecoveringEventStream = true;

     if (error is! ApiRequestException) {
       // Some unexpected error, outside even making the HTTP request.
@@ -1588,7 +1594,7 @@ class UpdateMachine {
     // or an unexpected exception representing a bug in our code or the server.
     // Either way, the show must go on.  So reload server data from scratch.

-    store.isLoading = true;
+    store.isRecoveringEventStream = true;

     bool isUnexpected;
     // TODO(#1054): handle auth failure
diff --git a/lib/widgets/app_bar.dart b/lib/widgets/app_bar.dart
index f548557681..77f77ba7e2 100644
--- a/lib/widgets/app_bar.dart
+++ b/lib/widgets/app_bar.dart
@@ -79,7 +79,7 @@ class _ZulipAppBarBottom extends StatelessWidget implements PreferredSizeWidget
   @override
   Widget build(BuildContext context) {
     final store = PerAccountStoreWidget.of(context);
-    if (!store.isLoading) return const SizedBox.shrink();
+    if (!store.isRecoveringEventStream) return const SizedBox.shrink();
     return LinearProgressIndicator(minHeight: 4.0, backgroundColor: backgroundColor);
   }
 }

diff --git a/test/model/store_checks.dart b/test/model/store_checks.dart
index 93e24dffdd..c157ac2190 100644
--- a/test/model/store_checks.dart
+++ b/test/model/store_checks.dart
@@ -45,7 +45,7 @@ extension GlobalStoreChecks on Subject<GlobalStore> {

 extension PerAccountStoreChecks on Subject<PerAccountStore> {
   Subject<ApiConnection> get connection => has((x) => x.connection, 'connection');
-  Subject<bool> get isLoading => has((x) => x.isLoading, 'isLoading');
+  Subject<bool> get isRecoveringEventStream => has((x) => x.isRecoveringEventStream, 'isRecoveringEventStream');
   Subject<Uri> get realmUrl => has((x) => x.realmUrl, 'realmUrl');
   Subject<String> get zulipVersion => has((x) => x.zulipVersion, 'zulipVersion');
   Subject<bool> get realmMandatoryTopics => has((x) => x.realmMandatoryTopics, 'realmMandatoryTopics');
diff --git a/test/model/store_test.dart b/test/model/store_test.dart
index 3736064f84..5ce42626cf 100644
--- a/test/model/store_test.dart
+++ b/test/model/store_test.dart
@@ -782,13 +782,14 @@ void main() {
       updateMachine.poll();
     }

-    void checkLastRequest({required int lastEventId}) {
+    void checkLastRequest({required int lastEventId, bool expectDontBlock = false}) {
       check(connection.takeRequests()).single.isA<http.Request>()
         ..method.equals('GET')
         ..url.path.equals('/api/v1/events')
         ..url.queryParameters.deepEquals({
           'queue_id': store.queueId,
           'last_event_id': lastEventId.toString(),
+          if (expectDontBlock) 'dont_block': 'true',
         });
     }

@@ -841,7 +842,7 @@ void main() {
         await prepareError();
         updateMachine.debugAdvanceLoop();
         async.elapse(Duration.zero);
-        check(store).isLoading.isTrue();
+        check(store).isRecoveringEventStream.isTrue();

         if (expectBackoff) {
           // The reload doesn't happen immediately; there's a timer.
@@ -853,7 +854,7 @@ void main() {
         // The global store has a new store.
         check(globalStore.perAccountSync(store.accountId)).not((it) => it.identicalTo(store));
         updateFromGlobalStore();
-        check(store).isLoading.isFalse();
+        check(store).isRecoveringEventStream.isFalse();

         // The new UpdateMachine updates the new store.
         updateMachine.debugPauseLoop();
@@ -878,8 +879,8 @@ void main() {
         prepareError();
         updateMachine.debugAdvanceLoop();
         async.elapse(Duration.zero);
-        checkLastRequest(lastEventId: 1);
-        check(store).isLoading.isTrue();
+        checkLastRequest(lastEventId: 1, expectDontBlock: false);
+        check(store).isRecoveringEventStream.isTrue();

         // Polling doesn't resume immediately; there's a timer.
         check(async.pendingTimers).length.equals(1);
@@ -893,9 +894,9 @@ void main() {
           HeartbeatEvent(id: 2),
         ], queueId: null).toJson());
         async.flushTimers();
-        checkLastRequest(lastEventId: 1);
+        checkLastRequest(lastEventId: 1, expectDontBlock: true);
         check(updateMachine.lastEventId).equals(2);
-        check(store).isLoading.isFalse();
+        check(store).isRecoveringEventStream.isFalse();
       });
     }

@@ -1032,11 +1033,13 @@ void main() {
         await preparePoll(lastEventId: 1);
       }

-      void pollAndFail(FakeAsync async, {bool shouldCheckRequest = true}) {
+      void pollAndFail(FakeAsync async, {bool shouldCheckRequest = true, bool expectDontBlock = false}) {
         updateMachine.debugAdvanceLoop();
         async.elapse(Duration.zero);
-        if (shouldCheckRequest) checkLastRequest(lastEventId: 1);
-        check(store).isLoading.isTrue();
+        if (shouldCheckRequest) {
+          checkLastRequest(lastEventId: 1, expectDontBlock: expectDontBlock);
+        }
+        check(store).isRecoveringEventStream.isTrue();
       }

       Subject<String> checkReported(void Function() prepareError) {
@@ -1054,9 +1057,11 @@ void main() {
         return awaitFakeAsync((async) async {
           await prepare();

+          bool expectDontBlock = false;
           for (int i = 0; i < UpdateMachine.transientFailureCountNotifyThreshold; i++) {
             prepareError();
-            pollAndFail(async);
+            pollAndFail(async, expectDontBlock: expectDontBlock);
+            expectDontBlock = true;
             check(takeLastReportedError()).isNull();
             async.flushTimers();
             if (!identical(store, globalStore.perAccountSync(store.accountId))) {
@@ -1064,11 +1069,14 @@ void main() {
               updateFromGlobalStore();
               updateMachine.debugPauseLoop();
               updateMachine.poll();
+              // Loading indicator is cleared on successful /register;
+              // we don't need dont_block for the new queue's first poll.
+              expectDontBlock = false;
             }
           }

           prepareError();
-          pollAndFail(async);
+          pollAndFail(async, expectDontBlock: expectDontBlock);
           return check(takeLastReportedError()).isNotNull();
         });
       }
@@ -1077,9 +1085,11 @@ void main() {
         return awaitFakeAsync((async) async {
           await prepare();

+          bool expectDontBlock = false;
           for (int i = 0; i < UpdateMachine.transientFailureCountNotifyThreshold; i++) {
             prepareError();
-            pollAndFail(async);
+            pollAndFail(async, expectDontBlock: expectDontBlock);
+            expectDontBlock = true;
             check(takeLastReportedError()).isNull();
             async.flushTimers();
             if (!identical(store, globalStore.perAccountSync(store.accountId))) {
@@ -1087,11 +1097,14 @@ void main() {
               updateFromGlobalStore();
               updateMachine.debugPauseLoop();
               updateMachine.poll();
+              // Loading indicator is cleared on successful /register;
+              // we don't need dont_block for the new queue's first poll.
+              expectDontBlock = false;
             }
           }

           prepareError();
-          pollAndFail(async);
+          pollAndFail(async, expectDontBlock: expectDontBlock);
           // Still no error reported, even after the same number of iterations
           // where other errors get reported (as [checkLateReported] checks).
           check(takeLastReportedError()).isNull();
@@ -1186,7 +1199,7 @@ void main() {
       globalStore.clearCachedApiConnections();
       updateMachine.debugAdvanceLoop();
       async.elapse(Duration.zero); // the bad-event-queue error arrives
-      check(store).isLoading.isTrue();
+      check(store).isRecoveringEventStream.isTrue();
     }

     test('user logged out before new store is loaded', () => awaitFakeAsync((async) async {
diff --git a/test/widgets/app_bar_test.dart b/test/widgets/app_bar_test.dart
index 099471d4f7..f4178b455a 100644
--- a/test/widgets/app_bar_test.dart
+++ b/test/widgets/app_bar_test.dart
@@ -31,7 +31,7 @@ void main() {
     await tester.pumpAndSettle();
     final rectBefore = tester.getRect(find.byType(ZulipAppBar));
     check(finder.evaluate()).isEmpty();
-    store.isLoading = true;
+    store.isRecoveringEventStream = true;

     await tester.pump();
     check(tester.getRect(find.byType(ZulipAppBar))).equals(rectBefore);