diff --git a/bitchat/Info.plist b/bitchat/Info.plist index 0e669e9a..20585e8e 100644 --- a/bitchat/Info.plist +++ b/bitchat/Info.plist @@ -33,6 +33,10 @@ $(MACOSX_DEPLOYMENT_TARGET) NSBluetoothAlwaysUsageDescription bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users. + NSPhotoLibraryAddUsageDescription + bitchat saves received images to your photo library when you choose to save them. + NSPhotoLibraryUsageDescription + bitchat needs access to your photos to let you pick images to send. NSBluetoothPeripheralUsageDescription bitchat uses Bluetooth to discover and connect with other bitchat users nearby. NSCameraUsageDescription diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 1045e9e7..187a8441 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -86,6 +86,9 @@ import CommonCrypto import CoreBluetooth #if os(iOS) import UIKit +#if canImport(PhotosUI) +import PhotosUI +#endif #endif /// Manages the application state and business logic for BitChat. @@ -6036,6 +6039,7 @@ private func checkForMentions(_ message: BitchatMessage) { impactFeedback.prepare() for i in 0..<8 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * TransportConfig.uiBatchDispatchStaggerSeconds) { impactFeedback.impactOccurred() } @@ -6101,6 +6105,102 @@ private func checkForMentions(_ message: BitchatMessage) { } } + // MARK: - Images (Send) + #if os(iOS) + @MainActor + func sendImage(_ image: UIImage) { + // Downscale and encode JPEG + guard let data = downscaleJPEG(image, maxDimension: 512, quality: 0.85) else { return } + + // Persist to app files (outgoing) + guard let fileURL = saveOutgoingImage(data: data) else { return } + + let name = fileURL.lastPathComponent + let mime = "image/jpeg" + let tlv = BitchatFilePacket(fileName: name, fileSize: UInt64(data.count), mimeType: mime, content: data) + guard let payload = tlv.encode() else { return } + let transferId = payload.sha256Hex() + + let contentMarker = "[image] \(fileURL.path)" + let now = Date() + + if let peer = selectedPrivateChatPeer { + let messageID = UUID().uuidString + let msg = BitchatMessage( + id: messageID, + sender: nickname, + content: contentMarker, + timestamp: now, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: meshService.peerNickname(peerID: peer), + senderPeerID: meshService.myPeerID, + mentions: nil, + deliveryStatus: .sending + ) + appendToPrivateChat(peerID: peer, message: msg) + meshService.sendFileTransferTLV(payload, recipientPeerID: peer, transferId: transferId, messageID: messageID) + } else { + let messageID = UUID().uuidString + let msg = BitchatMessage( + id: messageID, + sender: nickname, + content: contentMarker, + timestamp: now, + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: meshService.myPeerID, + mentions: nil, + deliveryStatus: .sending + ) + publicBuffer.append(msg) + schedulePublicFlush() + meshService.sendFileTransferTLV(payload, recipientPeerID: nil, transferId: transferId, messageID: messageID) + } + } + #endif + + #if os(iOS) + private func downscaleJPEG(_ image: UIImage, maxDimension: CGFloat, quality: CGFloat) -> Data? { + let size = image.size + guard size.width > 0 && size.height > 0 else { return image.jpegData(compressionQuality: quality) } + let maxSide = max(size.width, size.height) + let scale = min(1.0, maxDimension / maxSide) + let target = CGSize(width: floor(size.width * scale), height: floor(size.height * scale)) + + let rendererFormat = UIGraphicsImageRendererFormat.default() + rendererFormat.scale = 1 + let renderer = UIGraphicsImageRenderer(size: target, format: rendererFormat) + let scaled = renderer.image { ctx in + UIColor.clear.setFill() + ctx.fill(CGRect(origin: .zero, size: target)) + image.draw(in: CGRect(origin: .zero, size: target)) + } + return scaled.jpegData(compressionQuality: quality) + } + + private func saveOutgoingImage(data: Data) -> URL? { + do { + let fm = FileManager.default + let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let folder = base.appendingPathComponent("bitchat_images/outgoing", isDirectory: true) + if !fm.fileExists(atPath: folder.path) { + try fm.createDirectory(at: folder, withIntermediateDirectories: true) + } + let ts = Int(Date().timeIntervalSince1970) + let fileURL = folder.appendingPathComponent("img_\(ts)_\(UUID().uuidString.prefix(8)).jpg") + try data.write(to: fileURL, options: .atomic) + return fileURL + } catch { + SecureLogger.error("❌ Failed to save outgoing image: \(error)", category: .session) + return nil + } + } + #endif + @MainActor private func appendToPrivateChat(peerID: String, message: BitchatMessage) { var arr = privateChats[peerID] ?? [] diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 9711940a..121607d1 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -9,6 +9,10 @@ import SwiftUI #if os(iOS) import UIKit +#if os(iOS) +import PhotosUI +#endif + #endif // MARK: - Supporting Types @@ -60,6 +64,7 @@ struct ContentView: View { #if os(iOS) @State private var voiceRecorder = VoiceRecorder() @State private var isRecordingVoice = false + @State private var isShowingImagePicker = false #endif // MARK: - Computed Properties @@ -256,6 +261,16 @@ struct ContentView: View { scrollThrottleTimer?.invalidate() autocompleteDebounceTimer?.invalidate() } + #if os(iOS) + .sheet(isPresented: $isShowingImagePicker) { + ImagePickerView { image in + if let img = image { + viewModel.sendImage(img) + } + isShowingImagePicker = false + } + } + #endif } // MARK: - Message List View @@ -310,6 +325,8 @@ struct ContentView: View { let cashuTokens = message.content.extractCashuTokens() let lightningLinks = message.content.extractLightningLinks() let isVoice = message.content.hasPrefix("[voice] ") + let isImage = message.content.hasPrefix("[image] ") + if isVoice { // Hide the raw marker text; render custom voice view HStack(alignment: .center, spacing: 8) { @@ -331,6 +348,41 @@ struct ContentView: View { DeliveryStatusView(status: status, colorScheme: colorScheme) .padding(.leading, 4) } + } else if isImage { + // Hide the raw marker text; render image view + let path = String(message.content.dropFirst("[image] ".count)) + #if os(iOS) + if let uiImage = UIImage(contentsOfFile: path) { + VStack(alignment: .leading, spacing: 4) { + // Image with rounded corners and left alignment + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 200, maxHeight: 200) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(radius: 2) + + // Delivery status for our own private images + if message.isPrivate && message.sender == viewModel.nickname, + let status = message.deliveryStatus { + DeliveryStatusView(status: status, colorScheme: colorScheme) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + // Fallback for invalid image files + Text("⚠️ Image unavailable") + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + #else + // macOS: show path for now since NSImage loading is different + Text("📷 Image: \(path)") + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(textColor) + .frame(maxWidth: .infinity, alignment: .leading) + #endif } else { HStack(alignment: .top, spacing: 0) { let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty @@ -901,44 +953,55 @@ struct ContentView: View { Group { if messageText.isEmpty { #if os(iOS) - Image(systemName: isRecordingVoice ? "mic.circle.fill" : "mic.circle") - .font(.system(size: 22)) - .foregroundColor(isRecordingVoice ? .red : (viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor)) - .padding(.trailing, 12) - .highPriorityGesture( - DragGesture(minimumDistance: 0) - .onChanged { _ in - if !isRecordingVoice { - do { - let url = try VoiceRecorderPaths.outgoingURL() - try voiceRecorder.startRecording(to: url) - isRecordingVoice = true - } catch { + HStack(spacing: 8) { + // Plus button for image picker (to the left of mic) + Button(action: { showImagePicker() }) { + Image(systemName: "plus.circle") + .font(.system(size: 22)) + .foregroundColor(viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor) + } + .buttonStyle(.plain) + .accessibilityLabel("Add media") + + Image(systemName: isRecordingVoice ? "mic.circle.fill" : "mic.circle") + .font(.system(size: 22)) + .foregroundColor(isRecordingVoice ? .red : (viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor)) + .highPriorityGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if !isRecordingVoice { + do { + let url = try VoiceRecorderPaths.outgoingURL() + try voiceRecorder.startRecording(to: url) + isRecordingVoice = true + } catch { + isRecordingVoice = false + } + } + } + .onEnded { _ in + voiceRecorder.stopRecording { isRecordingVoice = false + // Send the most recent recorded file + do { + let fm = FileManager.default + let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let folder = base.appendingPathComponent("bitchat_voicenotes/outgoing", isDirectory: true) + let files = try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles]) + let latest = files.sorted { (a, b) -> Bool in + let ad = (try? a.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + let bd = (try? b.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + return ad > bd + }.first + if let u = latest { viewModel.sendVoiceNote(fileURL: u) } + } catch { + // ignore + } } } - } - .onEnded { _ in - voiceRecorder.stopRecording { - isRecordingVoice = false - // Send the most recent recorded file - do { - let fm = FileManager.default - let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - let folder = base.appendingPathComponent("bitchat_voicenotes/outgoing", isDirectory: true) - let files = try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles]) - let latest = files.sorted { (a, b) -> Bool in - let ad = (try? a.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - let bd = (try? b.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - return ad > bd - }.first - if let u = latest { viewModel.sendVoiceNote(fileURL: u) } - } catch { - // ignore - } - } - } - ) + ) + } + .padding(.trailing, 12) #else // Non-iOS: keep send button placeholder Button(action: sendMessage) { @@ -980,6 +1043,12 @@ struct ContentView: View { messageText = "" } + #if os(iOS) + private func showImagePicker() { + isShowingImagePicker = true + } + #endif + // MARK: - Sidebar View private var sidebarView: some View { diff --git a/bitchat/Views/ImagePickerView.swift b/bitchat/Views/ImagePickerView.swift new file mode 100644 index 00000000..a2bf1d17 --- /dev/null +++ b/bitchat/Views/ImagePickerView.swift @@ -0,0 +1,68 @@ +// ImagePickerView.swift +// Simple UIKit-based image picker bridge + +import SwiftUI +#if os(iOS) +import UIKit +import PhotosUI + +struct ImagePickerView: UIViewControllerRepresentable { + typealias UIViewControllerType = UIViewController + var onPicked: (UIImage?) -> Void + + func makeUIViewController(context: Context) -> UIViewController { + if #available(iOS 14.0, *) { + var config = PHPickerConfiguration(photoLibrary: .shared()) + config.filter = .images + config.selectionLimit = 1 + let picker = PHPickerViewController(configuration: config) + picker.delegate = context.coordinator + return picker + } else { + let picker = UIImagePickerController() + picker.delegate = context.coordinator + picker.sourceType = .photoLibrary + picker.allowsEditing = false + return picker + } + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(onPicked: onPicked) + } + + final class Coordinator: NSObject, PHPickerViewControllerDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let onPicked: (UIImage?) -> Void + init(onPicked: @escaping (UIImage?) -> Void) { self.onPicked = onPicked } + + // PHPicker + @available(iOS 14.0, *) + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard let item = results.first else { onPicked(nil); return } + if item.itemProvider.canLoadObject(ofClass: UIImage.self) { + item.itemProvider.loadObject(ofClass: UIImage.self) { object, _ in + DispatchQueue.main.async { + self.onPicked(object as? UIImage) + } + } + } else { + onPicked(nil) + } + } + + // UIImagePicker + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + onPicked(nil) + } + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + let image = info[.originalImage] as? UIImage + picker.dismiss(animated: true) + onPicked(image) + } + } +} +#endif