From 255538e5d73405bbc880bbdfb7ad19ee803f1b30 Mon Sep 17 00:00:00 2001 From: Arturs Krumins Date: Thu, 19 Sep 2024 10:04:19 +0300 Subject: [PATCH] ios: bulk forward (#4857) * ios: forward multiple messages * ios: batch previews, when sending media messsages (#4861) --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Co-authored-by: Arturs Krumins Co-authored-by: Diogo Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/SimpleXAPI.swift | 155 ++++++++++++------ .../Views/Chat/ChatItemForwardingView.swift | 21 ++- apps/ios/Shared/Views/Chat/ChatView.swift | 145 ++++++++++++++-- .../Chat/ComposeMessage/ComposeView.swift | 144 ++++++++-------- .../Chat/ComposeMessage/ContextItemView.swift | 46 ++++-- .../Chat/ComposeMessage/SendMessageView.swift | 1 + .../Chat/SelectableChatItemToolbars.swift | 23 ++- .../ios/Shared/Views/Helpers/ShareSheet.swift | 18 +- apps/ios/SimpleXChat/APITypes.swift | 15 +- 9 files changed, 404 insertions(+), 164 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 6bbacea245..fab3e10990 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -357,6 +357,12 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws - throw r } +func apiPlanForwardChatItems(type: ChatType, id: Int64, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) { + let r = await chatSendCmd(.apiPlanForwardChatItems(toChatType: type, toChatId: id, itemIds: itemIds)) + if case let .forwardPlan(_, chatItemIds, forwardConfimation) = r { return (chatItemIds, forwardConfimation) } + throw r +} + func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? { let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemIds: itemIds, ttl: ttl) return await processSendMessageCmd(toChatType: toChatType, cmd: cmd) @@ -1039,77 +1045,122 @@ func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationF } func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async { - if let chatItem = await apiReceiveFile( - fileId: fileId, - userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(), - encrypted: privacyEncryptLocalFilesGroupDefault.get(), + await receiveFiles( + user: user, + fileIds: [fileId], + userApprovedRelays: userApprovedRelays, auto: auto - ) { - await chatItemSimpleUpdate(user, chatItem) - } + ) } -func apiReceiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? { - let r = await chatSendCmd(.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline)) - let am = AlertManager.shared - if case let .rcvFileAccepted(_, chatItem) = r { return chatItem } - if case .rcvFileAcceptedSndCancelled = r { - logger.debug("apiReceiveFile error: sender cancelled file transfer") - if !auto { - am.showAlertMsg( - title: "Cannot receive file", - message: "Sender cancelled file transfer." +func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool = false, auto: Bool = false) async { + var fileIdsToApprove = [Int64]() + var srvsToApprove = Set() + var otherFileErrs = [ChatResponse]() + + for fileId in fileIds { + let r = await chatSendCmd( + .receiveFile( + fileId: fileId, + userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(), + encrypted: privacyEncryptLocalFilesGroupDefault.get(), + inline: nil ) + ) + switch r { + case let .rcvFileAccepted(_, chatItem): + await chatItemSimpleUpdate(user, chatItem) + default: + if let chatError = chatError(r) { + switch chatError { + case let .fileNotApproved(fileId, unknownServers): + fileIdsToApprove.append(fileId) + srvsToApprove.formUnion(unknownServers) + default: + otherFileErrs.append(r) + } + } } - } else if let networkErrorAlert = networkErrorAlert(r) { - logger.error("apiReceiveFile network error: \(String(describing: r))") - if !auto { - am.showAlert(networkErrorAlert) + } + + if !auto { + let otherErrsStr = if otherFileErrs.isEmpty { + "" + } else if otherFileErrs.count == 1 { + "\(otherFileErrs[0])" + } else if otherFileErrs.count == 2 { + "\(otherFileErrs[0])\n\(otherFileErrs[1])" + } else { + "\(otherFileErrs[0])\n\(otherFileErrs[1])\nand \(otherFileErrs.count - 2) other error(s)" } - } else { - switch chatError(r) { - case .fileCancelled: - logger.debug("apiReceiveFile ignoring fileCancelled error") - case .fileAlreadyReceiving: - logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error") - case let .fileNotApproved(fileId, unknownServers): - logger.debug("apiReceiveFile fileNotApproved error") - if !auto { - let srvs = unknownServers.map { s in + + // If there are not approved files, alert is shown the same way both in case of singular and plural files reception + if !fileIdsToApprove.isEmpty { + let srvs = srvsToApprove + .map { s in if let srv = parseServerAddress(s), !srv.hostnames.isEmpty { srv.hostnames[0] } else { serverHost(s) } } - am.showAlert(Alert( - title: Text("Unknown servers!"), - message: Text("Without Tor or VPN, your IP address will be visible to these XFTP relays: \(srvs.sorted().joined(separator: ", "))."), - primaryButton: .default( - Text("Download"), - action: { - Task { - logger.debug("apiReceiveFile fileNotApproved alert - in Task") - if let user = ChatModel.shared.currentUser { - await receiveFile(user: user, fileId: fileId, userApprovedRelays: true) - } + .sorted() + .joined(separator: ", ") + let fIds = fileIdsToApprove + await MainActor.run { + showAlert( + title: NSLocalizedString("Unknown servers!", comment: "alert title"), + message: ( + NSLocalizedString("Without Tor or VPN, your IP address will be visible to these XFTP relays: \(srvs).", comment: "alert message") + + (otherErrsStr != "" ? "\n\n" + NSLocalizedString("Other file errors:\n\(otherErrsStr)", comment: "alert message") : "") + ), + buttonTitle: NSLocalizedString("Download", comment: "alert button"), + buttonAction: { + Task { + logger.debug("apiReceiveFile fileNotApproved alert - in Task") + if let user = ChatModel.shared.currentUser { + await receiveFiles(user: user, fileIds: fIds, userApprovedRelays: true) } } - ), - secondaryButton: .cancel() - )) + }, + cancelButton: true + ) } - default: - logger.error("apiReceiveFile error: \(String(describing: r))") - if !auto { - am.showAlertMsg( - title: "Error receiving file", - message: "Error: \(responseError(r))" + } else if otherFileErrs.count == 1 { // If there is a single other error, we differentiate on it + let errorResponse = otherFileErrs.first! + switch errorResponse { + case let .rcvFileAcceptedSndCancelled(_, rcvFileTransfer): + logger.debug("receiveFiles error: sender cancelled file transfer \(rcvFileTransfer.fileId)") + await MainActor.run { + showAlert( + NSLocalizedString("Cannot receive file", comment: "alert title"), + message: NSLocalizedString("Sender cancelled file transfer.", comment: "alert message") + ) + } + default: + if let chatError = chatError(errorResponse) { + switch chatError { + case .fileCancelled, .fileAlreadyReceiving: + logger.debug("receiveFiles ignoring FileCancelled or FileAlreadyReceiving error") + default: + await MainActor.run { + showAlert( + NSLocalizedString("Error receiving file", comment: "alert title"), + message: responseError(errorResponse) + ) + } + } + } + } + } else if otherFileErrs.count > 1 { // If there are multiple other errors, we show general alert + await MainActor.run { + showAlert( + NSLocalizedString("Error receiving file", comment: "alert title"), + message: NSLocalizedString("File errors:\n\(otherErrsStr)", comment: "alert message") ) } } } - return nil } func cancelFile(user: User, fileId: Int64) async { diff --git a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift index 32993d1a76..79ede14be5 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift @@ -14,7 +14,7 @@ struct ChatItemForwardingView: View { @EnvironmentObject var theme: AppTheme @Environment(\.dismiss) var dismiss - var ci: ChatItem + var chatItems: [ChatItem] var fromChatInfo: ChatInfo @Binding var composeState: ComposeState @@ -73,11 +73,14 @@ struct ChatItemForwardingView: View { } @ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View { - let prohibited = chat.prohibitedByPref( - hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text), - isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false, - isVoice: ci.content.msgContent?.isVoice ?? false - ) + let prohibited = chatItems.map { ci in + chat.prohibitedByPref( + hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text), + isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false, + isVoice: ci.content.msgContent?.isVoice ?? false + ) + }.contains(true) + Button { if prohibited { alert = SomeAlert( @@ -93,10 +96,10 @@ struct ChatItemForwardingView: View { composeState = ComposeState( message: composeState.message, preview: composeState.linkPreview != nil ? composeState.preview : .noPreview, - contextItem: .forwardingItem(chatItem: ci, fromChatInfo: fromChatInfo) + contextItem: .forwardingItems(chatItems: chatItems, fromChatInfo: fromChatInfo) ) } else { - composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo) + composeState = ComposeState.init(forwardingItems: chatItems, fromChatInfo: fromChatInfo) ItemsModel.shared.loadOpenChat(chat.id) } } @@ -123,7 +126,7 @@ struct ChatItemForwardingView: View { #Preview { ChatItemForwardingView( - ci: ChatItem.getSample(1, .directSnd, .now, "hello"), + chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")], fromChatInfo: .direct(contact: Contact.sampleData), composeState: Binding.constant(ComposeState(message: "hello")) ).environmentObject(CurrentColors.toAppTheme()) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 5c11cdc3df..e7359587df 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -42,6 +42,7 @@ struct ChatView: View { @State private var showGroupLinkSheet: Bool = false @State private var groupLink: String? @State private var groupLinkMemberRole: GroupMemberRole = .member + @State private var forwardedChatItems: [ChatItem] = [] @State private var selectedChatItems: Set? = nil @State private var showDeleteSelectedMessages: Bool = false @State private var allowToDeleteSelectedMessagesForAll: Bool = false @@ -98,7 +99,8 @@ struct ChatView: View { if case let .group(groupInfo) = chat.chatInfo { showModerateSelectedMessagesAlert(groupInfo) } - } + }, + forwardItems: forwardSelectedMessages ) } } @@ -135,6 +137,22 @@ struct ChatView: View { } } } + .sheet(isPresented: Binding( + get: { !forwardedChatItems.isEmpty }, + set: { isPresented in + if !isPresented { + forwardedChatItems = [] + selectedChatItems = nil + } + } + )) { + if #available(iOS 16.0, *) { + ChatItemForwardingView(chatItems: forwardedChatItems, fromChatInfo: chat.chatInfo, composeState: $composeState) + .presentationDetents([.fraction(0.8)]) + } else { + ChatItemForwardingView(chatItems: forwardedChatItems, fromChatInfo: chat.chatInfo, composeState: $composeState) + } + } .onAppear { selectedChatItems = nil initChatView() @@ -411,7 +429,8 @@ struct ChatView: View { composeState: $composeState, selectedMember: $selectedMember, revealedChatItem: $revealedChatItem, - selectedChatItems: $selectedChatItems + selectedChatItems: $selectedChatItems, + forwardedChatItems: $forwardedChatItems ) .id(ci.id) // Required to trigger `onAppear` on iOS15 } loadPage: { @@ -701,6 +720,116 @@ struct ChatView: View { } } + private func forwardSelectedMessages() { + Task { + do { + if let selectedChatItems { + let (validItems, confirmation) = try await apiPlanForwardChatItems( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + itemIds: Array(selectedChatItems) + ) + if let confirmation { + if validItems.count > 0 { + showAlert( + String.localizedStringWithFormat( + NSLocalizedString("Forward %d message(s)?", comment: "alert title"), + validItems.count + ), + message: forwardConfirmationText(confirmation) + "\n" + + NSLocalizedString("Forward messages without files?", comment: "alert message") + ) { + switch confirmation { + case let .filesNotAccepted(fileIds): + [forwardAction(validItems), downloadAction(fileIds), cancelAlertAction] + default: + [forwardAction(validItems), cancelAlertAction] + } + } + } else { + showAlert( + NSLocalizedString("Nothing to forward!", comment: "alert title"), + message: forwardConfirmationText(confirmation) + ) { + switch confirmation { + case let .filesNotAccepted(fileIds): + [downloadAction(fileIds), cancelAlertAction] + default: + [okAlertAction] + } + } + } + } else { + await openForwardingSheet(validItems) + } + } + } catch { + logger.error("Plan forward chat items failed: \(error.localizedDescription)") + } + } + + func forwardConfirmationText(_ fc: ForwardConfirmation) -> String { + switch fc { + case let .filesNotAccepted(fileIds): + String.localizedStringWithFormat( + NSLocalizedString("%d file(s) were not downloaded.", comment: "forward confirmation reason"), + fileIds.count + ) + case let .filesInProgress(filesCount): + String.localizedStringWithFormat( + NSLocalizedString("%d file(s) are still being downloaded.", comment: "forward confirmation reason"), + filesCount + ) + case let .filesMissing(filesCount): + String.localizedStringWithFormat( + NSLocalizedString("%d file(s) were deleted.", comment: "forward confirmation reason"), + filesCount + ) + case let .filesFailed(filesCount): + String.localizedStringWithFormat( + NSLocalizedString("%d file(s) failed to download.", comment: "forward confirmation reason"), + filesCount + ) + } + } + + func forwardAction(_ items: [Int64]) -> UIAlertAction { + UIAlertAction( + title: NSLocalizedString("Forward messages", comment: "alert action"), + style: .default, + handler: { _ in Task { await openForwardingSheet(items) } } + ) + } + + func downloadAction(_ fileIds: [Int64]) -> UIAlertAction { + UIAlertAction( + title: NSLocalizedString("Download files", comment: "alert action"), + style: .default, + handler: { _ in + Task { + if let user = ChatModel.shared.currentUser { + await receiveFiles(user: user, fileIds: fileIds) + } + } + } + ) + } + + func openForwardingSheet(_ items: [Int64]) async { + let im = ItemsModel.shared + var items = Set(items) + var fci = [ChatItem]() + for reversedChatItem in im.reversedChatItems { + if items.contains(reversedChatItem.id) { + items.remove(reversedChatItem.id) + fci.insert(reversedChatItem, at: 0) + } + if items.isEmpty { break } + } + await MainActor.run { forwardedChatItems = fci } + } + } + private func loadChatItems(_ cInfo: ChatInfo) { Task { if loadingItems || firstPage { return } @@ -762,10 +891,10 @@ struct ChatView: View { @State private var showDeleteMessages = false @State private var showChatItemInfoSheet: Bool = false @State private var chatItemInfo: ChatItemInfo? - @State private var showForwardingSheet: Bool = false @State private var msgWidth: CGFloat = 0 @Binding var selectedChatItems: Set? + @Binding var forwardedChatItems: [ChatItem] @State private var allowMenu: Bool = true @State private var markedRead = false @@ -1079,14 +1208,6 @@ struct ChatView: View { }) { ChatItemInfoView(ci: ci, chatItemInfo: $chatItemInfo) } - .sheet(isPresented: $showForwardingSheet) { - if #available(iOS 16.0, *) { - ChatItemForwardingView(ci: ci, fromChatInfo: chat.chatInfo, composeState: $composeState) - .presentationDetents([.fraction(0.8)]) - } else { - ChatItemForwardingView(ci: ci, fromChatInfo: chat.chatInfo, composeState: $composeState) - } - } } private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool { @@ -1227,7 +1348,7 @@ struct ChatView: View { var forwardButton: Button { Button { - showForwardingSheet = true + forwardedChatItems = [chatItem] } label: { Label( NSLocalizedString("Forward", comment: "chat item action"), diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 99ab778a0e..8cd851446f 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -23,7 +23,7 @@ enum ComposeContextItem { case noContextItem case quotedItem(chatItem: ChatItem) case editingItem(chatItem: ChatItem) - case forwardingItem(chatItem: ChatItem, fromChatInfo: ChatInfo) + case forwardingItems(chatItems: [ChatItem], fromChatInfo: ChatInfo) } enum VoiceMessageRecordingState { @@ -73,10 +73,10 @@ struct ComposeState { } } - init(forwardingItem: ChatItem, fromChatInfo: ChatInfo) { + init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) { self.message = "" self.preview = .noPreview - self.contextItem = .forwardingItem(chatItem: forwardingItem, fromChatInfo: fromChatInfo) + self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo) self.voiceMessageRecordingState = .noRecording } @@ -112,7 +112,7 @@ struct ComposeState { var forwarding: Bool { switch contextItem { - case .forwardingItem: return true + case .forwardingItems: return true default: return false } } @@ -167,6 +167,13 @@ struct ComposeState { } } + var manyMediaPreviews: Bool { + switch preview { + case let .mediaPreviews(mediaPreviews): return mediaPreviews.count > 1 + default: return false + } + } + var attachmentDisabled: Bool { if editing || forwarding || liveMessage != nil || inProgress { return true } switch preview { @@ -687,7 +694,7 @@ struct ComposeView: View { case let .quotedItem(chatItem: quotedItem): ContextItemView( chat: chat, - contextItem: quotedItem, + contextItems: [quotedItem], contextIcon: "arrowshape.turn.up.left", cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } ) @@ -695,18 +702,17 @@ struct ComposeView: View { case let .editingItem(chatItem: editingItem): ContextItemView( chat: chat, - contextItem: editingItem, + contextItems: [editingItem], contextIcon: "pencil", cancelContextItem: { clearState() } ) Divider() - case let .forwardingItem(chatItem: forwardedItem, _): + case let .forwardingItems(chatItems, _): ContextItemView( chat: chat, - contextItem: forwardedItem, + contextItems: chatItems, contextIcon: "arrowshape.turn.up.forward", - cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }, - showSender: false + cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } ) Divider() } @@ -730,10 +736,11 @@ struct ComposeView: View { } if chat.chatInfo.contact?.nextSendGrpInv ?? false { await sendMemberContactInvitation() - } else if case let .forwardingItem(ci, fromChatInfo) = composeState.contextItem { - sent = await forwardItem(ci, fromChatInfo, ttl) + } else if case let .forwardingItems(chatItems, fromChatInfo) = composeState.contextItem { + // Composed text is send as a reply to the last forwarded item + sent = await forwardItems(chatItems, fromChatInfo, ttl).last if !composeState.message.isEmpty { - sent = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl) + _ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl) } } else if case let .editingItem(ci) = composeState.contextItem { sent = await updateMessage(ci, live: live) @@ -750,27 +757,28 @@ struct ComposeView: View { sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl) case .linkPreview: sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl) - case let .mediaPreviews(mediaPreviews: media): - // TODO batch send: batch media previews + case let .mediaPreviews(media): let last = media.count - 1 + var msgs: [ComposedMessage] = [] if last >= 0 { for i in 0.. 0 { + // Sleep to allow `progressByTimeout` update be rendered + try? await Task.sleep(nanoseconds: 100_000000) + } + if let (fileSource, msgContent) = mediaContent(media[i], text: "") { + msgs.append(ComposedMessage(fileSource: fileSource, msgContent: msgContent)) } - _ = try? await Task.sleep(nanoseconds: 100_000000) } - if case (_, .video(_, _, _)) = media[last] { - sent = await sendVideo(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl) - } else { - sent = await sendImage(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl) + if let (fileSource, msgContent) = mediaContent(media[last], text: msgText) { + msgs.append(ComposedMessage(fileSource: fileSource, quotedItemId: quoted, msgContent: msgContent)) } } - if sent == nil { - sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl) + if msgs.isEmpty { + msgs = [ComposedMessage(quotedItemId: quoted, msgContent: .text(msgText))] } + sent = await send(msgs, live: live, ttl: ttl).last + case let .voicePreview(recordingFileName, duration): stopPlayback.toggle() let file = voiceCryptoFile(recordingFileName) @@ -792,6 +800,20 @@ struct ComposeView: View { } return sent + func mediaContent(_ media: (String, UploadContent?), text: String) -> (CryptoFile?, MsgContent)? { + let (previewImage, uploadContent) = media + return switch uploadContent { + case let .simpleImage(image): + (saveImage(image), .image(text: text, image: previewImage)) + case let .animatedImage(image): + (saveAnimImage(image), .image(text: text, image: previewImage)) + case let .video(_, url, duration): + (moveTempFileFromURL(url), .video(text: text, image: previewImage, duration: duration)) + case .none: + nil + } + } + func sending() async { await MainActor.run { composeState.inProgress = true } } @@ -855,23 +877,6 @@ struct ComposeView: View { } } - func sendImage(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { - let (image, data) = imageData - if let data = data, let savedFile = saveAnyImage(data) { - return await send(.image(text: text, image: image), quoted: quoted, file: savedFile, live: live, ttl: ttl) - } - return nil - } - - func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { - let (image, data) = imageData - if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) { - ChatModel.shared.filesToDelete.remove(url) - return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl) - } - return nil - } - func voiceCryptoFile(_ fileName: String) -> CryptoFile? { if !privacyEncryptLocalFilesGroupDefault.get() { return CryptoFile.plain(fileName) @@ -888,17 +893,22 @@ struct ComposeView: View { } 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)], + live: live, + ttl: ttl + ).first + } + + func send(_ msgs: [ComposedMessage], live: Bool, ttl: Int?) async -> [ChatItem] { if let chatItems = chat.chatInfo.chatType == .local - ? await apiCreateChatItems( - noteFolderId: chat.chatInfo.apiId, - composedMessages: [ComposedMessage(fileSource: file, msgContent: mc)] - ) + ? await apiCreateChatItems(noteFolderId: chat.chatInfo.apiId, composedMessages: msgs) : await apiSendMessages( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, live: live, ttl: ttl, - composedMessages: [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)] + composedMessages: msgs ) { await MainActor.run { chatModel.removeLiveDummy(animated: false) @@ -906,33 +916,43 @@ struct ComposeView: View { chatModel.addChatItem(chat.chatInfo, chatItem) } } - // UI only supports sending one item at a time - return chatItems.first + return chatItems } - if let file = file { - removeFile(file.filePath) + for msg in msgs { + if let file = msg.fileSource { + removeFile(file.filePath) + } } - return nil + return [] } - func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> ChatItem? { + func forwardItems(_ forwardedItems: [ChatItem], _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> [ChatItem] { if let chatItems = await apiForwardChatItems( toChatType: chat.chatInfo.chatType, toChatId: chat.chatInfo.apiId, fromChatType: fromChatInfo.chatType, fromChatId: fromChatInfo.apiId, - itemIds: [forwardedItem.id], + itemIds: forwardedItems.map { $0.id }, ttl: ttl ) { await MainActor.run { for chatItem in chatItems { chatModel.addChatItem(chat.chatInfo, chatItem) } + if forwardedItems.count != chatItems.count { + showAlert( + String.localizedStringWithFormat( + NSLocalizedString("%d messages not forwarded", comment: "alert title"), + forwardedItems.count - chatItems.count + ), + message: NSLocalizedString("Messages were deleted after you selected them.", comment: "alert message") + ) + } } - // TODO batch send: forward multiple messages - return chatItems.first + return chatItems + } else { + return [] } - return nil } func checkLinkPreview() -> MsgContent { @@ -949,14 +969,6 @@ struct ComposeView: View { return .text(msgText) } } - - func saveAnyImage(_ img: UploadContent) -> CryptoFile? { - switch img { - case let .simpleImage(image): return saveImage(image) - case let .animatedImage(image): return saveAnimImage(image) - default: return nil - } - } } private func startVoiceMessageRecording() async { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index 6245bbe21f..8b988f5624 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -12,7 +12,7 @@ import SimpleXChat struct ContextItemView: View { @EnvironmentObject var theme: AppTheme @ObservedObject var chat: Chat - let contextItem: ChatItem + let contextItems: [ChatItem] let contextIcon: String let cancelContextItem: () -> Void var showSender: Bool = true @@ -24,13 +24,22 @@ struct ContextItemView: View { .aspectRatio(contentMode: .fit) .frame(width: 16, height: 16) .foregroundColor(theme.colors.secondary) - if showSender, let sender = contextItem.memberDisplayName { - VStack(alignment: .leading, spacing: 4) { - Text(sender).font(.caption).foregroundColor(theme.colors.secondary) - msgContentView(lines: 2) - } + if let singleItem = contextItems.first, contextItems.count == 1 { + if showSender, let sender = singleItem.memberDisplayName { + VStack(alignment: .leading, spacing: 4) { + Text(sender).font(.caption).foregroundColor(theme.colors.secondary) + msgContentView(lines: 2, contextItem: singleItem) + } + } else { + msgContentView(lines: 3, contextItem: singleItem) + } } else { - msgContentView(lines: 3) + Text( + chat.chatInfo.chatType == .local + ? "Saving \(contextItems.count) messages" + : "Forwarding \(contextItems.count) messages" + ) + .italic() } Spacer() Button { @@ -45,23 +54,32 @@ struct ContextItemView: View { .padding(12) .frame(minHeight: 54) .frame(maxWidth: .infinity) - .background(chatItemFrameColor(contextItem, theme)) + .background(background) } - private func msgContentView(lines: Int) -> some View { - contextMsgPreview() + private var background: Color { + contextItems.first + .map { chatItemFrameColor($0, theme) } + ?? Color(uiColor: .tertiarySystemBackground) + } + + private func msgContentView(lines: Int, contextItem: ChatItem) -> some View { + contextMsgPreview(contextItem) .multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading) .lineLimit(lines) } - private func contextMsgPreview() -> Text { + private func contextMsgPreview(_ contextItem: ChatItem) -> Text { return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary) func attachment() -> Text { + let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) { + FileManager.default.fileExists(atPath: getAppFilePath(fileSource.filePath).path) + } else { false } switch contextItem.content.msgContent { - case .file: return image("doc.fill") + case .file: return isFileLoaded ? image("doc.fill") : Text("") case .image: return image("photo") - case .voice: return image("play.fill") + case .voice: return isFileLoaded ? image("play.fill") : Text("") default: return Text("") } } @@ -75,6 +93,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, contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {}) + return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {}) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 9ad6e986bd..50ec8f28c1 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -227,6 +227,7 @@ struct SendMessageView: View { !composeState.editing { if case .noContextItem = composeState.contextItem, !composeState.voicePreview, + !composeState.manyMediaPreviews, let send = sendLiveMessage, let update = updateLiveMessage { Button { diff --git a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift index 87bc73a60e..746c423b8f 100644 --- a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift +++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift @@ -32,12 +32,15 @@ struct SelectedItemsBottomToolbar: View { var deleteItems: (Bool) -> Void var moderateItems: () -> Void //var shareItems: () -> Void + var forwardItems: () -> Void @State var deleteEnabled: Bool = false @State var deleteForEveryoneEnabled: Bool = false @State var canModerate: Bool = false @State var moderateEnabled: Bool = false + @State var forwardEnabled: Bool = false + @State var allButtonsDisabled = false var body: some View { @@ -50,6 +53,7 @@ struct SelectedItemsBottomToolbar: View { } label: { Image(systemName: "trash") .resizable() + .scaledToFit() .frame(width: 20, height: 20, alignment: .center) .foregroundColor(!deleteEnabled || allButtonsDisabled ? theme.colors.secondary: .red) } @@ -61,24 +65,24 @@ struct SelectedItemsBottomToolbar: View { } label: { Image(systemName: "flag") .resizable() + .scaledToFit() .frame(width: 20, height: 20, alignment: .center) .foregroundColor(!moderateEnabled || allButtonsDisabled ? theme.colors.secondary : .red) } .disabled(!moderateEnabled || allButtonsDisabled) .opacity(canModerate ? 1 : 0) - Spacer() Button { - //shareItems() + forwardItems() } label: { - Image(systemName: "square.and.arrow.up") + Image(systemName: "arrowshape.turn.up.forward") .resizable() + .scaledToFit() .frame(width: 20, height: 20, alignment: .center) - .foregroundColor(allButtonsDisabled ? theme.colors.secondary : theme.colors.primary) + .foregroundColor(!forwardEnabled || allButtonsDisabled ? theme.colors.secondary : theme.colors.primary) } - .disabled(allButtonsDisabled) - .opacity(0) + .disabled(!forwardEnabled || allButtonsDisabled) } .frame(maxHeight: .infinity) .padding([.leading, .trailing], 12) @@ -106,15 +110,16 @@ struct SelectedItemsBottomToolbar: View { if let selected = selectedItems { let me: Bool let onlyOwnGroupItems: Bool - (deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, selectedChatItems) = chatItems.reduce((true, true, true, true, [])) { (r, ci) in + (deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, [])) { (r, ci) in if selected.contains(ci.id) { - var (de, dee, me, onlyOwnGroupItems, sel) = r + 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 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, sel) + return (de, dee, me, onlyOwnGroupItems, fe, sel) } else { return r } diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index 7b80dd1544..b8de0e4ceb 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -47,8 +47,24 @@ func showAlert( buttonAction() }) if cancelButton { - alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel)) + alert.addAction(cancelAlertAction) } topController.present(alert, animated: true) } } + +func showAlert( + _ title: String, + message: String? = nil, + actions: () -> [UIAlertAction] = { [okAlertAction] } +) { + if let topController = getTopViewController() { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + for action in actions() { alert.addAction(action) } + topController.present(alert, animated: true) + } +} + +let okAlertAction = UIAlertAction(title: NSLocalizedString("Ok", comment: "alert button"), style: .default) + +let cancelAlertAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index c210431d12..b0cd62a867 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -49,6 +49,7 @@ public enum ChatCommand { case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64]) case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) + case apiPlanForwardChatItems(toChatType: ChatType, toChatId: Int64, itemIds: [Int64]) case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) case apiGetNtfToken case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) @@ -204,6 +205,7 @@ public enum ChatCommand { 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: ","))" case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))" + case let .apiPlanForwardChatItems(type, id, itemIds): return "/_forward plan \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ","))" case let .apiForwardChatItems(toChatType, toChatId, fromChatType, fromChatId, itemIds, ttl): let ttlStr = ttl != nil ? "\(ttl!)" : "default" return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)" @@ -359,6 +361,7 @@ public enum ChatCommand { case .apiConnectContactViaAddress: return "apiConnectContactViaAddress" case .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem" case .apiChatItemReaction: return "apiChatItemReaction" + case .apiPlanForwardChatItems: return "apiPlanForwardChatItems" case .apiForwardChatItems: return "apiForwardChatItems" case .apiGetNtfToken: return "apiGetNtfToken" case .apiRegisterToken: return "apiRegisterToken" @@ -601,6 +604,7 @@ public enum ChatResponse: Decodable, Error { case groupEmpty(user: UserRef, groupInfo: GroupInfo) case userContactLinkSubscribed case newChatItems(user: UserRef, chatItems: [AChatItem]) + case forwardPlan(user: UserRef, chatItemIds: [Int64], forwardConfirmation: ForwardConfirmation?) case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem]) case chatItemUpdated(user: UserRef, chatItem: AChatItem) case chatItemNotChanged(user: UserRef, chatItem: AChatItem) @@ -772,6 +776,7 @@ public enum ChatResponse: Decodable, Error { case .groupEmpty: return "groupEmpty" case .userContactLinkSubscribed: return "userContactLinkSubscribed" case .newChatItems: return "newChatItems" + case .forwardPlan: return "forwardPlan" case .chatItemsStatusesUpdated: return "chatItemsStatusesUpdated" case .chatItemUpdated: return "chatItemUpdated" case .chatItemNotChanged: return "chatItemNotChanged" @@ -943,6 +948,7 @@ public enum ChatResponse: Decodable, Error { case let .newChatItems(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") return withUser(u, itemsString) + case let .forwardPlan(u, chatItemIds, forwardConfirmation): return withUser(u, "items: \(chatItemIds) forwardConfirmation: \(String(describing: forwardConfirmation))") case let .chatItemsStatusesUpdated(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") return withUser(u, itemsString) @@ -1134,7 +1140,7 @@ public enum ChatPagination { public struct ComposedMessage: Encodable { public var fileSource: CryptoFile? var quotedItemId: Int64? - var msgContent: MsgContent + public var msgContent: MsgContent public init(fileSource: CryptoFile? = nil, quotedItemId: Int64? = nil, msgContent: MsgContent) { self.fileSource = fileSource @@ -1595,6 +1601,13 @@ public enum NetworkStatus: Decodable, Equatable { } } +public enum ForwardConfirmation: Decodable, Hashable { + case filesNotAccepted(fileIds: [Int64]) + case filesInProgress(filesCount: Int) + case filesMissing(filesCount: Int) + case filesFailed(filesCount: Int) +} + public struct ConnNetworkStatus: Decodable { public var agentConnId: String public var networkStatus: NetworkStatus