From ea7b90c0742c5231148311433993502423b76486 Mon Sep 17 00:00:00 2001
From: callebtc <93376500+callebtc@users.noreply.github.com>
Date: Wed, 17 Sep 2025 01:31:34 +0200
Subject: [PATCH] images wip
---
bitchat/Info.plist | 4 +
bitchat/ViewModels/ChatViewModel.swift | 100 ++++++++++++++++++
bitchat/Views/ContentView.swift | 139 ++++++++++++++++++-------
bitchat/Views/ImagePickerView.swift | 68 ++++++++++++
4 files changed, 276 insertions(+), 35 deletions(-)
create mode 100644 bitchat/Views/ImagePickerView.swift
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