diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 7eb78edf74..4744eeefae 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 { @@ -460,6 +454,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 32b4fab291..e843dd5e7f 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,21 @@ 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 let groupInfo = chat.chatInfo.groupInfo, ci.isReport, ci.meta.itemDeleted == nil { + if ci.chatDir == .groupSnd { + deleteButton(ci) + } else { + archiveReportButton(ci) + if let qi = ci.quotedItem { + moderateReportedButton(qi, ci, groupInfo) + if let rMember = qi.memberToModerate(chat.chatInfo) { + if !rMember.blockedByAdmin, rMember.canBlockForAll(groupInfo: groupInfo) { + blockMemberButton(rMember, groupInfo, qi, ci) + } + } + } + } + } 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 +1348,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 < .moderator, !live, composeState.voiceMessageRecordingState == .noRecording { + reportButton(ci) + } } } else if ci.meta.itemDeleted != nil { if revealed { @@ -1648,19 +1668,10 @@ struct ChatView: View { private func moderateButton(_ ci: ChatItem, _ groupInfo: GroupInfo) -> Button { Button(role: .destructive) { - 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." - ), - primaryButton: .destructive(Text("Delete")) { - deletingItem = ci - deleteMessage(.cidmBroadcast, moderate: true) - }, - secondaryButton: .cancel() - )) + showModerateMessageAlert(groupInfo) { + deletingItem = ci + deleteMessage(.cidmBroadcast, moderate: true) + } } label: { Label( NSLocalizedString("Moderate", comment: "chat item action"), @@ -1668,6 +1679,112 @@ struct ChatView: View { ) } } + + private func moderateReportedButton(_ rItem: CIQuote, _ reportItem: ChatItem, _ groupInfo: GroupInfo) -> Button { + Button(role: .destructive) { + showModerateMessageAlert(groupInfo) { + Task { + let deleted = await deleteReportedMessage(rItem, reportItem.id, groupInfo) + if deleted != nil { + await MainActor.run { + deletingItem = reportItem + deleteMessage(.cidmInternalMark, moderate: false) + } + } + } + } + } label: { + Label( + NSLocalizedString("Moderate", comment: "chat item action"), + systemImage: "flag" + ) + } + } + + 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( + NSLocalizedString("Archive", comment: "chat item action"), + systemImage: "archivebox" + ) + } + } + + private func blockMemberButton(_ member: GroupMember, _ groupInfo: GroupInfo, _ rItem: CIQuote, _ report: ChatItem) -> Button { + Button(role: .destructive) { + actionSheet = SomeActionSheet( + actionSheet: ActionSheet( + title: Text("Block and moderate?"), + buttons: [ + .destructive(Text("Block and moderate")) { + AlertManager.shared.showAlert( + Alert( + title: Text("Delete member message and block?"), + message: Text( + NSLocalizedString( + groupInfo.fullGroupPreferences.fullDelete.on + ? "The message will be deleted for all members.\nAll new messages from \(member.chatViewName) will be hidden!" + : "The message will be marked as moderated for all members.\n All new messages from \(member.chatViewName) will be hidden!" + , comment: "block and moderate action" + ) + ), + primaryButton: .destructive(Text("Delete and block")) { + Task { + let deleted = await deleteReportedMessage(rItem, report.id, groupInfo) + if deleted != nil { + let blocked = await blockMemberForAll(groupInfo, member, true) + + if blocked != nil { + await MainActor.run { + deletingItem = report + deleteMessage(.cidmInternalMark, moderate: false) + } + } + } + } + }, + secondaryButton: .cancel() + ) + ) + }, + .destructive(Text("Only block")) { + Task { + if (await getLocalIdForReportedMessage(rItem, report.id, groupInfo)) != nil { + AlertManager.shared.showAlert( + blockForAllAlert(groupInfo, member) { + deletingItem = report + deleteMessage(.cidmInternalMark, moderate: false) + } + ) + } else { + showNoMessageMessageAlert() + } + } + }, + .cancel() + ] + ), + id: "blockMember" + ) + } label: { + Label( + NSLocalizedString("Block member", comment: "chat item action"), + systemImage: "hand.raised" + ) + } + } private func revealButton(_ ci: ChatItem) -> Button { Button { @@ -1707,7 +1824,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?" @@ -1738,6 +1886,60 @@ struct ChatView: View { itemIds.forEach { selectedChatItems?.remove($0) } } } + + private func deleteReportedMessage(_ rItem: CIQuote, _ reportId: Int64, _ groupInfo: GroupInfo) async -> ChatItemDeletion? { + do { + let itemId = await getLocalIdForReportedMessage(rItem, reportId, groupInfo) + + if let itemId = itemId { + let deletedItem = try await apiDeleteMemberChatItems( + groupId: groupInfo.apiId, + itemIds: [itemId] + ).first + + if let di = deletedItem { + await MainActor.run { + if let toItem = di.toChatItem { + _ = m.upsertChatItem(chat.chatInfo, toItem.chatItem) + } else { + m.removeChatItem(chat.chatInfo, di.deletedChatItem.chatItem) + } + } + + return di + } + } else { + showNoMessageMessageAlert() + } + } catch { + logger.error("ChatView.deleteReportedMessage error: \(error)") + AlertManager.shared.showAlertMsg(title: LocalizedStringKey("Error"), message: LocalizedStringKey("Failed to delete reported message")) + } + + return nil + } + + private func getLocalIdForReportedMessage(_ rItem: CIQuote, _ reportId: Int64, _ groupInfo: GroupInfo) async -> Int64? { + do { + if let itemId = rItem.itemId { + return itemId + } else { + let reportItem = try await apiGetChatItems( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + pagination: .around(chatItemId: reportId, count: 0) + ).first + + if let itemId = reportItem?.quotedItem?.itemId { + return itemId + } + } + } catch { + logger.error("ChatView.getLocalIdForReportedMessage error: \(error)") + } + + return nil + } private func deleteMessage(_ mode: CIDeleteMode, moderate: Bool) { logger.debug("ChatView deleteMessage") @@ -1772,7 +1974,7 @@ struct ChatView: View { } } } catch { - logger.error("ChatView.deleteMessage error: \(error.localizedDescription)") + logger.error("ChatView.deleteMessage error: \(error)") } } } @@ -1812,6 +2014,26 @@ struct ChatView: View { } } +private func showModerateMessageAlert(_ groupInfo: GroupInfo, _ onModerate: @escaping () -> Void) { + 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." + ), + primaryButton: .destructive(Text("Delete"), action: onModerate), + secondaryButton: .cancel() + )) +} + +private func showNoMessageMessageAlert() { + AlertManager.shared.showAlertMsg( + title: LocalizedStringKey("No message"), + message: LocalizedStringKey("This message was deleted or not received yet.") + ) +} + private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey { chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" } 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 30972f7242..28f8426ad4 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(member, width: buttonWidth) } InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert() @@ -781,12 +781,18 @@ func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSet } } -func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { +func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember, _ onBlocked: (() -> Void)? = nil) -> Alert { Alert( title: Text("Block member for all?"), message: Text("All new messages from \(mem.chatViewName) will be hidden!"), primaryButton: .destructive(Text("Block for all")) { - blockMemberForAll(gInfo, mem, true) + Task { + let uMember = await blockMemberForAll(gInfo, mem, true) + + if uMember != nil { + onBlocked?() + } + } }, secondaryButton: .cancel() ) @@ -797,23 +803,25 @@ func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { title: Text("Unblock member for all?"), message: Text("Messages from \(mem.chatViewName) will be shown!"), primaryButton: .default(Text("Unblock for all")) { - blockMemberForAll(gInfo, mem, false) + Task { + await blockMemberForAll(gInfo, mem, false) + } }, secondaryButton: .cancel() ) } -func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) { - Task { - do { - let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked) - await MainActor.run { - _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember) - } - } catch let error { - logger.error("apiBlockMemberForAll error: \(responseError(error))") +func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) async -> GroupMember? { + do { + let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked) + await MainActor.run { + _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember) } + return updatedMember + } catch let error { + logger.error("apiBlockMemberForAll error: \(responseError(error))") } + return nil } struct GroupMemberInfoView_Previews: PreviewProvider { 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..a311db7d50 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(text, reason): return Text(!text.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/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 7459afe4f9..a07790728b 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -51,6 +51,7 @@ public enum ChatCommand { case apiUpdateChatTag(tagId: Int64, tagData: ChatTagData) case apiReorderChatTags(tagIds: [Int64]) 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]) @@ -221,6 +222,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: ","))" @@ -390,6 +393,7 @@ public enum ChatCommand { case .apiUpdateChatTag: return "apiUpdateChatTag" case .apiReorderChatTags: return "apiReorderChatTags" case .apiCreateChatItems: return "apiCreateChatItems" + case .apiReportMessage: return "apiReportMessage" case .apiUpdateChatItem: return "apiUpdateChatItem" case .apiDeleteChatItem: return "apiDeleteChatItem" case .apiConnectContactViaAddress: return "apiConnectContactViaAddress" @@ -1186,12 +1190,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)" } } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 571ac20684..f67b9e31e7 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 @@ -1695,7 +1701,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, @@ -1707,17 +1713,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 { @@ -1769,7 +1771,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, @@ -2008,6 +2010,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 ready: Bool { get { activeConn?.connStatus == .ready } } @@ -2110,7 +2113,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 } @@ -2129,7 +2144,8 @@ public struct GroupMember: Identifiable, Decodable, Hashable { memberProfile: LocalProfile.sampleData, memberContactId: 1, memberContactProfileId: 1, - activeConn: Connection.sampleData + activeConn: Connection.sampleData, + memberChatVRange: VersionRange(2, 12) ) } @@ -2148,19 +2164,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") } @@ -2168,11 +2188,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 } } @@ -2578,6 +2599,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 @@ -2663,6 +2695,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( @@ -3277,14 +3337,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" @@ -3306,6 +3364,17 @@ public struct CIQuote: Decodable, ItemContent, Hashable { } return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: mc) } + + public func memberToModerate(_ chatInfo: ChatInfo) -> GroupMember? { + switch (chatInfo, chatDir) { + case let (.group(groupInfo), .groupRcv(groupMember)): + let m = groupInfo.membership + return m.memberRole >= .admin && m.memberRole >= groupMember.memberRole + ? groupMember + : nil + default: return nil + } + } } public struct CIReactionCount: Decodable, Hashable { @@ -3649,6 +3718,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) @@ -3660,6 +3730,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 } } @@ -3719,6 +3790,7 @@ public enum MsgContent: Equatable, Hashable { case preview case image case duration + case reason } public static func == (lhs: MsgContent, rhs: MsgContent) -> Bool { @@ -3729,6 +3801,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 } @@ -3764,6 +3837,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") @@ -3801,6 +3878,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) @@ -3880,6 +3961,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/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index abbf4ae011..c78251c723 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 @@ -1556,11 +1556,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) @@ -1824,7 +1820,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 { @@ -1875,13 +1871,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) } @@ -2302,6 +2304,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 @@ -3132,6 +3140,19 @@ class CIQuote ( null -> null } + fun memberToModerate(chatInfo: ChatInfo): GroupMember? { + return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) { + val m = chatInfo.groupInfo.membership + if (m.memberRole >= GroupMemberRole.Moderator && m.memberRole >= chatDir.groupMember.memberRole) { + chatDir.groupMember + } else { + null + } + } else { + null + } + } + companion object { fun getSample(itemId: Long?, sentAt: Instant, text: String, chatDir: CIDirection?): CIQuote = CIQuote(chatDir = chatDir, itemId = itemId, sentAt = sentAt, content = MsgContent.MCText(text)) @@ -3777,6 +3798,19 @@ sealed class 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 { 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 ac9c1a131b..4630d77aa8 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, @@ -3386,7 +3383,7 @@ sealed class CC { val msgs = json.encodeToString(composedMessages) "/_create *$noteFolderId json $msgs" } - is ApiReportMessage -> "/_report #$groupId $chatItemId reason=$reportReason $reportText" + 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(",")}" 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 71e1a422a6..9db72b2f21 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 @@ -309,41 +309,41 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } }, deleteMessage = { itemId, mode -> - withBGApi { - val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId } - val toModerate = toDeleteItem?.memberToModerate(chatInfo) - val groupInfo = toModerate?.first - val groupMember = toModerate?.second - val deletedChatItem: ChatItem? - val toChatItem: ChatItem? - val r = if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) { - chatModel.controller.apiDeleteMemberChatItems( - chatRh, - groupId = groupInfo.groupId, - itemIds = listOf(itemId) - ) - } else { - chatModel.controller.apiDeleteChatItems( - chatRh, - type = chatInfo.chatType, - id = chatInfo.apiId, - itemIds = listOf(itemId), - mode = mode - ) - } - val deleted = r?.firstOrNull() - if (deleted != null) { - deletedChatItem = deleted.deletedChatItem.chatItem - toChatItem = deleted.toChatItem?.chatItem - withChats { - if (toChatItem != null) { - upsertChatItem(chatRh, chatInfo, toChatItem) - } else { - removeChatItem(chatRh, chatInfo, deletedChatItem) - } + val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId } + val toModerate = toDeleteItem?.memberToModerate(chatInfo) + val groupInfo = toModerate?.first + val groupMember = toModerate?.second + val deletedChatItem: ChatItem? + val toChatItem: ChatItem? + val r = if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) { + chatModel.controller.apiDeleteMemberChatItems( + chatRh, + groupId = groupInfo.groupId, + itemIds = listOf(itemId) + ) + } else { + chatModel.controller.apiDeleteChatItems( + chatRh, + type = chatInfo.chatType, + id = chatInfo.apiId, + itemIds = listOf(itemId), + mode = mode + ) + } + val deleted = r?.firstOrNull() + if (deleted != null) { + deletedChatItem = deleted.deletedChatItem.chatItem + toChatItem = deleted.toChatItem?.chatItem + withChats { + if (toChatItem != null) { + upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + removeChatItem(chatRh, chatInfo, deletedChatItem) } } } + + deleted }, deleteMessages = { itemIds -> deleteMessages(chatRh, chatInfo, itemIds, false, moderate = false) }, receiveFile = { fileId -> @@ -613,7 +613,7 @@ fun ChatLayout( info: () -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, - deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, @@ -960,7 +960,7 @@ fun BoxScope.ChatItemsList( showMemberInfo: (GroupInfo, GroupMember) -> Unit, showChatInfo: () -> Unit, loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, - deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, @@ -2449,7 +2449,7 @@ fun PreviewChatLayout() { info = {}, showMemberInfo = { _, _ -> }, loadMessages = { _, _, _, _ -> }, - deleteMessage = { _, _ -> }, + deleteMessage = { _, _ -> null }, deleteMessages = { _ -> }, receiveFile = { _ -> }, cancelFile = {}, @@ -2522,7 +2522,7 @@ fun PreviewGroupChatLayout() { info = {}, showMemberInfo = { _, _ -> }, loadMessages = { _, _, _, _ -> }, - deleteMessage = { _, _ -> }, + deleteMessage = { _, _ -> null }, deleteMessages = {}, receiveFile = { _ -> }, cancelFile = {}, 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 2247a615df..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 @@ -489,6 +512,19 @@ fun ComposeView( } } + 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) @@ -554,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() @@ -835,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) { @@ -856,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) + } } } @@ -893,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) @@ -1050,7 +1114,7 @@ fun ComposeView( sendButtonColor = sendButtonColor, timedMessageAllowed = timedMessageAllowed, customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, - placeholder = stringResource(MR.strings.compose_message_placeholder), + placeholder = composeState.value.placeholder, sendMessage = { ttl -> sendMessage(ttl) resetLinkPreview() 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 6072abfc36..fc637fa381 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 Unit = { withBGApi { blockMemberForAll(rhId, gInfo, mem, true) } }) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.block_for_all_question), text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.block_for_all), onConfirm = { - blockMemberForAll(rhId, gInfo, mem, true) + blockMember() }, destructive = true, ) @@ -775,17 +775,15 @@ fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.unblock_for_all), onConfirm = { - blockMemberForAll(rhId, gInfo, mem, false) + withBGApi { blockMemberForAll(rhId, gInfo, mem, false) } }, ) } -fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) { - withBGApi { - val updatedMember = ChatController.apiBlockMemberForAll(rhId, gInfo.groupId, member.groupMemberId, blocked) - withChats { - upsertGroupMember(rhId, gInfo, updatedMember) - } +suspend fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) { + val updatedMember = ChatController.apiBlockMemberForAll(rhId, gInfo.groupId, member.groupMemberId, blocked) + withChats { + upsertGroupMember(rhId, gInfo, updatedMember) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 647c74da06..1a094f613a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.chat.item +import SectionItemView import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.interaction.HoverInteraction @@ -20,14 +21,18 @@ import androidx.compose.ui.text.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.currentUser +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.group.blockForAllAlert +import chat.simplex.common.views.chat.group.blockMemberForAll import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -72,7 +77,7 @@ fun ChatItemView( selectedChatItems: MutableState?>, fillMaxWidth: Boolean = true, selectChatItem: () -> Unit, - deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, @@ -108,6 +113,12 @@ fun ChatItemView( val onLinkLongClick = { _: String -> showMenu.value = true } val live = remember { derivedStateOf { composeState.value.liveMessage != null } }.value + val deleteMessageAsync: (Long, CIDeleteMode) -> Unit = { id, mode -> + withBGApi { + deleteMessage(id, mode) + } + } + Box( modifier = if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier, contentAlignment = alignment, @@ -282,7 +293,7 @@ fun ChatItemView( @Composable fun DeleteItemMenu() { DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -295,7 +306,36 @@ fun ChatItemView( val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file) when { // cItem.id check is a special case for live message chat item which has negative ID while not sent yet - cItem.content.msgContent != null && cItem.id >= 0 -> { + cItem.isReport && cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group -> { + DefaultDropdownMenu(showMenu) { + if (cItem.chatDir is CIDirection.GroupSnd) { + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) + } else { + ArchiveReportItemAction(cItem, showMenu, deleteMessageAsync) + val qItem = cItem.quotedItem + if (qItem != null) { + ModerateReportItemAction(rhId, cInfo, cItem, qItem, showMenu, deleteMessage) + val rMember = qItem.memberToModerate(cInfo) + if (rMember != null && !rMember.blockedByAdmin && rMember.canBlockForAll(cInfo.groupInfo)) { + BlockMemberAction( + rhId, + chatInfo = cInfo, + groupInfo = cInfo.groupInfo, + cItem = cItem, + reportedItem = qItem, + member = rMember, + showMenu = showMenu, + deleteMessage = deleteMessage + ) + } + } + + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + cItem.content.msgContent != null && cItem.id >= 0 && !cItem.isReport -> { DefaultDropdownMenu(showMenu) { if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { MsgReactionsMenu() @@ -381,11 +421,15 @@ fun ChatItemView( CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) } if (!(live && cItem.meta.isLive) && !preview) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, 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, deleteMessageAsync) + } else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole < GroupMemberRole.Moderator && !live) { + ReportItemAction(cItem, composeState, showMenu) + } } if (cItem.canBeDeletedForSelf) { Divider() @@ -403,7 +447,7 @@ fun ChatItemView( ExpandItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -413,7 +457,7 @@ fun ChatItemView( cItem.isDeletedContent -> { DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -427,7 +471,7 @@ fun ChatItemView( } else { ExpandItemAction(revealed, showMenu, reveal) } - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -436,7 +480,7 @@ fun ChatItemView( } else -> { DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (selectedChatItems.value == null) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -453,7 +497,7 @@ fun ChatItemView( RevealItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -487,7 +531,7 @@ fun ChatItemView( DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -544,7 +588,7 @@ fun ChatItemView( MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -778,7 +822,7 @@ fun ModerateItemAction( painterResource(MR.images.ic_flag), onClick = { showMenu.value = false - moderateMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + moderateMessageAlertDialog(cItem.id, questionText, deleteMessage = deleteMessage) }, color = Color.Red ) @@ -847,6 +891,183 @@ 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 ModerateReportItemAction( + rhId: Long?, + chatInfo: ChatInfo, + cItem: ChatItem, + reportedItem: CIQuote, + showMenu: MutableState, + deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion? +) { + ItemAction( + stringResource(MR.strings.moderate_verb), + painterResource(MR.images.ic_flag), + onClick = { + withBGApi { + val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id) + if (reportedMessageId != null) { + moderateMessageAlertDialog( + reportedMessageId, + questionText = moderateMessageQuestionText(chatInfo.featureEnabled(ChatFeature.FullDelete), 1), + deleteMessage = { id, m -> + withApi { + val deleted = deleteMessage(id, m) + if (deleted != null) { + deleteMessage(cItem.id, CIDeleteMode.cidmInternalMark) + } + } + }, + ) + } + } + showMenu.value = false + }, + color = Color.Red + ) +} + +@Composable +private fun BlockMemberAction( + rhId: Long?, + chatInfo: ChatInfo, + groupInfo: GroupInfo, + cItem: ChatItem, + reportedItem: CIQuote, + member: GroupMember, + showMenu: MutableState, + deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion? +) { + ItemAction( + stringResource(MR.strings.block_member_button), + painterResource(MR.images.ic_back_hand), + onClick = { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.report_block_and_moderate_title), + buttons = { + SectionItemView({ + AlertManager.shared.hideAlert() + withBGApi { + val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id) + if (reportedMessageId != null) { + blockAndModerateAlertDialog( + rhId, + reportedMessageId = reportedMessageId, + reportId = cItem.id, + gInfo = groupInfo, + mem = member, + deleteMessage = deleteMessage, + ) + } + } + }) { + Text(generalGetString(MR.strings.report_block_and_moderate_block_and_moderate_action), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + SectionItemView({ + AlertManager.shared.hideAlert() + withBGApi { + val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id) + if (reportedMessageId != null) { + blockForAllAlert(rhId, gInfo = groupInfo, mem = member, blockMember = { + withBGApi { + try { + blockMemberForAll( + rhId, + gInfo = groupInfo, + member = member, + blocked = true + ) + deleteMessage(reportedMessageId, CIDeleteMode.cidmInternalMark) + } catch (ex: Exception) { + Log.e(TAG, "BlockMemberAction block and moderate ${ex.message}") + } + } + }) + } + } + }) { + Text(generalGetString(MR.strings.report_block_and_moderate_only_block_action), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(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_verb), + 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 +1354,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) @@ -1180,14 +1401,14 @@ fun moderateMessageQuestionText(fullDeleteAllowed: Boolean, count: Int): String } } -fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { +fun moderateMessageAlertDialog(chatItemId: Long, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.delete_member_message__question), text = questionText, confirmText = generalGetString(MR.strings.delete_verb), destructive = true, onConfirm = { - deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) + deleteMessage(chatItemId, CIDeleteMode.cidmBroadcast) } ) } @@ -1202,8 +1423,59 @@ fun moderateMessagesAlertDialog(itemIds: List, questionText: String, delet ) } +private fun blockAndModerateAlertDialog( + rhId: Long?, + reportedMessageId: Long, + reportId: Long, + gInfo: GroupInfo, + mem: GroupMember, + deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion? +) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.report_block_and_moderate_confirmation_title), + text = generalGetString( + if (gInfo.fullGroupPreferences.fullDelete.on) MR.strings.report_block_and_moderate_confirmation_desc_full_delete else MR.strings.report_block_and_moderate_confirmation_desc_full_delete).format(mem.chatViewName), + confirmText = generalGetString(MR.strings.report_block_and_moderate_confirmation_ok), + onConfirm = { + withBGApi { + try { + val deleted = deleteMessage(reportedMessageId, CIDeleteMode.cidmBroadcast) + if (deleted != null) { + blockMemberForAll(rhId, gInfo, mem, true) + deleteMessage(reportId, CIDeleteMode.cidmInternalMark) + } + } catch (ex: Exception) { + Log.e(TAG, "blockAndModerateAlertDialog block and moderate ${ex.message}") + } + } + }, + destructive = true, + ) +} + expect fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) +private suspend fun getLocalIdForReportedMessage( + rhId: Long?, + chatInfo: ChatInfo, + reportedItem: CIQuote, + itemId: Long): Long? { + if (reportedItem.itemId != null) { + return reportedItem.itemId + } + val item = apiLoadSingleMessage(rhId, chatInfo.chatType, chatInfo.apiId, itemId) + + if (item?.quotedItem?.itemId != null) { + withChats { + updateChatItem(chatInfo, item) + } + return item.quotedItem.itemId + } else { + showQuotedItemDoesNotExistAlert() + return null + } +} + @Preview @Composable fun PreviewChatItemView( @@ -1221,7 +1493,7 @@ fun PreviewChatItemView( range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, selectChatItem = {}, - deleteMessage = { _, _ -> }, + deleteMessage = { _, _ -> null }, deleteMessages = { _ -> }, receiveFile = { _ -> }, cancelFile = {}, @@ -1267,7 +1539,7 @@ fun PreviewChatItemViewDeletedContent() { range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, selectChatItem = {}, - deleteMessage = { _, _ -> }, + deleteMessage = { _, _ -> null }, deleteMessages = { _ -> }, receiveFile = { _ -> }, cancelFile = {}, 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/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 0e0c3e74f4..d0e9b003e2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.* import androidx.compose.ui.unit.* import chat.simplex.common.ui.theme.* @@ -174,13 +175,23 @@ fun ChatPreviewView( val (text: CharSequence, inlineTextContent) = when { chatModelDraftChatId == chat.id && chatModelDraft != null -> 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/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 04b025718a..a4dd5e7f06 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 @@ -295,6 +305,16 @@ 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. + Block and moderate? + Block and moderate + Only block + Delete member message and block? + The message will be deleted for all members.\nAll new messages from %1$s will be hidden! + The message will be marked as moderated for all members.\nAll new messages from %1$s will be hidden! + Delete and block Error: %1$s @@ -320,6 +340,7 @@ Edit Info Search + Archive Sent message Received message History @@ -337,6 +358,7 @@ Hide Allow Moderate + Report Select Expand Delete message? @@ -463,6 +485,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 @@ -1557,6 +1584,7 @@ observer author member + moderator admin owner diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index c8d31c713a..03ba45f23f 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -3020,9 +3020,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)