images wip

This commit is contained in:
callebtc
2025-09-17 01:31:34 +02:00
parent 5486fc6a69
commit ea7b90c074
4 changed files with 276 additions and 35 deletions
+4
View File
@@ -33,6 +33,10 @@
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>bitchat saves received images to your photo library when you choose to save them.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>bitchat needs access to your photos to let you pick images to send.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSCameraUsageDescription</key>
+100
View File
@@ -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] ?? []
+104 -35
View File
@@ -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 {
+68
View File
@@ -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