mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
images wip
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user