mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 05:25:19 +00:00
* Centralize UI theme colors into semantic ThemePalette tokens Introduce AppTheme/ThemePalette (Utils/Theme.swift) with an environment key and @ThemedPalette property wrapper, persisted via @AppStorage. Views now resolve background/primary/secondary/accentBlue/alertRed/ divider tokens from the environment instead of computing colors inline, removing the backgroundColor/textColor/secondaryTextColor prop-drilling through the header, composer, and people-sheet hierarchy. Matrix theme output is pixel-identical; this is groundwork for a user-selectable theme switcher. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add liquid glass theme with in-app appearance switcher Add a .liquidGlass AppTheme alongside the matrix terminal theme, selectable from a one-line APPEARANCE row in the app info sheet (persisted via @AppStorage, applies live). Liquid glass renders system fonts and colors over a subtle gradient backdrop, with the header and composer floating as Liquid Glass panels (real .glassEffect() on iOS/macOS 26, compiler-gated with an ultraThinMaterial fallback for older SDKs). The message list scrolls underneath the chrome via safe-area insets, and all sheets share the same backdrop and surface language. The matrix theme is unchanged. Details: - Theme is threaded through ChatMessageFormatter so message AttributedStrings switch font design per theme; the per-message format cache gains a variant key so themes never serve each other's cached strings - New palette tokens: accent (interactive tint) and locationAccent (geohash green), replacing scattered hardcoded greens/blues in the voice note, waveform, verification, and people-sheet views - Header controls get full-height tap targets and the people count becomes a real Button (previously a tap gesture on the cluster) - CommandSuggestionsView renders nothing when empty instead of a zero-height view that pushed the composer input off-center - New appearance strings localized for all 29 catalog languages Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
102 lines
3.9 KiB
Swift
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, .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)
|
|
}
|
|
}
|