From 4c8bc19182bf95221610c26c325f44c91d3a4ae8 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 10 Oct 2022 10:40:30 +0100 Subject: [PATCH] ios: send multiple images (#1188) * ios: send multiple images * multi-select works (TODO race conditions) * send multiple images, progress indicator in compose view * scroll between fullscreen images, scroll to quoted item * add swipe animation * fix model state when sending the image * fix sending multiple images * use MainActor * improve scrolling * faster scroll * improve scroll animation * fix model updates --- apps/ios/Shared/Model/ChatModel.swift | 48 +++++-- .../Views/Chat/ChatItem/CIImageView.swift | 31 +--- .../Views/Chat/ChatItem/FramedItemView.swift | 12 +- .../Chat/ChatItem/FullScreenImageView.swift | 93 ++++++++++++ apps/ios/Shared/Views/Chat/ChatItemView.swift | 3 +- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- .../ComposeMessage/ComposeImageView.swift | 29 +++- .../Chat/ComposeMessage/ComposeView.swift | 136 +++++++++++------- .../Chat/ComposeMessage/SendMessageView.swift | 2 +- .../Shared/Views/Helpers/ImagePicker.swift | 79 ++++++++-- .../Shared/Views/Helpers/NavLinkPlain.swift | 2 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + apps/ios/SimpleXChat/ChatTypes.swift | 2 +- 13 files changed, 333 insertions(+), 110 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/ChatItem/FullScreenImageView.swift diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index d93ee982fd..93c60868f3 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -178,7 +178,7 @@ final class ChatModel: ObservableObject { } // add to current chat if chatId == cInfo.id { - withAnimation { reversedChatItems.insert(cItem, at: 0) } + _ = _upsertChatItem(cInfo, cItem) } } @@ -186,7 +186,11 @@ final class ChatModel: ObservableObject { // update previews var res: Bool if let chat = getChat(cInfo.id) { - if let pItem = chat.chatItems.last, pItem.id == cItem.id { + if let pItem = chat.chatItems.last { + if pItem.id == cItem.id || (chatId == cInfo.id && reversedChatItems.first(where: { $0.id == cItem.id }) == nil) { + chat.chatItems = [cItem] + } + } else { chat.chatItems = [cItem] } res = false @@ -195,19 +199,23 @@ final class ChatModel: ObservableObject { res = true } // update current chat - if chatId == cInfo.id { - if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { - withAnimation(.default) { - self.reversedChatItems[i] = cItem - self.reversedChatItems[i].viewTimestamp = .now + return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res + } + + private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { + if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { + let ci = reversedChatItems[i] + withAnimation(.default) { + self.reversedChatItems[i] = cItem + self.reversedChatItems[i].viewTimestamp = .now + if case .sndNew = cItem.meta.itemStatus { + self.reversedChatItems[i].meta = ci.meta } - return false - } else { - withAnimation { reversedChatItems.insert(cItem, at: 0) } - return true } + return false } else { - return res + withAnimation { reversedChatItems.insert(cItem, at: 0) } + return true } } @@ -231,6 +239,22 @@ final class ChatModel: ObservableObject { } } + func nextChatItemData(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? { + guard var i = reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil } + if previous { + while i < reversedChatItems.count - 1 { + i += 1 + if let res = map(reversedChatItems[i]) { return res } + } + } else { + while i > 0 { + i -= 1 + if let res = map(reversedChatItems[i]) { return res } + } + } + return nil + } + func markChatItemsRead(_ cInfo: ChatInfo) { // update preview if let chat = getChat(cInfo.id) { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index 9c131cc1c9..0e05d6599f 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -11,41 +11,24 @@ import SimpleXChat struct CIImageView: View { @Environment(\.colorScheme) var colorScheme + let chatItem: ChatItem let image: String - let file: CIFile? let maxWidth: CGFloat @Binding var imgWidth: CGFloat? - @State var showFullScreenImage = false + @State var scrollProxy: ScrollViewProxy? + @State private var showFullScreenImage = false var body: some View { + let file = chatItem.file VStack(alignment: .center, spacing: 6) { if let uiImage = getLoadedImage(file) { imageView(uiImage) .fullScreenCover(isPresented: $showFullScreenImage) { - ZStack { - Color.black.edgesIgnoringSafeArea(.all) - ZoomableScrollView { - ZStack { - Color.black.edgesIgnoringSafeArea(.all) - Image(uiImage: uiImage) - .resizable() - .scaledToFit() - } - } - } - .onTapGesture { showFullScreenImage = false } - .gesture( - DragGesture(minimumDistance: 80).onChanged { gesture in - let t = gesture.translation - if t.height > 60 && t.height > abs(t.width) { - showFullScreenImage = false - } - } - ) + FullScreenImageView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy) } .onTapGesture { showFullScreenImage = true } } else if let data = Data(base64Encoded: dropImagePrefix(image)), - let uiImage = UIImage(data: data) { + let uiImage = UIImage(data: data) { imageView(uiImage) .onTapGesture { if let file = file { @@ -84,7 +67,7 @@ struct CIImageView: View { } @ViewBuilder private func loadingIndicator() -> some View { - if let file = file { + if let file = chatItem.file { switch file.fileStatus { case .sndTransfer: ProgressView() diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 152f649ae3..9bc0af66df 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -15,11 +15,13 @@ private let sentQuoteColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.09) struct FramedItemView: View { + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme var chatInfo: ChatInfo var chatItem: ChatItem var showMember = false var maxWidth: CGFloat = .infinity + @State var scrollProxy: ScrollViewProxy? = nil @State var msgWidth: CGFloat = 0 @State var imgWidth: CGFloat? = nil @State var metaColor = Color.secondary @@ -30,6 +32,14 @@ struct FramedItemView: View { VStack(alignment: .leading, spacing: 0) { if let qi = chatItem.quotedItem { ciQuoteView(qi) + .onTapGesture { + if let proxy = scrollProxy, + let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) { + withAnimation { + proxy.scrollTo(ci.viewId, anchor: .bottom) + } + } + } } if chatItem.formattedText == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text) { @@ -45,7 +55,7 @@ struct FramedItemView: View { } else { switch (chatItem.content.msgContent) { case let .image(text, image): - CIImageView(image: image, file: chatItem.file, maxWidth: maxWidth, imgWidth: $imgWidth) + CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy) .overlay(DetermineWidth()) if text == "" { Color.clear diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenImageView.swift new file mode 100644 index 0000000000..7ada35f592 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenImageView.swift @@ -0,0 +1,93 @@ +// +// FullScreenImageView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 08/10/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct FullScreenImageView: View { + @EnvironmentObject var m: ChatModel + @State var chatItem: ChatItem + @State var image: UIImage + @Binding var showView: Bool + @State var scrollProxy: ScrollViewProxy? + @State private var showNext = false + @State private var nextImage: UIImage? + @State private var scrolling = false + @State private var offset: CGFloat = 0 + @State private var nextOffset: CGFloat = 0 + + var body: some View { + GeometryReader(content: imageScrollView) + } + + func imageScrollView(_ g: GeometryProxy) -> some View { + ZStack { + Color.black.edgesIgnoringSafeArea(.all) + if showNext, let nextImage = nextImage { + imageView(image).offset(x: offset) + imageView(nextImage).offset(x: offset + nextOffset) + } else { + ZoomableScrollView { + imageView(image) + } + } + } + .onTapGesture { showView = false } + .gesture( + DragGesture(minimumDistance: 80) + .onChanged { gesture in + let t = gesture.translation + let w = abs(t.width) + if t.height > 60 && t.height > w * 2 { + showView = false + if let proxy = scrollProxy { + proxy.scrollTo(chatItem.viewId) + } + } else if w > 60 && w > abs(t.height) * 2 && !scrolling { + let previous = t.width > 0 + scrolling = true + if let item = m.nextChatItemData(chatItem.id, previous: previous, map: chatItemImage) { + var img: UIImage + (chatItem, img) = item + nextImage = img + let s = g.size.width + var toOffset: CGFloat + (toOffset, nextOffset) = previous ? (s, -s) : (-s, s) + showNext = true + withAnimation(.easeIn(duration: 0.2)) { + offset = toOffset + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + image = img + showNext = false + offset = 0 + } + } + } + } + .onEnded { _ in scrolling = false } + ) + } + + private func imageView(_ img: UIImage) -> some View { + ZStack { + Color.black + Image(uiImage: img) + .resizable() + .scaledToFit() + } + } + + private func chatItemImage(_ ci: ChatItem) -> (ChatItem, UIImage)? { + if case .image = ci.content.msgContent, + let img = getLoadedImage(ci.file) { + return (ci, img) + } + return nil + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 4179719307..983537a336 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -14,6 +14,7 @@ struct ChatItemView: View { var chatItem: ChatItem var showMember = false var maxWidth: CGFloat = .infinity + @State var scrollProxy: ScrollViewProxy? = nil var body: some View { switch chatItem.content { @@ -35,7 +36,7 @@ struct ChatItemView: View { if (chatItem.quotedItem == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text)) { EmojiItemView(chatItem: chatItem) } else { - FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth) + FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index f2515499ab..bbe39d7f89 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -487,7 +487,7 @@ struct ChatView: View { ) } - return ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth) + return ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy) .uiKitContextMenu(actions: menu) .confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) { Button("Delete for me", role: .destructive) { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift index 3e65600ce5..edaf86912c 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift @@ -11,18 +11,33 @@ import SimpleXChat struct ComposeImageView: View { @Environment(\.colorScheme) var colorScheme - let image: String + let images: [String] let cancelImage: (() -> Void) let cancelEnabled: Bool var body: some View { HStack(alignment: .center, spacing: 8) { - if let data = Data(base64Encoded: dropImagePrefix(image)), - let uiImage = UIImage(data: data) { - Image(uiImage: uiImage) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: 80, minHeight: 40, maxHeight: 60) + let imgs: [UIImage] = images.compactMap { image in + if let data = Data(base64Encoded: dropImagePrefix(image)) { + return UIImage(data: data) + } + return nil + } + if imgs.count == 0 { + ProgressView() + .padding(.leading, 12) + .frame(maxWidth: .infinity, minHeight: 60, maxHeight: 60, alignment: .leading) + } else { + ScrollView(.horizontal) { + HStack { + ForEach(imgs, id: \.hash) { img in + Image(uiImage: img) + .resizable() + .scaledToFit() + .frame(maxWidth: 80, minHeight: 40, maxHeight: 60) + } + } + } } Spacer() if cancelEnabled { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 2e9a8e0123..8d216e27cb 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -12,7 +12,7 @@ import SimpleXChat enum ComposePreview { case noPreview case linkPreview(linkPreview: LinkPreview?) - case imagePreview(imagePreview: String) + case imagePreviews(imagePreviews: [String]) case filePreview(fileName: String) } @@ -26,7 +26,8 @@ struct ComposeState { var message: String var preview: ComposePreview var contextItem: ComposeContextItem - var inProgress: Bool = false + var inProgress = false + var disabled = false var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) init( @@ -66,7 +67,7 @@ struct ComposeState { func sendEnabled() -> Bool { switch preview { - case .imagePreview: + case .imagePreviews: return true case .filePreview: return true @@ -77,7 +78,7 @@ struct ComposeState { func linkPreviewAllowed() -> Bool { switch preview { - case .imagePreview: + case .imagePreviews: return false case .filePreview: return false @@ -104,7 +105,7 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview { case let .link(_, preview: preview): chatItemPreview = .linkPreview(linkPreview: preview) case let .image(_, image: image): - chatItemPreview = .imagePreview(imagePreview: image) + chatItemPreview = .imagePreviews(imagePreviews: [image]) case .file: chatItemPreview = .filePreview(fileName: chatItem.file?.fileName ?? "") default: @@ -127,7 +128,7 @@ struct ComposeView: View { @State private var showChooseSource = false @State private var showImagePicker = false @State private var showTakePhoto = false - @State var chosenImage: UIImage? = nil + @State var chosenImages: [UIImage] = [] @State private var showFileImporter = false @State var chosenFile: URL? = nil @@ -179,7 +180,7 @@ struct ComposeView: View { } if UIPasteboard.general.hasImages { Button("Paste image") { - chosenImage = UIPasteboard.general.image + chosenImages = imageList(UIPasteboard.general.image) } } Button("Choose file") { @@ -189,20 +190,35 @@ struct ComposeView: View { .fullScreenCover(isPresented: $showTakePhoto) { ZStack { Color.black.edgesIgnoringSafeArea(.all) - CameraImagePicker(image: $chosenImage) + CameraImageListPicker(images: $chosenImages) } } .sheet(isPresented: $showImagePicker) { - LibraryImagePicker(image: $chosenImage) { - didSelectItem in showImagePicker = false + LibraryImageListPicker(images: $chosenImages, selectionLimit: 10) { itemsSelected in + showImagePicker = false + if itemsSelected { + DispatchQueue.main.async { + composeState = composeState.copy(preview: .imagePreviews(imagePreviews: [])) + } + } } } - .onChange(of: chosenImage) { image in - if let image = image, - let imagePreview = resizeImageToStrSize(image, maxDataSize: 14000) { - composeState = composeState.copy(preview: .imagePreview(imagePreview: imagePreview)) - } else { - composeState = composeState.copy(preview: .noPreview) + .onChange(of: chosenImages) { images in + Task { + var imgs: [String] = [] + for image in images { + if let img = resizeImageToStrSize(image, maxDataSize: 14000) { + imgs.append(img) + await MainActor.run { + composeState = composeState.copy(preview: .imagePreviews(imagePreviews: imgs)) + } + } + } + if imgs.count == 0 { + await MainActor.run { + composeState = composeState.copy(preview: .noPreview) + } + } } } .fileImporter( @@ -242,12 +258,12 @@ struct ComposeView: View { EmptyView() case let .linkPreview(linkPreview: preview): ComposeLinkView(linkPreview: preview, cancelPreview: cancelLinkPreview) - case let .imagePreview(imagePreview: img): + case let .imagePreviews(imagePreviews: images): ComposeImageView( - image: img, + images: images, cancelImage: { composeState = composeState.copy(preview: .noPreview) - chosenImage = nil + chosenImages = [] }, cancelEnabled: !composeState.editing()) case let .filePreview(fileName: fileName): @@ -288,62 +304,82 @@ struct ComposeView: View { case let .editingItem(chatItem: ei): if let oldMsgContent = ei.content.msgContent { do { + await sending() let mc = updateMsgContent(oldMsgContent) - await MainActor.run { clearState() } let chatItem = try await apiUpdateChatItem( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, itemId: ei.id, msg: mc ) - DispatchQueue.main.async { + await MainActor.run { + clearState() let _ = self.chatModel.upsertChatItem(self.chat.chatInfo, chatItem) } } catch { logger.error("ChatView.sendMessage error: \(error.localizedDescription)") + await MainActor.run { + composeState.disabled = false + composeState.inProgress = false + } AlertManager.shared.showAlertMsg(title: "Error updating message", message: "Error: \(responseError(error))") } } else { await MainActor.run { clearState() } } default: - var mc: MsgContent? = nil - var file: String? = nil + await sending() + var quoted: Int64? = nil + if case let .quotedItem(chatItem: quotedItem) = composeState.contextItem { + quoted = quotedItem.id + } + switch (composeState.preview) { case .noPreview: - mc = .text(composeState.message) + await send(.text(composeState.message), quoted: quoted) case .linkPreview: - mc = checkLinkPreview() - case let .imagePreview(imagePreview: image): - if let uiImage = chosenImage, - let savedFile = saveImage(uiImage) { - mc = .image(text: composeState.message, image: image) - file = savedFile + await send(checkLinkPreview(), quoted: quoted) + case let .imagePreviews(imagePreviews: images): + var text = composeState.message + var sent = false + for i in 0.. 0 { _ = try? await Task.sleep(nanoseconds: 100_000000) } + if let savedFile = saveImage(chosenImages[i]) { + await send(.image(text: text, image: images[i]), quoted: quoted, file: savedFile) + text = "" + quoted = nil + sent = true + } + } + if !sent { + await send(.text(composeState.message), quoted: quoted) } case .filePreview: if let fileURL = chosenFile, let savedFile = saveFileFromURL(fileURL) { - mc = .file(composeState.message) - file = savedFile + await send(.file(composeState.message), quoted: quoted, file: savedFile) } } - - var quotedItemId: Int64? = nil - switch (composeState.contextItem) { - case let .quotedItem(chatItem: quotedItem): - quotedItemId = quotedItem.id - default: - quotedItemId = nil - } - await MainActor.run { clearState() } - if let mc = mc, - let chatItem = await apiSendMessage( - type: chat.chatInfo.chatType, - id: chat.chatInfo.apiId, - file: file, - quotedItemId: quotedItemId, - msg: mc - ) { + } + await MainActor.run { clearState() } + } + + func sending() async { + await MainActor.run { composeState.disabled = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if composeState.disabled { composeState.inProgress = true } + } + } + + func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil) async { + if let chatItem = await apiSendMessage( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + file: file, + quotedItemId: quoted, + msg: mc + ) { + await MainActor.run { chatModel.addChatItem(chat.chatInfo, chatItem) } } @@ -356,7 +392,7 @@ struct ComposeView: View { prevLinkUrl = nil pendingLinkUrl = nil cancelledLinks = [] - chosenImage = nil + chosenImages = [] chosenFile = nil } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 2b5682b406..2fada6ad53 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -54,7 +54,7 @@ struct SendMessageView: View { .resizable() .foregroundColor(.accentColor) } - .disabled(!composeState.sendEnabled()) + .disabled(!composeState.sendEnabled() || composeState.disabled) .frame(width: 29, height: 29) .padding([.bottom, .trailing], 4) } diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift index 7fc77c03b5..194a0a306d 100644 --- a/apps/ios/Shared/Views/Helpers/ImagePicker.swift +++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift @@ -9,15 +9,30 @@ import SwiftUI import PhotosUI -struct LibraryImagePicker: UIViewControllerRepresentable { - typealias UIViewControllerType = PHPickerViewController +struct LibraryImagePicker: View { @Binding var image: UIImage? var didFinishPicking: (_ didSelectItems: Bool) -> Void + @State var images: [UIImage] = [] + + var body: some View { + LibraryImageListPicker(images: $images, selectionLimit: 1, didFinishPicking: didFinishPicking) + .onChange(of: images) { image = $0.first } + } +} + +struct LibraryImageListPicker: UIViewControllerRepresentable { + typealias UIViewControllerType = PHPickerViewController + @Binding var images: [UIImage] + var selectionLimit: Int + var didFinishPicking: (_ didSelectItems: Bool) -> Void class Coordinator: PHPickerViewControllerDelegate { - let parent: LibraryImagePicker + let parent: LibraryImageListPicker + let dispatchQueue = DispatchQueue(label: "chat.simplex.app.LibraryImageListPicker") + var images: [UIImage] = [] + var imageCount: Int = 0 - init(_ parent: LibraryImagePicker) { + init(_ parent: LibraryImageListPicker) { self.parent = parent } @@ -27,22 +42,47 @@ struct LibraryImagePicker: UIViewControllerRepresentable { return } - if let chosenImageProvider = results.first?.itemProvider { - if chosenImageProvider.canLoadObject(ofClass: UIImage.self) { - chosenImageProvider.loadObject(ofClass: UIImage.self) { [weak self] image, error in + parent.images = [] + images = [] + imageCount = results.count + for result in results { + logger.log("LibraryImageListPicker result") + let p = result.itemProvider + if p.canLoadObject(ofClass: UIImage.self) { + p.loadObject(ofClass: UIImage.self) { image, error in DispatchQueue.main.async { - self?.loadImage(object: image, error: error) + self.loadImage(object: image, error: error) } } + } else { + dispatchQueue.sync { self.imageCount -= 1} + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + self.dispatchQueue.sync { + if self.parent.images.count == 0 { + logger.log("LibraryImageListPicker: added \(self.images.count) images out of \(results.count)") + self.parent.images = self.images + } } } } func loadImage(object: Any?, error: Error? = nil) { if let error = error { - logger.error("Couldn't load image with error: \(error.localizedDescription)") + logger.error("LibraryImageListPicker: couldn't load image with error: \(error.localizedDescription)") + } else if let image = object as? UIImage { + images.append(image) + logger.log("LibraryImageListPicker: added image") + } + dispatchQueue.sync { + self.imageCount -= 1 + if self.imageCount == 0 && self.parent.images.count == 0 { + logger.log("LibraryImageListPicker: added all images") + self.parent.images = self.images + self.images = [] + } } - parent.image = object as? UIImage } } @@ -53,7 +93,7 @@ struct LibraryImagePicker: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> PHPickerViewController { var config = PHPickerConfiguration() config.filter = .images - config.selectionLimit = 1 + config.selectionLimit = selectionLimit let controller = PHPickerViewController(configuration: config) controller.delegate = context.coordinator return controller @@ -64,6 +104,23 @@ struct LibraryImagePicker: UIViewControllerRepresentable { } } +struct CameraImageListPicker: View { + @Binding var images: [UIImage] + @State var image: UIImage? + + var body: some View { + CameraImagePicker(image: $image) + .onChange(of: image) { images = imageList($0) } + } +} + +func imageList(_ img: UIImage?) -> [UIImage] { + if let img = img { + return [img] + } else { + return [] + } +} struct CameraImagePicker: UIViewControllerRepresentable { @Environment(\.presentationMode) var presentationMode diff --git a/apps/ios/Shared/Views/Helpers/NavLinkPlain.swift b/apps/ios/Shared/Views/Helpers/NavLinkPlain.swift index 3dde57a427..2d5458b9d3 100644 --- a/apps/ios/Shared/Views/Helpers/NavLinkPlain.swift +++ b/apps/ios/Shared/Views/Helpers/NavLinkPlain.swift @@ -16,7 +16,7 @@ struct NavLinkPlain: View { var body: some View { ZStack { - Button("") { selection = tag } + Button("") { DispatchQueue.main.async { selection = tag } } .disabled(disabled) label() } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index af5c47b564..4052d4b22a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C05DF522840AA1D00C683F9 /* CallSettings.swift */; }; 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; 5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */; }; + 5C10D88A28F187F300E58BF0 /* FullScreenImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C10D88928F187F300E58BF0 /* FullScreenImageView.swift */; }; 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C13730A28156D2700F43030 /* ContactConnectionView.swift */; }; 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; @@ -209,6 +210,7 @@ 5C05DF522840AA1D00C683F9 /* CallSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSettings.swift; sourceTree = ""; }; 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = ""; }; 5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionInfo.swift; sourceTree = ""; }; + 5C10D88928F187F300E58BF0 /* FullScreenImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenImageView.swift; sourceTree = ""; }; 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = ""; }; 5C13730A28156D2700F43030 /* ContactConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionView.swift; sourceTree = ""; }; 5C13730C2815740A00F43030 /* DebugJSON.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = DebugJSON.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; @@ -633,6 +635,7 @@ 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */, 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */, 649BCDA12805D6EF00C3A862 /* CIImageView.swift */, + 5C10D88928F187F300E58BF0 /* FullScreenImageView.swift */, 648010AA281ADD15009009B9 /* CIFileView.swift */, 3CDBCF4727FF621E00354CDD /* CILinkView.swift */, 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */, @@ -895,6 +898,7 @@ 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */, 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, + 5C10D88A28F187F300E58BF0 /* FullScreenImageView.swift in Sources */, 5C00164428A26FBC0094D739 /* ContextMenu.swift in Sources */, 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */, 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 7e818155de..0b9ab6f417 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1119,7 +1119,7 @@ public enum CIContent: Decodable, ItemContent { public struct CIQuote: Decodable, ItemContent { var chatDir: CIDirection? - var itemId: Int64? + public var itemId: Int64? var sharedMsgId: String? = nil var sentAt: Date public var content: MsgContent