Repository: zulip/zulip-flutter (language: dart)

Issue:
Update can-send-message check to use modern group-based permission when available
We have a permission check that controls which channels appear in the share-to-Zulip page, and also whether we offer a compose box in channels.

We should update it to use the modern `can_send_message_group` permission when that's available, instead of relying on the deprecated `channel_post_policy` field.


Discussion hints:
nan

Pull request: channel: Implement modern group-based permission check for can-send-message
Fixes #1862.

Test command: flutter test test/model/channel_test.dart --reporter expanded 2>&1

Fix patch:
diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart
index 94bf662e30..5d86935186 100644
--- a/lib/api/model/events.dart
+++ b/lib/api/model/events.dart
@@ -680,6 +680,7 @@ class ChannelUpdateEvent extends ChannelEvent {
       case ChannelPropertyName.canAddSubscribersGroup:
       case ChannelPropertyName.canDeleteAnyMessageGroup:
       case ChannelPropertyName.canDeleteOwnMessageGroup:
+      case ChannelPropertyName.canSendMessageGroup:
       case ChannelPropertyName.canSubscribeGroup:
         return GroupSettingValue.fromJson(value);
       case ChannelPropertyName.streamWeeklyTraffic:
diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart
index 398802892a..0313976018 100644
--- a/lib/api/model/events.g.dart
+++ b/lib/api/model/events.g.dart
@@ -465,6 +465,7 @@ const _$ChannelPropertyNameEnumMap = {
   ChannelPropertyName.canAddSubscribersGroup: 'can_add_subscribers_group',
   ChannelPropertyName.canDeleteAnyMessageGroup: 'can_delete_any_message_group',
   ChannelPropertyName.canDeleteOwnMessageGroup: 'can_delete_own_message_group',
+  ChannelPropertyName.canSendMessageGroup: 'can_send_message_group',
   ChannelPropertyName.canSubscribeGroup: 'can_subscribe_group',
   ChannelPropertyName.streamWeeklyTraffic: 'stream_weekly_traffic',
 };
diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart
index d0a80c2b4c..2bc6d7d487 100644
--- a/lib/api/model/model.dart
+++ b/lib/api/model/model.dart
@@ -649,12 +649,13 @@ class ZulipStream {
   bool historyPublicToSubscribers;
   int? messageRetentionDays;
   @JsonKey(name: 'stream_post_policy')
-  ChannelPostPolicy channelPostPolicy;
+  ChannelPostPolicy? channelPostPolicy; // TODO(server-10) remove
   // final bool isAnnouncementOnly; // deprecated for `channelPostPolicy`; ignore
 
   GroupSettingValue? canAddSubscribersGroup; // TODO(server-10)
   GroupSettingValue? canDeleteAnyMessageGroup; // TODO(server-11)
   GroupSettingValue? canDeleteOwnMessageGroup; // TODO(server-11)
+  GroupSettingValue? canSendMessageGroup; // TODO(server-10)
   GroupSettingValue? canSubscribeGroup; // TODO(server-10)
 
   // TODO(server-8): added in FL 199, was previously only on [Subscription] objects
@@ -676,6 +677,7 @@ class ZulipStream {
     required this.canAddSubscribersGroup,
     required this.canDeleteAnyMessageGroup,
     required this.canDeleteOwnMessageGroup,
+    required this.canSendMessageGroup,
     required this.canSubscribeGroup,
     required this.streamWeeklyTraffic,
   });
@@ -698,6 +700,7 @@ class ZulipStream {
       canAddSubscribersGroup: subscription.canAddSubscribersGroup,
       canDeleteAnyMessageGroup: subscription.canDeleteAnyMessageGroup,
       canDeleteOwnMessageGroup: subscription.canDeleteOwnMessageGroup,
+      canSendMessageGroup: subscription.canSendMessageGroup,
       canSubscribeGroup: subscription.canSubscribeGroup,
       streamWeeklyTraffic: subscription.streamWeeklyTraffic,
     );
@@ -733,6 +736,7 @@ enum ChannelPropertyName {
   canAddSubscribersGroup,
   canDeleteAnyMessageGroup,
   canDeleteOwnMessageGroup,
+  canSendMessageGroup,
   canSubscribeGroup,
   streamWeeklyTraffic;
 
@@ -817,6 +821,7 @@ class Subscription extends ZulipStream {
     required super.canAddSubscribersGroup,
     required super.canDeleteAnyMessageGroup,
     required super.canDeleteOwnMessageGroup,
+    required super.canSendMessageGroup,
     required super.canSubscribeGroup,
     required super.streamWeeklyTraffic,
     required this.desktopNotifications,
diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart
index b21b2ee29e..70316ae1eb 100644
--- a/lib/api/model/model.g.dart
+++ b/lib/api/model/model.g.dart
@@ -247,7 +247,7 @@ ZulipStream _$ZulipStreamFromJson(Map<String, dynamic> json) => ZulipStream(
   isWebPublic: json['is_web_public'] as bool,
   historyPublicToSubscribers: json['history_public_to_subscribers'] as bool,
   messageRetentionDays: (json['message_retention_days'] as num?)?.toInt(),
-  channelPostPolicy: $enumDecode(
+  channelPostPolicy: $enumDecodeNullable(
     _$ChannelPostPolicyEnumMap,
     json['stream_post_policy'],
   ),
@@ -260,6 +260,9 @@ ZulipStream _$ZulipStreamFromJson(Map<String, dynamic> json) => ZulipStream(
   canDeleteOwnMessageGroup: json['can_delete_own_message_group'] == null
       ? null
       : GroupSettingValue.fromJson(json['can_delete_own_message_group']),
+  canSendMessageGroup: json['can_send_message_group'] == null
+      ? null
+      : GroupSettingValue.fromJson(json['can_send_message_group']),
   canSubscribeGroup: json['can_subscribe_group'] == null
       ? null
       : GroupSettingValue.fromJson(json['can_subscribe_group']),
@@ -283,6 +286,7 @@ Map<String, dynamic> _$ZulipStreamToJson(ZulipStream instance) =>
       'can_add_subscribers_group': instance.canAddSubscribersGroup,
       'can_delete_any_message_group': instance.canDeleteAnyMessageGroup,
       'can_delete_own_message_group': instance.canDeleteOwnMessageGroup,
+      'can_send_message_group': instance.canSendMessageGroup,
       'can_subscribe_group': instance.canSubscribeGroup,
       'stream_weekly_traffic': instance.streamWeeklyTraffic,
     };
@@ -307,7 +311,7 @@ Subscription _$SubscriptionFromJson(Map<String, dynamic> json) => Subscription(
   isWebPublic: json['is_web_public'] as bool,
   historyPublicToSubscribers: json['history_public_to_subscribers'] as bool,
   messageRetentionDays: (json['message_retention_days'] as num?)?.toInt(),
-  channelPostPolicy: $enumDecode(
+  channelPostPolicy: $enumDecodeNullable(
     _$ChannelPostPolicyEnumMap,
     json['stream_post_policy'],
   ),
@@ -320,6 +324,9 @@ Subscription _$SubscriptionFromJson(Map<String, dynamic> json) => Subscription(
   canDeleteOwnMessageGroup: json['can_delete_own_message_group'] == null
       ? null
       : GroupSettingValue.fromJson(json['can_delete_own_message_group']),
+  canSendMessageGroup: json['can_send_message_group'] == null
+      ? null
+      : GroupSettingValue.fromJson(json['can_send_message_group']),
   canSubscribeGroup: json['can_subscribe_group'] == null
       ? null
       : GroupSettingValue.fromJson(json['can_subscribe_group']),
@@ -351,6 +358,7 @@ Map<String, dynamic> _$SubscriptionToJson(Subscription instance) =>
       'can_add_subscribers_group': instance.canAddSubscribersGroup,
       'can_delete_any_message_group': instance.canDeleteAnyMessageGroup,
       'can_delete_own_message_group': instance.canDeleteOwnMessageGroup,
+      'can_send_message_group': instance.canSendMessageGroup,
       'can_subscribe_group': instance.canSubscribeGroup,
       'stream_weekly_traffic': instance.streamWeeklyTraffic,
       'desktop_notifications': instance.desktopNotifications,
@@ -515,6 +523,7 @@ const _$ChannelPropertyNameEnumMap = {
   ChannelPropertyName.canAddSubscribersGroup: 'can_add_subscribers_group',
   ChannelPropertyName.canDeleteAnyMessageGroup: 'can_delete_any_message_group',
   ChannelPropertyName.canDeleteOwnMessageGroup: 'can_delete_own_message_group',
+  ChannelPropertyName.canSendMessageGroup: 'can_send_message_group',
   ChannelPropertyName.canSubscribeGroup: 'can_subscribe_group',
   ChannelPropertyName.streamWeeklyTraffic: 'stream_weekly_traffic',
 };
diff --git a/lib/model/channel.dart b/lib/model/channel.dart
index 0403ba0bdf..098f146df1 100644
--- a/lib/model/channel.dart
+++ b/lib/model/channel.dart
@@ -192,17 +192,36 @@ mixin ChannelStore on UserStore {
     required ZulipStream inChannel,
     required DateTime byDate,
   }) {
+    // (selfHasPermissionForGroupSetting isn't equipped to handle the old-server
+    // fallback logic for this specific permission; it's dynamic and depends on
+    // channelPostPolicy, so we do our own null check here.)
+    if (inChannel.canSendMessageGroup != null) {
+      return selfHasPermissionForGroupSetting(inChannel.canSendMessageGroup!,
+               GroupSettingType.stream, 'can_send_message_group');
+    } else if (inChannel.channelPostPolicy != null) {
+      return _selfPassesLegacyChannelPostPolicy(inChannel: inChannel, atDate: byDate);
+    } else {
+      assert(false); // TODO(log)
+      return true;
+    }
+  }
+
+  bool _selfPassesLegacyChannelPostPolicy({
+    required ZulipStream inChannel,
+    required DateTime atDate,
+  }) {
+    assert(inChannel.channelPostPolicy != null);
     final role = selfUser.role;
-    // We let the users with [unknown] role to send the message, then the server
-    // will decide to accept it or not based on its actual role.
-    if (role == UserRole.unknown) return true;
 
-    switch (inChannel.channelPostPolicy) {
+    // (Could early-return true on [UserRole.unknown],
+    // but pre-333 servers shouldn't be giving us an unknown role.)
+
+    switch (inChannel.channelPostPolicy!) {
       case ChannelPostPolicy.any:             return true;
       case ChannelPostPolicy.fullMembers:     {
         if (!role.isAtLeast(UserRole.member)) return false;
         if (role == UserRole.member) {
-          return selfHasPassedWaitingPeriod(byDate: byDate);
+          return selfHasPassedWaitingPeriod(byDate: atDate);
         }
         return true;
       }
@@ -405,6 +424,8 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore {
             stream.canDeleteAnyMessageGroup = event.value as GroupSettingValue;
           case ChannelPropertyName.canDeleteOwnMessageGroup:
             stream.canDeleteOwnMessageGroup = event.value as GroupSettingValue;
+          case ChannelPropertyName.canSendMessageGroup:
+            stream.canSendMessageGroup = event.value as GroupSettingValue;
           case ChannelPropertyName.canSubscribeGroup:
             stream.canSubscribeGroup = event.value as GroupSettingValue;
           case ChannelPropertyName.streamWeeklyTraffic:


Test patch:
diff --git a/test/example_data.dart b/test/example_data.dart
index 9a9eb1099e..9bc82b9d6d 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -461,9 +461,16 @@ ZulipStream stream({
   GroupSettingValue? canAddSubscribersGroup,
   GroupSettingValue? canDeleteAnyMessageGroup,
   GroupSettingValue? canDeleteOwnMessageGroup,
+  GroupSettingValue? canSendMessageGroup,
   GroupSettingValue? canSubscribeGroup,
   int? streamWeeklyTraffic,
 }) {
+  if (channelPostPolicy == null) {
+    // Set a default for realmCanDeleteOwnMessageGroup, but only if we're
+    // not trying to test legacy behavior with channelPostPolicy.
+    canSendMessageGroup ??= groupSetting(members: [selfUser.userId]);
+  }
+
   _checkPositive(streamId, 'stream ID');
   _checkPositive(firstMessageId, 'message ID');
   var effectiveStreamId = streamId ?? _nextStreamId();
@@ -485,6 +492,7 @@ ZulipStream stream({
     canAddSubscribersGroup: canAddSubscribersGroup ?? GroupSettingValueNamed(nobodyGroup.id),
     canDeleteAnyMessageGroup: canDeleteAnyMessageGroup ?? GroupSettingValueNamed(nobodyGroup.id),
     canDeleteOwnMessageGroup: canDeleteOwnMessageGroup ?? GroupSettingValueNamed(nobodyGroup.id),
+    canSendMessageGroup: canSendMessageGroup,
     canSubscribeGroup: canSubscribeGroup ?? GroupSettingValueNamed(nobodyGroup.id),
     streamWeeklyTraffic: streamWeeklyTraffic,
   );
@@ -528,6 +536,7 @@ Subscription subscription(
     canAddSubscribersGroup: stream.canAddSubscribersGroup,
     canDeleteAnyMessageGroup: stream.canDeleteAnyMessageGroup,
     canDeleteOwnMessageGroup: stream.canDeleteOwnMessageGroup,
+    canSendMessageGroup: stream.canSendMessageGroup,
     canSubscribeGroup: stream.canSubscribeGroup,
     streamWeeklyTraffic: stream.streamWeeklyTraffic,
     desktopNotifications: desktopNotifications ?? false,
@@ -1206,6 +1215,7 @@ ChannelUpdateEvent channelUpdateEvent(
     case ChannelPropertyName.canAddSubscribersGroup:
     case ChannelPropertyName.canDeleteAnyMessageGroup:
     case ChannelPropertyName.canDeleteOwnMessageGroup:
+    case ChannelPropertyName.canSendMessageGroup:
     case ChannelPropertyName.canSubscribeGroup:
       assert(value is GroupSettingValue);
     case ChannelPropertyName.streamWeeklyTraffic:
diff --git a/test/model/channel_test.dart b/test/model/channel_test.dart
index 00dbfb10a2..e7e57357d6 100644
--- a/test/model/channel_test.dart
+++ b/test/model/channel_test.dart
@@ -9,9 +9,12 @@ import 'package:zulip/model/channel.dart';
 import '../api/model/model_checks.dart';
 import '../example_data.dart' as eg;
 import '../stdlib_checks.dart';
+import 'binding.dart';
 import 'test_store.dart';
 
 void main() {
+  TestZulipBinding.ensureInitialized();
+
   group('Unified stream/sub data', () {
     /// Check that `streams`, `streamsByName`, and `subscriptions` all agree
     /// and point to the same objects where applicable.
@@ -455,34 +458,55 @@ void main() {
     });
   });
 
-  group('hasPostingPermission', () {
+  group('selfCanSendMessage', () {
+    test('in group', () {
+      addTearDown(testBinding.reset);
+      final now = testBinding.utcNow();
+
+      final canSendMessageGroup = eg.groupSetting(members: [eg.selfUser.userId]);
+      final channel = eg.stream(canSendMessageGroup: canSendMessageGroup);
+      final store = eg.store(
+        initialSnapshot: eg.initialSnapshot(streams: [channel]));
+      check(store.selfCanSendMessage(inChannel: channel, byDate: now))
+        .isTrue();
+    });
+
+    test('not in group', () {
+      addTearDown(testBinding.reset);
+      final now = testBinding.utcNow();
+
+      final canSendMessageGroup = eg.groupSetting(members: []);
+      final channel = eg.stream(canSendMessageGroup: canSendMessageGroup);
+      final store = eg.store(
+        initialSnapshot: eg.initialSnapshot(streams: [channel]));
+      check(store.selfCanSendMessage(inChannel: channel, byDate: now))
+        .isFalse();
+    });
+  });
+
+  group('selfCanSendMessage, legacy', () {
     final testCases = [
-      (ChannelPostPolicy.unknown,        UserRole.unknown,       true),
       (ChannelPostPolicy.unknown,        UserRole.guest,         true),
       (ChannelPostPolicy.unknown,        UserRole.member,        true),
       (ChannelPostPolicy.unknown,        UserRole.moderator,     true),
       (ChannelPostPolicy.unknown,        UserRole.administrator, true),
       (ChannelPostPolicy.unknown,        UserRole.owner,         true),
-      (ChannelPostPolicy.any,            UserRole.unknown,       true),
       (ChannelPostPolicy.any,            UserRole.guest,         true),
       (ChannelPostPolicy.any,            UserRole.member,        true),
       (ChannelPostPolicy.any,            UserRole.moderator,     true),
       (ChannelPostPolicy.any,            UserRole.administrator, true),
       (ChannelPostPolicy.any,            UserRole.owner,         true),
-      (ChannelPostPolicy.fullMembers,    UserRole.unknown,       true),
       (ChannelPostPolicy.fullMembers,    UserRole.guest,         false),
       // The fullMembers/member case gets its own tests further below.
       // (ChannelPostPolicy.fullMembers,    UserRole.member,        /* complicated */),
       (ChannelPostPolicy.fullMembers,    UserRole.moderator,     true),
       (ChannelPostPolicy.fullMembers,    UserRole.administrator, true),
       (ChannelPostPolicy.fullMembers,    UserRole.owner,         true),
-      (ChannelPostPolicy.moderators,     UserRole.unknown,       true),
       (ChannelPostPolicy.moderators,     UserRole.guest,         false),
       (ChannelPostPolicy.moderators,     UserRole.member,        false),
       (ChannelPostPolicy.moderators,     UserRole.moderator,     true),
       (ChannelPostPolicy.moderators,     UserRole.administrator, true),
       (ChannelPostPolicy.moderators,     UserRole.owner,         true),
-      (ChannelPostPolicy.administrators, UserRole.unknown,       true),
       (ChannelPostPolicy.administrators, UserRole.guest,         false),
       (ChannelPostPolicy.administrators, UserRole.member,        false),
       (ChannelPostPolicy.administrators, UserRole.moderator,     false),