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 00000000..e72e1c22 Binary files /dev/null and b/bitchat/_PreviewHelpers/PreviewAssets.xcassets/dummy.imageset/dummy.jpg differ