diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d822a05eb8..cd9df863d7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -89,7 +89,12 @@ jobs: cache_path: C:/cabal asset_name: simplex-chat-windows-x86-64 desktop_asset_name: simplex-desktop-windows-x86_64.msi + steps: + - name: Skip unreliable ghc 8.10.7 build on stable branch + if: matrix.ghc == '8.10.7' && github.ref == 'refs/heads/stable' + run: exit 0 + - name: Configure pagefile (Windows) if: matrix.os == 'windows-latest' uses: al-cheb/configure-pagefile-action@v1.3 diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 51be3191ec..ae9f0e3e19 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -15,12 +15,6 @@ import SimpleXChat private var chatController: chat_ctrl? -// currentChatVersion in core -public let CURRENT_CHAT_VERSION: Int = 2 - -// version range that supports establishing direct connection with a group member (xGrpDirectInvVRange in core) -public let CREATE_MEMBER_CONTACT_VRANGE = VersionRange(minVersion: 2, maxVersion: CURRENT_CHAT_VERSION) - private let networkStatusesLock = DispatchQueue(label: "chat.simplex.app.network-statuses.lock") enum TerminalItem: Identifiable { @@ -418,6 +412,18 @@ func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage] return nil } +func apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) async -> [ChatItem]? { + let r = await chatSendCmd(.apiReportMessage(groupId: groupId, chatItemId: chatItemId, reportReason: reportReason, reportText: reportText)) + if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.chatItem } } + + logger.error("apiReportMessage error: \(String(describing: r))") + AlertManager.shared.showAlertMsg( + title: "Error creating report", + message: "Error: \(responseError(r))" + ) + return nil +} + private func sendMessageErrorAlert(_ r: ChatResponse) { logger.error("send message error: \(String(describing: r))") AlertManager.shared.showAlertMsg( diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 9b71e6c4a4..6da893d1d2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -30,7 +30,17 @@ struct FramedItemView: View { var body: some View { let v = ZStack(alignment: .bottomTrailing) { VStack(alignment: .leading, spacing: 0) { - if let di = chatItem.meta.itemDeleted { + if chatItem.isReport { + if chatItem.meta.itemDeleted == nil { + let txt = chatItem.chatDir.sent ? + Text("Only you and moderators see it") : + Text("Only sender and moderators see it") + + framedItemHeader(icon: "flag", iconColor: .red, caption: txt.italic()) + } else { + framedItemHeader(icon: "flag", caption: Text("archived report").italic()) + } + } else if let di = chatItem.meta.itemDeleted { switch di { case let .moderated(_, byGroupMember): framedItemHeader(icon: "flag", caption: Text("moderated by \(byGroupMember.displayName)").italic()) @@ -144,6 +154,8 @@ struct FramedItemView: View { } case let .file(text): ciFileView(chatItem, text) + case let .report(text, reason): + ciMsgContentView(chatItem, Text(text.isEmpty ? reason.text : "\(reason.text): ").italic().foregroundColor(.red)) case let .link(_, preview): CILinkView(linkPreview: preview) ciMsgContentView(chatItem) @@ -159,13 +171,14 @@ struct FramedItemView: View { } } - @ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text, pad: Bool = false) -> some View { + @ViewBuilder func framedItemHeader(icon: String? = nil, iconColor: Color? = nil, caption: Text, pad: Bool = false) -> some View { let v = HStack(spacing: 6) { if let icon = icon { Image(systemName: icon) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 14, height: 14) + .foregroundColor(iconColor ?? theme.colors.secondary) } caption .font(.caption) @@ -228,7 +241,6 @@ struct FramedItemView: View { .overlay { if case .voice = chatItem.content.msgContent {} else { DetermineWidth() } } .frame(minWidth: msgWidth, alignment: .leading) .background(chatItemFrameContextColor(chatItem, theme)) - if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth { v.frame(maxWidth: mediaWidth, alignment: .leading) } else { @@ -281,7 +293,7 @@ struct FramedItemView: View { } } - @ViewBuilder private func ciMsgContentView(_ ci: ChatItem) -> some View { + @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, _ txtPrefix: Text? = nil) -> some View { let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text let rtl = isRightToLeft(text) let ft = text == "" ? [] : ci.formattedText @@ -291,7 +303,8 @@ struct FramedItemView: View { formattedText: ft, meta: ci.meta, rightToLeft: rtl, - showSecrets: showSecrets + showSecrets: showSecrets, + prefix: txtPrefix )) .multilineTextAlignment(rtl ? .trailing : .leading) .padding(.vertical, 6) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index c2b4021edc..87a9b2ce61 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -67,11 +67,15 @@ struct MarkedDeletedItemView: View { // same texts are in markedDeletedText in ChatPreviewView, but it returns String; // can be refactored into a single function if functions calling these are changed to return same type var markedDeletedText: LocalizedStringKey { - switch chatItem.meta.itemDeleted { - case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)" - case .blocked: "blocked" - case .blockedByAdmin: "blocked by admin" - case .deleted, nil: "marked deleted" + if chatItem.meta.itemDeleted != nil, chatItem.isReport { + "archived report" + } else { + switch chatItem.meta.itemDeleted { + case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)" + case .blocked: "blocked" + case .blockedByAdmin: "blocked by admin" + case .deleted, nil: "marked deleted" + } } } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 914f7c8a2f..e9b6d0ba84 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -34,6 +34,7 @@ struct MsgContentView: View { var meta: CIMeta? = nil var rightToLeft = false var showSecrets: Bool + var prefix: Text? = nil @State private var typingIdx = 0 @State private var timer: Timer? @@ -67,7 +68,7 @@ struct MsgContentView: View { } private func msgContentView() -> Text { - var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary) + var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary, prefix: prefix) if let mt = meta { if mt.isLive { v = v + typingIndicator(mt.recent) @@ -89,9 +90,10 @@ struct MsgContentView: View { } } -func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color) -> Text { +func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color, prefix: Text? = nil) -> Text { let s = text var res: Text + if let ft = formattedText, ft.count > 0 && ft.count <= 200 { res = formatText(ft[0], preview, showSecret: showSecrets) var i = 1 @@ -106,6 +108,10 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St if let i = icon { res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + textSpace + res } + + if let p = prefix { + res = p + res + } if let s = sender { let t = Text(s) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 90c277ce76..3444fd0723 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -917,6 +917,7 @@ struct ChatView: View { @State private var allowMenu: Bool = true @State private var markedRead = false + @State private var actionSheet: SomeActionSheet? = nil var revealed: Bool { chatItem == revealedChatItem } @@ -1001,6 +1002,7 @@ struct ChatView: View { } } } + .actionSheet(item: $actionSheet) { $0.actionSheet } } private func unreadItemIds(_ range: ClosedRange) -> [ChatItem.ID] { @@ -1208,7 +1210,7 @@ struct ChatView: View { Button("Delete for me", role: .destructive) { deleteMessage(.cidmInternal, moderate: false) } - if let di = deletingItem, di.meta.deletable && !di.localNote { + if let di = deletingItem, di.meta.deletable && !di.localNote && !di.isReport { Button(broadcastDeleteButtonText(chat), role: .destructive) { deleteMessage(.cidmBroadcast, moderate: false) } @@ -1282,7 +1284,12 @@ struct ChatView: View { @ViewBuilder private func menu(_ ci: ChatItem, _ range: ClosedRange?, live: Bool) -> some View { - if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed { + if case let .group(gInfo) = chat.chatInfo, ci.isReport, ci.meta.itemDeleted == nil { + if ci.chatDir != .groupSnd, gInfo.membership.memberRole >= .moderator { + archiveReportButton(ci) + } + deleteButton(ci, label: "Delete report") + } else if let mc = ci.content.msgContent, !ci.isReport, ci.meta.itemDeleted == nil || revealed { if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction, availableReactions.count > 0 { reactionsGroup @@ -1332,8 +1339,12 @@ struct ChatView: View { if !live || !ci.meta.isLive { deleteButton(ci) } - if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo), ci.chatDir != .groupSnd { - moderateButton(ci, groupInfo) + if ci.chatDir != .groupSnd { + if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { + moderateButton(ci, groupInfo) + } else if ci.meta.itemDeleted == nil, case let .group(gInfo) = chat.chatInfo, gInfo.membership.memberRole == .member, !live, composeState.voiceMessageRecordingState == .noRecording { + reportButton(ci) + } } } else if ci.meta.itemDeleted != nil { if revealed { @@ -1607,7 +1618,7 @@ struct ChatView: View { } } - private func deleteButton(_ ci: ChatItem) -> Button { + private func deleteButton(_ ci: ChatItem, label: LocalizedStringKey = "Delete") -> Button { Button(role: .destructive) { if !revealed, let currIndex = m.getChatItemIndex(ci), @@ -1629,10 +1640,7 @@ struct ChatView: View { deletingItem = ci } } label: { - Label( - NSLocalizedString("Delete", comment: "chat item action"), - systemImage: "trash" - ) + Label(label, systemImage: "trash") } } @@ -1651,10 +1659,10 @@ struct ChatView: View { AlertManager.shared.showAlert(Alert( title: Text("Delete member message?"), message: Text( - groupInfo.fullGroupPreferences.fullDelete.on - ? "The message will be deleted for all members." - : "The message will be marked as moderated for all members." - ), + groupInfo.fullGroupPreferences.fullDelete.on + ? "The message will be deleted for all members." + : "The message will be marked as moderated for all members." + ), primaryButton: .destructive(Text("Delete")) { deletingItem = ci deleteMessage(.cidmBroadcast, moderate: true) @@ -1668,6 +1676,24 @@ struct ChatView: View { ) } } + + private func archiveReportButton(_ cItem: ChatItem) -> Button { + Button(role: .destructive) { + AlertManager.shared.showAlert( + Alert( + title: Text("Archive report?"), + message: Text("The report will be archived for you."), + primaryButton: .destructive(Text("Archive")) { + deletingItem = cItem + deleteMessage(.cidmInternalMark, moderate: false) + }, + secondaryButton: .cancel() + ) + ) + } label: { + Label("Archive report", systemImage: "archivebox") + } + } private func revealButton(_ ci: ChatItem) -> Button { Button { @@ -1707,7 +1733,38 @@ struct ChatView: View { ) } } - + + private func reportButton(_ ci: ChatItem) -> Button { + Button(role: .destructive) { + var buttons: [ActionSheet.Button] = ReportReason.supportedReasons.map { reason in + .default(Text(reason.text)) { + withAnimation { + if composeState.editing { + composeState = ComposeState(preview: .noPreview, contextItem: .reportedItem(chatItem: chatItem, reason: reason)) + } else { + composeState = composeState.copy(preview: .noPreview, contextItem: .reportedItem(chatItem: chatItem, reason: reason)) + } + } + } + } + + buttons.append(.cancel()) + + actionSheet = SomeActionSheet( + actionSheet: ActionSheet( + title: Text("Report reason?"), + buttons: buttons + ), + id: "reportChatMessage" + ) + } label: { + Label ( + NSLocalizedString("Report", comment: "chat item action"), + systemImage: "flag" + ) + } + } + var deleteMessagesTitle: LocalizedStringKey { let n = deletingItems.count return n == 1 ? "Delete message?" : "Delete \(n) messages?" @@ -1772,7 +1829,7 @@ struct ChatView: View { } } } catch { - logger.error("ChatView.deleteMessage error: \(error.localizedDescription)") + logger.error("ChatView.deleteMessage error: \(error)") } } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 19e2b528f1..a68a4987a1 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -24,6 +24,7 @@ enum ComposeContextItem { case quotedItem(chatItem: ChatItem) case editingItem(chatItem: ChatItem) case forwardingItems(chatItems: [ChatItem], fromChatInfo: ChatInfo) + case reportedItem(chatItem: ChatItem, reason: ReportReason) } enum VoiceMessageRecordingState { @@ -116,13 +117,31 @@ struct ComposeState { default: return false } } - + + var reporting: Bool { + switch contextItem { + case .reportedItem: return true + default: return false + } + } + + var submittingValidReport: Bool { + switch contextItem { + case let .reportedItem(_, reason): + switch reason { + case .other: return !message.isEmpty + default: return true + } + default: return false + } + } + var sendEnabled: Bool { switch preview { case let .mediaPreviews(media): return !media.isEmpty case .voicePreview: return voiceMessageRecordingState == .finished case .filePreview: return true - default: return !message.isEmpty || forwarding || liveMessage != nil + default: return !message.isEmpty || forwarding || liveMessage != nil || submittingValidReport } } @@ -175,7 +194,7 @@ struct ComposeState { } var attachmentDisabled: Bool { - if editing || forwarding || liveMessage != nil || inProgress { return true } + if editing || forwarding || liveMessage != nil || inProgress || reporting { return true } switch preview { case .noPreview: return false case .linkPreview: return false @@ -193,6 +212,15 @@ struct ComposeState { } } + var placeholder: String? { + switch contextItem { + case let .reportedItem(_, reason): + return reason.text + default: + return nil + } + } + var empty: Bool { message == "" && noPreview } @@ -297,6 +325,11 @@ struct ComposeView: View { ContextInvitingContactMemberView() Divider() } + + if case let .reportedItem(_, reason) = composeState.contextItem { + reportReasonView(reason) + Divider() + } // preference checks should match checks in forwarding list let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks) let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files) @@ -686,6 +719,27 @@ struct ComposeView: View { .frame(maxWidth: .infinity, alignment: .leading) .background(.thinMaterial) } + + + private func reportReasonView(_ reason: ReportReason) -> some View { + let reportText = switch reason { + case .spam: NSLocalizedString("Report spam: only group moderators will see it.", comment: "report reason") + case .profile: NSLocalizedString("Report member profile: only group moderators will see it.", comment: "report reason") + case .community: NSLocalizedString("Report violation: only group moderators will see it.", comment: "report reason") + case .illegal: NSLocalizedString("Report content: only group moderators will see it.", comment: "report reason") + case .other: NSLocalizedString("Report other: only group moderators will see it.", comment: "report reason") + case .unknown: "" // Should never happen + } + + return Text(reportText) + .italic() + .font(.caption) + .padding(12) + .frame(minHeight: 44) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial) + } + @ViewBuilder private func contextItemView() -> some View { switch composeState.contextItem { @@ -715,6 +769,15 @@ struct ComposeView: View { cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } ) Divider() + case let .reportedItem(chatItem: reportedItem, _): + ContextItemView( + chat: chat, + contextItems: [reportedItem], + contextIcon: "flag", + cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }, + contextIconForeground: Color.red + ) + Divider() } } @@ -746,6 +809,8 @@ struct ComposeView: View { sent = await updateMessage(ci, live: live) } else if let liveMessage = liveMessage, liveMessage.sentMsg != nil { sent = await updateMessage(liveMessage.chatItem, live: live) + } else if case let .reportedItem(chatItem, reason) = composeState.contextItem { + sent = await send(reason, chatItemId: chatItem.id) } else { var quoted: Int64? = nil if case let .quotedItem(chatItem: quotedItem) = composeState.contextItem { @@ -872,6 +937,8 @@ struct ComposeView: View { return .voice(text: msgText, duration: duration) case .file: return .file(msgText) + case .report(_, let reason): + return .report(text: msgText, reason: reason) case .unknown(let type, _): return .unknown(type: type, text: msgText) } @@ -891,7 +958,25 @@ struct ComposeView: View { return nil } } - + + func send(_ reportReason: ReportReason, chatItemId: Int64) async -> ChatItem? { + if let chatItems = await apiReportMessage( + groupId: chat.chatInfo.apiId, + chatItemId: chatItemId, + reportReason: reportReason, + reportText: msgText + ) { + await MainActor.run { + for chatItem in chatItems { + chatModel.addChatItem(chat.chatInfo, chatItem) + } + } + return chatItems.first + } + + return nil + } + func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { await send( [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)], diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index fa999961fc..3cb747ec68 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -15,6 +15,7 @@ struct ContextItemView: View { let contextItems: [ChatItem] let contextIcon: String let cancelContextItem: () -> Void + var contextIconForeground: Color? = nil var showSender: Bool = true var body: some View { @@ -23,7 +24,7 @@ struct ContextItemView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 16, height: 16) - .foregroundColor(theme.colors.secondary) + .foregroundColor(contextIconForeground ?? theme.colors.secondary) if let singleItem = contextItems.first, contextItems.count == 1 { if showSender, let sender = singleItem.memberDisplayName { VStack(alignment: .leading, spacing: 4) { @@ -93,6 +94,6 @@ struct ContextItemView: View { struct ContextItemView_Previews: PreviewProvider { static var previews: some View { let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello") - return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {}) + return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {}, contextIconForeground: Color.red) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index ad47b7351a..2fc122f249 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -16,6 +16,7 @@ struct NativeTextEditor: UIViewRepresentable { @Binding var disableEditing: Bool @Binding var height: CGFloat @Binding var focused: Bool + @Binding var placeholder: String? let onImagesAdded: ([UploadContent]) -> Void private let minHeight: CGFloat = 37 @@ -50,6 +51,7 @@ struct NativeTextEditor: UIViewRepresentable { field.setOnFocusChangedListener { focused = $0 } field.delegate = field field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) + field.setPlaceholderView() updateFont(field) updateHeight(field) return field @@ -62,6 +64,11 @@ struct NativeTextEditor: UIViewRepresentable { updateFont(field) updateHeight(field) } + + let castedField = field as! CustomUITextField + if castedField.placeholder != placeholder { + castedField.placeholder = placeholder + } } private func updateHeight(_ field: UITextView) { @@ -97,11 +104,18 @@ private class CustomUITextField: UITextView, UITextViewDelegate { var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in } var onFocusChanged: (Bool) -> Void = { focused in } + private let placeholderLabel: UILabel = UILabel() + init(height: Binding) { self.height = height super.init(frame: .zero, textContainer: nil) } + var placeholder: String? { + get { placeholderLabel.text } + set { placeholderLabel.text = newValue } + } + required init?(coder: NSCoder) { fatalError("Not implemented") } @@ -124,6 +138,20 @@ private class CustomUITextField: UITextView, UITextViewDelegate { func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) { self.onTextChanged = onTextChanged } + + func setPlaceholderView() { + placeholderLabel.textColor = .lightGray + placeholderLabel.font = UIFont.preferredFont(forTextStyle: .body) + placeholderLabel.isHidden = !text.isEmpty + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(placeholderLabel) + + NSLayoutConstraint.activate([ + placeholderLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 7), + placeholderLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -7), + placeholderLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8) + ]) + } func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) { self.onFocusChanged = onFocusChanged @@ -172,6 +200,7 @@ private class CustomUITextField: UITextView, UITextViewDelegate { } func textViewDidChange(_ textView: UITextView) { + placeholderLabel.isHidden = !text.isEmpty if textView.markedTextRange == nil { var images: [UploadContent] = [] var rangeDiff = 0 @@ -217,6 +246,7 @@ struct NativeTextEditor_Previews: PreviewProvider{ disableEditing: Binding.constant(false), height: Binding.constant(100), focused: Binding.constant(false), + placeholder: Binding.constant("Placeholder"), onImagesAdded: { _ in } ) .fixedSize(horizontal: false, vertical: true) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 8880023e02..fb69dfdd17 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -61,6 +61,7 @@ struct SendMessageView: View { disableEditing: $composeState.inProgress, height: $teHeight, focused: $keyboardVisible, + placeholder: Binding(get: { composeState.placeholder }, set: { _ in }), onImagesAdded: onMediaAdded ) .allowsTightening(false) @@ -105,6 +106,8 @@ struct SendMessageView: View { let vmrs = composeState.voiceMessageRecordingState if nextSendGrpInv { inviteMemberContactButton() + } else if case .reportedItem = composeState.contextItem { + sendMessageButton() } else if showVoiceMessageButton && composeState.message.isEmpty && !composeState.editing diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index bdef8d0a62..66fe67a29e 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -175,10 +175,8 @@ struct AddGroupMembersViewCommon: View { private func rolePicker() -> some View { Picker("New member role", selection: $selectedRole) { - ForEach(GroupMemberRole.allCases) { role in - if role <= groupInfo.membership.memberRole && role != .author { - Text(role.text) - } + ForEach(GroupMemberRole.supportedRoles.filter({ $0 <= groupInfo.membership.memberRole })) { role in + Text(role.text) } } .frame(height: 36) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index a18de1b349..78ea394caf 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -296,7 +296,7 @@ struct GroupMemberInfoView: View { } else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) { if let contactId = member.memberContactId { newDirectChatButton(contactId, width: buttonWidth) - } else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false { + } else if member.versionRange.maxVersion >= CREATE_MEMBER_CONTACT_VERSION { createMemberContactButton(width: buttonWidth) } InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert() diff --git a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift index 7b185d8211..81498ee497 100644 --- a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift +++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift @@ -116,10 +116,10 @@ struct SelectedItemsBottomToolbar: View { if selected.contains(ci.id) { var (de, dee, me, onlyOwnGroupItems, fe, sel) = r de = de && ci.canBeDeletedForSelf - dee = dee && ci.meta.deletable && !ci.localNote - onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd - me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil - fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy + dee = dee && ci.meta.deletable && !ci.localNote && !ci.isReport + onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd && !ci.isReport + me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil && !ci.isReport + fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy && !ci.isReport sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list return (de, dee, me, onlyOwnGroupItems, fe, sel) } else { diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 13701a40a2..ff5fb2986b 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -248,16 +248,20 @@ struct ChatPreviewView: View { func chatItemPreview(_ cItem: ChatItem) -> Text { let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText() let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil - return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary) + return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary, prefix: prefix()) // same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey; // can be refactored into a single function if functions calling these are changed to return same type func markedDeletedText() -> String { - switch cItem.meta.itemDeleted { - case let .moderated(_, byGroupMember): String.localizedStringWithFormat(NSLocalizedString("moderated by %@", comment: "marked deleted chat item preview text"), byGroupMember.displayName) - case .blocked: NSLocalizedString("blocked", comment: "marked deleted chat item preview text") - case .blockedByAdmin: NSLocalizedString("blocked by admin", comment: "marked deleted chat item preview text") - case .deleted, nil: NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text") + if cItem.meta.itemDeleted != nil, cItem.isReport { + "archived report" + } else { + switch cItem.meta.itemDeleted { + case let .moderated(_, byGroupMember): String.localizedStringWithFormat(NSLocalizedString("moderated by %@", comment: "marked deleted chat item preview text"), byGroupMember.displayName) + case .blocked: NSLocalizedString("blocked", comment: "marked deleted chat item preview text") + case .blockedByAdmin: NSLocalizedString("blocked by admin", comment: "marked deleted chat item preview text") + case .deleted, nil: NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text") + } } } @@ -270,6 +274,13 @@ struct ChatPreviewView: View { default: return nil } } + + func prefix() -> Text { + switch cItem.content.msgContent { + case let .report(_, reason): return Text(!itemText.isEmpty ? "\(reason.text): " : reason.text).italic().foregroundColor(Color.red) + default: return Text("") + } + } } @ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View { diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index cea9dd0635..24da6a94a8 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -53,7 +53,7 @@ struct OperatorView: View { ServersErrorView(errStr: errStr) } else { switch (userServers[operatorIndex].operator_.conditionsAcceptance) { - case let .accepted(acceptedAt): + case let .accepted(acceptedAt, _): if let acceptedAt = acceptedAt { Text("Conditions accepted on: \(conditionsTimestamp(acceptedAt)).") .foregroundColor(theme.colors.secondary) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 759b16b196..33b0642d2b 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,9 +167,9 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a */; }; 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -516,9 +516,9 @@ 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a"; sourceTree = ""; }; 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -671,9 +671,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -754,8 +754,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a */, ); path = Libraries; sourceTree = ""; @@ -1931,7 +1931,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 257; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1956,7 +1956,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1980,7 +1980,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 257; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2005,7 +2005,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -2021,11 +2021,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 257; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2041,11 +2041,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 257; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2066,7 +2066,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 257; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2081,7 +2081,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2103,7 +2103,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 257; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2118,7 +2118,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2140,7 +2140,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 257; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2166,7 +2166,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2191,7 +2191,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 257; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2217,7 +2217,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2242,7 +2242,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 257; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2257,7 +2257,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2276,7 +2276,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 257; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2291,7 +2291,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 884993f542..71a5132d12 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -45,6 +45,7 @@ public enum ChatCommand { case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) + case apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool) case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64]) @@ -209,6 +210,8 @@ public enum ChatCommand { case let .apiCreateChatItems(noteFolderId, composedMessages): let msgs = encodeJSON(composedMessages) return "/_create *\(noteFolderId) json \(msgs)" + case let .apiReportMessage(groupId, chatItemId, reportReason, reportText): + return "/_report #\(groupId) \(chatItemId) reason=\(reportReason) \(reportText)" case let .apiUpdateChatItem(type, id, itemId, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)" case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))" @@ -372,6 +375,7 @@ public enum ChatCommand { case .apiGetChatItemInfo: return "apiGetChatItemInfo" case .apiSendMessages: return "apiSendMessages" case .apiCreateChatItems: return "apiCreateChatItems" + case .apiReportMessage: return "apiReportMessage" case .apiUpdateChatItem: return "apiUpdateChatItem" case .apiDeleteChatItem: return "apiDeleteChatItem" case .apiConnectContactViaAddress: return "apiConnectContactViaAddress" @@ -1162,12 +1166,14 @@ public enum ChatPagination { case last(count: Int) case after(chatItemId: Int64, count: Int) case before(chatItemId: Int64, count: Int) + case around(chatItemId: Int64, count: Int) var cmdString: String { switch self { case let .last(count): return "count=\(count)" case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)" case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)" + case let .around(chatItemId, count): return "around=\(chatItemId) count=\(count)" } } } @@ -1290,7 +1296,7 @@ public struct ServerOperatorConditions: Decodable { } public enum ConditionsAcceptance: Equatable, Codable, Hashable { - case accepted(acceptedAt: Date?) + case accepted(acceptedAt: Date?, autoAccepted: Bool) // If deadline is present, it means there's a grace period to review and accept conditions during which user can continue to use the operator. // No deadline indicates it's required to accept conditions for the operator to start using it. case required(deadline: Date?) @@ -1364,7 +1370,7 @@ public struct ServerOperator: Identifiable, Equatable, Codable { tradeName: "SimpleX Chat", legalName: "SimpleX Chat Ltd", serverDomains: ["simplex.im"], - conditionsAcceptance: .accepted(acceptedAt: nil), + conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false), enabled: true, smpRoles: ServerRoles(storage: true, proxy: true), xftpRoles: ServerRoles(storage: true, proxy: true) @@ -1397,7 +1403,7 @@ public struct UserOperatorServers: Identifiable, Equatable, Codable { tradeName: "", legalName: "", serverDomains: [], - conditionsAcceptance: .accepted(acceptedAt: nil), + conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false), enabled: false, smpRoles: ServerRoles(storage: true, proxy: true), xftpRoles: ServerRoles(storage: true, proxy: true) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index da1ce24b73..f64a1076a5 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -9,6 +9,12 @@ import Foundation import SwiftUI +// version to establishing direct connection with a group member (xGrpDirectInvVRange in core) +public let CREATE_MEMBER_CONTACT_VERSION = 2 + +// version to receive reports (MCReport) +public let REPORTS_VERSION = 12 + public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var userId: Int64 public var agentUserId: String @@ -1678,7 +1684,7 @@ public struct Connection: Decodable, Hashable { static let sampleData = Connection( connId: 1, agentConnId: "abc", - peerChatVRange: VersionRange(minVersion: 1, maxVersion: 1), + peerChatVRange: VersionRange(1, 1), connStatus: .ready, connLevel: 0, viaGroupLink: false, @@ -1690,17 +1696,13 @@ public struct Connection: Decodable, Hashable { } public struct VersionRange: Decodable, Hashable { - public init(minVersion: Int, maxVersion: Int) { + public init(_ minVersion: Int, _ maxVersion: Int) { self.minVersion = minVersion self.maxVersion = maxVersion } public var minVersion: Int public var maxVersion: Int - - public func isCompatibleRange(_ vRange: VersionRange) -> Bool { - self.minVersion <= vRange.maxVersion && vRange.minVersion <= self.maxVersion - } } public struct SecurityCode: Decodable, Equatable, Hashable { @@ -1752,7 +1754,7 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable { public static let sampleData = UserContactRequest( contactRequestId: 1, userContactLinkId: 1, - cReqChatVRange: VersionRange(minVersion: 1, maxVersion: 1), + cReqChatVRange: VersionRange(1, 1), localDisplayName: "alice", profile: Profile.sampleData, createdAt: .now, @@ -1989,6 +1991,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var memberContactId: Int64? public var memberContactProfileId: Int64 public var activeConn: Connection? + public var memberChatVRange: VersionRange public var id: String { "#\(groupId) @\(groupMemberId)" } public var displayName: String { @@ -2075,7 +2078,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? { if !canBeRemoved(groupInfo: groupInfo) { return nil } let userRole = groupInfo.membership.memberRole - return GroupMemberRole.allCases.filter { $0 <= userRole && $0 != .author } + return GroupMemberRole.supportedRoles.filter { $0 <= userRole } } public func canBlockForAll(groupInfo: GroupInfo) -> Bool { @@ -2083,7 +2086,19 @@ public struct GroupMember: Identifiable, Decodable, Hashable { return memberStatus != .memRemoved && memberStatus != .memLeft && memberRole < .admin && userRole >= .admin && userRole >= memberRole && groupInfo.membership.memberActive } + + public var canReceiveReports: Bool { + memberRole >= .moderator && versionRange.maxVersion >= REPORTS_VERSION + } + public var versionRange: VersionRange { + if let activeConn { + activeConn.peerChatVRange + } else { + memberChatVRange + } + } + public var memberIncognito: Bool { memberProfile.profileId != memberContactProfileId } @@ -2102,7 +2117,8 @@ public struct GroupMember: Identifiable, Decodable, Hashable { memberProfile: LocalProfile.sampleData, memberContactId: 1, memberContactProfileId: 1, - activeConn: Connection.sampleData + activeConn: Connection.sampleData, + memberChatVRange: VersionRange(2, 12) ) } @@ -2121,19 +2137,23 @@ public struct GroupMemberIds: Decodable, Hashable { } public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Codable, Hashable { - case observer = "observer" - case author = "author" - case member = "member" - case admin = "admin" - case owner = "owner" + case observer + case author + case member + case moderator + case admin + case owner public var id: Self { self } + public static var supportedRoles: [GroupMemberRole] = [.observer, .member, .admin, .owner] + public var text: String { switch self { case .observer: return NSLocalizedString("observer", comment: "member role") case .author: return NSLocalizedString("author", comment: "member role") case .member: return NSLocalizedString("member", comment: "member role") + case .moderator: return NSLocalizedString("moderator", comment: "member role") case .admin: return NSLocalizedString("admin", comment: "member role") case .owner: return NSLocalizedString("owner", comment: "member role") } @@ -2141,11 +2161,12 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Cod private var comparisonValue: Int { switch self { - case .observer: return 0 - case .author: return 1 - case .member: return 2 - case .admin: return 3 - case .owner: return 4 + case .observer: 0 + case .author: 1 + case .member: 2 + case .moderator: 3 + case .admin: 4 + case .owner: 5 } } @@ -2551,6 +2572,17 @@ public struct ChatItem: Identifiable, Decodable, Hashable { default: return true } } + + public var isReport: Bool { + switch content { + case let .sndMsgContent(msgContent), let .rcvMsgContent(msgContent): + switch msgContent { + case .report: true + default: false + } + default: false + } + } public var canBeDeletedForSelf: Bool { (content.msgContent != nil && !meta.isLive) || meta.itemDeleted != nil || isDeletedContent || mergeCategory != nil || showLocalDelete @@ -2636,6 +2668,34 @@ public struct ChatItem: Identifiable, Decodable, Hashable { file: nil ) } + + public static func getReportSample(text: String, reason: ReportReason, item: ChatItem, sender: GroupMember? = nil) -> ChatItem { + let chatDir = if let sender = sender { + CIDirection.groupRcv(groupMember: sender) + } else { + CIDirection.groupSnd + } + + return ChatItem( + chatDir: chatDir, + meta: CIMeta( + itemId: -2, + itemTs: .now, + itemText: "", + itemStatus: .rcvRead, + createdAt: .now, + updatedAt: .now, + itemDeleted: nil, + itemEdited: false, + itemLive: false, + deletable: false, + editable: false + ), + content: .sndMsgContent(msgContent: .report(text: text, reason: reason)), + quotedItem: CIQuote.getSample(item.id, item.meta.createdAt, item.text, chatDir: item.chatDir), + file: nil + ) + } public static func deletedItemDummy() -> ChatItem { ChatItem( @@ -3075,6 +3135,7 @@ public enum CIForwardedFrom: Decodable, Hashable { public enum CIDeleteMode: String, Decodable, Hashable { case cidmBroadcast = "broadcast" case cidmInternal = "internal" + case cidmInternalMark = "internalMark" } protocol ItemContent { @@ -3249,14 +3310,12 @@ public struct CIQuote: Decodable, ItemContent, Hashable { public var sentAt: Date public var content: MsgContent public var formattedText: [FormattedText]? - public var text: String { switch (content.text, content) { case let ("", .voice(_, duration)): return durationText(duration) default: return content.text } } - public func getSender(_ membership: GroupMember?) -> String? { switch (chatDir) { case .directSnd: return "you" @@ -3619,6 +3678,7 @@ public enum MsgContent: Equatable, Hashable { case video(text: String, image: String, duration: Int) case voice(text: String, duration: Int) case file(String) + case report(text: String, reason: ReportReason) // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift case unknown(type: String, text: String) @@ -3630,6 +3690,7 @@ public enum MsgContent: Equatable, Hashable { case let .video(text, _, _): return text case let .voice(text, _): return text case let .file(text): return text + case let .report(text, _): return text case let .unknown(_, text): return text } } @@ -3689,6 +3750,7 @@ public enum MsgContent: Equatable, Hashable { case preview case image case duration + case reason } public static func == (lhs: MsgContent, rhs: MsgContent) -> Bool { @@ -3699,6 +3761,7 @@ public enum MsgContent: Equatable, Hashable { case let (.video(lt, li, ld), .video(rt, ri, rd)): return lt == rt && li == ri && ld == rd case let (.voice(lt, ld), .voice(rt, rd)): return lt == rt && ld == rd case let (.file(lf), .file(rf)): return lf == rf + case let (.report(lt, lr), .report(rt, rr)): return lt == rt && lr == rr case let (.unknown(lType, lt), .unknown(rType, rt)): return lType == rType && lt == rt default: return false } @@ -3734,6 +3797,10 @@ extension MsgContent: Decodable { case "file": let text = try container.decode(String.self, forKey: CodingKeys.text) self = .file(text) + case "report": + let text = try container.decode(String.self, forKey: CodingKeys.text) + let reason = try container.decode(ReportReason.self, forKey: CodingKeys.reason) + self = .report(text: text, reason: reason) default: let text = try? container.decode(String.self, forKey: CodingKeys.text) self = .unknown(type: type, text: text ?? "unknown message format") @@ -3771,6 +3838,10 @@ extension MsgContent: Encodable { case let .file(text): try container.encode("file", forKey: .type) try container.encode(text, forKey: .text) + case let .report(text, reason): + try container.encode("report", forKey: .type) + try container.encode(text, forKey: .text) + try container.encode(reason, forKey: .reason) // TODO use original JSON and type case let .unknown(_, text): try container.encode("text", forKey: .type) @@ -3850,6 +3921,57 @@ public enum FormatColor: String, Decodable, Hashable { } } +public enum ReportReason: Hashable { + case spam + case illegal + case community + case profile + case other + case unknown(type: String) + + public static var supportedReasons: [ReportReason] = [.spam, .illegal, .community, .profile, .other] + + public var text: String { + switch self { + case .spam: return NSLocalizedString("Spam", comment: "report reason") + case .illegal: return NSLocalizedString("Inappropriate content", comment: "report reason") + case .community: return NSLocalizedString("Community guidelines violation", comment: "report reason") + case .profile: return NSLocalizedString("Inappropriate profile", comment: "report reason") + case .other: return NSLocalizedString("Another reason", comment: "report reason") + case let .unknown(type): return type + } + } +} + +extension ReportReason: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .spam: try container.encode("spam") + case .illegal: try container.encode("illegal") + case .community: try container.encode("community") + case .profile: try container.encode("profile") + case .other: try container.encode("other") + case let .unknown(type): try container.encode(type) + } + } +} + +extension ReportReason: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let type = try container.decode(String.self) + switch type { + case "spam": self = .spam + case "illegal": self = .illegal + case "community": self = .community + case "profile": self = .profile + case "other": self = .other + default: self = .unknown(type: type) + } + } +} + // Struct to use with simplex API public struct LinkPreview: Codable, Equatable, Hashable { public init(uri: URL, title: String, description: String = "", image: String) { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt index cb50336fce..6ca8dd43a0 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt @@ -470,53 +470,65 @@ class SimplexService: Service() { ) } - private fun showBGServiceNoticeIgnoreOptimization(mode: NotificationsMode, showOffAlert: Boolean) = AlertManager.shared.showAlert { - val ignoreOptimization = { - AlertManager.shared.hideAlert() - askAboutIgnoringBatteryOptimization() + private var showingIgnoreNotification = false + private fun showBGServiceNoticeIgnoreOptimization(mode: NotificationsMode, showOffAlert: Boolean) { + // that's workaround for situation when the app receives onPause/onResume events multiple times + // (for example, after showing system alert for enabling notifications) which triggers showing that alert multiple times + if (showingIgnoreNotification) { + return } - val disableNotifications = { - AlertManager.shared.hideAlert() - disableNotifications(mode, showOffAlert) - } - AlertDialog( - onDismissRequest = disableNotifications, - title = { - Row { - Icon( - painterResource(MR.images.ic_bolt), - contentDescription = - if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.icon_descr_instant_notifications) else stringResource(MR.strings.periodic_notifications), - ) - Text( - if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.service_notifications) else stringResource(MR.strings.periodic_notifications), - fontWeight = FontWeight.Bold - ) - } - }, - text = { - Column { - Text( - if (mode == NotificationsMode.SERVICE) annotatedStringResource(MR.strings.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery) else annotatedStringResource(MR.strings.periodic_notifications_desc), - Modifier.padding(bottom = 8.dp) - ) - Text(annotatedStringResource(MR.strings.turn_off_battery_optimization)) - - if (platform.androidIsXiaomiDevice() && (mode == NotificationsMode.PERIODIC || mode == NotificationsMode.SERVICE)) { - Text(annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization), - Modifier.padding(top = 8.dp) + showingIgnoreNotification = true + AlertManager.shared.showAlert { + val ignoreOptimization = { + AlertManager.shared.hideAlert() + showingIgnoreNotification = false + askAboutIgnoringBatteryOptimization() + } + val disableNotifications = { + AlertManager.shared.hideAlert() + showingIgnoreNotification = false + disableNotifications(mode, showOffAlert) + } + AlertDialog( + onDismissRequest = disableNotifications, + title = { + Row { + Icon( + painterResource(MR.images.ic_bolt), + contentDescription = + if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.icon_descr_instant_notifications) else stringResource(MR.strings.periodic_notifications), + ) + Text( + if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.service_notifications) else stringResource(MR.strings.periodic_notifications), + fontWeight = FontWeight.Bold ) } - } - }, - dismissButton = { - TextButton(onClick = disableNotifications) { Text(stringResource(MR.strings.disable_notifications_button), color = MaterialTheme.colors.error) } - }, - confirmButton = { - TextButton(onClick = ignoreOptimization) { Text(stringResource(MR.strings.turn_off_battery_optimization_button)) } - }, - shape = RoundedCornerShape(corner = CornerSize(25.dp)) - ) + }, + text = { + Column { + Text( + if (mode == NotificationsMode.SERVICE) annotatedStringResource(MR.strings.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery) else annotatedStringResource(MR.strings.periodic_notifications_desc), + Modifier.padding(bottom = 8.dp) + ) + Text(annotatedStringResource(MR.strings.turn_off_battery_optimization)) + + if (platform.androidIsXiaomiDevice() && (mode == NotificationsMode.PERIODIC || mode == NotificationsMode.SERVICE)) { + Text( + annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization), + Modifier.padding(top = 8.dp) + ) + } + } + }, + dismissButton = { + TextButton(onClick = disableNotifications) { Text(stringResource(MR.strings.disable_notifications_button), color = MaterialTheme.colors.error) } + }, + confirmButton = { + TextButton(onClick = ignoreOptimization) { Text(stringResource(MR.strings.turn_off_battery_optimization_button)) } + }, + shape = RoundedCornerShape(corner = CornerSize(25.dp)) + ) + } } private fun showBGServiceNoticeSystemRestricted(mode: NotificationsMode, showOffAlert: Boolean) = AlertManager.shared.showAlert { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt index a5a1726757..995b584fce 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt @@ -99,7 +99,8 @@ class CallActivity: ComponentActivity(), ServiceConnection { fun setPipParams(video: Boolean, sourceRectHint: Rect? = null, viewRatio: Rational? = null) { // By manually specifying source rect we exclude empty background while toggling PiP val builder = PictureInPictureParams.Builder() - .setAspectRatio(viewRatio) + // that's limitation of Android. Otherwise, may crash on devices like Z Fold 3 + .setAspectRatio(viewRatio?.coerceIn(Rational(100, 239)..Rational(239, 100))) .setSourceRectHint(sourceRectHint) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { builder.setAutoEnterEnabled(video) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt index a39e71947d..0378fcbd7a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt @@ -4,19 +4,31 @@ import android.Manifest import android.os.Build import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import chat.simplex.common.platform.ntfManager -import com.google.accompanist.permissions.PermissionStatus -import com.google.accompanist.permissions.rememberPermissionState +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.* +import com.google.accompanist.permissions.* @Composable actual fun SetNotificationsModeAdditions() { if (Build.VERSION.SDK_INT >= 33) { val notificationsPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) LaunchedEffect(notificationsPermissionState.status == PermissionStatus.Granted) { - if (notificationsPermissionState.status == PermissionStatus.Granted) { - ntfManager.androidCreateNtfChannelsMaybeShowAlert() + val canAsk = appPrefs.canAskToEnableNotifications.get() + if (notificationsPermissionState.status is PermissionStatus.Denied) { + if (notificationsPermissionState.status.shouldShowRationale || !canAsk) { + if (canAsk) { + appPrefs.canAskToEnableNotifications.set(false) + } + Log.w(TAG, "Notifications are disabled and nobody will ask to enable them") + } else { + notificationsPermissionState.launchPermissionRequest() + } } else { - notificationsPermissionState.launchPermissionRequest() + if (!canAsk) { + // the user allowed notifications in system alert or manually in settings, allow to ask him next time if needed + appPrefs.canAskToEnableNotifications.set(true) + } + ntfManager.androidCreateNtfChannelsMaybeShowAlert() } } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index fc17c49c7e..1542c35e58 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -114,7 +114,7 @@ fun MainScreen() { @Composable fun AuthView() { - Surface(color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { Box( Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -223,7 +223,7 @@ fun MainScreen() { if (chatModel.controller.appPrefs.performLA.get() && AppLock.laFailed.value) { AuthView() } else { - SplashView() + SplashView(true) ModalManager.fullscreen.showPasscodeInView() } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt index c93fabec8b..d6f9640cb9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt @@ -113,7 +113,7 @@ object AppLock { val appPrefs = ChatController.appPrefs ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( submit = { ChatModel.showAuthScreen.value = true 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 d407174e52..96f12b9ce9 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 @@ -1383,11 +1383,7 @@ data class Connection( } @Serializable -data class VersionRange(val minVersion: Int, val maxVersion: Int) { - - fun isCompatibleRange(vRange: VersionRange): Boolean = - this.minVersion <= vRange.maxVersion && vRange.minVersion <= this.maxVersion -} +data class VersionRange(val minVersion: Int, val maxVersion: Int) @Serializable data class SecurityCode(val securityCode: String, val verifiedAt: Instant) @@ -1642,7 +1638,7 @@ data class GroupMember ( fun canChangeRoleTo(groupInfo: GroupInfo): List? = if (!canBeRemoved(groupInfo)) null else groupInfo.membership.memberRole.let { userRole -> - GroupMemberRole.values().filter { it <= userRole && it != GroupMemberRole.Author } + GroupMemberRole.selectableRoles.filter { it <= userRole } } fun canBlockForAll(groupInfo: GroupInfo): Boolean { @@ -1693,13 +1689,19 @@ enum class GroupMemberRole(val memberRole: String) { @SerialName("observer") Observer("observer"), // order matters in comparisons @SerialName("author") Author("author"), @SerialName("member") Member("member"), + @SerialName("moderator") Moderator("moderator"), @SerialName("admin") Admin("admin"), @SerialName("owner") Owner("owner"); + companion object { + val selectableRoles: List = listOf(Observer, Member, Admin, Owner) + } + val text: String get() = when (this) { Observer -> generalGetString(MR.strings.group_member_role_observer) Author -> generalGetString(MR.strings.group_member_role_author) Member -> generalGetString(MR.strings.group_member_role_member) + Moderator -> generalGetString(MR.strings.group_member_role_moderator) Admin -> generalGetString(MR.strings.group_member_role_admin) Owner -> generalGetString(MR.strings.group_member_role_owner) } @@ -2120,6 +2122,12 @@ data class ChatItem ( else -> true } + val isReport: Boolean get() = when (content) { + is CIContent.SndMsgContent, is CIContent.RcvMsgContent -> + content.msgContent is MsgContent.MCReport + else -> false + } + val canBeDeletedForSelf: Boolean get() = (content.msgContent != null && !meta.isLive) || meta.itemDeleted != null || isDeletedContent || mergeCategory != null || showLocalDelete @@ -2540,7 +2548,7 @@ fun getTimestampDateText(t: Instant): String { val time = t.toLocalDateTime(tz).toJavaLocalDateTime() val weekday = time.format(DateTimeFormatter.ofPattern("EEE")) val dayMonthYear = time.format(DateTimeFormatter.ofPattern( - if (Clock.System.now().toLocalDateTime(tz).year == time.year) "d MMM" else "d MMM YYYY") + if (Clock.System.now().toLocalDateTime(tz).year == time.year) "d MMM" else "d MMM yyyy") ) return "$weekday, $dayMonthYear" @@ -2772,6 +2780,7 @@ sealed class CIForwardedFrom { @Serializable enum class CIDeleteMode(val deleteMode: String) { @SerialName("internal") cidmInternal("internal"), + @SerialName("internalMark") cidmInternalMark("internalMark"), @SerialName("broadcast") cidmBroadcast("broadcast"); } @@ -3320,6 +3329,7 @@ sealed class MsgContent { @Serializable(with = MsgContentSerializer::class) class MCVideo(override val text: String, val image: String, val duration: Int): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent() + @Serializable(with = MsgContentSerializer::class) class MCReport(override val text: String, val reason: ReportReason): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent() val isVoice: Boolean get() = @@ -3396,6 +3406,10 @@ object MsgContentSerializer : KSerializer { element("MCFile", buildClassSerialDescriptor("MCFile") { element("text") }) + element("MCReport", buildClassSerialDescriptor("MCReport") { + element("text") + element("reason") + }) element("MCUnknown", buildClassSerialDescriptor("MCUnknown")) } @@ -3426,6 +3440,10 @@ object MsgContentSerializer : KSerializer { MsgContent.MCVoice(text, duration) } "file" -> MsgContent.MCFile(text) + "report" -> { + val reason = Json.decodeFromString(json["reason"].toString()) + MsgContent.MCReport(text, reason) + } else -> MsgContent.MCUnknown(t, text, json) } } else { @@ -3474,6 +3492,12 @@ object MsgContentSerializer : KSerializer { put("type", "file") put("text", value.text) } + is MsgContent.MCReport -> + buildJsonObject { + put("type", "report") + put("text", value.text) + put("reason", json.encodeToJsonElement(value.reason)) + } is MsgContent.MCUnknown -> value.json } encoder.encodeJsonElement(json) @@ -3568,6 +3592,58 @@ enum class FormatColor(val color: String) { } } + +@Serializable(with = ReportReasonSerializer::class) +sealed class ReportReason { + @Serializable @SerialName("spam") object Spam: ReportReason() + @Serializable @SerialName("illegal") object Illegal: ReportReason() + @Serializable @SerialName("community") object Community: ReportReason() + @Serializable @SerialName("profile") object Profile: ReportReason() + @Serializable @SerialName("other") object Other: ReportReason() + @Serializable @SerialName("unknown") data class Unknown(val type: String): ReportReason() + + companion object { + val supportedReasons: List = listOf(Spam, Illegal, Community, Profile, Other) + } + + val text: String get() = when (this) { + Spam -> generalGetString(MR.strings.report_reason_spam) + Illegal -> generalGetString(MR.strings.report_reason_illegal) + Community -> generalGetString(MR.strings.report_reason_community) + Profile -> generalGetString(MR.strings.report_reason_profile) + Other -> generalGetString(MR.strings.report_reason_other) + is Unknown -> type + } +} + +object ReportReasonSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("ReportReason", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): ReportReason { + return when (val value = decoder.decodeString()) { + "spam" -> ReportReason.Spam + "illegal" -> ReportReason.Illegal + "community" -> ReportReason.Community + "profile" -> ReportReason.Profile + "other" -> ReportReason.Other + else -> ReportReason.Unknown(value) + } + } + + override fun serialize(encoder: Encoder, value: ReportReason) { + val stringValue = when (value) { + is ReportReason.Spam -> "spam" + is ReportReason.Illegal -> "illegal" + is ReportReason.Community -> "community" + is ReportReason.Profile -> "profile" + is ReportReason.Other -> "other" + is ReportReason.Unknown -> value.type + } + encoder.encodeString(stringValue) + } +} + @Serializable class SndFileTransfer() {} 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 08051927fd..6400d2d2d9 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 @@ -46,11 +46,8 @@ import java.util.Date typealias ChatCtrl = Long -// currentChatVersion in core -const val CURRENT_CHAT_VERSION: Int = 2 - // version range that supports establishing direct connection with a group member (xGrpDirectInvVRange in core) -val CREATE_MEMBER_CONTACT_VRANGE = VersionRange(minVersion = 2, maxVersion = CURRENT_CHAT_VERSION) +val CREATE_MEMBER_CONTACT_VERSION = 2 enum class CallOnLockScreen { DISABLE, @@ -80,6 +77,7 @@ class AppPreferences { if (!runServiceInBackground.get()) NotificationsMode.OFF else NotificationsMode.default ) { NotificationsMode.values().firstOrNull { it.name == this } } val notificationPreviewMode = mkStrPreference(SHARED_PREFS_NOTIFICATION_PREVIEW_MODE, NotificationPreviewMode.default.name) + val canAskToEnableNotifications = mkBoolPreference(SHARED_PREFS_CAN_ASK_TO_ENABLE_NOTIFICATIONS, true) val backgroundServiceNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false) val backgroundServiceBatteryNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, false) val autoRestartWorkerVersion = mkIntPreference(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0) @@ -358,6 +356,7 @@ class AppPreferences { private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground" private const val SHARED_PREFS_NOTIFICATIONS_MODE = "NotificationsMode" private const val SHARED_PREFS_NOTIFICATION_PREVIEW_MODE = "NotificationPreviewMode" + private const val SHARED_PREFS_CAN_ASK_TO_ENABLE_NOTIFICATIONS = "CanAskToEnableNotifications" private const val SHARED_PREFS_SERVICE_NOTICE_SHOWN = "BackgroundServiceNoticeShown" private const val SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN = "BackgroundServiceBatteryNoticeShown" private const val SHARED_PREFS_WEBRTC_POLICY_RELAY = "WebrtcPolicyRelay" @@ -939,6 +938,17 @@ object ChatController { } } + suspend fun apiReportMessage(rh: Long?, groupId: Long, chatItemId: Long, reportReason: ReportReason, reportText: String): List? { + val r = sendCmd(rh, CC.ApiReportMessage(groupId, chatItemId, reportReason, reportText)) + return when (r) { + is CR.NewChatItems -> r.chatItems + else -> { + apiErrorAlert("apiReportMessage", generalGetString(MR.strings.error_creating_report), r) + null + } + } + } + suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, itemId: Long): ChatItemInfo? { return when (val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, itemId))) { is CR.ApiChatItemInfo -> r.chatItemInfo @@ -3157,6 +3167,7 @@ sealed class CC { class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC() class ApiSendMessages(val type: ChatType, val id: Long, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() class ApiCreateChatItems(val noteFolderId: Long, val composedMessages: List): CC() + class ApiReportMessage(val groupId: Long, val chatItemId: Long, val reportReason: ReportReason, val reportText: String): CC() class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemIds: List, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List): CC() @@ -3319,6 +3330,7 @@ sealed class CC { val msgs = json.encodeToString(composedMessages) "/_create *$noteFolderId json $msgs" } + is ApiReportMessage -> "/_report #$groupId $chatItemId reason=${json.encodeToString(reportReason).trim('"')} $reportText" is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} ${itemIds.joinToString(",")} ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId ${itemIds.joinToString(",")}" @@ -3476,6 +3488,7 @@ sealed class CC { is ApiGetChatItemInfo -> "apiGetChatItemInfo" is ApiSendMessages -> "apiSendMessages" is ApiCreateChatItems -> "apiCreateChatItems" + is ApiReportMessage -> "apiReportMessage" is ApiUpdateChatItem -> "apiUpdateChatItem" is ApiDeleteChatItem -> "apiDeleteChatItem" is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem" @@ -3757,7 +3770,7 @@ data class ServerOperatorConditionsDetail( @Serializable() sealed class ConditionsAcceptance { - @Serializable @SerialName("accepted") data class Accepted(val acceptedAt: Instant?) : ConditionsAcceptance() + @Serializable @SerialName("accepted") data class Accepted(val acceptedAt: Instant?, val autoAccepted: Boolean) : ConditionsAcceptance() @Serializable @SerialName("required") data class Required(val deadline: Instant?) : ConditionsAcceptance() val conditionsAccepted: Boolean @@ -3801,7 +3814,7 @@ data class ServerOperator( tradeName = "SimpleX Chat", legalName = "SimpleX Chat Ltd", serverDomains = listOf("simplex.im"), - conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null), + conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null, autoAccepted = false), enabled = true, smpRoles = ServerRoles(storage = true, proxy = true), xftpRoles = ServerRoles(storage = true, proxy = true) @@ -3883,7 +3896,7 @@ data class UserOperatorServers( tradeName = "", legalName = null, serverDomains = emptyList(), - conditionsAcceptance = ConditionsAcceptance.Accepted(null), + conditionsAcceptance = ConditionsAcceptance.Accepted(null, autoAccepted = false), enabled = false, smpRoles = ServerRoles(storage = true, proxy = true), xftpRoles = ServerRoles(storage = true, proxy = true) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/SplashView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/SplashView.kt index 5265f3187b..a049230f27 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/SplashView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/SplashView.kt @@ -6,11 +6,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @Composable -fun SplashView() { +fun SplashView(nonTransparent: Boolean = false) { Surface( Modifier .fillMaxSize(), - color = MaterialTheme.colors.background, + color = if (nonTransparent) MaterialTheme.colors.background.copy(1f) else MaterialTheme.colors.background, contentColor = LocalContentColor.current ) { // Image( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index 6a6db0da85..c2fd52a58c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -88,7 +88,7 @@ fun TerminalLayout( .background(MaterialTheme.colors.background) ) { Divider() - Box(Modifier.padding(horizontal = 8.dp)) { + Surface(Modifier.padding(horizontal = 8.dp), color = MaterialTheme.colors.background, contentColor = MaterialTheme.colors.onBackground) { SendMsgView( composeState = composeState, showVoiceRecordIcon = false, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 3c0f1f7769..745e77268b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -992,6 +992,10 @@ fun BoxScope.ChatItemsList( val loadingMoreItems = remember { mutableStateOf(false) } val animatedScrollingInProgress = remember { mutableStateOf(false) } val ignoreLoadingRequests = remember(remoteHostId) { mutableSetOf() } + LaunchedEffect(chatInfo.id, searchValueIsEmpty.value) { + if (searchValueIsEmpty.value && reversedChatItems.value.size < ChatPagination.INITIAL_COUNT) + ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect) + } if (!loadingMoreItems.value) { PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> if (loadingMoreItems.value) return@PreloadItems false @@ -1081,7 +1085,7 @@ fun BoxScope.ChatItemsList( val dismissState = rememberDismissState(initialValue = DismissValue.Default) { if (it == DismissValue.DismissedToStart) { itemScope.launch { - if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local) { + if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local && !cItem.isReport) { if (composeState.value.editing) { composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) } else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { @@ -1543,7 +1547,7 @@ private fun PreloadItemsBefore( val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits) val items = chatModel.chatItems.value - if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining && items.size >= ChatPagination.INITIAL_COUNT) { + if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) { lastIndexToLoadFrom = items.lastIndex } if (allowLoad.value && lastIndexToLoadFrom != null) { 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 3a63cf508e..01c6faa573 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 @@ -11,6 +11,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.onSizeChanged @@ -51,6 +52,7 @@ sealed class ComposeContextItem { @Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem() @Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem() @Serializable class ForwardingItems(val chatItems: List, val fromChatInfo: ChatInfo): ComposeContextItem() + @Serializable class ReportedItem(val chatItem: ChatItem, val reason: ReportReason): ComposeContextItem() } @Serializable @@ -89,13 +91,28 @@ data class ComposeState( is ComposeContextItem.ForwardingItems -> true else -> false } + val reporting: Boolean + get() = when (contextItem) { + is ComposeContextItem.ReportedItem -> true + else -> false + } + val submittingValidReport: Boolean + get() = when (contextItem) { + is ComposeContextItem.ReportedItem -> { + when (contextItem.reason) { + is ReportReason.Other -> message.isNotEmpty() + else -> true + } + } + else -> false + } val sendEnabled: () -> Boolean get() = { val hasContent = when (preview) { is ComposePreview.MediaPreview -> true is ComposePreview.VoicePreview -> true is ComposePreview.FilePreview -> true - else -> message.isNotEmpty() || forwarding || liveMessage != null + else -> message.isNotEmpty() || forwarding || liveMessage != null || submittingValidReport } hasContent && !inProgress } @@ -119,7 +136,7 @@ data class ComposeState( val attachmentDisabled: Boolean get() { - if (editing || forwarding || liveMessage != null || inProgress) return true + if (editing || forwarding || liveMessage != null || inProgress || reporting) return true return when (preview) { ComposePreview.NoPreview -> false is ComposePreview.CLinkPreview -> false @@ -136,6 +153,12 @@ data class ComposeState( is ComposePreview.FilePreview -> true } + val placeholder: String + get() = when (contextItem) { + is ComposeContextItem.ReportedItem -> contextItem.reason.text + else -> generalGetString(MR.strings.compose_message_placeholder) + } + val empty: Boolean get() = message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem @@ -170,6 +193,7 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview { is MsgContent.MCVideo -> ComposePreview.MediaPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName)))) is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = fileName, mc.duration / 1000, true) is MsgContent.MCFile -> ComposePreview.FilePreview(fileName, getAppFileUri(fileName)) + is MsgContent.MCReport -> ComposePreview.NoPreview is MsgContent.MCUnknown, null -> ComposePreview.NoPreview } } @@ -483,10 +507,24 @@ fun ComposeView( is MsgContent.MCVideo -> MsgContent.MCVideo(msgText, image = msgContent.image, duration = msgContent.duration) is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration) is MsgContent.MCFile -> MsgContent.MCFile(msgText) + is MsgContent.MCReport -> MsgContent.MCReport(msgText, reason = msgContent.reason) is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json) } } + suspend fun sendReport(reportReason: ReportReason, chatItemId: Long): List? { + val cItems = chatModel.controller.apiReportMessage(chat.remoteHostId, chat.chatInfo.apiId, chatItemId, reportReason, msgText) + if (cItems != null) { + withChats { + cItems.forEach { chatItem -> + addChatItem(chat.remoteHostId, chat.chatInfo, chatItem.chatItem) + } + } + } + + return cItems?.map { it.chatItem } + } + suspend fun sendMemberContactInvitation() { val mc = checkLinkPreview() val contact = chatModel.controller.apiSendMemberContactInvitation(chat.remoteHostId, chat.chatInfo.apiId, mc) @@ -552,6 +590,8 @@ fun ComposeView( } else if (liveMessage != null && liveMessage.sent) { val updatedMessage = updateMessage(liveMessage.chatItem, chat, live) sent = if (updatedMessage != null) listOf(updatedMessage) else null + } else if (cs.contextItem is ComposeContextItem.ReportedItem) { + sent = sendReport(cs.contextItem.reason, cs.contextItem.chatItem.id) } else { val msgs: ArrayList = ArrayList() val files: ArrayList = ArrayList() @@ -833,14 +873,33 @@ fun ComposeView( @Composable fun MsgNotAllowedView(reason: String, icon: Painter) { - val color = MaterialTheme.appColors.receivedMessage - Row(Modifier.padding(top = 5.dp).fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) { + val color = MaterialTheme.appColors.receivedQuote + Row(Modifier.fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) { Icon(icon, null, tint = MaterialTheme.colors.secondary) Spacer(Modifier.width(DEFAULT_PADDING_HALF)) Text(reason, fontStyle = FontStyle.Italic) } } + @Composable + fun ReportReasonView(reason: ReportReason) { + val reportText = when (reason) { + is ReportReason.Spam -> generalGetString(MR.strings.report_compose_reason_header_spam) + is ReportReason.Illegal -> generalGetString(MR.strings.report_compose_reason_header_illegal) + is ReportReason.Profile -> generalGetString(MR.strings.report_compose_reason_header_profile) + is ReportReason.Community -> generalGetString(MR.strings.report_compose_reason_header_community) + is ReportReason.Other -> generalGetString(MR.strings.report_compose_reason_header_other) + is ReportReason.Unknown -> null // should never happen + } + + if (reportText != null) { + val color = MaterialTheme.appColors.receivedQuote + Row(Modifier.fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) { + Text(reportText, fontStyle = FontStyle.Italic, fontSize = 12.sp) + } + } + } + @Composable fun contextItemView() { when (val contextItem = composeState.value.contextItem) { @@ -854,6 +913,9 @@ fun ComposeView( is ComposeContextItem.ForwardingItems -> ContextItemView(contextItem.chatItems, painterResource(MR.images.ic_forward), showSender = false, chatType = chat.chatInfo.chatType) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } + is ComposeContextItem.ReportedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_flag), chatType = chat.chatInfo.chatType, contextIconColor = Color.Red) { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) + } } } @@ -891,6 +953,10 @@ fun ComposeView( if (nextSendGrpInv.value) { ComposeContextInvitingContactMemberView() } + val ctx = composeState.value.contextItem + if (ctx is ComposeContextItem.ReportedItem) { + ReportReasonView(ctx.reason) + } val simplexLinkProhibited = hasSimplexLink.value && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks) val fileProhibited = composeState.value.attachmentPreview && !chat.groupFeatureEnabled(GroupFeature.Files) val voiceProhibited = composeState.value.preview is ComposePreview.VoicePreview && !chat.chatInfo.featureEnabled(ChatFeature.Voice) @@ -918,154 +984,153 @@ fun ComposeView( } } } - Box(Modifier.background(MaterialTheme.colors.background)) { - Divider() - Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { - val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership) - val attachmentClicked = if (isGroupAndProhibitedFiles) { - { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.files_and_media_prohibited), - text = generalGetString(MR.strings.only_owners_can_enable_files_and_media) + Surface(color = MaterialTheme.colors.background, contentColor = MaterialTheme.colors.onBackground) { + Divider() + Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { + val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership) + val attachmentClicked = if (isGroupAndProhibitedFiles) { + { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.files_and_media_prohibited), + text = generalGetString(MR.strings.only_owners_can_enable_files_and_media) + ) + } + } else { + showChooseAttachment + } + val attachmentEnabled = + !composeState.value.attachmentDisabled + && sendMsgEnabled.value + && userCanSend.value + && !isGroupAndProhibitedFiles + && !nextSendGrpInv.value + IconButton( + attachmentClicked, + Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier), + enabled = attachmentEnabled + ) { + Icon( + painterResource(MR.images.ic_attach_file_filled_500), + contentDescription = stringResource(MR.strings.attach), + tint = if (attachmentEnabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + modifier = Modifier + .size(28.dp) + .clip(CircleShape) ) } - } else { - showChooseAttachment - } - val attachmentEnabled = - !composeState.value.attachmentDisabled - && sendMsgEnabled.value - && userCanSend.value - && !isGroupAndProhibitedFiles - && !nextSendGrpInv.value - IconButton( - attachmentClicked, - Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier), - enabled = attachmentEnabled - ) { - Icon( - painterResource(MR.images.ic_attach_file_filled_500), - contentDescription = stringResource(MR.strings.attach), - tint = if (attachmentEnabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - modifier = Modifier - .size(28.dp) - .clip(CircleShape) + val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) } + LaunchedEffect(allowedVoiceByPrefs) { + if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) { + // Voice was disabled right when this user records it, just cancel it + cancelVoice() + } + } + val needToAllowVoiceToContact = remember(chat.chatInfo) { + chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) { + ((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) && + contactPreference.allow == FeatureAllowed.YES + } + } + LaunchedEffect(Unit) { + snapshotFlow { recState.value } + .distinctUntilChanged() + .collect { + when (it) { + is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false) + is RecordingState.Finished -> if (it.durationMs > 300) { + onAudioAdded(it.filePath, it.durationMs, true) + } else { + cancelVoice() + } + is RecordingState.NotStarted -> {} + } + } + } + + LaunchedEffect(rememberUpdatedState(chat.chatInfo.userCanSend).value) { + if (!chat.chatInfo.userCanSend) { + clearCurrentDraft() + clearState() + } + } + + KeyChangeEffect(chatModel.chatId.value) { prevChatId -> + val cs = composeState.value + if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) { + sendMessage(null) + resetLinkPreview() + clearPrevDraft(prevChatId) + deleteUnusedFiles() + } else if (cs.inProgress) { + clearPrevDraft(prevChatId) + } else if (!cs.empty) { + if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) { + composeState.value = cs.copy(preview = cs.preview.copy(finished = true)) + } + if (saveLastDraft) { + chatModel.draft.value = composeState.value + chatModel.draftChatId.value = prevChatId + } + composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) + } else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) { + composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews) + } else { + clearPrevDraft(prevChatId) + deleteUnusedFiles() + } + chatModel.removeLiveDummy() + CIFile.cachedRemoteFileRequests.clear() + } + if (appPlatform.isDesktop) { + // Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)` + DisposableEffect(Unit) { + onDispose { + if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) { + chatModel.draft.value = composeState.value + chatModel.draftChatId.value = chat.id + } + } + } + } + val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) } + val sendButtonColor = + if (chat.chatInfo.incognito) + if (isInDarkTheme()) Indigo else Indigo.copy(alpha = 0.7F) + else MaterialTheme.colors.primary + SendMsgView( + composeState, + showVoiceRecordIcon = true, + recState, + chat.chatInfo is ChatInfo.Direct, + liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown, + sendMsgEnabled = sendMsgEnabled.value, + sendButtonEnabled = sendMsgEnabled.value && !(simplexLinkProhibited || fileProhibited || voiceProhibited), + nextSendGrpInv = nextSendGrpInv.value, + needToAllowVoiceToContact, + allowedVoiceByPrefs, + allowVoiceToContact = ::allowVoiceToContact, + userIsObserver = userIsObserver.value, + userCanSend = userCanSend.value, + sendButtonColor = sendButtonColor, + timedMessageAllowed = timedMessageAllowed, + customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, + placeholder = composeState.value.placeholder, + sendMessage = { ttl -> + sendMessage(ttl) + resetLinkPreview() + }, + sendLiveMessage = if (chat.chatInfo.chatType != ChatType.Local) ::sendLiveMessage else null, + updateLiveMessage = ::updateLiveMessage, + cancelLiveMessage = { + composeState.value = composeState.value.copy(liveMessage = null) + chatModel.removeLiveDummy() + }, + editPrevMessage = ::editPrevMessage, + onFilesPasted = { composeState.onFilesAttached(it) }, + onMessageChange = ::onMessageChange, + textStyle = textStyle ) } - val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) } - LaunchedEffect(allowedVoiceByPrefs) { - if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) { - // Voice was disabled right when this user records it, just cancel it - cancelVoice() - } - } - val needToAllowVoiceToContact = remember(chat.chatInfo) { - chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) { - ((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) && - contactPreference.allow == FeatureAllowed.YES - } - } - LaunchedEffect(Unit) { - snapshotFlow { recState.value } - .distinctUntilChanged() - .collect { - when (it) { - is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false) - is RecordingState.Finished -> if (it.durationMs > 300) { - onAudioAdded(it.filePath, it.durationMs, true) - } else { - cancelVoice() - } - is RecordingState.NotStarted -> {} - } - } - } - - LaunchedEffect(rememberUpdatedState(chat.chatInfo.userCanSend).value) { - if (!chat.chatInfo.userCanSend) { - clearCurrentDraft() - clearState() - } - } - - KeyChangeEffect(chatModel.chatId.value) { prevChatId -> - val cs = composeState.value - if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) { - sendMessage(null) - resetLinkPreview() - clearPrevDraft(prevChatId) - deleteUnusedFiles() - } else if (cs.inProgress) { - clearPrevDraft(prevChatId) - } else if (!cs.empty) { - if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) { - composeState.value = cs.copy(preview = cs.preview.copy(finished = true)) - } - if (saveLastDraft) { - chatModel.draft.value = composeState.value - chatModel.draftChatId.value = prevChatId - } - composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) - } else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) { - composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews) - } else { - clearPrevDraft(prevChatId) - deleteUnusedFiles() - } - chatModel.removeLiveDummy() - CIFile.cachedRemoteFileRequests.clear() - } - if (appPlatform.isDesktop) { - // Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)` - DisposableEffect(Unit) { - onDispose { - if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) { - chatModel.draft.value = composeState.value - chatModel.draftChatId.value = chat.id - } - } - } - } - - val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) } - val sendButtonColor = - if (chat.chatInfo.incognito) - if (isInDarkTheme()) Indigo else Indigo.copy(alpha = 0.7F) - else MaterialTheme.colors.primary - SendMsgView( - composeState, - showVoiceRecordIcon = true, - recState, - chat.chatInfo is ChatInfo.Direct, - liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown, - sendMsgEnabled = sendMsgEnabled.value, - sendButtonEnabled = sendMsgEnabled.value && !(simplexLinkProhibited || fileProhibited || voiceProhibited), - nextSendGrpInv = nextSendGrpInv.value, - needToAllowVoiceToContact, - allowedVoiceByPrefs, - allowVoiceToContact = ::allowVoiceToContact, - userIsObserver = userIsObserver.value, - userCanSend = userCanSend.value, - sendButtonColor = sendButtonColor, - timedMessageAllowed = timedMessageAllowed, - customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, - placeholder = stringResource(MR.strings.compose_message_placeholder), - sendMessage = { ttl -> - sendMessage(ttl) - resetLinkPreview() - }, - sendLiveMessage = if (chat.chatInfo.chatType != ChatType.Local) ::sendLiveMessage else null, - updateLiveMessage = ::updateLiveMessage, - cancelLiveMessage = { - composeState.value = composeState.value.copy(liveMessage = null) - chatModel.removeLiveDummy() - }, - editPrevMessage = ::editPrevMessage, - onFilesPasted = { composeState.onFilesAttached(it) }, - onMessageChange = ::onMessageChange, - textStyle = textStyle - ) } } - } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index 5850f0b7ec..1657a1f0b7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -12,6 +12,7 @@ import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.runtime.* +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp @@ -31,6 +32,7 @@ fun ContextItemView( contextIcon: Painter, showSender: Boolean = true, chatType: ChatType, + contextIconColor: Color = MaterialTheme.colors.secondary, cancelContextItem: () -> Unit, ) { val sentColor = MaterialTheme.appColors.sentMessage @@ -85,7 +87,6 @@ fun ContextItemView( Row( Modifier - .padding(top = 8.dp) .background(if (sent) sentColor else receivedColor), verticalAlignment = Alignment.CenterVertically ) { @@ -103,8 +104,8 @@ fun ContextItemView( .height(20.dp) .width(20.dp), contentDescription = stringResource(MR.strings.icon_descr_context), - tint = MaterialTheme.colors.secondary, - ) + tint = contextIconColor, + ) if (contextItems.count() == 1) { val contextItem = contextItems[0] diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt index 838398c503..582a981443 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -138,10 +138,10 @@ private fun recheckItems(chatInfo: ChatInfo, for (ci in chatItems) { if (selected.contains(ci.id)) { rDeleteEnabled = rDeleteEnabled && ci.canBeDeletedForSelf - rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote - rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd - rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null - rForwardEnabled = rForwardEnabled && ci.content.msgContent != null && ci.meta.itemDeleted == null && !ci.isLiveDummy + rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote && !ci.isReport + rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd && !ci.isReport + rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null && !ci.isReport + rForwardEnabled = rForwardEnabled && ci.content.msgContent != null && ci.meta.itemDeleted == null && !ci.isLiveDummy && !ci.isReport rSelectedChatItems.add(ci.id) // we are collecting new selected items here to account for any changes in chat items list } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index d912f8e030..e2b44478af 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -74,7 +74,7 @@ fun SendMsgView( } } val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && - !composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) + !composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) && (cs.contextItem !is ComposeContextItem.ReportedItem) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() || (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || @@ -125,6 +125,9 @@ fun SendMsgView( } when { progressByTimeout -> ProgressIndicator() + cs.contextItem is ComposeContextItem.ReportedItem -> { + SendMsgButton(painterResource(MR.images.ic_check_filled), sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendMessage) + } showVoiceButton && sendMsgEnabled -> { Row(verticalAlignment = Alignment.CenterVertically) { val stopRecOnNextClick = remember { mutableStateOf(false) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index 25661f00a0..c8b61e4179 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -209,8 +209,8 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState= 0 -> { + cItem.isReport && cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group -> { + DefaultDropdownMenu(showMenu) { + if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + ArchiveReportItemAction(cItem, showMenu, deleteMessage) + } + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + cItem.content.msgContent != null && cItem.id >= 0 && !cItem.isReport -> { DefaultDropdownMenu(showMenu) { if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { MsgReactionsMenu() @@ -383,9 +395,13 @@ fun ChatItemView( if (!(live && cItem.meta.isLive) && !preview) { DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) } - val groupInfo = cItem.memberToModerate(cInfo)?.first - if (groupInfo != null && cItem.chatDir !is CIDirection.GroupSnd) { - ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage) + if (cItem.chatDir !is CIDirection.GroupSnd) { + val groupInfo = cItem.memberToModerate(cInfo)?.first + if (groupInfo != null) { + ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage) + } else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) { + ReportItemAction(cItem, composeState, showMenu) + } } if (cItem.canBeDeletedForSelf) { Divider() @@ -728,9 +744,10 @@ fun DeleteItemAction( questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, + buttonText: String = stringResource(MR.strings.delete_verb), ) { ItemAction( - stringResource(MR.strings.delete_verb), + buttonText, painterResource(MR.images.ic_delete), onClick = { showMenu.value = false @@ -847,6 +864,73 @@ private fun ShrinkItemAction(revealed: State, showMenu: MutableState, + showMenu: MutableState, +) { + ItemAction( + stringResource(MR.strings.report_verb), + painterResource(MR.images.ic_flag), + onClick = { + AlertManager.shared.showAlertDialogButtons( + title = generalGetString(MR.strings.report_reason_alert_title), + buttons = { + ReportReason.supportedReasons.forEach { reason -> + SectionItemView({ + if (composeState.value.editing) { + composeState.value = ComposeState( + contextItem = ComposeContextItem.ReportedItem(cItem, reason), + useLinkPreviews = false, + preview = ComposePreview.NoPreview, + ) + } else { + composeState.value = composeState.value.copy( + contextItem = ComposeContextItem.ReportedItem(cItem, reason), + useLinkPreviews = false, + preview = ComposePreview.NoPreview, + ) + } + AlertManager.shared.hideAlert() + }) { + Text(reason.text, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + ) + showMenu.value = false + }, + color = Color.Red + ) +} + +@Composable +private fun ArchiveReportItemAction(cItem: ChatItem, showMenu: MutableState, deleteMessage: (Long, CIDeleteMode) -> Unit) { + ItemAction( + stringResource(MR.strings.archive_report), + painterResource(MR.images.ic_inventory_2), + onClick = { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.report_archive_alert_title), + text = generalGetString(MR.strings.report_archive_alert_desc), + onConfirm = { + deleteMessage(cItem.id, CIDeleteMode.cidmInternalMark) + }, + destructive = true, + confirmText = generalGetString(MR.strings.archive_verb), + ) + showMenu.value = false + }, + color = Color.Red + ) +} + @Composable fun ItemAction(text: String, icon: Painter, color: Color = Color.Unspecified, onClick: () -> Unit) { val finalColor = if (color == Color.Unspecified) { @@ -1133,7 +1217,7 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes deleteMessage(chatItem.id, CIDeleteMode.cidmInternal) AlertManager.shared.hideAlert() }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } - if (chatItem.meta.deletable && !chatItem.localNote) { + if (chatItem.meta.deletable && !chatItem.localNote && !chatItem.isReport) { Spacer(Modifier.padding(horizontal = 4.dp)) TextButton(onClick = { deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index e955428031..2827a649b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -88,7 +88,7 @@ fun FramedItemView( } @Composable - fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false) { + fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false, iconColor: Color? = null) { val sentColor = MaterialTheme.appColors.sentQuote val receivedColor = MaterialTheme.appColors.receivedQuote Row( @@ -104,7 +104,7 @@ fun FramedItemView( icon, caption, Modifier.size(18.dp), - tint = if (isInDarkTheme()) FileDark else FileLight + tint = iconColor ?: if (isInDarkTheme()) FileDark else FileLight ) } Text( @@ -216,7 +216,18 @@ fun FramedItemView( .padding(start = if (tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp) ) { PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) { - if (ci.meta.itemDeleted != null) { + if (ci.isReport) { + if (ci.meta.itemDeleted == null) { + FramedItemHeader( + stringResource(if (ci.chatDir.sent) MR.strings.report_item_visibility_submitter else MR.strings.report_item_visibility_moderators), + true, + painterResource(MR.images.ic_flag), + iconColor = Color.Red + ) + } else { + FramedItemHeader(stringResource(MR.strings.report_item_archived), true, painterResource(MR.images.ic_flag)) + } + } else if (ci.meta.itemDeleted != null) { when (ci.meta.itemDeleted) { is CIDeleted.Moderated -> { FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag)) @@ -288,6 +299,14 @@ fun FramedItemView( CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } + is MsgContent.MCReport -> { + val prefix = buildAnnotatedString { + withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) { + append(if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: ") + } + } + CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix) + } else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } @@ -315,13 +334,14 @@ fun CIMarkdownText( onLinkLongClick: (link: String) -> Unit = {}, showViaProxy: Boolean, showTimestamp: Boolean, + prefix: AnnotatedString? = null ) { Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) { val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text MarkdownText( text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode, - uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp + uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index d2e19a37d6..410372fe96 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -67,7 +67,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State } val total = moderated + blocked + blockedByAdmin + deleted if (total <= 1) - markedDeletedText(chatItem.meta) + markedDeletedText(chatItem) else if (total == moderated) stringResource(MR.strings.moderated_items_description).format(total, moderatedBy.joinToString(", ")) else if (total == blockedByAdmin) @@ -77,7 +77,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State else stringResource(MR.strings.marked_deleted_items_description).format(total) } else { - markedDeletedText(chatItem.meta) + markedDeletedText(chatItem) } Text( @@ -91,10 +91,11 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State ) } -fun markedDeletedText(meta: CIMeta): String = - when (meta.itemDeleted) { +fun markedDeletedText(cItem: ChatItem): String = + if (cItem.meta.itemDeleted != null && cItem.isReport) generalGetString(MR.strings.report_item_archived) + else when (cItem.meta.itemDeleted) { is CIDeleted.Moderated -> - String.format(generalGetString(MR.strings.moderated_item_description), meta.itemDeleted.byGroupMember.displayName) + String.format(generalGetString(MR.strings.moderated_item_description), cItem.meta.itemDeleted.byGroupMember.displayName) is CIDeleted.Blocked -> generalGetString(MR.strings.blocked_item_description) is CIDeleted.BlockedByAdmin -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 434cde608a..257ede7d4a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -71,7 +71,8 @@ fun MarkdownText ( inlineContent: Pair Unit, Map>? = null, onLinkLongClick: (link: String) -> Unit = {}, showViaProxy: Boolean = false, - showTimestamp: Boolean = true + showTimestamp: Boolean = true, + prefix: AnnotatedString? = null ) { val textLayoutDirection = remember (text) { if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr @@ -123,6 +124,7 @@ fun MarkdownText ( val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) + if (prefix != null) append(prefix) if (text is String) append(text) else if (text is AnnotatedString) append(text) if (meta?.isLive == true) { @@ -136,6 +138,7 @@ fun MarkdownText ( val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) + if (prefix != null) append(prefix) for ((i, ft) in formattedText.withIndex()) { if (ft.format == null) append(ft.text) else if (toggleSecrets && ft.format is Format.Secret) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index ff776bc8ca..2ed6315466 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -187,6 +187,12 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow remember(chatModelDraft) { chatModelDraft.message to messageDraft(chatModelDraft, sp20) } ci.meta.itemDeleted == null -> ci.text to null - else -> markedDeletedText(ci.meta) to null + else -> markedDeletedText(ci) to null } val formattedText = when { chatModelDraftChatId == chat.id && chatModelDraft != null -> null ci.meta.itemDeleted == null -> ci.formattedText else -> null } + val prefix = when (val mc = ci.content.msgContent) { + is MsgContent.MCReport -> + buildAnnotatedString { + withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) { + append(if (text.isEmpty()) mc.reason.text else "${mc.reason.text}: ") + } + } + else -> null + } + MarkdownText( text, formattedText, @@ -202,6 +213,7 @@ fun ChatPreviewView( ), inlineContent = inlineTextContent, modifier = Modifier.fillMaxWidth(), + prefix = prefix ) } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt index 28f6320ee7..1f2b5485f2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt @@ -51,7 +51,7 @@ fun authenticateWithPasscode( close() completed(LAResult.Error(generalGetString(MR.strings.authentication_cancelled))) } - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { LocalAuthView(ChatModel, LocalAuthRequest(promptTitle, promptSubtitle, password, selfDestruct && ChatController.appPrefs.selfDestruct.get()) { close() completed(it) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 9ec2d29843..5978f812a8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -422,7 +422,7 @@ fun SimplexLockView( } LAMode.PASSCODE -> { ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( submit = { laLockDelay.set(30) @@ -466,7 +466,7 @@ fun SimplexLockView( when (laResult) { LAResult.Success -> { ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( reason = generalGetString(MR.strings.la_app_passcode), submit = { @@ -490,7 +490,7 @@ fun SimplexLockView( when (laResult) { LAResult.Success -> { ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( passcodeKeychain = ksSelfDestructPassword, prohibitedPasscodeKeychain = ksAppPassword, @@ -525,7 +525,7 @@ fun SimplexLockView( } LAMode.PASSCODE -> { ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( submit = { laLockDelay.set(30) @@ -638,7 +638,7 @@ private fun EnableSelfDestruct( selfDestruct: SharedPreference, close: () -> Unit ) { - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( passcodeKeychain = ksSelfDestructPassword, prohibitedPasscodeKeychain = ksAppPassword, title = generalGetString(MR.strings.set_passcode), reason = generalGetString(MR.strings.enabled_self_destruct_passcode), submit = { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 51a0ffad8d..5bd45ccaab 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -444,17 +444,19 @@ fun doWithAuth(title: String, desc: String, block: () -> Unit) { runAuth(title, desc, onFinishAuth) } } - Box( - Modifier.fillMaxSize().background(MaterialTheme.colors.background), - contentAlignment = Alignment.Center - ) { - SimpleButton( - stringResource(MR.strings.auth_unlock), - icon = painterResource(MR.images.ic_lock), - click = { - runAuth(title, desc, onFinishAuth) - } - ) + Surface(color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + SimpleButton( + stringResource(MR.strings.auth_unlock), + icon = painterResource(MR.images.ic_lock), + click = { + runAuth(title, desc, onFinishAuth) + } + ) + } } } } 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 b052727ad2..c9c0f00555 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -37,6 +37,9 @@ %d messages marked deleted moderated by %s %1$d messages moderated by %2$s + Only you and moderators see it + Only sender and moderators see it + archived report blocked blocked by admin %d messages blocked @@ -94,6 +97,13 @@ Via browser Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. + + Spam + Inappropriate content + Community guidelines violation + Inappropriate profile + Another reason + Error saving SMP servers Error saving XFTP servers @@ -139,6 +149,7 @@ Error sending message Error forwarding messages Error creating message + Error creating report Error loading details Error adding member(s) Error joining group @@ -291,6 +302,9 @@ Most likely this contact has deleted the connection with you. No message This message was deleted or not received yet. + Report reason? + Archive report? + The report will be archived for you. Error: %1$s @@ -316,6 +330,9 @@ Edit Info Search + Archive + Archive report + Delete report Sent message Received message History @@ -333,6 +350,7 @@ Hide Allow Moderate + Report Select Expand Delete message? @@ -447,6 +465,11 @@ Please reduce the message size and send again. Please reduce the message size or remove media and send again. You can copy and reduce the message size to send it. + Report spam: only group moderators will see it. + Report member profile: only group moderators will see it. + Report violation: only group moderators will see it. + Report content: only group moderators will see it. + Report other: only group moderators will see it. Image @@ -1528,6 +1551,7 @@ observer author member + moderator admin owner diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index cde173f24c..9af367a5f6 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.2.1 -android.version_code=261 +android.version_name=6.2.3 +android.version_code=265 -desktop.version_name=6.2.1 -desktop.version_code=83 +desktop.version_name=6.2.3 +desktop.version_code=85 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 diff --git a/docs/rfcs/2024-12-28-reports.md b/docs/rfcs/2024-12-28-reports.md new file mode 100644 index 0000000000..729ad47f19 --- /dev/null +++ b/docs/rfcs/2024-12-28-reports.md @@ -0,0 +1,84 @@ +# Content complaints / reports + +## Problem + +Group moderation is a hard work, particularly when members can join anonymously. + +As groups count and size grows, and as we are moving to working large groups, so will the abuse, so we need report function for active groups that would forward the message that members may find offensive or inappropriate or off-topic or violating any rules that community wants to have. + +It doesn't mean that the moderators must censor everything that is reported, and even less so, that it should be centralized (although in our directory our directory bot would also receive these complaints, and would allow us supporting group owners). + +While we have necessary basic features to remove content and block members, we need to simplify identifying the content both to the group owners and to ourselves, when it comes to the groups listed in directory, or for the groups and files hosted on our servers. + +Having simpler way to report content would also improve the perceived safety of the network for the majority of the users. + +## Solution proposal + +"Report" feature on the messages that would highlight this message to all group admins and moderators. + +Group directory service is also an admin (and will be reduced to moderator in the future), so reported content will be visible to us, so that we can both help group owners to moderate their groups and also to remove the group from directory if necessary. + +To the user who have the new version the reports will be sent as a special event, similar to reaction (or it can be simply an extended reaction?) the usual forwarded messages in the same group, but only to moderators (including admins and owners), with additional flag indicating that this is the report. + +In the clients with the new version the reports could be shown as a flag, possibly with the counter, on group messages that were reported, in the same line where we show emojis. + +If we do that these flags will be seen only by moderators and by the user who submitted the report. When the moderator taps the flag, s/he would see the list of user who reported it, together with the reason. + +The downside of the above UX is that it: +- does not solve the problem of highlighting the problem to admins, particularly if them manage many groups. +- creates confusion about who can see the reports. +- further increases data model complexity, as it requires additional table or self-references (as with quotes), as reports can be received prior to the reported content. +- does not allow admins to see the reported content before it is received by them (would be less important with super-peers). + +Alternatively, and it is probably a better option, all reports, both sent by the users and received by moderators across all groups can be shown in the special subview Reports in each group. The report should be shown as the reported message with the header showing the report reason and the reporter. The report should allow these actions: +- moderate the original message, +- navigate to the original message (requires infinite scrolling, so initially will be only supported on Android and desktop), +- connect to the user who sent the report - it should be possible even if the group prohibits direct messages. There are two options how this communication can be handled - either by creating a new connection, and shown as normal contacts, or as comments to the report, and sent in the same group connection. The latter approach has the advantage that the interface would not be clutter the interace. The former is much simpler, so should probably be offered as MVP. + +This additional chat is necessary, as without it it would be very hard to notice the reports, particularly for the people who moderate multiple groups, and even more so - in our group directory and future super peers. + +## Protocol + +**Option 1** + +The special message `x.msg.report` will be sent in the group with this schema: + +```json +{ + "properties": { + "msgId": {"ref": "base64url"}, + "params": { + "properties": { + "msgId": {"ref": "base64url"}, + "reason": {"enum": ["spam", "illegal", "community", "other"]} + }, + "optionalProperties": { + "memberId": {"ref": "base64url"}, + "comment": {"type": "string"} + } + } + } +} +``` + +The downside is that it does not include the original message, so that the admin cannot act on it before the message is received. + +**Option 2** + +Message quote with the new content type. + +Pro - backwards compatible (quote would include text repeating the reason). + +Con - allows reporting non-existing messages, or even mis-reporting, but it is the same consideration that applies to all quotes. In this case though the admin might moderate the message they did not see yet, and it can be abused to remove appropriate content, so the UI should show warning "do you trust the reporter, as you did not receive the message yet". Moderation via reports may have additional information to ensure that exactly the reported message is moderated - e.g., the receiving client would check that the hash of the message in moderation event matches the hash of one of the messages in history. Possibly this is unnecessary with the view of migration of groups to super-peers. + +The report itself would be a new message content type where the report reason would be repeated as text, for backward compatibility. + +The option 2 seems to be simpler to implement, backward compatible and also more naturally fitting the protocol design - the report is simply a message with the new type that the old clients would be able to show correctly as the usual quote. + +The new clients would have a special presentation of these messages and also merging them into one - e.g. they can be shown as group events on in a more prominent way, but less prominent than the actual messages, and also merge subsequent reports about the same message. + +Given that the old clients would not be able to differentiate the reports and normal replies, and can inadvertently reply to all, we probably should warn the members submitting the report that some of the moderators are running the old version, and give them a choice - send to all or send only to moderators with the new version (or don't send, in case all admins run the old version). + +Having the conversation with the member about their report probably fits with the future comment feature that we should start adding to the backend and to the UI as well, as there is no reasonable backward compatibility for it, and members with the old clients simply won't see the comments, so we will have to release it in two stages and simply not send comments to the members with the old version. + +The model for the comments is a new subtype of MsgContainer, that references the original message and member, but does not include the full message. diff --git a/docs/rfcs/2024-12-30-content-moderation.md b/docs/rfcs/2024-12-30-content-moderation.md new file mode 100644 index 0000000000..e4f21a2d21 --- /dev/null +++ b/docs/rfcs/2024-12-30-content-moderation.md @@ -0,0 +1,136 @@ +# Evolving content moderation + +## Problem + +As the users and groups grow, and particularly given that we are planning to make large (10-100k members) groups work, the abuse will inevitably grow as well. + +Our current approach to content moderation is the following: +- receive a user complaints about the group that violates content guidelines (e.g., most users who send complaints, send them about relatively rare cases of CSAM distribution). This complaint contains the link to join the group, so it is a public group that anybody can join, and there is no expectation of privacy of communications in this group. +- we forward this complaint to our automatic bot joins this group and validates the complaint. +- if the complaint is valid, and the link is hosted on one of the pre-configured servers, then we can disable the link to join the group. +- in addition to that, the bot automatically deletes all files sent to the group, in case they are uploaded to our servers, via secure SSH connection directly to server control port (we don't expose shell access in this way, only to a limited set of server control port commands). + +The problem of CSAM is small at the moment, compared with the network size, but without moderation it would grow, and we need to be ahead of this problem, so this solution was in place since early 2024 - we wrote about it on social media. + +The limitation of this approach is that nothing prevents users who created such group to create a new one, and communicate the link to the new group to the existing members so they can migrate there. While this whack-a-mole game has been working so far, it will not be sustainable once we add support for large groups, so we need to be ahead of this problem again, and implement more efficient solutions. + +At the same time, the advantage of both this solution and of the proposed one is that it achieves removal of CSAM without compromising privacy in any way. Most CSAM distribution in all communication networks happens in publicly accessible channels, and it's the same for SimpleX network. So while as server operators we cannot access any content, as users, anybody can access it, and we, acting as users can use available information to remove this content without any compromise to privacy in security. + +This is covered in our [Privacy Policy](https://simplex.chat/privacy/). + +## Solution + +The solution to prevent further CSAM distribution by the users who did it requires restricting their activity on the client side, and also preventing migration of blocked group to another group. + +Traditionally, communication networks have some form of identification on the server side, and that identification is used to block offending users. + +Innovative SimpleX network design removed the need for persistent user identification of users, and many users see it as an unsolvable dilemma - if we cannot identify the users, then we cannot restrict their actions. + +But it is not true. In the same way we already impose restriction on the sent file size, limiting it to 1gb only on the client-side, we can restrict any user actions on the client side, without having any form of user identification, and without knowing how many users were blocked - we would only know how many blocking actions we applied, but we would not have any information about whether they were applied to one or to many users, in the same way as we don't know whether multiple messaging queues are controlled by one or by multiple users. + +The usual counter-argument is that this can be easily circumvented, because the code is open-source, and the users can modify it, so this approach won't work. While this argument premise is correct, the conclusion that this solution won't be effective is incorrect for two reasons: +- most users are either unable or unwilling to invest time into modifying code. This fact alone makes this solution effective in absolute majority of cases. +- any restriction on communication can be applied both on sending and on receiving client, without the need to identify either of these clients. We already do it with 1gb file restriction - e.g., even if file sender modifies their client to allow sending larger files, most of the recipients won't be able to receive this file anyway, as their clients also restrict the size of file that can be received to 1gb. + +For the group that is blocked to continue functioning, not only message senders have to modify their clients, but also message recipients, which won't happen in the absence of ability to communicate in disabled group. Such groups will only be able to function in an isolated segment of the network, when all users use modified clients and with self-hosted servers, which is outside of our zone of any moral and any potential legal responsibility (while we do not have any responsibility for user-generated content under the existing laws, there are requirements we have to comply with that exist outside of law, e.g. requirements of application stores). + +## Potential changes + +This section is the brain-dump of technically possible changes for the future. They will not be implemented all at once, and this list is neither exhaustive, as we or our users can come up with better ideas, nor committed - some of the ideas below may never be implemented. So these ideas are only listed as technical possibilities. + +Our priority is to continue being able to prevent CSAM distribution as network and groups grow, while doing what is reasonable and minimally possible, to save our costs, to avoid any disruption to the users, and to avoid the reduction in privacy and security - on the opposite, we are planning multiple privacy and security improvements in 2025. + +### Mark files and group links as blocked on the server, with the relevant client action + +Add additional protocol command `BLOCK` that would contain the blocking reason that will be presented to the users who try to connect to the link or to download the file. This would differentiate between "not working" scenarios, when file simply fails to download, and "blocked" scenario, and this simple measure would already reduce any prohibited usage of our servers. This change is likely to be implemented in the near future, to make users aware that we are actively moderating illegal content on the network, to educate users about how we do it without any compromise to their privacy and security, and to increase trust in network reliability, as currently our moderation actions are perceived as "something is broken" by affected users. + +### Extend blocking records on files to include client-side restrictions, and apply them to the client who received this blocking record. + +E.g., the client of the user who uploaded the file would periodically check who this file was received by (this functionality currently does not exist), and during this check the client may find out that the file was blocked. When client finds it out it may do any of the following: +- show a warning that the file violated allowed usage conditions that user agreed to. +- apply restrictions, whether temporary or permanent, to upload further files to servers of this operator only (it would be inappropriate to apply wider restrictions - so we appreciate this comment made by one of the users during the consultation). In case we decide that permanent restrictions should be applied, we could also program the ability to appeal this decision to support team and lift it via unblock code - without the need to have any user identification. + +The downside of this approach is that the client would have to check the file after it is uploaded, which may create additional traffic. But at the same time it would provide file delivery receipts, so overall it could be a valuable, although substantial, change. + +To continue with the file, the clients of the users who attempt to receive the file after it was blocked could do one of the following, depending on the blocking record: +- see the warning that the file is blocked. If CSAM was sent in a group that is not distributing CSAM, this adds comfort and the feeling of safety. +- block image preview, in the same way we block avatars of blocked members. +- users can configure automatic deletion of messages with blocked files. +- refuse, temporarily or permanently, to receive future files and/or messages from this group member. Permanent restriction may be automatically lifted once the member's client presents the proof of being unblocked by server operator. + +Applying the restrictions on the receiving side is technically simpler, and requires only minimal protocol changes mentioned above. + +While file senders can circumvent client side restrictions applied by server operators, these measures can be effective, because the recipients would also have to circumvent them, which is much less likely to happen in a coordinated way. + +The upside of this approach is that it does not compromise users' privacy in any way, and it does not interfere with users rights too. A user voluntarily accepted the Conditions of Use that prohibit upload of illegal content to our servers, so it is in line with the agreement for us to enforce these conditions and restrict functionality in case of conditions being violated. At the same time it would be inappropriate for us to restrict the ability to upload files to the servers of 3rd party operators that are not pre-configured in the app - only these operators should be able to restrict uploads to their servers. + +It also avoids the need for any scanning of content, whether client- or server-side, that would also be an infringement on the users right to privacy under European Convention of Human Rights, article 8. It also makes it unnecessary to identify users, contrary to common belief that to restrict users one needs to identify them. + +In the same way the network design allows delivering user messages without any form of user identification on the network protocol level, which is the innovation that does not exist in any other network, we can apply client-side restrictions on user activities without the need to identify a user. So if the block we apply to a specific piece of content results in client-side upload/download restrictions, all we would know is how many times this restriction was applied, but not to how many users - multiple blocked files could have been all uploaded by one user or by multiple users, but this is not the knowledge that is required to restrict further abuse of our servers and violation of condition of use. Again, this is an innovative approach to moderation that is not present in any of the networks, that allows us both to remain in compliance with the contractual obligations (e.g., with application store owners) and any potential legal obligation (even though the legal advice we have is that we do not have obligation to moderate content, as we are not providing communication services), once it becomes a bigger issue. + +### Extend blocking records on links to include client-side restrictions, and apply them to the clients who received this blocking record. + +Similarly to files, once the link to join the group is blocked, both the owner's client and all members' clients can impose (technically) any of the following restrictions. + +For the owner: +- restrict, temporarily or permanently, ability to create public groups on the servers of the operator (or group of operators, in case of pre-configured operators) who applied this blocking record. +- restrict, temporarily or permanently, ability to upload files to operator's servers. +- restrict, temporarily or permanently, sending any messages to operator's servers, not only in the blocked group. + +For all group members: +- restrict, temporarily or permanently, ability to send and receive messages in the blocked group. + +For the same reason as with files, this measure will be an effective deterrence, even though the code is open-source. + +While full blocking may be seen as draconian, for the people who repeatedly violate the conditions of use, ignoring temporary or limited restrictions, it may be appropriate. The tracking of repeat violations of conditions also does not require any user identification and can be done fully on the client side, with sufficient efficiency. + +### Implement ability to submit reports to group owners and moderators + +This is covered under a [separate RFC](./2024-12-28-reports.md) and is currently in progress. This would improve the ability of group owners to moderate their groups, and would also improve our ability to moderate all listed groups, both manually and automatically, as Directory Service has moderation rights. + +### Implement ability to submit reports to 3rd party server operators + +While users already can send reports to ourselves directly via the app, sending them to other server operators requires additional steps from the users. + +This function would allow sending reports to any server operator directly via the app, to the address sent by the server during the initial connection. + +Server operators may be then offered efficient interfaces in the clients to manage these complaints and to apply client-side restrictions to the users who violate the conditions. + +### Blacklist servers who refuse to remove CSAM from receiving any traffic from our servers + +We cannot and should not enforce that 3rd party server operators remove CSAM from their servers. We will only be recommending it and providing tools to simplify it. + +But we can, technically, implement block-lists of servers so that the users who need to send messages to these servers would not be able to do that via our servers. + +We also can require mandatory server identification to requests to proxy messages via client certificates of the server that could be validated via a reverse connection, and also block incoming traffic from these servers. + +While both these measures are undesirable and would result in network fragmentation, they are technically possible. Similar restrictions already happen in fediverse networks, and they are effective. + +## Actual planned changes + +To summarize, the changes that are planned in the near future: + +- client-side notifications that files or group links were blocked (as opposed to show error, creating an impression that something is not working). +- [content reports](./2024-12-28-reports.md) to group owners and moderators. +- additional short notice about conditions of use that apply to file uploads prior to the first upload. + +Additional simple changes that are considered: + +- applying client-side restriction to create new public groups on operator's servers on admins of blocked groups (do not confuse that with the groups that we decided not to list in our directory, or decided to remove from our directory - this is not blocking that is being discussed here). +- if the group link was registered via directory service, we can prevent further registration of public groups in directory service for this user by, communicating that this link is blocked to directory service. +- preventing any communication in blocked groups. + +To clarify, all these restrictions are considered only for the groups that were created primarily to distribute or to promote CSAM content, they won't apply in cases some group members maliciously posted illegal content in a public group - in which case they will only be applied to this member, helping group owners to moderate. + +We will continue moderating the content as we do now, and as long as CSAM distribution is prevented, we may not need additional measures listed here. + +At the same time, we are committed to make it impossible to distribute CSAM in the part of SimpleX network that we or any other pre-configured operators operate. + +We are also committed to achieve this goal without any reduction in privacy and security even for the affected users. E.g., unless there is an enforceable order, we will not be recording any information identifying the user, such as IP address, because it may inadvertently affect the users whose content was flagged by mistake. + +Our ultimate commitment, and our business is to provide private and secure communication to the users who comply with conditions of use, and to prevent mass-scale surveillance of non-suspects (which is a direct violation of European Convention of Human Rights). + +Privacy and security of the network will further improve in 2025, as we plan: +- adding post-quantum encryption to small groups. +- adding proxying during file reception from unknown (or all) servers. +- adding scheduled and delayed re-broadcasts in large groups, to frustrate timing attacks that could otherwise allow identifying users who send messages to groups. diff --git a/package.yaml b/package.yaml index 668e2f26a0..f8c75a0eb7 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 6.2.2.0 +version: 6.2.4.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 37153da27e..fc8cab293c 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.2.2.0 +version: 6.2.4.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -156,6 +156,8 @@ library Simplex.Chat.Migrations.M20241128_business_chats Simplex.Chat.Migrations.M20241205_business_chat_members Simplex.Chat.Migrations.M20241222_operator_conditions + Simplex.Chat.Migrations.M20241223_chat_tags + Simplex.Chat.Migrations.M20241230_reports Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8cc3267f3a..ca8ec60c41 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -885,7 +885,7 @@ processChatCommand' vr = \case Just (CIFFGroup _ _ (Just gId) (Just fwdItemId)) -> Just <$> withFastStore (\db -> getAChatItem db vr user (ChatRef CTGroup gId) fwdItemId) _ -> pure Nothing - APISendMessages (ChatRef cType chatId) live itemTTL cms -> withUser $ \user -> case cType of + APISendMessages (ChatRef cType chatId) live itemTTL cms -> withUser $ \user -> mapM_ assertAllowedContent' cms >> case cType of CTDirect -> withContactLock "sendMessage" chatId $ sendContactContentMessages user chatId live itemTTL (L.map (,Nothing) cms) @@ -895,9 +895,28 @@ processChatCommand' vr = \case CTLocal -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" - APICreateChatItems folderId cms -> withUser $ \user -> + APICreateChatItems folderId cms -> withUser $ \user -> do + mapM_ assertAllowedContent' cms createNoteFolderContentItems user folderId (L.map (,Nothing) cms) - APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> case cType of + APIReportMessage gId reportedItemId reportReason reportText -> withUser $ \user -> + withGroupLock "reportMessage" gId $ do + (gInfo, ms) <- + withFastStore $ \db -> do + gInfo <- getGroupInfo db vr user gId + (gInfo,) <$> liftIO (getGroupModerators db vr user gInfo) + let ms' = filter compatibleModerator ms + mc = MCReport reportText reportReason + cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc} + when (null ms') $ throwChatError $ CECommandError "no moderators support receiving reports" + sendGroupContentMessages_ user gInfo ms' False Nothing [(cm, Nothing)] + where + compatibleModerator GroupMember {activeConn, memberChatVRange} = + maxVersion (maybe memberChatVRange peerChatVRange activeConn) >= contentReportsVersion + ReportMessage {groupName, contactName_, reportReason, reportedMessage} -> withUser $ \user -> do + gId <- withFastStore $ \db -> getGroupIdByName db user groupName + reportedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user gId contactName_ reportedMessage + processChatCommand $ APIReportMessage gId reportedItemId reportReason "" + APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> assertAllowedContent mc >> case cType of CTDirect -> withContactLock "updateChatItem" chatId $ do ct@Contact {contactId} <- withFastStore $ \db -> getContact db vr user chatId assertDirectAllowed user MDSnd ct XMsgUpdate_ @@ -965,6 +984,7 @@ processChatCommand' vr = \case (ct, items) <- getCommandDirectChatItems user chatId itemIds case mode of CIDMInternal -> deleteDirectCIs user ct items True False + CIDMInternalMark -> markDirectCIsDeleted user ct items True =<< liftIO getCurrentTime CIDMBroadcast -> do assertDeletable items assertDirectAllowed user MDSnd ct XMsgDel_ @@ -980,6 +1000,7 @@ processChatCommand' vr = \case ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo case mode of CIDMInternal -> deleteGroupCIs user gInfo items True False Nothing =<< liftIO getCurrentTime + CIDMInternalMark -> markGroupCIsDeleted user gInfo items True Nothing =<< liftIO getCurrentTime CIDMBroadcast -> do assertDeletable items assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier @@ -1010,7 +1031,7 @@ processChatCommand' vr = \case (gInfo@GroupInfo {membership}, items) <- getCommandGroupChatItems user gId itemIds ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo assertDeletable gInfo items - assertUserGroupRole gInfo GRAdmin + assertUserGroupRole gInfo GRAdmin -- TODO GRModerator when most users migrate let msgMemIds = itemsMsgMemIds gInfo items events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId (Just memId)) msgMemIds mapM_ (sendGroupMessages user gInfo ms) events @@ -1131,6 +1152,7 @@ processChatCommand' vr = \case MCVideo {text} -> text /= "" MCVoice {text} -> text /= "" MCFile t -> t /= "" + MCReport {} -> True MCUnknown {} -> True APIForwardChatItems (ChatRef toCType toChatId) (ChatRef fromCType fromChatId) itemIds itemTTL -> withUser $ \user -> case toCType of CTDirect -> do @@ -1888,6 +1910,7 @@ processChatCommand' vr = \case gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId m <- withFastStore $ \db -> getGroupMember db vr user gId mId let GroupInfo {membership = GroupMember {memberRole = membershipRole}} = gInfo + -- TODO GRModerator when most users migrate when (membershipRole >= GRAdmin) $ throwChatError $ CECantBlockMemberForSelf gInfo m showMessages let settings = (memberSettings m) {showMessages} processChatCommand $ APISetMemberSettings gId mId settings @@ -2312,6 +2335,7 @@ processChatCommand' vr = \case Nothing -> throwChatError $ CEException "expected to find a single blocked member" Just (bm, remainingMembers) -> do let GroupMember {memberId = bmMemberId, memberRole = bmRole, memberProfile = bmp} = bm + -- TODO GRModerator when most users migrate assertUserGroupRole gInfo $ max GRAdmin bmRole when (blocked == blockedByAdmin bm) $ throwChatError $ CECommandError $ if blocked then "already blocked" else "already unblocked" withGroupLock "blockForAll" groupId . procCmd $ do @@ -3198,6 +3222,12 @@ processChatCommand' vr = \case forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId, itemId) _ -> pure () -- prohibited + assertAllowedContent :: MsgContent -> CM () + assertAllowedContent = \case + MCReport {} -> throwChatError $ CECommandError "sending reports via this API is not supported" + _ -> pure () + assertAllowedContent' :: ComposedMessage -> CM () + assertAllowedContent' ComposedMessage {msgContent} = assertAllowedContent msgContent sendContactContentMessages :: User -> ContactId -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse sendContactContentMessages user contactId live itemTTL cmrs = do assertMultiSendable live cmrs @@ -3258,13 +3288,16 @@ processChatCommand' vr = \case sendGroupContentMessages :: User -> GroupId -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse sendGroupContentMessages user groupId live itemTTL cmrs = do assertMultiSendable live cmrs - g@(Group gInfo _) <- withFastStore $ \db -> getGroup db vr user groupId + Group gInfo ms <- withFastStore $ \db -> getGroup db vr user groupId + sendGroupContentMessages_ user gInfo ms live itemTTL cmrs + sendGroupContentMessages_ :: User -> GroupInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse + sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} ms live itemTTL cmrs = do assertUserGroupRole gInfo GRAuthor - assertGroupContentAllowed gInfo - processComposedMessages g + assertGroupContentAllowed + processComposedMessages where - assertGroupContentAllowed :: GroupInfo -> CM () - assertGroupContentAllowed gInfo@GroupInfo {membership} = + assertGroupContentAllowed :: CM () + assertGroupContentAllowed = case findProhibited (L.toList cmrs) of Just f -> throwChatError (CECommandError $ "feature not allowed " <> T.unpack (groupFeatureNameText f)) Nothing -> pure () @@ -3274,8 +3307,8 @@ processChatCommand' vr = \case foldr' (\(ComposedMessage {fileSource, msgContent = mc}, _) acc -> prohibitedGroupContent gInfo membership mc fileSource <|> acc) Nothing - processComposedMessages :: Group -> CM ChatResponse - processComposedMessages g@(Group gInfo ms) = do + processComposedMessages :: CM ChatResponse + processComposedMessages = do (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length $ filter memberCurrent ms) timed_ <- sndGroupCITimed live gInfo itemTTL (msgContainers, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ @@ -3296,7 +3329,7 @@ processChatCommand' vr = \case forM cmrs $ \(ComposedMessage {fileSource = file_}, _) -> case file_ of Just file -> do fileSize <- checkSndFile file - (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup g + (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup gInfo ms pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) prepareMsgs :: NonEmpty (ComposeMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTGroup))) @@ -3354,7 +3387,7 @@ processChatCommand' vr = \case case contactOrGroup of CGContact Contact {activeConn} -> forM_ activeConn $ \conn -> withFastStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft dummyFileDescr - CGGroup (Group _ ms) -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user)) + CGGroup _ ms -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user)) where -- we are not sending files to pending members, same as with inline files saveMemberFD m@GroupMember {activeConn = Just conn@Connection {connStatus}} = @@ -3545,6 +3578,7 @@ quoteContent mc qmc ciFile_ MCImage {} -> True MCVideo {} -> True MCVoice {} -> False + MCReport {} -> False MCUnknown {} -> True qText = msgContentText qmc getFileName :: CIFile d -> String @@ -6076,7 +6110,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ci' <- withStore' $ \db -> markGroupCIBlockedByAdmin db user gInfo ci groupMsgToView gInfo ci' applyModeration CIModeration {moderatorMember = moderator@GroupMember {memberRole = moderatorRole}, moderatedAt} - | moderatorRole < GRAdmin || moderatorRole < memberRole = + | moderatorRole < GRModerator || moderatorRole < memberRole = createContentItem | groupFeatureAllowed SGFFullDelete gInfo = do ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs CIRcvModerated Nothing timed' False @@ -6161,7 +6195,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = CIGroupSnd -> moderate membership cci Left e | msgMemberId == memberId -> messageError $ "x.msg.del: message not found, " <> tshow e - | senderRole < GRAdmin -> messageError $ "x.msg.del: message not found, message of another member with insufficient member permissions, " <> tshow e + | senderRole < GRModerator -> messageError $ "x.msg.del: message not found, message of another member with insufficient member permissions, " <> tshow e | otherwise -> withStore' $ \db -> createCIModeration db gInfo m msgMemberId sharedMsgId msgId brokerTs where moderate :: GroupMember -> CChatItem 'CTGroup -> CM () @@ -6171,7 +6205,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise -> messageError "x.msg.del: message of another member with incorrect memberId" _ -> messageError "x.msg.del: message of another member without memberId" checkRole GroupMember {memberRole} a - | senderRole < GRAdmin || senderRole < memberRole = + | senderRole < GRModerator || senderRole < memberRole = messageError "x.msg.del: message of another member with insufficient member permissions" | otherwise = a delete :: CChatItem 'CTGroup -> Maybe GroupMember -> CM ChatResponse @@ -6907,7 +6941,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Right bm@GroupMember {groupMemberId = bmId, memberRole, memberProfile = bmp} - | senderRole < GRAdmin || senderRole < memberRole -> messageError "x.grp.mem.restrict with insufficient member permissions" + | senderRole < GRModerator || senderRole < memberRole -> messageError "x.grp.mem.restrict with insufficient member permissions" | otherwise -> do bm' <- setMemberBlocked bmId toggleNtf user bm' (not blocked) @@ -8404,8 +8438,10 @@ chatCommandP = "/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal), "/_send " *> (APISendMessages <$> chatRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), "/_create *" *> (APICreateChatItems <$> A.decimal <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), + "/_report #" *> (APIReportMessage <$> A.decimal <* A.space <*> A.decimal <*> (" reason=" *> strP) <*> (A.space *> textP <|> pure "")), + "/report #" *> (ReportMessage <$> displayName <*> optional (" @" *> displayName) <*> _strP <* A.space <*> msgTextP), "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP), - "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <* A.space <*> ciDeleteMode), + "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <*> _strP), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP), "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), "/_reaction members " *> (APIGetReactionMembers <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <* A.space <*> jsonP), @@ -8688,7 +8724,6 @@ chatCommandP = <|> (PTBefore <$ "before=" <*> strP <* A.space <* "count=" <*> A.decimal) mcTextP = MCText . safeDecodeUtf8 <$> A.takeByteString msgContentP = "text " *> mcTextP <|> "json " *> jsonP - ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal chatDeleteMode = A.choice [ " full" *> (CDMFull <$> notifyP), @@ -8758,6 +8793,7 @@ chatCommandP = A.choice [ " owner" $> GROwner, " admin" $> GRAdmin, + " moderator" $> GRModerator, " member" $> GRMember, " observer" $> GRObserver ] diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 593c328d0c..318d1939c8 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -300,6 +300,8 @@ data ChatCommand | APIGetChatItemInfo ChatRef ChatItemId | APISendMessages {chatRef :: ChatRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessages :: NonEmpty ComposedMessage} | APICreateChatItems {noteFolderId :: NoteFolderId, composedMessages :: NonEmpty ComposedMessage} + | APIReportMessage {groupId :: GroupId, chatItemId :: ChatItemId, reportReason :: ReportReason, reportText :: Text} + | ReportMessage {groupName :: GroupName, contactName_ :: Maybe ContactName, reportReason :: ReportReason, reportedMessage :: Text} | APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, msgContent :: MsgContent} | APIDeleteChatItem ChatRef (NonEmpty ChatItemId) CIDeleteMode | APIDeleteMemberChatItem GroupId (NonEmpty ChatItemId) diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index e198183b06..96a4f9f6c8 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -17,6 +17,7 @@ module Simplex.Chat.Messages.CIContent where import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ +import qualified Data.Attoparsec.ByteString.Char8 as A import Data.Int (Int64) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) @@ -103,15 +104,33 @@ msgDirectionIntP = \case 1 -> Just MDSnd _ -> Nothing -data CIDeleteMode = CIDMBroadcast | CIDMInternal +data CIDeleteMode = CIDMBroadcast | CIDMInternal | CIDMInternalMark deriving (Show) -$(JQ.deriveJSON (enumJSON $ dropPrefix "CIDM") ''CIDeleteMode) +instance StrEncoding CIDeleteMode where + strEncode = \case + CIDMBroadcast -> "broadcast" + CIDMInternal -> "internal" + CIDMInternalMark -> "internalMark" + strP = + A.takeTill (== ' ') >>= \case + "broadcast" -> pure CIDMBroadcast + "internal" -> pure CIDMInternal + "internalMark" -> pure CIDMInternalMark + _ -> fail "bad CIDeleteMode" + +instance ToJSON CIDeleteMode where + toJSON = strToJSON + toEncoding = strToJEncoding + +instance FromJSON CIDeleteMode where + parseJSON = strParseJSON "CIDeleteMode" ciDeleteModeToText :: CIDeleteMode -> Text ciDeleteModeToText = \case CIDMBroadcast -> "this item is deleted (broadcast)" - CIDMInternal -> "this item is deleted (internal)" + CIDMInternal -> "this item is deleted (locally)" + CIDMInternalMark -> "this item is deleted (locally)" -- This type is used both in API and in DB, so we use different JSON encodings for the database and for the API -- ! Nested sum types also have to use different encodings for database and API diff --git a/src/Simplex/Chat/Migrations/M20241223_chat_tags.hs b/src/Simplex/Chat/Migrations/M20241223_chat_tags.hs new file mode 100644 index 0000000000..a83be7549d --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20241223_chat_tags.hs @@ -0,0 +1,47 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20241223_chat_tags where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20241223_chat_tags :: Query +m20241223_chat_tags = + [sql| +CREATE TABLE chat_tags ( + chat_tag_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users, + chat_tag_text TEXT NOT NULL, + chat_tag_emoji TEXT, + tag_order INTEGER NOT NULL +); + +CREATE TABLE chat_tags_chats ( + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, + group_id INTEGER REFERENCES groups ON DELETE CASCADE, + chat_tag_id INTEGER NOT NULL REFERENCES chat_tags ON DELETE CASCADE +); + +CREATE INDEX idx_chat_tags_user_id ON chat_tags(user_id); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_text ON chat_tags(user_id, chat_tag_text); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_emoji ON chat_tags(user_id, chat_tag_emoji); + +CREATE INDEX idx_chat_tags_chats_chat_tag_id ON chat_tags_chats(chat_tag_id); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_contact_id ON chat_tags_chats(contact_id, chat_tag_id); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_group_id ON chat_tags_chats(group_id, chat_tag_id); +|] + +down_m20241223_chat_tags :: Query +down_m20241223_chat_tags = + [sql| +DROP INDEX idx_chat_tags_user_id; +DROP INDEX idx_chat_tags_user_id_chat_tag_text; +DROP INDEX idx_chat_tags_user_id_chat_tag_emoji; + +DROP INDEX idx_chat_tags_chats_chat_tag_id; +DROP INDEX idx_chat_tags_chats_chat_tag_id_contact_id; +DROP INDEX idx_chat_tags_chats_chat_tag_id_group_id; + +DROP TABLE chat_tags_chats; +DROP TABLE chat_tags; +|] diff --git a/src/Simplex/Chat/Migrations/M20241230_reports.hs b/src/Simplex/Chat/Migrations/M20241230_reports.hs new file mode 100644 index 0000000000..d5b3c183cf --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20241230_reports.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + + module Simplex.Chat.Migrations.M20241230_reports where + + import Database.SQLite.Simple (Query) + import Database.SQLite.Simple.QQ (sql) + + m20241230_reports :: Query + m20241230_reports = + [sql| +ALTER TABLE chat_items ADD COLUMN msg_content_tag TEXT; +|] + + down_m20241230_reports :: Query + down_m20241230_reports = + [sql| +ALTER TABLE chat_items DROP COLUMN msg_content_tag; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 0a6a581cbe..ee968383e0 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -402,7 +402,8 @@ CREATE TABLE chat_items( fwd_from_contact_id INTEGER REFERENCES contacts ON DELETE SET NULL, fwd_from_group_id INTEGER REFERENCES groups ON DELETE SET NULL, fwd_from_chat_item_id INTEGER REFERENCES chat_items ON DELETE SET NULL, - via_proxy INTEGER + via_proxy INTEGER, + msg_content_tag TEXT ); CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE chat_item_messages( @@ -625,6 +626,18 @@ CREATE TABLE operator_usage_conditions( , auto_accepted INTEGER DEFAULT 0 ); +CREATE TABLE chat_tags( + chat_tag_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users, + chat_tag_text TEXT NOT NULL, + chat_tag_emoji TEXT, + tag_order INTEGER NOT NULL +); +CREATE TABLE chat_tags_chats( + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, + group_id INTEGER REFERENCES groups ON DELETE CASCADE, + chat_tag_id INTEGER NOT NULL REFERENCES chat_tags ON DELETE CASCADE +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -931,3 +944,21 @@ CREATE INDEX idx_chat_items_notes ON chat_items( created_at ); CREATE INDEX idx_groups_business_xcontact_id ON groups(business_xcontact_id); +CREATE INDEX idx_chat_tags_user_id ON chat_tags(user_id); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_text ON chat_tags( + user_id, + chat_tag_text +); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_emoji ON chat_tags( + user_id, + chat_tag_emoji +); +CREATE INDEX idx_chat_tags_chats_chat_tag_id ON chat_tags_chats(chat_tag_id); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_contact_id ON chat_tags_chats( + contact_id, + chat_tag_id +); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_group_id ON chat_tags_chats( + group_id, + chat_tag_id +); diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 9eda85aaf3..07225856b6 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -300,22 +300,22 @@ newUserServer_ preset enabled server = UserServer {serverId = DBNewEntity, server, preset, tested = Nothing, enabled, deleted = False} -- This function should be used inside DB transaction to update conditions in the database --- it evaluates to (conditions to mark as accepted to SimpleX operator, current conditions, and conditions to add) -usageConditionsToAdd :: Bool -> UTCTime -> [UsageConditions] -> (Maybe UsageConditions, UsageConditions, [UsageConditions]) +-- it evaluates to (current conditions, and conditions to add) +usageConditionsToAdd :: Bool -> UTCTime -> [UsageConditions] -> (UsageConditions, [UsageConditions]) usageConditionsToAdd = usageConditionsToAdd' previousConditionsCommit usageConditionsCommit -- This function is used in unit tests -usageConditionsToAdd' :: Text -> Text -> Bool -> UTCTime -> [UsageConditions] -> (Maybe UsageConditions, UsageConditions, [UsageConditions]) +usageConditionsToAdd' :: Text -> Text -> Bool -> UTCTime -> [UsageConditions] -> (UsageConditions, [UsageConditions]) usageConditionsToAdd' prevCommit sourceCommit newUser createdAt = \case [] - | newUser -> (Just sourceCond, sourceCond, [sourceCond]) - | otherwise -> (Just prevCond, sourceCond, [prevCond, sourceCond]) + | newUser -> (sourceCond, [sourceCond]) + | otherwise -> (sourceCond, [prevCond, sourceCond]) where prevCond = conditions 1 prevCommit sourceCond = conditions 2 sourceCommit conds - | hasSourceCond -> (Nothing, last conds, []) - | otherwise -> (Nothing, sourceCond, [sourceCond]) + | hasSourceCond -> (last conds, []) + | otherwise -> (sourceCond, [sourceCond]) where hasSourceCond = any ((sourceCommit ==) . conditionsCommit) conds sourceCond = conditions cId sourceCommit diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 934f23007d..577cb6293d 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -37,7 +37,7 @@ import Data.Maybe (fromMaybe, mapMaybe) import Data.String import Data.Text (Text) import qualified Data.Text as T -import Data.Text.Encoding (decodeLatin1, encodeUtf8) +import Data.Text.Encoding (decodeASCII', decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime) import Data.Type.Equality import Data.Typeable (Typeable) @@ -69,12 +69,13 @@ import Simplex.Messaging.Version hiding (version) -- 9 - batch sending in direct connections (2024-07-24) -- 10 - business chats (2024-11-29) -- 11 - fix profile update in business chats (2024-12-05) +-- 12 - fix profile update in business chats (2025-01-03) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 11 +currentChatVersion = VersionChat 12 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -121,6 +122,10 @@ businessChatsVersion = VersionChat 10 businessChatPrefsVersion :: VersionChat businessChatPrefsVersion = VersionChat 11 +-- support sending and receiving content reports (MCReport message content) +contentReportsVersion :: VersionChat +contentReportsVersion = VersionChat 12 + agentToChatVersion :: VersionSMPA -> VersionChat agentToChatVersion v | v < pqdrSMPAgentVersion = initialChatVersion @@ -246,6 +251,9 @@ data LinkPreview = LinkPreview {uri :: Text, title :: Text, description :: Text, data LinkContent = LCPage | LCImage | LCVideo {duration :: Maybe Int} | LCUnknown {tag :: Text, json :: J.Object} deriving (Eq, Show) +data ReportReason = RRSpam | RRContent | RRCommunity | RRProfile | RROther | RRUnknown Text + deriving (Eq, Show) + $(pure []) instance FromJSON LinkContent where @@ -265,6 +273,30 @@ instance ToJSON LinkContent where $(JQ.deriveJSON defaultJSON ''LinkPreview) +instance StrEncoding ReportReason where + strEncode = \case + RRSpam -> "spam" + RRContent -> "content" + RRCommunity -> "community" + RRProfile -> "profile" + RROther -> "other" + RRUnknown t -> encodeUtf8 t + strP = + A.takeTill (== ' ') >>= \case + "spam" -> pure RRSpam + "content" -> pure RRContent + "community" -> pure RRCommunity + "profile" -> pure RRProfile + "other" -> pure RROther + t -> maybe (fail "bad ReportReason") (pure . RRUnknown) $ decodeASCII' t + +instance FromJSON ReportReason where + parseJSON = strParseJSON "ReportReason" + +instance ToJSON ReportReason where + toJSON = strToJSON + toEncoding = strToJEncoding + data ChatMessage e = ChatMessage { chatVRange :: VersionRangeChat, msgId :: Maybe SharedMsgId, @@ -451,7 +483,7 @@ cmToQuotedMsg = \case ACME _ (XMsgNew (MCQuote quotedMsg _)) -> Just quotedMsg _ -> Nothing -data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCVideo_ | MCVoice_ | MCFile_ | MCUnknown_ Text +data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCVideo_ | MCVoice_ | MCFile_ | MCReport_ | MCUnknown_ Text deriving (Eq) instance StrEncoding MsgContentTag where @@ -462,6 +494,7 @@ instance StrEncoding MsgContentTag where MCVideo_ -> "video" MCFile_ -> "file" MCVoice_ -> "voice" + MCReport_ -> "report" MCUnknown_ t -> encodeUtf8 t strDecode = \case "text" -> Right MCText_ @@ -470,6 +503,7 @@ instance StrEncoding MsgContentTag where "video" -> Right MCVideo_ "voice" -> Right MCVoice_ "file" -> Right MCFile_ + "report" -> Right MCReport_ t -> Right . MCUnknown_ $ safeDecodeUtf8 t strP = strDecode <$?> A.takeTill (== ' ') @@ -480,6 +514,8 @@ instance ToJSON MsgContentTag where toJSON = strToJSON toEncoding = strToJEncoding +instance ToField MsgContentTag where toField = toField . safeDecodeUtf8 . strEncode + data MsgContainer = MCSimple ExtMsgContent | MCQuote QuotedMsg ExtMsgContent @@ -504,6 +540,7 @@ data MsgContent | MCVideo {text :: Text, image :: ImageData, duration :: Int} | MCVoice {text :: Text, duration :: Int} | MCFile Text + | MCReport {text :: Text, reason :: ReportReason} | MCUnknown {tag :: Text, text :: Text, json :: J.Object} deriving (Eq, Show) @@ -518,6 +555,10 @@ msgContentText = \case where msg = "voice message " <> durationText duration MCFile t -> t + MCReport {text, reason} -> + if T.null text then msg else msg <> ": " <> text + where + msg = "report " <> safeDecodeUtf8 (strEncode reason) MCUnknown {text} -> text toMCText :: MsgContent -> MsgContent @@ -532,16 +573,9 @@ durationText duration = | otherwise = show n msgContentHasText :: MsgContent -> Bool -msgContentHasText = \case - MCText t -> hasText t - MCLink {text} -> hasText text - MCImage {text} -> hasText text - MCVideo {text} -> hasText text - MCVoice {text} -> hasText text - MCFile t -> hasText t - MCUnknown {text} -> hasText text - where - hasText = not . T.null +msgContentHasText = not . T.null . \case + MCVoice {text} -> text + mc -> msgContentText mc isVoice :: MsgContent -> Bool isVoice = \case @@ -556,6 +590,7 @@ msgContentTag = \case MCVideo {} -> MCVideo_ MCVoice {} -> MCVoice_ MCFile {} -> MCFile_ + MCReport {} -> MCReport_ MCUnknown {tag} -> MCUnknown_ tag data ExtMsgContent = ExtMsgContent {content :: MsgContent, file :: Maybe FileInvitation, ttl :: Maybe Int, live :: Maybe Bool} @@ -654,6 +689,10 @@ instance FromJSON MsgContent where duration <- v .: "duration" pure MCVoice {text, duration} MCFile_ -> MCFile <$> v .: "text" + MCReport_ -> do + text <- v .: "text" + reason <- v .: "reason" + pure MCReport {text, reason} MCUnknown_ tag -> do text <- fromMaybe unknownMsgType <$> v .:? "text" pure MCUnknown {tag, text, json = v} @@ -681,6 +720,7 @@ instance ToJSON MsgContent where MCVideo {text, image, duration} -> J.object ["type" .= MCVideo_, "text" .= text, "image" .= image, "duration" .= duration] MCVoice {text, duration} -> J.object ["type" .= MCVoice_, "text" .= text, "duration" .= duration] MCFile t -> J.object ["type" .= MCFile_, "text" .= t] + MCReport {text, reason} -> J.object ["type" .= MCReport_, "text" .= text, "reason" .= reason] toEncoding = \case MCUnknown {json} -> JE.value $ J.Object json MCText t -> J.pairs $ "type" .= MCText_ <> "text" .= t @@ -689,6 +729,7 @@ instance ToJSON MsgContent where MCVideo {text, image, duration} -> J.pairs $ "type" .= MCVideo_ <> "text" .= text <> "image" .= image <> "duration" .= duration MCVoice {text, duration} -> J.pairs $ "type" .= MCVoice_ <> "text" .= text <> "duration" .= duration MCFile t -> J.pairs $ "type" .= MCFile_ <> "text" .= t + MCReport {text, reason} -> J.pairs $ "type" .= MCReport_ <> "text" .= text <> "reason" .= reason instance ToField MsgContent where toField = toField . encodeJSON diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index cfc4fe2fa0..070c38e2c6 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -73,11 +73,11 @@ import UnliftIO.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExis -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [6, 2, 0, 7] +minRemoteCtrlVersion = AppVersion [6, 2, 4, 0] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 2, 0, 7] +minRemoteHostVersion = AppVersion [6, 2, 4, 0] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 36ce7f3575..51d23cf1e9 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -49,6 +49,7 @@ module Simplex.Chat.Store.Groups getGroupMemberById, getGroupMemberByMemberId, getGroupMembers, + getGroupModerators, getGroupMembersForExpiration, getGroupCurrentMembersCount, deleteGroupConnectionsAndFiles, @@ -740,8 +741,16 @@ getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do map (toContactMember vr user) <$> DB.query db - (groupMemberQuery <> " WHERE m.group_id = ? AND m.user_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") - (userId, groupId, userId, userContactId) + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") + (userId, userId, groupId, userContactId) + +getGroupModerators :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] +getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = do + map (toContactMember vr user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND member_role IN (?,?,?)") + (userId, userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) getGroupMembersForExpiration :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo {groupId} = do diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 13cadcca77..83cac97b18 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -405,21 +405,21 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q -- user and IDs user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, -- meta - item_sent, item_ts, item_content, item_content_tag, item_text, item_status, shared_msg_id, + item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id, forwarded_by_group_member_id, created_at, updated_at, item_live, timed_ttl, timed_delete_at, -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, -- forwarded from fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((userId, msgId_) :. idsRow :. itemRow :. quoteRow :. forwardedFromRow) ciId <- insertedRowId db forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt pure ciId where - itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe Bool) :. (Maybe Int, Maybe UTCTime) - itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, sharedMsgId, forwardedByMember) :. (createdAt, createdAt, justTrue live) :. ciTimedRow timed + itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe Bool) :. (Maybe Int, Maybe UTCTime) + itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember) :. (createdAt, createdAt, justTrue live) :. ciTimedRow timed idsRow :: (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) idsRow = case chatDirection of CDDirectRcv Contact {contactId} -> (Just contactId, Nothing, Nothing, Nothing) @@ -3051,9 +3051,10 @@ getGroupHistoryItems db user@User {userId} GroupInfo {groupId} m count = do LEFT JOIN group_snd_item_statuses s ON s.chat_item_id = i.chat_item_id AND s.group_member_id = ? WHERE i.user_id = ? AND i.group_id = ? AND i.item_content_tag IN (?,?) + AND i.msg_content_tag NOT IN (?) AND i.item_deleted = 0 AND s.group_snd_item_status_id IS NULL ORDER BY i.item_ts DESC, i.chat_item_id DESC LIMIT ? |] - (groupMemberId' m, userId, groupId, rcvMsgContentTag, sndMsgContentTag, count) + (groupMemberId' m, userId, groupId, rcvMsgContentTag, sndMsgContentTag, MCReport_, count) diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 7d4d96dff2..8710641b33 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -120,6 +120,8 @@ import Simplex.Chat.Migrations.M20241125_indexes import Simplex.Chat.Migrations.M20241128_business_chats import Simplex.Chat.Migrations.M20241205_business_chat_members import Simplex.Chat.Migrations.M20241222_operator_conditions +import Simplex.Chat.Migrations.M20241223_chat_tags +import Simplex.Chat.Migrations.M20241230_reports import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -239,7 +241,9 @@ schemaMigrations = ("20241125_indexes", m20241125_indexes, Just down_m20241125_indexes), ("20241128_business_chats", m20241128_business_chats, Just down_m20241128_business_chats), ("20241205_business_chat_members", m20241205_business_chat_members, Just down_m20241205_business_chat_members), - ("20241222_operator_conditions", m20241222_operator_conditions, Just down_m20241222_operator_conditions) + ("20241222_operator_conditions", m20241222_operator_conditions, Just down_m20241222_operator_conditions), + ("20241223_chat_tags", m20241223_chat_tags, Just down_m20241223_chat_tags), + ("20241230_reports", m20241230_reports, Just down_m20241230_reports) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 013075841e..5f72915701 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -617,22 +617,17 @@ getUpdateServerOperators :: DB.Connection -> NonEmpty PresetOperator -> Bool -> getUpdateServerOperators db presetOps newUser = do conds <- map toUsageConditions <$> DB.query_ db usageCondsQuery now <- getCurrentTime - let (acceptForSimplex_, currentConds, condsToAdd) = usageConditionsToAdd newUser now conds + let (currentConds, condsToAdd) = usageConditionsToAdd newUser now conds mapM_ insertConditions condsToAdd latestAcceptedConds_ <- getLatestAcceptedConditions db ops <- updatedServerOperators presetOps <$> getServerOperators_ db forM ops $ traverse $ mapM $ \(ASO _ op) -> -- traverse for tuple, mapM for Maybe case operatorId op of - DBNewEntity -> do - op' <- insertOperator op - case (operatorTag op', acceptForSimplex_) of - (Just OTSimplex, Just cond) -> autoAcceptConditions op' cond now - _ -> pure op' + DBNewEntity -> insertOperator op DBEntityId _ -> do updateOperator op getOperatorConditions_ db op currentConds latestAcceptedConds_ now >>= \case - CARequired Nothing | operatorTag op == Just OTSimplex -> autoAcceptConditions op currentConds now CARequired (Just ts) | ts < now -> autoAcceptConditions op currentConds now ca -> pure op {conditionsAcceptance = ca} where diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 77a02a4bc1..d5b45b35c0 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -411,12 +411,12 @@ data GroupSummary = GroupSummary } deriving (Show) -data ContactOrGroup = CGContact Contact | CGGroup Group +data ContactOrGroup = CGContact Contact | CGGroup GroupInfo [GroupMember] contactAndGroupIds :: ContactOrGroup -> (Maybe ContactId, Maybe GroupId) contactAndGroupIds = \case CGContact Contact {contactId} -> (Just contactId, Nothing) - CGGroup (Group GroupInfo {groupId} _) -> (Nothing, Just groupId) + CGGroup GroupInfo {groupId} _ -> (Nothing, Just groupId) -- TODO when more settings are added we should create another type to allow partial setting updates (with all Maybe properties) data ChatSettings = ChatSettings diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index f44457160f..4601fe4e4a 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -16,6 +16,7 @@ data GroupMemberRole = GRObserver -- connects to all group members and receives all messages, can't send messages | GRAuthor -- reserved, unused | GRMember -- + can send messages to all group members + | GRModerator -- + moderate messages and block members (excl. Admins and Owners) | GRAdmin -- + add/remove members, change member role (excl. Owners) | GROwner -- + delete and change group information, add/remove/change roles for Owners deriving (Eq, Show, Ord) @@ -28,12 +29,14 @@ instance StrEncoding GroupMemberRole where strEncode = \case GROwner -> "owner" GRAdmin -> "admin" + GRModerator -> "moderator" GRMember -> "member" GRAuthor -> "author" GRObserver -> "observer" strDecode = \case "owner" -> Right GROwner "admin" -> Right GRAdmin + "moderator" -> Right GRModerator "member" -> Right GRMember "author" -> Right GRAuthor "observer" -> Right GRObserver diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 95d27801f6..960b4bd96e 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1231,14 +1231,14 @@ testOperators = alice ##> "/_conditions" alice <##. "Current conditions: 2." alice ##> "/_operators" - alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required (" + alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required" alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: required" alice <##. "The new conditions will be accepted for SimpleX Chat Ltd at " -- set conditions notified alice ##> "/_conditions_notified 2" alice <## "ok" alice ##> "/_operators" - alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required (" + alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required" alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: required" alice ##> "/_conditions" alice <##. "Current conditions: 2 (notified)." diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index a185b0038e..7f1a9d3e56 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -175,6 +175,8 @@ chatGroupTests = do it "can't repeat block, unblock" testBlockForAllCantRepeat describe "group member inactivity" $ do it "mark member inactive on reaching quota" testGroupMemberInactive + describe "group member reports" $ do + it "should send report to group owner, admins and moderators, but not other users" testGroupMemberReports where _0 = supportedChatVRange -- don't create direct connections _1 = groupCreateDirectVRange @@ -6540,3 +6542,61 @@ testGroupMemberInactive tmp = do { smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003"] } } + +testGroupMemberReports :: HasCallStack => FilePath -> IO () +testGroupMemberReports = + testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + createGroup3 "jokes" alice bob cath + alice ##> "/mr jokes bob moderator" + concurrentlyN_ + [ alice <## "#jokes: you changed the role of bob from admin to moderator", + bob <## "#jokes: alice changed your role from admin to moderator", + cath <## "#jokes: alice changed the role of bob from admin to moderator" + ] + alice ##> "/mr jokes cath member" + concurrentlyN_ + [ alice <## "#jokes: you changed the role of cath from admin to member", + bob <## "#jokes: alice changed the role of cath from admin to member", + cath <## "#jokes: alice changed your role from admin to member" + ] + alice ##> "/create link #jokes" + gLink <- getGroupLink alice "jokes" GRMember True + dan ##> ("/c " <> gLink) + dan <## "connection request sent!" + concurrentlyN_ + [ do + alice <## "dan (Daniel): accepting request to join group #jokes..." + alice <## "#jokes: dan joined the group", + do + dan <## "#jokes: joining the group..." + dan <## "#jokes: you joined the group" + dan <### + [ "#jokes: member bob (Bob) is connected", + "#jokes: member cath (Catherine) is connected" + ], + do + bob <## "#jokes: alice added dan (Daniel) to the group (connecting...)" + bob <## "#jokes: new member dan is connected", + do + cath <## "#jokes: alice added dan (Daniel) to the group (connecting...)" + cath <## "#jokes: new member dan is connected" + ] + cath #> "#jokes inappropriate joke" + concurrentlyN_ + [ alice <# "#jokes cath> inappropriate joke", + bob <# "#jokes cath> inappropriate joke", + dan <# "#jokes cath> inappropriate joke" + ] + dan ##> "/report #jokes content inappropriate joke" + dan <# "#jokes > cath inappropriate joke" + dan <## " report content" + concurrentlyN_ + [ do + alice <# "#jokes dan> > cath inappropriate joke" + alice <## " report content", + do + bob <# "#jokes dan> > cath inappropriate joke" + bob <## " report content", + (cath show currentChatVersion <> ")") groupInfo a = do a <## "group ID: 1" a <## "current members: 1" diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 523df81ade..aba67ed034 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -133,7 +133,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-11\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-12\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -182,6 +182,12 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ) ) ) + it "x.msg.new report" $ + "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"reason\":\"spam\",\"type\":\"report\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" + ##==## ChatMessage + chatInitialVRange + (Just $ SharedMsgId "\1\2\3\4") + (XMsgNew (MCQuote quotedMsg (extMsgContent (MCReport "" RRSpam) Nothing))) it "x.msg.new forward with file" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) @@ -243,13 +249,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-11\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-12\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-11\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-12\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" @@ -264,7 +270,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-11\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-12\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"