From 4b000b07853403447ddda399588eee5f161a699c Mon Sep 17 00:00:00 2001 From: Islam <2553451+qalandarov@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:49:17 +0100 Subject: [PATCH] Refactor & Extract image viewers (#1074) * Extract `ImagePreviewView` + add `#Preview` * Extract image pickers + dedupe and bg processing of images * Include a dummy photo in the preview assets vs live url * Fix development assets + url percent encoding * Fix iOS vs macOS build issue * Fix SPM-related issues --- Package.swift | 1 + bitchat.xcodeproj/project.pbxproj | 2 + .../ChatViewModel+PrivateChat.swift | 30 +++ bitchat/Views/ContentView.swift | 253 +----------------- .../Views/Image Viewers/ImagePickerView.swift | 79 ++++++ .../Image Viewers/ImagePreviewView.swift | 147 ++++++++++ .../Image Viewers/MacImagePickerView.swift | 71 +++++ .../PreviewAssets.xcassets/Contents.json | 6 + .../dummy.imageset/Contents.json | 21 ++ .../dummy.imageset/dummy.jpg | Bin 0 -> 13783 bytes 10 files changed, 361 insertions(+), 249 deletions(-) create mode 100644 bitchat/Views/Image Viewers/ImagePickerView.swift create mode 100644 bitchat/Views/Image Viewers/ImagePreviewView.swift create mode 100644 bitchat/Views/Image Viewers/MacImagePickerView.swift create mode 100644 bitchat/_PreviewHelpers/PreviewAssets.xcassets/Contents.json create mode 100644 bitchat/_PreviewHelpers/PreviewAssets.xcassets/dummy.imageset/Contents.json create mode 100644 bitchat/_PreviewHelpers/PreviewAssets.xcassets/dummy.imageset/dummy.jpg diff --git a/Package.swift b/Package.swift index 181880cb..c76990f3 100644 --- a/Package.swift +++ b/Package.swift @@ -32,6 +32,7 @@ let package = Package( exclude: [ "Info.plist", "Assets.xcassets", + "_PreviewHelpers/PreviewAssets.xcassets", "bitchat.entitlements", "bitchat-macOS.entitlements", "LaunchScreen.storyboard", diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 572035df..fea9b691 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -548,6 +548,7 @@ CODE_SIGNING_REQUIRED = YES; CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; + DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = NO; INFOPLIST_FILE = bitchat/Info.plist; @@ -608,6 +609,7 @@ CODE_SIGNING_REQUIRED = YES; CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; + DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift index bc93c763..5155c7ee 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift @@ -364,6 +364,36 @@ extension ChatViewModel { } } + #if os(iOS) + func processThenSendImage(_ image: UIImage?) { + guard let image else { return } + Task.detached { + do { + let processedURL = try ImageUtils.processImage(image) + await MainActor.run { + self.sendImage(from: processedURL) + } + } catch { + SecureLogger.error("Image processing failed: \(error)", category: .session) + } + } + } + #elseif os(macOS) + func processThenSendImage(from url: URL?) { + guard let url else { return } + Task.detached { + do { + let processedURL = try ImageUtils.processImage(at: url) + await MainActor.run { + self.sendImage(from: processedURL) + } + } catch { + SecureLogger.error("Image processing failed: \(error)", category: .session) + } + } + } + #endif + @MainActor func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) { guard canSendMediaInCurrentContext else { diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 78c2316b..e825d6ea 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -223,18 +223,7 @@ struct ContentView: View { )) { ImagePickerView(sourceType: imagePickerSourceType) { image in showImagePicker = false - if let image = image { - Task { - do { - let processedURL = try ImageUtils.processImage(image) - await MainActor.run { - viewModel.sendImage(from: processedURL) - } - } catch { - SecureLogger.error("Image processing failed: \(error)", category: .session) - } - } - } + viewModel.processThenSendImage(image) } .environmentObject(viewModel) .ignoresSafeArea() @@ -252,18 +241,7 @@ struct ContentView: View { )) { MacImagePickerView { url in showMacImagePicker = false - if let url = url { - Task { - do { - let processedURL = try ImageUtils.processImage(at: url) - await MainActor.run { - viewModel.sendImage(from: processedURL) - } - } catch { - SecureLogger.error("Image processing failed: \(error)", category: .session) - } - } - } + viewModel.processThenSendImage(from: url) } .environmentObject(viewModel) } @@ -842,18 +820,7 @@ struct ContentView: View { )) { ImagePickerView(sourceType: imagePickerSourceType) { image in showImagePicker = false - if let image = image { - Task { - do { - let processedURL = try ImageUtils.processImage(image) - await MainActor.run { - viewModel.sendImage(from: processedURL) - } - } catch { - SecureLogger.error("Image processing failed: \(error)", category: .session) - } - } - } + viewModel.processThenSendImage(image) } .environmentObject(viewModel) .ignoresSafeArea() @@ -863,18 +830,7 @@ struct ContentView: View { .sheet(isPresented: $showMacImagePicker) { MacImagePickerView { url in showMacImagePicker = false - if let url = url { - Task { - do { - let processedURL = try ImageUtils.processImage(at: url) - await MainActor.run { - viewModel.sendImage(from: processedURL) - } - } catch { - SecureLogger.error("Image processing failed: \(error)", category: .session) - } - } - } + viewModel.processThenSendImage(from: url) } .environmentObject(viewModel) } @@ -1850,204 +1806,3 @@ private extension ContentView { } } } - -// - -struct ImagePreviewView: View { - let url: URL - - @Environment(\.dismiss) private var dismiss - #if os(iOS) - @State private var showExporter = false - @State private var platformImage: UIImage? - #else - @State private var platformImage: NSImage? - #endif - - var body: some View { - ZStack { - Color.black.ignoresSafeArea() - VStack { - Spacer() - if let image = platformImage { - #if os(iOS) - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .padding() - #else - Image(nsImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .padding() - #endif - } else { - ProgressView() - .progressViewStyle(.circular) - .tint(.white) - } - Spacer() - HStack { - Button(action: { dismiss() }) { - Text("close", comment: "Button to dismiss fullscreen media viewer") - .font(.bitchatSystem(size: 15, weight: .semibold)) - .foregroundColor(.white) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(RoundedRectangle(cornerRadius: 12).stroke(Color.white.opacity(0.5), lineWidth: 1)) - } - Spacer() - Button(action: saveCopy) { - Text("save", comment: "Button to save media to device") - .font(.bitchatSystem(size: 15, weight: .semibold)) - .foregroundColor(.white) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(RoundedRectangle(cornerRadius: 12).fill(Color.blue.opacity(0.6))) - } - } - .padding([.horizontal, .bottom], 24) - } - } - .onAppear(perform: loadImage) - #if os(iOS) - .sheet(isPresented: $showExporter) { - FileExportWrapper(url: url) - } - #endif - } - - private func loadImage() { - DispatchQueue.global(qos: .userInitiated).async { - #if os(iOS) - guard let image = UIImage(contentsOfFile: url.path) else { return } - #else - guard let image = NSImage(contentsOf: url) else { return } - #endif - DispatchQueue.main.async { - self.platformImage = image - } - } - } - - private func saveCopy() { - #if os(iOS) - showExporter = true - #else - Task { @MainActor in - let panel = NSSavePanel() - panel.canCreateDirectories = true - panel.nameFieldStringValue = url.lastPathComponent - panel.prompt = "save" - if panel.runModal() == .OK, let destination = panel.url { - do { - if FileManager.default.fileExists(atPath: destination.path) { - try FileManager.default.removeItem(at: destination) - } - try FileManager.default.copyItem(at: url, to: destination) - } catch { - SecureLogger.error("Failed to save image preview copy: \(error)", category: .session) - } - } - } - #endif - } - - #if os(iOS) - private struct FileExportWrapper: UIViewControllerRepresentable { - let url: URL - - func makeUIViewController(context: Context) -> UIDocumentPickerViewController { - let controller = UIDocumentPickerViewController(forExporting: [url]) - controller.shouldShowFileExtensions = true - return controller - } - - func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {} - } -#endif -} - -#if os(iOS) -// MARK: - Image Picker (Camera or Photo Library) -struct ImagePickerView: UIViewControllerRepresentable { - let sourceType: UIImagePickerController.SourceType - let completion: (UIImage?) -> Void - - func makeUIViewController(context: Context) -> UIImagePickerController { - let picker = UIImagePickerController() - picker.sourceType = sourceType - picker.delegate = context.coordinator - picker.allowsEditing = false - - // Use standard full screen - iOS handles safe areas automatically - picker.modalPresentationStyle = .fullScreen - - // Force dark mode to make safe area bars black instead of white - picker.overrideUserInterfaceStyle = .dark - - return picker - } - - func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} - - func makeCoordinator() -> Coordinator { - Coordinator(completion: completion) - } - - class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { - let completion: (UIImage?) -> Void - - init(completion: @escaping (UIImage?) -> Void) { - self.completion = completion - } - - func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { - let image = info[.originalImage] as? UIImage - completion(image) - } - - func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { - completion(nil) - } - } -} -#endif - -#if os(macOS) -// MARK: - macOS Image Picker -struct MacImagePickerView: View { - let completion: (URL?) -> Void - @Environment(\.dismiss) private var dismiss - - var body: some View { - VStack(spacing: 16) { - Text("Choose an image") - .font(.headline) - - Button("Select Image") { - let panel = NSOpenPanel() - panel.allowsMultipleSelection = false - panel.canChooseDirectories = false - panel.canChooseFiles = true - panel.allowedContentTypes = [.image, .png, .jpeg, .heic] - panel.message = "Choose an image to send" - - if panel.runModal() == .OK { - completion(panel.url) - } else { - dismiss() - } - } - .buttonStyle(.borderedProminent) - - Button("Cancel") { - completion(nil) - } - .buttonStyle(.bordered) - } - .padding(40) - .frame(minWidth: 300, minHeight: 150) - } -} -#endif diff --git a/bitchat/Views/Image Viewers/ImagePickerView.swift b/bitchat/Views/Image Viewers/ImagePickerView.swift new file mode 100644 index 00000000..defc81e4 --- /dev/null +++ b/bitchat/Views/Image Viewers/ImagePickerView.swift @@ -0,0 +1,79 @@ +// +// ImagePickerView.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +#if os(iOS) + +import SwiftUI + +/// Camera or Photo Library +struct ImagePickerView: UIViewControllerRepresentable { + let sourceType: UIImagePickerController.SourceType + let completion: (UIImage?) -> Void + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = sourceType + picker.delegate = context.coordinator + picker.allowsEditing = false + + // Use standard full screen - iOS handles safe areas automatically + picker.modalPresentationStyle = .fullScreen + + // Force dark mode to make safe area bars black instead of white + picker.overrideUserInterfaceStyle = .dark + + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(completion: completion) + } + + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let completion: (UIImage?) -> Void + + init(completion: @escaping (UIImage?) -> Void) { + self.completion = completion + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + let image = info[.originalImage] as? UIImage + completion(image) + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + completion(nil) + } + } +} + +@available(iOS 17, *) +#Preview { + @Previewable @State var isPresented = true + @Previewable @State var selectedImage: UIImage? + VStack { + if let selectedImage { + Image(uiImage: selectedImage) + .resizable() + .scaledToFit() + } else { + Text("No image selected") + } + Button("Show") { isPresented = true } + } + .sheet(isPresented: $isPresented) { + ImagePickerView(sourceType: .photoLibrary) { image in + selectedImage = image + isPresented = false + } + } +} + +#endif diff --git a/bitchat/Views/Image Viewers/ImagePreviewView.swift b/bitchat/Views/Image Viewers/ImagePreviewView.swift new file mode 100644 index 00000000..c7b4fb92 --- /dev/null +++ b/bitchat/Views/Image Viewers/ImagePreviewView.swift @@ -0,0 +1,147 @@ +// +// ImagePreviewView.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI +#if os(macOS) +import BitLogger +#endif + +struct ImagePreviewView: View { + let url: URL + + @Environment(\.dismiss) private var dismiss + #if os(iOS) + @State private var showExporter = false + @State private var platformImage: UIImage? + #else + @State private var platformImage: NSImage? + #endif + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + VStack { + Spacer() + if let image = platformImage { + #if os(iOS) + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .padding() + #else + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .padding() + #endif + } else { + ProgressView() + .progressViewStyle(.circular) + .tint(.white) + } + Spacer() + HStack { + Button(action: { dismiss() }) { + Text("close", comment: "Button to dismiss fullscreen media viewer") + .font(.bitchatSystem(size: 15, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 12).stroke(Color.white.opacity(0.5), lineWidth: 1)) + } + Spacer() + Button(action: saveCopy) { + Text("save", comment: "Button to save media to device") + .font(.bitchatSystem(size: 15, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.blue.opacity(0.6))) + } + } + .padding([.horizontal, .bottom], 24) + } + } + .onAppear(perform: loadImage) + #if os(iOS) + .sheet(isPresented: $showExporter) { + FileExportWrapper(url: url) + } + #endif + } + + private func loadImage() { + DispatchQueue.global(qos: .userInitiated).async { + #if os(iOS) + guard let image = UIImage(contentsOfFile: url.path) else { return } + #else + guard let image = NSImage(contentsOf: url) else { return } + #endif + DispatchQueue.main.async { + self.platformImage = image + } + } + } + + private func saveCopy() { + #if os(iOS) + showExporter = true + #else + Task { @MainActor in + let panel = NSSavePanel() + panel.canCreateDirectories = true + panel.nameFieldStringValue = url.lastPathComponent + panel.prompt = "save" + if panel.runModal() == .OK, let destination = panel.url { + do { + if FileManager.default.fileExists(atPath: destination.path) { + try FileManager.default.removeItem(at: destination) + } + try FileManager.default.copyItem(at: url, to: destination) + } catch { + SecureLogger.error("Failed to save image preview copy: \(error)", category: .session) + } + } + } + #endif + } + + #if os(iOS) + private struct FileExportWrapper: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context: Context) -> UIDocumentPickerViewController { + let controller = UIDocumentPickerViewController(forExporting: [url]) + controller.shouldShowFileExtensions = true + return controller + } + + func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {} + } + #endif +} + +#Preview { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("dummy.jpg") + if !FileManager.default.fileExists(atPath: tempURL.path(percentEncoded: false)) { + #if os(iOS) + let image = UIImage(named: "dummy") + let data = image?.jpegData(compressionQuality: 0.8) + let _ = try? data?.write(to: tempURL) + #elseif os(macOS) + let image = NSImage(named: "dummy") + var rect = NSRect(origin: .zero, size: image?.size ?? .zero) + if let cgImage = image?.cgImage(forProposedRect: &rect, context: nil, hints: nil) { + let rep = NSBitmapImageRep(cgImage: cgImage) + let jpegData = rep.representation(using: .jpeg, properties: [.compressionFactor: 0.8]) + let _ = try? jpegData?.write(to: tempURL) + } + #endif + } + ImagePreviewView(url: tempURL) +} diff --git a/bitchat/Views/Image Viewers/MacImagePickerView.swift b/bitchat/Views/Image Viewers/MacImagePickerView.swift new file mode 100644 index 00000000..805d7495 --- /dev/null +++ b/bitchat/Views/Image Viewers/MacImagePickerView.swift @@ -0,0 +1,71 @@ +// +// MacImagePickerView.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +#if os(macOS) + +import SwiftUI + +struct MacImagePickerView: View { + let completion: (URL?) -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 16) { + Text("Choose an image") + .font(.headline) + + Button("Select Image") { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowedContentTypes = [.image, .png, .jpeg, .heic] + panel.message = "Choose an image to send" + + if panel.runModal() == .OK { + completion(panel.url) + } else { + dismiss() + } + } + .buttonStyle(.borderedProminent) + + Button("Cancel") { + completion(nil) + } + .buttonStyle(.bordered) + } + .padding(40) + .frame(minWidth: 300, minHeight: 150) + } +} + +@available(OSX 14, *) +#Preview { + @Previewable @State var isPresented = true + @Previewable @State var selectedImage: NSImage? + + VStack { + if let selectedImage { + Image(nsImage: selectedImage) + .resizable() + .scaledToFit() + } else { + Text("No image selected") + } + Button("Show") { isPresented = true } + } + .sheet(isPresented: $isPresented) { + MacImagePickerView { url in + selectedImage = url.map(NSImage.init) + isPresented = false + } + } +} + +#endif diff --git a/bitchat/_PreviewHelpers/PreviewAssets.xcassets/Contents.json b/bitchat/_PreviewHelpers/PreviewAssets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/bitchat/_PreviewHelpers/PreviewAssets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/bitchat/_PreviewHelpers/PreviewAssets.xcassets/dummy.imageset/Contents.json b/bitchat/_PreviewHelpers/PreviewAssets.xcassets/dummy.imageset/Contents.json new file mode 100644 index 00000000..5c48b225 --- /dev/null +++ b/bitchat/_PreviewHelpers/PreviewAssets.xcassets/dummy.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "dummy.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/bitchat/_PreviewHelpers/PreviewAssets.xcassets/dummy.imageset/dummy.jpg b/bitchat/_PreviewHelpers/PreviewAssets.xcassets/dummy.imageset/dummy.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e72e1c22c11ec4f1471549cb5fe31d19c4e20bb5 GIT binary patch literal 13783 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 literal 0 HcmV?d00001