From 182225a5b10cb93130ac3cff189f1ec6d4a7be53 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 12 Apr 2024 11:29:58 +0100 Subject: [PATCH] ios: role-based preferences, SimpleX links preference (#4021) * ios: role-based preferences, SimpleX links preference * disable some options --- apps/ios/Shared/Model/ChatModel.swift | 18 +++ .../Chat/ChatItem/CIChatFeatureView.swift | 11 +- apps/ios/Shared/Views/Chat/ChatItemView.swift | 8 +- .../Chat/ComposeMessage/ComposeView.swift | 67 +++++++-- .../Chat/ComposeMessage/SendMessageView.swift | 4 +- .../Chat/Group/GroupMemberInfoView.swift | 4 +- .../Chat/Group/GroupPreferencesView.swift | 42 +++++- apps/ios/SimpleXChat/ChatTypes.swift | 138 ++++++++++++++---- 8 files changed, 235 insertions(+), 57 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 90a9406627..a4651e1d42 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -779,6 +779,24 @@ final class Chat: ObservableObject, Identifiable { var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } } + func groupFeatureEnabled(_ feature: GroupFeature) -> Bool { + if case let .group(groupInfo) = self.chatInfo { + let p = groupInfo.fullGroupPreferences + return switch feature { + case .timedMessages: p.timedMessages.on + case .directMessages: p.directMessages.on(for: groupInfo.membership) + case .fullDelete: p.fullDelete.on + case .reactions: p.reactions.on + case .voice: p.voice.on(for: groupInfo.membership) + case .files: p.files.on(for: groupInfo.membership) + case .simplexLinks: p.simplexLinks.on(for: groupInfo.membership) + case .history: p.history.on + } + } else { + return true + } + } + public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift index 03afa30331..5c9ea0f6d8 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift @@ -11,6 +11,7 @@ import SimpleXChat struct CIChatFeatureView: View { @EnvironmentObject var m: ChatModel + @ObservedObject var chat: Chat var chatItem: ChatItem @Binding var revealed: Bool var feature: Feature @@ -18,7 +19,7 @@ struct CIChatFeatureView: View { var iconColor: Color var body: some View { - if !revealed, let fs = mergedFeautures() { + if !revealed, let fs = mergedFeatures() { HStack { ForEach(fs, content: featureIconView) } @@ -47,7 +48,7 @@ struct CIChatFeatureView: View { } } - private func mergedFeautures() -> [FeatureInfo]? { + private func mergedFeatures() -> [FeatureInfo]? { var fs: [FeatureInfo] = [] var icons: Set = [] if var i = m.getChatItemIndex(chatItem) { @@ -67,8 +68,8 @@ struct CIChatFeatureView: View { switch ci.content { case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param) case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param) - case let .rcvGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param) - case let .sndGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param) + case let .rcvGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor, param) + case let .sndGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor, param) default: nil } } @@ -103,6 +104,6 @@ struct CIChatFeatureView: View { struct CIChatFeatureView_Previews: PreviewProvider { static var previews: some View { let enabled = FeatureEnabled(forUser: false, forContact: false) - CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor) + CIChatFeatureView(chat: Chat.sampleData, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 8fe2b71d09..c2adcacbfe 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -102,9 +102,9 @@ struct ChatItemContentView: View { case let .rcvChatPreference(feature, allowed, param): CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param) case let .sndChatPreference(feature, _, _): - CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: .secondary) - case let .rcvGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor) - case let .sndGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor) + CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: .secondary) + case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor) + case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor) case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red) case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red) case .sndModerated: deletedItemView() @@ -149,7 +149,7 @@ struct ChatItemContentView: View { } private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View { - CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor) + CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor) } private var mergedGroupEventText: Text? { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 604e0a276d..24fe983661 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -161,6 +161,16 @@ struct ComposeState { } } + var attachmentPreview: Bool { + switch preview { + case .noPreview: false + case .linkPreview: false + case let .mediaPreviews(mediaPreviews): !mediaPreviews.isEmpty + case .voicePreview: false + case .filePreview: true + } + } + var empty: Bool { message == "" && noPreview } @@ -234,6 +244,7 @@ struct ComposeView: View { @Binding var keyboardVisible: Bool @State var linkUrl: URL? = nil + @State var hasSimplexLink: Bool = false @State var prevLinkUrl: URL? = nil @State var pendingLinkUrl: URL? = nil @State var cancelledLinks: Set = [] @@ -260,6 +271,16 @@ struct ComposeView: View { if chat.chatInfo.contact?.nextSendGrpInv ?? false { ContextInvitingContactMemberView() } + let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks) + let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files) + let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice) + if simplexLinkProhibited { + msgNotAllowedView("SimpleX links not allowed", icon: "link") + } else if fileProhibited { + msgNotAllowedView("Files and media not allowed", icon: "doc") + } else if voiceProhibited { + msgNotAllowedView("Voice messages not allowed", icon: "mic") + } contextItemView() switch (composeState.editing, composeState.preview) { case (true, .filePreview): EmptyView() @@ -278,7 +299,7 @@ struct ComposeView: View { .padding(.bottom, 12) .padding(.leading, 12) if case let .group(g) = chat.chatInfo, - !g.fullGroupPreferences.files.on { + !g.fullGroupPreferences.files.on(for: g.membership) { b.disabled(true).onTapGesture { AlertManager.shared.showAlertMsg( title: "Files and media prohibited!", @@ -303,6 +324,7 @@ struct ComposeView: View { }, nextSendGrpInv: chat.chatInfo.contact?.nextSendGrpInv ?? false, voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice), + disableSendButton: simplexLinkProhibited || fileProhibited || voiceProhibited, showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert, startVoiceMessageRecording: { Task { @@ -337,13 +359,18 @@ struct ComposeView: View { } } } - .onChange(of: composeState.message) { _ in + .onChange(of: composeState.message) { msg in if composeState.linkPreviewAllowed { - if composeState.message.count > 0 { - showLinkPreview(composeState.message) + if msg.count > 0 { + showLinkPreview(msg) } else { resetLinkPreview() + hasSimplexLink = false } + } else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) { + (_, hasSimplexLink) = parseMessage(msg) + } else { + hasSimplexLink = false } } .onChange(of: chat.userCanSend) { canSend in @@ -610,6 +637,18 @@ struct ComposeView: View { } } + private func msgNotAllowedView(_ reason: LocalizedStringKey, icon: String) -> some View { + HStack { + Image(systemName: icon).foregroundColor(.secondary) + Text(reason).italic() + } + .padding(12) + .frame(minHeight: 50) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(uiColor: .tertiarySystemGroupedBackground)) + .padding(.top, 8) + } + @ViewBuilder private func contextItemView() -> some View { switch composeState.contextItem { case .noContextItem: @@ -818,7 +857,7 @@ struct ComposeView: View { func checkLinkPreview() -> MsgContent { switch (composeState.preview) { case let .linkPreview(linkPreview: linkPreview): - if let url = parseMessage(msgText), + if let url = parseMessage(msgText).url, let linkPreview = linkPreview, url == linkPreview.uri { return .link(text: msgText, preview: linkPreview) @@ -947,7 +986,7 @@ struct ComposeView: View { private func showLinkPreview(_ s: String) { prevLinkUrl = linkUrl - linkUrl = parseMessage(s) + (linkUrl, hasSimplexLink) = parseMessage(s) if let url = linkUrl { if url != composeState.linkPreview?.uri && url != pendingLinkUrl { pendingLinkUrl = url @@ -964,13 +1003,17 @@ struct ComposeView: View { } } - private func parseMessage(_ msg: String) -> URL? { - let parsedMsg = parseSimpleXMarkdown(msg) - let uri = parsedMsg?.first(where: { ft in + private func parseMessage(_ msg: String) -> (url: URL?, hasSimplexLink: Bool) { + guard let parsedMsg = parseSimpleXMarkdown(msg) else { return (nil, false) } + let url: URL? = if let uri = parsedMsg.first(where: { ft in ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) - }) - if let uri = uri { return URL(string: uri.text) } - else { return nil } + }) { + URL(string: uri.text) + } else { + nil + } + let simplexLink = parsedMsg.contains(where: { ft in ft.format?.isSimplexLink ?? false }) + return (url, simplexLink) } private func isSimplexLink(_ link: String) -> Bool { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 8f7b23c888..f9e5b1c2b1 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -20,6 +20,7 @@ struct SendMessageView: View { var nextSendGrpInv: Bool = false var showVoiceMessageButton: Bool = true var voiceMessageAllowed: Bool = true + var disableSendButton = false var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other var startVoiceMessageRecording: (() -> Void)? = nil var finishVoiceMessageRecording: (() -> Void)? = nil @@ -184,7 +185,8 @@ struct SendMessageView: View { !composeState.sendEnabled || composeState.inProgress || (!voiceMessageAllowed && composeState.voicePreview) || - composeState.endLiveDisabled + composeState.endLiveDisabled || + disableSendButton ) .frame(width: 29, height: 29) .contextMenu{ diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 999617dde7..766aaf6577 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -83,7 +83,7 @@ struct GroupMemberInfoView: View { Section { if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) { knownDirectChatButton(chat) - } else if groupInfo.fullGroupPreferences.directMessages.on { + } else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) { if let contactId = member.memberContactId { newDirectChatButton(contactId) } else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false { @@ -110,7 +110,7 @@ struct GroupMemberInfoView: View { Label("Share address", systemImage: "square.and.arrow.up") } if let contactId = member.memberContactId { - if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on { + if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) { connectViaAddressButton(contactLink) } } else { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index 7ab4bf4ece..b4e1992848 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -9,6 +9,12 @@ import SwiftUI import SimpleXChat +private let featureRoles: [(role: GroupMemberRole?, text: LocalizedStringKey)] = [ + (nil, "all members"), + (.admin, "admins"), + (.owner, "owners") +] + struct GroupPreferencesView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var chatModel: ChatModel @@ -24,10 +30,12 @@ struct GroupPreferencesView: View { List { featureSection(.timedMessages, $preferences.timedMessages.enable) featureSection(.fullDelete, $preferences.fullDelete.enable) - featureSection(.directMessages, $preferences.directMessages.enable) + featureSection(.directMessages, $preferences.directMessages.enable, $preferences.directMessages.role) featureSection(.reactions, $preferences.reactions.enable) - featureSection(.voice, $preferences.voice.enable) - featureSection(.files, $preferences.files.enable) + featureSection(.voice, $preferences.voice.enable, $preferences.voice.role) + featureSection(.files, $preferences.files.enable, $preferences.files.role) + // TODO enable simplexLinks preference in 5.8 + // featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role) featureSection(.history, $preferences.history.enable) if groupInfo.canEdit { @@ -64,7 +72,7 @@ struct GroupPreferencesView: View { } } - private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding) -> some View { + private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding, _ enableForRole: Binding? = nil) -> some View { Section { let color: Color = enableFeature.wrappedValue == .on ? .green : .secondary let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon @@ -87,6 +95,16 @@ struct GroupPreferencesView: View { ) .frame(height: 36) } + if enableFeature.wrappedValue == .on, let enableForRole { + Picker("Enabled for", selection: enableForRole) { + ForEach(featureRoles, id: \.role) { fr in + Text(fr.text) + } + } + .frame(height: 36) + // remove in v5.8 + .disabled(true) + } } else { settingsRow(icon, color: color) { infoRow(Text(feature.text), enableFeature.wrappedValue.text) @@ -94,10 +112,26 @@ struct GroupPreferencesView: View { if timedOn { infoRow("Delete after", timeText(preferences.timedMessages.ttl)) } + if enableFeature.wrappedValue == .on, let enableForRole { + HStack { + Text("Enabled for").foregroundColor(.secondary) + Spacer() + Text( + featureRoles.first(where: { fr in fr.role == enableForRole.wrappedValue })?.text + ?? "all members" + ) + .foregroundColor(.secondary) + } + } } } footer: { Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit)) } + .onChange(of: enableFeature.wrappedValue) { enabled in + if case .off = enabled { + enableForRole?.wrappedValue = nil + } + } } private func savePreferences() { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 7990f1caea..9db0468d95 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -543,6 +543,7 @@ public protocol Feature { var iconFilled: String { get } var iconScale: CGFloat { get } var hasParam: Bool { get } + var hasRole: Bool { get } var text: String { get } } @@ -569,6 +570,8 @@ public enum ChatFeature: String, Decodable, Feature { } } + public var hasRole: Bool { false } + public var text: String { switch self { case .timedMessages: return NSLocalizedString("Disappearing messages", comment: "chat feature") @@ -694,6 +697,7 @@ public enum GroupFeature: String, Decodable, Feature { case reactions case voice case files + case simplexLinks case history public var id: Self { self } @@ -705,6 +709,19 @@ public enum GroupFeature: String, Decodable, Feature { } } + public var hasRole: Bool { + switch self { + case .timedMessages: false + case .directMessages: true + case .fullDelete: false + case .reactions: false + case .voice: true + case .files: true + case .simplexLinks: true + case .history: false + } + } + public var text: String { switch self { case .timedMessages: return NSLocalizedString("Disappearing messages", comment: "chat feature") @@ -713,6 +730,7 @@ public enum GroupFeature: String, Decodable, Feature { case .reactions: return NSLocalizedString("Message reactions", comment: "chat feature") case .voice: return NSLocalizedString("Voice messages", comment: "chat feature") case .files: return NSLocalizedString("Files and media", comment: "chat feature") + case .simplexLinks: return NSLocalizedString("SimpleX links", comment: "chat feature") case .history: return NSLocalizedString("Visible history", comment: "chat feature") } } @@ -725,6 +743,7 @@ public enum GroupFeature: String, Decodable, Feature { case .reactions: return "face.smiling" case .voice: return "mic" case .files: return "doc" + case .simplexLinks: return "link.circle" case .history: return "clock" } } @@ -737,6 +756,7 @@ public enum GroupFeature: String, Decodable, Feature { case .reactions: return "face.smiling.fill" case .voice: return "mic.fill" case .files: return "doc.fill" + case .simplexLinks: return "link.circle.fill" case .history: return "clock.fill" } } @@ -781,6 +801,11 @@ public enum GroupFeature: String, Decodable, Feature { case .on: return "Allow to send files and media." case .off: return "Prohibit sending files and media." } + case .simplexLinks: + switch enabled { + case .on: return "Allow to send SimpleX links." + case .off: return "Prohibit sending SimpleX links." + } case .history: switch enabled { case .on: return "Send up to 100 last messages to new members." @@ -819,6 +844,11 @@ public enum GroupFeature: String, Decodable, Feature { case .on: return "Group members can send files and media." case .off: return "Files and media are prohibited in this group." } + case .simplexLinks: + switch enabled { + case .on: return "Group members can send SimpleX links." + case .off: return "SimpleX links are prohibited in this group." + } case .history: switch enabled { case .on: return "Up to 100 last messages are sent to new members." @@ -958,20 +988,22 @@ public enum FeatureAllowed: String, Codable, Identifiable { public struct FullGroupPreferences: Decodable, Equatable { public var timedMessages: TimedMessagesGroupPreference - public var directMessages: GroupPreference + public var directMessages: RoleGroupPreference public var fullDelete: GroupPreference public var reactions: GroupPreference - public var voice: GroupPreference - public var files: GroupPreference + public var voice: RoleGroupPreference + public var files: RoleGroupPreference + public var simplexLinks: RoleGroupPreference public var history: GroupPreference public init( timedMessages: TimedMessagesGroupPreference, - directMessages: GroupPreference, + directMessages: RoleGroupPreference, fullDelete: GroupPreference, reactions: GroupPreference, - voice: GroupPreference, - files: GroupPreference, + voice: RoleGroupPreference, + files: RoleGroupPreference, + simplexLinks: RoleGroupPreference, history: GroupPreference ) { self.timedMessages = timedMessages @@ -980,36 +1012,40 @@ public struct FullGroupPreferences: Decodable, Equatable { self.reactions = reactions self.voice = voice self.files = files + self.simplexLinks = simplexLinks self.history = history } public static let sampleData = FullGroupPreferences( timedMessages: TimedMessagesGroupPreference(enable: .off), - directMessages: GroupPreference(enable: .off), + directMessages: RoleGroupPreference(enable: .off, role: nil), fullDelete: GroupPreference(enable: .off), reactions: GroupPreference(enable: .on), - voice: GroupPreference(enable: .on), - files: GroupPreference(enable: .on), + voice: RoleGroupPreference(enable: .on, role: nil), + files: RoleGroupPreference(enable: .on, role: nil), + simplexLinks: RoleGroupPreference(enable: .on, role: nil), history: GroupPreference(enable: .on) ) } public struct GroupPreferences: Codable { public var timedMessages: TimedMessagesGroupPreference? - public var directMessages: GroupPreference? + public var directMessages: RoleGroupPreference? public var fullDelete: GroupPreference? public var reactions: GroupPreference? - public var voice: GroupPreference? - public var files: GroupPreference? + public var voice: RoleGroupPreference? + public var files: RoleGroupPreference? + public var simplexLinks: RoleGroupPreference? public var history: GroupPreference? public init( timedMessages: TimedMessagesGroupPreference? = nil, - directMessages: GroupPreference? = nil, + directMessages: RoleGroupPreference? = nil, fullDelete: GroupPreference? = nil, reactions: GroupPreference? = nil, - voice: GroupPreference? = nil, - files: GroupPreference? = nil, + voice: RoleGroupPreference? = nil, + files: RoleGroupPreference? = nil, + simplexLinks: RoleGroupPreference? = nil, history: GroupPreference? = nil ) { self.timedMessages = timedMessages @@ -1018,16 +1054,18 @@ public struct GroupPreferences: Codable { self.reactions = reactions self.voice = voice self.files = files + self.simplexLinks = simplexLinks self.history = history } public static let sampleData = GroupPreferences( timedMessages: TimedMessagesGroupPreference(enable: .off), - directMessages: GroupPreference(enable: .off), + directMessages: RoleGroupPreference(enable: .off, role: nil), fullDelete: GroupPreference(enable: .off), reactions: GroupPreference(enable: .on), - voice: GroupPreference(enable: .on), - files: GroupPreference(enable: .on), + voice: RoleGroupPreference(enable: .on, role: nil), + files: RoleGroupPreference(enable: .on, role: nil), + simplexLinks: RoleGroupPreference(enable: .on, role: nil), history: GroupPreference(enable: .on) ) } @@ -1040,6 +1078,7 @@ public func toGroupPreferences(_ fullPreferences: FullGroupPreferences) -> Group reactions: fullPreferences.reactions, voice: fullPreferences.voice, files: fullPreferences.files, + simplexLinks: fullPreferences.simplexLinks, history: fullPreferences.history ) } @@ -1051,11 +1090,37 @@ public struct GroupPreference: Codable, Equatable { enable == .on } + public func enabled(_ role: GroupMemberRole?, for m: GroupMember?) -> GroupFeatureEnabled { + switch enable { + case .off: .off + case .on: + if let role, let m { + m.memberRole >= role ? .on : .off + } else { + .on + } + } + } + public init(enable: GroupFeatureEnabled) { self.enable = enable } } +public struct RoleGroupPreference: Codable, Equatable { + public var enable: GroupFeatureEnabled + public var role: GroupMemberRole? + + public func on(for m: GroupMember) -> Bool { + enable == .on && m.memberRole >= (role ?? .observer) + } + + public init(enable: GroupFeatureEnabled, role: GroupMemberRole?) { + self.enable = enable + self.role = role + } +} + public struct TimedMessagesGroupPreference: Codable, Equatable { public var enable: GroupFeatureEnabled public var ttl: Int? @@ -1280,7 +1345,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { case .timedMessages: return prefs.timedMessages.on case .fullDelete: return prefs.fullDelete.on case .reactions: return prefs.reactions.on - case .voice: return prefs.voice.on + case .voice: return prefs.voice.on(for: groupInfo.membership) case .calls: return false } case .local: @@ -1323,7 +1388,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { return .other } case let .group(groupInfo): - if !groupInfo.fullGroupPreferences.voice.on { + if !groupInfo.fullGroupPreferences.voice.on(for: groupInfo.membership) { return .groupOwnerCan } else { return .other @@ -1975,7 +2040,7 @@ public struct GroupMemberIds: Decodable { var groupId: Int64 } -public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Decodable { +public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Codable { case observer = "observer" case author = "author" case member = "member" @@ -2759,8 +2824,8 @@ public enum CIContent: Decodable, ItemContent { case sndChatFeature(feature: ChatFeature, enabled: FeatureEnabled, param: Int?) case rcvChatPreference(feature: ChatFeature, allowed: FeatureAllowed, param: Int?) case sndChatPreference(feature: ChatFeature, allowed: FeatureAllowed, param: Int?) - case rcvGroupFeature(groupFeature: GroupFeature, preference: GroupPreference, param: Int?) - case sndGroupFeature(groupFeature: GroupFeature, preference: GroupPreference, param: Int?) + case rcvGroupFeature(groupFeature: GroupFeature, preference: GroupPreference, param: Int?, memberRole_: GroupMemberRole?) + case sndGroupFeature(groupFeature: GroupFeature, preference: GroupPreference, param: Int?, memberRole_: GroupMemberRole?) case rcvChatFeatureRejected(feature: ChatFeature) case rcvGroupFeatureRejected(groupFeature: GroupFeature) case sndModerated @@ -2794,8 +2859,8 @@ public enum CIContent: Decodable, ItemContent { case let .sndChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param) case let .rcvChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param) case let .sndChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param) - case let .rcvGroupFeature(feature, preference, param): return CIContent.featureText(feature, preference.enable.text, param) - case let .sndGroupFeature(feature, preference, param): return CIContent.featureText(feature, preference.enable.text, param) + case let .rcvGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role) + case let .sndGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role) case let .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text) case let .rcvGroupFeatureRejected(groupFeature): return String.localizedStringWithFormat("%@: received, prohibited", groupFeature.text) case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item") @@ -2820,10 +2885,25 @@ public enum CIContent: Decodable, ItemContent { NSLocalizedString("This chat is protected by end-to-end encryption.", comment: "E2EE info chat item") } - static func featureText(_ feature: Feature, _ enabled: String, _ param: Int?) -> String { - feature.hasParam - ? "\(feature.text): \(timeText(param))" - : "\(feature.text): \(enabled)" + static func featureText(_ feature: Feature, _ enabled: String, _ param: Int?, _ role: GroupMemberRole? = nil) -> String { + ( + feature.hasParam + ? "\(feature.text): \(timeText(param))" + : "\(feature.text): \(enabled)" + ) + + ( + feature.hasRole && role != nil + ? " (\(roleText(role)))" + : "" + ) + } + + private static func roleText(_ role: GroupMemberRole?) -> String { + switch role { + case .owner: NSLocalizedString("owners", comment: "feature role") + case .admin: NSLocalizedString("admins", comment: "feature role") + default: NSLocalizedString("all members", comment: "feature role") + } } public static func preferenceText(_ feature: Feature, _ allowed: FeatureAllowed, _ param: Int?) -> String {