Liquid Glass theme with in-app appearance switcher (#1337)

* 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>
This commit is contained in:
jack
2026-06-12 01:13:04 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent c8381737bb
commit af954b05ea
27 changed files with 1347 additions and 498 deletions
+8 -4
View File
@@ -41,6 +41,10 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.currentColorScheme = colorScheme
}
func setCurrentTheme(_ theme: AppTheme) {
chatViewModel.currentTheme = theme
}
func sendMessage(_ message: String) {
chatViewModel.sendMessage(message)
}
@@ -76,12 +80,12 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.completeNickname(nickname, in: &text)
}
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme)
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme)
}
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
chatViewModel.formatMessageHeader(message, colorScheme: colorScheme)
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
chatViewModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme)
}
func mediaAttachment(for message: BitchatMessage) -> BitchatMessage.Media? {
+2
View File
@@ -15,6 +15,7 @@ struct BitchatApp: App {
static let groupID = "group.\(bundleID)"
@StateObject private var runtime: AppRuntime
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
#if os(iOS)
@Environment(\.scenePhase) var scenePhase
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@@ -30,6 +31,7 @@ struct BitchatApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.appTheme, AppTheme(rawValue: appThemeRawValue) ?? .matrix)
.environmentObject(runtime.publicChatModel)
.environmentObject(runtime.privateInboxModel)
.environmentObject(runtime.privateConversationModel)
+537
View File
@@ -540,6 +540,543 @@
}
}
},
"app_info.appearance.liquid_glass" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "زجاج سائل"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "লিকুইড গ্লাস"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "זכוכית נוזלית"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "लिक्विड ग्लास"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "リキッドガラス"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "리퀴드 글래스"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "लिक्विड ग्लास"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "жидкое стекло"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "லிக்விட் கிளாஸ்"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ลิควิดกลาส"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "рідке скло"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "لیکویڈ گلاس"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "液态玻璃"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "液態玻璃"
}
}
}
},
"app_info.appearance.matrix" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "ماتريكس"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "ম্যাট্রিক্স"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "מטריקס"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "मैट्रिक्स"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "マトリックス"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "매트릭스"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "म्याट्रिक्स"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "матрица"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "மேட்ரிக்ஸ்"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "เมทริกซ์"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "матриця"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "میٹرکس"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "矩阵"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "矩陣"
}
}
}
},
"app_info.appearance.title" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "المظهر"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "চেহারা"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "ERSCHEINUNGSBILD"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "APPEARANCE"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "APARIENCIA"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "HITSURA"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "APPARENCE"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "מראה"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "दिखावट"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "TAMPILAN"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "ASPETTO"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "外観"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "화면 모드"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "PENAMPILAN"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "रूप"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "WEERGAVE"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "WYGLĄD"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "APARÊNCIA"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "APARÊNCIA"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "ОФОРМЛЕНИЕ"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "UTSEENDE"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "தோற்றம்"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ลักษณะที่ปรากฏ"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "GÖRÜNÜM"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "ОФОРМЛЕННЯ"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "ظاہری شکل"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "GIAO DIỆN"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "外观"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "外觀"
}
}
}
},
"app_info.close" : {
"extractionState" : "manual",
"localizations" : {
+339
View File
@@ -0,0 +1,339 @@
//
// Theme.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
/// A user-selectable app-wide visual theme. Persisted by raw value.
enum AppTheme: String, CaseIterable, Identifiable {
case matrix
case liquidGlass
var id: String { rawValue }
/// UserDefaults key backing the theme selection.
static let storageKey = "appTheme"
var displayNameKey: LocalizedStringKey {
switch self {
case .matrix: return "app_info.appearance.matrix"
case .liquidGlass: return "app_info.appearance.liquid_glass"
}
}
/// Font design used for themed text. Matrix keeps the terminal monospace;
/// liquid glass uses the system default.
var bodyFontDesign: Font.Design {
switch self {
case .matrix: return .monospaced
case .liquidGlass: return .default
}
}
/// Whether chrome surfaces (header/composer bars, input field) render as
/// translucent glass/material instead of the flat matrix background.
var usesGlassChrome: Bool {
self == .liquidGlass
}
/// Discriminator mixed into per-message formatting caches so cached
/// AttributedStrings from one theme are never served under another.
/// Empty for matrix to keep its historical cache keys.
var formatCacheVariant: String {
switch self {
case .matrix: return ""
case .liquidGlass: return "lg:"
}
}
/// Resolves the semantic color palette for this theme under the given color scheme.
func palette(for colorScheme: ColorScheme) -> ThemePalette {
switch self {
case .matrix:
return .matrix(colorScheme)
case .liquidGlass:
return .liquidGlass(colorScheme)
}
}
}
/// Semantic colors for the active theme, resolved against the current color scheme.
/// Views should consume these via `@ThemedPalette` rather than computing colors inline.
struct ThemePalette {
/// Primary window/sheet background.
let background: Color
/// Primary text color.
let primary: Color
/// De-emphasized text (timestamps, hints, captions).
let secondary: Color
/// Interactive tint (buttons, toggles, selection).
let accent: Color
/// Location/geohash channel accent (badges, counts, subtitles).
let locationAccent: Color
/// Informational accent (links, read receipts, teleport markers).
let accentBlue: Color
/// Destructive/error accent.
let alertRed: Color
/// Hairline separators.
let divider: Color
static func matrix(_ colorScheme: ColorScheme) -> ThemePalette {
let isDark = colorScheme == .dark
let green = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
return ThemePalette(
background: isDark ? Color.black : Color.white,
primary: green,
secondary: green.opacity(0.8),
accent: green,
locationAccent: green,
accentBlue: Color(red: 0.0, green: 0.478, blue: 1.0),
alertRed: Color(red: 0.75, green: 0.1, blue: 0.1),
divider: isDark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)
)
}
static func liquidGlass(_ colorScheme: ColorScheme) -> ThemePalette {
ThemePalette(
background: systemBackground,
primary: .primary,
secondary: .secondary,
accent: .blue,
locationAccent: .green,
accentBlue: .blue,
alertRed: .red,
divider: separator
)
}
private static var systemBackground: Color {
#if os(iOS)
Color(UIColor.systemBackground)
#else
Color(NSColor.windowBackgroundColor)
#endif
}
private static var separator: Color {
#if os(iOS)
Color(UIColor.separator)
#else
Color(NSColor.separatorColor)
#endif
}
}
private struct AppThemeKey: EnvironmentKey {
static let defaultValue: AppTheme = .matrix
}
extension EnvironmentValues {
var appTheme: AppTheme {
get { self[AppThemeKey.self] }
set { self[AppThemeKey.self] = newValue }
}
}
/// Resolves the active theme's palette against the view's color scheme.
///
/// @ThemedPalette private var palette
/// var body: some View { Text("hi").foregroundColor(palette.primary) }
@propertyWrapper
struct ThemedPalette: DynamicProperty {
@Environment(\.appTheme) private var theme
@Environment(\.colorScheme) private var colorScheme
var wrappedValue: ThemePalette { theme.palette(for: colorScheme) }
}
// MARK: - Themed view helpers
/// Themed replacement for `.font(.bitchatSystem(size:weight:design: .monospaced))`:
/// monospaced under matrix, system default under liquid glass.
private struct ThemedFontModifier: ViewModifier {
@Environment(\.appTheme) private var theme
let size: CGFloat
let weight: Font.Weight
func body(content: Content) -> some View {
content.font(.bitchatSystem(size: size, weight: weight, design: theme.bodyFontDesign))
}
}
/// Root backdrop. Matrix gets its flat background; glass gets a subtle static
/// gradient with a soft tinted glow glass panels need visual texture behind
/// them to refract, and collapse to flat gray over a solid color.
struct ThemedRootBackground: View {
@Environment(\.appTheme) private var theme
@Environment(\.colorScheme) private var colorScheme
@ThemedPalette private var palette
var body: some View {
if theme.usesGlassChrome {
let isDark = colorScheme == .dark
ZStack {
LinearGradient(
colors: isDark
? [Color(red: 0.09, green: 0.10, blue: 0.15), Color(red: 0.04, green: 0.04, blue: 0.07)]
: [Color(red: 0.93, green: 0.95, blue: 1.0), Color(red: 0.98, green: 0.97, blue: 0.99)],
startPoint: .top,
endPoint: .bottom
)
RadialGradient(
colors: [Color.blue.opacity(isDark ? 0.22 : 0.12), .clear],
center: .topLeading,
startRadius: 0,
endRadius: 600
)
RadialGradient(
colors: [Color.purple.opacity(isDark ? 0.14 : 0.08), .clear],
center: .bottomTrailing,
startRadius: 0,
endRadius: 500
)
}
.ignoresSafeArea()
} else {
palette.background
}
}
}
/// Wraps glass-shape content in real Liquid Glass on OS 26+, with a material
/// fallback below that keeps the frosted look.
private struct GlassPanel<S: Shape>: ViewModifier {
let shape: S
@ViewBuilder
func body(content: Content) -> some View {
#if compiler(>=6.2)
if #available(iOS 26.0, macOS 26.0, *) {
content.glassEffect(.regular, in: shape)
} else {
materialFallback(content)
}
#else
materialFallback(content)
#endif
}
private func materialFallback(_ content: Content) -> some View {
content
.background(shape.fill(.ultraThinMaterial))
.overlay(shape.stroke(Color.white.opacity(0.15), lineWidth: 0.5))
}
}
/// Chrome surface for the header and composer. Matrix keeps the original flat
/// edge-to-edge wash; glass floats the content as an inset Liquid Glass panel
/// (content is expected to scroll underneath via safe-area insets).
private struct ThemedChromePanelModifier: ViewModifier {
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
let edge: VerticalEdge
@ViewBuilder
func body(content: Content) -> some View {
if theme.usesGlassChrome {
content
.modifier(GlassPanel(shape: RoundedRectangle(cornerRadius: 18, style: .continuous)))
.padding(.horizontal, 8)
.padding(edge == .top ? .top : .bottom, 4)
} else {
content.background(palette.background.opacity(0.95))
}
}
}
/// Background for the composer input field. Matrix keeps its translucent fill;
/// glass leaves it clear the field sits inside the composer's glass panel,
/// and glass cannot sample other glass.
private struct ThemedInputBackgroundModifier: ViewModifier {
@Environment(\.appTheme) private var theme
@Environment(\.colorScheme) private var colorScheme
private var shape: RoundedRectangle {
RoundedRectangle(cornerRadius: 14, style: .continuous)
}
@ViewBuilder
func body(content: Content) -> some View {
if theme.usesGlassChrome {
content
} else {
content.background(
shape.fill(colorScheme == .dark ? Color.black.opacity(0.35) : Color.white.opacity(0.7))
)
}
}
}
extension View {
func bitchatFont(size: CGFloat, weight: Font.Weight = .regular) -> some View {
modifier(ThemedFontModifier(size: size, weight: weight))
}
func themedChromePanel(edge: VerticalEdge) -> some View {
modifier(ThemedChromePanelModifier(edge: edge))
}
func themedInputBackground() -> some View {
modifier(ThemedInputBackgroundModifier())
}
/// Floating surface for popover-style boxes (autocomplete, command
/// suggestions): glass panel under liquid glass, the original flat
/// background + hairline stroke under matrix.
func themedOverlayPanel() -> some View {
modifier(ThemedOverlayPanelModifier())
}
/// Root background for sheets same backdrop as the main window so every
/// surface speaks one visual language.
func themedSheetBackground() -> some View {
background(ThemedRootBackground())
}
/// Flat background wash for bars/headers inside sheets. Matrix keeps its
/// opaque wash; glass goes transparent so the backdrop gradient shows.
func themedSurface(opacity: Double = 1.0) -> some View {
modifier(ThemedSurfaceModifier(opacity: opacity))
}
}
private struct ThemedSurfaceModifier: ViewModifier {
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
let opacity: Double
@ViewBuilder
func body(content: Content) -> some View {
if theme.usesGlassChrome {
content
} else {
content.background(palette.background.opacity(opacity))
}
}
}
private struct ThemedOverlayPanelModifier: ViewModifier {
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@ViewBuilder
func body(content: Content) -> some View {
if theme.usesGlassChrome {
content.modifier(GlassPanel(shape: RoundedRectangle(cornerRadius: 12, style: .continuous)))
} else {
content
.background(palette.background)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(palette.secondary.opacity(0.3), lineWidth: 1)
)
}
}
}
+24 -22
View File
@@ -14,7 +14,8 @@ final class ChatMessageFormatter {
self.viewModel = viewModel
}
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme = .matrix) -> AttributedString {
let design = theme.bodyFontDesign
let isSelf: Bool = {
if let spid = message.senderPeerID {
if case .location(let channel) = viewModel.activeChannel, spid.isGeoChat {
@@ -40,7 +41,7 @@ final class ChatMessageFormatter {
}()
let isDark = colorScheme == .dark
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant) {
return cachedText
}
@@ -52,7 +53,7 @@ final class ChatMessageFormatter {
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor
let fontWeight: Font.Weight = isSelf ? .bold : .medium
senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced)
senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: design)
if let spid = message.senderPeerID,
let url = URL(string: "bitchat://user/\(spid.toPercentEncoded())") {
senderStyle.link = url
@@ -79,8 +80,8 @@ final class ChatMessageFormatter {
var plainStyle = AttributeContainer()
plainStyle.foregroundColor = baseColor
plainStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: design)
result.append(AttributedString(content).mergingAttributes(plainStyle))
} else {
let hashtagRegex = Patterns.hashtag
@@ -197,8 +198,8 @@ final class ChatMessageFormatter {
var beforeStyle = AttributeContainer()
beforeStyle.foregroundColor = baseColor
beforeStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: design)
if isMentioned {
beforeStyle.font = beforeStyle.font?.bold()
}
@@ -230,7 +231,7 @@ final class ChatMessageFormatter {
mentionStyle.font = .bitchatSystem(
size: 14,
weight: isSelf ? .bold : .semibold,
design: .monospaced
design: design
)
let mentionColor: Color = isMentionToMe ? .orange : baseColor
mentionStyle.foregroundColor = mentionColor
@@ -267,8 +268,8 @@ final class ChatMessageFormatter {
var tagStyle = AttributeContainer()
tagStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: design)
tagStyle.foregroundColor = baseColor
if isGeohash && !attachedToMentionToken && standalone,
let url = URL(string: "bitchat://geohash/\(token)") {
@@ -280,15 +281,15 @@ final class ChatMessageFormatter {
var spacer = AttributeContainer()
spacer.foregroundColor = baseColor
spacer.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: design)
result.append(AttributedString(" ").mergingAttributes(spacer))
} else {
var matchStyle = AttributeContainer()
matchStyle.font = .bitchatSystem(
size: 14,
weight: isSelf ? .bold : .semibold,
design: .monospaced
design: design
)
if type == "url" {
matchStyle.foregroundColor = isSelf ? .orange : .blue
@@ -310,8 +311,8 @@ final class ChatMessageFormatter {
var remainingStyle = AttributeContainer()
remainingStyle.foregroundColor = baseColor
remainingStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: design)
if isMentioned {
remainingStyle.font = remainingStyle.font?.bold()
}
@@ -322,27 +323,28 @@ final class ChatMessageFormatter {
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
timestampStyle.font = .bitchatSystem(size: 10, design: design)
result.append(timestamp.mergingAttributes(timestampStyle))
} else {
var contentStyle = AttributeContainer()
contentStyle.foregroundColor = Color.gray
let content = AttributedString("* \(message.content) *")
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
contentStyle.font = .bitchatSystem(size: 12, design: design).italic()
result.append(content.mergingAttributes(contentStyle))
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
timestampStyle.font = .bitchatSystem(size: 10, design: design)
result.append(timestamp.mergingAttributes(timestampStyle))
}
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant)
return result
}
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme = .matrix) -> AttributedString {
let design = theme.bodyFontDesign
let isSelf: Bool = {
if let spid = message.senderPeerID {
if case .location(let channel) = viewModel.activeChannel, spid.id.hasPrefix("nostr:"),
@@ -362,7 +364,7 @@ final class ChatMessageFormatter {
if message.sender == "system" {
var style = AttributeContainer()
style.foregroundColor = baseColor
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
style.font = .bitchatSystem(size: 14, weight: .medium, design: design)
return AttributedString(message.sender).mergingAttributes(style)
}
@@ -370,7 +372,7 @@ final class ChatMessageFormatter {
let (baseName, suffix) = message.sender.splitSuffix()
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor
senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced)
senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: design)
if let spid = message.senderPeerID,
let url = URL(string: "bitchat://user/\(spid.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)") {
senderStyle.link = url
+5 -4
View File
@@ -139,6 +139,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
}
private var visibleMessagesCache: [BitchatMessage]?
@Published var currentColorScheme: ColorScheme = .light
@Published var currentTheme: AppTheme = .matrix
@Published var isConnected = false
@Published var nickname: String = "" {
didSet {
@@ -1257,13 +1258,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// MARK: - Message Formatting
@MainActor
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
messageFormatter.formatMessageAsText(message, colorScheme: colorScheme)
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
messageFormatter.formatMessageAsText(message, colorScheme: colorScheme, theme: theme ?? currentTheme)
}
@MainActor
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
messageFormatter.formatMessageHeader(message, colorScheme: colorScheme)
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
messageFormatter.formatMessageHeader(message, colorScheme: colorScheme, theme: theme ?? currentTheme)
}
// MARK: - Noise Protocol Support
+48 -30
View File
@@ -2,24 +2,24 @@ import SwiftUI
struct AppInfoView: View {
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
@ThemedPalette private var palette
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
private var backgroundColor: Color {
colorScheme == .dark ? Color.black : Color.white
private var selectedTheme: AppTheme {
AppTheme(rawValue: appThemeRawValue) ?? .matrix
}
private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var backgroundColor: Color { palette.background }
private var secondaryTextColor: Color {
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
}
private var textColor: Color { palette.primary }
private var secondaryTextColor: Color { palette.secondary }
// MARK: - Constants
private enum Strings {
static let appName: LocalizedStringKey = "app_info.app_name"
static let tagline: LocalizedStringKey = "app_info.tagline"
static let appearanceTitle: LocalizedStringKey = "app_info.appearance.title"
enum Features {
static let title: LocalizedStringKey = "app_info.features.title"
@@ -101,12 +101,12 @@ struct AppInfoView: View {
.foregroundColor(textColor)
.padding()
}
.background(backgroundColor.opacity(0.95))
.themedSurface(opacity: 0.95)
ScrollView {
infoContent
}
.background(backgroundColor)
.themedSheetBackground()
}
.frame(width: 600, height: 700)
#else
@@ -114,13 +114,13 @@ struct AppInfoView: View {
ScrollView {
infoContent
}
.background(backgroundColor)
.themedSheetBackground()
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.bitchatFont(size: 13, weight: .semibold)
.foregroundColor(textColor)
.frame(width: 32, height: 32)
}
@@ -138,16 +138,40 @@ struct AppInfoView: View {
// Header
VStack(alignment: .center, spacing: 8) {
Text(Strings.appName)
.font(.bitchatSystem(size: 32, weight: .bold, design: .monospaced))
.bitchatFont(size: 32, weight: .bold)
.foregroundColor(textColor)
Text(Strings.tagline)
.font(.bitchatSystem(size: 16, design: .monospaced))
.bitchatFont(size: 16)
.foregroundColor(secondaryTextColor)
}
.frame(maxWidth: .infinity)
.padding(.vertical)
// Appearance single row: label left, theme chips right
HStack(spacing: 12) {
SectionHeader(Strings.appearanceTitle)
Spacer()
ForEach(AppTheme.allCases) { theme in
Button {
appThemeRawValue = theme.rawValue
} label: {
Text(theme.displayNameKey)
.bitchatFont(size: 13, weight: selectedTheme == theme ? .semibold : .regular)
.foregroundColor(selectedTheme == theme ? palette.accent : secondaryTextColor)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(selectedTheme == theme ? palette.accent.opacity(0.15) : Color.clear)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityAddTraits(selectedTheme == theme ? .isSelected : [])
}
}
// How to Use
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.HowToUse.title)
@@ -157,7 +181,7 @@ struct AppInfoView: View {
Text(instruction)
}
}
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(textColor)
}
@@ -201,11 +225,9 @@ struct AppInfoFeatureInfo {
struct SectionHeader: View {
let title: LocalizedStringKey
@Environment(\.colorScheme) var colorScheme
@ThemedPalette private var palette
private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var textColor: Color { palette.primary }
init(_ title: LocalizedStringKey) {
self.title = title
@@ -213,7 +235,7 @@ struct SectionHeader: View {
var body: some View {
Text(title)
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.bitchatFont(size: 16, weight: .bold)
.foregroundColor(textColor)
.padding(.top, 8)
}
@@ -221,15 +243,11 @@ struct SectionHeader: View {
struct FeatureRow: View {
let info: AppInfoFeatureInfo
@Environment(\.colorScheme) var colorScheme
@ThemedPalette private var palette
private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var textColor: Color { palette.primary }
private var secondaryTextColor: Color {
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
}
private var secondaryTextColor: Color { palette.secondary }
var body: some View {
HStack(alignment: .top, spacing: 12) {
@@ -240,11 +258,11 @@ struct FeatureRow: View {
VStack(alignment: .leading, spacing: 4) {
Text(info.title)
.font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))
.bitchatFont(size: 14, weight: .semibold)
.foregroundColor(textColor)
Text(info.description)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
}
@@ -10,13 +10,10 @@ import SwiftUI
struct CommandSuggestionsView: View {
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@ThemedPalette private var palette
@Binding var messageText: String
let textColor: Color
let backgroundColor: Color
let secondaryTextColor: Color
private var filteredCommands: [CommandInfo] {
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
@@ -27,42 +24,43 @@ struct CommandSuggestionsView: View {
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(filteredCommands) { command in
Button {
messageText = command.alias + " "
} label: {
buttonRow(for: command)
// Render nothing when there are no matches: a zero-height view would
// still receive the composer VStack's spacing and push the input row
// off-center.
if !filteredCommands.isEmpty {
VStack(alignment: .leading, spacing: 0) {
ForEach(filteredCommands) { command in
Button {
messageText = command.alias + " "
} label: {
buttonRow(for: command)
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
}
.themedOverlayPanel()
}
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
)
}
private func buttonRow(for command: CommandInfo) -> some View {
HStack {
Text(command.alias)
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(textColor)
.bitchatFont(size: 11)
.foregroundColor(palette.primary)
.fontWeight(.medium)
if let placeholder = command.placeholder {
Text(placeholder)
.font(.bitchatSystem(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor.opacity(0.8))
.bitchatFont(size: 10)
.foregroundColor(palette.secondary.opacity(0.8))
}
Spacer()
Text(command.description)
.font(.bitchatSystem(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor)
.bitchatFont(size: 10)
.foregroundColor(palette.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 3)
@@ -85,12 +83,7 @@ struct CommandSuggestionsView: View {
)
let locationChannelsModel = LocationChannelsModel()
CommandSuggestionsView(
messageText: $messageText,
textColor: .green,
backgroundColor: .primary,
secondaryTextColor: .secondary
)
.environmentObject(privateConversationModel)
.environmentObject(locationChannelsModel)
CommandSuggestionsView(messageText: $messageText)
.environmentObject(privateConversationModel)
.environmentObject(locationChannelsModel)
}
@@ -10,18 +10,14 @@ import SwiftUI
import BitFoundation
struct DeliveryStatusView: View {
@Environment(\.colorScheme) private var colorScheme
@ThemedPalette private var palette
let status: DeliveryStatus
// MARK: - Computed Properties
private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var textColor: Color { palette.primary }
private var secondaryTextColor: Color {
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
}
private var secondaryTextColor: Color { palette.secondary }
private enum Strings {
static func delivered(to nickname: String) -> String {
@@ -89,7 +85,7 @@ struct DeliveryStatusView: View {
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10, weight: .bold))
}
.foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue
.foregroundColor(palette.accentBlue)
.help(Strings.read(by: nickname))
case .failed(let reason):
@@ -103,7 +99,7 @@ struct DeliveryStatusView: View {
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10))
Text(verbatim: "\(reached)/\(total)")
.font(.bitchatSystem(size: 10, design: .monospaced))
.bitchatFont(size: 10)
}
.foregroundColor(secondaryTextColor.opacity(0.6))
.help(Strings.deliveredToMembers(reached, total))
@@ -11,6 +11,7 @@ import SwiftUI
struct PaymentChipView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.openURL) private var openURL
@ThemedPalette private var palette
enum PaymentType {
case cashu(String)
@@ -54,9 +55,7 @@ struct PaymentChipView: View {
let paymentType: PaymentType
private var fgColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var fgColor: Color { palette.primary }
private var bgColor: Color {
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
}
@@ -73,7 +72,7 @@ struct PaymentChipView: View {
HStack(spacing: 6) {
Text(paymentType.emoji)
Text(paymentType.label)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.bitchatFont(size: 12, weight: .semibold)
}
.padding(.vertical, 6)
.padding(.horizontal, 12)
@@ -11,6 +11,7 @@ import BitFoundation
struct TextMessageView: View {
@Environment(\.colorScheme) private var colorScheme: ColorScheme
@Environment(\.appTheme) private var theme
@EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage
@@ -37,7 +38,7 @@ struct TextMessageView: View {
HStack(alignment: .top, spacing: 0) {
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id)
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme))
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -58,7 +59,7 @@ struct TextMessageView: View {
if isExpanded { expandedMessageIDs.remove(message.id) }
else { expandedMessageIDs.insert(message.id) }
}
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))
.bitchatFont(size: 11, weight: .medium)
.foregroundColor(Color.blue)
.padding(.top, 4)
}
+15 -29
View File
@@ -6,16 +6,14 @@ import UIKit
struct ContentComposerView: View {
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@Environment(\.colorScheme) private var colorScheme
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@Binding var messageText: String
var isTextFieldFocused: FocusState<Bool>.Binding
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
@Binding var autocompleteDebounceTimer: Timer?
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
let onSendMessage: () -> Void
#if os(iOS)
@@ -35,8 +33,8 @@ struct ContentComposerView: View {
}) {
HStack {
Text(suggestion)
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(textColor)
.bitchatFont(size: 11)
.foregroundColor(palette.primary)
.fontWeight(.medium)
Spacer()
}
@@ -48,20 +46,11 @@ struct ContentComposerView: View {
.background(Color.gray.opacity(0.1))
}
}
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
)
.themedOverlayPanel()
.padding(.horizontal, 12)
}
CommandSuggestionsView(
messageText: $messageText,
textColor: textColor,
backgroundColor: backgroundColor,
secondaryTextColor: secondaryTextColor
)
CommandSuggestionsView(messageText: $messageText)
if voiceRecordingVM.state.isActive {
recordingIndicator
@@ -74,11 +63,11 @@ struct ContentComposerView: View {
prompt: Text(
String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer")
)
.foregroundColor(secondaryTextColor.opacity(0.6))
.foregroundColor(palette.secondary.opacity(0.6))
)
.textFieldStyle(.plain)
.font(.bitchatSystem(size: 15, design: .monospaced))
.foregroundColor(textColor)
.bitchatFont(size: 15)
.foregroundColor(palette.primary)
.focused(isTextFieldFocused)
.autocorrectionDisabled(true)
#if os(iOS)
@@ -86,12 +75,9 @@ struct ContentComposerView: View {
#endif
.submitLabel(.send)
.onSubmit(onSendMessage)
.padding(.vertical, 4)
.padding(.vertical, theme.usesGlassChrome ? 8 : 4)
.padding(.horizontal, 6)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(colorScheme == .dark ? Color.black.opacity(0.35) : Color.white.opacity(0.7))
)
.themedInputBackground()
.modifier(FocusEffectDisabledModifier())
.frame(maxWidth: .infinity, alignment: .leading)
.onChange(of: messageText) { newValue in
@@ -114,9 +100,9 @@ struct ContentComposerView: View {
}
}
.padding(.horizontal, 6)
.padding(.top, 6)
.padding(.top, theme.usesGlassChrome ? 8 : 6)
.padding(.bottom, 8)
.background(backgroundColor.opacity(0.95))
.themedChromePanel(edge: .bottom)
.onDisappear {
autocompleteDebounceTimer?.invalidate()
}
@@ -134,7 +120,7 @@ private extension ContentComposerView {
"recording \(voiceRecordingVM.formattedDuration(for: context.date))",
comment: "Voice note recording duration indicator"
)
.font(.bitchatSystem(size: 13, design: .monospaced))
.bitchatFont(size: 13)
.foregroundColor(.red)
}
Spacer()
@@ -154,7 +140,7 @@ private extension ContentComposerView {
}
var composerAccentColor: Color {
privateConversationModel.selectedPeerID != nil ? Color.orange : textColor
privateConversationModel.selectedPeerID != nil ? Color.orange : palette.accent
}
var attachmentButton: some View {
+67 -62
View File
@@ -8,8 +8,9 @@ struct ContentHeaderView: View {
@EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@Binding var showSidebar: Bool
@Binding var showVerifySheet: Bool
@@ -20,15 +21,12 @@ struct ContentHeaderView: View {
let headerHeight: CGFloat
let headerPeerIconSize: CGFloat
let headerPeerCountFontSize: CGFloat
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
var body: some View {
HStack(spacing: 0) {
Text(verbatim: "bitchat/")
.font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced))
.foregroundColor(textColor)
.bitchatFont(size: 18, weight: .medium)
.foregroundColor(palette.primary)
.onTapGesture(count: 3) {
appChromeModel.panicClearAllData()
}
@@ -38,8 +36,8 @@ struct ContentHeaderView: View {
HStack(spacing: 0) {
Text(verbatim: "@")
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
TextField(
"content.input.nickname_placeholder",
@@ -49,9 +47,9 @@ struct ContentHeaderView: View {
)
)
.textFieldStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.frame(maxWidth: 80)
.foregroundColor(textColor)
.foregroundColor(palette.primary)
.focused(isNicknameFieldFocused)
.autocorrectionDisabled(true)
#if os(iOS)
@@ -79,12 +77,13 @@ struct ContentHeaderView: View {
return countAndColor.0
}()
HStack(spacing: 10) {
HStack(spacing: 2) {
if appChromeModel.hasUnreadPrivateMessages {
Button(action: { appChromeModel.openMostRelevantPrivateChat() }) {
Image(systemName: "envelope.fill")
.font(.bitchatSystem(size: 12))
.foregroundColor(Color.orange)
.headerTapTarget()
}
.buttonStyle(.plain)
.accessibilityLabel(
@@ -99,13 +98,10 @@ struct ContentHeaderView: View {
notesGeohash = locationChannelsModel.currentBuildingGeohash
showLocationNotes = true
}) {
HStack(alignment: .center, spacing: 4) {
Image(systemName: "note.text")
.font(.bitchatSystem(size: 12))
.foregroundColor(Color.orange.opacity(0.8))
.padding(.top, 1)
}
.fixedSize(horizontal: true, vertical: false)
Image(systemName: "note.text")
.font(.bitchatSystem(size: 12))
.foregroundColor(Color.orange.opacity(0.8))
.headerTapTarget()
}
.buttonStyle(.plain)
.accessibilityLabel(
@@ -117,6 +113,7 @@ struct ContentHeaderView: View {
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
.font(.bitchatSystem(size: 12))
.headerTapTarget()
}
.buttonStyle(.plain)
.accessibilityLabel(
@@ -140,49 +137,54 @@ struct ContentHeaderView: View {
case .mesh:
return Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
case .location:
return colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
return palette.locationAccent
}
}()
Text(badgeText)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(badgeColor)
.lineLimit(headerLineLimit)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
.padding(.horizontal, 6)
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.accessibilityLabel(
String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button")
)
}
.buttonStyle(.plain)
.padding(.leading, 4)
.padding(.trailing, 2)
HStack(spacing: 4) {
Image(systemName: "person.2.fill")
.font(.system(size: headerPeerIconSize, weight: .regular))
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"),
locale: .current,
headerOtherPeersCount
)
)
Text("\(headerOtherPeersCount)")
.font(.system(size: headerPeerCountFontSize, weight: .regular, design: .monospaced))
.accessibilityHidden(true)
Button(action: {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar.toggle()
}
}) {
HStack(spacing: 4) {
Image(systemName: "person.2.fill")
.font(.system(size: headerPeerIconSize, weight: .regular))
Text("\(headerOtherPeersCount)")
.font(.system(size: headerPeerCountFontSize, weight: .regular, design: theme.bodyFontDesign))
.accessibilityHidden(true)
}
.foregroundColor(headerCountColor)
.lineLimit(headerLineLimit)
.fixedSize(horizontal: true, vertical: false)
.padding(.leading, 6)
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
}
.foregroundColor(headerCountColor)
.padding(.leading, 2)
.lineLimit(headerLineLimit)
.fixedSize(horizontal: true, vertical: false)
.buttonStyle(.plain)
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"),
locale: .current,
headerOtherPeersCount
)
)
}
.layoutPriority(3)
.onTapGesture {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar.toggle()
}
}
.sheet(isPresented: $showVerifySheet) {
VerificationSheetView(isPresented: $showVerifySheet)
.environmentObject(verificationModel)
@@ -208,10 +210,7 @@ struct ContentHeaderView: View {
} else {
ContentLocationNotesUnavailableView(
showLocationNotes: $showLocationNotes,
headerHeight: headerHeight,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor
headerHeight: headerHeight
)
.environmentObject(locationChannelsModel)
}
@@ -249,7 +248,16 @@ struct ContentHeaderView: View {
} message: {
Text("content.alert.screenshot.message")
}
.background(backgroundColor.opacity(0.95))
.themedChromePanel(edge: .top)
}
}
private extension View {
/// Expands a small header icon to a comfortably tappable, full-bar-height
/// hit area without changing its visual size.
func headerTapTarget() -> some View {
frame(minWidth: 30, maxHeight: .infinity)
.contentShape(Rectangle())
}
}
@@ -262,8 +270,7 @@ private extension ContentHeaderView {
switch locationChannelsModel.selectedChannel {
case .location:
let count = peerListModel.visibleGeohashPeerCount
let standardGreen = colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
return (count, count > 0 ? standardGreen : Color.secondary)
return (count, count > 0 ? palette.locationAccent : Color.secondary)
case .mesh:
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : Color.secondary
@@ -274,24 +281,22 @@ private extension ContentHeaderView {
private struct ContentLocationNotesUnavailableView: View {
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@ThemedPalette private var palette
@Binding var showLocationNotes: Bool
let headerHeight: CGFloat
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
var body: some View {
VStack(spacing: 12) {
HStack {
Text("content.notes.title")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.bitchatFont(size: 16, weight: .bold)
Spacer()
Button(action: { showLocationNotes = false }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.bitchatFont(size: 13, weight: .semibold)
.foregroundColor(palette.primary)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
@@ -299,17 +304,17 @@ private struct ContentLocationNotesUnavailableView: View {
}
.frame(height: headerHeight)
.padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95))
.themedChromePanel(edge: .top)
Text("content.notes.location_unavailable")
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
Button("content.location.enable") {
locationChannelsModel.enableAndRefresh()
}
.buttonStyle(.bordered)
Spacer()
}
.background(backgroundColor)
.foregroundColor(textColor)
.themedSheetBackground()
.foregroundColor(palette.primary)
}
}
+31 -54
View File
@@ -25,10 +25,8 @@ struct ContentPeopleSheetView: View {
var isTextFieldFocused: FocusState<Bool>.Binding
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
@Binding var autocompleteDebounceTimer: Timer?
@ThemedPalette private var palette
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
let headerHeight: CGFloat
let onSendMessage: () -> Void
@@ -56,9 +54,6 @@ struct ContentPeopleSheetView: View {
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight,
onSendMessage: onSendMessage,
showImagePicker: $showImagePicker,
@@ -77,9 +72,6 @@ struct ContentPeopleSheetView: View {
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight,
onSendMessage: onSendMessage,
showMacImagePicker: $showMacImagePicker
@@ -88,9 +80,6 @@ struct ContentPeopleSheetView: View {
} else {
ContentPeopleListView(
showSidebar: $showSidebar,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight
)
}
@@ -109,8 +98,8 @@ struct ContentPeopleSheetView: View {
}
}
}
.background(backgroundColor)
.foregroundColor(textColor)
.themedSheetBackground()
.foregroundColor(palette.primary)
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
@@ -148,12 +137,10 @@ private struct ContentPeopleListView: View {
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.dismiss) private var dismiss
@ThemedPalette private var palette
@Binding var showSidebar: Bool
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
let headerHeight: CGFloat
@State private var showVerifySheet = false
@@ -163,8 +150,8 @@ private struct ContentPeopleListView: View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text(peopleSheetTitle)
.font(.bitchatSystem(size: 18, design: .monospaced))
.foregroundColor(textColor)
.bitchatFont(size: 18)
.foregroundColor(palette.primary)
Spacer()
if case .mesh = locationChannelsModel.selectedChannel {
Button(action: { showVerifySheet = true }) {
@@ -185,7 +172,7 @@ private struct ContentPeopleListView: View {
}
}) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
@@ -201,9 +188,9 @@ private struct ContentPeopleListView: View {
let subtitleColor: Color = {
switch locationChannelsModel.selectedChannel {
case .mesh:
return Color.blue
return palette.accentBlue
case .location:
return Color.green
return palette.locationAccent
}
}()
@@ -213,32 +200,28 @@ private struct ContentPeopleListView: View {
Text(activeText)
.foregroundColor(.secondary)
}
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
} else {
Text(activeText)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 12)
.background(backgroundColor)
.themedSurface()
ScrollView {
VStack(alignment: .leading, spacing: 6) {
if case .location = locationChannelsModel.selectedChannel {
GeohashPeopleList(
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPerson: {
showSidebar = true
}
)
} else {
MeshPeerList(
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPeer: { peerID in
peerListModel.startConversation(with: peerID)
showSidebar = true
@@ -302,10 +285,9 @@ private struct ContentPrivateChatSheetView: View {
var isTextFieldFocused: FocusState<Bool>.Binding
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
@Binding var autocompleteDebounceTimer: Timer?
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
let headerHeight: CGFloat
let onSendMessage: () -> Void
@@ -327,7 +309,7 @@ private struct ContentPrivateChatSheetView: View {
}) {
Image(systemName: "chevron.left")
.font(.bitchatSystem(size: 12))
.foregroundColor(textColor)
.foregroundColor(palette.primary)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
@@ -341,8 +323,7 @@ private struct ContentPrivateChatSheetView: View {
HStack(spacing: 8) {
ContentPrivateHeaderInfoButton(
headerState: headerState,
headerHeight: headerHeight,
textColor: textColor
headerHeight: headerHeight
)
if headerState.supportsFavoriteToggle {
@@ -351,7 +332,7 @@ private struct ContentPrivateChatSheetView: View {
}) {
Image(systemName: headerState.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 14))
.foregroundColor(headerState.isFavorite ? Color.yellow : textColor)
.foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary)
}
.buttonStyle(.plain)
.accessibilityLabel(
@@ -372,7 +353,7 @@ private struct ContentPrivateChatSheetView: View {
}
}) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
@@ -382,7 +363,7 @@ private struct ContentPrivateChatSheetView: View {
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 12)
.background(backgroundColor)
.themedSurface()
}
MessageListView(
@@ -397,10 +378,12 @@ private struct ContentPrivateChatSheetView: View {
showSidebar: $showSidebar,
isTextFieldFocused: isTextFieldFocused
)
.background(backgroundColor)
.themedSurface()
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
if !theme.usesGlassChrome {
Divider()
}
#if os(iOS)
ContentComposerView(
@@ -408,9 +391,6 @@ private struct ContentPrivateChatSheetView: View {
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onSendMessage: onSendMessage,
showImagePicker: $showImagePicker,
imagePickerSourceType: $imagePickerSourceType
@@ -421,16 +401,13 @@ private struct ContentPrivateChatSheetView: View {
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onSendMessage: onSendMessage,
showMacImagePicker: $showMacImagePicker
)
#endif
}
.background(backgroundColor)
.foregroundColor(textColor)
.themedSheetBackground()
.foregroundColor(palette.primary)
.highPriorityGesture(
DragGesture(minimumDistance: 25, coordinateSpace: .local)
.onEnded { value in
@@ -448,10 +425,10 @@ private struct ContentPrivateChatSheetView: View {
private struct ContentPrivateHeaderInfoButton: View {
@EnvironmentObject private var appChromeModel: AppChromeModel
@ThemedPalette private var palette
let headerState: PrivateConversationHeaderState
let headerHeight: CGFloat
let textColor: Color
var body: some View {
Button(action: {
@@ -462,12 +439,12 @@ private struct ContentPrivateHeaderInfoButton: View {
case .bluetoothConnected:
Image(systemName: "dot.radiowaves.left.and.right")
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.foregroundColor(palette.primary)
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
case .meshReachable:
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.foregroundColor(palette.primary)
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
case .nostrAvailable:
Image(systemName: "globe")
@@ -479,8 +456,8 @@ private struct ContentPrivateHeaderInfoButton: View {
}
Text(headerState.displayName)
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
.foregroundColor(textColor)
.bitchatFont(size: 16, weight: .medium)
.foregroundColor(palette.primary)
if let encryptionStatus = headerState.encryptionStatus,
let icon = encryptionStatus.icon {
@@ -488,7 +465,7 @@ private struct ContentPrivateHeaderInfoButton: View {
.font(.bitchatSystem(size: 14))
.foregroundColor(
encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured
? textColor
? palette.primary
: Color.red
)
.accessibilityLabel(
+100 -86
View File
@@ -40,6 +40,7 @@ struct ContentView: View {
@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?
@@ -63,39 +64,19 @@ struct ContentView: View {
@State private var windowCountPublic: Int = 300
@State private var windowCountPrivate: [PeerID: Int] = [:]
private var backgroundColor: Color {
colorScheme == .dark ? Color.black : Color.white
}
private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var secondaryTextColor: Color {
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
}
@ThemedPalette private var palette
private var selectedPrivatePeerID: PeerID? {
privateConversationModel.selectedPeerID
}
private var usesGlassLayout: Bool { appTheme.usesGlassChrome }
var body: some View {
VStack(spacing: 0) {
ContentHeaderView(
showSidebar: $showSidebar,
showVerifySheet: $showVerifySheet,
showLocationNotes: $showLocationNotes,
notesGeohash: $notesGeohash,
isNicknameFieldFocused: $isNicknameFieldFocused,
headerHeight: headerHeight,
headerPeerIconSize: headerPeerIconSize,
headerPeerCountFontSize: headerPeerCountFontSize,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor
)
mainContent
.onAppear {
conversationUIModel.setCurrentColorScheme(colorScheme)
conversationUIModel.setCurrentTheme(appTheme)
#if os(macOS)
DispatchQueue.main.async {
isNicknameFieldFocused = false
@@ -106,62 +87,11 @@ struct ContentView: View {
.onChange(of: colorScheme) { newValue in
conversationUIModel.setCurrentColorScheme(newValue)
}
Divider()
GeometryReader { geometry in
VStack(spacing: 0) {
MessageListView(
privatePeer: nil,
isAtBottom: $isAtBottomPublic,
messageText: $messageText,
selectedMessageSender: $selectedMessageSender,
selectedMessageSenderID: $selectedMessageSenderID,
imagePreviewURL: $imagePreviewURL,
windowCountPublic: $windowCountPublic,
windowCountPrivate: $windowCountPrivate,
showSidebar: $showSidebar,
isTextFieldFocused: $isTextFieldFocused
)
.background(backgroundColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(width: geometry.size.width, height: geometry.size.height)
.onChange(of: appTheme) { newValue in
conversationUIModel.setCurrentTheme(newValue)
}
Divider()
if selectedPrivatePeerID == nil {
#if os(iOS)
ContentComposerView(
messageText: $messageText,
isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onSendMessage: sendMessage,
showImagePicker: $showImagePicker,
imagePickerSourceType: $imagePickerSourceType
)
#else
ContentComposerView(
messageText: $messageText,
isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onSendMessage: sendMessage,
showMacImagePicker: $showMacImagePicker
)
#endif
}
}
.background(backgroundColor)
.foregroundColor(textColor)
.background(ThemedRootBackground())
.foregroundColor(palette.primary)
#if os(macOS)
.frame(minWidth: 600, minHeight: 400)
#endif
@@ -194,9 +124,6 @@ struct ContentView: View {
isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight,
onSendMessage: sendMessage,
showImagePicker: $showImagePicker,
@@ -215,9 +142,6 @@ struct ContentView: View {
isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight,
onSendMessage: sendMessage,
showMacImagePicker: $showMacImagePicker
@@ -302,6 +226,96 @@ struct ContentView: View {
}
}
/// 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 }
+16 -20
View File
@@ -13,15 +13,11 @@ struct FingerprintView: View {
@EnvironmentObject private var verificationModel: VerificationModel
let peerID: PeerID
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
@ThemedPalette private var palette
private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var textColor: Color { palette.primary }
private var backgroundColor: Color {
colorScheme == .dark ? Color.black : Color.white
}
private var backgroundColor: Color { palette.background }
private enum Strings {
static let title: LocalizedStringKey = "fingerprint.title"
@@ -53,7 +49,7 @@ struct FingerprintView: View {
// Header
HStack {
Text(Strings.title)
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.bitchatFont(size: 16, weight: .bold)
.foregroundColor(textColor)
Spacer()
@@ -76,11 +72,11 @@ struct FingerprintView: View {
VStack(alignment: .leading, spacing: 4) {
Text(fingerprintState.peerNickname)
.font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced))
.bitchatFont(size: 18, weight: .semibold)
.foregroundColor(textColor)
Text(fingerprintState.encryptionStatus.description)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(textColor.opacity(0.7))
}
@@ -93,12 +89,12 @@ struct FingerprintView: View {
// Their fingerprint
VStack(alignment: .leading, spacing: 8) {
Text(Strings.theirFingerprint)
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
.bitchatFont(size: 12, weight: .bold)
.foregroundColor(textColor.opacity(0.7))
if let fingerprint = fingerprintState.theirFingerprint {
Text(formatFingerprint(fingerprint))
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(textColor)
.multilineTextAlignment(.leading)
.lineLimit(nil)
@@ -119,7 +115,7 @@ struct FingerprintView: View {
}
} else {
Text(Strings.handshakePending)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(Color.orange)
.padding()
}
@@ -128,11 +124,11 @@ struct FingerprintView: View {
// My fingerprint
VStack(alignment: .leading, spacing: 8) {
Text(Strings.yourFingerprint)
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
.bitchatFont(size: 12, weight: .bold)
.foregroundColor(textColor.opacity(0.7))
Text(formatFingerprint(fingerprintState.myFingerprint))
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(textColor)
.multilineTextAlignment(.leading)
.lineLimit(nil)
@@ -157,7 +153,7 @@ struct FingerprintView: View {
if fingerprintState.canToggleVerification {
VStack(spacing: 12) {
Text(fingerprintState.isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
.bitchatFont(size: 14, weight: .bold)
.foregroundColor(fingerprintState.isVerified ? Color.green : Color.orange)
.frame(maxWidth: .infinity)
@@ -168,7 +164,7 @@ struct FingerprintView: View {
Text(Strings.verifyHint(fingerprintState.peerNickname))
}
}
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(textColor.opacity(0.7))
.multilineTextAlignment(.center)
.lineLimit(nil)
@@ -181,7 +177,7 @@ struct FingerprintView: View {
dismiss()
}) {
Text(Strings.markVerified)
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
.bitchatFont(size: 14, weight: .bold)
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 10)
@@ -195,7 +191,7 @@ struct FingerprintView: View {
dismiss()
}) {
Text(Strings.removeVerification)
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
.bitchatFont(size: 14, weight: .bold)
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 10)
@@ -216,7 +212,7 @@ struct FingerprintView: View {
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(backgroundColor)
.themedSheetBackground()
}
private func formatFingerprint(_ fingerprint: String) -> String {
+6 -7
View File
@@ -2,8 +2,7 @@ import SwiftUI
struct GeohashPeopleList: View {
@EnvironmentObject private var peerListModel: PeerListModel
let textColor: Color
let secondaryTextColor: Color
@ThemedPalette private var palette
let onTapPerson: () -> Void
@Environment(\.colorScheme) var colorScheme
@State private var orderedIDs: [String] = []
@@ -20,8 +19,8 @@ struct GeohashPeopleList: View {
if peerListModel.geohashPeople.isEmpty {
VStack(alignment: .leading, spacing: 0) {
Text(Strings.noneNearby)
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
.padding(.horizontal)
.padding(.top, 12)
}
@@ -52,18 +51,18 @@ struct GeohashPeopleList: View {
let (base, suffix) = person.displayName.splitSuffix()
HStack(spacing: 0) {
Text(base)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.fontWeight(person.isMe ? .bold : .regular)
.foregroundColor(rowColor)
if !suffix.isEmpty {
let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
Text(suffix)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(suffixColor)
}
if person.isMe {
Text(Strings.youSuffix)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(rowColor)
}
}
+31 -37
View File
@@ -9,11 +9,11 @@ struct LocationChannelsSheet: View {
@Binding var isPresented: Bool
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.colorScheme) var colorScheme
@ThemedPalette private var palette
@State private var customGeohash: String = ""
@State private var customError: String? = nil
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
private var backgroundColor: Color { palette.background }
private enum Strings {
static let title: LocalizedStringKey = "location_channels.title"
@@ -97,12 +97,12 @@ struct LocationChannelsSheet: View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
Text(Strings.title)
.font(.bitchatSystem(size: 18, design: .monospaced))
.bitchatFont(size: 18)
Spacer()
closeButton
}
Text(Strings.description)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
Group {
@@ -110,7 +110,7 @@ struct LocationChannelsSheet: View {
case .notDetermined:
Button(action: { locationChannelsModel.enableLocationChannels() }) {
Text(Strings.requestPermissions)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(standardGreen)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
@@ -121,7 +121,7 @@ struct LocationChannelsSheet: View {
case .denied, .restricted:
VStack(alignment: .leading, spacing: 8) {
Text(Strings.permissionDenied)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
Button(Strings.openSettings, action: SystemSettings.location.open)
.buttonStyle(.plain)
@@ -136,7 +136,7 @@ struct LocationChannelsSheet: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(backgroundColor)
.themedSurface()
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true)
@@ -147,7 +147,7 @@ struct LocationChannelsSheet: View {
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
.background(backgroundColor)
.themedSheetBackground()
.onAppear {
// Refresh channels when opening
if locationChannelsModel.permissionState == .authorized {
@@ -171,7 +171,7 @@ struct LocationChannelsSheet: View {
private var closeButton: some View {
Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
@@ -223,7 +223,7 @@ struct LocationChannelsSheet: View {
HStack(spacing: 8) {
ProgressView()
Text(Strings.loadingNearby)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 10)
@@ -246,8 +246,8 @@ struct LocationChannelsSheet: View {
.padding(.top, 12)
Button(action: SystemSettings.location.open) {
Text(Strings.removeAccess)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
.bitchatFont(size: 12)
.foregroundColor(palette.alertRed)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.red.opacity(0.08))
@@ -259,9 +259,9 @@ struct LocationChannelsSheet: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 6)
.background(backgroundColor)
.themedSurface()
}
.background(backgroundColor)
.themedSurface()
}
private var sectionDivider: some View {
@@ -270,15 +270,13 @@ struct LocationChannelsSheet: View {
.frame(height: 1)
}
private var dividerColor: Color {
colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)
}
private var dividerColor: Color { palette.divider }
private var customTeleportSection: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 2) {
Text(verbatim: "#")
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(.secondary)
TextField("geohash", text: $customGeohash)
#if os(iOS)
@@ -286,7 +284,7 @@ struct LocationChannelsSheet: View {
.autocorrectionDisabled(true)
.keyboardType(.asciiCapable)
#endif
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.onChange(of: customGeohash) { newValue in
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
let filtered = newValue
@@ -312,13 +310,13 @@ struct LocationChannelsSheet: View {
}) {
HStack(spacing: 6) {
Text(Strings.teleport)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
Image(systemName: "face.dashed")
.font(.bitchatSystem(size: 14))
}
}
.buttonStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Color.secondary.opacity(0.12))
@@ -328,7 +326,7 @@ struct LocationChannelsSheet: View {
}
if let err = customError {
Text(err)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.red)
}
}
@@ -337,7 +335,7 @@ struct LocationChannelsSheet: View {
private func bookmarkedSection(_ entries: [String]) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(Strings.bookmarked)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
LazyVStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
@@ -409,18 +407,18 @@ struct LocationChannelsSheet: View {
let parts = splitTitleAndCount(title)
HStack(spacing: 4) {
Text(parts.base)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? Color.primary)
if let count = parts.countSuffix, !count.isEmpty {
Text(count)
.font(.bitchatSystem(size: 11, design: .monospaced))
.bitchatFont(size: 11)
.foregroundColor(.secondary)
}
}
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
Text(subtitleFull)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
@@ -428,7 +426,7 @@ struct LocationChannelsSheet: View {
Spacer()
if isSelected {
Text(verbatim: "✔︎")
.font(.bitchatSystem(size: 16, design: .monospaced))
.bitchatFont(size: 16)
.foregroundColor(standardGreen)
}
trailingAccessory()
@@ -478,26 +476,22 @@ extension LocationChannelsSheet {
Toggle(isOn: torToggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(Strings.torTitle)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(.primary)
Text(Strings.torSubtitle)
.font(.bitchatSystem(size: 11, design: .monospaced))
.bitchatFont(size: 11)
.foregroundColor(.secondary)
}
}
.toggleStyle(IRCToggleStyle(accent: standardGreen, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
}
.padding(12)
.background(Color.secondary.opacity(0.12))
.cornerRadius(8)
}
private var standardGreen: Color {
(colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var standardBlue: Color {
Color(red: 0.0, green: 0.478, blue: 1.0)
}
private var standardGreen: Color { palette.primary }
private var standardBlue: Color { palette.accentBlue }
}
private struct IRCToggleStyle: ToggleStyle {
@@ -512,7 +506,7 @@ private struct IRCToggleStyle: ToggleStyle {
Spacer()
Text(configuration.isOn ? onLabel : offLabel)
.textCase(.uppercase)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(configuration.isOn ? accent : .secondary)
.padding(.vertical, 4)
.padding(.horizontal, 10)
+28 -28
View File
@@ -6,7 +6,7 @@ struct LocationNotesView: View {
let senderNickname: String
let onNotesCountChanged: ((Int) -> Void)?
@Environment(\.colorScheme) var colorScheme
@ThemedPalette private var palette
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@Environment(\.dismiss) private var dismiss
@@ -25,8 +25,8 @@ struct LocationNotesView: View {
_manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
}
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
private var accentGreen: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) }
private var backgroundColor: Color { palette.background }
private var accentGreen: Color { palette.accent }
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
private enum Strings {
@@ -53,11 +53,11 @@ struct LocationNotesView: View {
notesContent
}
}
.background(backgroundColor)
.themedSurface()
inputSection
}
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
.background(backgroundColor)
.themedSheetBackground()
.onDisappear { manager.cancel() }
.onChange(of: geohash) { newValue in
manager.setGeohash(newValue)
@@ -76,7 +76,7 @@ struct LocationNotesView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
inputSection
}
.background(backgroundColor)
.themedSurface()
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true)
@@ -84,7 +84,7 @@ struct LocationNotesView: View {
.navigationTitle("")
#endif
}
.background(backgroundColor)
.themedSheetBackground()
.onDisappear { manager.cancel() }
.onChange(of: geohash) { newValue in
manager.setGeohash(newValue)
@@ -99,7 +99,7 @@ struct LocationNotesView: View {
private var closeButton: some View {
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
@@ -111,33 +111,33 @@ struct LocationNotesView: View {
return VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text(headerTitle(for: count))
.font(.bitchatSystem(size: 18, design: .monospaced))
.bitchatFont(size: 18)
Spacer()
closeButton
}
if let building = locationChannelsModel.locationName(for: .building), !building.isEmpty {
Text(building)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(accentGreen)
} else if let block = locationChannelsModel.locationName(for: .block), !block.isEmpty {
Text(block)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(accentGreen)
}
Text(Strings.description)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
if manager.state == .noRelays {
Text(Strings.relaysPaused)
.font(.bitchatSystem(size: 11, design: .monospaced))
.bitchatFont(size: 11)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 12)
.background(backgroundColor)
.themedSurface()
}
private func headerTitle(for count: Int) -> String {
@@ -176,16 +176,16 @@ struct LocationNotesView: View {
return VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(verbatim: "@\(baseName)")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.bitchatFont(size: 12, weight: .semibold)
if !ts.isEmpty {
Text(ts)
.font(.bitchatSystem(size: 11, design: .monospaced))
.bitchatFont(size: 11)
.foregroundColor(.secondary)
}
Spacer()
}
Text(note.content)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 4)
@@ -194,12 +194,12 @@ struct LocationNotesView: View {
private var noRelaysRow: some View {
VStack(alignment: .leading, spacing: 4) {
Text(Strings.noRelaysNearby)
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.relaysRetryHint)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
Button(Strings.retry) { manager.refresh() }
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.buttonStyle(.plain)
}
.padding(.vertical, 6)
@@ -209,7 +209,7 @@ struct LocationNotesView: View {
HStack(spacing: 10) {
ProgressView()
Text(Strings.loadingNotes)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
Spacer()
}
@@ -219,9 +219,9 @@ struct LocationNotesView: View {
private var emptyRow: some View {
VStack(alignment: .leading, spacing: 4) {
Text(Strings.emptyTitle)
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.emptySubtitle)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
}
.padding(.vertical, 6)
@@ -231,13 +231,13 @@ struct LocationNotesView: View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
Text(message)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
Spacer()
}
Button(Strings.dismissError) { manager.clearError() }
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.buttonStyle(.plain)
}
.padding(.vertical, 6)
@@ -247,7 +247,7 @@ struct LocationNotesView: View {
HStack(alignment: .top, spacing: 10) {
TextField(Strings.addPlaceholder, text: $draft, axis: .vertical)
.textFieldStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.lineLimit(maxDraftLines, reservesSpace: true)
.padding(.vertical, 6)
Button(action: send) {
@@ -261,7 +261,7 @@ struct LocationNotesView: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(backgroundColor)
.themedSurface()
.overlay(Divider(), alignment: .top)
}
+2 -1
View File
@@ -10,6 +10,7 @@ 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
@@ -36,7 +37,7 @@ struct MediaMessageView: View {
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 4) {
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme))
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
+4 -3
View File
@@ -8,6 +8,7 @@ struct VoiceNoteView: View {
private let onCancel: (() -> Void)?
@Environment(\.colorScheme) private var colorScheme
@ThemedPalette private var palette
@StateObject private var playback: VoiceNotePlaybackController
@State private var waveform: [Float] = []
@@ -31,7 +32,7 @@ struct VoiceNoteView: View {
}
private var borderColor: Color {
colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2)
colorScheme == .dark ? palette.accent.opacity(0.3) : palette.accent.opacity(0.2)
}
private var playbackLabel: String {
@@ -46,7 +47,7 @@ struct VoiceNoteView: View {
Image(systemName: playback.isPlaying ? "pause.fill" : "play.fill")
.foregroundColor(.white)
.frame(width: 36, height: 36)
.background(Circle().fill(Color.green))
.background(Circle().fill(palette.accent))
}
.buttonStyle(.plain)
@@ -61,7 +62,7 @@ struct VoiceNoteView: View {
)
Text(playbackLabel)
.font(.bitchatSystem(size: 13, design: .monospaced))
.bitchatFont(size: 13)
.foregroundColor(Color.secondary)
if let onCancel = onCancel, isSending {
+3 -2
View File
@@ -6,6 +6,7 @@ struct WaveformView: View {
let sendProgress: Double?
let onSeek: ((Double) -> Void)?
let isInteractive: Bool
@ThemedPalette private var palette
private var clampedPlayback: Double {
max(0, min(1, playbackProgress))
@@ -37,9 +38,9 @@ struct WaveformView: View {
let binPosition = Double(index) / Double(samples.count)
let color: Color
if binPosition <= clampedPlayback {
color = Color.green
color = palette.accent
} else if let send = clampedSend, binPosition <= send {
color = Color.blue
color = palette.accentBlue
} else {
color = Color.gray.opacity(0.35)
}
+7 -8
View File
@@ -3,8 +3,7 @@ import BitFoundation
struct MeshPeerList: View {
@EnvironmentObject private var peerListModel: PeerListModel
let textColor: Color
let secondaryTextColor: Color
@ThemedPalette private var palette
let onTapPeer: (PeerID) -> Void
let onToggleFavorite: (PeerID) -> Void
let onShowFingerprint: (PeerID) -> Void
@@ -28,8 +27,8 @@ struct MeshPeerList: View {
if peerListModel.meshRows.isEmpty {
VStack(alignment: .leading, spacing: 0) {
Text(Strings.noneNearby)
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
.padding(.horizontal)
.padding(.top, 12)
}
@@ -64,18 +63,18 @@ struct MeshPeerList: View {
// Fallback icon for others (dimmed)
Image(systemName: "person")
.font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor)
.foregroundColor(palette.secondary)
}
let (base, suffix) = peer.displayName.splitSuffix()
HStack(spacing: 0) {
Text(base)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(baseColor)
if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
Text(suffix)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(suffixColor)
}
}
@@ -123,7 +122,7 @@ struct MeshPeerList: View {
Button(action: { onToggleFavorite(peer.peerID) }) {
Image(systemName: peer.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 12))
.foregroundColor(peer.isFavorite ? .yellow : secondaryTextColor)
.foregroundColor(peer.isFavorite ? .yellow : palette.secondary)
}
.buttonStyle(.plain)
}
+2 -1
View File
@@ -21,6 +21,7 @@ struct MessageListView: View {
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@Environment(\.colorScheme) private var colorScheme
@Environment(\.appTheme) private var theme
let privatePeer: PeerID?
@Binding var isAtBottom: Bool
@@ -222,7 +223,7 @@ private extension MessageListView {
@ViewBuilder
func systemMessageRow(_ message: BitchatMessage) -> some View {
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme))
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
+13 -13
View File
@@ -21,7 +21,7 @@ struct MyQRView: View {
var body: some View {
VStack(spacing: 12) {
Text(Strings.title)
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.bitchatFont(size: 16, weight: .bold)
VStack(spacing: 10) {
QRCodeImage(data: qrString, size: 240)
@@ -29,7 +29,7 @@ struct MyQRView: View {
// Non-scrolling, fully visible URL (wraps across lines)
Text(qrString)
.font(.bitchatSystem(size: 11, design: .monospaced))
.bitchatFont(size: 11)
.textSelection(.enabled)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
@@ -69,7 +69,7 @@ struct QRCodeImage: View {
.frame(width: size, height: size)
.overlay(
Text(Strings.unavailable)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.gray)
)
}
@@ -150,7 +150,7 @@ struct QRScanView: View {
.clipShape(RoundedRectangle(cornerRadius: 8))
#else
Text(Strings.pastePrompt)
.font(.bitchatSystem(size: 14, weight: .medium, design: .monospaced))
.bitchatFont(size: 14, weight: .medium)
TextEditor(text: $input)
.frame(height: 100)
.border(Color.gray.opacity(0.4))
@@ -281,10 +281,10 @@ struct VerificationSheetView: View {
@EnvironmentObject private var verificationModel: VerificationModel
@Binding var isPresented: Bool
@State private var showingScanner = false
@Environment(\.colorScheme) var colorScheme
@ThemedPalette private var palette
private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white }
private var accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) }
private var backgroundColor: Color { palette.background }
private var accentColor: Color { palette.accent }
private var boxColor: Color { Color.gray.opacity(0.1) }
var body: some View {
@@ -292,7 +292,7 @@ struct VerificationSheetView: View {
// Top header (always at top)
HStack {
Text("verification.sheet.title")
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
.bitchatFont(size: 14, weight: .bold)
.foregroundColor(accentColor)
Spacer()
Button(action: {
@@ -316,7 +316,7 @@ struct VerificationSheetView: View {
if showingScanner {
VStack(alignment: .leading, spacing: 12) {
Text("verification.scan.prompt_friend")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.bitchatFont(size: 16, weight: .bold)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
.foregroundColor(accentColor)
@@ -350,13 +350,13 @@ struct VerificationSheetView: View {
if showingScanner {
Button(action: { showingScanner = false }) {
Label("show my qr", systemImage: "qrcode")
.font(.bitchatSystem(size: 13, design: .monospaced))
.bitchatFont(size: 13)
}
.buttonStyle(.bordered)
} else {
Button(action: { showingScanner = true }) {
Label("scan someone else's qr", systemImage: "camera.viewfinder")
.font(.bitchatSystem(size: 13, weight: .medium, design: .monospaced))
.bitchatFont(size: 13, weight: .medium)
}
.buttonStyle(.bordered)
.tint(.gray)
@@ -367,7 +367,7 @@ struct VerificationSheetView: View {
verificationModel.isVerified(peerID: peerID) {
Button(action: { verificationModel.unverifyFingerprint(for: peerID) }) {
Label("remove verification", systemImage: "minus.circle")
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
}
.buttonStyle(.bordered)
.tint(.gray)
@@ -376,7 +376,7 @@ struct VerificationSheetView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.background(backgroundColor)
.themedSheetBackground()
.onDisappear { showingScanner = false }
}
}
+1 -18
View File
@@ -142,9 +142,6 @@ private struct ContentPeopleSheetHarness: View {
isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: .black,
textColor: .green,
secondaryTextColor: .gray,
headerHeight: 44,
onSendMessage: {},
showImagePicker: $showImagePicker,
@@ -163,9 +160,6 @@ private struct ContentPeopleSheetHarness: View {
isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: .black,
textColor: .green,
secondaryTextColor: .gray,
headerHeight: 44,
onSendMessage: {},
showMacImagePicker: $showMacImagePicker
@@ -335,8 +329,6 @@ struct ViewSmokeTests {
_ = mount(
MeshPeerList(
textColor: .green,
secondaryTextColor: .gray,
onTapPeer: { _ in },
onToggleFavorite: { _ in },
onShowFingerprint: { _ in }
@@ -344,8 +336,6 @@ struct ViewSmokeTests {
.environmentObject(featureModels.peerListModel)
)
_ = MeshPeerList(
textColor: .green,
secondaryTextColor: .gray,
onTapPeer: { _ in },
onToggleFavorite: { _ in },
onShowFingerprint: { _ in }
@@ -363,8 +353,6 @@ struct ViewSmokeTests {
_ = mount(
MeshPeerList(
textColor: .green,
secondaryTextColor: .gray,
onTapPeer: { _ in },
onToggleFavorite: { _ in },
onShowFingerprint: { _ in }
@@ -389,10 +377,7 @@ struct ViewSmokeTests {
messageText: Binding(
get: { messageText },
set: { messageText = $0 }
),
textColor: .green,
backgroundColor: .black,
secondaryTextColor: .gray
)
)
.environmentObject(featureModels.privateConversationModel)
.environmentObject(featureModels.locationChannelsModel)
@@ -539,8 +524,6 @@ struct ViewSmokeTests {
let (viewModel, _, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
let geohashPeopleList = GeohashPeopleList(
textColor: .green,
secondaryTextColor: .gray,
onTapPerson: {}
)
let truncatableMessage = BitchatMessage(
@@ -34,12 +34,12 @@ public final class BitchatMessage: Codable {
// Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:]
public func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
return _cachedFormattedText["\(isDark)-\(isSelf)"]
public func getCachedFormattedText(isDark: Bool, isSelf: Bool, variant: String = "") -> AttributedString? {
return _cachedFormattedText["\(variant)\(isDark)-\(isSelf)"]
}
public func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
public func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool, variant: String = "") {
_cachedFormattedText["\(variant)\(isDark)-\(isSelf)"] = text
}
// Codable implementation