diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index fd47ddfacb..334abd76ee 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -515,7 +515,7 @@ struct ComposeView: View { sendMessageView( disableSendButton, placeholder: chat.chatInfo.groupInfo.map { gi in - gi.useRelays && gi.membership.memberRole >= .owner + gi.useRelays && gi.membership.memberRole >= .owner && chat.chatInfo.groupChatScope() == nil ? NSLocalizedString("Broadcast", comment: "compose placeholder for channel owner") : nil } ?? nil @@ -1659,7 +1659,7 @@ struct ComposeView: View { type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, scope: chat.chatInfo.groupChatScope(), - sendAsGroup: chat.chatInfo.groupInfo.map { $0.useRelays && $0.membership.memberRole >= .owner } ?? false, + sendAsGroup: chat.chatInfo.sendAsGroup, live: live, ttl: ttl, composedMessages: msgs diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 9279c53c83..21685fccd1 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -103,6 +103,10 @@ struct GroupChatInfoView: View { } } + let showUserSupportChat = groupInfo.membership.memberActive + && ((groupInfo.fullGroupPreferences.support.on && groupInfo.membership.memberRole < .moderator) + || groupInfo.membership.supportChat != nil) + if groupInfo.useRelays { Section { // TODO [relays] allow other owners to manage channel link (requires protocol changes to share link ownership) @@ -124,6 +128,12 @@ struct GroupChatInfoView: View { if groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole >= .owner }) { channelMembersButton() } + if groupInfo.membership.memberRole >= .moderator { + memberSupportButton() + } + if showUserSupportChat { + UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) + } } footer: { if !groupInfo.isOwner && groupInfo.groupProfile.publicGroup?.groupLink != nil { Text("You can share a link or a QR code - anybody will be able to join the channel.") @@ -141,8 +151,7 @@ struct GroupChatInfoView: View { if groupInfo.canModerate { GroupReportsChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) } - if groupInfo.membership.memberActive - && (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) { + if showUserSupportChat { UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) } } header: { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index af7054db01..4dff86f7bb 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -121,13 +121,15 @@ struct GroupMemberInfoView: View { } if connectionLoaded { + let showMemberSupportChat = !openedFromSupportChat + && groupInfo.membership.memberRole >= .moderator + && member.memberRole != .relay + && ((groupInfo.fullGroupPreferences.support.on && member.memberRole < .moderator) + || member.supportChat != nil) if member.memberActive { Section { - if !openedFromSupportChat - && groupInfo.membership.memberRole >= .moderator - && member.memberRole != .relay - && (member.memberRole < .moderator || member.supportChat != nil) { + if showMemberSupportChat { MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember, scrollToItemId: $scrollToItemId) } if let code = connectionCode, @@ -142,6 +144,10 @@ struct GroupMemberInfoView: View { // synchronizeConnectionButtonForce() // } } + } else if groupInfo.useRelays && member.memberCurrent && showMemberSupportChat { + Section { + MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember, scrollToItemId: $scrollToItemId) + } } if let contactLink = member.contactLink { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index 84e852f5a3..49b9829830 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -46,13 +46,30 @@ struct GroupPreferencesView: View { featureSection(.voice, $preferences.voice.enable, $preferences.voice.role) featureSection(.files, $preferences.files.enable, $preferences.files.role) featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role) - featureSection(.reports, $preferences.reports.enable) + featureSection(.reports, $preferences.reports.enable, disabled: true) // enable reports in 7.0 once directory support added featureSection(.history, $preferences.history.enable) + featureSection(.support, $preferences.support.enable, disabled: true) } else { featureSection(.timedMessages, $preferences.timedMessages.enable) featureSection(.fullDelete, $preferences.fullDelete.enable) featureSection(.reactions, $preferences.reactions.enable) featureSection(.history, $preferences.history.enable) + let supportNotice = NSLocalizedString("Chats with admins in public channels have no E2E encryption - use only with trusted chat relays.", comment: "alert message") + featureSection(.support, $preferences.support.enable, notice: supportNotice) + .onChange(of: preferences.support.enable) { enable in + if enable == .on { + showAlert( + NSLocalizedString("Enable chats with admins?", comment: "alert title"), + message: supportNotice, + actions: {[ + UIAlertAction(title: NSLocalizedString("Enable", comment: "alert button"), style: .destructive) { _ in }, + UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel) { _ in + preferences.support.enable = .off + } + ]} + ) + } + } } if groupInfo.isOwner { @@ -92,7 +109,7 @@ struct GroupPreferencesView: View { } } - private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding, _ enableForRole: Binding? = nil) -> some View { + private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding, _ enableForRole: Binding? = nil, disabled: Bool = false, notice: String? = nil) -> some View { Section { let color: Color = enableFeature.wrappedValue == .on ? .green : theme.colors.secondary let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon @@ -105,7 +122,7 @@ struct GroupPreferencesView: View { settingsRow(icon, color: color) { Toggle(feature.text, isOn: enable) } - .disabled(feature == .reports) // remove in 6.4 + .disabled(disabled) if timedOn { DropdownCustomTimePicker( selection: $preferences.timedMessages.ttl, @@ -144,8 +161,11 @@ struct GroupPreferencesView: View { } } } footer: { - Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.isOwner)) - .foregroundColor(theme.colors.secondary) + VStack(alignment: .leading) { + Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.isOwner)) + if let notice { Text(notice) } + } + .foregroundColor(theme.colors.secondary) } .onChange(of: enableFeature.wrappedValue) { enabled in if case .off = enabled { diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index 3dc27c08f6..880933985c 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -45,7 +45,7 @@ struct MemberSupportView: View { : membersWithChats.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } if membersWithChats.isEmpty { - Text("No chats with members") + Text(groupInfo.fullGroupPreferences.support.on ? "No chats with members" : "Chats with members are disabled") .foregroundColor(.secondary) } else { List { diff --git a/apps/ios/Shared/Views/NewChat/AddChannelView.swift b/apps/ios/Shared/Views/NewChat/AddChannelView.swift index 4e9a42971c..eae690f5d5 100644 --- a/apps/ios/Shared/Views/NewChat/AddChannelView.swift +++ b/apps/ios/Shared/Views/NewChat/AddChannelView.swift @@ -161,7 +161,10 @@ struct AddChannelView: View { private func createChannel() { focusDisplayName = false profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces) - profile.groupPreferences = GroupPreferences(history: GroupPreference(enable: .on)) + profile.groupPreferences = GroupPreferences( + history: GroupPreference(enable: .on), + support: GroupPreference(enable: .off) + ) creationInProgress = true Task { do { diff --git a/apps/ios/SimpleX SE/ShareAPI.swift b/apps/ios/SimpleX SE/ShareAPI.swift index f13401d437..52c0405e5e 100644 --- a/apps/ios/SimpleX SE/ShareAPI.swift +++ b/apps/ios/SimpleX SE/ShareAPI.swift @@ -68,7 +68,7 @@ func apiSendMessages( type: chatInfo.chatType, id: chatInfo.apiId, scope: chatInfo.groupChatScope(), - sendAsGroup: chatInfo.groupInfo.map { $0.useRelays && $0.membership.memberRole >= .owner } ?? false, + sendAsGroup: chatInfo.sendAsGroup, live: false, ttl: nil, composedMessages: composedMessages diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 1dd2e5dd3f..aa856c8fc3 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -867,6 +867,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case simplexLinks case reports case history + case support public var id: Self { self } @@ -888,6 +889,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .simplexLinks: true case .reports: false case .history: false + case .support: false } } @@ -902,6 +904,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .simplexLinks: return NSLocalizedString("SimpleX links", comment: "chat feature") case .reports: return NSLocalizedString("Member reports", comment: "chat feature") case .history: return NSLocalizedString("Visible history", comment: "chat feature") + case .support: return NSLocalizedString("Chat with admins", comment: "chat feature") } } @@ -916,6 +919,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .simplexLinks: return "link.circle" case .reports: return "flag" case .history: return "clock" + case .support: return "questionmark.circle" } } @@ -930,6 +934,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .simplexLinks: return "link.circle.fill" case .reports: return "flag.fill" case .history: return "clock.fill" + case .support: return "questionmark.circle.fill" } } @@ -988,6 +993,11 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .on: return "Send up to 100 last messages to new members." case .off: return "Do not send history to new members." } + case .support: + switch enabled { + case .on: return "Allow members to chat with admins." + case .off: return "Prohibit chats with admins." + } } } else { switch self { @@ -1036,6 +1046,11 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .on: return "Up to 100 last messages are sent to new members." case .off: return "History is not sent to new members." } + case .support: + switch enabled { + case .on: return "Members can chat with admins." + case .off: return "Chats with admins are prohibited." + } } } } @@ -1190,6 +1205,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { public var simplexLinks: RoleGroupPreference public var reports: GroupPreference public var history: GroupPreference + public var support: GroupPreference public var commands: [ChatBotCommand] public init( @@ -1202,6 +1218,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { simplexLinks: RoleGroupPreference, reports: GroupPreference, history: GroupPreference, + support: GroupPreference, commands: [ChatBotCommand] ) { self.timedMessages = timedMessages @@ -1213,6 +1230,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { self.simplexLinks = simplexLinks self.reports = reports self.history = history + self.support = support self.commands = commands } @@ -1226,6 +1244,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { simplexLinks: RoleGroupPreference(enable: .on, role: nil), reports: GroupPreference(enable: .on), history: GroupPreference(enable: .on), + support: GroupPreference(enable: .on), commands: [] ) } @@ -1240,6 +1259,7 @@ public struct GroupPreferences: Codable, Hashable { public var simplexLinks: RoleGroupPreference? public var reports: GroupPreference? public var history: GroupPreference? + public var support: GroupPreference? public var commands: [ChatBotCommand]? public init( @@ -1252,6 +1272,7 @@ public struct GroupPreferences: Codable, Hashable { simplexLinks: RoleGroupPreference? = nil, reports: GroupPreference? = nil, history: GroupPreference? = nil, + support: GroupPreference? = nil, commands: [ChatBotCommand]? = nil ) { self.timedMessages = timedMessages @@ -1263,6 +1284,7 @@ public struct GroupPreferences: Codable, Hashable { self.simplexLinks = simplexLinks self.reports = reports self.history = history + self.support = support self.commands = commands } @@ -1276,6 +1298,7 @@ public struct GroupPreferences: Codable, Hashable { simplexLinks: RoleGroupPreference(enable: .on, role: nil), reports: GroupPreference(enable: .on), history: GroupPreference(enable: .on), + support: GroupPreference(enable: .on), commands: nil ) } @@ -1760,6 +1783,18 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } + public var sendAsGroup: Bool { + if let g = groupInfo, g.useRelays && g.membership.memberRole >= .owner { + switch groupChatScope() { + case .none: true + case .memberSupport: false + case .reports: false + } + } else { + false + } + } + public func ntfsEnabled(chatItem: ChatItem) -> Bool { ntfsEnabled(chatItem.meta.userMention) } diff --git a/apps/ios/SimpleXChat/ChatUtils.swift b/apps/ios/SimpleXChat/ChatUtils.swift index 451ac8b4ef..788ac12bae 100644 --- a/apps/ios/SimpleXChat/ChatUtils.swift +++ b/apps/ios/SimpleXChat/ChatUtils.swift @@ -25,6 +25,7 @@ extension ChatLike { case .files: p.files.on(for: groupInfo.membership) case .simplexLinks: p.simplexLinks.on(for: groupInfo.membership) case .history: p.history.on + case .support: p.support.on case .reports: p.reports.on } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index ef3aa19267..a745542602 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1687,6 +1687,18 @@ sealed class ChatInfo: SomeChat, NamedChat { else -> null } + val sendAsGroup: Boolean get() { + val g = (this as? Group)?.groupInfo + return if (g != null && g.useRelays && g.membership.memberRole >= GroupMemberRole.Owner) { + when (groupChatScope()) { + null -> true + is GroupChatScope.MemberSupport -> false + } + } else { + false + } + } + fun ntfsEnabled(ci: ChatItem): Boolean = ntfsEnabled(ci.meta.userMention) @@ -2133,6 +2145,7 @@ data class GroupInfo ( GroupFeature.SimplexLinks -> p.simplexLinks.on(membership) GroupFeature.Reports -> p.reports.on GroupFeature.History -> p.history.on + GroupFeature.Support -> p.support.on } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index f62d4c262d..0e5b57d8c2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -5693,7 +5693,8 @@ enum class GroupFeature: Feature { @SerialName("files") Files, @SerialName("simplexLinks") SimplexLinks, @SerialName("reports") Reports, - @SerialName("history") History; + @SerialName("history") History, + @SerialName("support") Support; override val hasParam: Boolean get() = when(this) { TimedMessages -> true @@ -5711,6 +5712,7 @@ enum class GroupFeature: Feature { SimplexLinks -> true Reports -> false History -> false + Support -> false } override val text: String @@ -5724,6 +5726,7 @@ enum class GroupFeature: Feature { SimplexLinks -> generalGetString(MR.strings.simplex_links) Reports -> generalGetString(MR.strings.group_reports_member_reports) History -> generalGetString(MR.strings.recent_history) + Support -> generalGetString(MR.strings.chat_with_admins) } val icon: Painter @@ -5737,6 +5740,7 @@ enum class GroupFeature: Feature { SimplexLinks -> painterResource(MR.images.ic_link) Reports -> painterResource(MR.images.ic_flag) History -> painterResource(MR.images.ic_schedule) + Support -> painterResource(MR.images.ic_help) } @Composable @@ -5750,6 +5754,7 @@ enum class GroupFeature: Feature { SimplexLinks -> painterResource(MR.images.ic_link) Reports -> painterResource(MR.images.ic_flag_filled) History -> painterResource(MR.images.ic_schedule_filled) + Support -> painterResource(MR.images.ic_help_filled) } fun enableDescription(enabled: GroupFeatureEnabled, canEdit: Boolean): String = @@ -5791,6 +5796,10 @@ enum class GroupFeature: Feature { GroupFeatureEnabled.ON -> generalGetString(MR.strings.enable_sending_recent_history) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.disable_sending_recent_history) } + Support -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(MR.strings.allow_chat_with_admins) + GroupFeatureEnabled.OFF -> generalGetString(MR.strings.prohibit_chat_with_admins) + } } } else { when(this) { @@ -5830,6 +5839,10 @@ enum class GroupFeature: Feature { GroupFeatureEnabled.ON -> generalGetString(MR.strings.recent_history_is_sent_to_new_members) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.recent_history_is_not_sent_to_new_members) } + Support -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(MR.strings.members_can_chat_with_admins) + GroupFeatureEnabled.OFF -> generalGetString(MR.strings.chat_with_admins_is_prohibited) + } } } } @@ -5955,6 +5968,7 @@ data class FullGroupPreferences( val simplexLinks: RoleGroupPreference, val reports: GroupPreference, val history: GroupPreference, + val support: GroupPreference, val commands: List, ) { fun toGroupPreferences(): GroupPreferences = @@ -5968,6 +5982,7 @@ data class FullGroupPreferences( simplexLinks = simplexLinks, reports = reports, history = history, + support = support, commands = commands, ) @@ -5982,6 +5997,7 @@ data class FullGroupPreferences( simplexLinks = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), reports = GroupPreference(GroupFeatureEnabled.ON), history = GroupPreference(GroupFeatureEnabled.ON), + support = GroupPreference(GroupFeatureEnabled.ON), commands = listOf() ) } @@ -5998,6 +6014,7 @@ data class GroupPreferences( val simplexLinks: RoleGroupPreference? = null, val reports: GroupPreference? = null, val history: GroupPreference? = null, + val support: GroupPreference? = null, val commands: List? = null ) { companion object { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 95f51bf284..480320e33e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -534,7 +534,7 @@ fun ComposeView( type = cInfo.chatType, id = cInfo.apiId, scope = cInfo.groupChatScope(), - sendAsGroup = (cInfo as? ChatInfo.Group)?.groupInfo?.let { it.useRelays && it.membership.memberRole >= GroupMemberRole.Owner } ?: false, + sendAsGroup = cInfo.sendAsGroup, live = live, ttl = ttl, composedMessages = listOf(ComposedMessage(file, quoted, mc, mentions)) @@ -665,7 +665,7 @@ fun ComposeView( toChatType = chat.chatInfo.chatType, toChatId = chat.chatInfo.apiId, toScope = chat.chatInfo.groupChatScope(), - sendAsGroup = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.let { it.useRelays && it.membership.memberRole >= GroupMemberRole.Owner } ?: false, + sendAsGroup = chat.chatInfo.sendAsGroup, fromChatType = fromChatInfo.chatType, fromChatId = fromChatInfo.apiId, fromScope = fromChatInfo.groupChatScope(), @@ -1496,7 +1496,7 @@ fun ComposeView( ) is SharedContent.ChatLink -> { val cInfo = chat.chatInfo - val sendAsGroup = (cInfo as? ChatInfo.Group)?.groupInfo?.let { it.useRelays && it.membership.memberRole >= GroupMemberRole.Owner } ?: false + val sendAsGroup = cInfo.sendAsGroup withBGApi { val mc = chatModel.controller.apiShareChatMsgContent( chat.remoteHostId, ChatType.Group, shared.groupInfo.groupId, @@ -1693,7 +1693,7 @@ fun ComposeView( Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { AttachmentAndCommandsButtons() val broadcastPlaceholder = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.let { gi -> - if (gi.useRelays && gi.membership.memberRole >= GroupMemberRole.Owner) generalGetString(MR.strings.compose_view_broadcast) + if (gi.useRelays && gi.membership.memberRole >= GroupMemberRole.Owner && chat.chatInfo.groupChatScope() == null) generalGetString(MR.strings.compose_view_broadcast) else null } SendMsgView_(disableSendButton = disableSendButton, placeholder = broadcastPlaceholder) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 156ad2cd97..2c3a6c713b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -546,6 +546,10 @@ fun ModalData.GroupChatInfoLayout( var anyTopSectionRowShow = false val channelLink = groupInfo.groupProfile.publicGroup?.groupLink + val showUserSupportChat = groupInfo.membership.memberActive && + ((groupInfo.fullGroupPreferences.support.on && groupInfo.membership.memberRole < GroupMemberRole.Moderator) + || groupInfo.membership.supportChat != null) + if (groupInfo.useRelays) { SectionView { if (groupInfo.isOwner && groupLink != null) { @@ -564,6 +568,14 @@ fun ModalData.GroupChatInfoLayout( anyTopSectionRowShow = true ChannelMembersButton(chat.remoteHostId, groupInfo, showMemberInfo) } + if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + anyTopSectionRowShow = true + MemberSupportButton(chat, openMemberSupport) + } + if (showUserSupportChat) { + anyTopSectionRowShow = true + UserSupportChatButton(chat, groupInfo, scrollToItemId) + } } if (!groupInfo.isOwner && channelLink != null) { SectionTextFooter(stringResource(MR.strings.you_can_share_channel_link_anybody_will_be_able_to_connect)) @@ -590,10 +602,7 @@ fun ModalData.GroupChatInfoLayout( } } } - if ( - groupInfo.membership.memberActive && - (groupInfo.membership.memberRole < GroupMemberRole.Moderator || groupInfo.membership.supportChat != null) - ) { + if (showUserSupportChat) { anyTopSectionRowShow = true UserSupportChatButton(chat, groupInfo, scrollToItemId) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index fc5d697f4f..1bc6f038f3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -483,14 +483,15 @@ fun GroupMemberInfoLayout( SectionSpacer() } + val showMemberSupportChat = !openedFromSupportChat && + groupInfo.membership.memberRole >= GroupMemberRole.Moderator && + member.memberRole != GroupMemberRole.Relay && + ((groupInfo.fullGroupPreferences.support.on && member.memberRole < GroupMemberRole.Moderator) + || member.supportChat != null) + if (member.memberActive) { SectionView { - if ( - !openedFromSupportChat && - groupInfo.membership.memberRole >= GroupMemberRole.Moderator && - member.memberRole != GroupMemberRole.Relay && - (member.memberRole < GroupMemberRole.Moderator || member.supportChat != null) - ) { + if (showMemberSupportChat) { SupportChatButton() } if (connectionCode != null && !(groupInfo.useRelays && member.memberRole == GroupMemberRole.Relay)) { @@ -504,6 +505,11 @@ fun GroupMemberInfoLayout( // } } SectionDividerSpaced() + } else if (groupInfo.useRelays && member.memberCurrent && showMemberSupportChat) { + SectionView { + SupportChatButton() + } + SectionDividerSpaced() } if (member.contactLink != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index 902b4c9828..b84d4a4730 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -155,7 +155,7 @@ private fun GroupPreferencesLayout( } @Composable fun ReportsPreference() { val enableReports = remember(preferences) { mutableStateOf(preferences.reports.enable) } - FeatureSection(GroupFeature.Reports, enableReports, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> + FeatureSection(GroupFeature.Reports, enableReports, null, groupInfo, preferences, onTTLUpdated, disabled = true) { enable, _ -> // enable reports in 7.0 once directory support added applyPrefs(preferences.copy(reports = GroupPreference(enable = enable))) } } @@ -165,6 +165,16 @@ private fun GroupPreferencesLayout( applyPrefs(preferences.copy(history = GroupPreference(enable = enable))) } } + @Composable fun SupportPreference(disabled: Boolean = false, notice: String? = null, onEnable: ((() -> Unit) -> Unit)? = null) { + val enableSupport = remember(preferences) { mutableStateOf(preferences.support.enable) } + FeatureSection(GroupFeature.Support, enableSupport, null, groupInfo, preferences, onTTLUpdated, disabled = disabled, notice = notice) { enable, _ -> + applyPrefs(preferences.copy(support = GroupPreference(enable = enable))) + if (enable == GroupFeatureEnabled.ON) onEnable?.invoke { + enableSupport.value = GroupFeatureEnabled.OFF + applyPrefs(preferences.copy(support = GroupPreference(enable = GroupFeatureEnabled.OFF))) + } + } + } ColumnWithScrollBar { val titleId = if (groupInfo.useRelays) MR.strings.channel_preferences else if (groupInfo.businessChat == null) MR.strings.group_preferences @@ -192,6 +202,8 @@ private fun GroupPreferencesLayout( ReportsPreference() SectionDividerSpaced(true, maxBottomPadding = false) HistoryPreference() + SectionDividerSpaced(true, maxBottomPadding = false) + SupportPreference(disabled = true) } else { TimedMessagesPreference() SectionDividerSpaced(true, maxBottomPadding = false) @@ -200,6 +212,17 @@ private fun GroupPreferencesLayout( ReactionsPreference() SectionDividerSpaced(true, maxBottomPadding = false) HistoryPreference() + SectionDividerSpaced(true, maxBottomPadding = false) + SupportPreference(notice = generalGetString(MR.strings.chat_with_admins_relay_note), onEnable = { revert -> + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.enable_chats_with_admins_question), + text = generalGetString(MR.strings.chat_with_admins_relay_note), + confirmText = generalGetString(MR.strings.enable_chats_with_admins), + destructive = true, + onDismiss = revert, + onDismissRequest = revert, + ) + }) } if (groupInfo.isOwner) { SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) @@ -233,6 +256,8 @@ private fun FeatureSection( groupInfo: GroupInfo, preferences: FullGroupPreferences, onTTLUpdated: (Int?) -> Unit, + disabled: Boolean = false, + notice: String? = null, onSelected: (GroupFeatureEnabled, GroupMemberRole?) -> Unit ) { SectionView { @@ -245,7 +270,7 @@ private fun FeatureSection( feature.text, icon, iconTint, - disabled = feature == GroupFeature.Reports, // remove in 6.4 + disabled = disabled, checked = enableFeature.value == GroupFeatureEnabled.ON, ) { checked -> onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF, enableForRole?.value) @@ -293,6 +318,9 @@ private fun FeatureSection( } } SectionTextFooter(feature.enableDescription(enableFeature.value, groupInfo.isOwner)) + if (notice != null) { + SectionTextFooter(notice) + } } @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt index c3cf954ab6..3d76c845ad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -116,7 +116,10 @@ private fun ModalData.MemberSupportViewLayout( if (membersWithChats.isEmpty()) { item { Box(Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING), contentAlignment = Alignment.Center) { - Text(generalGetString(MR.strings.no_support_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + Text( + generalGetString(if (groupInfo.fullGroupPreferences.support.on) MR.strings.no_support_chats else MR.strings.support_chats_disabled), + color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center + ) } } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt index e60fcfc921..77b5cd73b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt @@ -110,7 +110,10 @@ fun AddChannelView(chatModel: ChatModel, close: () -> Unit, closeAll: () -> Unit fullName = "", shortDescr = null, image = profileImage.value, - groupPreferences = GroupPreferences(history = GroupPreference(GroupFeatureEnabled.ON)) + groupPreferences = GroupPreferences( + history = GroupPreference(GroupFeatureEnabled.ON), + support = GroupPreference(GroupFeatureEnabled.OFF) + ) ) creationInProgress.value = true withBGApi { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 4c7241fb5f..4bd9d9b96f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2337,6 +2337,14 @@ History is not sent to new members. Members can report messsages to moderators. Reporting messages is prohibited in this group. + Chat with admins + Allow members to chat with admins. + Prohibit chats with admins. + Members can chat with admins. + Chats with admins are prohibited. + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + Enable chats with admins? + Enable Delete after %d sec %ds @@ -2373,6 +2381,7 @@ Chats with members No chats with members + Chats with members are disabled Delete chat Delete chat with member? diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 93dbd560eb..ceb38939b1 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -171,6 +171,7 @@ This file is generated automatically. - [SrvError](#srverror) - [StoreError](#storeerror) - [SubscriptionStatus](#subscriptionstatus) +- [SupportGroupPreference](#supportgrouppreference) - [SwitchPhase](#switchphase) - [TimedMessagesGroupPreference](#timedmessagesgrouppreference) - [TimedMessagesPreference](#timedmessagespreference) @@ -2109,6 +2110,7 @@ Phone: - simplexLinks: [RoleGroupPreference](#rolegrouppreference) - reports: [GroupPreference](#grouppreference) - history: [GroupPreference](#grouppreference) +- support: [SupportGroupPreference](#supportgrouppreference) - sessions: [RoleGroupPreference](#rolegrouppreference) - comments: [CommentsGroupPreference](#commentsgrouppreference) - commands: [[ChatBotCommand](#chatbotcommand)] @@ -2200,6 +2202,7 @@ MemberSupport: - "simplexLinks" - "reports" - "history" +- "support" - "sessions" - "comments" @@ -2434,6 +2437,7 @@ NoRelays: - simplexLinks: [RoleGroupPreference](#rolegrouppreference)? - reports: [GroupPreference](#grouppreference)? - history: [GroupPreference](#grouppreference)? +- support: [SupportGroupPreference](#supportgrouppreference)? - sessions: [RoleGroupPreference](#rolegrouppreference)? - comments: [CommentsGroupPreference](#commentsgrouppreference)? - commands: [[ChatBotCommand](#chatbotcommand)]? @@ -3899,6 +3903,14 @@ NoSub: - type: "noSub" +--- + +## SupportGroupPreference + +**Record type**: +- enable: [GroupFeatureEnabled](#groupfeatureenabled) + + --- ## SwitchPhase diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 50adf6f7e5..933858c5cc 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -354,6 +354,7 @@ chatTypesDocsData = (sti @SrvError, STUnion, "SrvErr", [], "", ""), (sti @StoreError, STUnion, "SE", [], "", ""), (sti @SubscriptionStatus, STUnion, "SS", [], "", ""), + (sti @SupportGroupPreference, STRecord, "", [], "", ""), (sti @SwitchPhase, STEnum, "SP", [], "", ""), (sti @TimedMessagesGroupPreference, STRecord, "", [], "", ""), (sti @TimedMessagesPreference, STRecord, "", [], "", ""), @@ -563,6 +564,7 @@ deriving instance Generic SndGroupEvent deriving instance Generic SrvError deriving instance Generic StoreError deriving instance Generic SubscriptionStatus +deriving instance Generic SupportGroupPreference deriving instance Generic SwitchPhase deriving instance Generic TimedMessagesGroupPreference deriving instance Generic TimedMessagesPreference diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 59329d3c0d..eed9d5edc1 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2443,6 +2443,7 @@ export interface FullGroupPreferences { simplexLinks: RoleGroupPreference reports: GroupPreference history: GroupPreference + support: SupportGroupPreference sessions: RoleGroupPreference comments: CommentsGroupPreference commands: ChatBotCommand[] @@ -2516,6 +2517,7 @@ export enum GroupFeature { SimplexLinks = "simplexLinks", Reports = "reports", History = "history", + Support = "support", Sessions = "sessions", Comments = "comments", } @@ -2715,6 +2717,7 @@ export interface GroupPreferences { simplexLinks?: RoleGroupPreference reports?: GroupPreference history?: GroupPreference + support?: SupportGroupPreference sessions?: RoleGroupPreference comments?: CommentsGroupPreference commands?: ChatBotCommand[] @@ -4637,6 +4640,10 @@ export namespace SubscriptionStatus { } } +export interface SupportGroupPreference { + enable: GroupFeatureEnabled +} + export enum SwitchPhase { Started = "started", Confirmed = "confirmed", diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 5b6eda579d..31e6533ad3 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -625,7 +625,10 @@ processChatCommand vr nm = \case mapM_ assertNoMentions cms withContactLock "sendMessage" chatId $ sendContactContentMessages user chatId live itemTTL (L.map composedMessageReq cms) - SRGroup chatId gsScope asGroup -> + SRGroup chatId gsScope asGroup -> do + case gsScope of + Just (GCSMemberSupport _) -> when asGroup $ throwCmdError "cannot send as group in support scope" + Nothing -> pure () withGroupLock "sendMessage" chatId $ do (gInfo, cmrs) <- withFastStore $ \db -> do g <- getGroupInfo db vr user chatId @@ -2375,7 +2378,7 @@ processChatCommand vr nm = \case forM scope_ $ \(GSNMemberSupport mName_) -> GCSMemberSupport <$> mapM (getGroupMemberIdByName db user gId) mName_ (gInfo, cScope_,) <$> liftIO (getMessageMentions db user gId msg) - let sendRef = SRGroup (groupId' gInfo) cScope_ (sendAsGroup' gInfo) + let sendRef = SRGroup (groupId' gInfo) cScope_ (sendAsGroup' gInfo cScope_) processChatCommand vr nm $ APISendMessages sendRef False Nothing [ComposedMessage Nothing Nothing mc mentions] SNLocal -> do folderId <- withFastStore (`getUserNoteFolderId` user) @@ -3128,7 +3131,7 @@ processChatCommand vr nm = \case qiId <- getGroupChatItemIdByText db user gId cName quotedMsg (gInfo, qiId,) <$> liftIO (getMessageMentions db user gId msg) let mc = MCText msg - processChatCommand vr nm $ APISendMessages (SRGroup (groupId' gInfo) Nothing (sendAsGroup' gInfo)) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc mentions] + processChatCommand vr nm $ APISendMessages (SRGroup (groupId' gInfo) Nothing (sendAsGroup' gInfo Nothing)) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc mentions] ClearNoteFolder -> withUser $ \user -> do folderId <- withFastStore (`getUserNoteFolderId` user) processChatCommand vr nm $ APIClearChat (ChatRef CTLocal folderId Nothing) @@ -3404,7 +3407,7 @@ processChatCommand vr nm = \case _ -> throwCmdError "not supported" pure $ ChatRef cType chatId Nothing getSendAsGroup :: User -> ChatRef -> CM ShowGroupAsSender - getSendAsGroup user' (ChatRef CTGroup chatId _) = sendAsGroup' <$> withFastStore (\db -> getGroupInfo db vr user' chatId) + getSendAsGroup user' (ChatRef CTGroup chatId scope) = (`sendAsGroup'` scope) <$> withFastStore (\db -> getGroupInfo db vr user' chatId) getSendAsGroup _ _ = pure False getChatRefAndMentions :: User -> ChatName -> Text -> CM (ChatRef, Map MemberName GroupMemberId) getChatRefAndMentions user cName msg = do @@ -4490,7 +4493,7 @@ processChatCommand vr nm = \case ChatRef CTDirect cId _ -> a $ SRDirect cId ChatRef CTGroup gId scope -> do gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId - a $ SRGroup gId scope (sendAsGroup' gInfo) + a $ SRGroup gId scope (sendAsGroup' gInfo scope) _ -> throwCmdError "not supported" getSharedMsgId :: CM SharedMsgId getSharedMsgId = do @@ -5020,7 +5023,7 @@ chatCommandP = ("/help" <|> "/h") $> ChatHelp HSMain, ("/group" <|> "/g") *> (NewGroup <$> incognitoP <* A.space <* char_ '#' <*> groupProfile), "/_group " *> (APINewGroup <$> A.decimal <*> incognitoOnOffP <* A.space <*> jsonP), - ("/public group" <|> "/pg") *> (NewPublicGroup <$> incognitoP <* " relays=" <*> strP <* A.space <* char_ '#' <*> groupProfile), + ("/public group" <|> "/pg") *> (NewPublicGroup <$> incognitoP <* " relays=" <*> strP <* A.space <* char_ '#' <*> channelProfile), "/_public group " *> (APINewPublicGroup <$> A.decimal <*> incognitoOnOffP <*> _strP <* A.space <*> jsonP), "/_get relays #" *> (APIGetGroupRelays <$> A.decimal), ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> (memberRole <|> pure GRMember)), @@ -5150,6 +5153,7 @@ chatCommandP = "/set disappear @" *> (SetContactTimedMessages <$> displayNameP <*> optional (A.space *> timedMessagesEnabledP)), "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), "/set reports #" *> (SetGroupFeature (AGFNR SGFReports) <$> displayNameP <*> _strP), + "/set support #" *> (SetGroupFeature (AGFNR SGFSupport) <$> displayNameP <*> (A.space *> strP)), "/set links #" *> (SetGroupFeatureRole (AGFR SGFSimplexLinks) <$> displayNameP <*> _strP <*> optional memberRole), "/set admission review #" *> (SetGroupMemberAdmissionReview <$> displayNameP <*> (A.space *> memberCriteriaP)), ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, @@ -5287,6 +5291,10 @@ chatCommandP = history = Just HistoryGroupPreference {enable = FEOn} } pure GroupProfile {displayName = gName, fullName = "", shortDescr, description = Nothing, image = Nothing, publicGroup = Nothing, groupPreferences, memberAdmission = Nothing} + channelProfile = do + p@GroupProfile {groupPreferences = prefs_} <- groupProfile + let prefs = (fromMaybe emptyGroupPrefs prefs_) {support = Just SupportGroupPreference {enable = FEOff}} :: GroupPreferences + pure p {groupPreferences = Just prefs} memberCriteriaP = ("all" $> Just MCAll) <|> ("off" $> Nothing) shortDescrP = do descr <- A.takeWhile1 isSpace *> (T.dropWhileEnd isSpace <$> textP) <|> pure "" diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 3be69bd949..d7de3a52ad 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -338,12 +338,17 @@ quoteContent mc qmc ciFile_ prohibitedGroupContent :: GroupInfo -> GroupMember -> Maybe GroupChatScopeInfo -> MsgContent -> Maybe MarkdownList -> Maybe f -> Bool -> Maybe GroupFeature prohibitedGroupContent gInfo@GroupInfo {membership = mem@GroupMember {memberRole = userRole}} m scopeInfo mc ft file_ sent + | not supportAllowed = Just GFSupport | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) && not hostApprovalVoice = Just GFVoice | isNothing scopeInfo && not (isVoice mc) && isJust file_ && not (groupFeatureMemberAllowed SGFFiles m gInfo) = Just GFFiles | isNothing scopeInfo && isReport mc && (badReportUser || not (groupFeatureAllowed SGFReports gInfo)) = Just GFReports | isNothing scopeInfo && prohibitedSimplexLinks gInfo m mc ft = Just GFSimplexLinks | otherwise = Nothing where + supportAllowed = case scopeInfo of + Just (GCSIMemberSupport scopeMem_) -> + groupFeatureAllowed SGFSupport gInfo || isJust (supportChat $ fromMaybe mem scopeMem_) + Nothing -> True hostApprovalVoice | sent = userRole >= GRAdmin && sendApprovalPhase | otherwise = memberCategory m == GCHostMember && hostApprovalPhase diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 3fef977f13..087914e4f9 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1535,7 +1535,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = memberCanSend :: Maybe GroupMember -> Maybe MsgScope -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext) memberCanSend Nothing _ a = a -- channel message - was previously checked and allowed by relay memberCanSend (Just m@GroupMember {memberRole}) msgScope a = case msgScope of - Just MSMember {} -> a + Just (MSMember mId) + | sameMemberId mId m || memberRole >= GRModerator -> a + | otherwise -> messageError "member is not allowed to send to this support chat" $> Nothing Nothing | memberRole > GRObserver || memberPending m -> a | otherwise -> messageError "member is not allowed to send messages" $> Nothing @@ -1837,13 +1839,19 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- This patches initial sharedMsgId into chat item when locally deleted chat item -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). -- Chat item and update message which created it will have different sharedMsgId in this case... - let timed_ = rcvContactCITimed ct ttl - ts = ciContentTexts content - (ci, cInfo) <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live M.empty - ci' <- withStore' $ \db -> do - createChatItemVersion db (chatItemId' ci) brokerTs mc - updateDirectChatItem' db user contactId ci content True live Nothing Nothing - toView $ CEvtChatItemUpdated user (AChatItem SCTDirect SMDRcv cInfo ci') + if isVoice mc && not (featureAllowed SCFVoice forContact ct) + then do + let ciContent = ciContentNoParse $ CIRcvChatFeatureRejected CFVoice + (ci, cInfo) <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) brokerTs ciContent Nothing Nothing False M.empty + toView $ CEvtChatItemUpdated user (AChatItem SCTDirect SMDRcv cInfo ci) + else do + let timed_ = rcvContactCITimed ct ttl + ts = ciContentTexts content + (ci, cInfo) <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live M.empty + ci' <- withStore' $ \db -> do + createChatItemVersion db (chatItemId' ci) brokerTs mc + updateDirectChatItem' db user contactId ci content True live Nothing Nothing + toView $ CEvtChatItemUpdated user (AChatItem SCTDirect SMDRcv cInfo ci') where brokerTs = metaBrokerTs msgMeta content = CIRcvMsgContent mc @@ -2073,15 +2081,22 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m mc msgScope_ pure (gInfo', CDGroupRcv gInfo' scopeInfo m', mentions', scopeInfo) Nothing -> pure (gInfo, CDChannelRcv gInfo Nothing, mentions, Nothing) - (ci, cInfo) <- saveRcvChatItem' user chatDir msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live mentions' - ci' <- withStore' $ \db -> do - createChatItemVersion db (chatItemId' ci) brokerTs mc - updateGroupChatItem db user groupId ci content True live Nothing - ci'' <- case chatDir of - CDGroupRcv gi' _ m' -> blockedMemberCI gi' m' ci' - CDChannelRcv {} -> pure ci' - toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv cInfo ci'') - pure $ Just $ infoToDeliveryContext gInfo' scopeInfo showGroupAsSender + case m_ >>= \m -> prohibitedGroupContent gInfo' m scopeInfo mc ft_ (Nothing :: Maybe String) False of + Just f -> do + let ciContent = ciContentNoParse $ CIRcvGroupFeatureRejected f + (ci, cInfo) <- saveRcvChatItem' user chatDir msg (Just sharedMsgId) brokerTs ciContent Nothing timed_ False M.empty + groupMsgToView cInfo ci + pure Nothing + Nothing -> do + (ci, cInfo) <- saveRcvChatItem' user chatDir msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live mentions' + ci' <- withStore' $ \db -> do + createChatItemVersion db (chatItemId' ci) brokerTs mc + updateGroupChatItem db user groupId ci content True live Nothing + ci'' <- case chatDir of + CDGroupRcv gi' _ m' -> blockedMemberCI gi' m' ci' + CDChannelRcv {} -> pure ci' + toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv cInfo ci'') + pure $ Just $ infoToDeliveryContext gInfo' scopeInfo showGroupAsSender where content = CIRcvMsgContent mc ts@(_, ft_) = msgContentTexts mc diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index d90429f58e..5800ab5bdd 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -119,6 +119,11 @@ checkChatType x = case testEquality (chatTypeI @c) (chatTypeI @c') of data GroupChatScope = GCSMemberSupport {groupMemberId_ :: Maybe GroupMemberId} -- Nothing means own conversation with support deriving (Eq, Show, Ord) +sendAsGroup' :: GroupInfo -> Maybe GroupChatScope -> Bool +sendAsGroup' gInfo@GroupInfo {membership} scope = case scope of + Nothing -> useRelays' gInfo && memberRole' membership == GROwner + Just (GCSMemberSupport _) -> False + data GroupChatScopeTag = GCSTMemberSupport_ deriving (Eq, Show) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 3f5414a1d8..93db63ee71 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -1555,6 +1555,7 @@ createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMembe (Binary invId, groupLink, minVersion reqChatVRange, maxVersion reqChatVRange, groupId) insertOwner_ currentTs groupId = do let MemberIdRole {memberId, memberRole} = fromMember + VersionRange minV maxV = reqChatVRange (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do @@ -1563,11 +1564,13 @@ createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMembe [sql| INSERT INTO group_members ( group_id, index_in_group, member_id, member_role, member_category, member_status, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, GSMemAccepted) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) + :. (minV, maxV) ) insertedRowId db diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 366074acc2..99880b23d7 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -523,8 +523,9 @@ SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) Query: INSERT INTO group_members ( group_id, index_in_group, member_id, member_role, member_category, member_status, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 6fb55c84ce..b4acaedd39 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -494,9 +494,6 @@ data GroupInfo = GroupInfo useRelays' :: GroupInfo -> Bool useRelays' GroupInfo {useRelays} = isTrue useRelays -sendAsGroup' :: GroupInfo -> Bool -sendAsGroup' gInfo@GroupInfo {membership} = useRelays' gInfo && memberRole' membership == GROwner - groupId' :: GroupInfo -> GroupId groupId' GroupInfo {groupId} = groupId diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index c02d2b8433..be189379c9 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -176,6 +176,7 @@ data GroupFeature | GFSimplexLinks | GFReports | GFHistory + | GFSupport | GFSessions | GFComments deriving (Show) @@ -190,6 +191,7 @@ data SGroupFeature (f :: GroupFeature) where SGFSimplexLinks :: SGroupFeature 'GFSimplexLinks SGFReports :: SGroupFeature 'GFReports SGFHistory :: SGroupFeature 'GFHistory + SGFSupport :: SGroupFeature 'GFSupport SGFSessions :: SGroupFeature 'GFSessions SGFComments :: SGroupFeature 'GFComments @@ -218,6 +220,7 @@ groupFeatureNameText = \case GFSimplexLinks -> "SimpleX links" GFReports -> "Member reports" GFHistory -> "Recent history" + GFSupport -> "Chat with admins" GFSessions -> "Chat sessions" GFComments -> "Comments" @@ -248,11 +251,12 @@ allGroupFeatures = AGF SGFFiles, AGF SGFSimplexLinks, AGF SGFReports, - AGF SGFHistory + AGF SGFHistory, + AGF SGFSupport ] groupPrefSel :: SGroupFeature f -> GroupPreferences -> Maybe (GroupFeaturePreference f) -groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, sessions, comments} = case f of +groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, support, sessions, comments} = case f of SGFTimedMessages -> timedMessages SGFDirectMessages -> directMessages SGFFullDelete -> fullDelete @@ -262,6 +266,7 @@ groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reac SGFSimplexLinks -> simplexLinks SGFReports -> reports SGFHistory -> history + SGFSupport -> support SGFSessions -> sessions SGFComments -> comments @@ -276,6 +281,7 @@ toGroupFeature = \case SGFSimplexLinks -> GFSimplexLinks SGFReports -> GFReports SGFHistory -> GFHistory + SGFSupport -> GFSupport SGFSessions -> GFSessions SGFComments -> GFComments @@ -289,7 +295,7 @@ instance GroupPreferenceI (Maybe GroupPreferences) where getGroupPreference pt prefs = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPrefSel pt =<< prefs) instance GroupPreferenceI FullGroupPreferences where - getGroupPreference f FullGroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, sessions, comments} = case f of + getGroupPreference f FullGroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, support, sessions, comments} = case f of SGFTimedMessages -> timedMessages SGFDirectMessages -> directMessages SGFFullDelete -> fullDelete @@ -299,6 +305,7 @@ instance GroupPreferenceI FullGroupPreferences where SGFSimplexLinks -> simplexLinks SGFReports -> reports SGFHistory -> history + SGFSupport -> support SGFSessions -> sessions SGFComments -> comments {-# INLINE getGroupPreference #-} @@ -314,6 +321,7 @@ data GroupPreferences = GroupPreferences simplexLinks :: Maybe SimplexLinksGroupPreference, reports :: Maybe ReportsGroupPreference, history :: Maybe HistoryGroupPreference, + support :: Maybe SupportGroupPreference, sessions :: Maybe SessionsGroupPreference, comments :: Maybe CommentsGroupPreference, commands :: Maybe [ChatBotCommand] @@ -365,6 +373,7 @@ setGroupPreference_ f pref prefs = SGFSimplexLinks -> prefs {simplexLinks = pref} SGFReports -> prefs {reports = pref} SGFHistory -> prefs {history = pref} + SGFSupport -> prefs {support = pref} SGFSessions -> prefs {sessions = pref} SGFComments -> prefs {comments = pref} @@ -408,6 +417,7 @@ data FullGroupPreferences = FullGroupPreferences simplexLinks :: SimplexLinksGroupPreference, reports :: ReportsGroupPreference, history :: HistoryGroupPreference, + support :: SupportGroupPreference, sessions :: SessionsGroupPreference, comments :: CommentsGroupPreference, commands :: ListDef ChatBotCommand @@ -478,13 +488,14 @@ defaultGroupPrefs = simplexLinks = SimplexLinksGroupPreference {enable = FEOn, role = Nothing}, reports = ReportsGroupPreference {enable = FEOn}, history = HistoryGroupPreference {enable = FEOff}, + support = SupportGroupPreference {enable = FEOn}, sessions = SessionsGroupPreference {enable = FEOff, role = Nothing}, comments = CommentsGroupPreference {enable = FEOff, duration = Nothing}, commands = ListDef [] } emptyGroupPrefs :: GroupPreferences -emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing +emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing businessGroupPrefs :: Preferences -> GroupPreferences businessGroupPrefs Preferences {timedMessages, fullDelete, reactions, voice, files, sessions, commands} = @@ -515,6 +526,7 @@ defaultBusinessGroupPrefs = simplexLinks = Just $ SimplexLinksGroupPreference FEOn Nothing, reports = Just $ ReportsGroupPreference FEOff, history = Just $ HistoryGroupPreference FEOn, + support = Just $ SupportGroupPreference FEOn, sessions = Just $ SessionsGroupPreference FEOn Nothing, comments = Just $ CommentsGroupPreference FEOff Nothing, commands = Nothing @@ -647,6 +659,10 @@ data HistoryGroupPreference = HistoryGroupPreference {enable :: GroupFeatureEnabled} deriving (Eq, Show) +data SupportGroupPreference = SupportGroupPreference + {enable :: GroupFeatureEnabled} + deriving (Eq, Show) + data SessionsGroupPreference = SessionsGroupPreference {enable :: GroupFeatureEnabled, role :: Maybe GroupMemberRole} deriving (Eq, Show) @@ -699,6 +715,9 @@ instance HasField "enable" ReportsGroupPreference GroupFeatureEnabled where instance HasField "enable" HistoryGroupPreference GroupFeatureEnabled where hasField p@HistoryGroupPreference {enable} = (\e -> p {enable = e}, enable) +instance HasField "enable" SupportGroupPreference GroupFeatureEnabled where + hasField p@SupportGroupPreference {enable} = (\e -> p {enable = e}, enable) + instance HasField "enable" SessionsGroupPreference GroupFeatureEnabled where hasField p@SessionsGroupPreference {enable} = (\e -> p {enable = e}, enable) @@ -759,6 +778,12 @@ instance GroupFeatureI 'GFHistory where groupPrefParam _ = Nothing groupPrefRole _ = Nothing +instance GroupFeatureI 'GFSupport where + type GroupFeaturePreference 'GFSupport = SupportGroupPreference + sGroupFeature = SGFSupport + groupPrefParam _ = Nothing + groupPrefRole _ = Nothing + instance GroupFeatureI 'GFSessions where type GroupFeaturePreference 'GFSessions = SessionsGroupPreference sGroupFeature = SGFSessions @@ -781,6 +806,8 @@ instance GroupFeatureNoRoleI 'GFReports instance GroupFeatureNoRoleI 'GFHistory +instance GroupFeatureNoRoleI 'GFSupport + instance GroupFeatureNoRoleI 'GFComments instance HasField "role" DirectMessagesGroupPreference (Maybe GroupMemberRole) where @@ -973,6 +1000,7 @@ mergeGroupPreferences groupPreferences = simplexLinks = pref SGFSimplexLinks, reports = pref SGFReports, history = pref SGFHistory, + support = pref SGFSupport, sessions = pref SGFSessions, comments = pref SGFComments, commands = ListDef $ fromMaybe [] $ groupPreferences >>= commands_ @@ -993,6 +1021,7 @@ toGroupPreferences groupPreferences@FullGroupPreferences {commands = ListDef cmd simplexLinks = pref SGFSimplexLinks, reports = pref SGFReports, history = pref SGFHistory, + support = pref SGFSupport, sessions = pref SGFSessions, comments = pref SGFComments, commands = Just cmds @@ -1123,11 +1152,13 @@ $(J.deriveJSON defaultJSON ''ReportsGroupPreference) $(J.deriveJSON defaultJSON ''HistoryGroupPreference) -$(J.deriveToJSON defaultJSON ''SessionsGroupPreference) +$(J.deriveToJSON defaultJSON ''SupportGroupPreference) -instance FromJSON SessionsGroupPreference where - parseJSON v = $(J.mkParseJSON defaultJSON ''SessionsGroupPreference) v - omittedField = Just SessionsGroupPreference {enable = FEOff, role = Nothing} +instance FromJSON SupportGroupPreference where + parseJSON v = $(J.mkParseJSON defaultJSON ''SupportGroupPreference) v + omittedField = Just SupportGroupPreference {enable = FEOn} + +$(J.deriveJSON defaultJSON ''SessionsGroupPreference) $(J.deriveToJSON defaultJSON ''CommentsGroupPreference) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 714aa0c0ed..279a09e718 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -170,7 +170,7 @@ termSettings :: VirtualTerminalSettings termSettings = VirtualTerminalSettings { virtualType = "xterm", - virtualWindowSize = pure C.Size {height = 20, width = 6000}, + virtualWindowSize = pure C.Size {height = 24, width = 6000}, virtualEvent = retry, virtualInterrupt = retry } diff --git a/tests/ChatTests/ChatList.hs b/tests/ChatTests/ChatList.hs index 14d48dbf60..889915a6e8 100644 --- a/tests/ChatTests/ChatList.hs +++ b/tests/ChatTests/ChatList.hs @@ -200,14 +200,14 @@ testPaginationAllChatTypes = ts7 <- iso8601Show <$> getCurrentTime - getChats_ alice "count=10" [("*", "psst"), ("@dan", "hey"), ("#team", "Recent history: on"), (":3", ""), ("@cath", "Audio/video calls: enabled"), ("@bob", "hey")] - getChats_ alice "count=3" [("*", "psst"), ("@dan", "hey"), ("#team", "Recent history: on")] + getChats_ alice "count=10" [("*", "psst"), ("@dan", "hey"), ("#team", "Chat with admins: on"), (":3", ""), ("@cath", "Audio/video calls: enabled"), ("@bob", "hey")] + getChats_ alice "count=3" [("*", "psst"), ("@dan", "hey"), ("#team", "Chat with admins: on")] getChats_ alice ("after=" <> ts2 <> " count=2") [(":3", ""), ("@cath", "Audio/video calls: enabled")] - getChats_ alice ("before=" <> ts5 <> " count=2") [("#team", "Recent history: on"), (":3", "")] - getChats_ alice ("after=" <> ts3 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", "Recent history: on"), (":3", "")] + getChats_ alice ("before=" <> ts5 <> " count=2") [("#team", "Chat with admins: on"), (":3", "")] + getChats_ alice ("after=" <> ts3 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", "Chat with admins: on"), (":3", "")] getChats_ alice ("before=" <> ts4 <> " count=10") [(":3", ""), ("@cath", "Audio/video calls: enabled"), ("@bob", "hey")] - getChats_ alice ("after=" <> ts1 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", "Recent history: on"), (":3", ""), ("@cath", "Audio/video calls: enabled"), ("@bob", "hey")] - getChats_ alice ("before=" <> ts7 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", "Recent history: on"), (":3", ""), ("@cath", "Audio/video calls: enabled"), ("@bob", "hey")] + getChats_ alice ("after=" <> ts1 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", "Chat with admins: on"), (":3", ""), ("@cath", "Audio/video calls: enabled"), ("@bob", "hey")] + getChats_ alice ("before=" <> ts7 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", "Chat with admins: on"), (":3", ""), ("@cath", "Audio/video calls: enabled"), ("@bob", "hey")] getChats_ alice ("after=" <> ts7 <> " count=10") [] getChats_ alice ("before=" <> ts1 <> " count=10") [] @@ -219,11 +219,11 @@ testPaginationAllChatTypes = alice ##> "/_settings #1 {\"enableNtfs\":\"all\",\"favorite\":true}" alice <## "ok" - getChats_ alice queryFavorite [("#team", "Recent history: on"), ("@bob", "hey")] + getChats_ alice queryFavorite [("#team", "Chat with admins: on"), ("@bob", "hey")] getChats_ alice ("before=" <> ts4 <> " count=1 " <> queryFavorite) [("@bob", "hey")] - getChats_ alice ("before=" <> ts5 <> " count=1 " <> queryFavorite) [("#team", "Recent history: on")] + getChats_ alice ("before=" <> ts5 <> " count=1 " <> queryFavorite) [("#team", "Chat with admins: on")] getChats_ alice ("after=" <> ts1 <> " count=1 " <> queryFavorite) [("@bob", "hey")] - getChats_ alice ("after=" <> ts4 <> " count=1 " <> queryFavorite) [("#team", "Recent history: on")] + getChats_ alice ("after=" <> ts4 <> " count=1 " <> queryFavorite) [("#team", "Chat with admins: on")] let queryUnread = "{\"type\": \"filters\", \"favorite\": false, \"unread\": true}" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 3ff884d3d1..89fb0a2004 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -231,6 +231,8 @@ chatGroupTests = do it "should remove support chat with member when member is removed" testScopedSupportMemberRemoved it "should remove support chat with member when user removes member" testScopedSupportUserRemovesMember it "should remove support chat with member when member leaves" testScopedSupportMemberLeaves + it "should respect support preference in group" testSupportPreferenceGroup + it "should respect support preference in channel" testSupportPreferenceChannel -- TODO [relays] add tests for channels -- TODO - tests with delivery loop over members restored after restart -- TODO - delivery in support scopes inside channels @@ -267,6 +269,7 @@ chatGroupTests = do it "owner should update profile in channel (signed)" testChannelOwnerProfileUpdate it "subscriber should update profile in channel (signed)" testChannelSubscriberProfileUpdate it "should report relay results when one relay deleted its address" testChannelCreateDeletedRelay + it "should deliver support scope messages via relay" testChannelSupportScope describe "channel message operations" $ do it "should update channel message" testChannelMessageUpdate it "should delete channel message" testChannelMessageDelete @@ -457,7 +460,7 @@ testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice forM_ ([1 .. 10] :: [Int]) $ \n -> bob <# ("#team alice> " <> show n) -- All messages are unread for bob, should return area around unread - bob #$> ("/_get chat #1 initial=2", chat, [(0, "Recent history: on"), (0, "connected"), (0, "1"), (0, "2"), (0, "3")]) + bob #$> ("/_get chat #1 initial=2", chat, [(0, "Chat with admins: on"), (0, "connected"), (0, "1"), (0, "2"), (0, "3")]) -- Read next 2 items let itemIds = intercalate "," $ map groupItemId [1 .. 2] @@ -646,7 +649,7 @@ testGroup2 = ] dan <##> alice -- show last messages - alice ##> "/t #club 20" + alice ##> "/t #club 21" alice -- these strings are expected in any order because of sorting by time and rounding of time for sent <##? ( map (ConsoleString . ("#club " <> )) groupFeatureStrs @@ -1667,6 +1670,7 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile alice <## "SimpleX links: on" alice <## "Member reports: on" alice <## "Recent history: on" + alice <## "Chat with admins: on" bobAddedDan :: HasCallStack => TestCC -> IO () bobAddedDan cc = do cc <## "#team: bob added dan (Daniel) to the group (connecting...)" @@ -8400,6 +8404,120 @@ testScopedSupportMemberLeaves = { markRead = False } +testSupportPreferenceGroup :: HasCallStack => TestParams -> IO () +testSupportPreferenceGroup = + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do + createGroup3' "team" alice (bob, GRMember) (cath, GRMember) + + threadDelay 1000000 + + -- support enabled by default, bob sends to support + bob #> "#team (support) hello" + alice <# "#team (support: bob) bob> hello" + + -- alice replies + alice #> "#team (support: bob) hi" + bob <# "#team (support) alice> hi" + + -- alice disables support + alice ##> "/set support #team off" + alice <## "updated group preferences:" + alice <## "Chat with admins: off" + concurrentlyN_ + [ do + bob <## "alice updated group #team:" + bob <## "updated group preferences:" + bob <## "Chat with admins: off", + do + cath <## "alice updated group #team:" + cath <## "updated group preferences:" + cath <## "Chat with admins: off" + ] + + threadDelay 500000 + + -- cath can't send support (no existing chat) + cath ##> "#team (support) hey" + cath <## "bad chat command: feature not allowed Chat with admins" + + -- alice can't send to cath's support (no existing chat) + alice ##> "#team (support: cath) hey" + alice <## "bad chat command: feature not allowed Chat with admins" + + -- bob can still send (existing chat) + bob #> "#team (support) still here" + alice <# "#team (support: bob) bob> still here" + + -- alice can still send to bob (existing chat) + alice #> "#team (support: bob) yes" + bob <# "#team (support) alice> yes" + +testSupportPreferenceChannel :: HasCallStack => TestParams -> IO () +testSupportPreferenceChannel ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "relay" relayProfile $ \relay -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice relay + memberJoinChannel "team" [relay] [alice] shortLink fullLink bob + memberJoinChannel "team" [relay] [alice] shortLink fullLink cath + + threadDelay 1000000 + + alice ##> "/set support #team on" + alice <## "updated group preferences:" + alice <## "Chat with admins: on" + toggledSupport relay "alice" "team" "on" + concurrentlyN_ + [ toggledSupport bob "alice" "team" "on", + toggledSupport cath "alice" "team" "on" + ] + + -- support enabled by default, bob sends to support + bob #> "#team (support) hello" + relay <# "#team (support: bob) bob> hello" + alice <# "#team (support: bob) bob> hello [>>]" + + -- alice replies + alice #> "#team (support: bob) hi" + relay <# "#team (support: bob) alice> hi" + bob <# "#team (support) alice> hi [>>]" + + -- alice disables support + + threadDelay 1000000 + + alice ##> "/set support #team off" + alice <## "updated group preferences:" + alice <## "Chat with admins: off" + toggledSupport relay "alice" "team" "off" + concurrentlyN_ + [ toggledSupport bob "alice" "team" "off", + toggledSupport cath "alice" "team" "off" + ] + + threadDelay 500000 + + -- cath can't send support (no existing chat) + cath ##> "#team (support) hey" + cath <## "bad chat command: feature not allowed Chat with admins" + alice ##> "#team (support: cath) hey too" + alice <## "bad chat command: feature not allowed Chat with admins" + + -- bob can still send (existing chat) + bob #> "#team (support) still here" + concurrentlyN_ + [ relay <# "#team (support: bob) bob> still here", + alice <# "#team (support: bob) bob> still here [>>]" + ] + + -- alice can still send to bob (existing chat) + alice #> "#team (support: bob) yes" + concurrentlyN_ + [ relay <# "#team (support: bob) alice> yes", + bob <# "#team (support) alice> yes [>>]" + ] + testChannels1RelayDeliver :: HasCallStack => TestParams -> IO () testChannels1RelayDeliver ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -8858,7 +8976,7 @@ testChannelLinkAfterWelcomeUpdate ps = shortLink' `shouldBe` shortLink fullLink' `shouldBe` fullLink memberJoinChannel "team" [bob] [alice] shortLink' fullLink' dan - dan #$> ("/_get chat #1 count=100", chat, groupFeaturesNoE2E <> [(0, "welcome to team"), (0, T.unpack publicGroupNoE2EText), (0, "connected")]) + dan #$> ("/_get chat #1 count=100", chat, channelFeaturesNoE2E <> [(0, "welcome to team"), (0, T.unpack publicGroupNoE2EText), (0, "connected")]) alice #> "#team hi" bob <# "#team> hi" @@ -9568,6 +9686,45 @@ testChannelCreateDeletedRelay ps = -- bob's agent reports AUTH error when the queue is gone — drain it. void $ getTermLine bob +testChannelSupportScope :: HasCallStack => TestParams -> IO () +testChannelSupportScope ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "relay" relayProfile $ \relay -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice relay + memberJoinChannel "team" [relay] [alice] shortLink fullLink cath + memberJoinChannel "team" [relay] [alice] shortLink fullLink dan + + threadDelay 1000000 + + alice ##> "/set support #team on" + alice <## "updated group preferences:" + alice <## "Chat with admins: on" + toggledSupport relay "alice" "team" "on" + concurrentlyN_ + [ toggledSupport cath "alice" "team" "on", + toggledSupport dan "alice" "team" "on" + ] + + -- owner sends to cath's support scope, dan doesn't receive + alice #> "#team (support: cath) hello" + relay <# "#team (support: cath) alice> hello" + cath <# "#team (support) alice> hello [>>]" + (dan "#team (support) hi" + relay <# "#team (support: cath) cath> hi" + alice <# "#team (support: cath) cath> hi [>>]" + (dan TestCC -> String -> String -> String -> IO () +toggledSupport c owner channel onOff = do + c <## (owner <> " updated group #" <> channel <> ": (signed)") + c <## "updated group preferences:" + c <## ("Chat with admins: " <> onOff) + testChannelMessageUpdate :: HasCallStack => TestParams -> IO () testChannelMessageUpdate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index ebc9056164..d42e833c39 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -290,7 +290,10 @@ groupFeatures :: [(Int, String)] groupFeatures = map (\(a, _, _) -> a) $ groupFeatures'' 0 groupFeaturesNoE2E :: [(Int, String)] -groupFeaturesNoE2E = map (\(a, _, _) -> a) $ ((1, "chat banner"), Nothing, Nothing) : groupFeatures_ 0 +groupFeaturesNoE2E = map (\(a, _, _) -> a) $ ((1, "chat banner"), Nothing, Nothing) : groupFeatures_ 0 False + +channelFeaturesNoE2E :: [(Int, String)] +channelFeaturesNoE2E = map (\(a, _, _) -> a) $ ((1, "chat banner"), Nothing, Nothing) : groupFeatures_ 0 True sndGroupFeatures :: [(Int, String)] sndGroupFeatures = map (\(a, _, _) -> a) $ groupFeatures'' 1 @@ -299,10 +302,10 @@ groupFeatureStrs :: [String] groupFeatureStrs = map (\(a, _, _) -> snd a) $ groupFeatures'' 0 groupFeatures'' :: Int -> [((Int, String), Maybe (Int, String), Maybe String)] -groupFeatures'' dir = ((1, "chat banner"), Nothing, Nothing) : ((dir, e2eeInfoNoPQStr), Nothing, Nothing) : groupFeatures_ dir +groupFeatures'' dir = ((1, "chat banner"), Nothing, Nothing) : ((dir, e2eeInfoNoPQStr), Nothing, Nothing) : groupFeatures_ dir False -groupFeatures_ :: Int -> [((Int, String), Maybe (Int, String), Maybe String)] -groupFeatures_ dir = +groupFeatures_ :: Int -> Bool -> [((Int, String), Maybe (Int, String), Maybe String)] +groupFeatures_ dir isChannel = [ ((dir, "Disappearing messages: off"), Nothing, Nothing), ((dir, "Direct messages: on"), Nothing, Nothing), ((dir, "Full deletion: off"), Nothing, Nothing), @@ -311,7 +314,8 @@ groupFeatures_ dir = ((dir, "Files and media: on"), Nothing, Nothing), ((dir, "SimpleX links: on"), Nothing, Nothing), ((dir, "Member reports: on"), Nothing, Nothing), - ((dir, "Recent history: on"), Nothing, Nothing) + ((dir, "Recent history: on"), Nothing, Nothing), + ((dir, "Chat with admins: " <> (if isChannel then "off" else "on")), Nothing, Nothing) ] businessGroupFeatures :: [(Int, String)] @@ -329,7 +333,8 @@ businessGroupFeatures'' dir = ((dir, "Files and media: on"), Nothing, Nothing), ((dir, "SimpleX links: on"), Nothing, Nothing), ((dir, "Member reports: off"), Nothing, Nothing), - ((dir, "Recent history: on"), Nothing, Nothing) + ((dir, "Recent history: on"), Nothing, Nothing), + ((dir, "Chat with admins: on"), Nothing, Nothing) ] itemId :: Int -> String diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 3ba789b988..01399f6bbb 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -101,7 +101,7 @@ testChatPreferences :: Maybe Preferences testChatPreferences = Just Preferences {voice = Just VoicePreference {allow = FAYes}, files = Nothing, fullDelete = Nothing, timedMessages = Nothing, calls = Nothing, reactions = Just ReactionsPreference {allow = FAYes}, sessions = Nothing, commands = Nothing} testGroupPreferences :: Maybe GroupPreferences -testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing, reports = Nothing, sessions = Nothing, comments = Nothing, commands = Nothing} +testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing, reports = Nothing, support = Nothing, sessions = Nothing, comments = Nothing, commands = Nothing} testProfile :: Profile testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences}