mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 07:45:21 +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>
329 lines
12 KiB
Swift
329 lines
12 KiB
Swift
//
|
|
// ContentView.swift
|
|
// bitchat
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
import SwiftUI
|
|
#if os(iOS)
|
|
import UIKit
|
|
#elseif os(macOS)
|
|
import AppKit
|
|
#endif
|
|
import BitFoundation
|
|
|
|
/// On macOS 14+, disables the default system focus ring on TextFields.
|
|
/// On earlier macOS versions and on iOS this is a no-op.
|
|
struct FocusEffectDisabledModifier: ViewModifier {
|
|
func body(content: Content) -> some View {
|
|
#if os(macOS)
|
|
if #available(macOS 14.0, *) {
|
|
content.focusEffectDisabled()
|
|
} else {
|
|
content
|
|
}
|
|
#else
|
|
content
|
|
#endif
|
|
}
|
|
}
|
|
|
|
struct ContentView: View {
|
|
@EnvironmentObject private var appChromeModel: AppChromeModel
|
|
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
|
@EnvironmentObject private var verificationModel: VerificationModel
|
|
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
|
|
|
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
|
|
@State private var messageText = ""
|
|
@FocusState private var isTextFieldFocused: Bool
|
|
@Environment(\.colorScheme) var colorScheme
|
|
@Environment(\.appTheme) private var appTheme
|
|
@State private var showSidebar = false
|
|
@State private var selectedMessageSender: String?
|
|
@State private var selectedMessageSenderID: PeerID?
|
|
@FocusState private var isNicknameFieldFocused: Bool
|
|
@State private var isAtBottomPublic = true
|
|
@State private var isAtBottomPrivate = true
|
|
@State private var autocompleteDebounceTimer: Timer?
|
|
@State private var showVerifySheet = false
|
|
@State private var showLocationNotes = false
|
|
@State private var notesGeohash: String?
|
|
@State private var imagePreviewURL: URL?
|
|
#if os(iOS)
|
|
@State private var showImagePicker = false
|
|
@State private var imagePickerSourceType: UIImagePickerController.SourceType = .camera
|
|
#else
|
|
@State private var showMacImagePicker = false
|
|
#endif
|
|
@ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44
|
|
@ScaledMetric(relativeTo: .subheadline) private var headerPeerIconSize: CGFloat = 11
|
|
@ScaledMetric(relativeTo: .subheadline) private var headerPeerCountFontSize: CGFloat = 12
|
|
@State private var windowCountPublic: Int = 300
|
|
@State private var windowCountPrivate: [PeerID: Int] = [:]
|
|
|
|
@ThemedPalette private var palette
|
|
|
|
private var selectedPrivatePeerID: PeerID? {
|
|
privateConversationModel.selectedPeerID
|
|
}
|
|
|
|
private var usesGlassLayout: Bool { appTheme.usesGlassChrome }
|
|
|
|
var body: some View {
|
|
mainContent
|
|
.onAppear {
|
|
conversationUIModel.setCurrentColorScheme(colorScheme)
|
|
conversationUIModel.setCurrentTheme(appTheme)
|
|
#if os(macOS)
|
|
DispatchQueue.main.async {
|
|
isNicknameFieldFocused = false
|
|
isTextFieldFocused = true
|
|
}
|
|
#endif
|
|
}
|
|
.onChange(of: colorScheme) { newValue in
|
|
conversationUIModel.setCurrentColorScheme(newValue)
|
|
}
|
|
.onChange(of: appTheme) { newValue in
|
|
conversationUIModel.setCurrentTheme(newValue)
|
|
}
|
|
.background(ThemedRootBackground())
|
|
.foregroundColor(palette.primary)
|
|
#if os(macOS)
|
|
.frame(minWidth: 600, minHeight: 400)
|
|
#endif
|
|
.onChange(of: selectedPrivatePeerID) { newValue in
|
|
if newValue != nil {
|
|
showSidebar = true
|
|
}
|
|
}
|
|
.sheet(
|
|
isPresented: Binding(
|
|
get: { showSidebar || selectedPrivatePeerID != nil },
|
|
set: { isPresented in
|
|
if !isPresented {
|
|
showSidebar = false
|
|
privateConversationModel.endConversation()
|
|
}
|
|
}
|
|
)
|
|
) {
|
|
#if os(iOS)
|
|
ContentPeopleSheetView(
|
|
showSidebar: $showSidebar,
|
|
messageText: $messageText,
|
|
selectedMessageSender: $selectedMessageSender,
|
|
selectedMessageSenderID: $selectedMessageSenderID,
|
|
imagePreviewURL: $imagePreviewURL,
|
|
windowCountPublic: $windowCountPublic,
|
|
windowCountPrivate: $windowCountPrivate,
|
|
isAtBottomPrivate: $isAtBottomPrivate,
|
|
isTextFieldFocused: $isTextFieldFocused,
|
|
voiceRecordingVM: voiceRecordingVM,
|
|
autocompleteDebounceTimer: $autocompleteDebounceTimer,
|
|
headerHeight: headerHeight,
|
|
onSendMessage: sendMessage,
|
|
showImagePicker: $showImagePicker,
|
|
imagePickerSourceType: $imagePickerSourceType
|
|
)
|
|
#else
|
|
ContentPeopleSheetView(
|
|
showSidebar: $showSidebar,
|
|
messageText: $messageText,
|
|
selectedMessageSender: $selectedMessageSender,
|
|
selectedMessageSenderID: $selectedMessageSenderID,
|
|
imagePreviewURL: $imagePreviewURL,
|
|
windowCountPublic: $windowCountPublic,
|
|
windowCountPrivate: $windowCountPrivate,
|
|
isAtBottomPrivate: $isAtBottomPrivate,
|
|
isTextFieldFocused: $isTextFieldFocused,
|
|
voiceRecordingVM: voiceRecordingVM,
|
|
autocompleteDebounceTimer: $autocompleteDebounceTimer,
|
|
headerHeight: headerHeight,
|
|
onSendMessage: sendMessage,
|
|
showMacImagePicker: $showMacImagePicker
|
|
)
|
|
#endif
|
|
}
|
|
.sheet(isPresented: $appChromeModel.isAppInfoPresented) {
|
|
AppInfoView()
|
|
}
|
|
.sheet(isPresented: Binding(
|
|
get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil },
|
|
set: { _ in appChromeModel.clearFingerprint() }
|
|
)) {
|
|
if let peerID = appChromeModel.showingFingerprintFor {
|
|
FingerprintView(peerID: peerID)
|
|
.environmentObject(verificationModel)
|
|
}
|
|
}
|
|
#if os(iOS)
|
|
.fullScreenCover(isPresented: Binding(
|
|
get: { showImagePicker && !showSidebar && selectedPrivatePeerID == nil },
|
|
set: { newValue in
|
|
if !newValue {
|
|
showImagePicker = false
|
|
}
|
|
}
|
|
)) {
|
|
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
|
showImagePicker = false
|
|
conversationUIModel.processSelectedImage(image)
|
|
}
|
|
.ignoresSafeArea()
|
|
}
|
|
#endif
|
|
#if os(macOS)
|
|
.sheet(isPresented: Binding(
|
|
get: { showMacImagePicker && !showSidebar && selectedPrivatePeerID == nil },
|
|
set: { newValue in
|
|
if !newValue {
|
|
showMacImagePicker = false
|
|
}
|
|
}
|
|
)) {
|
|
MacImagePickerView { url in
|
|
showMacImagePicker = false
|
|
conversationUIModel.processSelectedImage(from: url)
|
|
}
|
|
}
|
|
#endif
|
|
.sheet(isPresented: Binding(
|
|
get: { imagePreviewURL != nil },
|
|
set: { presenting in
|
|
if !presenting {
|
|
imagePreviewURL = nil
|
|
}
|
|
}
|
|
)) {
|
|
if let url = imagePreviewURL {
|
|
ImagePreviewView(url: url)
|
|
}
|
|
}
|
|
.alert("Recording Error", isPresented: $voiceRecordingVM.showAlert, actions: {
|
|
Button("common.ok", role: .cancel) {}
|
|
if voiceRecordingVM.state == .permissionDenied {
|
|
Button("location_channels.action.open_settings") {
|
|
SystemSettings.microphone.open()
|
|
}
|
|
}
|
|
}, message: {
|
|
Text(voiceRecordingVM.state.alertMessage)
|
|
})
|
|
.alert("content.alert.bluetooth_required.title", isPresented: $appChromeModel.showBluetoothAlert) {
|
|
Button("content.alert.bluetooth_required.settings") {
|
|
SystemSettings.bluetooth.open()
|
|
}
|
|
Button("common.ok", role: .cancel) {}
|
|
} message: {
|
|
Text(appChromeModel.bluetoothAlertMessage)
|
|
}
|
|
.onDisappear {
|
|
autocompleteDebounceTimer?.invalidate()
|
|
}
|
|
}
|
|
|
|
/// Matrix: classic opaque bars with dividers. Glass: full-bleed message
|
|
/// list scrolling underneath floating chrome panels (safe-area insets),
|
|
/// so the translucency gains usable space instead of losing it.
|
|
@ViewBuilder
|
|
private var mainContent: some View {
|
|
if usesGlassLayout {
|
|
publicMessageList
|
|
.safeAreaInset(edge: .top, spacing: 0) {
|
|
headerView
|
|
}
|
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
|
if selectedPrivatePeerID == nil {
|
|
composerView
|
|
}
|
|
}
|
|
} else {
|
|
VStack(spacing: 0) {
|
|
headerView
|
|
|
|
Divider()
|
|
|
|
GeometryReader { geometry in
|
|
VStack(spacing: 0) {
|
|
publicMessageList
|
|
.background(palette.background)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
|
}
|
|
|
|
Divider()
|
|
|
|
if selectedPrivatePeerID == nil {
|
|
composerView
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var headerView: some View {
|
|
ContentHeaderView(
|
|
showSidebar: $showSidebar,
|
|
showVerifySheet: $showVerifySheet,
|
|
showLocationNotes: $showLocationNotes,
|
|
notesGeohash: $notesGeohash,
|
|
isNicknameFieldFocused: $isNicknameFieldFocused,
|
|
headerHeight: headerHeight,
|
|
headerPeerIconSize: headerPeerIconSize,
|
|
headerPeerCountFontSize: headerPeerCountFontSize
|
|
)
|
|
}
|
|
|
|
private var publicMessageList: some View {
|
|
MessageListView(
|
|
privatePeer: nil,
|
|
isAtBottom: $isAtBottomPublic,
|
|
messageText: $messageText,
|
|
selectedMessageSender: $selectedMessageSender,
|
|
selectedMessageSenderID: $selectedMessageSenderID,
|
|
imagePreviewURL: $imagePreviewURL,
|
|
windowCountPublic: $windowCountPublic,
|
|
windowCountPrivate: $windowCountPrivate,
|
|
showSidebar: $showSidebar,
|
|
isTextFieldFocused: $isTextFieldFocused
|
|
)
|
|
}
|
|
|
|
private var composerView: some View {
|
|
#if os(iOS)
|
|
ContentComposerView(
|
|
messageText: $messageText,
|
|
isTextFieldFocused: $isTextFieldFocused,
|
|
voiceRecordingVM: voiceRecordingVM,
|
|
autocompleteDebounceTimer: $autocompleteDebounceTimer,
|
|
onSendMessage: sendMessage,
|
|
showImagePicker: $showImagePicker,
|
|
imagePickerSourceType: $imagePickerSourceType
|
|
)
|
|
#else
|
|
ContentComposerView(
|
|
messageText: $messageText,
|
|
isTextFieldFocused: $isTextFieldFocused,
|
|
voiceRecordingVM: voiceRecordingVM,
|
|
autocompleteDebounceTimer: $autocompleteDebounceTimer,
|
|
onSendMessage: sendMessage,
|
|
showMacImagePicker: $showMacImagePicker
|
|
)
|
|
#endif
|
|
}
|
|
|
|
private func sendMessage() {
|
|
guard let trimmed = messageText.trimmedOrNilIfEmpty else { return }
|
|
|
|
messageText = ""
|
|
|
|
DispatchQueue.main.async {
|
|
self.conversationUIModel.sendMessage(trimmed)
|
|
}
|
|
}
|
|
}
|