diff --git a/bitchat/Models/BitchatMessage+Media.swift b/bitchat/Models/BitchatMessage+Media.swift new file mode 100644 index 00000000..108bb5a6 --- /dev/null +++ b/bitchat/Models/BitchatMessage+Media.swift @@ -0,0 +1,66 @@ +// +// BitchatMessage+Media.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +extension BitchatMessage { + enum Media { + case voice(URL) + case image(URL) + + var url: URL { + switch self { + case .voice(let url), .image(let url): + return url + } + } + } + + // Cache the directory lookup to avoid repeated FileManager calls during view rendering + private struct Cache { + let filesDir: URL? + + static let shared = Cache() + private init() { + do { + 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) + self.filesDir = filesDir + } catch { + filesDir = nil + } + } + } + + func mediaAttachment(for nickname: String) -> Media? { + guard let baseDirectory = Cache.shared.filesDir else { return nil } + + func url(for category: MimeType.Category) -> URL? { + guard content.hasPrefix(category.messagePrefix) else { return nil } + let filename = String(content.dropFirst(category.messagePrefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) + guard !filename.isEmpty else { return nil } + + // Check outgoing first for sent messages, incoming for received + let subdir = sender == nickname ? "\(category.mediaDir)/outgoing" : "\(category.mediaDir)/incoming" + + // Construct URL directly without fileExists check (avoids blocking disk I/O in view body) + // Files are checked during playback/display, so missing files fail gracefully + let directory = baseDirectory.appendingPathComponent(subdir, isDirectory: true) + return directory.appendingPathComponent(filename) + } + + if let url = url(for: .audio) { + return .voice(url) + } + if let url = url(for: .image) { + return .image(url) + } + return nil + } +} diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 908086ec..949a9c20 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -1222,38 +1222,16 @@ final class BLEService: NSObject { // BCH-01-002: Enforce storage quota before saving enforceIncomingFilesQuota(reservingBytes: filePacket.content.count) - let fallbackExt = mime.defaultExtension - let subdirectory: String - switch mime.category { - case .audio: - subdirectory = "voicenotes/incoming" - case .image: - subdirectory = "images/incoming" - case .file: - subdirectory = "files/incoming" - } - guard let destination = saveIncomingFile( data: filePacket.content, preferredName: filePacket.fileName, - subdirectory: subdirectory, - fallbackExtension: fallbackExt, + subdirectory: "\(mime.category.mediaDir)/incoming", + fallbackExtension: mime.defaultExtension, defaultPrefix: mime.category.rawValue ) else { return } - let marker: String - let fileName = destination.lastPathComponent - switch mime.category { - case .audio: - marker = "[voice] \(fileName)" - case .image: - marker = "[image] \(fileName)" - case .file: - marker = "[file] \(fileName)" - } - let isPrivateMessage = PeerID(hexData: packet.recipientID) == myPeerID if isPrivateMessage { @@ -1263,7 +1241,7 @@ final class BLEService: NSObject { let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let message = BitchatMessage( sender: senderNickname, - content: marker, + content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)", timestamp: ts, isRelay: false, originalSender: nil, diff --git a/bitchat/Services/BLE/MimeType.swift b/bitchat/Services/BLE/MimeType.swift index b424ea95..df2afacf 100644 --- a/bitchat/Services/BLE/MimeType.swift +++ b/bitchat/Services/BLE/MimeType.swift @@ -191,5 +191,22 @@ enum MimeType: CaseIterable, Hashable { extension MimeType { enum Category: String { case audio, image, file + + /// Ends with a space + var messagePrefix: String { + switch self { + case .audio: "[voice] " + case .image: "[image] " + case .file: "[file] " + } + } + + var mediaDir: String { + switch self { + case .audio: "voicenotes" + case .image: "images" + case .file: "files" + } + } } } diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift index 5155c7ee..31d332fb 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift @@ -319,7 +319,7 @@ extension ChatViewModel { } let targetPeer = selectedPrivateChatPeer - let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer) + let message = enqueueMediaMessage(content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)", targetPeer: targetPeer) let messageID = message.id let transferId = makeTransferID(messageID: messageID) @@ -428,7 +428,7 @@ extension ChatViewModel { ) guard packet.encode() != nil else { throw MediaSendError.encodingFailed } await MainActor.run { - let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer) + let message = self.enqueueMediaMessage(content: "\(MimeType.Category.image.messagePrefix)\(outputURL.lastPathComponent)", targetPeer: targetPeer) let messageID = message.id let transferId = self.makeTransferID(messageID: messageID) self.registerTransfer(transferId: transferId, messageID: messageID) @@ -547,13 +547,9 @@ extension ChatViewModel { func cleanupLocalFile(forMessage message: BitchatMessage) { // Check both outgoing and incoming directories for thorough cleanup - let prefixes = ["[voice] ", "[image] ", "[file] "] - let subdirs = ["voicenotes/outgoing", "voicenotes/incoming", - "images/outgoing", "images/incoming", - "files/outgoing", "files/incoming"] - - guard let prefix = prefixes.first(where: { message.content.hasPrefix($0) }) else { return } - let rawFilename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) + let categories: [MimeType.Category] = [.audio, .image, .file] + guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }) else { return } + let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) guard !rawFilename.isEmpty, let base = try? applicationFilesDirectory() else { return } // Security: Extract only the last path component to prevent directory traversal @@ -561,6 +557,7 @@ extension ChatViewModel { guard !safeFilename.isEmpty && safeFilename != "." && safeFilename != ".." else { return } // Try all possible locations (outgoing and incoming) + let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] } for subdir in subdirs { let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index e825d6ea..a3e3bcaa 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -1456,85 +1456,13 @@ struct ContentView: View { // MARK: - Helper Views -// Rounded payment chip button -// - -private enum MessageMedia { - case voice(URL) - case image(URL) - - var url: URL { - switch self { - case .voice(let url), .image(let url): - return url - } - } -} - private extension ContentView { - func mediaAttachment(for message: BitchatMessage) -> MessageMedia? { - guard let baseDirectory = applicationFilesDirectory() else { return nil } - - // Extract filename from message content - func url(from prefix: String, subdirectory: String) -> URL? { - guard message.content.hasPrefix(prefix) else { return nil } - let filename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) - guard !filename.isEmpty else { return nil } - - // Construct URL directly without fileExists check (avoids blocking disk I/O in view body) - // Files are checked during playback/display, so missing files fail gracefully - let directory = baseDirectory.appendingPathComponent(subdirectory, isDirectory: true) - return directory.appendingPathComponent(filename) - } - - // Try outgoing first (most common for sent media), fall back to incoming - if message.content.hasPrefix("[voice] ") { - let filename = String(message.content.dropFirst("[voice] ".count)).trimmingCharacters(in: .whitespacesAndNewlines) - guard !filename.isEmpty else { return nil } - // Check outgoing first for sent messages, incoming for received - let subdir = message.sender == viewModel.nickname ? "voicenotes/outgoing" : "voicenotes/incoming" - let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename) - return .voice(url) - } - if message.content.hasPrefix("[image] ") { - let filename = String(message.content.dropFirst("[image] ".count)).trimmingCharacters(in: .whitespacesAndNewlines) - guard !filename.isEmpty else { return nil } - let subdir = message.sender == viewModel.nickname ? "images/outgoing" : "images/incoming" - let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename) - return .image(url) - } - return nil - } - - func mediaSendState(for message: BitchatMessage, mediaURL: URL) -> (isSending: Bool, progress: Double?, canCancel: Bool) { - var isSending = false - var progress: Double? - if let status = message.deliveryStatus { - switch status { - case .sending: - isSending = true - progress = 0 - case .partiallyDelivered(let reached, let total): - if total > 0 { - isSending = true - progress = Double(reached) / Double(total) - } - case .sent, .read, .delivered, .failed: - break - } - } - let isOutgoing = mediaURL.path.contains("/outgoing/") - let canCancel = isSending && isOutgoing - let clamped = progress.map { max(0, min(1, $0)) } - return (isSending, isSending ? clamped : nil, canCancel) - } - @ViewBuilder private func messageRow(for message: BitchatMessage) -> some View { if message.sender == "system" { systemMessageRow(message) - } else if let media = mediaAttachment(for: message) { - mediaMessageRow(message: message, media: media) + } else if let media = message.mediaAttachment(for: viewModel.nickname) { + MediaMessageView(message: message, media: media, imagePreviewURL: $imagePreviewURL) } else { TextMessageView(message: message, expandedMessageIDs: $expandedMessageIDs) } @@ -1547,59 +1475,6 @@ private extension ContentView { .frame(maxWidth: .infinity, alignment: .leading) } - @ViewBuilder - private func mediaMessageRow(message: BitchatMessage, media: MessageMedia) -> some View { - let mediaURL = media.url - let state = mediaSendState(for: message, mediaURL: mediaURL) - let isOutgoing = mediaURL.path.contains("/outgoing/") - let isAuthoredByUs = isOutgoing || (message.senderPeerID == viewModel.meshService.myPeerID) - let shouldBlurImage = !isAuthoredByUs - let cancelAction: (() -> Void)? = state.canCancel ? { viewModel.cancelMediaSend(messageID: message.id) } : nil - - VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .center, spacing: 4) { - Text(viewModel.formatMessageHeader(message, colorScheme: colorScheme)) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - if message.isPrivate && message.sender == viewModel.nickname, - let status = message.deliveryStatus { - DeliveryStatusView(status: status) - .padding(.leading, 4) - } - } - - Group { - switch media { - case .voice(let url): - VoiceNoteView( - url: url, - isSending: state.isSending, - sendProgress: state.progress, - onCancel: cancelAction - ) - case .image(let url): - BlockRevealImageView( - url: url, - revealProgress: state.progress, - isSending: state.isSending, - onCancel: cancelAction, - initiallyBlurred: shouldBlurImage, - onOpen: { - if !state.isSending { - imagePreviewURL = url - } - }, - onDelete: shouldBlurImage ? { - viewModel.deleteMediaMessage(messageID: message.id) - } : nil - ) - .frame(maxWidth: 280) - } - } - } - .padding(.vertical, 4) - } - private func expandWindow(ifNeededFor message: BitchatMessage, allMessages: [BitchatMessage], privatePeer: PeerID?, @@ -1780,29 +1655,4 @@ private extension ContentView { : String(localized: "content.accessibility.send_hint_empty", comment: "Hint prompting the user to enter a message") ) } - - func applicationFilesDirectory() -> URL? { - // Cache the directory lookup to avoid repeated FileManager calls during view rendering - struct Cache { - static var cachedURL: URL? - static var didAttempt = false - } - - if Cache.didAttempt { - return Cache.cachedURL - } - - do { - 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) - Cache.cachedURL = filesDir - Cache.didAttempt = true - return filesDir - } catch { - SecureLogger.error("Failed to resolve application files directory: \(error)", category: .session) - Cache.didAttempt = true - return nil - } - } } diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift new file mode 100644 index 00000000..76669489 --- /dev/null +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -0,0 +1,87 @@ +// +// MediaMessageView.swift +// bitchat +// +// Created by Islam on 30/03/2026. +// + +import SwiftUI + +struct MediaMessageView: View { + @Environment(\.colorScheme) private var colorScheme + + @EnvironmentObject var viewModel: ChatViewModel + let message: BitchatMessage + let media: BitchatMessage.Media + + @Binding var imagePreviewURL: URL? + + var body: some View { + let state = mediaSendState(for: message) + let isFromMe = message.sender == viewModel.nickname || message.senderPeerID == viewModel.meshService.myPeerID + let cancelAction: (() -> Void)? = state.canCancel ? { viewModel.cancelMediaSend(messageID: message.id) } : nil + + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .center, spacing: 4) { + Text(viewModel.formatMessageHeader(message, colorScheme: colorScheme)) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + if message.isPrivate && message.sender == viewModel.nickname, + let status = message.deliveryStatus { + DeliveryStatusView(status: status) + .padding(.leading, 4) + } + } + + Group { + switch media { + case .voice(let url): + VoiceNoteView( + url: url, + isSending: state.isSending, + sendProgress: state.progress, + onCancel: cancelAction + ) + case .image(let url): + BlockRevealImageView( + url: url, + revealProgress: state.progress, + isSending: state.isSending, + onCancel: cancelAction, + initiallyBlurred: !isFromMe, + onOpen: { + if !state.isSending { + imagePreviewURL = url + } + }, + onDelete: !isFromMe ? { viewModel.deleteMediaMessage(messageID: message.id) } : nil + ) + .frame(maxWidth: 280) + } + } + } + .padding(.vertical, 4) + } + + private func mediaSendState(for message: BitchatMessage) -> (isSending: Bool, progress: Double?, canCancel: Bool) { + var isSending = false + var progress: Double? + if let status = message.deliveryStatus { + switch status { + case .sending: + isSending = true + progress = 0 + case .partiallyDelivered(let reached, let total): + if total > 0 { + isSending = true + progress = Double(reached) / Double(total) + } + case .sent, .read, .delivered, .failed: + break + } + } + let canCancel = isSending && message.sender == viewModel.nickname + let clamped = progress.map { max(0, min(1, $0)) } + return (isSending, isSending ? clamped : nil, canCancel) + } +}