diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 69b660ad3c..65e24eeb25 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -353,11 +353,20 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws - throw r } +func apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64) async -> ChatItem? { + let cmd: ChatCommand = .apiForwardChatItem(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemId: itemId) + return await processSendMessageCmd(toChatType: toChatType, cmd: cmd) +} + func apiSendMessage(type: ChatType, id: Int64, file: CryptoFile?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? { - let chatModel = ChatModel.shared let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl) + return await processSendMessageCmd(toChatType: type, cmd: cmd) +} + +private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> ChatItem? { + let chatModel = ChatModel.shared let r: ChatResponse - if type == .direct { + if toChatType == .direct { var cItem: ChatItem? = nil let endTask = beginBGTask({ if let cItem = cItem { @@ -397,7 +406,7 @@ func apiCreateChatItem(noteFolderId: Int64, file: CryptoFile?, msg: MsgContent) } private func sendMessageErrorAlert(_ r: ChatResponse) { - logger.error("apiSendMessage error: \(String(describing: r))") + logger.error("send message error: \(String(describing: r))") AlertManager.shared.showAlertMsg( title: "Error sending message", message: "Error: \(String(describing: r))" @@ -1803,7 +1812,6 @@ func processReceivedMsg(_ res: ChatResponse) async { } case let .sndFileCompleteXFTP(user, aChatItem, _): await chatItemSimpleUpdate(user, aChatItem) - Task { cleanupFile(aChatItem) } case let .sndFileError(user, aChatItem, _): if let aChatItem = aChatItem { await chatItemSimpleUpdate(user, aChatItem) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 3e292c131b..3b10bcad55 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -65,8 +65,8 @@ struct FramedItemView: View { } } } - } else if chatItem.meta.itemForwarded != nil { - framedItemHeader(icon: "arrowshape.turn.up.forward", caption: Text("forwarded").italic()) + } else if let itemForwarded = chatItem.meta.itemForwarded { + framedItemHeader(icon: "arrowshape.turn.up.forward", caption: Text(itemForwarded.text(chat.chatInfo.chatType)).italic(), pad: true) } ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: framedMsgContentView) @@ -165,7 +165,7 @@ struct FramedItemView: View { ) } - @ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text) -> some View { + @ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text, pad: Bool = true) -> some View { let v = HStack(spacing: 6) { if let icon = icon { Image(systemName: icon) @@ -180,7 +180,7 @@ struct FramedItemView: View { .foregroundColor(.secondary) .padding(.horizontal, 12) .padding(.top, 6) - .padding(.bottom, chatItem.quotedItem == nil ? 6 : 0) // TODO think how to regroup + .padding(.bottom, pad || (chatItem.quotedItem == nil && chatItem.meta.itemForwarded == nil) ? 6 : 0) .overlay(DetermineWidth()) .frame(minWidth: msgWidth, alignment: .leading) .background(chatItemFrameContextColor(chatItem, colorScheme)) diff --git a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift new file mode 100644 index 0000000000..f575b6f9a2 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift @@ -0,0 +1,152 @@ +// +// ChatItemForwardingView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 12.04.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ChatItemForwardingView: View { + @EnvironmentObject var chatModel: ChatModel + @Environment(\.dismiss) var dismiss + + var ci: ChatItem + var fromChatInfo: ChatInfo + @Binding var composeState: ComposeState + + @State private var searchText: String = "" + @FocusState private var searchFocused + + var body: some View { + NavigationView { + forwardListView() + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .principal) { + Text("Forward") + .bold() + } + } + } + } + + @ViewBuilder private func forwardListView() -> some View { + VStack(alignment: .leading) { + let chatsToForwardTo = filterChatsToForwardTo() + if !chatsToForwardTo.isEmpty { + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + searchFieldView(text: $searchText, focussed: $searchFocused) + .padding(.leading, 2) + let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase + let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { filterChatSearched($0, s) } + ForEach(chats) { chat in + Divider() + forwardListNavLinkView(chat) + .disabled(chatModel.deletedChats.contains(chat.chatInfo.id)) + } + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(uiColor: .systemBackground)) + .cornerRadius(12) + .padding(.horizontal) + } + .background(Color(.systemGroupedBackground)) + } else { + emptyList() + } + } + } + + private func filterChatsToForwardTo() -> [Chat] { + var filteredChats = chatModel.chats.filter({ canForwardToChat($0) }) + if let index = filteredChats.firstIndex(where: { $0.chatInfo.chatType == .local }) { + let privateNotes = filteredChats.remove(at: index) + filteredChats.insert(privateNotes, at: 0) + } + return filteredChats + } + + private func filterChatSearched(_ chat: Chat, _ searchStr: String) -> Bool { + let cInfo = chat.chatInfo + return switch cInfo { + case let .direct(contact): + viewNameContains(cInfo, searchStr) || + contact.profile.displayName.localizedLowercase.contains(searchStr) || + contact.fullName.localizedLowercase.contains(searchStr) + default: + viewNameContains(cInfo, searchStr) + } + + func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool { + cInfo.chatViewName.localizedLowercase.contains(s) + } + } + + private func canForwardToChat(_ chat: Chat) -> Bool { + switch chat.chatInfo { + case let .direct(contact): contact.sendMsgEnabled && !contact.nextSendGrpInv + case let .group(groupInfo): groupInfo.sendMsgEnabled + case let .local(noteFolder): noteFolder.sendMsgEnabled + case .contactRequest: false + case .contactConnection: false + case .invalidJSON: false + } + } + + private func emptyList() -> some View { + Text("No filtered chats") + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + } + + @ViewBuilder private func forwardListNavLinkView(_ chat: Chat) -> some View { + Button { + dismiss() + if chat.id == fromChatInfo.id { + composeState = ComposeState( + message: composeState.message, + preview: composeState.linkPreview != nil ? composeState.preview : .noPreview, + contextItem: .forwardingItem(chatItem: ci, fromChatInfo: fromChatInfo) + ) + } else { + composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo) + chatModel.chatId = chat.id + } + } label: { + HStack { + ChatInfoImage(chat: chat) + .frame(width: 30, height: 30) + .padding(.trailing, 2) + Text(chat.chatInfo.chatViewName) + .foregroundColor(.primary) + .lineLimit(1) + if chat.chatInfo.incognito { + Spacer() + Image(systemName: "theatermasks") + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +#Preview { + ChatItemForwardingView( + ci: ChatItem.getSample(1, .directSnd, .now, "hello"), + fromChatInfo: .direct(contact: Contact.sampleData), + composeState: Binding.constant(ComposeState(message: "hello")) + ) +} diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 8dd43cc01b..0d1f99f3bd 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -11,6 +11,7 @@ import SimpleXChat struct ChatItemInfoView: View { @EnvironmentObject var chatModel: ChatModel + @Environment(\.dismiss) var dismiss @Environment(\.colorScheme) var colorScheme var ci: ChatItem @Binding var chatItemInfo: ChatItemInfo? @@ -21,6 +22,7 @@ struct ChatItemInfoView: View { enum CIInfoTab { case history case quote + case forwarded case delivery } @@ -68,9 +70,20 @@ struct ChatItemInfoView: View { if ci.quotedItem != nil { numTabs += 1 } + if chatItemInfo?.forwardedFromChatItem != nil { + numTabs += 1 + } return numTabs } + private var local: Bool { + switch ci.chatDir { + case .localSnd: true + case .localRcv: true + default: false + } + } + @ViewBuilder private func itemInfoView() -> some View { if numTabs > 1 { TabView(selection: $selection) { @@ -93,6 +106,13 @@ struct ChatItemInfoView: View { } .tag(CIInfoTab.quote) } + if let forwardedFromItem = chatItemInfo?.forwardedFromChatItem { + forwardedFromTab(forwardedFromItem) + .tabItem { + Label(local ? "Saved" : "Forwarded", systemImage: "arrowshape.turn.up.forward") + } + .tag(CIInfoTab.forwarded) + } } .onAppear { if chatItemInfo?.memberDeliveryStatuses != nil { @@ -275,6 +295,75 @@ struct ChatItemInfoView: View { : Color(uiColor: .tertiarySystemGroupedBackground) } + @ViewBuilder private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + details() + Divider().padding(.vertical) + Text(local ? "Saved from" : "Forwarded from") + .font(.title2) + .padding(.bottom, 4) + forwardedFromView(forwardedFromItem) + } + .padding() + } + .frame(maxHeight: .infinity, alignment: .top) + } + + private func forwardedFromView(_ forwardedFromItem: AChatItem) -> some View { + VStack(alignment: .leading, spacing: 8) { + Button { + Task { + await MainActor.run { + chatModel.chatId = forwardedFromItem.chatInfo.id + dismiss() + } + } + } label: { + forwardedFromSender(forwardedFromItem) + } + + if !local { + Divider().padding(.top, 32) + Text("Recipient(s) can't see who this message is from.") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + @ViewBuilder private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View { + HStack { + ChatInfoImage(chat: Chat(chatInfo: forwardedFromItem.chatInfo)) + .frame(width: 48, height: 48) + .padding(.trailing, 6) + + if forwardedFromItem.chatItem.chatDir.sent { + VStack(alignment: .leading) { + Text("you") + .italic() + .foregroundColor(.primary) + Text(forwardedFromItem.chatInfo.chatViewName) + .foregroundColor(.secondary) + .lineLimit(1) + } + } else if case let .groupRcv(groupMember) = forwardedFromItem.chatItem.chatDir { + VStack(alignment: .leading) { + Text(groupMember.chatViewName) + .foregroundColor(.primary) + .lineLimit(1) + Text(forwardedFromItem.chatInfo.chatViewName) + .foregroundColor(.secondary) + .lineLimit(1) + } + } else { + Text(forwardedFromItem.chatInfo.chatViewName) + .foregroundColor(.primary) + .lineLimit(1) + } + } + } + @ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View { ScrollView { VStack(alignment: .leading, spacing: 16) { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 34565193ff..6651b7cc5a 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -83,7 +83,11 @@ struct ChatView: View { initChatView() } .onChange(of: chatModel.chatId) { cId in - if cId != nil { + showChatInfoSheet = false + if let cId { + if let c = chatModel.getChat(cId) { + chat = c + } initChatView() } else { dismiss() @@ -251,7 +255,8 @@ struct ChatView: View { } } } - if chatModel.draftChatId == cInfo.id, let draft = chatModel.draft { + if chatModel.draftChatId == cInfo.id && !composeState.forwarding, + let draft = chatModel.draft { composeState = draft } if chat.chatStats.unreadChat { @@ -342,8 +347,8 @@ struct ChatView: View { .onChange(of: searchText) { _ in loadChat(chat: chat, search: searchText) } - .onChange(of: chatModel.chatId) { _ in - if let chatId = chatModel.chatId, let c = chatModel.getChat(chatId) { + .onChange(of: chatModel.chatId) { chatId in + if let chatId, let c = chatModel.getChat(chatId) { chat = c showChatInfoSheet = false loadChat(chat: c) @@ -539,6 +544,7 @@ struct ChatView: View { @State private var revealed = false @State private var showChatItemInfoSheet: Bool = false @State private var chatItemInfo: ChatItemInfo? + @State private var showForwardingSheet: Bool = false @State private var allowMenu: Bool = true @@ -693,6 +699,14 @@ 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 { @@ -775,6 +789,9 @@ struct ChatView: View { if ci.meta.editable && !mc.isVoice && !live { menu.append(editAction(ci)) } + if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live { + menu.append(forwardUIAction(ci)) + } if !ci.isLiveDummy { menu.append(viewInfoUIAction(ci)) } @@ -826,6 +843,15 @@ struct ChatView: View { } } + private func forwardUIAction(_ ci: ChatItem) -> UIAction { + UIAction( + title: NSLocalizedString("Forward", comment: "chat item action"), + image: UIImage(systemName: "arrowshape.turn.up.forward") + ) { _ in + showForwardingSheet = true + } + } + private func reactionUIMenuPreiOS16(_ rs: [UIAction]) -> UIMenu { UIMenu( title: NSLocalizedString("React…", comment: "chat item menu"), diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 24fe983661..6cf9df782b 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -23,6 +23,7 @@ enum ComposeContextItem { case noContextItem case quotedItem(chatItem: ChatItem) case editingItem(chatItem: ChatItem) + case forwardingItem(chatItem: ChatItem, fromChatInfo: ChatInfo) } enum VoiceMessageRecordingState { @@ -72,6 +73,13 @@ struct ComposeState { } } + init(forwardingItem: ChatItem, fromChatInfo: ChatInfo) { + self.message = "" + self.preview = .noPreview + self.contextItem = .forwardingItem(chatItem: forwardingItem, fromChatInfo: fromChatInfo) + self.voiceMessageRecordingState = .noRecording + } + func copy( message: String? = nil, liveMessage: LiveMessage? = nil, @@ -102,12 +110,19 @@ struct ComposeState { } } + var forwarding: Bool { + switch contextItem { + case .forwardingItem: 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 || liveMessage != nil + default: return !message.isEmpty || forwarding || liveMessage != nil } } @@ -153,7 +168,7 @@ struct ComposeState { } var attachmentDisabled: Bool { - if editing || liveMessage != nil || inProgress { return true } + if editing || forwarding || liveMessage != nil || inProgress { return true } switch preview { case .noPreview: return false case .linkPreview: return false @@ -667,6 +682,14 @@ struct ComposeView: View { contextIcon: "pencil", cancelContextItem: { clearState() } ) + case let .forwardingItem(chatItem: forwardedItem, _): + ContextItemView( + chat: chat, + contextItem: forwardedItem, + contextIcon: "arrowshape.turn.up.forward", + cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }, + showSender: false + ) } } @@ -688,6 +711,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) + if !composeState.message.isEmpty { + sent = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: nil) + } } else if case let .editingItem(ci) = composeState.contextItem { sent = await updateMessage(ci, live: live) } else if let liveMessage = liveMessage, liveMessage.sentMsg != nil { @@ -733,7 +761,15 @@ struct ComposeView: View { } } } - await MainActor.run { clearState(live: live) } + await MainActor.run { + let wasForwarding = composeState.forwarding + clearState(live: live) + if wasForwarding, + chatModel.draftChatId == chat.chatInfo.id, + let draft = chatModel.draft { + composeState = draft + } + } return sent func sending() async { @@ -854,6 +890,22 @@ struct ComposeView: View { return nil } + func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo) async -> ChatItem? { + if let chatItem = await apiForwardChatItem( + toChatType: chat.chatInfo.chatType, + toChatId: chat.chatInfo.apiId, + fromChatType: fromChatInfo.chatType, + fromChatId: fromChatInfo.apiId, + itemId: forwardedItem.id + ) { + await MainActor.run { + chatModel.addChatItem(chat.chatInfo, chatItem) + } + return chatItem + } + return nil + } + func checkLinkPreview() -> MsgContent { switch (composeState.preview) { case let .linkPreview(linkPreview: linkPreview): diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index 3eb128cded..2777d8321c 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 contextItem: ChatItem let contextIcon: String let cancelContextItem: () -> Void + var showSender: Bool = true var body: some View { HStack { @@ -23,7 +24,7 @@ struct ContextItemView: View { .aspectRatio(contentMode: .fit) .frame(width: 16, height: 16) .foregroundColor(.secondary) - if let sender = contextItem.memberDisplayName { + if showSender, let sender = contextItem.memberDisplayName { VStack(alignment: .leading, spacing: 4) { Text(sender).font(.caption).foregroundColor(.secondary) msgContentView(lines: 2) @@ -48,14 +49,26 @@ struct ContextItemView: View { } private func msgContentView(lines: Int) -> some View { - MsgContentView( - chat: chat, - text: contextItem.text, - formattedText: contextItem.formattedText, - showSecrets: false - ) - .multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading) - .lineLimit(lines) + contextMsgPreview() + .multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading) + .lineLimit(lines) + } + + private func contextMsgPreview() -> Text { + return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false) + + func attachment() -> Text { + switch contextItem.content.msgContent { + case .file: return image("doc.fill") + case .image: return image("photo") + case .voice: return image("play.fill") + default: return Text("") + } + } + + func image(_ s: String) -> Text { + Text(Image(systemName: s)).foregroundColor(Color(uiColor: .tertiaryLabel)) + Text(" ") + } } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index f9e5b1c2b1..ae0394475d 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -110,6 +110,7 @@ struct SendMessageView: View { } else if showVoiceMessageButton && composeState.message.isEmpty && !composeState.editing + && !composeState.forwarding && composeState.liveMessage == nil && ((composeState.noPreview && vmrs == .noRecording) || (vmrs == .recording && holdingVMR)) { @@ -225,7 +226,8 @@ struct SendMessageView: View { @ViewBuilder private func sendButtonContextMenuItems() -> some View { if composeState.liveMessage == nil, - !composeState.editing { + !composeState.editing, + !composeState.forwarding { if case .noContextItem = composeState.contextItem, !composeState.voicePreview, let send = sendLiveMessage, diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index efd8f33dd8..faa7f4f44c 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -586,9 +586,6 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { cleanupDirectFile(aChatItem) } return nil - case let .sndFileCompleteXFTP(_, aChatItem, _): - cleanupFile(aChatItem) - return nil case let .callInvitation(invitation): // Do not post it without CallKit support, iOS will stop launching the app without showing CallKit return ( diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 9ed03affd7..f3841f43cf 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -172,6 +172,7 @@ 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; }; 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; }; 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; + 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; @@ -464,6 +465,7 @@ 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = ""; }; 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; + 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -585,6 +587,7 @@ 5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */, 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */, 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */, + 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */, ); path = Chat; sourceTree = ""; @@ -1161,6 +1164,7 @@ 5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */, 5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */, 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, + 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */, 64466DC829FC2B3B00E3D48D /* CreateSimpleXAddress.swift in Sources */, 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */, 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index c599b720ae..e27e280e40 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -50,6 +50,7 @@ public enum ChatCommand { case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) case apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64) case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) + case apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64) case apiGetNtfToken case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) case apiVerifyToken(token: DeviceToken, nonce: String, code: String) @@ -195,6 +196,7 @@ public enum ChatCommand { case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" case let .apiDeleteMemberChatItem(groupId, groupMemberId, itemId): return "/_delete member item #\(groupId) \(groupMemberId) \(itemId)" case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))" + case let .apiForwardChatItem(toChatType, toChatId, fromChatType, fromChatId, itemId): return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(itemId)" case .apiGetNtfToken: return "/_ntf get " case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)" case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" @@ -343,6 +345,7 @@ public enum ChatCommand { case .apiConnectContactViaAddress: return "apiConnectContactViaAddress" case .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem" case .apiChatItemReaction: return "apiChatItemReaction" + case .apiForwardChatItem: return "apiForwardChatItem" case .apiGetNtfToken: return "apiGetNtfToken" case .apiRegisterToken: return "apiRegisterToken" case .apiVerifyToken: return "apiVerifyToken" diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 9db0468d95..d1f1fc06d1 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2784,15 +2784,29 @@ public enum CIDeleted: Decodable { } } -public enum MsgDirection: Decodable { - case rcv - case snd +public enum MsgDirection: String, Decodable { + case rcv = "rcv" + case snd = "snd" } public enum CIForwardedFrom: Decodable { case unknown case contact(chatName: String, msgDir: MsgDirection, contactId: Int64?, chatItemId: Int64?) case group(chatName: String, msgDir: MsgDirection, groupId: Int64?, chatItemId: Int64?) + + var chatName: String { + switch self { + case .unknown: "" + case let .contact(chatName, _, _, _): chatName + case let .group(chatName, _, _, _): chatName + } + } + + public func text(_ chatType: ChatType) -> LocalizedStringKey { + chatType == .local + ? (chatName == "" ? "saved" : "saved from \(chatName)") + : "forwarded" + } } public enum CIDeleteMode: String, Decodable {