Files
bitchat/bitchat/Views/Media/MediaMessageView.swift
T
jackandClaude Fable 5 5aaa209020 Friend-courier store-and-forward: mutual favorites carry sealed messages to offline peers
When a private message has no reachable transport, the router now seals it
to the recipient's Noise static key (new one-way Noise X pattern) and hands
the envelope to up to three connected mutual favorites. Couriers store the
opaque ciphertext under strict quotas (20 total, 5 per depositor, 16 KiB,
24 h) and hand it over when the recipient's announce matches a rotating
HMAC recipient tag; the recipient opens it and the message flows through
the normal private-message pipeline, so dedup and delivery acks just work.

- CourierEnvelope TLV + courierEnvelope (0x04) message type in BitFoundation
- Noise X one-way pattern reusing the existing handshake machinery,
  domain-separated by a courier prologue; sender identity authenticated
  via the ss DH (no forward secrecy - documented tradeoff)
- CourierStore with eviction, file persistence, and panic-wipe integration
- Rotating recipient tags (HMAC over epoch day) so carried envelopes don't
  correlate for observers who don't already know the recipient's key
- New "carried" delivery status with figure.walk glyph; header indicator
  while carrying mail for others
- Three-node end-to-end test ferrying packets through real BLEService
  instances, plus codec/crypto/store/router suites (986 tests green)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:13:16 +02:00

102 lines
3.9 KiB
Swift

//
// MediaMessageView.swift
// bitchat
//
// Created by Islam on 30/03/2026.
//
import SwiftUI
import BitFoundation
struct MediaMessageView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.appTheme) private var theme
@EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage
let media: BitchatMessage.Media
/// Value snapshot of the message's mutable delivery status, captured at
/// construction (see `TextMessageView.deliveryStatus`): `BitchatMessage`
/// is a reference type mutated in place, and SwiftUI compares reference
/// fields by identity, so without the snapshot a status-only change
/// (send progress, delivered → read) would not re-render this row.
private let deliveryStatus: DeliveryStatus?
@Binding var imagePreviewURL: URL?
init(message: BitchatMessage, media: BitchatMessage.Media, imagePreviewURL: Binding<URL?>) {
self.message = message
self.media = media
self.deliveryStatus = message.deliveryStatus
self._imagePreviewURL = imagePreviewURL
}
var body: some View {
let state = mediaSendState(for: deliveryStatus)
let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message)
let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 4) {
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = 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 ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil
)
.frame(maxWidth: 280)
}
}
}
.padding(.vertical, 4)
}
private func mediaSendState(for deliveryStatus: DeliveryStatus?) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
var isSending = false
var progress: Double?
if let status = 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, .carried, .read, .delivered, .failed:
break
}
}
let canCancel = isSending && conversationUIModel.isSentByCurrentUser(message)
let clamped = progress.map { max(0, min(1, $0)) }
return (isSending, isSending ? clamped : nil, canCancel)
}
}