mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
Mesh and geohash timelines are now store conversations. All public mutation sites flow through store intents; PublicMessagePipeline keeps its 80ms UI batching but commits batches via store appends with each buffered entry carrying its destination conversation (a mid-batch channel switch now flushes instead of dropping the buffer). ChatViewModel.messages becomes a cached get-only view of the active conversation, invalidated through the change subject. The mesh late-insert threshold is consciously removed: it only ever ordered the non-rendered messages copy, so strict timestamp insertion makes the working set agree with rendered order. PublicTimelineStore and the per-message full-array legacy sync are deleted; the coalescing bridge mirrors public conversations for the remaining legacy readers. pipeline.publicIngest: 6.6k -> 9.5k msg/s (+45%); private steady; store.append 237k/s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
364 lines
15 KiB
Swift
364 lines
15 KiB
Swift
import BitFoundation
|
|
import BitLogger
|
|
import Foundation
|
|
|
|
#if os(iOS)
|
|
import UIKit
|
|
#endif
|
|
|
|
/// The narrow surface `ChatMediaTransferCoordinator` needs from its owner.
|
|
///
|
|
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
|
|
/// minimal context it actually uses instead of holding an `unowned` back-ref
|
|
/// to the whole `ChatViewModel`. This keeps the coordinator independently
|
|
/// testable (see `ChatMediaTransferCoordinatorContextTests`) and makes its
|
|
/// true dependencies explicit.
|
|
@MainActor
|
|
protocol ChatMediaTransferContext: AnyObject {
|
|
// MARK: Composition state
|
|
var canSendMediaInCurrentContext: Bool { get }
|
|
var selectedPrivateChatPeer: PeerID? { get }
|
|
var nickname: String { get }
|
|
var myPeerID: PeerID { get }
|
|
var activeChannel: ChannelID { get }
|
|
func nicknameForPeer(_ peerID: PeerID) -> String
|
|
func currentPublicSender() -> (name: String, peerID: PeerID)
|
|
|
|
// MARK: Message state
|
|
var privateChats: [PeerID: [BitchatMessage]] { get }
|
|
/// Appends a private message via the single-writer store intent.
|
|
@discardableResult
|
|
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
|
|
/// Appends a public message via the single-writer store intent
|
|
/// (immediate: outgoing media placeholders must render without batching).
|
|
@discardableResult
|
|
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool
|
|
func removeMessage(withID messageID: String, cleanupFile: Bool)
|
|
func addSystemMessage(_ content: String)
|
|
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
|
func notifyUIChanged()
|
|
|
|
// MARK: Delivery status & dedup
|
|
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
|
func normalizedContentKey(_ content: String) -> String
|
|
func recordContentKey(_ key: String, timestamp: Date)
|
|
|
|
// MARK: Mesh file transfer
|
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
|
func cancelTransfer(_ transferId: String)
|
|
}
|
|
|
|
extension ChatViewModel: ChatMediaTransferContext {
|
|
// `canSendMediaInCurrentContext`, `selectedPrivateChatPeer`, `nickname`,
|
|
// `myPeerID`, `activeChannel`, `nicknameForPeer(_:)`,
|
|
// `currentPublicSender()`, `privateChats`,
|
|
// `appendPublicMessage(_:to:)`, `removeMessage(withID:cleanupFile:)`,
|
|
// `addSystemMessage(_:)`, `notifyUIChanged()`,
|
|
// `updateMessageDeliveryStatus(_:status:)`, `normalizedContentKey(_:)`,
|
|
// and `recordContentKey(_:timestamp:)` are shared requirements with the
|
|
// other contexts or satisfied by existing `ChatViewModel` members. The
|
|
// members below flatten mesh service accesses.
|
|
|
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
|
meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
|
}
|
|
|
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
|
meshService.sendFileBroadcast(packet, transferId: transferId)
|
|
}
|
|
|
|
func cancelTransfer(_ transferId: String) {
|
|
meshService.cancelTransfer(transferId)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class ChatMediaTransferCoordinator {
|
|
private unowned let context: any ChatMediaTransferContext
|
|
|
|
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
|
private(set) var messageIDToTransferId: [String: String] = [:]
|
|
|
|
init(context: any ChatMediaTransferContext) {
|
|
self.context = context
|
|
}
|
|
|
|
func sendVoiceNote(at url: URL) {
|
|
guard context.canSendMediaInCurrentContext else {
|
|
SecureLogger.info("Voice note blocked outside mesh/private context", category: .session)
|
|
try? FileManager.default.removeItem(at: url)
|
|
context.addSystemMessage("Voice notes are only available in mesh chats.")
|
|
return
|
|
}
|
|
|
|
let targetPeer = context.selectedPrivateChatPeer
|
|
let message = enqueueMediaMessage(
|
|
content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)",
|
|
targetPeer: targetPeer
|
|
)
|
|
let messageID = message.id
|
|
let transferId = makeTransferID(messageID: messageID)
|
|
|
|
Task.detached(priority: .userInitiated) { [weak self] in
|
|
do {
|
|
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
|
|
|
await MainActor.run { [weak self] in
|
|
guard let self else { return }
|
|
self.registerTransfer(transferId: transferId, messageID: messageID)
|
|
if let peerID = targetPeer {
|
|
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
|
} else {
|
|
self.context.sendFileBroadcast(packet, transferId: transferId)
|
|
}
|
|
}
|
|
} catch ChatMediaPreparationError.voiceNoteTooLarge(let size) {
|
|
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
|
try? FileManager.default.removeItem(at: url)
|
|
await MainActor.run { [weak self] in
|
|
guard let self else { return }
|
|
self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large")
|
|
}
|
|
} catch {
|
|
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
|
await MainActor.run { [weak self] in
|
|
guard let self else { return }
|
|
self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
func processThenSendImage(_ image: UIImage?) {
|
|
guard let image else { return }
|
|
Task.detached { [weak self] in
|
|
do {
|
|
let processedURL = try ImageUtils.processImage(image)
|
|
await MainActor.run { [weak self] in
|
|
guard let self else { return }
|
|
self.sendImage(from: processedURL)
|
|
}
|
|
} catch {
|
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
|
}
|
|
}
|
|
}
|
|
#elseif os(macOS)
|
|
func processThenSendImage(from url: URL?) {
|
|
guard let url else { return }
|
|
Task.detached { [weak self] in
|
|
do {
|
|
let processedURL = try ImageUtils.processImage(at: url)
|
|
await MainActor.run { [weak self] in
|
|
guard let self else { return }
|
|
self.sendImage(from: processedURL)
|
|
}
|
|
} catch {
|
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
|
|
guard context.canSendMediaInCurrentContext else {
|
|
SecureLogger.info("Image send blocked outside mesh/private context", category: .session)
|
|
cleanup?()
|
|
context.addSystemMessage("Images are only available in mesh chats.")
|
|
return
|
|
}
|
|
|
|
let targetPeer = context.selectedPrivateChatPeer
|
|
|
|
do {
|
|
try ImageUtils.validateImageSource(at: sourceURL)
|
|
} catch {
|
|
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
|
context.addSystemMessage("Failed to prepare image for sending.")
|
|
return
|
|
}
|
|
|
|
Task.detached(priority: .userInitiated) { [weak self] in
|
|
do {
|
|
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
|
|
|
|
await MainActor.run { [weak self] in
|
|
guard let self else { return }
|
|
let message = self.enqueueMediaMessage(
|
|
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
|
targetPeer: targetPeer
|
|
)
|
|
let messageID = message.id
|
|
let transferId = self.makeTransferID(messageID: messageID)
|
|
self.registerTransfer(transferId: transferId, messageID: messageID)
|
|
if let peerID = targetPeer {
|
|
self.context.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId)
|
|
} else {
|
|
self.context.sendFileBroadcast(prepared.packet, transferId: transferId)
|
|
}
|
|
}
|
|
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
|
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
|
|
await MainActor.run { [weak self] in
|
|
guard let self else { return }
|
|
self.context.addSystemMessage("Image is too large to send.")
|
|
}
|
|
} catch {
|
|
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
|
await MainActor.run { [weak self] in
|
|
guard let self else { return }
|
|
self.context.addSystemMessage("Failed to prepare image for sending.")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage {
|
|
let timestamp = Date()
|
|
let message: BitchatMessage
|
|
|
|
if let peerID = targetPeer {
|
|
message = BitchatMessage(
|
|
sender: context.nickname,
|
|
content: content,
|
|
timestamp: timestamp,
|
|
isRelay: false,
|
|
originalSender: nil,
|
|
isPrivate: true,
|
|
recipientNickname: context.nicknameForPeer(peerID),
|
|
senderPeerID: context.myPeerID,
|
|
deliveryStatus: .sending
|
|
)
|
|
context.appendPrivateMessage(message, to: peerID)
|
|
} else {
|
|
let (displayName, senderPeerID) = context.currentPublicSender()
|
|
message = BitchatMessage(
|
|
sender: displayName,
|
|
content: content,
|
|
timestamp: timestamp,
|
|
isRelay: false,
|
|
originalSender: nil,
|
|
isPrivate: false,
|
|
recipientNickname: nil,
|
|
senderPeerID: senderPeerID,
|
|
deliveryStatus: .sending
|
|
)
|
|
context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
|
|
}
|
|
|
|
let key = context.normalizedContentKey(message.content)
|
|
context.recordContentKey(key, timestamp: timestamp)
|
|
context.notifyUIChanged()
|
|
return message
|
|
}
|
|
|
|
func registerTransfer(transferId: String, messageID: String) {
|
|
transferIdToMessageIDs[transferId, default: []].append(messageID)
|
|
messageIDToTransferId[messageID] = transferId
|
|
}
|
|
|
|
func makeTransferID(messageID: String) -> String {
|
|
"\(messageID)-\(UUID().uuidString)"
|
|
}
|
|
|
|
func clearTransferMapping(for messageID: String) {
|
|
guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return }
|
|
guard var queue = transferIdToMessageIDs[transferId] else { return }
|
|
|
|
if !queue.isEmpty {
|
|
if queue.first == messageID {
|
|
queue.removeFirst()
|
|
} else if let index = queue.firstIndex(of: messageID) {
|
|
queue.remove(at: index)
|
|
}
|
|
}
|
|
|
|
transferIdToMessageIDs[transferId] = queue.isEmpty ? nil : queue
|
|
}
|
|
|
|
func handleMediaSendFailure(messageID: String, reason: String) {
|
|
context.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason))
|
|
clearTransferMapping(for: messageID)
|
|
}
|
|
|
|
func handleTransferEvent(_ event: TransferProgressManager.Event) {
|
|
switch event {
|
|
case .started(let id, let total):
|
|
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
|
context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total))
|
|
case .updated(let id, let sent, let total):
|
|
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
|
context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total))
|
|
case .completed(let id, _):
|
|
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
|
context.updateMessageDeliveryStatus(messageID, status: .sent)
|
|
clearTransferMapping(for: messageID)
|
|
case .cancelled(let id, _, _):
|
|
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
|
clearTransferMapping(for: messageID)
|
|
context.removeMessage(withID: messageID, cleanupFile: true)
|
|
}
|
|
}
|
|
|
|
func cleanupLocalFile(forMessage message: BitchatMessage) {
|
|
let categories: [MimeType.Category] = [.audio, .image, .file]
|
|
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }),
|
|
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty,
|
|
let base = try? applicationFilesDirectory(),
|
|
let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty,
|
|
safeFilename != ".",
|
|
safeFilename != ".." else {
|
|
return
|
|
}
|
|
|
|
let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] }
|
|
for subdir in subdirs {
|
|
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
|
|
guard target.path.hasPrefix(base.path) else { continue }
|
|
|
|
do {
|
|
try FileManager.default.removeItem(at: target)
|
|
} catch CocoaError.fileNoSuchFile {
|
|
continue
|
|
} catch {
|
|
SecureLogger.error("Failed to cleanup \(safeFilename): \(error)", category: .session)
|
|
}
|
|
}
|
|
}
|
|
|
|
func cancelMediaSend(messageID: String) {
|
|
if let transferId = messageIDToTransferId[messageID],
|
|
let active = transferIdToMessageIDs[transferId]?.first,
|
|
active == messageID {
|
|
context.cancelTransfer(transferId)
|
|
}
|
|
clearTransferMapping(for: messageID)
|
|
context.removeMessage(withID: messageID, cleanupFile: true)
|
|
}
|
|
|
|
func deleteMediaMessage(messageID: String) {
|
|
clearTransferMapping(for: messageID)
|
|
context.removeMessage(withID: messageID, cleanupFile: true)
|
|
}
|
|
}
|
|
|
|
private extension ChatMediaTransferCoordinator {
|
|
func applicationFilesDirectory() throws -> URL {
|
|
let base = try FileManager.default.url(
|
|
for: .applicationSupportDirectory,
|
|
in: .userDomainMask,
|
|
appropriateFor: nil,
|
|
create: true
|
|
)
|
|
let filesDirectory = base.appendingPathComponent("files", isDirectory: true)
|
|
try FileManager.default.createDirectory(
|
|
at: filesDirectory,
|
|
withIntermediateDirectories: true,
|
|
attributes: nil
|
|
)
|
|
return filesDirectory
|
|
}
|
|
}
|