From 7e344b3ee88ee84a2f85bddb288f5ea825e2ad7c Mon Sep 17 00:00:00 2001 From: Diogo Date: Wed, 8 Jan 2025 18:28:45 +0000 Subject: [PATCH] ios: reports inline (#5466) * initial types * changes types * decode * possible mock for inline report * remove avatar * diff * updates * parser and display message * send messages and support placeholder * profile reports and all reports working * new api * check member support for receiving reports * report chat item text * moderator role * placeholder on text compose for report * rename method * remove need to have reported item in memory to action * archived reports * changes/fix * fix block member * delete and moderate * archive * report reason * context menu/moderation fixes * typo * not needed * report reason as caption, and change text * remove auto archive * move placeholder to match text * prefix red italic report * archive * apply mark deleted fix * Revert "apply mark deleted fix" This reverts commit b12f14c0f54af84d53a7d6c61e1fd316c950e085. * remove extra space * context menu rework * strings, icons * recheck items extra check on reports * simplify * simpler * reports: never show for own messages, disable attachments, hide when recording or live * style, allow local deletion --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/SimpleXAPI.swift | 18 +- .../Views/Chat/ChatItem/FramedItemView.swift | 23 +- .../Chat/ChatItem/MarkedDeletedItemView.swift | 14 +- .../Views/Chat/ChatItem/MsgContentView.swift | 10 +- apps/ios/Shared/Views/Chat/ChatView.swift | 260 ++++++++++++++++-- .../Chat/ComposeMessage/ComposeView.swift | 93 ++++++- .../Chat/ComposeMessage/ContextItemView.swift | 5 +- .../ComposeMessage/NativeTextEditor.swift | 30 ++ .../Chat/ComposeMessage/SendMessageView.swift | 3 + .../Chat/Group/AddGroupMembersView.swift | 6 +- .../Chat/Group/GroupMemberInfoView.swift | 34 ++- .../Chat/SelectableChatItemToolbars.swift | 8 +- .../Views/ChatList/ChatPreviewView.swift | 23 +- apps/ios/SimpleXChat/APITypes.swift | 6 + apps/ios/SimpleXChat/ChatTypes.swift | 172 ++++++++++-- .../chat/simplex/common/model/ChatModel.kt | 6 +- .../chat/simplex/common/model/SimpleXAPI.kt | 5 +- 17 files changed, 617 insertions(+), 99 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 51be3191ec..ae9f0e3e19 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -15,12 +15,6 @@ import SimpleXChat private var chatController: chat_ctrl? -// currentChatVersion in core -public let CURRENT_CHAT_VERSION: Int = 2 - -// version range that supports establishing direct connection with a group member (xGrpDirectInvVRange in core) -public let CREATE_MEMBER_CONTACT_VRANGE = VersionRange(minVersion: 2, maxVersion: CURRENT_CHAT_VERSION) - private let networkStatusesLock = DispatchQueue(label: "chat.simplex.app.network-statuses.lock") enum TerminalItem: Identifiable { @@ -418,6 +412,18 @@ func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage] return nil } +func apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) async -> [ChatItem]? { + let r = await chatSendCmd(.apiReportMessage(groupId: groupId, chatItemId: chatItemId, reportReason: reportReason, reportText: reportText)) + if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.chatItem } } + + logger.error("apiReportMessage error: \(String(describing: r))") + AlertManager.shared.showAlertMsg( + title: "Error creating report", + message: "Error: \(responseError(r))" + ) + return nil +} + private func sendMessageErrorAlert(_ r: ChatResponse) { logger.error("send message error: \(String(describing: r))") AlertManager.shared.showAlertMsg( diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 9b71e6c4a4..6da893d1d2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -30,7 +30,17 @@ struct FramedItemView: View { var body: some View { let v = ZStack(alignment: .bottomTrailing) { VStack(alignment: .leading, spacing: 0) { - if let di = chatItem.meta.itemDeleted { + if chatItem.isReport { + if chatItem.meta.itemDeleted == nil { + let txt = chatItem.chatDir.sent ? + Text("Only you and moderators see it") : + Text("Only sender and moderators see it") + + framedItemHeader(icon: "flag", iconColor: .red, caption: txt.italic()) + } else { + framedItemHeader(icon: "flag", caption: Text("archived report").italic()) + } + } else if let di = chatItem.meta.itemDeleted { switch di { case let .moderated(_, byGroupMember): framedItemHeader(icon: "flag", caption: Text("moderated by \(byGroupMember.displayName)").italic()) @@ -144,6 +154,8 @@ struct FramedItemView: View { } case let .file(text): ciFileView(chatItem, text) + case let .report(text, reason): + ciMsgContentView(chatItem, Text(text.isEmpty ? reason.text : "\(reason.text): ").italic().foregroundColor(.red)) case let .link(_, preview): CILinkView(linkPreview: preview) ciMsgContentView(chatItem) @@ -159,13 +171,14 @@ struct FramedItemView: View { } } - @ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text, pad: Bool = false) -> some View { + @ViewBuilder func framedItemHeader(icon: String? = nil, iconColor: Color? = nil, caption: Text, pad: Bool = false) -> some View { let v = HStack(spacing: 6) { if let icon = icon { Image(systemName: icon) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 14, height: 14) + .foregroundColor(iconColor ?? theme.colors.secondary) } caption .font(.caption) @@ -228,7 +241,6 @@ struct FramedItemView: View { .overlay { if case .voice = chatItem.content.msgContent {} else { DetermineWidth() } } .frame(minWidth: msgWidth, alignment: .leading) .background(chatItemFrameContextColor(chatItem, theme)) - if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth { v.frame(maxWidth: mediaWidth, alignment: .leading) } else { @@ -281,7 +293,7 @@ struct FramedItemView: View { } } - @ViewBuilder private func ciMsgContentView(_ ci: ChatItem) -> some View { + @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, _ txtPrefix: Text? = nil) -> some View { let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text let rtl = isRightToLeft(text) let ft = text == "" ? [] : ci.formattedText @@ -291,7 +303,8 @@ struct FramedItemView: View { formattedText: ft, meta: ci.meta, rightToLeft: rtl, - showSecrets: showSecrets + showSecrets: showSecrets, + prefix: txtPrefix )) .multilineTextAlignment(rtl ? .trailing : .leading) .padding(.vertical, 6) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index c2b4021edc..87a9b2ce61 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -67,11 +67,15 @@ struct MarkedDeletedItemView: View { // same texts are in markedDeletedText in ChatPreviewView, but it returns String; // can be refactored into a single function if functions calling these are changed to return same type var markedDeletedText: LocalizedStringKey { - switch chatItem.meta.itemDeleted { - case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)" - case .blocked: "blocked" - case .blockedByAdmin: "blocked by admin" - case .deleted, nil: "marked deleted" + if chatItem.meta.itemDeleted != nil, chatItem.isReport { + "archived report" + } else { + switch chatItem.meta.itemDeleted { + case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)" + case .blocked: "blocked" + case .blockedByAdmin: "blocked by admin" + case .deleted, nil: "marked deleted" + } } } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 914f7c8a2f..e9b6d0ba84 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -34,6 +34,7 @@ struct MsgContentView: View { var meta: CIMeta? = nil var rightToLeft = false var showSecrets: Bool + var prefix: Text? = nil @State private var typingIdx = 0 @State private var timer: Timer? @@ -67,7 +68,7 @@ struct MsgContentView: View { } private func msgContentView() -> Text { - var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary) + var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary, prefix: prefix) if let mt = meta { if mt.isLive { v = v + typingIndicator(mt.recent) @@ -89,9 +90,10 @@ struct MsgContentView: View { } } -func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color) -> Text { +func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color, prefix: Text? = nil) -> Text { let s = text var res: Text + if let ft = formattedText, ft.count > 0 && ft.count <= 200 { res = formatText(ft[0], preview, showSecret: showSecrets) var i = 1 @@ -106,6 +108,10 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St if let i = icon { res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + textSpace + res } + + if let p = prefix { + res = p + res + } if let s = sender { let t = Text(s) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 90c277ce76..285bdf2d98 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 all moderators and reporter."), + 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 a18de1b349..58e22e63a2 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -296,7 +296,7 @@ struct GroupMemberInfoView: View { } else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) { if let contactId = member.memberContactId { newDirectChatButton(contactId, width: buttonWidth) - } else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false { + } else if member.versionRange.maxVersion >= CREATE_MEMBER_CONTACT_VERSION { createMemberContactButton(width: buttonWidth) } InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert() @@ -764,12 +764,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() ) @@ -780,23 +786,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 ae7e67b32f..71a5132d12 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -45,6 +45,7 @@ public enum ChatCommand { case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) + case apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool) case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64]) @@ -209,6 +210,8 @@ public enum ChatCommand { case let .apiCreateChatItems(noteFolderId, composedMessages): let msgs = encodeJSON(composedMessages) return "/_create *\(noteFolderId) json \(msgs)" + case let .apiReportMessage(groupId, chatItemId, reportReason, reportText): + return "/_report #\(groupId) \(chatItemId) reason=\(reportReason) \(reportText)" case let .apiUpdateChatItem(type, id, itemId, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)" case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))" @@ -372,6 +375,7 @@ public enum ChatCommand { case .apiGetChatItemInfo: return "apiGetChatItemInfo" case .apiSendMessages: return "apiSendMessages" case .apiCreateChatItems: return "apiCreateChatItems" + case .apiReportMessage: return "apiReportMessage" case .apiUpdateChatItem: return "apiUpdateChatItem" case .apiDeleteChatItem: return "apiDeleteChatItem" case .apiConnectContactViaAddress: return "apiConnectContactViaAddress" @@ -1162,12 +1166,14 @@ public enum ChatPagination { case last(count: Int) case after(chatItemId: Int64, count: Int) case before(chatItemId: Int64, count: Int) + case around(chatItemId: Int64, count: Int) var cmdString: String { switch self { case let .last(count): return "count=\(count)" case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)" case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)" + case let .around(chatItemId, count): return "around=\(chatItemId) count=\(count)" } } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index f8711ff779..2638c56776 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -9,6 +9,12 @@ import Foundation import SwiftUI +// version to establishing direct connection with a group member (xGrpDirectInvVRange in core) +public let CREATE_MEMBER_CONTACT_VERSION = 2 + +// version to receive reports (MCReport) +public let REPORTS_VERSION = 12 + public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var userId: Int64 public var agentUserId: String @@ -1678,7 +1684,7 @@ public struct Connection: Decodable, Hashable { static let sampleData = Connection( connId: 1, agentConnId: "abc", - peerChatVRange: VersionRange(minVersion: 1, maxVersion: 1), + peerChatVRange: VersionRange(1, 1), connStatus: .ready, connLevel: 0, viaGroupLink: false, @@ -1690,17 +1696,13 @@ public struct Connection: Decodable, Hashable { } public struct VersionRange: Decodable, Hashable { - public init(minVersion: Int, maxVersion: Int) { + public init(_ minVersion: Int, _ maxVersion: Int) { self.minVersion = minVersion self.maxVersion = maxVersion } public var minVersion: Int public var maxVersion: Int - - public func isCompatibleRange(_ vRange: VersionRange) -> Bool { - self.minVersion <= vRange.maxVersion && vRange.minVersion <= self.maxVersion - } } public struct SecurityCode: Decodable, Equatable, Hashable { @@ -1752,7 +1754,7 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable { public static let sampleData = UserContactRequest( contactRequestId: 1, userContactLinkId: 1, - cReqChatVRange: VersionRange(minVersion: 1, maxVersion: 1), + cReqChatVRange: VersionRange(1, 1), localDisplayName: "alice", profile: Profile.sampleData, createdAt: .now, @@ -1989,6 +1991,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var memberContactId: Int64? public var memberContactProfileId: Int64 public var activeConn: Connection? + public var memberChatVRange: VersionRange public var id: String { "#\(groupId) @\(groupMemberId)" } public var displayName: String { @@ -2083,7 +2086,19 @@ public struct GroupMember: Identifiable, Decodable, Hashable { return memberStatus != .memRemoved && memberStatus != .memLeft && memberRole < .admin && userRole >= .admin && userRole >= memberRole && groupInfo.membership.memberActive } + + public var canReceiveReports: Bool { + memberRole >= .moderator && versionRange.maxVersion >= REPORTS_VERSION + } + public var versionRange: VersionRange { + if let activeConn { + activeConn.peerChatVRange + } else { + memberChatVRange + } + } + public var memberIncognito: Bool { memberProfile.profileId != memberContactProfileId } @@ -2102,7 +2117,8 @@ public struct GroupMember: Identifiable, Decodable, Hashable { memberProfile: LocalProfile.sampleData, memberContactId: 1, memberContactProfileId: 1, - activeConn: Connection.sampleData + activeConn: Connection.sampleData, + memberChatVRange: VersionRange(2, 12) ) } @@ -2121,19 +2137,23 @@ public struct GroupMemberIds: Decodable, Hashable { } public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Codable, Hashable { - case observer = "observer" - case author = "author" - case member = "member" - case admin = "admin" - case owner = "owner" + case observer + case author + case member + case moderator + case admin + case owner public var id: Self { self } + public static var supportedRoles: [GroupMemberRole] = [.observer, .member, .admin, .owner] + public var text: String { switch self { case .observer: return NSLocalizedString("observer", comment: "member role") case .author: return NSLocalizedString("author", comment: "member role") case .member: return NSLocalizedString("member", comment: "member role") + case .moderator: return NSLocalizedString("moderator", comment: "member role") case .admin: return NSLocalizedString("admin", comment: "member role") case .owner: return NSLocalizedString("owner", comment: "member role") } @@ -2141,11 +2161,12 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Cod private var comparisonValue: Int { switch self { - case .observer: return 0 - case .author: return 1 - case .member: return 2 - case .admin: return 3 - case .owner: return 4 + case .observer: 0 + case .author: 1 + case .member: 2 + case .moderator: 3 + case .admin: 4 + case .owner: 5 } } @@ -2551,6 +2572,17 @@ public struct ChatItem: Identifiable, Decodable, Hashable { default: return true } } + + public var isReport: Bool { + switch content { + case let .sndMsgContent(msgContent), let .rcvMsgContent(msgContent): + switch msgContent { + case .report: true + default: false + } + default: false + } + } public var canBeDeletedForSelf: Bool { (content.msgContent != nil && !meta.isLive) || meta.itemDeleted != nil || isDeletedContent || mergeCategory != nil || showLocalDelete @@ -2636,6 +2668,34 @@ public struct ChatItem: Identifiable, Decodable, Hashable { file: nil ) } + + public static func getReportSample(text: String, reason: ReportReason, item: ChatItem, sender: GroupMember? = nil) -> ChatItem { + let chatDir = if let sender = sender { + CIDirection.groupRcv(groupMember: sender) + } else { + CIDirection.groupSnd + } + + return ChatItem( + chatDir: chatDir, + meta: CIMeta( + itemId: -2, + itemTs: .now, + itemText: "", + itemStatus: .rcvRead, + createdAt: .now, + updatedAt: .now, + itemDeleted: nil, + itemEdited: false, + itemLive: false, + deletable: false, + editable: false + ), + content: .sndMsgContent(msgContent: .report(text: text, reason: reason)), + quotedItem: CIQuote.getSample(item.id, item.meta.createdAt, item.text, chatDir: item.chatDir), + file: nil + ) + } public static func deletedItemDummy() -> ChatItem { ChatItem( @@ -3250,14 +3310,12 @@ public struct CIQuote: Decodable, ItemContent, Hashable { public var sentAt: Date public var content: MsgContent public var formattedText: [FormattedText]? - public var text: String { switch (content.text, content) { case let ("", .voice(_, duration)): return durationText(duration) default: return content.text } } - public func getSender(_ membership: GroupMember?) -> String? { switch (chatDir) { case .directSnd: return "you" @@ -3279,6 +3337,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 { @@ -3620,6 +3689,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) @@ -3631,6 +3701,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 } } @@ -3690,6 +3761,7 @@ public enum MsgContent: Equatable, Hashable { case preview case image case duration + case reason } public static func == (lhs: MsgContent, rhs: MsgContent) -> Bool { @@ -3700,6 +3772,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 } @@ -3735,6 +3808,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") @@ -3772,6 +3849,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) @@ -3851,6 +3932,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 ac33917588..2ff5bb157d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1383,11 +1383,7 @@ data class Connection( } @Serializable -data class VersionRange(val minVersion: Int, val maxVersion: Int) { - - fun isCompatibleRange(vRange: VersionRange): Boolean = - this.minVersion <= vRange.maxVersion && vRange.minVersion <= this.maxVersion -} +data class VersionRange(val minVersion: Int, val maxVersion: Int) @Serializable data class SecurityCode(val securityCode: String, val verifiedAt: Instant) 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 e78472d2d4..0594bd9711 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,