initial_snapshot: Centralize group-permission defaults; fix admin permission for legacy servers

Fixes #1850.

Also does the refactor Greg suggested here: https://github.com/zulip/zulip-flutter/pull/1842#discussion_r2331337006

Make `role:administrators` the default for `realm_can_delete_any_message_group`
Server 10 added the `realm_can_delete_any_message_group` permission. For servers before that, we're meant to use `role:administrators` when deciding whether to show the "delete message" button, but (with #1842, not yet merged) we'll actually use `role:nobody`. We should fix that and refactor a bit; see discussion: https://github.com/zulip/zulip-flutter/pull/1842#discussion_r2331362461

flutter test test/model/message_test.dart test/model/realm_test.dart test/model/user_test.dart --reporter expanded 2>&1

diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart
index a8e99dde28..9250f75fa2 100644
--- a/lib/api/model/initial_snapshot.dart
+++ b/lib/api/model/initial_snapshot.dart
@@ -424,248 +424,3 @@ class UnreadHuddleSnapshot {

   Map<String, dynamic> toJson() => _$UnreadHuddleSnapshotToJson(this);
 }
-
-/// Metadata about how to interpret the various group-based permission settings.
-///
-/// This is the type that [InitialSnapshot.serverSupportedPermissionSettings]
-/// would have, according to the API as it exists as of 2025-08;
-/// but that API is documented as unstable and subject to change.
-///
-/// For a useful value of this type, see [SupportedPermissionSettings.fixture].
-///
-/// For docs, search for "d_perm" in: https://zulip.com/api/register-queue
-@JsonSerializable(fieldRename: FieldRename.snake)
-class SupportedPermissionSettings {
-  final Map<String, PermissionSettingsItem> realm;
-  final Map<String, PermissionSettingsItem> stream;
-  final Map<String, PermissionSettingsItem> group;
-
-  /// Metadata about how to interpret certain group-based permission settings,
-  /// including all those that this client uses, based on "current" servers.
-  ///
-  /// "Current" here means as of when this code was written, or last updated;
-  /// details in comments below.  Naturally it'd be better to have an API to
-  /// get this information from the actual server.
-  ///
-  /// Effectively we're counting on it being uncommon for the metadata for a
-  /// given permission to ever change from one server version to the next,
-  /// so that the values we take from one server version usually remain valid
-  /// for all past and future server versions that have the corresponding
-  /// permission at all.
-  ///
-  /// TODO(server): Stabilize [InitialSnapshot.serverSupportedPermissionSettings]
-  ///   or a similar API, and switch to using that.  See thread:
-  ///     https://chat.zulip.org/#narrow/channel/378-api-design/topic/server_supported_permission_settings/near/2247549
-  static SupportedPermissionSettings fixture = SupportedPermissionSettings(
-    realm: {
-      // From the server's Realm.REALM_PERMISSION_GROUP_SETTINGS,
-      // in zerver/models/realms.py.  Current as of 6ab30fcce, 2025-08.
-      'create_multiuse_invite_group': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: false,
-          // default_group_name=SystemGroups.ADMINISTRATORS,
-      ),
-      'can_access_all_users_group': PermissionSettingsItem(
-          // require_system_group=True,
-          // allow_nobody_group=False,
-          allowEveryoneGroup: true,
-          // default_group_name=SystemGroups.EVERYONE,
-          // # Note that user_can_access_all_other_users in the web
-          // # app is relying on members always have access.
-          // allowed_system_groups=[SystemGroups.EVERYONE, SystemGroups.MEMBERS],
-      ),
-      'can_add_subscribers_group': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: false,
-          // default_group_name=SystemGroups.MEMBERS,
-      ),
-      'can_add_custom_emoji_group': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: false,
-          // default_group_name=SystemGroups.MEMBERS,
-      ),
-      'can_create_bots_group': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: false,
-          // default_group_name=SystemGroups.MEMBERS,
-      ),
-      'can_create_groups': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: false,
-          // default_group_name=SystemGroups.MEMBERS,
-      ),
-      'can_create_public_channel_group': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: false,
-          // default_group_name=SystemGroups.MEMBERS,
-      ),
-      'can_create_private_channel_group': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: false,
-          // default_group_name=SystemGroups.MEMBERS,
-      ),
-      'can_create_web_public_channel_group': PermissionSettingsItem(
-          // require_system_group=True,
-          // allow_nobody_group=True,
-          allowEveryoneGroup: false,
-          // default_group_name=SystemGroups.OWNERS,
-          // allowed_system_groups=[
-          //     SystemGroups.MODERATORS,
-          //     SystemGroups.ADMINISTRATORS,
-          //     SystemGroups.OWNERS,
-          //     SystemGroups.NOBODY,
-          // ],
-      ),
-      'can_create_write_only_bots_group': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: false,
-          // default_group_name=SystemGroups.MEMBERS,
-      ),
-      'can_delete_any_message_group': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: false,
-          // default_group_name=SystemGroups.ADMINISTRATORS,
-      ),
-      'can_delete_own_message_group': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: true,
-          // default_group_name=SystemGroups.EVERYONE,
-      ),
-      'can_invite_users_group': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: false,
-          // default_group_name=SystemGroups.MEMBERS,
-      ),
-      'can_manage_all_groups': PermissionSettingsItem(
-          // allow_nobody_group=False,
-          allowEveryoneGroup: false,
-          // default_group_name=SystemGroups.OWNERS,
-      ),
-      'can_manage_billing_group': PermissionSettingsItem(
-          // allow_nobody_group=False,
-          allowEveryoneGroup: false,
-          // default_group_name=SystemGroups.ADMINISTRATORS,
-      ),
-      'can_mention_many_users_group': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: true,
-          // default_group_name=SystemGroups.ADMINISTRATORS,
-      ),
-      'can_move_messages_between_channels_group': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: false,
-          // default_group_name=SystemGroups.MEMBERS,
-      ),
-      'can_move_messages_between_topics_group': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: true,
-          // default_group_name=SystemGroups.EVERYONE,
-      ),
-      'can_resolve_topics_group': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: true,
-          // default_group_name=SystemGroups.EVERYONE,
-      ),
-      'can_set_delete_message_policy_group': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: false,
-          // default_group_name=SystemGroups.MODERATORS,
-      ),
-      'can_set_topics_policy_group': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: true,
-          // default_group_name=SystemGroups.MEMBERS,
-      ),
-      'can_summarize_topics_group': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: true,
-          // default_group_name=SystemGroups.EVERYONE,
-      ),
-      'direct_message_initiator_group': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: true,
-          // default_group_name=SystemGroups.EVERYONE,
-      ),
-      'direct_message_permission_group': PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: true,
-          // default_group_name=SystemGroups.EVERYONE,
-      ),
-    },
-    group: {}, // Please go ahead and fill this in when we come to need it.
-    stream: {
-      // From the server's Stream.stream_permission_group_settings,
-      // in zerver/models/streams.py.  Current as of f9dc13014, 2025-08.
-      "can_add_subscribers_group": PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: false,
-          // default_group_name=SystemGroups.NOBODY,
-      ),
-      "can_administer_channel_group": PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: false,
-          // default_group_name="stream_creator_or_nobody",
-      ),
-      "can_delete_any_message_group": PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: true,
-          // default_group_name=SystemGroups.NOBODY,
-      ),
-      "can_delete_own_message_group": PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: true,
-          // default_group_name=SystemGroups.NOBODY,
-      ),
-      "can_move_messages_out_of_channel_group": PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: true,
-          // default_group_name=SystemGroups.NOBODY,
-      ),
-      "can_move_messages_within_channel_group": PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: true,
-          // default_group_name=SystemGroups.NOBODY,
-      ),
-      "can_remove_subscribers_group": PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: true,
-          // default_group_name=SystemGroups.ADMINISTRATORS,
-      ),
-      "can_send_message_group": PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: true,
-          // default_group_name=SystemGroups.EVERYONE,
-      ),
-      "can_subscribe_group": PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: false,
-          // default_group_name=SystemGroups.NOBODY,
-      ),
-      "can_resolve_topics_group": PermissionSettingsItem(
-          // allow_nobody_group=True,
-          allowEveryoneGroup: true,
-          // default_group_name=SystemGroups.NOBODY,
-      ),
-    },
-  );
-
-  SupportedPermissionSettings({required this.realm, required this.stream, required this.group});
-
-  factory SupportedPermissionSettings.fromJson(Map<String, dynamic> json) =>
-    _$SupportedPermissionSettingsFromJson(json);
-
-  Map<String, dynamic> toJson() => _$SupportedPermissionSettingsToJson(this);
-}
-
-@JsonSerializable(fieldRename: FieldRename.snake)
-class PermissionSettingsItem {
-  final bool allowEveryoneGroup;
-  // also other fields not yet used
-
-  PermissionSettingsItem({required this.allowEveryoneGroup});
-
-  factory PermissionSettingsItem.fromJson(Map<String, dynamic> json) =>
-    _$PermissionSettingsItemFromJson(json);
-
-  Map<String, dynamic> toJson() => _$PermissionSettingsItemToJson(this);
-}
diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart
index 4a4d153e2c..ffcbfba827 100644
--- a/lib/api/model/initial_snapshot.g.dart
+++ b/lib/api/model/initial_snapshot.g.dart
@@ -382,38 +382,3 @@ Map<String, dynamic> _$UnreadHuddleSnapshotToJson(
   'user_ids_string': instance.userIdsString,
   'unread_message_ids': instance.unreadMessageIds,
 };
-
-SupportedPermissionSettings _$SupportedPermissionSettingsFromJson(
-  Map<String, dynamic> json,
-) => SupportedPermissionSettings(
-  realm: (json['realm'] as Map<String, dynamic>).map(
-    (k, e) =>
-        MapEntry(k, PermissionSettingsItem.fromJson(e as Map<String, dynamic>)),
-  ),
-  stream: (json['stream'] as Map<String, dynamic>).map(
-    (k, e) =>
-        MapEntry(k, PermissionSettingsItem.fromJson(e as Map<String, dynamic>)),
-  ),
-  group: (json['group'] as Map<String, dynamic>).map(
-    (k, e) =>
-        MapEntry(k, PermissionSettingsItem.fromJson(e as Map<String, dynamic>)),
-  ),
-);
-
-Map<String, dynamic> _$SupportedPermissionSettingsToJson(
-  SupportedPermissionSettings instance,
-) => <String, dynamic>{
-  'realm': instance.realm,
-  'stream': instance.stream,
-  'group': instance.group,
-};
-
-PermissionSettingsItem _$PermissionSettingsItemFromJson(
-  Map<String, dynamic> json,
-) => PermissionSettingsItem(
-  allowEveryoneGroup: json['allow_everyone_group'] as bool,
-);
-
-Map<String, dynamic> _$PermissionSettingsItemToJson(
-  PermissionSettingsItem instance,
-) => <String, dynamic>{'allow_everyone_group': instance.allowEveryoneGroup};
diff --git a/lib/api/model/permission.dart b/lib/api/model/permission.dart
new file mode 100644
index 0000000000..315f781ea8
--- /dev/null
+++ b/lib/api/model/permission.dart
@@ -0,0 +1,354 @@
+import 'package:json_annotation/json_annotation.dart';
+
+part 'permission.g.dart';
+
+/// Metadata about how to interpret the various group-based permission settings.
+///
+/// This is the type that [InitialSnapshot.serverSupportedPermissionSettings]
+/// would have, according to the API as it exists as of 2025-08;
+/// but that API is documented as unstable and subject to change.
+///
+/// For a useful value of this type, see [SupportedPermissionSettings.fixture].
+///
+/// For docs, search for "d_perm" in: https://zulip.com/api/register-queue
+@JsonSerializable(fieldRename: FieldRename.snake)
+class SupportedPermissionSettings {
+  final Map<String, PermissionSettingsItem> realm;
+  final Map<String, PermissionSettingsItem> stream;
+  final Map<String, PermissionSettingsItem> group;
+
+  /// Metadata about how to interpret certain group-based permission settings,
+  /// including all those that this client uses, based on "current" servers.
+  ///
+  /// "Current" here means as of when this code was written, or last updated;
+  /// details in comments below.  Naturally it'd be better to have an API to
+  /// get this information from the actual server.
+  ///
+  /// Effectively we're counting on it being uncommon for the metadata for a
+  /// given permission to ever change from one server version to the next,
+  /// so that the values we take from one server version usually remain valid
+  /// for all past and future server versions that have the corresponding
+  /// permission at all.
+  ///
+  /// TODO(server): Stabilize [InitialSnapshot.serverSupportedPermissionSettings]
+  ///   or a similar API, and switch to using that.  See thread:
+  ///     https://chat.zulip.org/#narrow/channel/378-api-design/topic/server_supported_permission_settings/near/2247549
+  // TODO: When we get this data from the server, it will sometimes be missing
+  //   items that appear here, because they're for newer permissions that the
+  //   server doesn't know about. We'll want reasonable fallback behavior for
+  //   those missing items, and as a source for that, we can still record
+  //   current-server data somewhere in our codebase. Discussion:
+  //     https://github.com/zulip/zulip-flutter/pull/1842#discussion_r2331337006
+  static SupportedPermissionSettings fixture = SupportedPermissionSettings(
+    realm: {
+      // From the server's Realm.REALM_PERMISSION_GROUP_SETTINGS,
+      // in zerver/models/realms.py.  Current as of 6ab30fcce, 2025-08.
+      'create_multiuse_invite_group': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: false,
+          defaultGroupName: SystemGroupName.administrators,
+      ),
+      'can_access_all_users_group': PermissionSettingsItem(
+          // require_system_group=True,
+          // allow_nobody_group=False,
+          allowEveryoneGroup: true,
+          defaultGroupName: SystemGroupName.everyone,
+          // # Note that user_can_access_all_other_users in the web
+          // # app is relying on members always have access.
+          // allowed_system_groups=[SystemGroups.EVERYONE, SystemGroups.MEMBERS],
+      ),
+      'can_add_subscribers_group': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: false,
+          defaultGroupName: SystemGroupName.members,
+      ),
+      'can_add_custom_emoji_group': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: false,
+          defaultGroupName: SystemGroupName.members,
+      ),
+      'can_create_bots_group': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: false,
+          defaultGroupName: SystemGroupName.members,
+      ),
+      'can_create_groups': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: false,
+          defaultGroupName: SystemGroupName.members,
+      ),
+      'can_create_public_channel_group': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: false,
+          defaultGroupName: SystemGroupName.members,
+      ),
+      'can_create_private_channel_group': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: false,
+          defaultGroupName: SystemGroupName.members,
+      ),
+      'can_create_web_public_channel_group': PermissionSettingsItem(
+          // require_system_group=True,
+          // allow_nobody_group=True,
+          allowEveryoneGroup: false,
+          defaultGroupName: SystemGroupName.owners,
+          // allowed_system_groups=[
+          //     SystemGroups.MODERATORS,
+          //     SystemGroups.ADMINISTRATORS,
+          //     SystemGroups.OWNERS,
+          //     SystemGroups.NOBODY,
+          // ],
+      ),
+      'can_create_write_only_bots_group': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: false,
+          defaultGroupName: SystemGroupName.members,
+      ),
+      'can_delete_any_message_group': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: false,
+          defaultGroupName: SystemGroupName.administrators,
+      ),
+      'can_delete_own_message_group': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: true,
+          defaultGroupName: SystemGroupName.everyone,
+      ),
+      'can_invite_users_group': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: false,
+          defaultGroupName: SystemGroupName.members,
+      ),
+      'can_manage_all_groups': PermissionSettingsItem(
+          // allow_nobody_group=False,
+          allowEveryoneGroup: false,
+          defaultGroupName: SystemGroupName.owners,
+      ),
+      'can_manage_billing_group': PermissionSettingsItem(
+          // allow_nobody_group=False,
+          allowEveryoneGroup: false,
+          defaultGroupName: SystemGroupName.administrators,
+      ),
+      'can_mention_many_users_group': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: true,
+          defaultGroupName: SystemGroupName.administrators,
+      ),
+      'can_move_messages_between_channels_group': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: false,
+          defaultGroupName: SystemGroupName.members,
+      ),
+      'can_move_messages_between_topics_group': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: true,
+          defaultGroupName: SystemGroupName.everyone,
+      ),
+      'can_resolve_topics_group': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: true,
+          defaultGroupName: SystemGroupName.everyone,
+      ),
+      'can_set_delete_message_policy_group': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: false,
+          defaultGroupName: SystemGroupName.moderators,
+      ),
+      'can_set_topics_policy_group': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: true,
+          defaultGroupName: SystemGroupName.members,
+      ),
+      'can_summarize_topics_group': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: true,
+          defaultGroupName: SystemGroupName.everyone,
+      ),
+      'direct_message_initiator_group': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: true,
+          defaultGroupName: SystemGroupName.everyone,
+      ),
+      'direct_message_permission_group': PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: true,
+          defaultGroupName: SystemGroupName.everyone,
+      ),
+    },
+    group: {}, // Please go ahead and fill this in when we come to need it.
+    stream: {
+      // From the server's Stream.stream_permission_group_settings,
+      // in zerver/models/streams.py.  Current as of f9dc13014, 2025-08.
+      "can_add_subscribers_group": PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: false,
+          defaultGroupName: SystemGroupName.nobody,
+      ),
+      "can_administer_channel_group": PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: false,
+          defaultGroupName: PseudoSystemGroupName.streamCreatorOrNobody,
+      ),
+      "can_delete_any_message_group": PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: true,
+          defaultGroupName: SystemGroupName.nobody,
+      ),
+      "can_delete_own_message_group": PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: true,
+          defaultGroupName: SystemGroupName.nobody,
+      ),
+      "can_move_messages_out_of_channel_group": PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: true,
+          defaultGroupName: SystemGroupName.nobody,
+      ),
+      "can_move_messages_within_channel_group": PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: true,
+          defaultGroupName: SystemGroupName.nobody,
+      ),
+      "can_remove_subscribers_group": PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: true,
+          defaultGroupName: SystemGroupName.administrators,
+      ),
+      "can_send_message_group": PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: true,
+          defaultGroupName: SystemGroupName.everyone,
+      ),
+      "can_subscribe_group": PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: false,
+          defaultGroupName: SystemGroupName.nobody,
+      ),
+      "can_resolve_topics_group": PermissionSettingsItem(
+          // allow_nobody_group=True,
+          allowEveryoneGroup: true,
+          defaultGroupName: SystemGroupName.nobody,
+      ),
+    },
+  );
+
+  SupportedPermissionSettings({required this.realm, required this.stream, required this.group});
+
+  factory SupportedPermissionSettings.fromJson(Map<String, dynamic> json) =>
+    _$SupportedPermissionSettingsFromJson(json);
+
+  Map<String, dynamic> toJson() => _$SupportedPermissionSettingsToJson(this);
+}
+
+@JsonSerializable(fieldRename: FieldRename.snake)
+class PermissionSettingsItem {
+  final bool allowEveryoneGroup;
+
+  final DefaultGroupName defaultGroupName;
+
+  // also other fields not yet used
+
+  PermissionSettingsItem({
+    required this.allowEveryoneGroup,
+    required this.defaultGroupName,
+  });
+
+  factory PermissionSettingsItem.fromJson(Map<String, dynamic> json) =>
+    _$PermissionSettingsItemFromJson(json);
+
+  Map<String, dynamic> toJson() => _$PermissionSettingsItemToJson(this);
+}
+
+/// A value of [PermissionSettingsItem.defaultGroupName].
+///
+/// Can be any of these:
+/// - a known system group [SystemGroupName]
+/// - a known special string [PseudoSystemGroupName]
+/// - an unknown system group or special string [DefaultGroupNameUnknown]
+sealed class DefaultGroupName {
+  DefaultGroupName();
+
+  factory DefaultGroupName.fromJson(String json) {
+    final DefaultGroupName? maybeResult = json.startsWith('role:')
+      ? SystemGroupName.fromJson(json)
+      : PseudoSystemGroupName.fromJson(json);
+    return maybeResult ?? DefaultGroupNameUnknown(json);
+  }
+
+  String toJson();
+}
+
+class DefaultGroupNameUnknown extends DefaultGroupName {
+  DefaultGroupNameUnknown(this.apiValue);
+  final String apiValue;
+
+  @override
+  String toJson() => apiValue;
+}
+
+/// A known special string
+/// that [PermissionSettingsItem.defaultGroupName] might be.
+///
+/// See server implementation, e.g.
+/// `can_administer_channel_group` in zerver/models/streams.py.
+@JsonEnum(valueField: 'apiValue', alwaysCreate: true)
+enum PseudoSystemGroupName implements DefaultGroupName {
+  // Discussion on this; it looks like it might get renamed:
+  //   https://chat.zulip.org/#narrow/channel/378-api-design/topic/stream_creator_or_nobody/near/2258637
+  streamCreatorOrNobody(apiValue: 'stream_creator_or_nobody'),
+  ;
+
+  const PseudoSystemGroupName({required this.apiValue});
+
+  final String apiValue;
+
+  /// Get a [PseudoSystemGroupName] from an [apiValue],
+  /// or null if it's not recognized.
+  ///
+  /// Example:
+  ///   'stream_creator_or_nobody' -> PseudoSystemGroupName.streamCreatorOrNobody
+  static PseudoSystemGroupName? fromJson(String json) => _byApiValue[json];
+
+  // _$…EnumMap is thanks to `alwaysCreate: true`
+  static final _byApiValue = _$PseudoSystemGroupNameEnumMap
+    .map((key, value) => MapEntry(value, key));
+
+  @override
+  String toJson() => _$PseudoSystemGroupNameEnumMap[this]!;
+}
+
+/// A known canonical name for a system group.
+///
+/// Doc: https://zulip.com/api/group-setting-values#system-groups
+@JsonEnum(valueField: 'apiValue', alwaysCreate: true)
+enum SystemGroupName implements DefaultGroupName {
+  // TODO(#1096) audit all references when implementing public-access option
+  everyoneOnInternet(apiValue: 'role:internet'),
+
+  everyone(apiValue: 'role:everyone'),
+  members(apiValue: 'role:members'),
+  fullMembers(apiValue: 'role:fullmembers'),
+  moderators(apiValue: 'role:moderators'),
+  administrators(apiValue: 'role:administrators'),
+  owners(apiValue: 'role:owners'),
+  nobody(apiValue: 'role:nobody'),
+  ;
+
+  const SystemGroupName({required this.apiValue});
+
+  final String apiValue;
+
+  /// Get a [SystemGroupName] from an [apiValue],
+  /// or null if it's not recognized.
+  ///
+  /// Example:
+  ///   'role:administrators' -> SystemGroupName.administrators
+  static SystemGroupName? fromJson(String json) => _byApiValue[json];
+
+  // _$…EnumMap is thanks to `alwaysCreate: true`
+  static final _byApiValue = _$SystemGroupNameEnumMap
+    .map((key, value) => MapEntry(value, key));
+
+  @override
+  String toJson() => _$SystemGroupNameEnumMap[this]!;
+}
diff --git a/lib/api/model/permission.g.dart b/lib/api/model/permission.g.dart
new file mode 100644
index 0000000000..280822f58f
--- /dev/null
+++ b/lib/api/model/permission.g.dart
@@ -0,0 +1,65 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+// ignore_for_file: constant_identifier_names, unnecessary_cast
+
+part of 'permission.dart';
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+SupportedPermissionSettings _$SupportedPermissionSettingsFromJson(
+  Map<String, dynamic> json,
+) => SupportedPermissionSettings(
+  realm: (json['realm'] as Map<String, dynamic>).map(
+    (k, e) =>
+        MapEntry(k, PermissionSettingsItem.fromJson(e as Map<String, dynamic>)),
+  ),
+  stream: (json['stream'] as Map<String, dynamic>).map(
+    (k, e) =>
+        MapEntry(k, PermissionSettingsItem.fromJson(e as Map<String, dynamic>)),
+  ),
+  group: (json['group'] as Map<String, dynamic>).map(
+    (k, e) =>
+        MapEntry(k, PermissionSettingsItem.fromJson(e as Map<String, dynamic>)),
+  ),
+);
+
+Map<String, dynamic> _$SupportedPermissionSettingsToJson(
+  SupportedPermissionSettings instance,
+) => <String, dynamic>{
+  'realm': instance.realm,
+  'stream': instance.stream,
+  'group': instance.group,
+};
+
+PermissionSettingsItem _$PermissionSettingsItemFromJson(
+  Map<String, dynamic> json,
+) => PermissionSettingsItem(
+  allowEveryoneGroup: json['allow_everyone_group'] as bool,
+  defaultGroupName: DefaultGroupName.fromJson(
+    json['default_group_name'] as String,
+  ),
+);
+
+Map<String, dynamic> _$PermissionSettingsItemToJson(
+  PermissionSettingsItem instance,
+) => <String, dynamic>{
+  'allow_everyone_group': instance.allowEveryoneGroup,
+  'default_group_name': instance.defaultGroupName,
+};
+
+const _$PseudoSystemGroupNameEnumMap = {
+  PseudoSystemGroupName.streamCreatorOrNobody: 'stream_creator_or_nobody',
+};
+
+const _$SystemGroupNameEnumMap = {
+  SystemGroupName.everyoneOnInternet: 'role:internet',
+  SystemGroupName.everyone: 'role:everyone',
+  SystemGroupName.members: 'role:members',
+  SystemGroupName.fullMembers: 'role:fullmembers',
+  SystemGroupName.moderators: 'role:moderators',
+  SystemGroupName.administrators: 'role:administrators',
+  SystemGroupName.owners: 'role:owners',
+  SystemGroupName.nobody: 'role:nobody',
+};
diff --git a/lib/model/channel.dart b/lib/model/channel.dart
index e4a40790d4..0403ba0bdf 100644
--- a/lib/model/channel.dart
+++ b/lib/model/channel.dart
@@ -174,25 +174,14 @@ mixin ChannelStore on UserStore {

   bool _selfHasContentAccessViaGroupPermissions(ZulipStream channel) {
     // Compare web's stream_data.has_content_access_via_group_permissions.
-    // TODO(#814) try to clean up this logic; perhaps record more explicitly
-    //   what default/fallback value to use for a given group-based permission
-    //   on older servers.
-
-    if (channel.canAddSubscribersGroup != null
-        && selfHasPermissionForGroupSetting(channel.canAddSubscribersGroup!,
-             GroupSettingType.stream, 'can_add_subscribers_group')) {
-      // The behavior before this permission was introduced was equivalent to
-      // the "nobody" group.
-      // TODO(server-10): simplify
+
+    if (selfHasPermissionForGroupSetting(channel.canAddSubscribersGroup,
+          GroupSettingType.stream, 'can_add_subscribers_group')) {
       return true;
     }

-    if (channel.canSubscribeGroup != null
-        && selfHasPermissionForGroupSetting(channel.canSubscribeGroup!,
-             GroupSettingType.stream, 'can_subscribe_group')) {
-      // The behavior before this permission was introduced was equivalent to
-      // the "nobody" group.
-      // TODO(server-10): simplify
+    if (selfHasPermissionForGroupSetting(channel.canSubscribeGroup,
+          GroupSettingType.stream, 'can_subscribe_group')) {
       return true;
     }

@@ -213,7 +202,7 @@ mixin ChannelStore on UserStore {
       case ChannelPostPolicy.fullMembers:     {
         if (!role.isAtLeast(UserRole.member)) return false;
         if (role == UserRole.member) {
-          return hasPassedWaitingPeriod(selfUser, byDate: byDate);
+          return selfHasPassedWaitingPeriod(byDate: byDate);
         }
         return true;
       }
diff --git a/lib/model/message.dart b/lib/model/message.dart
index 9cc4d76f48..68c20c0d65 100644
--- a/lib/model/message.dart
+++ b/lib/model/message.dart
@@ -107,18 +107,14 @@ mixin MessageStore on ChannelStore {
       return false;
     }

-    // TODO(#1850) really the default should be `role:administrators`:
-    //   https://github.com/zulip/zulip-flutter/pull/1842#discussion_r2331362461
-    if (realmCanDeleteAnyMessageGroup != null
-        && selfHasPermissionForGroupSetting(realmCanDeleteAnyMessageGroup!,
-             GroupSettingType.realm, 'can_delete_any_message_group')) {
+    if (selfHasPermissionForGroupSetting(realmCanDeleteAnyMessageGroup,
+          GroupSettingType.realm, 'can_delete_any_message_group')) {
       return true;
     }

     if (channel != null) {
-      if (channel.canDeleteAnyMessageGroup != null
-          && selfHasPermissionForGroupSetting(channel.canDeleteAnyMessageGroup!,
-               GroupSettingType.stream, 'can_delete_any_message_group')) {
+      if (selfHasPermissionForGroupSetting(channel.canDeleteAnyMessageGroup,
+            GroupSettingType.stream, 'can_delete_any_message_group')) {
         return true;
       }
     }
@@ -137,6 +133,9 @@ mixin MessageStore on ChannelStore {
     // that's impossible here because `message` can't be an [OutboxMessage]
     // (it's a [Message] from [MessageStore.messages]).

+    // (selfHasPermissionForGroupSetting isn't equipped to handle the old-server
+    // fallback logic for this specific permission; it's dynamic and depends on
+    // realmDeleteOwnMessagePolicy, so we do our own null check here.)
     if (realmCanDeleteOwnMessageGroup != null) {
       if (!selfHasPermissionForGroupSetting(realmCanDeleteOwnMessageGroup!,
             GroupSettingType.realm, 'can_delete_own_message_group')) {
@@ -145,11 +144,8 @@ mixin MessageStore on ChannelStore {
           return false;
         }

-        if (
-          channel.canDeleteOwnMessageGroup == null
-          || !selfHasPermissionForGroupSetting(channel.canDeleteOwnMessageGroup!,
-               GroupSettingType.stream, 'can_delete_own_message_group')
-        ) {
+        if (!selfHasPermissionForGroupSetting(channel.canDeleteOwnMessageGroup,
+              GroupSettingType.stream, 'can_delete_own_message_group')) {
           return false;
         }
       }
@@ -185,7 +181,7 @@ mixin MessageStore on ChannelStore {
       case RealmDeleteOwnMessagePolicy.fullMembers: {
         if (!role.isAtLeast(UserRole.member)) return false;
         if (role == UserRole.member) {
-          return hasPassedWaitingPeriod(selfUser, byDate: atDate);
+          return selfHasPassedWaitingPeriod(byDate: atDate);
         }
         return true;
       }
diff --git a/lib/model/realm.dart b/lib/model/realm.dart
index 782740405d..48036a343a 100644
--- a/lib/model/realm.dart
+++ b/lib/model/realm.dart
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
 import '../api/model/events.dart';
 import '../api/model/initial_snapshot.dart';
 import '../api/model/model.dart';
+import '../api/model/permission.dart';
 import 'store.dart';
 import 'user_group.dart';

@@ -128,9 +129,22 @@ mixin RealmStore on PerAccountStoreBase, UserGroupStore {
     return topic;
   }

+  /// Whether the self-user has passed the realm's waiting period
+  /// to be a full member.
+  ///
+  /// See:
+  ///   https://zulip.com/api/roles-and-permissions#determining-if-a-user-is-a-full-member
+  ///
+  /// To determine if the self-user is a full member,
+  /// callers must also check that the user's role is at least [UserRole.member].
+  bool selfHasPassedWaitingPeriod({required DateTime byDate});
+
   /// Whether the self-user has the given (group-based) permission.
-  bool selfHasPermissionForGroupSetting(GroupSettingValue value,
-      GroupSettingType type, String name);
+  ///
+  /// If the server doesn't know about the permission,
+  /// pass null for [value] and a reasonable default will be chosen.
+  bool selfHasPermissionForGroupSetting(GroupSettingValue? value,
+    GroupSettingType type, String name);
 }

 enum GroupSettingType { realm, stream, group }
@@ -180,7 +194,10 @@ mixin ProxyRealmStore on RealmStore {
   @override
   List<CustomProfileField> get customProfileFields => realmStore.customProfileFields;
   @override
-  bool selfHasPermissionForGroupSetting(GroupSettingValue value, GroupSettingType type, String name) =>
+  bool selfHasPassedWaitingPeriod({required DateTime byDate}) =>
+    realmStore.selfHasPassedWaitingPeriod(byDate: byDate);
+  @override
+  bool selfHasPermissionForGroupSetting(GroupSettingValue? value, GroupSettingType type, String name) =>
     realmStore.selfHasPermissionForGroupSetting(value, type, name);
 }

@@ -203,6 +220,7 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore {
     required User selfUser,
   }) :
     _selfUserRole = selfUser.role,
+    _selfUserDateJoined = selfUser.dateJoined,
     serverPresencePingIntervalSeconds = initialSnapshot.serverPresencePingIntervalSeconds,
     serverPresenceOfflineThresholdSeconds = initialSnapshot.serverPresenceOfflineThresholdSeconds,
     serverTypingStartedExpiryPeriodMilliseconds = initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds,
@@ -225,7 +243,23 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore {
     customProfileFields = _sortCustomProfileFields(initialSnapshot.customProfileFields);

   @override
-  bool selfHasPermissionForGroupSetting(GroupSettingValue value,
+  bool selfHasPassedWaitingPeriod({required DateTime byDate}) {
+    // [User.dateJoined] is in UTC. For logged-in users, the format is:
+    // YYYY-MM-DDTHH:mm+00:00, which includes the timezone offset for UTC.
+    // For logged-out spectators, the format is: YYYY-MM-DD, which doesn't
+    // include the timezone offset. In the later case, [DateTime.parse] will
+    // interpret it as the client's local timezone, which could lead to
+    // incorrect results; but that's acceptable for now because the app
+    // doesn't support viewing as a spectator.
+    //
+    // See the related discussion:
+    //   https://chat.zulip.org/#narrow/channel/412-api-documentation/topic/provide.20an.20explicit.20format.20for.20.60realm_user.2Edate_joined.60/near/1980194
+    final dateJoined = DateTime.parse(_selfUserDateJoined);
+    return byDate.difference(dateJoined).inDays >= realmWaitingPeriodThreshold;
+  }
+
+  @override
+  bool selfHasPermissionForGroupSetting(GroupSettingValue? value,
       GroupSettingType type, String name) {
     // Compare web's settings_data.user_has_permission_for_group_setting.
     //
@@ -236,13 +270,60 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore {
     // That exists for deciding whether to offer the "Generate email address"
     // button, and if so then which users to offer in the dropdown;
     // it's predicting whether /api/get-stream-email-address would succeed.
-    if (_selfUserRole == UserRole.guest) {
-      final config = _groupSettingConfig(type, name);
-      if (!config.allowEveryoneGroup) return false;
+
+    final config = _groupSettingConfig(type, name);
+
+    if (_selfUserRole == UserRole.guest && !config.allowEveryoneGroup) {
+      return false;
     }
+
+    if (value == null) {
+      // The server doesn't know about the permission. *We* know about it
+      // (or presumably we wouldn't have called this method),
+      // and we know a reasonable default; use that.
+      return _hasPermissionByDefault(config);
+    }
+
     return selfInGroupSetting(value);
   }

+  bool _hasPermissionByDefault(PermissionSettingsItem config) {
+    switch (config.defaultGroupName) {
+      case DefaultGroupNameUnknown():
+        // When we know about a permission, we should also know about the group
+        // we've said is the default value for it.
+        assert(false);
+        return true;
+      case PseudoSystemGroupName.streamCreatorOrNobody:
+        // TODO(#1102) implement
+        assert(() {
+          throw UnimplementedError();
+        }());
+        return true;
+      case SystemGroupName.everyoneOnInternet:
+      case SystemGroupName.everyone:
+        return true;
+      case SystemGroupName.members:
+        return _selfUserRole.isAtLeast(UserRole.member);
+      case SystemGroupName.fullMembers:
+        // There aren't any permissions where this is the default, and we
+        // probably won't add any. So for now we skip the complication of
+        // doing the waiting-period check.
+        assert(() {
+          throw UnimplementedError();
+        }());
+        return _selfUserRole.isAtLeast(UserRole.member);
+      case SystemGroupName.moderators:
+        return _selfUserRole.isAtLeast(UserRole.moderator);
+      case SystemGroupName.administrators:
+        return _selfUserRole.isAtLeast(UserRole.administrator);
+      case SystemGroupName.owners:
+        return _selfUserRole.isAtLeast(UserRole.owner);
+      case SystemGroupName.nobody:
+        return false;
+    }
+  }
+
   /// The metadata for how to interpret the given group-based permission setting.
   PermissionSettingsItem _groupSettingConfig(GroupSettingType type, String name) {
     final supportedSettings = SupportedPermissionSettings.fixture;
@@ -262,8 +343,20 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore {
   /// The main home of this information is [UserStore]: `store.selfUser.role`.
   /// We need it here for interpreting some permission settings;
   /// so we denormalize it here to avoid a cycle between substores.
+  ///
+  /// See also [_selfUserDateJoined].
   UserRole _selfUserRole;

+  /// The [User.dateJoined] of the self-user.
+  ///
+  /// The main home of this information is [UserStore]:
+  /// `store.selfUser.dateJoined`.
+  /// We need it here for interpreting some permission settings;
+  /// so we denormalize it here to avoid a cycle between substores.
+  ///
+  /// See also [_selfUserRole].
+  final String _selfUserDateJoined;
+
   @override
   final int serverPresencePingIntervalSeconds;
   @override
diff --git a/lib/model/user.dart b/lib/model/user.dart
index 4983cf48ca..ef26b9e589 100644
--- a/lib/model/user.dart
+++ b/lib/model/user.dart
@@ -91,28 +91,6 @@ mixin UserStore on PerAccountStoreBase, RealmStore {
     return getUser(senderId)?.fullName ?? message.senderFullName;
   }

-  /// Whether [user] has passed the realm's waiting period to be a full member.
-  ///
-  /// See:
-  ///   https://zulip.com/api/roles-and-permissions#determining-if-a-user-is-a-full-member
-  ///
-  /// To determine if a user is a full member, callers must also check that the
-  /// user's role is at least [UserRole.member].
-  bool hasPassedWaitingPeriod(User user, {required DateTime byDate}) {
-    // [User.dateJoined] is in UTC. For logged-in users, the format is:
-    // YYYY-MM-DDTHH:mm+00:00, which includes the timezone offset for UTC.
-    // For logged-out spectators, the format is: YYYY-MM-DD, which doesn't
-    // include the timezone offset. In the later case, [DateTime.parse] will
-    // interpret it as the client's local timezone, which could lead to
-    // incorrect results; but that's acceptable for now because the app
-    // doesn't support viewing as a spectator.
-    //
-    // See the related discussion:
-    //   https://chat.zulip.org/#narrow/channel/412-api-documentation/topic/provide.20an.20explicit.20format.20for.20.60realm_user.2Edate_joined.60/near/1980194
-    final dateJoined = DateTime.parse(user.dateJoined);
-    return byDate.difference(dateJoined).inDays >= realmWaitingPeriodThreshold;
-  }
-
   /// Whether the user with [userId] is muted by the self-user.
   ///
   /// Looks for [userId] in a private [Set],

diff --git a/test/model/message_test.dart b/test/model/message_test.dart
index 7dbf28a47d..4eccf75112 100644
--- a/test/model/message_test.dart
+++ b/test/model/message_test.dart
@@ -868,7 +868,7 @@ void main() {
   group('selfCanDeleteMessage', () {
     /// Call the method, with setup from [params].
     Future<bool> evaluate(CanDeleteMessageParams params) async {
-      final selfUser = eg.selfUser;
+      final selfUser = eg.user(role: params.selfUserRole);
       final botUserOwnedBySelf = eg.user(isBot: true, botOwnerId: selfUser.userId);
       final botUserNotOwnedBySelf = eg.user(isBot: true, botOwnerId: eg.otherUser.userId);

@@ -1078,8 +1078,8 @@ void main() {
     group('legacy behavior', () {
       group('pre-407', () {
         // The channel-level group permissions don't exist,
-        // so we act as though they were present and denied,
-        // notably by not throwing.
+        // so we act as though they were present with role:nobody,
+        // and we don't throw.

         test('denial is not forced just because one of the permissions is absent (the any-message one)', () async {
           check(await evaluate(
@@ -1127,7 +1127,7 @@ void main() {
         // doesn't exist, so we follow realmDeleteOwnMessagePolicy instead,
         // and we don't error.

-        test('allowed', () async {
+        test('allowed (permissive policy, low role)', () async {
           check(await evaluate(
             CanDeleteMessageParams.pre291(
               senderConfig: CanDeleteMessageSenderConfig.self,
@@ -1135,6 +1135,27 @@ void main() {
               inRealmCanDeleteAnyMessageGroup: false,
               isChannelArchived: false,
               realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone,
+              selfUserRole: UserRole.member,
+          )))
+            ..equals(await evaluate(
+             CanDeleteMessageParams.pre407(
+               senderConfig: CanDeleteMessageSenderConfig.self,
+               timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited,
+               inRealmCanDeleteAnyMessageGroup: false,
+               inRealmCanDeleteOwnMessageGroup: true,
+               isChannelArchived: false)))
+            ..isTrue();
+        });
+
+        test('allowed (strict policy, high role)', () async {
+          check(await evaluate(
+            CanDeleteMessageParams.pre291(
+              senderConfig: CanDeleteMessageSenderConfig.self,
+              timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited,
+              inRealmCanDeleteAnyMessageGroup: false,
+              isChannelArchived: false,
+              realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.admins,
+              selfUserRole: UserRole.administrator,
           )))
             ..equals(await evaluate(
              CanDeleteMessageParams.pre407(
@@ -1154,6 +1175,7 @@ void main() {
               inRealmCanDeleteAnyMessageGroup: false,
               isChannelArchived: false,
               realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.admins,
+              selfUserRole: UserRole.moderator,
           )))..equals(await evaluate(
               CanDeleteMessageParams.pre407(
                 senderConfig: CanDeleteMessageSenderConfig.self,
@@ -1166,26 +1188,47 @@ void main() {
       });

       group('pre-281', () {
-        // The realm-level can-delete-any-message permission
-        // doesn't exist, so we act as though that's present and denied,
-        // notably by not throwing.
+        // The realm-level can-delete-any-message permission doesn't exist,
+        // so we act as though that's present with role:administrators,
+        // and we don't throw.

-        test('denied', () async {
+        test('self-user is not admin', () async {
           check(await evaluate(
             CanDeleteMessageParams.pre281(
               senderConfig: CanDeleteMessageSenderConfig.otherHuman,
               timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited,
               isChannelArchived: false,
               realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone,
+              selfUserRole: UserRole.member,
           )))..equals(await evaluate(
               CanDeleteMessageParams.pre291(
                 senderConfig: CanDeleteMessageSenderConfig.otherHuman,
                 timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited,
                 inRealmCanDeleteAnyMessageGroup: false,
                 isChannelArchived: false,
-                realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone)))
+                realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone,
+                selfUserRole: UserRole.member)))
             ..isFalse();
         });
+
+        test('self-user is admin', () async {
+          check(await evaluate(
+            CanDeleteMessageParams.pre281(
+              senderConfig: CanDeleteMessageSenderConfig.otherHuman,
+              timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited,
+              isChannelArchived: false,
+              realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone,
+              selfUserRole: UserRole.administrator,
+          )))..equals(await evaluate(
+              CanDeleteMessageParams.pre291(
+                senderConfig: CanDeleteMessageSenderConfig.otherHuman,
+                timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited,
+                inRealmCanDeleteAnyMessageGroup: true,
+                isChannelArchived: false,
+                realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone,
+                selfUserRole: UserRole.administrator)))
+            ..isTrue();
+        });
       });
     });
   });
@@ -2006,6 +2049,7 @@ class CanDeleteMessageParams {
   final bool? inChannelCanDeleteAnyMessageGroup;
   final bool? inChannelCanDeleteOwnMessageGroup;
   final RealmDeleteOwnMessagePolicy? realmDeleteOwnMessagePolicy;
+  final UserRole? selfUserRole;

   CanDeleteMessageParams._({
     required this.senderConfig,
@@ -2016,6 +2060,7 @@ class CanDeleteMessageParams {
     required this.inChannelCanDeleteAnyMessageGroup,
     required this.inChannelCanDeleteOwnMessageGroup,
     required this.realmDeleteOwnMessagePolicy,
+    required this.selfUserRole,
   });

   CanDeleteMessageParams.modern({
@@ -2026,7 +2071,9 @@ class CanDeleteMessageParams {
     required this.isChannelArchived,
     required this.inChannelCanDeleteAnyMessageGroup,
     required this.inChannelCanDeleteOwnMessageGroup,
-  }) : realmDeleteOwnMessagePolicy = null;
+  }) :
+    realmDeleteOwnMessagePolicy = null,
+    selfUserRole = null;

   factory CanDeleteMessageParams.restrictiveForChannelMessageExcept({
     CanDeleteMessageSenderConfig? senderConfig,
@@ -2110,6 +2157,7 @@ class CanDeleteMessageParams {
     inChannelCanDeleteAnyMessageGroup: null,
     inChannelCanDeleteOwnMessageGroup: null,
     realmDeleteOwnMessagePolicy: null,
+    selfUserRole: null,
   );

   // TODO(server-10) delete
@@ -2119,6 +2167,7 @@ class CanDeleteMessageParams {
     required bool inRealmCanDeleteAnyMessageGroup,
     required bool? isChannelArchived,
     required RealmDeleteOwnMessagePolicy realmDeleteOwnMessagePolicy,
+    required UserRole selfUserRole,
   }) => CanDeleteMessageParams._(
     senderConfig: senderConfig,
     timeLimitConfig: timeLimitConfig,
@@ -2128,6 +2177,7 @@ class CanDeleteMessageParams {
     inChannelCanDeleteAnyMessageGroup: null,
     inChannelCanDeleteOwnMessageGroup: null,
     realmDeleteOwnMessagePolicy: realmDeleteOwnMessagePolicy,
+    selfUserRole: selfUserRole,
   );

   // TODO(server-10) delete
@@ -2136,6 +2186,7 @@ class CanDeleteMessageParams {
     required CanDeleteMessageTimeLimitConfig timeLimitConfig,
     required bool? isChannelArchived,
     required RealmDeleteOwnMessagePolicy realmDeleteOwnMessagePolicy,
+    required UserRole selfUserRole,
   }) => CanDeleteMessageParams._(
     senderConfig: senderConfig,
     timeLimitConfig: timeLimitConfig,
@@ -2145,6 +2196,7 @@ class CanDeleteMessageParams {
     inChannelCanDeleteAnyMessageGroup: null,
     inChannelCanDeleteOwnMessageGroup: null,
     realmDeleteOwnMessagePolicy: realmDeleteOwnMessagePolicy,
+    selfUserRole: selfUserRole,
   );

   String describe() {
diff --git a/test/model/realm_test.dart b/test/model/realm_test.dart
index 04ceb28855..222a2d1d87 100644
--- a/test/model/realm_test.dart
+++ b/test/model/realm_test.dart
@@ -2,7 +2,9 @@ import 'package:checks/checks.dart';
 import 'package:test/scaffolding.dart';
 import 'package:zulip/api/model/events.dart';
 import 'package:zulip/api/model/model.dart';
+import 'package:zulip/api/model/permission.dart';
 import 'package:zulip/model/realm.dart';
+import 'package:zulip/model/store.dart';

 import '../example_data.dart' as eg;

@@ -35,6 +37,32 @@ void main() {
     doCheck(eg.t('(no topic)'),          eg.t(''),                    370);
   });

+  group('selfHasPassedWaitingPeriod', () {
+    final testCases = [
+      ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 0, 10, 00), false),
+      ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1, 10, 00), false),
+      ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 09, 59), false),
+      ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 10, 00), true),
+      ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1000, 07, 00), true),
+    ];
+
+    for (final (String dateJoined, DateTime currentDate, bool expected) in testCases) {
+      test('self-user joined at $dateJoined ${expected ? 'has' : "hasn't"} '
+          'passed waiting period by $currentDate', () {
+        final selfUser = eg.user(dateJoined: dateJoined);
+        final store = eg.store(
+          selfUser: selfUser,
+          initialSnapshot: eg.initialSnapshot(
+            realmWaitingPeriodThreshold: 2,
+            realmUsers: [selfUser],
+          ),
+        );
+        check(store.selfHasPassedWaitingPeriod(byDate: currentDate))
+          .equals(expected);
+      });
+    }
+  });
+
   group('selfHasPermissionForGroupSetting', () {
     // Most of the implementation of this is in [UserGroupStore.selfInGroupSetting],
     // and is tested in more detail in user_group_test.dart .
@@ -82,6 +110,93 @@ void main() {
       check(hasPermission(selfUser, group, 'can_send_message_group'))
         .isFalse();
     });
+
+    group('fallbacks for permissions not known to the server', () {
+      late PerAccountStore store;
+
+      void prepare({UserRole? selfUserRole}) {
+        final selfUser = eg.user(role: selfUserRole);
+        store = eg.store(selfUser: selfUser,
+          initialSnapshot: eg.initialSnapshot(realmUsers: [selfUser]));
+      }
+
+      void doCheck(GroupSettingType type, String name, bool expected) {
+        check(store.selfHasPermissionForGroupSetting(null, type, name)).equals(expected);
+      }
+
+      for (final pseudoSystemGroupName in PseudoSystemGroupName.values) {
+        switch (pseudoSystemGroupName) {
+          case PseudoSystemGroupName.streamCreatorOrNobody:
+            // TODO implement and test
+        }
+      }
+
+      for (final systemGroupName in SystemGroupName.values) {
+        switch (systemGroupName) {
+          case SystemGroupName.everyoneOnInternet:
+            // (No permissions where we use this default value; continue.)
+            break;
+          case SystemGroupName.everyone:
+            test('everyone', () {
+              prepare(selfUserRole: UserRole.guest);
+              doCheck(GroupSettingType.realm, 'can_access_all_users_group', true);
+            });
+          case SystemGroupName.members:
+            test('members, is guest', () {
+              prepare(selfUserRole: UserRole.guest);
+              doCheck(GroupSettingType.realm, 'can_add_custom_emoji_group', false);
+            });
+            test('members, is member', () {
+              prepare(selfUserRole: UserRole.member);
+              doCheck(GroupSettingType.realm, 'can_add_custom_emoji_group', true);
+            });
+          case SystemGroupName.fullMembers:
+            // (No permissions where we use this default value; continue.)
+            break;
+          case SystemGroupName.moderators:
+            test('moderators, is member', () {
+              prepare(selfUserRole: UserRole.member);
+              doCheck(GroupSettingType.realm, 'can_set_delete_message_policy_group', false);
+            });
+            test('moderators, is moderator', () {
+              prepare(selfUserRole: UserRole.moderator);
+              doCheck(GroupSettingType.realm, 'can_set_delete_message_policy_group', true);
+            });
+          case SystemGroupName.administrators:
+            test('administrators, is moderator', () {
+              prepare(selfUserRole: UserRole.moderator);
+              doCheck(GroupSettingType.stream, 'can_remove_subscribers_group', false);
+            });
+            test('administrators, is administrator', () {
+              prepare(selfUserRole: UserRole.administrator);
+              doCheck(GroupSettingType.stream, 'can_remove_subscribers_group', true);
+            });
+          case SystemGroupName.owners:
+            test('owners, is administrator', () {
+              prepare(selfUserRole: UserRole.administrator);
+              doCheck(GroupSettingType.realm, 'can_create_web_public_channel_group', false);
+            });
+            test('owners, is owner', () {
+              prepare(selfUserRole: UserRole.owner);
+              doCheck(GroupSettingType.realm, 'can_create_web_public_channel_group', true);
+            });
+          case SystemGroupName.nobody:
+            test('nobody', () {
+              prepare(selfUserRole: UserRole.owner);
+              doCheck(GroupSettingType.stream, 'can_delete_own_message_group', false);
+            });
+        }
+      }
+
+      test('throw on unknown name', () {
+        // We should know about all the permissions we're trying to implement,
+        // even the ones old servers don't know about.
+        prepare(selfUserRole: UserRole.member);
+        check(() => store.selfHasPermissionForGroupSetting(null,
+          GroupSettingType.realm, 'example_future_permission_name'),
+        ).throws<Error>();
+      });
+    });
   });

   group('customProfileFields', () {
diff --git a/test/model/user_test.dart b/test/model/user_test.dart
index 6d5e5c2519..d306d12f0a 100644
--- a/test/model/user_test.dart
+++ b/test/model/user_test.dart
@@ -58,28 +58,6 @@ void main() {
     });
   });

-  group('hasPassedWaitingPeriod', () {
-    final store = eg.store(initialSnapshot:
-      eg.initialSnapshot(realmWaitingPeriodThreshold: 2));
-
-    final testCases = [
-      ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 0, 10, 00), false),
-      ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1, 10, 00), false),
-      ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 09, 59), false),
-      ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 10, 00), true),
-      ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1000, 07, 00), true),
-    ];
-
-    for (final (String dateJoined, DateTime currentDate, bool hasPassedWaitingPeriod) in testCases) {
-      test('user joined at $dateJoined ${hasPassedWaitingPeriod ? 'has' : "hasn't"} '
-          'passed waiting period by $currentDate', () {
-        final user = eg.user(dateJoined: dateJoined);
-        check(store.hasPassedWaitingPeriod(user, byDate: currentDate))
-          .equals(hasPassedWaitingPeriod);
-      });
-    }
-  });
-
   group('RealmUserUpdateEvent', () {
     // TODO write more tests for handling RealmUserUpdateEvent


msglist: Suppress bottom inset in top sliver, too

Fixes #1523.

Chris reported the issue, debugged, and wrote the core of the fix. I added the test, found that the left and right insets reappeared, debugged why, and added removeLeft and removeRight to fix those.

Co-authored-by: Chris Bobbe <cbobbe@zulip.com>

content/msglist: Horizontal scrollbar in code block sometimes has wrong vertical position (again)
In a message-list page without a compose box, the scroll bar in a code block is misaligned, if it's in a message in the top sliver:

| Bottom sliver (aligned correctly) | Top sliver (bug) |
| --- | --- |
|  |  |

## Diagnosis

This is #736 again, reappearing in fca651bf5f14562a8a742d25c8e64385ac3ed9a7; the bug is present at that commit but not its parent. We have a regression test for that issue, but it doesn't check the case where the message is in the top sliver:

```dart
    testWidgets('content not asked to consume insets (including bottom), even without compose box', (tester) async {
      // Regression test for: https://github.com/zulip/zulip-flutter/issues/736
      const fakePadding = FakeViewPadding(left: 10, top: 10, right: 10, bottom: 10);
      tester.view.viewInsets = fakePadding;
      tester.view.padding = fakePadding;

      await setupMessageListPage(tester, narrow: const CombinedFeedNarrow(),
        messages: [eg.streamMessage(content: ContentExample.codeBlockPlain.html)]);

      // Verify this message list lacks a compose box.
      // (The original bug wouldn't reproduce with a compose box present.)
      final state = MessageListPage.ancestorOf(tester.element(find.text("verb\natim")));
      check(state.composeBoxState).isNull();

      final element = tester.element(find.byType(CodeBlock));
      final padding = MediaQuery.of(element).padding;
      check(padding).equals(EdgeInsets.zero);
    });
```

cc @gnprice as the author of fca651bf5f14562a8a742d25c8e64385ac3ed9a7.

This seems to fix it; @gnprice, would you check this and send a PR, updating the regression test so it exercises the top-sliver case?

```diff
diff --git lib/widgets/message_list.dart lib/widgets/message_list.dart
index dcd063a62..071231412 100644
--- lib/widgets/message_list.dart
+++ lib/widgets/message_list.dart
@@ -579,7 +579,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat

     // The top sliver has its child 0 as the item just before the
     // sliver boundary, child 1 as the item before that, and so on.
-    final topSliver = SliverStickyHeaderList(
+    Widget topSliver = SliverStickyHeaderList(
       headerPlacement: HeaderPlacement.scrollingStart,
       delegate: SliverChildBuilderDelegate(
         // To preserve state across rebuilds for individual [MessageItem]
@@ -663,6 +663,8 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
     if (!ComposeBox.hasComposeBox(widget.narrow)) {
       // TODO(#311) If we have a bottom nav, it will pad the bottom inset,
       //   and this can be removed; also remove mention in MessageList dartdoc
+      topSliver = MediaQuery.removePadding(context: context,
+        removeBottom: true, child: topSliver);
       bottomSliver = SliverSafeArea(key: bottomSliver.key, sliver: bottomSliver);
     }
```
Hi @chrisbobbe and @gnprice,

I’m interested in addressing this bug (#1523). May I begin working on a fix, or is someone else already assigned?
Sure, I've just assigned it to you. A key part of this work will be to update the regression test so that it fails without the fix and passes with the fix.
Hi @chrisbobbe,

Thanks for assigning me this issue! I'm really excited to work on it and make sure the fix is solid.

I've already implemented the core fix for the issue, and now I'm focusing on writing the regression test. To be completely transparent, I haven't had much prior experience writing comprehensive regression tests in Flutter before. My main focus has been on feature development.

However, I'm fully committed to learning and implementing this correctly. I understand the importance of having a robust regression test that fails without the fix and passes with it. I've already started diving into Flutter's testing documentation and examples, and I'm confident I can get this done.

I'll be sure to keep you updated on my progress, and if I face any major blockers, I'll reach out for guidance.

Thanks again for the opportunity!

"Hey Chris,

Hope you're doing well!

I wanted to let you know that I've completed the main fix for *Bug #1523
(ComposeBox Padding)*. The padding issue has now been resolved.

However, I'm facing a bit of a challenge with updating the regression test.
To be honest, I don't have much experience with testing and regression
tests yet, and I'm finding this part difficult to grasp properly.

Would it be possible for me to commit just this padding fix for now? This
would resolve the core problem, and we could get it merged quickly. I'm
definitely keen to learn more about testing concepts in the future, but for
now, I'd like to see this fix go live.

After this, I'd like to focus on other help wanted issues that might be
more critical and where I can contribute effectively with my current skills.

Please let me know what you think.

Thanks a lot, Vinod"

On Thu, Jun 5, 2025 at 11:02 AM Chris Bobbe ***@***.***>
wrote:

> *chrisbobbe* left a comment (zulip/zulip-flutter#1523)
> <https://github.com/zulip/zulip-flutter/issues/1523#issuecomment-2942814219>
>
> Sure, I've just assigned it to you. A key part of this work will be to
> update the regression test so that it fails without the fix and passes with
> the fix.
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/zulip/zulip-flutter/issues/1523#issuecomment-2942814219>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/BSX2GV63I35CR2JRHQXLLCD3B7I6ZAVCNFSM6AAAAAB5SIEGBKVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDSNBSHAYTIMRRHE>
> .
> You are receiving this because you were assigned.Message ID:
> ***@***.***>
>

The test is an important part of the change: it's how we make sure your fix doesn't get accidentally broken by a future change.

To ask for help with writing the test, you're welcome to stop by the #mobile-dev-help channel on chat.zulip.org and ask there. See our guide to using chat.zulip.org, and to asking for help:
https://zulip.com/development-community/

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

diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart
index 6316655705..77e2e8cb24 100644
--- a/lib/widgets/message_list.dart
+++ b/lib/widgets/message_list.dart
@@ -1069,7 +1069,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat

     // The top sliver has its child 0 as the item just before the
     // sliver boundary, child 1 as the item before that, and so on.
-    final topSliver = SliverStickyHeaderList(
+    Widget topSliver = SliverStickyHeaderList(
       headerPlacement: HeaderPlacement.scrollingStart,
       delegate: SliverChildBuilderDelegate(
         // To preserve state across rebuilds for individual [MessageItem]
@@ -1148,6 +1148,15 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
       // TODO(#311) If we have a bottom nav, it will pad the bottom inset,
       //   and this can be removed; also remove mention in MessageList dartdoc
       bottomSliver = SliverSafeArea(key: bottomSliver.key, sliver: bottomSliver);
+      topSliver = MediaQuery.removePadding(context: context,
+        // In the top sliver, forget the bottom inset;
+        // we're having the bottom sliver take care of it.
+        removeBottom: true,
+        // (Also forget the left and right insets; the outer SafeArea, above,
+        // does that, but the `context` we're passing to this `removePadding`
+        // is from outside that SafeArea, so we need to repeat it.)
+        removeLeft: true, removeRight: true,
+        child: topSliver);
     }

     return MessageListScrollView(

diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart
index 1755994cfd..acdde3fd2c 100644
--- a/test/widgets/message_list_test.dart
+++ b/test/widgets/message_list_test.dart
@@ -405,7 +405,31 @@ void main() {
   });

   group('presents message content appropriately', () {
-    testWidgets('content not asked to consume insets (including bottom), even without compose box', (tester) async {
+    testWidgets('content not asked to consume insets (including bottom), even without compose box, in top sliver', (tester) async {
+      // Regression test for: https://github.com/zulip/zulip-flutter/issues/1523
+      const fakePadding = FakeViewPadding(left: 10, top: 10, right: 10, bottom: 10);
+      tester.view.viewInsets = fakePadding;
+      tester.view.padding = fakePadding;
+
+      await setupMessageListPage(tester, narrow: const CombinedFeedNarrow(),
+        messages: [
+          eg.streamMessage(content: ContentExample.codeBlockPlain.html),
+          eg.streamMessage(),
+        ]);
+
+      // Verify this message list lacks a compose box.
+      // (The original bug wouldn't reproduce with a compose box present.)
+      final state = MessageListPage.ancestorOf(tester.element(find.text("verb\natim")));
+      check(state.composeBoxState).isNull();
+      // Also verify that the first message is in the top sliver.
+      check(state.model!.middleMessage).equals(1);
+
+      final element = tester.element(find.byType(CodeBlock));
+      final padding = MediaQuery.of(element).padding;
+      check(padding).equals(EdgeInsets.zero);
+    });
+
+    testWidgets('content not asked to consume insets (including bottom), even without compose box, in bottom sliver', (tester) async {
       // Regression test for: https://github.com/zulip/zulip-flutter/issues/736
       const fakePadding = FakeViewPadding(left: 10, top: 10, right: 10, bottom: 10);
       tester.view.viewInsets = fakePadding;
@@ -418,6 +442,8 @@ void main() {
       // (The original bug wouldn't reproduce with a compose box present.)
       final state = MessageListPage.ancestorOf(tester.element(find.text("verb\natim")));
       check(state.composeBoxState).isNull();
+      // Also verify that the message is in the bottom sliver.
+      check(state.model!.middleMessage).equals(0);

       final element = tester.element(find.byType(CodeBlock));
       final padding = MediaQuery.of(element).padding;