Group-based permissions

Fixes #1687.
Fixes #814.

Stacked atop #1814.

This adds all the generic infrastructure needed for implementing any given group-based permission. The last commit (or pair of commits) then adds the permission "has content access (to a channel)" (which we'll want for #188), to demonstrate end-to-end how to add individual permissions using the new infrastructure.

Implement group-based permission checks

Track membership of user groups

Implement group-based permission checks
In the Zulip API, we've recently introduced a new group-based permission system, where a realm's admins can define arbitrary user groups to use for permissions. This is more flexible than the old roles-based system, which has a fixed handful of roles like "admin", "moderator", or "member".

Mostly this is a matter for the server, and for admin settings UIs, and we generally implement very little of an admin settings UI in the Zulip mobile apps (leaving users to go to the web/desktop app to handle those tasks). But in some places a client also needs to check if the user has a given permission, in order to give them a good experience in the case where they don't. For example, #791.

So we need to implement permission checks, and we should do so with the new API. In particular, new permission settings in Zulip (like #791) are being expressed only in the new system, so it's the only way to properly handle those.

This issue is for implementing the infrastructure of that system, so that we can easily add new permissions checks that use it when we need them. That means:
* First, as a prerequisite, implement #662.

* Then implement a method for "user A is transitively in group X". The desired semantics are transitive: if user A is in group Z, which is in group Y, which is in group X, then A is transitively in X and Y as well as Z.

  API reference for this: https://zulip.com/api/group-setting-values
  See also the web implementation, at `is_user_in_group` in `user_groups.ts`.

* Implement some particular setting that uses the system, so we can demonstrate end-to-end that the implementation works.

  I think the one current candidate for this is `direct_message_permission_group` and `direct_message_initiator_group` (for #791).
  The server also has `can_mention_group` ([this feature](https://zulip.com/api/update-user-group#parameter-can_mention_group)), which we'd use as part of #233.
  I'd thought I'd seen mention of `stream_post_policy` (for #674) getting turned into a group-based setting; but I don't see that [in the server API](https://zulip.com/api/update-stream#parameter-stream_post_policy).
  By the time you read this, there may be more: see https://zulip.com/api/changelog after feature level 270 (the latest as I write this).

Track membership of user groups
In the initial implementation #662, we'll skip tracking the membership of the user groups, because that information isn't needed for our first application of the user-group data: #233.

This issue is for tracking the groups' membership, both direct and indirect. The prime application we'll need that data for is group-based permission checks, #814.

Copying from the #662 thread one wrinkle we'll need to handle when implementing this, to help make sure we remember:

> For current servers since [zulip/zulip#29498](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

### References
* Prerequisite: #662
* A motivating use case: #814

> - Implement some particular setting that uses the system, so we can demonstrate end-to-end that the implementation works.

The edit-topic permission is another candidate for this, used for
- #1159.

flutter test test/model/realm_test.dart 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 e472f151b7..2f0f846d49 100644
--- a/lib/api/model/events.dart
+++ b/lib/api/model/events.dart
@@ -34,7 +34,10 @@ sealed class Event {
         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 'add_members': return UserGroupAddMembersEvent.fromJson(json);
+          case 'remove_members': return UserGroupRemoveMembersEvent.fromJson(json);
+          case 'add_subgroups': return UserGroupAddSubgroupsEvent.fromJson(json);
+          case 'remove_subgroups': return UserGroupRemoveSubgroupsEvent.fromJson(json);
           case 'remove': return UserGroupRemoveEvent.fromJson(json);
           default: return UnexpectedEvent.fromJson(json);
         }
@@ -280,6 +283,78 @@ class UserGroupUpdateData {
   Map<String, dynamic> toJson() => _$UserGroupUpdateDataToJson(this);
 }

+/// A [UserGroupEvent] with op `add_members`: https://zulip.com/api/get-events#user_group-add_members
+@JsonSerializable(fieldRename: FieldRename.snake)
+class UserGroupAddMembersEvent extends UserGroupEvent {
+  @override
+  @JsonKey(includeToJson: true)
+  String get op => 'add_members';
+
+  final int groupId;
+  final List<int> userIds;
+
+  UserGroupAddMembersEvent({required super.id, required this.groupId, required this.userIds});
+
+  factory UserGroupAddMembersEvent.fromJson(Map<String, dynamic> json) => _$UserGroupAddMembersEventFromJson(json);
+
+  @override
+  Map<String, dynamic> toJson() => _$UserGroupAddMembersEventToJson(this);
+}
+
+/// A [UserGroupEvent] with op `remove_members`: https://zulip.com/api/get-events#user_group-remove_members
+@JsonSerializable(fieldRename: FieldRename.snake)
+class UserGroupRemoveMembersEvent extends UserGroupEvent {
+  @override
+  @JsonKey(includeToJson: true)
+  String get op => 'remove_members';
+
+  final int groupId;
+  final List<int> userIds;
+
+  UserGroupRemoveMembersEvent({required super.id, required this.groupId, required this.userIds});
+
+  factory UserGroupRemoveMembersEvent.fromJson(Map<String, dynamic> json) => _$UserGroupRemoveMembersEventFromJson(json);
+
+  @override
+  Map<String, dynamic> toJson() => _$UserGroupRemoveMembersEventToJson(this);
+}
+
+/// A [UserGroupEvent] with op `add_subgroups`: https://zulip.com/api/get-events#user_group-add_subgroups
+@JsonSerializable(fieldRename: FieldRename.snake)
+class UserGroupAddSubgroupsEvent extends UserGroupEvent {
+  @override
+  @JsonKey(includeToJson: true)
+  String get op => 'add_subgroups';
+
+  final int groupId;
+  final List<int> directSubgroupIds;
+
+  UserGroupAddSubgroupsEvent({required super.id, required this.groupId, required this.directSubgroupIds});
+
+  factory UserGroupAddSubgroupsEvent.fromJson(Map<String, dynamic> json) => _$UserGroupAddSubgroupsEventFromJson(json);
+
+  @override
+  Map<String, dynamic> toJson() => _$UserGroupAddSubgroupsEventToJson(this);
+}
+
+/// A [UserGroupEvent] with op `remove_subgroups`: https://zulip.com/api/get-events#user_group-remove_subgroups
+@JsonSerializable(fieldRename: FieldRename.snake)
+class UserGroupRemoveSubgroupsEvent extends UserGroupEvent {
+  @override
+  @JsonKey(includeToJson: true)
+  String get op => 'remove_subgroups';
+
+  final int groupId;
+  final List<int> directSubgroupIds;
+
+  UserGroupRemoveSubgroupsEvent({required super.id, required this.groupId, required this.directSubgroupIds});
+
+  factory UserGroupRemoveSubgroupsEvent.fromJson(Map<String, dynamic> json) => _$UserGroupRemoveSubgroupsEventFromJson(json);
+
+  @override
+  Map<String, dynamic> toJson() => _$UserGroupRemoveSubgroupsEventToJson(this);
+}
+
 /// A [UserGroupEvent] with op `remove`: https://zulip.com/api/get-events#user_group-remove
 @JsonSerializable(fieldRename: FieldRename.snake)
 class UserGroupRemoveEvent extends UserGroupEvent {
@@ -599,6 +674,9 @@ class ChannelUpdateEvent extends ChannelEvent {
         return value as int?;
       case ChannelPropertyName.channelPostPolicy:
         return ChannelPostPolicy.fromApiValue(value as int);
+      case ChannelPropertyName.canAddSubscribersGroup:
+      case ChannelPropertyName.canSubscribeGroup:
+        return GroupSettingValue.fromJson(value);
       case ChannelPropertyName.streamWeeklyTraffic:
         return value as int?;
       case null:
diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart
index bb85039555..6e7aadf362 100644
--- a/lib/api/model/events.g.dart
+++ b/lib/api/model/events.g.dart
@@ -134,6 +134,86 @@ Map<String, dynamic> _$UserGroupUpdateDataToJson(
   'deactivated': instance.deactivated,
 };

+UserGroupAddMembersEvent _$UserGroupAddMembersEventFromJson(
+  Map<String, dynamic> json,
+) => UserGroupAddMembersEvent(
+  id: (json['id'] as num).toInt(),
+  groupId: (json['group_id'] as num).toInt(),
+  userIds: (json['user_ids'] as List<dynamic>)
+      .map((e) => (e as num).toInt())
+      .toList(),
+);
+
+Map<String, dynamic> _$UserGroupAddMembersEventToJson(
+  UserGroupAddMembersEvent instance,
+) => <String, dynamic>{
+  'id': instance.id,
+  'type': instance.type,
+  'op': instance.op,
+  'group_id': instance.groupId,
+  'user_ids': instance.userIds,
+};
+
+UserGroupRemoveMembersEvent _$UserGroupRemoveMembersEventFromJson(
+  Map<String, dynamic> json,
+) => UserGroupRemoveMembersEvent(
+  id: (json['id'] as num).toInt(),
+  groupId: (json['group_id'] as num).toInt(),
+  userIds: (json['user_ids'] as List<dynamic>)
+      .map((e) => (e as num).toInt())
+      .toList(),
+);
+
+Map<String, dynamic> _$UserGroupRemoveMembersEventToJson(
+  UserGroupRemoveMembersEvent instance,
+) => <String, dynamic>{
+  'id': instance.id,
+  'type': instance.type,
+  'op': instance.op,
+  'group_id': instance.groupId,
+  'user_ids': instance.userIds,
+};
+
+UserGroupAddSubgroupsEvent _$UserGroupAddSubgroupsEventFromJson(
+  Map<String, dynamic> json,
+) => UserGroupAddSubgroupsEvent(
+  id: (json['id'] as num).toInt(),
+  groupId: (json['group_id'] as num).toInt(),
+  directSubgroupIds: (json['direct_subgroup_ids'] as List<dynamic>)
+      .map((e) => (e as num).toInt())
+      .toList(),
+);
+
+Map<String, dynamic> _$UserGroupAddSubgroupsEventToJson(
+  UserGroupAddSubgroupsEvent instance,
+) => <String, dynamic>{
+  'id': instance.id,
+  'type': instance.type,
+  'op': instance.op,
+  'group_id': instance.groupId,
+  'direct_subgroup_ids': instance.directSubgroupIds,
+};
+
+UserGroupRemoveSubgroupsEvent _$UserGroupRemoveSubgroupsEventFromJson(
+  Map<String, dynamic> json,
+) => UserGroupRemoveSubgroupsEvent(
+  id: (json['id'] as num).toInt(),
+  groupId: (json['group_id'] as num).toInt(),
+  directSubgroupIds: (json['direct_subgroup_ids'] as List<dynamic>)
+      .map((e) => (e as num).toInt())
+      .toList(),
+);
+
+Map<String, dynamic> _$UserGroupRemoveSubgroupsEventToJson(
+  UserGroupRemoveSubgroupsEvent instance,
+) => <String, dynamic>{
+  'id': instance.id,
+  'type': instance.type,
+  'op': instance.op,
+  'group_id': instance.groupId,
+  'direct_subgroup_ids': instance.directSubgroupIds,
+};
+
 UserGroupRemoveEvent _$UserGroupRemoveEventFromJson(
   Map<String, dynamic> json,
 ) => UserGroupRemoveEvent(
@@ -381,6 +461,8 @@ const _$ChannelPropertyNameEnumMap = {
   ChannelPropertyName.inviteOnly: 'invite_only',
   ChannelPropertyName.messageRetentionDays: 'message_retention_days',
   ChannelPropertyName.channelPostPolicy: 'stream_post_policy',
+  ChannelPropertyName.canAddSubscribersGroup: 'can_add_subscribers_group',
+  ChannelPropertyName.canSubscribeGroup: 'can_subscribe_group',
   ChannelPropertyName.streamWeeklyTraffic: 'stream_weekly_traffic',
 };

diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart
index 50a9f0c243..26e1948d9d 100644
--- a/lib/api/model/initial_snapshot.dart
+++ b/lib/api/model/initial_snapshot.dart
@@ -109,6 +109,9 @@ class InitialSnapshot {
   @JsonKey(readValue: _readUsersIsActiveFallbackTrue)
   final List<User> crossRealmBots;

+  // TODO(server): Get this API stabilized, to replace [SupportedPermissionSettings.fixture].
+  // final SupportedPermissionSettings? serverSupportedPermissionSettings;
+
   // TODO etc., etc.
   // If adding fields, keep them all in the order they appear in the API docs.

@@ -389,3 +392,114 @@ 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: {}, // Please go ahead and fill this in when we come to need it.
+    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 d6de29713e..481b3184b7 100644
--- a/lib/api/model/initial_snapshot.g.dart
+++ b/lib/api/model/initial_snapshot.g.dart
@@ -355,3 +355,38 @@ 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/model.dart b/lib/api/model/model.dart
index 6f0e502a39..a821474325 100644
--- a/lib/api/model/model.dart
+++ b/lib/api/model/model.dart
@@ -12,6 +12,50 @@ export 'reaction.dart';

 part 'model.g.dart';

+/// A Zulip "group-setting value": https://zulip.com/api/group-setting-values
+sealed class GroupSettingValue {
+  const GroupSettingValue();
+
+  factory GroupSettingValue.fromJson(Object? json) {
+    return switch (json) {
+      int() => GroupSettingValueNamed.fromJson(json),
+      Map<String, dynamic>() => GroupSettingValueNameless.fromJson(json),
+      _ => throw FormatException(),
+    };
+  }
+
+  Object? toJson();
+}
+
+class GroupSettingValueNamed extends GroupSettingValue {
+  final int groupId;
+
+  const GroupSettingValueNamed(this.groupId);
+
+  factory GroupSettingValueNamed.fromJson(int json) => GroupSettingValueNamed(json);
+
+  @override
+  int toJson() => groupId;
+}
+
+@JsonSerializable(fieldRename: FieldRename.snake)
+class GroupSettingValueNameless extends GroupSettingValue {
+  // TODO(server): The API docs say these should be "direct_member_ids" and
+  //   "direct_subgroup_ids", but empirically they're "direct_members"
+  //   and "direct_subgroups".  Discussion:
+  //     https://chat.zulip.org/#narrow/channel/378-api-design/topic/groups.20redesign/near/2247218
+  final List<int> directMembers;
+  final List<int> directSubgroups;
+
+  GroupSettingValueNameless({required this.directMembers, required this.directSubgroups});
+
+  factory GroupSettingValueNameless.fromJson(Map<String, dynamic> json) =>
+    _$GroupSettingValueNamelessFromJson(json);
+
+  @override
+  Map<String, dynamic> toJson() => _$GroupSettingValueNamelessToJson(this);
+}
+
 /// As in [InitialSnapshot.customProfileFields].
 ///
 /// For docs, search for "custom_profile_fields:"
@@ -357,9 +401,8 @@ enum Emojiset {
 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
+  final Set<int> members;
+  final Set<int> directSubgroupIds;

   String name;
   String description;
@@ -377,6 +420,8 @@ class UserGroup {

   UserGroup({
     required this.id,
+    required this.members,
+    required this.directSubgroupIds,
     required this.name,
     required this.description,
     required this.isSystemGroup,
@@ -596,7 +641,8 @@ class ZulipStream {
   ChannelPostPolicy channelPostPolicy;
   // final bool isAnnouncementOnly; // deprecated for `channelPostPolicy`; ignore

-  // GroupSettingsValue canRemoveSubscribersGroup; // TODO(#814)
+  GroupSettingValue? canAddSubscribersGroup; // TODO(server-10)
+  GroupSettingValue? canSubscribeGroup; // TODO(server-10)

   // TODO(server-8): added in FL 199, was previously only on [Subscription] objects
   int? streamWeeklyTraffic;
@@ -613,6 +659,8 @@ class ZulipStream {
     required this.historyPublicToSubscribers,
     required this.messageRetentionDays,
     required this.channelPostPolicy,
+    required this.canAddSubscribersGroup,
+    required this.canSubscribeGroup,
     required this.streamWeeklyTraffic,
   });

@@ -630,6 +678,8 @@ class ZulipStream {
       historyPublicToSubscribers: subscription.historyPublicToSubscribers,
       messageRetentionDays: subscription.messageRetentionDays,
       channelPostPolicy: subscription.channelPostPolicy,
+      canAddSubscribersGroup: subscription.canAddSubscribersGroup,
+      canSubscribeGroup: subscription.canSubscribeGroup,
       streamWeeklyTraffic: subscription.streamWeeklyTraffic,
     );
   }
@@ -660,8 +710,8 @@ enum ChannelPropertyName {
   messageRetentionDays,
   @JsonValue('stream_post_policy')
   channelPostPolicy,
-  // canRemoveSubscribersGroup, // TODO(#814)
-  // canRemoveSubscribersGroupId, // TODO(#814) handle // TODO(server-8) remove
+  canAddSubscribersGroup,
+  canSubscribeGroup,
   streamWeeklyTraffic;

   /// Get a [ChannelPropertyName] from a raw, snake-case string we recognize, else null.
@@ -741,6 +791,8 @@ class Subscription extends ZulipStream {
     required super.historyPublicToSubscribers,
     required super.messageRetentionDays,
     required super.channelPostPolicy,
+    required super.canAddSubscribersGroup,
+    required super.canSubscribeGroup,
     required super.streamWeeklyTraffic,
     required this.desktopNotifications,
     required this.emailNotifications,
diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart
index e9cf30e5d2..b5bb32da2a 100644
--- a/lib/api/model/model.g.dart
+++ b/lib/api/model/model.g.dart
@@ -8,6 +8,24 @@ part of 'model.dart';
 // JsonSerializableGenerator
 // **************************************************************************

+GroupSettingValueNameless _$GroupSettingValueNamelessFromJson(
+  Map<String, dynamic> json,
+) => GroupSettingValueNameless(
+  directMembers: (json['direct_members'] as List<dynamic>)
+      .map((e) => (e as num).toInt())
+      .toList(),
+  directSubgroups: (json['direct_subgroups'] as List<dynamic>)
+      .map((e) => (e as num).toInt())
+      .toList(),
+);
+
+Map<String, dynamic> _$GroupSettingValueNamelessToJson(
+  GroupSettingValueNameless instance,
+) => <String, dynamic>{
+  'direct_members': instance.directMembers,
+  'direct_subgroups': instance.directSubgroups,
+};
+
 CustomProfileField _$CustomProfileFieldFromJson(Map<String, dynamic> json) =>
     CustomProfileField(
       id: (json['id'] as num).toInt(),
@@ -96,6 +114,12 @@ Map<String, dynamic> _$RealmEmojiItemToJson(RealmEmojiItem instance) =>

 UserGroup _$UserGroupFromJson(Map<String, dynamic> json) => UserGroup(
   id: (json['id'] as num).toInt(),
+  members: (json['members'] as List<dynamic>)
+      .map((e) => (e as num).toInt())
+      .toSet(),
+  directSubgroupIds: (json['direct_subgroup_ids'] as List<dynamic>)
+      .map((e) => (e as num).toInt())
+      .toSet(),
   name: json['name'] as String,
   description: json['description'] as String,
   isSystemGroup: json['is_system_group'] as bool,
@@ -104,6 +128,8 @@ UserGroup _$UserGroupFromJson(Map<String, dynamic> json) => UserGroup(

 Map<String, dynamic> _$UserGroupToJson(UserGroup instance) => <String, dynamic>{
   'id': instance.id,
+  'members': instance.members.toList(),
+  'direct_subgroup_ids': instance.directSubgroupIds.toList(),
   'name': instance.name,
   'description': instance.description,
   'is_system_group': instance.isSystemGroup,
@@ -224,6 +250,12 @@ ZulipStream _$ZulipStreamFromJson(Map<String, dynamic> json) => ZulipStream(
     _$ChannelPostPolicyEnumMap,
     json['stream_post_policy'],
   ),
+  canAddSubscribersGroup: json['can_add_subscribers_group'] == null
+      ? null
+      : GroupSettingValue.fromJson(json['can_add_subscribers_group']),
+  canSubscribeGroup: json['can_subscribe_group'] == null
+      ? null
+      : GroupSettingValue.fromJson(json['can_subscribe_group']),
   streamWeeklyTraffic: (json['stream_weekly_traffic'] as num?)?.toInt(),
 );

@@ -240,6 +272,8 @@ Map<String, dynamic> _$ZulipStreamToJson(ZulipStream instance) =>
       'history_public_to_subscribers': instance.historyPublicToSubscribers,
       'message_retention_days': instance.messageRetentionDays,
       'stream_post_policy': instance.channelPostPolicy,
+      'can_add_subscribers_group': instance.canAddSubscribersGroup,
+      'can_subscribe_group': instance.canSubscribeGroup,
       'stream_weekly_traffic': instance.streamWeeklyTraffic,
     };

@@ -266,6 +300,12 @@ Subscription _$SubscriptionFromJson(Map<String, dynamic> json) => Subscription(
     _$ChannelPostPolicyEnumMap,
     json['stream_post_policy'],
   ),
+  canAddSubscribersGroup: json['can_add_subscribers_group'] == null
+      ? null
+      : GroupSettingValue.fromJson(json['can_add_subscribers_group']),
+  canSubscribeGroup: json['can_subscribe_group'] == null
+      ? null
+      : GroupSettingValue.fromJson(json['can_subscribe_group']),
   streamWeeklyTraffic: (json['stream_weekly_traffic'] as num?)?.toInt(),
   desktopNotifications: json['desktop_notifications'] as bool?,
   emailNotifications: json['email_notifications'] as bool?,
@@ -290,6 +330,8 @@ Map<String, dynamic> _$SubscriptionToJson(Subscription instance) =>
       'history_public_to_subscribers': instance.historyPublicToSubscribers,
       'message_retention_days': instance.messageRetentionDays,
       'stream_post_policy': instance.channelPostPolicy,
+      'can_add_subscribers_group': instance.canAddSubscribersGroup,
+      'can_subscribe_group': instance.canSubscribeGroup,
       'stream_weekly_traffic': instance.streamWeeklyTraffic,
       'desktop_notifications': instance.desktopNotifications,
       'email_notifications': instance.emailNotifications,
@@ -449,6 +491,8 @@ const _$ChannelPropertyNameEnumMap = {
   ChannelPropertyName.inviteOnly: 'invite_only',
   ChannelPropertyName.messageRetentionDays: 'message_retention_days',
   ChannelPropertyName.channelPostPolicy: 'stream_post_policy',
+  ChannelPropertyName.canAddSubscribersGroup: 'can_add_subscribers_group',
+  ChannelPropertyName.canSubscribeGroup: 'can_subscribe_group',
   ChannelPropertyName.streamWeeklyTraffic: 'stream_weekly_traffic',
 };

diff --git a/lib/model/channel.dart b/lib/model/channel.dart
index 7a10d52f6b..8ef01054bd 100644
--- a/lib/model/channel.dart
+++ b/lib/model/channel.dart
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
 import '../api/model/events.dart';
 import '../api/model/initial_snapshot.dart';
 import '../api/model/model.dart';
+import 'realm.dart';
 import 'store.dart';
 import 'user.dart';

@@ -139,6 +140,44 @@ mixin ChannelStore on UserStore {
     }
   }

+  bool selfHasContentAccess(ZulipStream channel) {
+    // Compare web's stream_data.has_content_access.
+    if (channel.isWebPublic) return true;
+    if (channel is Subscription) return true;
+    // Here web calls has_metadata_access... but that always returns true,
+    // as its comment says.
+    if (selfUser.role == UserRole.guest) return false;
+    if (!channel.inviteOnly) return true;
+    return _selfHasContentAccessViaGroupPermissions(channel);
+  }
+
+  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
+      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
+      return true;
+    }
+
+    return false;
+  }
+
   bool hasPostingPermission({
     required ZulipStream inChannel,
     required User user,
@@ -337,6 +376,10 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore {
             stream.messageRetentionDays = event.value as int?;
           case ChannelPropertyName.channelPostPolicy:
             stream.channelPostPolicy = event.value as ChannelPostPolicy;
+          case ChannelPropertyName.canAddSubscribersGroup:
+            stream.canAddSubscribersGroup = event.value as GroupSettingValue;
+          case ChannelPropertyName.canSubscribeGroup:
+            stream.canSubscribeGroup = event.value as GroupSettingValue;
           case ChannelPropertyName.streamWeeklyTraffic:
             stream.streamWeeklyTraffic = event.value as int?;
         }
diff --git a/lib/model/realm.dart b/lib/model/realm.dart
index d54e5a5467..02db21c56e 100644
--- a/lib/model/realm.dart
+++ b/lib/model/realm.dart
@@ -4,6 +4,7 @@ import '../api/model/events.dart';
 import '../api/model/initial_snapshot.dart';
 import '../api/model/model.dart';
 import 'store.dart';
+import 'user_group.dart';

 /// The portion of [PerAccountStore] for realm settings, server settings,
 /// and similar data about the whole realm or server.
@@ -11,7 +12,10 @@ import 'store.dart';
 /// See also:
 ///  * [RealmStoreImpl] for the implementation of this that does the work.
 ///  * [HasRealmStore] for an implementation useful for other substores.
-mixin RealmStore on PerAccountStoreBase {
+mixin RealmStore on PerAccountStoreBase, UserGroupStore {
+  @protected
+  UserGroupStore get userGroupStore;
+
   //|//////////////////////////////////////////////////////////////
   // Server settings, explicitly so named.

@@ -117,8 +121,14 @@ mixin RealmStore on PerAccountStoreBase {
     }
     return topic;
   }
+
+  /// Whether the self-user has the given (group-based) permission.
+  bool selfHasPermissionForGroupSetting(GroupSettingValue value,
+      GroupSettingType type, String name);
 }

+enum GroupSettingType { realm, stream, group }
+
 mixin ProxyRealmStore on RealmStore {
   @protected
   RealmStore get realmStore;
@@ -155,13 +165,16 @@ mixin ProxyRealmStore on RealmStore {
   Map<String, RealmDefaultExternalAccount> get realmDefaultExternalAccounts => realmStore.realmDefaultExternalAccounts;
   @override
   List<CustomProfileField> get customProfileFields => realmStore.customProfileFields;
+  @override
+  bool selfHasPermissionForGroupSetting(GroupSettingValue value, GroupSettingType type, String name) =>
+    realmStore.selfHasPermissionForGroupSetting(value, type, name);
 }

 /// A base class for [PerAccountStore] substores that need access to [RealmStore]
 /// as well as to [CorePerAccountStore].
-abstract class HasRealmStore extends PerAccountStoreBase with RealmStore, ProxyRealmStore {
+abstract class HasRealmStore extends HasUserGroupStore with RealmStore, ProxyRealmStore {
   HasRealmStore({required RealmStore realm})
-    : realmStore = realm, super(core: realm.core);
+    : realmStore = realm, super(groups: realm.userGroupStore);

   @protected
   @override
@@ -169,11 +182,13 @@ abstract class HasRealmStore extends PerAccountStoreBase with RealmStore, ProxyR
 }

 /// The implementation of [RealmStore] that does the work.
-class RealmStoreImpl extends PerAccountStoreBase with RealmStore {
+class RealmStoreImpl extends HasUserGroupStore with RealmStore {
   RealmStoreImpl({
-    required super.core,
+    required super.groups,
     required InitialSnapshot initialSnapshot,
+    required User selfUser,
   }) :
+    _selfUserRole = selfUser.role,
     serverPresencePingIntervalSeconds = initialSnapshot.serverPresencePingIntervalSeconds,
     serverPresenceOfflineThresholdSeconds = initialSnapshot.serverPresenceOfflineThresholdSeconds,
     serverTypingStartedExpiryPeriodMilliseconds = initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds,
@@ -191,6 +206,46 @@ class RealmStoreImpl extends PerAccountStoreBase with RealmStore {
     realmDefaultExternalAccounts = initialSnapshot.realmDefaultExternalAccounts,
     customProfileFields = _sortCustomProfileFields(initialSnapshot.customProfileFields);

+  @override
+  bool selfHasPermissionForGroupSetting(GroupSettingValue value,
+      GroupSettingType type, String name) {
+    // Compare web's settings_data.user_has_permission_for_group_setting.
+    //
+    // In the whole web app, there's just one caller for that function with
+    // a user other than the self user: stream_data.can_post_messages_in_stream,
+    // and only for get_current_user_and_their_bots_with_post_messages_permission,
+    // with only the self-user's own bots as the arguments.
+    // 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;
+    }
+    return selfInGroupSetting(value);
+  }
+
+  /// The metadata for how to interpret the given group-based permission setting.
+  PermissionSettingsItem _groupSettingConfig(GroupSettingType type, String name) {
+    final supportedSettings = SupportedPermissionSettings.fixture;
+
+    // Compare web's group_permission_settings.get_group_permission_setting_config.
+    final configGroup = switch (type) {
+      GroupSettingType.realm => supportedSettings.realm,
+      GroupSettingType.stream => supportedSettings.stream,
+      GroupSettingType.group => supportedSettings.group,
+    };
+    final config = configGroup[name];
+    return config!; // TODO(log)
+  }
+
+  /// The [User.role] of the self-user.
+  ///
+  /// 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.
+  UserRole _selfUserRole;
+
   @override
   final int serverPresencePingIntervalSeconds;
   @override
@@ -253,4 +308,11 @@ class RealmStoreImpl extends PerAccountStoreBase with RealmStore {
   void handleCustomProfileFieldsEvent(CustomProfileFieldsEvent event) {
     customProfileFields = _sortCustomProfileFields(event.fields);
   }
+
+  void handleRealmUserUpdateEvent(RealmUserUpdateEvent event) {
+    // Compare [UserStoreImpl.handleRealmUserEvent].
+    if (event.userId == selfUserId) {
+      if (event.role != null) _selfUserRole = event.role!;
+    }
+  }
 }
diff --git a/lib/model/store.dart b/lib/model/store.dart
index c240d47920..71bf40f094 100644
--- a/lib/model/store.dart
+++ b/lib/model/store.dart
@@ -496,15 +496,17 @@ class PerAccountStore extends PerAccountStoreBase with
       throw Exception("bad initial snapshot: self-user missing from user list");
     }

-    final realm = RealmStoreImpl(core: core, initialSnapshot: initialSnapshot);
+    final groups = UserGroupStoreImpl(core: core,
+      groups: initialSnapshot.realmUserGroups);
+    final realm = RealmStoreImpl(groups: groups, initialSnapshot: initialSnapshot,
+      selfUser: selfUser);
     final users = UserStoreImpl(realm: realm, initialSnapshot: initialSnapshot,
       userMap: userMap);
     final channels = ChannelStoreImpl(users: users,
       initialSnapshot: initialSnapshot);
     return PerAccountStore._(
       core: core,
-      groups: UserGroupStoreImpl(core: core,
-        groups: initialSnapshot.realmUserGroups),
+      groups: groups,
       realm: realm,
       emoji: EmojiStoreImpl(core: core,
         allRealmEmoji: initialSnapshot.realmEmoji),
@@ -757,6 +759,8 @@ class PerAccountStore extends PerAccountStoreBase with

       case RealmUserUpdateEvent():
         assert(debugLog("server event: realm_user/update"));
+        _groups.handleRealmUserUpdateEvent(event);
+        _realm.handleRealmUserUpdateEvent(event);
         _users.handleRealmUserEvent(event);
         autocompleteViewManager.handleRealmUserUpdateEvent(event);
         notifyListeners();
diff --git a/lib/model/user_group.dart b/lib/model/user_group.dart
index 2079b323ec..4fb52b6d49 100644
--- a/lib/model/user_group.dart
+++ b/lib/model/user_group.dart
@@ -18,6 +18,10 @@ mixin UserGroupStore on PerAccountStoreBase {
   ///
   /// Consider using [activeGroups] instead.
   Iterable<UserGroup> get allGroups;
+
+  /// Whether the self-user is a (transitive) member of the given group,
+  /// a group-setting value.
+  bool selfInGroupSetting(GroupSettingValue value);
 }

 mixin ProxyUserGroupStore on UserGroupStore {
@@ -30,6 +34,18 @@ mixin ProxyUserGroupStore on UserGroupStore {
   Iterable<UserGroup> get activeGroups => userGroupStore.activeGroups;
   @override
   Iterable<UserGroup> get allGroups => userGroupStore.allGroups;
+  @override
+  bool selfInGroupSetting(GroupSettingValue value)
+    => userGroupStore.selfInGroupSetting(value);
+}
+
+abstract class HasUserGroupStore extends PerAccountStoreBase with UserGroupStore, ProxyUserGroupStore {
+  HasUserGroupStore({required UserGroupStore groups})
+    : userGroupStore = groups, super(core: groups.core);
+
+  @protected
+  @override
+  final UserGroupStore userGroupStore;
 }

 /// The implementation of [UserGroupStore] that does the work.
@@ -55,8 +71,34 @@ class UserGroupStoreImpl extends PerAccountStoreBase with UserGroupStore {
     return _groups.values;
   }

+  @override
+  bool selfInGroupSetting(GroupSettingValue value) {
+    return switch (value) {
+      GroupSettingValueNamed() =>
+        _selfInGroup(value.groupId),
+      GroupSettingValueNameless() =>
+        value.directMembers.contains(selfUserId)
+          || value.directSubgroups.any(_selfInGroup),
+    };
+  }
+
+  bool _selfInGroup(int groupId) {
+    final group = _groups[groupId];
+    if (group == null) return false; // TODO(log); should know all groups
+    // TODO(perf), TODO(#814): memoize which groups the self-user is in,
+    //   to save doing this depth-first search on each permission check
+    return group.members.contains(selfUserId)
+      || group.directSubgroupIds.any(_selfInGroup);
+  }
+
   final Map<int, UserGroup> _groups;

+  UserGroup? _expectGroup(int groupId) {
+    final group = _groups[groupId];
+    // TODO(log) if group not found
+    return group;
+  }
+
   void handleUserGroupEvent(UserGroupEvent event) {
     switch (event) {
       case UserGroupAddEvent():
@@ -66,14 +108,40 @@ class UserGroupStoreImpl extends PerAccountStoreBase with UserGroupStore {
         _groups.remove(event.groupId);

       case UserGroupUpdateEvent():
-        final group = _groups[event.groupId];
-        if (group == null) {
-          return; // TODO log
-        }
+        final group = _expectGroup(event.groupId);
+        if (group == null) return;
         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!;
+
+      case UserGroupAddMembersEvent():
+        final group = _expectGroup(event.groupId);
+        if (group == null) return;
+        group.members.addAll(event.userIds);
+
+      case UserGroupRemoveMembersEvent():
+        final group = _expectGroup(event.groupId);
+        if (group == null) return;
+        group.members.removeAll(event.userIds);
+
+      case UserGroupAddSubgroupsEvent():
+        final group = _expectGroup(event.groupId);
+        if (group == null) return;
+        group.directSubgroupIds.addAll(event.directSubgroupIds);
+
+      case UserGroupRemoveSubgroupsEvent():
+        final group = _expectGroup(event.groupId);
+        if (group == null) return;
+        group.directSubgroupIds.removeAll(event.directSubgroupIds);
+    }
+  }
+
+  void handleRealmUserUpdateEvent(RealmUserUpdateEvent event) {
+    if (event.isActive == false) {
+      for (final group in _groups.values) {
+        group.members.remove(event.userId);
+      }
     }
   }
 }
diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart
index dcf82389bc..ce1be46000 100644
--- a/lib/widgets/action_sheet.dart
+++ b/lib/widgets/action_sheet.dart
@@ -445,10 +445,11 @@ void showChannelActionSheet(BuildContext context, {
     && messageListPageNarrow.streamId == channelId;

   final unreadCount = store.unreads.countInChannelNarrow(channelId);
-  final isSubscribed = store.subscriptions[channelId] != null;
+  final channel = store.streams[channelId];
+  final isSubscribed = channel is Subscription;
   final buttonSections = [
-    if (!isSubscribed)
-      // TODO(#1786) check group-based can-subscribe permission
+    if (!isSubscribed
+        && channel != null && store.selfHasContentAccess(channel))
       [SubscribeButton(pageContext: pageContext, channelId: channelId)],
     [
       if (unreadCount > 0)

diff --git a/test/example_data.dart b/test/example_data.dart
index 3e4da34b96..84d1abf6aa 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -198,6 +198,8 @@ int _lastUserGroupId = 100;

 UserGroup userGroup({
   int? id,
+  Iterable<int>? members,
+  Iterable<int>? directSubgroupIds,
   String? name,
   String? description,
   bool isSystemGroup = false,
@@ -205,6 +207,8 @@ UserGroup userGroup({
 }) {
   return UserGroup(
     id: id ??= _nextUserGroupId(),
+    members: Set.of(members ?? []),
+    directSubgroupIds: Set.of(directSubgroupIds ?? []),
     name: name ??= 'group-$id',
     description: description ?? 'A group named $name',
     isSystemGroup: isSystemGroup,
@@ -212,6 +216,12 @@ UserGroup userGroup({
   );
 }

+final UserGroup nobodyGroup = userGroup(
+  isSystemGroup: true,
+  name: 'role:nobody', description: 'Nobody',
+  members: [], directSubgroupIds: [],
+);
+
 RealmEmojiItem realmEmojiItem({
   required String emojiCode,
   required String emojiName,
@@ -433,6 +443,8 @@ ZulipStream stream({
   bool? historyPublicToSubscribers,
   int? messageRetentionDays,
   ChannelPostPolicy? channelPostPolicy,
+  GroupSettingValue? canAddSubscribersGroup,
+  GroupSettingValue? canSubscribeGroup,
   int? streamWeeklyTraffic,
 }) {
   _checkPositive(streamId, 'stream ID');
@@ -452,6 +464,8 @@ ZulipStream stream({
     historyPublicToSubscribers: historyPublicToSubscribers ?? true,
     messageRetentionDays: messageRetentionDays,
     channelPostPolicy: channelPostPolicy ?? ChannelPostPolicy.any,
+    canAddSubscribersGroup: canAddSubscribersGroup ?? GroupSettingValueNamed(nobodyGroup.id),
+    canSubscribeGroup: canSubscribeGroup ?? GroupSettingValueNamed(nobodyGroup.id),
     streamWeeklyTraffic: streamWeeklyTraffic,
   );
 }
@@ -490,6 +504,8 @@ Subscription subscription(
     historyPublicToSubscribers: stream.historyPublicToSubscribers,
     messageRetentionDays: stream.messageRetentionDays,
     channelPostPolicy: stream.channelPostPolicy,
+    canAddSubscribersGroup: stream.canAddSubscribersGroup,
+    canSubscribeGroup: stream.canSubscribeGroup,
     streamWeeklyTraffic: stream.streamWeeklyTraffic,
     desktopNotifications: desktopNotifications ?? false,
     emailNotifications: emailNotifications ?? false,
@@ -1161,6 +1177,9 @@ ChannelUpdateEvent channelUpdateEvent(
       assert(value is int?);
     case ChannelPropertyName.channelPostPolicy:
       assert(value is ChannelPostPolicy);
+    case ChannelPropertyName.canAddSubscribersGroup:
+    case ChannelPropertyName.canSubscribeGroup:
+      assert(value is GroupSettingValue);
     case ChannelPropertyName.streamWeeklyTraffic:
       assert(value is int?);
   }
diff --git a/test/model/realm_test.dart b/test/model/realm_test.dart
index 5aecfd289d..04ceb28855 100644
--- a/test/model/realm_test.dart
+++ b/test/model/realm_test.dart
@@ -2,10 +2,88 @@ 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/model/realm.dart';

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

 void main() {
+  test('processTopicLikeServer', () {
+    final emptyTopicDisplayName = eg.defaultRealmEmptyTopicDisplayName;
+
+    TopicName process(TopicName topic, int zulipFeatureLevel) {
+      final account = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel);
+      final store = eg.store(account: account, initialSnapshot: eg.initialSnapshot(
+        zulipFeatureLevel: zulipFeatureLevel,
+        realmEmptyTopicDisplayName: emptyTopicDisplayName));
+      return store.processTopicLikeServer(topic);
+    }
+
+    void doCheck(TopicName topic, TopicName expected, int zulipFeatureLevel) {
+      check(process(topic, zulipFeatureLevel)).equals(expected);
+    }
+
+    check(() => process(eg.t(''), 333)).throws<void>();
+    doCheck(eg.t('(no topic)'),          eg.t('(no topic)'),          333);
+    doCheck(eg.t(emptyTopicDisplayName), eg.t(emptyTopicDisplayName), 333);
+    doCheck(eg.t('other topic'),         eg.t('other topic'),         333);
+
+    doCheck(eg.t(''),                    eg.t(''),                    334);
+    doCheck(eg.t('(no topic)'),          eg.t('(no topic)'),          334);
+    doCheck(eg.t(emptyTopicDisplayName), eg.t(''),                    334);
+    doCheck(eg.t('other topic'),         eg.t('other topic'),         334);
+
+    doCheck(eg.t('(no topic)'),          eg.t(''),                    370);
+  });
+
+  group('selfHasPermissionForGroupSetting', () {
+    // Most of the implementation of this is in [UserGroupStore.selfInGroupSetting],
+    // and is tested in more detail in user_group_test.dart .
+
+    bool hasPermission(User selfUser, UserGroup group, String permissionName) {
+      final store = eg.store(selfUser: selfUser,
+        initialSnapshot: eg.initialSnapshot(
+          realmUsers: [selfUser], realmUserGroups: [group]));
+      return store.selfHasPermissionForGroupSetting(
+        GroupSettingValueNamed(group.id),
+        GroupSettingType.stream, permissionName);
+    }
+
+    test('not in group -> no permission', () {
+      final selfUser = eg.user();
+      final group = eg.userGroup(members: []);
+      check(hasPermission(selfUser, group, 'can_subscribe_group'))
+        .isFalse();
+    });
+
+    test('in group -> has permission', () {
+      final selfUser = eg.user();
+      final group = eg.userGroup(members: [selfUser.userId]);
+      check(hasPermission(selfUser, group, 'can_subscribe_group'))
+        .isTrue();
+    });
+
+    test('guest -> no permission, despite group', () {
+      final selfUser = eg.user(role: UserRole.guest);
+      final group = eg.userGroup(members: [selfUser.userId]);
+      check(hasPermission(selfUser, group, 'can_subscribe_group'))
+        .isFalse();
+    });
+
+    test('guest -> still has permission, if allowEveryoneGroup', () {
+      final selfUser = eg.user(role: UserRole.guest);
+      final group = eg.userGroup(members: [selfUser.userId]);
+      check(hasPermission(selfUser, group, 'can_send_message_group'))
+        .isTrue();
+    });
+
+    test('guest not in group -> no permission, even if allowEveryoneGroup', () {
+      final selfUser = eg.user(role: UserRole.guest);
+      final group = eg.userGroup(members: []);
+      check(hasPermission(selfUser, group, 'can_send_message_group'))
+        .isFalse();
+    });
+  });
+
   group('customProfileFields', () {
     test('update clobbers old list', () async {
       final store = eg.store(initialSnapshot: eg.initialSnapshot(
@@ -47,32 +125,4 @@ void main() {
       check(store.customProfileFields.map((f) => f.id)).deepEquals([2, 0, 1]);
     });
   });
-
-  test('processTopicLikeServer', () {
-    final emptyTopicDisplayName = eg.defaultRealmEmptyTopicDisplayName;
-
-    TopicName process(TopicName topic, int zulipFeatureLevel) {
-      final account = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel);
-      final store = eg.store(account: account, initialSnapshot: eg.initialSnapshot(
-        zulipFeatureLevel: zulipFeatureLevel,
-        realmEmptyTopicDisplayName: emptyTopicDisplayName));
-      return store.processTopicLikeServer(topic);
-    }
-
-    void doCheck(TopicName topic, TopicName expected, int zulipFeatureLevel) {
-      check(process(topic, zulipFeatureLevel)).equals(expected);
-    }
-
-    check(() => process(eg.t(''), 333)).throws<void>();
-    doCheck(eg.t('(no topic)'),          eg.t('(no topic)'),          333);
-    doCheck(eg.t(emptyTopicDisplayName), eg.t(emptyTopicDisplayName), 333);
-    doCheck(eg.t('other topic'),         eg.t('other topic'),         333);
-
-    doCheck(eg.t(''),                    eg.t(''),                    334);
-    doCheck(eg.t('(no topic)'),          eg.t('(no topic)'),          334);
-    doCheck(eg.t(emptyTopicDisplayName), eg.t(''),                    334);
-    doCheck(eg.t('other topic'),         eg.t('other topic'),         334);
-
-    doCheck(eg.t('(no topic)'),          eg.t(''),                    370);
-  });
 }
diff --git a/test/model/user_group_test.dart b/test/model/user_group_test.dart
index f05b3c3c32..0741f53b95 100644
--- a/test/model/user_group_test.dart
+++ b/test/model/user_group_test.dart
@@ -2,6 +2,7 @@ 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/store.dart';
 import 'package:zulip/model/user_group.dart';

 import '../api/model/model_checks.dart';
@@ -92,6 +93,93 @@ void main() {
     }]);
   });

+  group('membership', () {
+    // These tests exercise membership via selfInGroupSetting, because that's
+    // the main interface the app uses to consume group membership.
+
+    late PerAccountStore store;
+
+    void prepare(List<UserGroup> groups, {List<User>? users}) {
+      store = eg.store(initialSnapshot: eg.initialSnapshot(
+        realmUsers: users, realmUserGroups: groups));
+    }
+
+    bool isMember(UserGroup group) {
+      return store.selfInGroupSetting(GroupSettingValueNamed(group.id));
+    }
+
+    test('initial', () {
+      final groups = <UserGroup>[];
+      groups.add(eg.userGroup(members: [eg.selfUser.userId]));
+      groups.add(eg.userGroup(members: [eg.user().userId]));
+      groups.add(eg.userGroup(directSubgroupIds: [groups[0].id]));
+      groups.add(eg.userGroup(directSubgroupIds: [groups[2].id]));
+      groups.add(eg.userGroup(directSubgroupIds: [groups[1].id]));
+
+      prepare(groups);
+      check(groups.map(isMember)).deepEquals([
+        true,
+        false,
+        true,
+        true,
+        false,
+      ]);
+    });
+
+    test('UserGroupEvent', () async {
+      final groups = List.generate(4, (_) => eg.userGroup());
+      prepare(groups);
+      check(groups.map(isMember)).deepEquals([false, false, false, false]);
+
+      // Add a membership.
+      await store.handleEvent(UserGroupAddMembersEvent(id: 0,
+        groupId: groups[0].id, userIds: [eg.selfUser.userId]));
+      check(groups.map(isMember)).deepEquals([true, false, false, false]);
+
+      // Add a chain of transitive memberships.
+      await store.handleEvent(UserGroupAddSubgroupsEvent(id: 0,
+        groupId: groups[1].id, directSubgroupIds: [groups[0].id]));
+      check(groups.map(isMember)).deepEquals([true, true, false, false]);
+      await store.handleEvent(UserGroupAddSubgroupsEvent(id: 0,
+        groupId: groups[2].id, directSubgroupIds: [groups[1].id]));
+      check(groups.map(isMember)).deepEquals([true, true, true, false]);
+
+      // Cut the middle link of the chain.
+      await store.handleEvent(UserGroupRemoveSubgroupsEvent(id: 0,
+        groupId: groups[1].id, directSubgroupIds: [groups[0].id]));
+      check(groups.map(isMember)).deepEquals([true, false, false, false]);
+
+      // Restore the middle link; cut the bottom link.
+      await store.handleEvent(UserGroupAddSubgroupsEvent(id: 0,
+        groupId: groups[1].id, directSubgroupIds: [groups[0].id]));
+      check(groups.map(isMember)).deepEquals([true, true, true, false]);
+      await store.handleEvent(UserGroupRemoveMembersEvent(id: 0,
+        groupId: groups[0].id, userIds: [eg.selfUser.userId]));
+      check(groups.map(isMember)).deepEquals([false, false, false, false]);
+    });
+
+    test('RealmUserUpdateEvent', () async {
+      // This test uses the membership data structure directly, because
+      // selfInGroupSetting would only be affected if the self-user were
+      // deactivated, and in that case we wouldn't be getting an event.
+
+      final user = eg.user();
+      final group = eg.userGroup(members: [user.userId]);
+      prepare(users: [eg.selfUser, user], [group]);
+      check(store.getGroup(group.id)!.members).deepEquals([user.userId]);
+
+      // An update to a random irrelevant field has no effect.
+      await store.handleEvent(RealmUserUpdateEvent(id: 0,
+        userId: user.userId, fullName: 'New Name'));
+      check(store.getGroup(group.id)!.members).deepEquals([user.userId]);
+
+      // But deactivating the user removes them from groups.
+      await store.handleEvent(RealmUserUpdateEvent(id: 0,
+        userId: user.userId, isActive: false));
+      check(store.getGroup(group.id)!.members).isEmpty();
+    });
+  });
+
   test('various fields make it through', () async {
     final store = eg.store(initialSnapshot: eg.initialSnapshot(
       realmUserGroups: [


content: Fix bugs where `TextSpan` styles clobber each other with `InlineContent.style` attributes

Fixes #806.
Fixes #1812.
Fixes #1817.
Fixes #1818.

There are other issues with inline-span styles:
- #1813
- #1819
- (possibly more)

but I think those all involve `WidgetSpan`s in `InlineContent`'s span tree and can be addressed separately.

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

content: Bold text in strikethrough should have strikethrough line, but doesn't

content: Inline code links should be rendered with the link color
We should fix inline code's color when it is a part of a link.

| web (expected) | flutter |
| - | - |
| ![Screenshot from 2024-07-11 21-33-29](https://github.com/user-attachments/assets/256fd1d0-c627-428d-9706-59dd562da0bd) |  ![1000013523](https://github.com/user-attachments/assets/fecc3c09-ddb5-4dd5-bfdc-1333990a99d7) |

Since a recent change (https://github.com/zulip/zulip-flutter/commit/299c76be7), inline code links no longer have the ambient color and is always black. We want to fix this to match the behavior of the web app.

For reference, the web app [defines](https://github.com/zulip/zulip/blob/4d34a902c9c2c7df87ded0ce5a05df7ccf052c97/web/styles/app_variables.css#L494-L495) a separate CSS variable `--color-markdown-code-link`

```css
    --color-markdown-link: hsl(200deg 100% 40%);
    --color-markdown-code-link: var(--color-markdown-link);
```

that [applies to](https://github.com/zulip/zulip/blob/4d34a902c9c2c7df87ded0ce5a05df7ccf052c97/web/styles/rendered_markdown.css#L679C1-L685) inline code that appears inside a link:

```
    & a {
        color: var(--color-markdown-link);
        text-decoration: none;

        & code {
            color: var(--color-markdown-code-link);
        }
```
content: Inline code in a bold span should be bold (but isn't)
### References
* Related symptom: #1813
* Related symptom: #806
content: Unicode emoji in strikethrough should have strikethrough line, but doesn't

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

diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart
index 5851222a1c..3626d55ae4 100644
--- a/lib/widgets/content.dart
+++ b/lib/widgets/content.dart
@@ -1127,8 +1127,7 @@ class _InlineContentBuilder {

       case UnicodeEmojiNode():
         return TextSpan(text: node.emojiUnicode, recognizer: _recognizer,
-          style: widget.style
-            .merge(ContentTheme.of(_context!).textStyleEmoji));
+          style: ContentTheme.of(_context!).textStyleEmoji);

       case ImageEmojiNode():
         return WidgetSpan(alignment: PlaceholderAlignment.middle,
@@ -1138,9 +1137,8 @@ class _InlineContentBuilder {
         final nodes = node.nodes;
         return nodes == null
           ? TextSpan(
-              style: widget.style
-                .merge(ContentTheme.of(_context!).textStyleInlineMath)
-                .apply(fontSizeFactor: kInlineCodeFontSizeFactor),
+              style: ContentTheme.of(_context!).textStyleInlineMath
+                .copyWith(fontSize: widget.style.fontSize! * kInlineCodeFontSizeFactor),
               children: [TextSpan(text: node.texSource)])
           : WidgetSpan(
               alignment: PlaceholderAlignment.baseline,
@@ -1181,11 +1179,9 @@ class _InlineContentBuilder {
     // TODO `code`: find equivalent of web's `unicode-bidi: embed; direction: ltr`

     return _buildNodes(
-      style: widget.style
-        .merge(ContentTheme.of(_context!).textStyleInlineCode)
-        .apply(fontSizeFactor: kInlineCodeFontSizeFactor),
-      node.nodes,
-    );
+      style: ContentTheme.of(_context!).textStyleInlineCode
+        .copyWith(fontSize: widget.style.fontSize! * kInlineCodeFontSizeFactor),
+      node.nodes);

     // Another fun solution -- we can in fact have a border!  Like so:
     //   TextStyle(
diff --git a/lib/widgets/text.dart b/lib/widgets/text.dart
index 13bd141772..f2f0edd968 100644
--- a/lib/widgets/text.dart
+++ b/lib/widgets/text.dart
@@ -288,8 +288,11 @@ double bolderWght(double baseWght, {double by = 300}) {
   return clampDouble(baseWght + by, kWghtMin, kWghtMax);
 }

-/// A [TextStyle] whose [FontVariation] "wght" and [TextStyle.fontWeight]
-/// have been raised using [bolderWght].
+/// A [TextStyle] with [FontVariation] "wght" and [TextStyle.fontWeight]
+/// that have been raised from the input using [bolderWght].
+///
+/// Non-weight attributes in [style] are ignored
+/// and will not appear in the result.
 ///
 /// [style] must have already been processed with [weightVariableTextStyle],
 /// and [by] must be positive.
@@ -311,12 +314,9 @@ TextStyle bolderWghtTextStyle(TextStyle style, {double by = 300}) {

   final newWght = bolderWght(wghtFromTextStyle(style)!, by: by);

-  TextStyle result = style.copyWith(
-    fontVariations: style.fontVariations!.map((v) => v.axis == 'wght'
-      ? FontVariation('wght', newWght)
-      : v).toList(),
-    fontWeight: clampVariableFontWeight(newWght),
-  );
+  TextStyle result = TextStyle(
+    fontVariations: [FontVariation('wght', newWght)],
+    fontWeight: clampVariableFontWeight(newWght));

   assert(() {
     result = result.copyWith(debugLabel: 'bolderWghtTextStyle(by: $by)');

diff --git a/test/model/content_test.dart b/test/model/content_test.dart
index 9cabfbb3b8..84e3baed42 100644
--- a/test/model/content_test.dart
+++ b/test/model/content_test.dart
@@ -85,6 +85,13 @@ class ContentExample {
     '
bold
',
     const StrongNode(nodes: [TextNode('bold')]));

+  static final deleted = ContentExample.inline(
+    'deleted/strike-through',
+    '~~strike through~~',
+    expectedText: 'strike through',
+    '
strike through
',
+    const DeletedNode(nodes: [TextNode('strike through')]));
+
   static final emphasis = ContentExample.inline(
     'emphasis/italic',
     '*italic*',
@@ -1535,10 +1542,7 @@ void main() async {

   testParseExample(ContentExample.strong);

-  testParseInline('parse deleted/strike-through',
-    // "~~strike through~~"
-    '
strike through
',
-    const DeletedNode(nodes: [TextNode('strike through')]));
+  testParseExample(ContentExample.deleted);

   testParseExample(ContentExample.emphasis);

diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart
index aef5ef3cc0..28af496c59 100644
--- a/test/widgets/content_test.dart
+++ b/test/widgets/content_test.dart
@@ -186,11 +186,11 @@ void main() {
   /// [styleFinder] must return the [TextStyle] containing the "wght"
   /// (in [TextStyle.fontVariations]) and the [TextStyle.fontWeight]
   /// to be checked.
-  Future<void> testFontWeight(String description, {
+  void testFontWeight(String description, {
     required Widget content,
     required double expectedWght,
     required TextStyle Function(WidgetTester tester) styleFinder,
-  }) async {
+  }) {
     for (final platformRequestsBold in [false, true]) {
       testWidgets(
         description + (platformRequestsBold ? ' (platform requests bold)' : ''),
@@ -707,8 +707,18 @@ void main() {
           '
\n
\ntext	\n
\n
\n'
           '
'),
       styleFinder: findWordBold);
+
+    testWidgets('has strike-through line in strike-through', (tester) async {
+      // Regression test for: https://github.com/zulip/zulip-flutter/issues/1817
+      await prepareContent(tester,
+        plainContent('
bold
'));
+      final style = mergedStyleOf(tester, 'bold');
+      check(style!.decoration).equals(TextDecoration.lineThrough);
+    });
   });

+  testContentSmoke(ContentExample.deleted);
+
   testContentSmoke(ContentExample.emphasis);

   group('inline code', () {
@@ -719,6 +729,22 @@ void main() {
         targetHtml: 'code',
         targetFontSizeFinder: mkTargetFontSizeFinderFromPattern('code'));
     });
+
+    testFontWeight('is bold in bold span',
+      // Regression test for: https://github.com/zulip/zulip-flutter/issues/1812
+      expectedWght: 600,
+      // **`bold`**
+      content: plainContent('
bold
'),
+      styleFinder: (tester) => mergedStyleOf(tester, 'bold')!,
+    );
+
+    testWidgets('is link-colored in link span', (tester) async {
+      // Regression test for: https://github.com/zulip/zulip-flutter/issues/806
+      await prepareContent(tester,
+        plainContent('
code
'));
+      final style = mergedStyleOf(tester, 'code');
+      check(style!.color).equals(const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor());
+    });
   });

   group('UserMention', () {
@@ -980,6 +1006,14 @@ void main() {
           _ => throw StateError('unexpected platform in test'),
         });
     }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
+
+    testWidgets('has strike-through line in strike-through', (tester) async {
+      // Regression test for https://github.com/zulip/zulip-flutter/issues/1818
+      await prepareContent(tester,
+        plainContent('
foo:thumbs_up:bar
'));
+      final style = mergedStyleOf(tester, '\u{1f44d}');
+      check(style!.decoration).equals(TextDecoration.lineThrough);
+    });
   });

   group('inline math', () {
@@ -1010,27 +1044,35 @@ void main() {
         });
     });

-    testWidgets('maintains font-size ratio with surrounding text, when falling back to TeX source', (tester) async {
-      const unsupportedHtml = ''
-        '<math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>λ</mi></mrow>'
-          '<annotation encoding="application/x-tex"> \\lambda </annotation></semantics></math>'
-        ''
-          '' // Server doesn't generate this 'unknown' class.
-          ''
-          'λ';
-      await checkFontSizeRatio(tester,
-        targetHtml: unsupportedHtml,
-        targetFontSizeFinder: mkTargetFontSizeFinderFromPattern(r'\lambda'));
-    });
+    group('fallback to displaying KaTeX source if unsupported KaTeX HTML', () {
+      testContentSmoke(ContentExample.mathInlineUnknown);

-    testWidgets('displays KaTeX content', (tester) async {
-      await prepareContent(tester, plainContent(ContentExample.mathInline.html));
-      tester.widget(find.text('λ', findRichText: true));
-    });
+      assert(ContentExample.mathInlineUnknown.html.startsWith('
'));
+      assert(ContentExample.mathInlineUnknown.html.endsWith('
'));
+      final unsupportedKatexHtml = ContentExample.mathInlineUnknown.html
+        .substring(3, ContentExample.mathInlineUnknown.html.length - 4);
+      final expectedText = ContentExample.mathInlineUnknown.expectedText!;

-    testWidgets('fallback to displaying KaTeX source if unsupported KaTeX HTML', (tester) async {
-      await prepareContent(tester, plainContent(ContentExample.mathInlineUnknown.html));
-      tester.widget(find.text(r'\lambda'));
+      testWidgets('maintains font-size ratio with surrounding text, when falling back to TeX source', (tester) async {
+        await checkFontSizeRatio(tester,
+          targetHtml: unsupportedKatexHtml,
+          targetFontSizeFinder: mkTargetFontSizeFinderFromPattern(expectedText));
+      });
+
+      testFontWeight('is bold in bold span',
+        // Regression test for: https://github.com/zulip/zulip-flutter/issues/1812
+        expectedWght: 600,
+        content: plainContent('
$unsupportedKatexHtml
'),
+        styleFinder: (tester) => mergedStyleOf(tester, expectedText)!,
+      );
+
+      testWidgets('is link-colored in link span', (tester) async {
+        // Regression test for: https://github.com/zulip/zulip-flutter/issues/806
+        await prepareContent(tester,
+          plainContent('
$unsupportedKatexHtml
'));
+        final style = mergedStyleOf(tester, expectedText);
+        check(style!.color).equals(const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor());
+      });
     });
   });