Refactor and Extract Media-related code (#1077)

* Extract media-related code from ContentView

* Refactor hard-coded prefix and directory names
This commit is contained in:
Islam
2026-04-02 20:31:51 -05:00
committed by GitHub
parent 4b000b0785
commit 097d916da7
6 changed files with 181 additions and 186 deletions
+66
View File
@@ -0,0 +1,66 @@
//
// BitchatMessage+Media.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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
}
}
+3 -25
View File
@@ -1222,38 +1222,16 @@ final class BLEService: NSObject {
// BCH-01-002: Enforce storage quota before saving // BCH-01-002: Enforce storage quota before saving
enforceIncomingFilesQuota(reservingBytes: filePacket.content.count) 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( guard let destination = saveIncomingFile(
data: filePacket.content, data: filePacket.content,
preferredName: filePacket.fileName, preferredName: filePacket.fileName,
subdirectory: subdirectory, subdirectory: "\(mime.category.mediaDir)/incoming",
fallbackExtension: fallbackExt, fallbackExtension: mime.defaultExtension,
defaultPrefix: mime.category.rawValue defaultPrefix: mime.category.rawValue
) else { ) else {
return 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 let isPrivateMessage = PeerID(hexData: packet.recipientID) == myPeerID
if isPrivateMessage { if isPrivateMessage {
@@ -1263,7 +1241,7 @@ final class BLEService: NSObject {
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
let message = BitchatMessage( let message = BitchatMessage(
sender: senderNickname, sender: senderNickname,
content: marker, content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
timestamp: ts, timestamp: ts,
isRelay: false, isRelay: false,
originalSender: nil, originalSender: nil,
+17
View File
@@ -191,5 +191,22 @@ enum MimeType: CaseIterable, Hashable {
extension MimeType { extension MimeType {
enum Category: String { enum Category: String {
case audio, image, file 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"
}
}
} }
} }
@@ -319,7 +319,7 @@ extension ChatViewModel {
} }
let targetPeer = selectedPrivateChatPeer 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 messageID = message.id
let transferId = makeTransferID(messageID: messageID) let transferId = makeTransferID(messageID: messageID)
@@ -428,7 +428,7 @@ extension ChatViewModel {
) )
guard packet.encode() != nil else { throw MediaSendError.encodingFailed } guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
await MainActor.run { 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 messageID = message.id
let transferId = self.makeTransferID(messageID: messageID) let transferId = self.makeTransferID(messageID: messageID)
self.registerTransfer(transferId: transferId, messageID: messageID) self.registerTransfer(transferId: transferId, messageID: messageID)
@@ -547,13 +547,9 @@ extension ChatViewModel {
func cleanupLocalFile(forMessage message: BitchatMessage) { func cleanupLocalFile(forMessage message: BitchatMessage) {
// Check both outgoing and incoming directories for thorough cleanup // Check both outgoing and incoming directories for thorough cleanup
let prefixes = ["[voice] ", "[image] ", "[file] "] let categories: [MimeType.Category] = [.audio, .image, .file]
let subdirs = ["voicenotes/outgoing", "voicenotes/incoming", guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }) else { return }
"images/outgoing", "images/incoming", let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
"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)
guard !rawFilename.isEmpty, let base = try? applicationFilesDirectory() else { return } guard !rawFilename.isEmpty, let base = try? applicationFilesDirectory() else { return }
// Security: Extract only the last path component to prevent directory traversal // Security: Extract only the last path component to prevent directory traversal
@@ -561,6 +557,7 @@ extension ChatViewModel {
guard !safeFilename.isEmpty && safeFilename != "." && safeFilename != ".." else { return } guard !safeFilename.isEmpty && safeFilename != "." && safeFilename != ".." else { return }
// Try all possible locations (outgoing and incoming) // Try all possible locations (outgoing and incoming)
let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] }
for subdir in subdirs { for subdir in subdirs {
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename) let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
+2 -152
View File
@@ -1456,85 +1456,13 @@ struct ContentView: View {
// MARK: - Helper Views // 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 { 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 @ViewBuilder
private func messageRow(for message: BitchatMessage) -> some View { private func messageRow(for message: BitchatMessage) -> some View {
if message.sender == "system" { if message.sender == "system" {
systemMessageRow(message) systemMessageRow(message)
} else if let media = mediaAttachment(for: message) { } else if let media = message.mediaAttachment(for: viewModel.nickname) {
mediaMessageRow(message: message, media: media) MediaMessageView(message: message, media: media, imagePreviewURL: $imagePreviewURL)
} else { } else {
TextMessageView(message: message, expandedMessageIDs: $expandedMessageIDs) TextMessageView(message: message, expandedMessageIDs: $expandedMessageIDs)
} }
@@ -1547,59 +1475,6 @@ private extension ContentView {
.frame(maxWidth: .infinity, alignment: .leading) .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, private func expandWindow(ifNeededFor message: BitchatMessage,
allMessages: [BitchatMessage], allMessages: [BitchatMessage],
privatePeer: PeerID?, 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") : 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
}
}
} }
@@ -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)
}
}