diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 2f0f846d49..94bf662e30 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -664,6 +664,9 @@ class ChannelUpdateEvent extends ChannelEvent { final value = json['value']; switch (ChannelPropertyName.fromRawString(json['property'] as String)) { case ChannelPropertyName.name: + return value as String; + case ChannelPropertyName.isArchived: + return value as bool; case ChannelPropertyName.description: return value as String; case ChannelPropertyName.firstMessageId: @@ -675,6 +678,8 @@ class ChannelUpdateEvent extends ChannelEvent { case ChannelPropertyName.channelPostPolicy: return ChannelPostPolicy.fromApiValue(value as int); case ChannelPropertyName.canAddSubscribersGroup: + case ChannelPropertyName.canDeleteAnyMessageGroup: + case ChannelPropertyName.canDeleteOwnMessageGroup: 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 6e7aadf362..398802892a 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -456,12 +456,15 @@ Map _$ChannelUpdateEventToJson(ChannelUpdateEvent instance) => const _$ChannelPropertyNameEnumMap = { ChannelPropertyName.name: 'name', + ChannelPropertyName.isArchived: 'is_archived', ChannelPropertyName.description: 'description', ChannelPropertyName.firstMessageId: 'first_message_id', ChannelPropertyName.inviteOnly: 'invite_only', ChannelPropertyName.messageRetentionDays: 'message_retention_days', ChannelPropertyName.channelPostPolicy: 'stream_post_policy', ChannelPropertyName.canAddSubscribersGroup: 'can_add_subscribers_group', + ChannelPropertyName.canDeleteAnyMessageGroup: 'can_delete_any_message_group', + ChannelPropertyName.canDeleteOwnMessageGroup: 'can_delete_own_message_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 26e1948d9d..a8e99dde28 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -72,6 +72,17 @@ class InitialSnapshot { final List? userTopics; // TODO(server-6) + final GroupSettingValue? realmCanDeleteAnyMessageGroup; // TODO(server-10) + + final GroupSettingValue? realmCanDeleteOwnMessageGroup; // TODO(server-10) + + /// The policy for who can delete their own messages, + /// on supported servers below version 10. + /// + /// Removed in FL 291, so absent in the current API doc; + /// see zulip/zulip@0cd51f2fe. + final RealmDeleteOwnMessagePolicy? realmDeleteOwnMessagePolicy; // TODO(server-10) + /// The policy for who can use wildcard mentions in large channels. /// /// Search for "realm_wildcard_mention_policy" in https://zulip.com/api/register-queue. @@ -87,6 +98,8 @@ class InitialSnapshot { /// https://zulip.com/api/roles-and-permissions#determining-if-a-user-is-a-full-member final int realmWaitingPeriodThreshold; + final int? realmMessageContentDeleteLimitSeconds; + final bool realmAllowMessageEditing; final int? realmMessageContentEditLimitSeconds; @@ -158,9 +171,13 @@ class InitialSnapshot { required this.userStatuses, required this.userSettings, required this.userTopics, + required this.realmCanDeleteAnyMessageGroup, + required this.realmCanDeleteOwnMessageGroup, + required this.realmDeleteOwnMessagePolicy, required this.realmWildcardMentionPolicy, required this.realmMandatoryTopics, required this.realmWaitingPeriodThreshold, + required this.realmMessageContentDeleteLimitSeconds, required this.realmAllowMessageEditing, required this.realmMessageContentEditLimitSeconds, required this.realmEnableReadReceipts, @@ -196,6 +213,21 @@ enum RealmWildcardMentionPolicy { int? toJson() => apiValue; } +@JsonEnum(valueField: 'apiValue') +enum RealmDeleteOwnMessagePolicy { + members(apiValue: 1), + admins(apiValue: 2), + fullMembers(apiValue: 3), + moderators(apiValue: 4), + everyone(apiValue: 5); + + const RealmDeleteOwnMessagePolicy({required this.apiValue}); + + final int apiValue; + + int toJson() => apiValue; +} + /// An item in `realm_default_external_accounts`. /// /// For docs, search for "realm_default_external_accounts:" @@ -425,7 +457,141 @@ class SupportedPermissionSettings { /// 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. + 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, diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 481b3184b7..4a4d153e2c 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -81,6 +81,18 @@ InitialSnapshot _$InitialSnapshotFromJson( userTopics: (json['user_topics'] as List?) ?.map((e) => UserTopicItem.fromJson(e as Map)) .toList(), + realmCanDeleteAnyMessageGroup: + json['realm_can_delete_any_message_group'] == null + ? null + : GroupSettingValue.fromJson(json['realm_can_delete_any_message_group']), + realmCanDeleteOwnMessageGroup: + json['realm_can_delete_own_message_group'] == null + ? null + : GroupSettingValue.fromJson(json['realm_can_delete_own_message_group']), + realmDeleteOwnMessagePolicy: $enumDecodeNullable( + _$RealmDeleteOwnMessagePolicyEnumMap, + json['realm_delete_own_message_policy'], + ), realmWildcardMentionPolicy: $enumDecode( _$RealmWildcardMentionPolicyEnumMap, json['realm_wildcard_mention_policy'], @@ -88,6 +100,8 @@ InitialSnapshot _$InitialSnapshotFromJson( realmMandatoryTopics: json['realm_mandatory_topics'] as bool, realmWaitingPeriodThreshold: (json['realm_waiting_period_threshold'] as num) .toInt(), + realmMessageContentDeleteLimitSeconds: + (json['realm_message_content_delete_limit_seconds'] as num?)?.toInt(), realmAllowMessageEditing: json['realm_allow_message_editing'] as bool, realmMessageContentEditLimitSeconds: (json['realm_message_content_edit_limit_seconds'] as num?)?.toInt(), @@ -157,9 +171,14 @@ Map _$InitialSnapshotToJson( 'user_status': instance.userStatuses.map((k, e) => MapEntry(k.toString(), e)), 'user_settings': instance.userSettings, 'user_topics': instance.userTopics, + 'realm_can_delete_any_message_group': instance.realmCanDeleteAnyMessageGroup, + 'realm_can_delete_own_message_group': instance.realmCanDeleteOwnMessageGroup, + 'realm_delete_own_message_policy': instance.realmDeleteOwnMessagePolicy, 'realm_wildcard_mention_policy': instance.realmWildcardMentionPolicy, 'realm_mandatory_topics': instance.realmMandatoryTopics, 'realm_waiting_period_threshold': instance.realmWaitingPeriodThreshold, + 'realm_message_content_delete_limit_seconds': + instance.realmMessageContentDeleteLimitSeconds, 'realm_allow_message_editing': instance.realmAllowMessageEditing, 'realm_message_content_edit_limit_seconds': instance.realmMessageContentEditLimitSeconds, @@ -174,6 +193,14 @@ Map _$InitialSnapshotToJson( 'cross_realm_bots': instance.crossRealmBots, }; +const _$RealmDeleteOwnMessagePolicyEnumMap = { + RealmDeleteOwnMessagePolicy.members: 1, + RealmDeleteOwnMessagePolicy.admins: 2, + RealmDeleteOwnMessagePolicy.fullMembers: 3, + RealmDeleteOwnMessagePolicy.moderators: 4, + RealmDeleteOwnMessagePolicy.everyone: 5, +}; + const _$RealmWildcardMentionPolicyEnumMap = { RealmWildcardMentionPolicy.everyone: 1, RealmWildcardMentionPolicy.members: 2, diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index a821474325..2a6c9f6aa1 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -627,6 +627,12 @@ class ZulipStream { final int streamId; String name; + + // Servers that don't send this property will only send non-archived channels; + // default to false for those servers. + @JsonKey(defaultValue: false) + bool isArchived; // TODO(server-10) remove default and its comment + String description; String renderedDescription; @@ -642,6 +648,8 @@ class ZulipStream { // final bool isAnnouncementOnly; // deprecated for `channelPostPolicy`; ignore GroupSettingValue? canAddSubscribersGroup; // TODO(server-10) + GroupSettingValue? canDeleteAnyMessageGroup; // TODO(server-11) + GroupSettingValue? canDeleteOwnMessageGroup; // TODO(server-11) GroupSettingValue? canSubscribeGroup; // TODO(server-10) // TODO(server-8): added in FL 199, was previously only on [Subscription] objects @@ -650,6 +658,7 @@ class ZulipStream { ZulipStream({ required this.streamId, required this.name, + required this.isArchived, required this.description, required this.renderedDescription, required this.dateCreated, @@ -660,6 +669,8 @@ class ZulipStream { required this.messageRetentionDays, required this.channelPostPolicy, required this.canAddSubscribersGroup, + required this.canDeleteAnyMessageGroup, + required this.canDeleteOwnMessageGroup, required this.canSubscribeGroup, required this.streamWeeklyTraffic, }); @@ -670,6 +681,7 @@ class ZulipStream { streamId: subscription.streamId, name: subscription.name, description: subscription.description, + isArchived: subscription.isArchived, renderedDescription: subscription.renderedDescription, dateCreated: subscription.dateCreated, firstMessageId: subscription.firstMessageId, @@ -679,6 +691,8 @@ class ZulipStream { messageRetentionDays: subscription.messageRetentionDays, channelPostPolicy: subscription.channelPostPolicy, canAddSubscribersGroup: subscription.canAddSubscribersGroup, + canDeleteAnyMessageGroup: subscription.canDeleteAnyMessageGroup, + canDeleteOwnMessageGroup: subscription.canDeleteOwnMessageGroup, canSubscribeGroup: subscription.canSubscribeGroup, streamWeeklyTraffic: subscription.streamWeeklyTraffic, ); @@ -700,6 +714,7 @@ class ZulipStream { enum ChannelPropertyName { // streamId is immutable name, + isArchived, description, // renderedDescription is updated via its own [ChannelUpdateEvent] field // dateCreated is immutable @@ -711,6 +726,8 @@ enum ChannelPropertyName { @JsonValue('stream_post_policy') channelPostPolicy, canAddSubscribersGroup, + canDeleteAnyMessageGroup, + canDeleteOwnMessageGroup, canSubscribeGroup, streamWeeklyTraffic; @@ -783,6 +800,7 @@ class Subscription extends ZulipStream { required super.streamId, required super.name, required super.description, + required super.isArchived, required super.renderedDescription, required super.dateCreated, required super.firstMessageId, @@ -792,6 +810,8 @@ class Subscription extends ZulipStream { required super.messageRetentionDays, required super.channelPostPolicy, required super.canAddSubscribersGroup, + required super.canDeleteAnyMessageGroup, + required super.canDeleteOwnMessageGroup, 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 b5bb32da2a..b21b2ee29e 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -238,6 +238,7 @@ Map _$SavedSnippetToJson(SavedSnippet instance) => ZulipStream _$ZulipStreamFromJson(Map json) => ZulipStream( streamId: (json['stream_id'] as num).toInt(), name: json['name'] as String, + isArchived: json['is_archived'] as bool? ?? false, description: json['description'] as String, renderedDescription: json['rendered_description'] as String, dateCreated: (json['date_created'] as num).toInt(), @@ -253,6 +254,12 @@ ZulipStream _$ZulipStreamFromJson(Map json) => ZulipStream( canAddSubscribersGroup: json['can_add_subscribers_group'] == null ? null : GroupSettingValue.fromJson(json['can_add_subscribers_group']), + canDeleteAnyMessageGroup: json['can_delete_any_message_group'] == null + ? null + : GroupSettingValue.fromJson(json['can_delete_any_message_group']), + canDeleteOwnMessageGroup: json['can_delete_own_message_group'] == null + ? null + : GroupSettingValue.fromJson(json['can_delete_own_message_group']), canSubscribeGroup: json['can_subscribe_group'] == null ? null : GroupSettingValue.fromJson(json['can_subscribe_group']), @@ -263,6 +270,7 @@ Map _$ZulipStreamToJson(ZulipStream instance) => { 'stream_id': instance.streamId, 'name': instance.name, + 'is_archived': instance.isArchived, 'description': instance.description, 'rendered_description': instance.renderedDescription, 'date_created': instance.dateCreated, @@ -273,6 +281,8 @@ Map _$ZulipStreamToJson(ZulipStream instance) => 'message_retention_days': instance.messageRetentionDays, 'stream_post_policy': instance.channelPostPolicy, 'can_add_subscribers_group': instance.canAddSubscribersGroup, + 'can_delete_any_message_group': instance.canDeleteAnyMessageGroup, + 'can_delete_own_message_group': instance.canDeleteOwnMessageGroup, 'can_subscribe_group': instance.canSubscribeGroup, 'stream_weekly_traffic': instance.streamWeeklyTraffic, }; @@ -289,6 +299,7 @@ Subscription _$SubscriptionFromJson(Map json) => Subscription( streamId: (json['stream_id'] as num).toInt(), name: json['name'] as String, description: json['description'] as String, + isArchived: json['is_archived'] as bool? ?? false, renderedDescription: json['rendered_description'] as String, dateCreated: (json['date_created'] as num).toInt(), firstMessageId: (json['first_message_id'] as num?)?.toInt(), @@ -303,6 +314,12 @@ Subscription _$SubscriptionFromJson(Map json) => Subscription( canAddSubscribersGroup: json['can_add_subscribers_group'] == null ? null : GroupSettingValue.fromJson(json['can_add_subscribers_group']), + canDeleteAnyMessageGroup: json['can_delete_any_message_group'] == null + ? null + : GroupSettingValue.fromJson(json['can_delete_any_message_group']), + canDeleteOwnMessageGroup: json['can_delete_own_message_group'] == null + ? null + : GroupSettingValue.fromJson(json['can_delete_own_message_group']), canSubscribeGroup: json['can_subscribe_group'] == null ? null : GroupSettingValue.fromJson(json['can_subscribe_group']), @@ -321,6 +338,7 @@ Map _$SubscriptionToJson(Subscription instance) => { 'stream_id': instance.streamId, 'name': instance.name, + 'is_archived': instance.isArchived, 'description': instance.description, 'rendered_description': instance.renderedDescription, 'date_created': instance.dateCreated, @@ -331,6 +349,8 @@ Map _$SubscriptionToJson(Subscription instance) => 'message_retention_days': instance.messageRetentionDays, 'stream_post_policy': instance.channelPostPolicy, 'can_add_subscribers_group': instance.canAddSubscribersGroup, + 'can_delete_any_message_group': instance.canDeleteAnyMessageGroup, + 'can_delete_own_message_group': instance.canDeleteOwnMessageGroup, 'can_subscribe_group': instance.canSubscribeGroup, 'stream_weekly_traffic': instance.streamWeeklyTraffic, 'desktop_notifications': instance.desktopNotifications, @@ -486,12 +506,15 @@ const _$PresenceStatusEnumMap = { const _$ChannelPropertyNameEnumMap = { ChannelPropertyName.name: 'name', + ChannelPropertyName.isArchived: 'is_archived', ChannelPropertyName.description: 'description', ChannelPropertyName.firstMessageId: 'first_message_id', ChannelPropertyName.inviteOnly: 'invite_only', ChannelPropertyName.messageRetentionDays: 'message_retention_days', ChannelPropertyName.channelPostPolicy: 'stream_post_policy', ChannelPropertyName.canAddSubscribersGroup: 'can_add_subscribers_group', + ChannelPropertyName.canDeleteAnyMessageGroup: 'can_delete_any_message_group', + ChannelPropertyName.canDeleteOwnMessageGroup: 'can_delete_own_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 8ef01054bd..ae864e7ed5 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -16,6 +16,9 @@ import 'user.dart'; /// /// The data structures described here are implemented at [ChannelStoreImpl]. mixin ChannelStore on UserStore { + @protected + UserStore get userStore; + /// All known channels/streams, indexed by [ZulipStream.streamId]. /// /// The same [ZulipStream] objects also appear in [streamsByName]. @@ -248,6 +251,18 @@ mixin ProxyChannelStore on ChannelStore { channelStore.debugTopicVisibility; } +/// A base class for [PerAccountStore] substores +/// that need access to [ChannelStore] as well as to its prerequisites +/// [CorePerAccountStore], [RealmStore], and [UserStore]. +abstract class HasChannelStore extends HasUserStore with ChannelStore, ProxyChannelStore { + HasChannelStore({required ChannelStore channels}) + : channelStore = channels, super(users: channels.userStore); + + @protected + @override + final ChannelStore channelStore; +} + /// The implementation of [ChannelStore] that does the work. /// /// Generally the only code that should need this class is [PerAccountStore] @@ -366,6 +381,8 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore { stream.name = event.value as String; streamsByName.remove(streamName); streamsByName[stream.name] = stream; + case ChannelPropertyName.isArchived: + stream.isArchived = event.value as bool; case ChannelPropertyName.description: stream.description = event.value as String; case ChannelPropertyName.firstMessageId: @@ -378,6 +395,10 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore { stream.channelPostPolicy = event.value as ChannelPostPolicy; case ChannelPropertyName.canAddSubscribersGroup: stream.canAddSubscribersGroup = event.value as GroupSettingValue; + case ChannelPropertyName.canDeleteAnyMessageGroup: + stream.canDeleteAnyMessageGroup = event.value as GroupSettingValue; + case ChannelPropertyName.canDeleteOwnMessageGroup: + stream.canDeleteOwnMessageGroup = event.value as GroupSettingValue; case ChannelPropertyName.canSubscribeGroup: stream.canSubscribeGroup = event.value as GroupSettingValue; case ChannelPropertyName.streamWeeklyTraffic: diff --git a/lib/model/message.dart b/lib/model/message.dart index 48bdbf5a49..9cc4d76f48 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -7,10 +7,12 @@ import 'package:flutter/foundation.dart'; import '../api/exception.dart'; import '../api/model/events.dart'; +import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; import '../log.dart'; import 'binding.dart'; +import 'channel.dart'; import 'message_list.dart'; import 'realm.dart'; import 'store.dart'; @@ -18,7 +20,7 @@ import 'store.dart'; const _apiSendMessage = sendMessage; // Bit ugly; for alternatives, see: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20PerAccountStore.20methods/near/1545809 /// The portion of [PerAccountStore] for messages and message lists. -mixin MessageStore { +mixin MessageStore on ChannelStore { /// All known messages, indexed by [Message.id]. Map get messages; @@ -77,6 +79,122 @@ mixin MessageStore { /// Should only be called when there is a failed request, /// per [getEditMessageErrorStatus]. ({String originalRawContent, String newContent}) takeFailedMessageEdit(int messageId); + + /// Whether the user has permission to delete a message, as of [atDate]. + /// + /// For a value of [atDate], use [ZulipBinding.instance.utcNow]. + bool selfCanDeleteMessage(int messageId, {required DateTime atDate}) { + // Compare web's message_delete.get_deletability. + + final message = messages[messageId]; + if (message == null) { + assert(false); // TODO(log) + return true; + } + + final ZulipStream? channel; + if (message is StreamMessage) { + channel = streams[message.streamId]; + if (channel == null) { + assert(false); // TODO(log) + return true; + } + } else { + channel = null; + } + + if (channel != null && channel.isArchived) { + 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')) { + return true; + } + + if (channel != null) { + if (channel.canDeleteAnyMessageGroup != null + && selfHasPermissionForGroupSetting(channel.canDeleteAnyMessageGroup!, + GroupSettingType.stream, 'can_delete_any_message_group')) { + return true; + } + } + + final sender = getUser(message.senderId); + if (sender == null) return false; + + if (!( + sender.userId == selfUserId + || (sender.isBot && sender.botOwnerId == selfUserId) + )) { + return false; + } + + // Web returns false here for local-echoed message objects; + // that's impossible here because `message` can't be an [OutboxMessage] + // (it's a [Message] from [MessageStore.messages]). + + if (realmCanDeleteOwnMessageGroup != null) { + if (!selfHasPermissionForGroupSetting(realmCanDeleteOwnMessageGroup!, + GroupSettingType.realm, 'can_delete_own_message_group')) { + if (channel == null) { + // i.e. this is a DM + return false; + } + + if ( + channel.canDeleteOwnMessageGroup == null + || !selfHasPermissionForGroupSetting(channel.canDeleteOwnMessageGroup!, + GroupSettingType.stream, 'can_delete_own_message_group') + ) { + return false; + } + } + } else if (realmDeleteOwnMessagePolicy != null) { + if (!_selfPassesLegacyDeleteMessagePolicy(messageId, atDate: atDate)) { + return false; + } + } else { + assert(false); // TODO(log) + return true; + } + + if (realmMessageContentDeleteLimitSeconds == null) { + // i.e., no limit + return true; + } + return atDate.millisecondsSinceEpoch ~/ 1000 - message.timestamp + <= realmMessageContentDeleteLimitSeconds!; + } + + bool _selfPassesLegacyDeleteMessagePolicy(int messageId, {required DateTime atDate}) { + assert(realmDeleteOwnMessagePolicy != null); + final role = selfUser.role; + + // (Could early-return true on [UserRole.unknown], + // but pre-291 servers shouldn't be giving us an unknown role.) + + switch (realmDeleteOwnMessagePolicy!) { + case RealmDeleteOwnMessagePolicy.everyone: + return true; + case RealmDeleteOwnMessagePolicy.members: + return role.isAtLeast(UserRole.member); + case RealmDeleteOwnMessagePolicy.fullMembers: { + if (!role.isAtLeast(UserRole.member)) return false; + if (role == UserRole.member) { + return hasPassedWaitingPeriod(selfUser, byDate: atDate); + } + return true; + } + case RealmDeleteOwnMessagePolicy.moderators: + return role.isAtLeast(UserRole.moderator); + case RealmDeleteOwnMessagePolicy.admins: + return role.isAtLeast(UserRole.administrator); + } + } } mixin ProxyMessageStore on MessageStore { @@ -138,8 +256,8 @@ class _EditMessageRequestStatus { final String newContent; } -class MessageStoreImpl extends HasRealmStore with MessageStore, _OutboxMessageStore { - MessageStoreImpl({required super.realm}) +class MessageStoreImpl extends HasChannelStore with MessageStore, _OutboxMessageStore { + MessageStoreImpl({required super.channels}) : // There are no messages in InitialSnapshot, so we don't have // a use case for initializing MessageStore with nonempty [messages]. messages = {}; diff --git a/lib/model/realm.dart b/lib/model/realm.dart index 02db21c56e..782740405d 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -37,16 +37,21 @@ mixin RealmStore on PerAccountStoreBase, UserGroupStore { //|////////////////////////////// // Realm settings found in realm/update_dict events: // https://zulip.com/api/get-events#realm-update_dict + // + // In order of appearance in the realm/update_dict event doc. // TODO(#668): update all these realm settings on events. bool get realmAllowMessageEditing; + GroupSettingValue? get realmCanDeleteAnyMessageGroup; // TODO(server-10) + GroupSettingValue? get realmCanDeleteOwnMessageGroup; // TODO(server-10) + bool get realmEnableReadReceipts; bool get realmMandatoryTopics; int get maxFileUploadSizeMib; + int? get realmMessageContentDeleteLimitSeconds; Duration? get realmMessageContentEditLimit => realmMessageContentEditLimitSeconds == null ? null : Duration(seconds: realmMessageContentEditLimitSeconds!); int? get realmMessageContentEditLimitSeconds; - bool get realmEnableReadReceipts; bool get realmPresenceDisabled; int get realmWaitingPeriodThreshold; @@ -55,6 +60,7 @@ mixin RealmStore on PerAccountStoreBase, UserGroupStore { // but now deprecated. RealmWildcardMentionPolicy get realmWildcardMentionPolicy; // TODO(#662): replaced by can_mention_many_users_group + RealmDeleteOwnMessagePolicy? get realmDeleteOwnMessagePolicy; // TODO(server-10) remove //|////////////////////////////// // Realm settings that lack events. @@ -146,13 +152,19 @@ mixin ProxyRealmStore on RealmStore { @override bool get realmAllowMessageEditing => realmStore.realmAllowMessageEditing; @override + GroupSettingValue? get realmCanDeleteAnyMessageGroup => realmStore.realmCanDeleteAnyMessageGroup; + @override + GroupSettingValue? get realmCanDeleteOwnMessageGroup => realmStore.realmCanDeleteOwnMessageGroup; + @override + bool get realmEnableReadReceipts => realmStore.realmEnableReadReceipts; + @override bool get realmMandatoryTopics => realmStore.realmMandatoryTopics; @override int get maxFileUploadSizeMib => realmStore.maxFileUploadSizeMib; @override - int? get realmMessageContentEditLimitSeconds => realmStore.realmMessageContentEditLimitSeconds; + int? get realmMessageContentDeleteLimitSeconds => realmStore.realmMessageContentDeleteLimitSeconds; @override - bool get realmEnableReadReceipts => realmStore.realmEnableReadReceipts; + int? get realmMessageContentEditLimitSeconds => realmStore.realmMessageContentEditLimitSeconds; @override bool get realmPresenceDisabled => realmStore.realmPresenceDisabled; @override @@ -160,6 +172,8 @@ mixin ProxyRealmStore on RealmStore { @override RealmWildcardMentionPolicy get realmWildcardMentionPolicy => realmStore.realmWildcardMentionPolicy; @override + RealmDeleteOwnMessagePolicy? get realmDeleteOwnMessagePolicy => realmStore.realmDeleteOwnMessagePolicy; + @override String get realmEmptyTopicDisplayName => realmStore.realmEmptyTopicDisplayName; @override Map get realmDefaultExternalAccounts => realmStore.realmDefaultExternalAccounts; @@ -195,13 +209,17 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { serverTypingStoppedWaitPeriodMilliseconds = initialSnapshot.serverTypingStoppedWaitPeriodMilliseconds, serverTypingStartedWaitPeriodMilliseconds = initialSnapshot.serverTypingStartedWaitPeriodMilliseconds, realmAllowMessageEditing = initialSnapshot.realmAllowMessageEditing, + realmCanDeleteAnyMessageGroup = initialSnapshot.realmCanDeleteAnyMessageGroup, + realmCanDeleteOwnMessageGroup = initialSnapshot.realmCanDeleteOwnMessageGroup, realmMandatoryTopics = initialSnapshot.realmMandatoryTopics, maxFileUploadSizeMib = initialSnapshot.maxFileUploadSizeMib, + realmMessageContentDeleteLimitSeconds = initialSnapshot.realmMessageContentDeleteLimitSeconds, realmMessageContentEditLimitSeconds = initialSnapshot.realmMessageContentEditLimitSeconds, realmEnableReadReceipts = initialSnapshot.realmEnableReadReceipts, realmPresenceDisabled = initialSnapshot.realmPresenceDisabled, realmWaitingPeriodThreshold = initialSnapshot.realmWaitingPeriodThreshold, realmWildcardMentionPolicy = initialSnapshot.realmWildcardMentionPolicy, + realmDeleteOwnMessagePolicy = initialSnapshot.realmDeleteOwnMessagePolicy, _realmEmptyTopicDisplayName = initialSnapshot.realmEmptyTopicDisplayName, realmDefaultExternalAccounts = initialSnapshot.realmDefaultExternalAccounts, customProfileFields = _sortCustomProfileFields(initialSnapshot.customProfileFields); @@ -261,13 +279,19 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { @override final bool realmAllowMessageEditing; @override + final GroupSettingValue? realmCanDeleteAnyMessageGroup; + @override + final GroupSettingValue? realmCanDeleteOwnMessageGroup; + @override + final bool realmEnableReadReceipts; + @override final bool realmMandatoryTopics; @override final int maxFileUploadSizeMib; @override - final int? realmMessageContentEditLimitSeconds; + final int? realmMessageContentDeleteLimitSeconds; @override - final bool realmEnableReadReceipts; + final int? realmMessageContentEditLimitSeconds; @override final bool realmPresenceDisabled; @override @@ -275,6 +299,8 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { @override final RealmWildcardMentionPolicy realmWildcardMentionPolicy; + @override + final RealmDeleteOwnMessagePolicy? realmDeleteOwnMessagePolicy; @override String get realmEmptyTopicDisplayName { diff --git a/lib/model/store.dart b/lib/model/store.dart index 903d1b94be..a5da6aa3f8 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -537,7 +537,7 @@ class PerAccountStore extends PerAccountStoreBase with presence: Presence(realm: realm, initial: initialSnapshot.presences), channels: channels, - messages: MessageStoreImpl(realm: realm), + messages: MessageStoreImpl(channels: channels), unreads: Unreads(core: core, channelStore: channels, initial: initialSnapshot.unreadMsgs), recentDmConversationsView: RecentDmConversationsView(core: core, diff --git a/test/example_data.dart b/test/example_data.dart index f079f5d719..110a2f0ca8 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -271,6 +271,7 @@ User user({ String? dateJoined, bool? isActive, bool? isBot, + int? botOwnerId, UserRole? role, String? avatarUrl, Map? profileData, @@ -286,7 +287,7 @@ User user({ isActive: isActive ?? true, isBot: isBot ?? false, botType: null, - botOwnerId: null, + botOwnerId: botOwnerId, role: role ?? UserRole.member, timezone: 'UTC', avatarUrl: avatarUrl, @@ -440,6 +441,7 @@ ZulipStream stream({ int? streamId, String? name, String? description, + bool? isArchived, String? renderedDescription, int? dateCreated, int? firstMessageId, @@ -449,6 +451,8 @@ ZulipStream stream({ int? messageRetentionDays, ChannelPostPolicy? channelPostPolicy, GroupSettingValue? canAddSubscribersGroup, + GroupSettingValue? canDeleteAnyMessageGroup, + GroupSettingValue? canDeleteOwnMessageGroup, GroupSettingValue? canSubscribeGroup, int? streamWeeklyTraffic, }) { @@ -460,6 +464,7 @@ ZulipStream stream({ return ZulipStream( streamId: effectiveStreamId, name: effectiveName, + isArchived: isArchived ?? false, description: effectiveDescription, renderedDescription: renderedDescription ?? '

$effectiveDescription

', dateCreated: dateCreated ?? 1686774898, @@ -470,6 +475,8 @@ ZulipStream stream({ messageRetentionDays: messageRetentionDays, channelPostPolicy: channelPostPolicy ?? ChannelPostPolicy.any, canAddSubscribersGroup: canAddSubscribersGroup ?? GroupSettingValueNamed(nobodyGroup.id), + canDeleteAnyMessageGroup: canDeleteAnyMessageGroup ?? GroupSettingValueNamed(nobodyGroup.id), + canDeleteOwnMessageGroup: canDeleteOwnMessageGroup ?? GroupSettingValueNamed(nobodyGroup.id), canSubscribeGroup: canSubscribeGroup ?? GroupSettingValueNamed(nobodyGroup.id), streamWeeklyTraffic: streamWeeklyTraffic, ); @@ -500,6 +507,7 @@ Subscription subscription( return Subscription( streamId: stream.streamId, name: stream.name, + isArchived: stream.isArchived, description: stream.description, renderedDescription: stream.renderedDescription, dateCreated: stream.dateCreated, @@ -510,6 +518,8 @@ Subscription subscription( messageRetentionDays: stream.messageRetentionDays, channelPostPolicy: stream.channelPostPolicy, canAddSubscribersGroup: stream.canAddSubscribersGroup, + canDeleteAnyMessageGroup: stream.canDeleteAnyMessageGroup, + canDeleteOwnMessageGroup: stream.canDeleteOwnMessageGroup, canSubscribeGroup: stream.canSubscribeGroup, streamWeeklyTraffic: stream.streamWeeklyTraffic, desktopNotifications: desktopNotifications ?? false, @@ -1172,6 +1182,9 @@ ChannelUpdateEvent channelUpdateEvent( }) { switch (property) { case ChannelPropertyName.name: + assert(value is String); + case ChannelPropertyName.isArchived: + assert(value is bool); case ChannelPropertyName.description: assert(value is String); case ChannelPropertyName.firstMessageId: @@ -1183,6 +1196,8 @@ ChannelUpdateEvent channelUpdateEvent( case ChannelPropertyName.channelPostPolicy: assert(value is ChannelPostPolicy); case ChannelPropertyName.canAddSubscribersGroup: + case ChannelPropertyName.canDeleteAnyMessageGroup: + case ChannelPropertyName.canDeleteOwnMessageGroup: case ChannelPropertyName.canSubscribeGroup: assert(value is GroupSettingValue); case ChannelPropertyName.streamWeeklyTraffic: @@ -1243,9 +1258,13 @@ InitialSnapshot initialSnapshot({ Map? userStatuses, UserSettings? userSettings, List? userTopics, + GroupSettingValue? realmCanDeleteAnyMessageGroup, + GroupSettingValue? realmCanDeleteOwnMessageGroup, + RealmDeleteOwnMessagePolicy? realmDeleteOwnMessagePolicy, RealmWildcardMentionPolicy? realmWildcardMentionPolicy, bool? realmMandatoryTopics, int? realmWaitingPeriodThreshold, + int? realmMessageContentDeleteLimitSeconds, bool? realmAllowMessageEditing, int? realmMessageContentEditLimitSeconds, bool? realmEnableReadReceipts, @@ -1291,9 +1310,15 @@ InitialSnapshot initialSnapshot({ presenceEnabled: true, ), userTopics: userTopics, + // no default; allow `null` to simulate servers without this + realmCanDeleteAnyMessageGroup: realmCanDeleteAnyMessageGroup, + // no default; allow `null` to simulate servers without this + realmCanDeleteOwnMessageGroup: realmCanDeleteOwnMessageGroup, + realmDeleteOwnMessagePolicy: realmDeleteOwnMessagePolicy, realmWildcardMentionPolicy: realmWildcardMentionPolicy ?? RealmWildcardMentionPolicy.everyone, realmMandatoryTopics: realmMandatoryTopics ?? true, realmWaitingPeriodThreshold: realmWaitingPeriodThreshold ?? 0, + realmMessageContentDeleteLimitSeconds: realmMessageContentDeleteLimitSeconds, realmAllowMessageEditing: realmAllowMessageEditing ?? true, realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds, realmEnableReadReceipts: realmEnableReadReceipts ?? true, diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 79aa441771..7dbf28a47d 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -9,6 +9,7 @@ import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/exception.dart'; import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/submessage.dart'; import 'package:zulip/api/route/messages.dart'; @@ -864,6 +865,331 @@ void main() { })); }); + group('selfCanDeleteMessage', () { + /// Call the method, with setup from [params]. + Future evaluate(CanDeleteMessageParams params) async { + final selfUser = eg.selfUser; + final botUserOwnedBySelf = eg.user(isBot: true, botOwnerId: selfUser.userId); + final botUserNotOwnedBySelf = eg.user(isBot: true, botOwnerId: eg.otherUser.userId); + + final groupWithSelf = eg.userGroup(members: [selfUser.userId]); + final groupWithoutSelf = eg.userGroup(members: [eg.otherUser.userId]); + final groupSettingWithSelf = GroupSettingValueNamed(groupWithSelf.id); + final groupSettingWithoutSelf = GroupSettingValueNamed(groupWithoutSelf.id); + + final GroupSettingValue? realmCanDeleteAnyMessageGroup; + final GroupSettingValue? realmCanDeleteOwnMessageGroup; + final RealmDeleteOwnMessagePolicy? realmDeleteOwnMessagePolicy; + + if (params.inRealmCanDeleteAnyMessageGroup != null) { + realmCanDeleteAnyMessageGroup = params.inRealmCanDeleteAnyMessageGroup! + ? groupSettingWithSelf : groupSettingWithoutSelf; + } else { + realmCanDeleteAnyMessageGroup = null; + } + + if (params.inRealmCanDeleteOwnMessageGroup != null) { + assert(params.inRealmCanDeleteAnyMessageGroup != null); // TODO(server-10) + assert(params.realmDeleteOwnMessagePolicy == null); + realmCanDeleteOwnMessageGroup = params.inRealmCanDeleteOwnMessageGroup! + ? groupSettingWithSelf : groupSettingWithoutSelf; + } else { + realmCanDeleteOwnMessageGroup = null; + } + + if (params.realmDeleteOwnMessagePolicy != null) { + assert(params.inRealmCanDeleteOwnMessageGroup == null); + realmDeleteOwnMessagePolicy = params.realmDeleteOwnMessagePolicy!; + } else { + realmDeleteOwnMessagePolicy = null; + } + + final sender = switch (params.senderConfig) { + CanDeleteMessageSenderConfig.unknown => eg.user(), + CanDeleteMessageSenderConfig.self => selfUser, + CanDeleteMessageSenderConfig.otherHuman => eg.otherUser, + CanDeleteMessageSenderConfig.botOwnedBySelf => botUserOwnedBySelf, + CanDeleteMessageSenderConfig.botNotOwnedBySelf => botUserNotOwnedBySelf, + }; + + final channel = eg.stream(); + + final now = testBinding.utcNow(); + final timestamp = (now.millisecondsSinceEpoch ~/ 1000) - 60; + final Message message; + if (params.isChannelArchived != null) { + // testing with a channel message + message = eg.streamMessage(sender: sender, stream: channel, timestamp: timestamp); + channel.isArchived = params.isChannelArchived!; + if ( + params.inChannelCanDeleteAnyMessageGroup != null + && params.inChannelCanDeleteOwnMessageGroup != null + ) { + channel.canDeleteAnyMessageGroup = params.inChannelCanDeleteAnyMessageGroup! + ? groupSettingWithSelf : groupSettingWithoutSelf; + channel.canDeleteOwnMessageGroup = params.inChannelCanDeleteOwnMessageGroup! + ? groupSettingWithSelf : groupSettingWithoutSelf; + } else { + assert(params.inChannelCanDeleteAnyMessageGroup == null); + assert(params.inChannelCanDeleteOwnMessageGroup == null); + channel.canDeleteAnyMessageGroup = null; + channel.canDeleteOwnMessageGroup = null; + } + } else { + // testing with a DM message + final to = sender == selfUser ? [] : [selfUser]; + message = eg.dmMessage(from: sender, to: to, timestamp: timestamp); + } + + final realmMessageContentDeleteLimitSeconds = switch (params.timeLimitConfig) { + CanDeleteMessageTimeLimitConfig.notLimited => null, + CanDeleteMessageTimeLimitConfig.insideLimit => 24 * 60 * 60, + CanDeleteMessageTimeLimitConfig.outsideLimit => 1, + }; + + final store = eg.store( + selfUser: selfUser, + initialSnapshot: eg.initialSnapshot( + realmUsers: [selfUser, eg.otherUser, botUserOwnedBySelf, botUserNotOwnedBySelf], + streams: [channel], + realmUserGroups: [groupWithSelf, groupWithoutSelf], + realmCanDeleteAnyMessageGroup: realmCanDeleteAnyMessageGroup, + realmCanDeleteOwnMessageGroup: realmCanDeleteOwnMessageGroup, + realmMessageContentDeleteLimitSeconds: realmMessageContentDeleteLimitSeconds, + realmDeleteOwnMessagePolicy: realmDeleteOwnMessagePolicy)); + + await store.addMessage(message); + + return store.selfCanDeleteMessage(message.id, atDate: now); + } + + void doTest(bool expected, CanDeleteMessageParams params) { + test('params: ${params.describe()}', () async { + check(await evaluate(params)).equals(expected); + }); + } + + group('channel message', () { + doTest(true, CanDeleteMessageParams.permissiveForChannelMessageExcept()); + doTest(false, CanDeleteMessageParams.restrictiveForChannelMessageExcept()); + + group('denial conditions', () { + doTest(false, CanDeleteMessageParams.permissiveForChannelMessageExcept( + isChannelArchived: true)); + + doTest(false, CanDeleteMessageParams.permissiveForChannelMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + inChannelCanDeleteAnyMessageGroup: false, + senderConfig: CanDeleteMessageSenderConfig.unknown)); + + doTest(false, CanDeleteMessageParams.permissiveForChannelMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + inChannelCanDeleteAnyMessageGroup: false, + senderConfig: CanDeleteMessageSenderConfig.otherHuman)); + + doTest(false, CanDeleteMessageParams.permissiveForChannelMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + inChannelCanDeleteAnyMessageGroup: false, + senderConfig: CanDeleteMessageSenderConfig.botNotOwnedBySelf)); + + doTest(false, CanDeleteMessageParams.permissiveForChannelMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + inChannelCanDeleteAnyMessageGroup: false, + inRealmCanDeleteOwnMessageGroup: false, + inChannelCanDeleteOwnMessageGroup: false)); + + doTest(false, CanDeleteMessageParams.permissiveForChannelMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + inChannelCanDeleteAnyMessageGroup: false, + inRealmCanDeleteOwnMessageGroup: false, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.outsideLimit)); + + doTest(false, CanDeleteMessageParams.permissiveForChannelMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + inChannelCanDeleteAnyMessageGroup: false, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.outsideLimit)); + }); + + group('approval conditions', () { + doTest(true, CanDeleteMessageParams.restrictiveForChannelMessageExcept( + isChannelArchived: false, + inRealmCanDeleteAnyMessageGroup: true)); + + doTest(true, CanDeleteMessageParams.restrictiveForChannelMessageExcept( + isChannelArchived: false, + inChannelCanDeleteAnyMessageGroup: true)); + + doTest(true, CanDeleteMessageParams.restrictiveForChannelMessageExcept( + isChannelArchived: false, + senderConfig: CanDeleteMessageSenderConfig.self, + inRealmCanDeleteOwnMessageGroup: true, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited)); + + doTest(true, CanDeleteMessageParams.restrictiveForChannelMessageExcept( + isChannelArchived: false, + senderConfig: CanDeleteMessageSenderConfig.botOwnedBySelf, + inChannelCanDeleteOwnMessageGroup: true, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.insideLimit)); + }); + }); + + group('dm message', () { + doTest(true, CanDeleteMessageParams.permissiveForDmMessageExcept()); + doTest(false, CanDeleteMessageParams.restrictiveForDmMessageExcept()); + + group('denial conditions', () { + doTest(false, CanDeleteMessageParams.permissiveForDmMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + senderConfig: CanDeleteMessageSenderConfig.unknown)); + + doTest(false, CanDeleteMessageParams.permissiveForDmMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + senderConfig: CanDeleteMessageSenderConfig.otherHuman)); + + doTest(false, CanDeleteMessageParams.permissiveForDmMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + senderConfig: CanDeleteMessageSenderConfig.botNotOwnedBySelf)); + + doTest(false, CanDeleteMessageParams.permissiveForDmMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + inRealmCanDeleteOwnMessageGroup: false)); + + doTest(false, CanDeleteMessageParams.permissiveForDmMessageExcept( + inRealmCanDeleteAnyMessageGroup: false, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.outsideLimit)); + }); + + group('approval conditions', () { + doTest(true, CanDeleteMessageParams.restrictiveForDmMessageExcept( + inRealmCanDeleteAnyMessageGroup: true)); + + doTest(true, CanDeleteMessageParams.restrictiveForDmMessageExcept( + senderConfig: CanDeleteMessageSenderConfig.self, + inRealmCanDeleteOwnMessageGroup: true, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited)); + + doTest(true, CanDeleteMessageParams.restrictiveForDmMessageExcept( + senderConfig: CanDeleteMessageSenderConfig.self, + inRealmCanDeleteOwnMessageGroup: true, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.insideLimit)); + }); + }); + + 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. + + test('denial is not forced just because one of the permissions is absent (the any-message one)', () async { + check(await evaluate( + CanDeleteMessageParams.pre407( + senderConfig: CanDeleteMessageSenderConfig.self, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + inRealmCanDeleteOwnMessageGroup: true, + isChannelArchived: false, + )))..equals(await evaluate( + CanDeleteMessageParams.modern( + senderConfig: CanDeleteMessageSenderConfig.self, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + inRealmCanDeleteOwnMessageGroup: true, + isChannelArchived: false, + inChannelCanDeleteAnyMessageGroup: false, + inChannelCanDeleteOwnMessageGroup: false))) + ..isTrue(); + }); + + test('exercise both existence checks', () async { + check(await evaluate( + CanDeleteMessageParams.pre407( + senderConfig: CanDeleteMessageSenderConfig.self, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + inRealmCanDeleteOwnMessageGroup: false, + isChannelArchived: false, + )))..equals(await evaluate( + CanDeleteMessageParams.modern( + senderConfig: CanDeleteMessageSenderConfig.self, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + inRealmCanDeleteOwnMessageGroup: false, + isChannelArchived: false, + inChannelCanDeleteAnyMessageGroup: false, + inChannelCanDeleteOwnMessageGroup: false))) + ..isFalse(); + }); + }); + + group('pre-291', () { + // The realm-level can-delete-own-message group permission + // doesn't exist, so we follow realmDeleteOwnMessagePolicy instead, + // and we don't error. + + test('allowed', () async { + check(await evaluate( + CanDeleteMessageParams.pre291( + senderConfig: CanDeleteMessageSenderConfig.self, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + isChannelArchived: false, + realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone, + ))) + ..equals(await evaluate( + CanDeleteMessageParams.pre407( + senderConfig: CanDeleteMessageSenderConfig.self, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + inRealmCanDeleteOwnMessageGroup: true, + isChannelArchived: false))) + ..isTrue(); + }); + + test('denied', () async { + check(await evaluate( + CanDeleteMessageParams.pre291( + senderConfig: CanDeleteMessageSenderConfig.self, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + isChannelArchived: false, + realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.admins, + )))..equals(await evaluate( + CanDeleteMessageParams.pre407( + senderConfig: CanDeleteMessageSenderConfig.self, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + inRealmCanDeleteOwnMessageGroup: false, + isChannelArchived: false))) + ..isFalse(); + }); + }); + + 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. + + test('denied', () async { + check(await evaluate( + CanDeleteMessageParams.pre281( + senderConfig: CanDeleteMessageSenderConfig.otherHuman, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + isChannelArchived: false, + realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone, + )))..equals(await evaluate( + CanDeleteMessageParams.pre291( + senderConfig: CanDeleteMessageSenderConfig.otherHuman, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + isChannelArchived: false, + realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone))) + ..isFalse(); + }); + }); + }); + }); + group('handleMessageEvent', () { test('from empty', () async { await prepare(); @@ -1668,3 +1994,183 @@ void main() { }); }); } + +/// Params for testing the logic for +/// whether the self-user has permission to delete a message. +class CanDeleteMessageParams { + final CanDeleteMessageSenderConfig senderConfig; + final CanDeleteMessageTimeLimitConfig timeLimitConfig; + final bool? inRealmCanDeleteAnyMessageGroup; + final bool? inRealmCanDeleteOwnMessageGroup; + final bool? isChannelArchived; + final bool? inChannelCanDeleteAnyMessageGroup; + final bool? inChannelCanDeleteOwnMessageGroup; + final RealmDeleteOwnMessagePolicy? realmDeleteOwnMessagePolicy; + + CanDeleteMessageParams._({ + required this.senderConfig, + required this.timeLimitConfig, + required this.inRealmCanDeleteAnyMessageGroup, + required this.inRealmCanDeleteOwnMessageGroup, + required this.isChannelArchived, + required this.inChannelCanDeleteAnyMessageGroup, + required this.inChannelCanDeleteOwnMessageGroup, + required this.realmDeleteOwnMessagePolicy, + }); + + CanDeleteMessageParams.modern({ + required this.senderConfig, + required this.timeLimitConfig, + required this.inRealmCanDeleteAnyMessageGroup, + required this.inRealmCanDeleteOwnMessageGroup, + required this.isChannelArchived, + required this.inChannelCanDeleteAnyMessageGroup, + required this.inChannelCanDeleteOwnMessageGroup, + }) : realmDeleteOwnMessagePolicy = null; + + factory CanDeleteMessageParams.restrictiveForChannelMessageExcept({ + CanDeleteMessageSenderConfig? senderConfig, + CanDeleteMessageTimeLimitConfig? timeLimitConfig, + bool? inRealmCanDeleteAnyMessageGroup, + bool? inRealmCanDeleteOwnMessageGroup, + bool? isChannelArchived, + bool? inChannelCanDeleteAnyMessageGroup, + bool? inChannelCanDeleteOwnMessageGroup, + }) => CanDeleteMessageParams.modern( + senderConfig: senderConfig ?? CanDeleteMessageSenderConfig.unknown, + timeLimitConfig: timeLimitConfig ?? CanDeleteMessageTimeLimitConfig.outsideLimit, + inRealmCanDeleteAnyMessageGroup: inRealmCanDeleteAnyMessageGroup ?? false, + inRealmCanDeleteOwnMessageGroup: inRealmCanDeleteOwnMessageGroup ?? false, + isChannelArchived: isChannelArchived ?? true, + inChannelCanDeleteAnyMessageGroup: inChannelCanDeleteAnyMessageGroup ?? false, + inChannelCanDeleteOwnMessageGroup: inChannelCanDeleteOwnMessageGroup ?? false, + ); + + factory CanDeleteMessageParams.permissiveForChannelMessageExcept({ + CanDeleteMessageSenderConfig? senderConfig, + CanDeleteMessageTimeLimitConfig? timeLimitConfig, + bool? inRealmCanDeleteAnyMessageGroup, + bool? inRealmCanDeleteOwnMessageGroup, + bool? isChannelArchived, + bool? inChannelCanDeleteAnyMessageGroup, + bool? inChannelCanDeleteOwnMessageGroup, + }) => CanDeleteMessageParams.modern( + senderConfig: senderConfig ?? CanDeleteMessageSenderConfig.self, + timeLimitConfig: timeLimitConfig ?? CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: inRealmCanDeleteAnyMessageGroup ?? true, + inRealmCanDeleteOwnMessageGroup: inRealmCanDeleteOwnMessageGroup ?? true, + isChannelArchived: isChannelArchived ?? false, + inChannelCanDeleteAnyMessageGroup: inChannelCanDeleteAnyMessageGroup ?? true, + inChannelCanDeleteOwnMessageGroup: inChannelCanDeleteOwnMessageGroup ?? true, + ); + + factory CanDeleteMessageParams.restrictiveForDmMessageExcept({ + CanDeleteMessageSenderConfig? senderConfig, + CanDeleteMessageTimeLimitConfig? timeLimitConfig, + bool? inRealmCanDeleteAnyMessageGroup, + bool? inRealmCanDeleteOwnMessageGroup, + }) => CanDeleteMessageParams.modern( + senderConfig: senderConfig ?? CanDeleteMessageSenderConfig.unknown, + timeLimitConfig: timeLimitConfig ?? CanDeleteMessageTimeLimitConfig.outsideLimit, + inRealmCanDeleteAnyMessageGroup: inRealmCanDeleteAnyMessageGroup ?? false, + inRealmCanDeleteOwnMessageGroup: inRealmCanDeleteOwnMessageGroup ?? false, + isChannelArchived: null, + inChannelCanDeleteAnyMessageGroup: null, + inChannelCanDeleteOwnMessageGroup: null, + ); + + factory CanDeleteMessageParams.permissiveForDmMessageExcept({ + CanDeleteMessageSenderConfig? senderConfig, + CanDeleteMessageTimeLimitConfig? timeLimitConfig, + bool? inRealmCanDeleteAnyMessageGroup, + bool? inRealmCanDeleteOwnMessageGroup, + }) => CanDeleteMessageParams.modern( + senderConfig: senderConfig ?? CanDeleteMessageSenderConfig.self, + timeLimitConfig: timeLimitConfig ?? CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: inRealmCanDeleteAnyMessageGroup ?? true, + inRealmCanDeleteOwnMessageGroup: inRealmCanDeleteOwnMessageGroup ?? true, + isChannelArchived: null, + inChannelCanDeleteAnyMessageGroup: null, + inChannelCanDeleteOwnMessageGroup: null, + ); + + // TODO(server-11) delete + factory CanDeleteMessageParams.pre407({ + required CanDeleteMessageSenderConfig senderConfig, + required CanDeleteMessageTimeLimitConfig timeLimitConfig, + required bool inRealmCanDeleteAnyMessageGroup, + required bool inRealmCanDeleteOwnMessageGroup, + required bool? isChannelArchived, + }) => CanDeleteMessageParams._( + senderConfig: senderConfig, + timeLimitConfig: timeLimitConfig, + inRealmCanDeleteAnyMessageGroup: inRealmCanDeleteAnyMessageGroup, + inRealmCanDeleteOwnMessageGroup: inRealmCanDeleteOwnMessageGroup, + isChannelArchived: isChannelArchived, + inChannelCanDeleteAnyMessageGroup: null, + inChannelCanDeleteOwnMessageGroup: null, + realmDeleteOwnMessagePolicy: null, + ); + + // TODO(server-10) delete + factory CanDeleteMessageParams.pre291({ + required CanDeleteMessageSenderConfig senderConfig, + required CanDeleteMessageTimeLimitConfig timeLimitConfig, + required bool inRealmCanDeleteAnyMessageGroup, + required bool? isChannelArchived, + required RealmDeleteOwnMessagePolicy realmDeleteOwnMessagePolicy, + }) => CanDeleteMessageParams._( + senderConfig: senderConfig, + timeLimitConfig: timeLimitConfig, + inRealmCanDeleteAnyMessageGroup: inRealmCanDeleteAnyMessageGroup, + inRealmCanDeleteOwnMessageGroup: null, + isChannelArchived: isChannelArchived, + inChannelCanDeleteAnyMessageGroup: null, + inChannelCanDeleteOwnMessageGroup: null, + realmDeleteOwnMessagePolicy: realmDeleteOwnMessagePolicy, + ); + + // TODO(server-10) delete + factory CanDeleteMessageParams.pre281({ + required CanDeleteMessageSenderConfig senderConfig, + required CanDeleteMessageTimeLimitConfig timeLimitConfig, + required bool? isChannelArchived, + required RealmDeleteOwnMessagePolicy realmDeleteOwnMessagePolicy, + }) => CanDeleteMessageParams._( + senderConfig: senderConfig, + timeLimitConfig: timeLimitConfig, + inRealmCanDeleteAnyMessageGroup: null, + inRealmCanDeleteOwnMessageGroup: null, + isChannelArchived: isChannelArchived, + inChannelCanDeleteAnyMessageGroup: null, + inChannelCanDeleteOwnMessageGroup: null, + realmDeleteOwnMessagePolicy: realmDeleteOwnMessagePolicy, + ); + + String describe() { + return [ + 'sender: ${senderConfig.name}', + 'time limit: ${timeLimitConfig.name}', + 'in realmCanDeleteAnyMessageGroup?: ${inRealmCanDeleteAnyMessageGroup ?? 'N/A'}', + 'in realmCanDeleteOwnMessageGroup?: ${inRealmCanDeleteOwnMessageGroup ?? 'N/A'}', + 'channel is archived?: ${isChannelArchived ?? 'N/A'}', + 'in channel.canDeleteAnyMessageGroup?: ${inChannelCanDeleteAnyMessageGroup ?? 'N/A'}', + 'in channel.canDeleteOwnMessageGroup?: ${inChannelCanDeleteOwnMessageGroup ?? 'N/A'}', + 'realmDeleteOwnMessagePolicy: ${realmDeleteOwnMessagePolicy ?? 'N/A'}', + ].join(', '); + } +} + +enum CanDeleteMessageSenderConfig { + unknown, + self, + otherHuman, + botOwnedBySelf, + botNotOwnedBySelf, +} + +enum CanDeleteMessageTimeLimitConfig { + notLimited, + insideLimit, + outsideLimit, +}