mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 23:25:20 +00:00
Add BLE file transfer support and media UX
This commit is contained in:
@@ -87,6 +87,7 @@ import Tor
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Manages the application state and business logic for BitChat.
|
||||
/// Acts as the primary coordinator between UI components and backend services,
|
||||
@@ -430,6 +431,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
// Delivery tracking
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var transferIdToMessageID: [String: String] = [:]
|
||||
private var messageIDToTransferId: [String: String] = [:]
|
||||
|
||||
// MARK: - QR Verification (pending state)
|
||||
private struct PendingVerification {
|
||||
@@ -644,6 +647,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Set up Noise encryption callbacks
|
||||
setupNoiseCallbacks()
|
||||
|
||||
TransferProgressManager.shared.publisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] event in
|
||||
self?.handleTransferEvent(event)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Observe location channel selection
|
||||
LocationChannelManager.shared.$selectedChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
@@ -2381,6 +2391,411 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Media Transfers
|
||||
|
||||
private enum MediaSendError: Error {
|
||||
case encodingFailed
|
||||
case tooLarge
|
||||
case copyFailed
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendVoiceNote(at url: URL) {
|
||||
let targetPeer = selectedPrivateChatPeer
|
||||
let message = enqueueMediaMessage(content: "[voice] \(url.path)", targetPeer: targetPeer)
|
||||
let messageID = message.id
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
guard data.count <= FileTransferLimits.maxVoiceNoteBytes else {
|
||||
SecureLogger.warning("Voice note exceeds size limit (\(data.count) bytes)", category: .session)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
await MainActor.run {
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large")
|
||||
}
|
||||
return
|
||||
}
|
||||
let packet = BitchatFilePacket(
|
||||
fileName: url.lastPathComponent,
|
||||
fileSize: UInt64(data.count),
|
||||
mimeType: "audio/mp4",
|
||||
content: data
|
||||
)
|
||||
guard let payload = packet.encode() else { throw MediaSendError.encodingFailed }
|
||||
let transferId = payload.sha256Hex()
|
||||
await MainActor.run {
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
if let peerID = targetPeer {
|
||||
self.meshService.sendFilePrivate(packet, to: peerID)
|
||||
} else {
|
||||
self.meshService.sendFileBroadcast(packet)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||
await MainActor.run {
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendImage(from sourceURL: URL) {
|
||||
let targetPeer = selectedPrivateChatPeer
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
var processedURL: URL?
|
||||
do {
|
||||
let outputURL = try ImageUtils.processImage(at: sourceURL)
|
||||
processedURL = outputURL
|
||||
let data = try Data(contentsOf: outputURL)
|
||||
guard data.count <= FileTransferLimits.maxImageBytes else {
|
||||
SecureLogger.warning("Processed image exceeds size limit (\(data.count) bytes)", category: .session)
|
||||
await MainActor.run {
|
||||
self.addSystemMessage("Image is too large to send.")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
return
|
||||
}
|
||||
let packet = BitchatFilePacket(
|
||||
fileName: outputURL.lastPathComponent,
|
||||
fileSize: UInt64(data.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: data
|
||||
)
|
||||
guard let payload = packet.encode() else { throw MediaSendError.encodingFailed }
|
||||
let transferId = payload.sha256Hex()
|
||||
await MainActor.run {
|
||||
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.path)", targetPeer: targetPeer)
|
||||
let messageID = message.id
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
if let peerID = targetPeer {
|
||||
self.meshService.sendFilePrivate(packet, to: peerID)
|
||||
} else {
|
||||
self.meshService.sendFileBroadcast(packet)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
||||
await MainActor.run {
|
||||
self.addSystemMessage("Failed to prepare image for sending.")
|
||||
}
|
||||
if let url = processedURL {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendFileAttachment(from sourceURL: URL) {
|
||||
let targetPeer = selectedPrivateChatPeer
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
var destinationURL: URL?
|
||||
do {
|
||||
let data = try Data(contentsOf: sourceURL)
|
||||
guard FileTransferLimits.isValidPayload(data.count) else {
|
||||
throw MediaSendError.tooLarge
|
||||
}
|
||||
|
||||
let destination = try self.prepareOutgoingFileCopy(from: sourceURL, data: data)
|
||||
destinationURL = destination
|
||||
let packet = BitchatFilePacket(
|
||||
fileName: destination.lastPathComponent,
|
||||
fileSize: UInt64(data.count),
|
||||
mimeType: self.mimeType(for: destination),
|
||||
content: data
|
||||
)
|
||||
guard let payload = packet.encode() else {
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
throw MediaSendError.encodingFailed
|
||||
}
|
||||
let transferId = payload.sha256Hex()
|
||||
|
||||
await MainActor.run {
|
||||
let message = self.enqueueMediaMessage(content: "[file] \(destination.path)", targetPeer: targetPeer)
|
||||
let messageID = message.id
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
if let peerID = targetPeer {
|
||||
self.meshService.sendFilePrivate(packet, to: peerID)
|
||||
} else {
|
||||
self.meshService.sendFileBroadcast(packet)
|
||||
}
|
||||
}
|
||||
} catch MediaSendError.tooLarge {
|
||||
await MainActor.run {
|
||||
self.addSystemMessage("File is too large to send.")
|
||||
}
|
||||
} catch {
|
||||
if let destination = destinationURL {
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
}
|
||||
SecureLogger.error("File attachment send failed: \(error)", category: .session)
|
||||
await MainActor.run {
|
||||
self.addSystemMessage("Failed to send file.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func cancelMediaSend(messageID: String) {
|
||||
if let transferId = messageIDToTransferId[messageID] {
|
||||
meshService.cancelTransfer(transferId)
|
||||
}
|
||||
clearTransferMapping(for: messageID)
|
||||
removeMessage(withID: messageID, cleanupFile: true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func deleteMediaMessage(messageID: String) {
|
||||
clearTransferMapping(for: messageID)
|
||||
removeMessage(withID: messageID, cleanupFile: true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func enqueueMediaMessage(content: String, targetPeer: String?) -> BitchatMessage {
|
||||
let timestamp = Date()
|
||||
let message: BitchatMessage
|
||||
|
||||
if let peerID = targetPeer {
|
||||
message = BitchatMessage(
|
||||
sender: nickname,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: nicknameForPeer(peerID),
|
||||
senderPeerID: meshService.myPeerID,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
var chats = privateChats
|
||||
chats[peerID, default: []].append(message)
|
||||
privateChats = chats
|
||||
trimPrivateChatMessagesIfNeeded(for: peerID)
|
||||
} else {
|
||||
let (displayName, senderPeerID) = currentPublicSender()
|
||||
message = BitchatMessage(
|
||||
sender: displayName,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: senderPeerID,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
messages.append(message)
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
meshTimeline.append(message)
|
||||
trimMeshTimelineIfNeeded()
|
||||
case .location(let ch):
|
||||
var arr = geoTimelines[ch.geohash] ?? []
|
||||
arr.append(message)
|
||||
if arr.count > geoTimelineCap {
|
||||
arr = Array(arr.suffix(geoTimelineCap))
|
||||
}
|
||||
geoTimelines[ch.geohash] = arr
|
||||
}
|
||||
trimMessagesIfNeeded()
|
||||
}
|
||||
|
||||
let key = normalizedContentKey(message.content)
|
||||
recordContentKey(key, timestamp: timestamp)
|
||||
objectWillChange.send()
|
||||
return message
|
||||
}
|
||||
|
||||
private func currentPublicSender() -> (name: String, peerID: String) {
|
||||
var displaySender = nickname
|
||||
var senderPeerID = meshService.myPeerID
|
||||
if case .location(let ch) = activeChannel,
|
||||
let identity = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
let suffix = String(identity.publicKeyHex.suffix(4))
|
||||
displaySender = nickname + "#" + suffix
|
||||
let shortKey = identity.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength)
|
||||
senderPeerID = "nostr:\(shortKey)"
|
||||
}
|
||||
return (displaySender, senderPeerID)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func nicknameForPeer(_ peerID: String) -> String {
|
||||
if let name = meshService.peerNickname(peerID: peerID) {
|
||||
return name
|
||||
}
|
||||
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||
!favorite.peerNickname.isEmpty {
|
||||
return favorite.peerNickname
|
||||
}
|
||||
if let noiseKey = Data(hexString: peerID),
|
||||
let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
!favorite.peerNickname.isEmpty {
|
||||
return favorite.peerNickname
|
||||
}
|
||||
return "user"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func registerTransfer(transferId: String, messageID: String) {
|
||||
transferIdToMessageID[transferId] = messageID
|
||||
messageIDToTransferId[messageID] = transferId
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func clearTransferMapping(for messageID: String) {
|
||||
if let transferId = messageIDToTransferId.removeValue(forKey: messageID) {
|
||||
transferIdToMessageID.removeValue(forKey: transferId)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleMediaSendFailure(messageID: String, reason: String) {
|
||||
updateMessageDeliveryStatus(messageID, status: .failed(reason: reason))
|
||||
clearTransferMapping(for: messageID)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleTransferEvent(_ event: TransferProgressManager.Event) {
|
||||
switch event {
|
||||
case .started(let id, let total):
|
||||
guard let messageID = transferIdToMessageID[id] else { return }
|
||||
updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total))
|
||||
case .updated(let id, let sent, let total):
|
||||
guard let messageID = transferIdToMessageID[id] else { return }
|
||||
updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total))
|
||||
case .completed(let id, _):
|
||||
guard let messageID = transferIdToMessageID[id] else { return }
|
||||
updateMessageDeliveryStatus(messageID, status: .sent)
|
||||
clearTransferMapping(for: messageID)
|
||||
case .cancelled(let id, _, _):
|
||||
guard let messageID = transferIdToMessageID[id] else { return }
|
||||
clearTransferMapping(for: messageID)
|
||||
removeMessage(withID: messageID, cleanupFile: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanupLocalFile(forMessage message: BitchatMessage) {
|
||||
let prefixes = ["[voice] ", "[image] ", "[file] "]
|
||||
guard let prefix = prefixes.first(where: { message.content.hasPrefix($0) }) else { return }
|
||||
let path = String(message.content.dropFirst(prefix.count))
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
try? FileManager.default.removeItem(atPath: path)
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareOutgoingFileCopy(from sourceURL: URL, data: Data) throws -> URL {
|
||||
let base = try applicationFilesDirectory().appendingPathComponent("files/outgoing", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
||||
|
||||
let rawName = sourceURL.lastPathComponent
|
||||
let sanitized = sanitizeOutgoingFileName(rawName.isEmpty ? "file" : rawName)
|
||||
var destination = base.appendingPathComponent(sanitized)
|
||||
var counter = 1
|
||||
while FileManager.default.fileExists(atPath: destination.path) {
|
||||
let baseName = (sanitized as NSString).deletingPathExtension
|
||||
let ext = (sanitized as NSString).pathExtension
|
||||
let newName: String
|
||||
if ext.isEmpty {
|
||||
newName = "\(baseName) (\(counter))"
|
||||
} else {
|
||||
newName = "\(baseName) (\(counter)).\(ext)"
|
||||
}
|
||||
destination = base.appendingPathComponent(newName)
|
||||
counter += 1
|
||||
}
|
||||
|
||||
try data.write(to: destination, options: .atomic)
|
||||
return destination
|
||||
}
|
||||
|
||||
private func sanitizeOutgoingFileName(_ name: String) -> String {
|
||||
var candidate = name
|
||||
candidate = candidate.replacingOccurrences(of: "\\", with: "/")
|
||||
candidate = candidate.components(separatedBy: "/").last ?? name
|
||||
candidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if candidate.isEmpty { candidate = "file" }
|
||||
let invalid = CharacterSet(charactersIn: "<>:\"|?*")
|
||||
candidate = candidate.components(separatedBy: invalid).joined(separator: "_")
|
||||
if candidate.isEmpty { candidate = "file" }
|
||||
if (candidate as NSString).pathExtension.isEmpty {
|
||||
candidate += ".bin"
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func mimeType(for url: URL) -> String {
|
||||
let ext = url.pathExtension.lowercased()
|
||||
if !ext.isEmpty,
|
||||
let type = UTType(filenameExtension: ext),
|
||||
let mime = type.preferredMIMEType {
|
||||
return mime
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
private func applicationFilesDirectory() throws -> URL {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
return filesDir
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func removeMessage(withID messageID: String, cleanupFile: Bool = false) {
|
||||
var removedMessage: BitchatMessage?
|
||||
|
||||
if let idx = messages.firstIndex(where: { $0.id == messageID }) {
|
||||
removedMessage = messages.remove(at: idx)
|
||||
}
|
||||
|
||||
meshTimeline.removeAll { $0.id == messageID }
|
||||
|
||||
for key in Array(geoTimelines.keys) {
|
||||
var entries = geoTimelines[key] ?? []
|
||||
if let idx = entries.firstIndex(where: { $0.id == messageID }) {
|
||||
removedMessage = removedMessage ?? entries[idx]
|
||||
entries.remove(at: idx)
|
||||
if entries.isEmpty {
|
||||
geoTimelines.removeValue(forKey: key)
|
||||
} else {
|
||||
geoTimelines[key] = entries
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var chats = privateChats
|
||||
for (peerID, items) in chats {
|
||||
let filtered = items.filter { $0.id != messageID }
|
||||
if filtered.count != items.count {
|
||||
if filtered.isEmpty {
|
||||
chats.removeValue(forKey: peerID)
|
||||
} else {
|
||||
chats[peerID] = filtered
|
||||
}
|
||||
if removedMessage == nil {
|
||||
removedMessage = items.first(where: { $0.id == messageID })
|
||||
}
|
||||
}
|
||||
}
|
||||
privateChats = chats
|
||||
|
||||
if cleanupFile, let message = removedMessage {
|
||||
cleanupLocalFile(forMessage: message)
|
||||
}
|
||||
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
// MARK: - Geohash DMs initiation
|
||||
@MainActor
|
||||
func startGeohashDM(withPubkeyHex hex: String) {
|
||||
@@ -3655,6 +4070,53 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
||||
let isSelf: Bool = {
|
||||
if let spid = message.senderPeerID {
|
||||
if case .location(let ch) = activeChannel, spid.hasPrefix("nostr:") {
|
||||
if let myGeo = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
return spid == "nostr:\(myGeo.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))"
|
||||
}
|
||||
}
|
||||
return spid == meshService.myPeerID
|
||||
}
|
||||
if message.sender == nickname { return true }
|
||||
if message.sender.hasPrefix(nickname + "#") { return true }
|
||||
return false
|
||||
}()
|
||||
|
||||
let isDark = colorScheme == .dark
|
||||
let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)
|
||||
|
||||
if message.sender == "system" {
|
||||
var style = AttributeContainer()
|
||||
style.foregroundColor = baseColor
|
||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
||||
return AttributedString(message.sender).mergingAttributes(style)
|
||||
}
|
||||
|
||||
var result = AttributedString()
|
||||
let (baseName, suffix) = message.sender.splitSuffix()
|
||||
var senderStyle = AttributeContainer()
|
||||
senderStyle.foregroundColor = baseColor
|
||||
senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced)
|
||||
if let spid = message.senderPeerID,
|
||||
let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
|
||||
senderStyle.link = url
|
||||
}
|
||||
|
||||
result.append(AttributedString("<@").mergingAttributes(senderStyle))
|
||||
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
|
||||
if !suffix.isEmpty {
|
||||
var suffixStyle = senderStyle
|
||||
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
||||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||||
}
|
||||
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||||
return result
|
||||
}
|
||||
|
||||
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
||||
var result = AttributedString()
|
||||
|
||||
Reference in New Issue
Block a user