mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 14:05:18 +00:00
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:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user