mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 19:25:23 +00:00
The realistic compromise for many of the people this app is built for is not interception but a phone taken, and often unlocked under coercion. Content encryption is in good shape; these are the gaps around it. Hide notification previews, by default. Notification content is rendered by the system on the lock screen, so it was readable without unlocking: DM alerts carried the sender's nickname and the full message body, and geohash alerts put the geohash in the title. Alerts now state that a DM, mention, or location-channel activity arrived and withhold the rest until the app is opened. userInfo still carries the routing peer ID and deep link, neither of which the system displays, so taps land where they did. A settings toggle restores full previews for anyone who wants them. Default-on is the deliberate part: a phone face-up on a table should not narrate conversations, and someone who wants previews can say so. Cover the window on willResignActive, so the snapshot iOS stores for the app switcher shows a placeholder rather than an open conversation. Opaque rather than blurred, because blurred large text stays partly legible and the snapshot goes to disk. Added synchronously from a UIKit notification with queue: nil, since the capture follows shortly after and an OperationQueue hop or a SwiftUI state change can lose that race. Panic wipe already deleted snapshots already on disk; this stops new ones from being worth deleting. Bound media by age as well as size. The 100 MB quota only ever considered incoming files, so outgoing media had no lifetime at all and a received photo could outlive its conversation indefinitely. A launch-time sweep now deletes managed media older than seven days, incoming and outgoing, with the same exemptions quota eviction honors: in-flight live captures and files reserved by a delivery or deletion in progress. Make /clear tell the truth on the mesh timeline. It recorded an echo watermark and left the gossip archive on disk for up to 6 hours, so someone who cleared before a police stop had deleted nothing. Clearing now erases the archive too. The watermark still matters: it suppresses pre-clear messages this device hears again from peers. The cost is that the device stops serving recent public backlog until it hears fresh traffic, which is a fair reading of what clearing a timeline means. Documented in PRIVACY_POLICY.md and the privacy assessment, including a new section on what is deliberately NOT addressed: there is still no duress mechanism of any kind (no decoy passphrase, no wipe-on-failed-auth, no app lock), macOS gets no file-protection classes, and media is not sealed at the app layer. The duress question is a product decision as much as an engineering one, since in some jurisdictions destroying data on demand is itself an offence and hiding may protect someone better than destroying, so it is called out rather than guessed at. Three findings from the audit that prompted this work turned out to be already fixed on main and are not included: keychain accessibility is AfterFirstUnlockThisDeviceOnly with a retrying migration, the panic media wipe uses a two-location durable marker transaction, and panic already discards staged share-extension content. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
794 lines
36 KiB
Swift
794 lines
36 KiB
Swift
import SwiftUI
|
|
|
|
/// The sheet behind the "bitchat/" logo: a segmented Settings/Info surface.
|
|
/// Settings gathers every user preference (appearance, voice, connectivity
|
|
/// toggles, panic wipe); Info keeps the about content (how-to, features,
|
|
/// privacy, symbols legend).
|
|
struct AppInfoView: View {
|
|
@Environment(\.dismiss) var dismiss
|
|
@ThemedPalette private var palette
|
|
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
|
|
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
|
@ObservedObject private var bridgeService = BridgeService.shared
|
|
|
|
/// Supplies the mesh topology map data. Nil (previews, missing wiring)
|
|
/// hides the topology row entirely.
|
|
var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)?
|
|
/// Wipes all local data. Nil (previews, missing wiring) hides the danger
|
|
/// zone entirely.
|
|
var onPanicWipe: (@MainActor () -> Void)?
|
|
|
|
@State private var showTopology = false
|
|
@State private var liveVoiceEnabled = PTTSettings.liveVoiceEnabled
|
|
@State private var locationNotesEnabled = LocationNotesSettings.enabled
|
|
@State private var hideMessagePreviews = NotificationPrivacySettings.hideMessagePreviews
|
|
@ObservedObject private var locationManager = LocationChannelManager.shared
|
|
/// Sticky across opens: first-ever open lands on Info (the gentler
|
|
/// introduction), and afterwards the sheet reopens wherever it was left.
|
|
@AppStorage("appInfo.selectedPane") private var selectedPane: Pane = .info
|
|
@State private var showPanicConfirmation = false
|
|
@AppStorage(AppLanguageSettings.overrideKey) private var languageOverride = ""
|
|
/// The override changed this session; localization resolves at process
|
|
/// start, so surface the restart hint.
|
|
@State private var showLanguageRestartNote = false
|
|
|
|
private enum Pane: String {
|
|
case settings
|
|
case info
|
|
}
|
|
|
|
private var selectedTheme: AppTheme {
|
|
AppTheme(rawValue: appThemeRawValue) ?? .matrix
|
|
}
|
|
|
|
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"
|
|
|
|
/// New keys carry their English copy inline (defaultValue) until the
|
|
/// i18n pass lands them in the catalog; moved keys keep their homes.
|
|
enum Settings {
|
|
static let tabPickerLabel = String(localized: "app_info.tab.picker_label", defaultValue: "view", comment: "Accessibility label for the segmented control switching between the settings and info panes of the app info sheet")
|
|
static let tabSettings = String(localized: "app_info.tab.settings", defaultValue: "settings", comment: "Segmented control label for the settings pane of the app info sheet")
|
|
static let tabInfo = String(localized: "app_info.tab.info", defaultValue: "info", comment: "Segmented control label for the info pane of the app info sheet")
|
|
|
|
static let connectivityTitle = String(localized: "app_info.settings.connectivity.title", defaultValue: "CONNECTIVITY", comment: "Section header (uppercase) for the connectivity toggles: mesh bridge, internet gateway, tor routing")
|
|
|
|
static let languageTitle = String(localized: "app_info.settings.language.title", defaultValue: "LANGUAGE", comment: "Section header (uppercase) for the app language picker in settings")
|
|
static let languagePickerLabel = String(localized: "app_info.settings.language.picker_label", defaultValue: "app language", comment: "Label of the app language picker row in settings")
|
|
static let languageSystem = String(localized: "app_info.settings.language.system", defaultValue: "system default", comment: "Menu option that clears the in-app language override so the app follows the device language")
|
|
static let languageRestartNote = String(localized: "app_info.settings.language.restart_note", defaultValue: "restart bitchat to apply the new language", comment: "Caption shown after the user picks a different app language; the change takes effect on next launch")
|
|
|
|
static let bridgeTitle = String(localized: "app_info.settings.bridge.title", defaultValue: "mesh bridge", comment: "Title of the mesh bridge toggle in settings")
|
|
static let bridgeSubtitle = String(localized: "app_info.settings.bridge.subtitle", defaultValue: "joins nearby mesh islands over the internet: what you say in the mesh channel also reaches people in your area beyond radio range, and their messages appear here marked with the network glyph. while you have internet, your device also carries bridge and location-channel traffic for phones around you that have none.", comment: "Subtitle explaining what the mesh bridge toggle does")
|
|
static func bridgeCell(_ cell: String) -> String {
|
|
String(
|
|
format: String(localized: "app_info.settings.bridge.cell", defaultValue: "rendezvous cell: %@", comment: "Caption under the mesh bridge toggle showing the geohash cell the bridge is meeting on"),
|
|
locale: .current,
|
|
cell
|
|
)
|
|
}
|
|
static let bridgeNoCell = String(localized: "app_info.settings.bridge.no_cell", defaultValue: "no rendezvous cell yet — needs location access or a nearby bridge peer", comment: "Caption under the mesh bridge toggle when the bridge is on but has no geohash cell to meet on")
|
|
|
|
// Moved from LocationChannelsSheet; keys unchanged. (The former
|
|
// internet-gateway toggle is gone: the bridge switch drives all
|
|
// internet sharing, including geohash-channel gatewaying.)
|
|
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
|
|
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
|
|
static let toggleOn: LocalizedStringKey = "common.toggle.on"
|
|
static let toggleOff: LocalizedStringKey = "common.toggle.off"
|
|
|
|
static let privacyTitle = String(localized: "app_info.settings.privacy.title", defaultValue: "PRIVACY", comment: "Section header (uppercase) for privacy settings such as hiding notification previews")
|
|
static let hidePreviewsTitle = String(localized: "app_info.settings.hide_previews.title", defaultValue: "hide message previews", comment: "Title of the setting that keeps message text, sender names, and geohashes out of lock-screen notifications")
|
|
static let hidePreviewsSubtitle = String(localized: "app_info.settings.hide_previews.subtitle", defaultValue: "notifications say that something arrived without showing the message, who sent it, or which location channel it came from. anyone holding your locked phone learns nothing from the lock screen. on by default.", comment: "Subtitle explaining what hiding notification message previews does")
|
|
|
|
static let dangerTitle = String(localized: "app_info.settings.danger.title", defaultValue: "DANGER ZONE", comment: "Section header (uppercase) for destructive actions in settings")
|
|
static let panicButton = String(localized: "app_info.settings.danger.panic_button", defaultValue: "panic wipe", comment: "Button in the settings danger zone that erases all local data after confirmation")
|
|
static let panicNote = String(localized: "app_info.settings.danger.panic_note", defaultValue: "erases all messages, keys, and identity. triple-tapping the bitchat/ logo does the same, instantly.", comment: "Caption under the panic wipe button explaining what it does and the triple-tap shortcut")
|
|
static let panicConfirmTitle = String(localized: "app_info.settings.danger.panic_confirm_title", defaultValue: "wipe all data?", comment: "Title of the confirmation dialog before a panic wipe")
|
|
static let panicConfirmAction = String(localized: "app_info.settings.danger.panic_confirm_action", defaultValue: "wipe everything", comment: "Destructive confirmation button that performs the panic wipe")
|
|
}
|
|
|
|
enum Features {
|
|
static let title: LocalizedStringKey = "app_info.features.title"
|
|
static let offlineComm = AppInfoFeatureInfo(
|
|
icon: "wifi.slash",
|
|
title: "app_info.features.offline.title",
|
|
description: "app_info.features.offline.description"
|
|
)
|
|
static let encryption = AppInfoFeatureInfo(
|
|
icon: "lock.shield",
|
|
title: "app_info.features.encryption.title",
|
|
description: "app_info.features.encryption.description"
|
|
)
|
|
static let extendedRange = AppInfoFeatureInfo(
|
|
icon: "antenna.radiowaves.left.and.right",
|
|
title: "app_info.features.extended_range.title",
|
|
description: "app_info.features.extended_range.description"
|
|
)
|
|
static let mentions = AppInfoFeatureInfo(
|
|
icon: "at",
|
|
title: "app_info.features.mentions.title",
|
|
description: "app_info.features.mentions.description"
|
|
)
|
|
static let favorites = AppInfoFeatureInfo(
|
|
icon: "star.fill",
|
|
title: "app_info.features.favorites.title",
|
|
description: "app_info.features.favorites.description"
|
|
)
|
|
static let geohash = AppInfoFeatureInfo(
|
|
icon: "number",
|
|
title: "app_info.features.geohash.title",
|
|
description: "app_info.features.geohash.description"
|
|
)
|
|
static let bridge = AppInfoFeatureInfo(
|
|
icon: "network",
|
|
resolvedTitle: String(localized: "app_info.features.bridge.title", defaultValue: "mesh bridging", comment: "Feature row title for the mesh bridge in the app info sheet"),
|
|
resolvedDescription: String(localized: "app_info.features.bridge.description", defaultValue: "links nearby mesh islands through the internet so one crowd isn't split by radio range", comment: "Feature row description for the mesh bridge in the app info sheet")
|
|
)
|
|
}
|
|
|
|
enum Legend {
|
|
static let title: LocalizedStringKey = "app_info.legend.title"
|
|
/// Every glyph the peer lists and headers use, in one place —
|
|
/// nothing else in the app defines them. A nil color renders in
|
|
/// the theme's primary text color.
|
|
static let items: [(icon: String, color: Color?, text: String)] = [
|
|
("antenna.radiowaves.left.and.right", nil, String(localized: "app_info.legend.mesh_connected")),
|
|
("point.3.filled.connected.trianglepath.dotted", nil, String(localized: "app_info.legend.mesh_relayed")),
|
|
("globe", nil, String(localized: "app_info.legend.nostr")),
|
|
("network", Color.cyan, String(localized: "app_info.legend.bridged", defaultValue: "message arrived across a mesh bridge", comment: "Symbols legend entry for the cyan network glyph shown on messages carried across a mesh bridge")),
|
|
("person", nil, String(localized: "app_info.legend.offline")),
|
|
("mappin.and.ellipse", nil, String(localized: "app_info.legend.location_nearby")),
|
|
("face.dashed", nil, String(localized: "app_info.legend.teleported")),
|
|
("lock.fill", nil, String(localized: "app_info.legend.encrypted")),
|
|
("lock.slash", nil, String(localized: "app_info.legend.encryption_failed")),
|
|
("checkmark.seal.fill", nil, String(localized: "app_info.legend.verified")),
|
|
("star.fill", nil, String(localized: "app_info.legend.favorite")),
|
|
("envelope.fill", nil, String(localized: "app_info.legend.unread")),
|
|
("nosign", nil, String(localized: "app_info.legend.blocked"))
|
|
]
|
|
}
|
|
|
|
enum Voice {
|
|
static let title: LocalizedStringKey = "app_info.voice.title"
|
|
// The live-voice title/description keys are referenced inline at
|
|
// the toggle (they ride the shared settingToggle now).
|
|
}
|
|
|
|
enum Location {
|
|
static let notes = AppInfoFeatureInfo(
|
|
icon: "mappin.and.ellipse",
|
|
title: "app_info.location.notes.title",
|
|
description: "app_info.location.notes.description"
|
|
)
|
|
}
|
|
|
|
enum Network {
|
|
static let title: LocalizedStringKey = "app_info.network.title"
|
|
static let topology = AppInfoFeatureInfo(
|
|
icon: "point.3.connected.trianglepath.dotted",
|
|
title: "app_info.network.topology.title",
|
|
description: "app_info.network.topology.description"
|
|
)
|
|
}
|
|
|
|
enum Privacy {
|
|
static let title: LocalizedStringKey = "app_info.privacy.title"
|
|
static let noTracking = AppInfoFeatureInfo(
|
|
icon: "eye.slash",
|
|
title: "app_info.privacy.no_tracking.title",
|
|
description: "app_info.privacy.no_tracking.description"
|
|
)
|
|
static let ephemeral = AppInfoFeatureInfo(
|
|
icon: "shuffle",
|
|
title: "app_info.privacy.ephemeral.title",
|
|
description: "app_info.privacy.ephemeral.description"
|
|
)
|
|
static let panic = AppInfoFeatureInfo(
|
|
icon: "hand.raised.fill",
|
|
title: "app_info.privacy.panic.title",
|
|
description: "app_info.privacy.panic.description"
|
|
)
|
|
}
|
|
|
|
enum HowToUse {
|
|
static let title: LocalizedStringKey = "app_info.how_to_use.title"
|
|
/// The instruction strings flowed into one comma-separated
|
|
/// paragraph. The translations carry their legacy bullet-list
|
|
/// prefix ("• "), so it is stripped here.
|
|
static var paragraph: String {
|
|
[
|
|
String(localized: "app_info.how_to_use.set_nickname"),
|
|
String(localized: "app_info.how_to_use.change_channels"),
|
|
String(localized: "app_info.how_to_use.open_sidebar"),
|
|
String(localized: "app_info.how_to_use.start_dm"),
|
|
String(localized: "app_info.how_to_use.clear_chat"),
|
|
String(localized: "app_info.how_to_use.commands")
|
|
]
|
|
.map { $0.hasPrefix("• ") ? String($0.dropFirst(2)) : $0 }
|
|
.joined(separator: ", ")
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
var body: some View {
|
|
#if os(macOS)
|
|
VStack(spacing: 0) {
|
|
// Custom header for macOS
|
|
HStack {
|
|
Spacer()
|
|
Button("app_info.done") {
|
|
dismiss()
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundColor(textColor)
|
|
.padding()
|
|
}
|
|
.themedSurface(opacity: 0.95)
|
|
|
|
VStack(spacing: 0) {
|
|
panePicker
|
|
|
|
ScrollView {
|
|
paneContent
|
|
}
|
|
}
|
|
.themedSheetBackground()
|
|
}
|
|
.frame(width: 600, height: 700)
|
|
.sheet(isPresented: $showTopology) {
|
|
if let topologyProvider {
|
|
MeshTopologyView(provider: topologyProvider)
|
|
}
|
|
}
|
|
#else
|
|
NavigationView {
|
|
VStack(spacing: 0) {
|
|
panePicker
|
|
|
|
ScrollView {
|
|
paneContent
|
|
}
|
|
}
|
|
.themedSheetBackground()
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
SheetCloseButton { dismiss() }
|
|
.foregroundColor(textColor)
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showTopology) {
|
|
if let topologyProvider {
|
|
MeshTopologyView(provider: topologyProvider)
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Pane switching
|
|
|
|
private var panePicker: some View {
|
|
Picker(Strings.Settings.tabPickerLabel, selection: $selectedPane) {
|
|
Text(Strings.Settings.tabInfo).tag(Pane.info)
|
|
Text(Strings.Settings.tabSettings).tag(Pane.settings)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
.padding(.horizontal)
|
|
.padding(.top, 12)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var paneContent: some View {
|
|
switch selectedPane {
|
|
case .settings:
|
|
settingsContent
|
|
case .info:
|
|
infoContent
|
|
}
|
|
}
|
|
|
|
// MARK: - Settings pane
|
|
|
|
@ViewBuilder
|
|
private var settingsContent: some View {
|
|
VStack(alignment: .leading, spacing: 24) {
|
|
// 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 : [])
|
|
}
|
|
}
|
|
|
|
// Language — an in-app override so the UI language can differ
|
|
// from the device language (takes effect on next launch).
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
SectionHeader(verbatim: Strings.Settings.languageTitle)
|
|
|
|
settingsCard {
|
|
Menu {
|
|
Button {
|
|
selectLanguage(nil)
|
|
} label: {
|
|
menuItemLabel(Strings.Settings.languageSystem, isSelected: languageOverride.isEmpty)
|
|
}
|
|
Divider()
|
|
ForEach(AppLanguageSettings.availableLanguages, id: \.self) { code in
|
|
Button {
|
|
selectLanguage(code)
|
|
} label: {
|
|
menuItemLabel(AppLanguageSettings.endonym(for: code), isSelected: languageOverride == code)
|
|
}
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Text(Strings.Settings.languagePickerLabel)
|
|
.bitchatFont(size: 12, weight: .semibold)
|
|
.foregroundColor(textColor)
|
|
Spacer()
|
|
Text(languageOverride.isEmpty ? Strings.Settings.languageSystem : AppLanguageSettings.endonym(for: languageOverride))
|
|
.bitchatFont(size: 12)
|
|
.foregroundColor(palette.accent)
|
|
Image(systemName: "chevron.up.chevron.down")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(secondaryTextColor)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if showLanguageRestartNote {
|
|
Text(Strings.Settings.languageRestartNote)
|
|
.bitchatFont(size: 11)
|
|
.foregroundColor(secondaryTextColor)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Voice — same card + IRC pill as every other toggle setting.
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
SectionHeader(Strings.Voice.title)
|
|
|
|
settingsCard {
|
|
settingToggle(
|
|
title: Text("app_info.voice.live.title"),
|
|
subtitle: Text("app_info.voice.live.description"),
|
|
isOn: Binding(
|
|
get: { liveVoiceEnabled },
|
|
set: { newValue in
|
|
liveVoiceEnabled = newValue
|
|
PTTSettings.liveVoiceEnabled = newValue
|
|
}
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
// Connectivity: mesh bridge, internet gateway, tor routing
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
SectionHeader(verbatim: Strings.Settings.connectivityTitle)
|
|
|
|
settingsCard {
|
|
settingToggle(
|
|
title: Text(Strings.Settings.bridgeTitle),
|
|
subtitle: Text(Strings.Settings.bridgeSubtitle),
|
|
isOn: bridgeToggleBinding
|
|
)
|
|
// Where the bridge meets: the geohash rendezvous cell, or
|
|
// a hint about why there isn't one yet (no location and no
|
|
// bridge peer advertising a cell).
|
|
if bridgeService.isEnabled {
|
|
Text(bridgeService.activeCell.map(Strings.Settings.bridgeCell) ?? Strings.Settings.bridgeNoCell)
|
|
.bitchatFont(size: 11)
|
|
.foregroundColor(secondaryTextColor)
|
|
}
|
|
}
|
|
|
|
settingsCard {
|
|
settingToggle(
|
|
title: Text(Strings.Settings.torTitle),
|
|
subtitle: Text(Strings.Settings.torSubtitle),
|
|
isOn: torToggleBinding
|
|
)
|
|
}
|
|
|
|
// Location notes / dead drops (merged from main's flat
|
|
// layout into the shared card + pill style). Turning it on
|
|
// may need the location prompt; the permission control below
|
|
// covers the denied path.
|
|
settingsCard {
|
|
settingToggle(
|
|
title: Strings.Location.notes.title,
|
|
subtitle: Strings.Location.notes.description,
|
|
isOn: Binding(
|
|
get: { locationNotesEnabled },
|
|
set: { newValue in
|
|
locationNotesEnabled = newValue
|
|
LocationNotesSettings.enabled = newValue
|
|
if newValue {
|
|
locationManager.enableLocationChannels()
|
|
}
|
|
}
|
|
)
|
|
)
|
|
}
|
|
|
|
// Location powers the channels list and the bridge cell, so
|
|
// its control lives with the other connectivity settings.
|
|
// Platform reality shapes the three states: the app may only
|
|
// prompt while never-asked; granted/denied both flip in the
|
|
// system permission screen.
|
|
switch locationChannelsModel.permissionState {
|
|
case .authorized:
|
|
Button(action: SystemSettings.location.open) {
|
|
Text("location_channels.action.remove_access")
|
|
.bitchatFont(size: 12)
|
|
.foregroundColor(palette.alertRed)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 6)
|
|
.background(Color.red.opacity(0.08))
|
|
.cornerRadius(6)
|
|
}
|
|
.buttonStyle(.plain)
|
|
case .notDetermined:
|
|
Button(action: { locationChannelsModel.enableLocationChannels() }) {
|
|
Text("location_channels.action.request_permissions")
|
|
.bitchatFont(size: 12)
|
|
.foregroundColor(palette.accent)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 6)
|
|
.background(palette.accent.opacity(0.12))
|
|
.cornerRadius(6)
|
|
}
|
|
.buttonStyle(.plain)
|
|
case .denied, .restricted:
|
|
settingsCard {
|
|
Text("location_channels.permission_denied")
|
|
.bitchatFont(size: 11)
|
|
.foregroundColor(secondaryTextColor)
|
|
Button("location_channels.action.open_settings", action: SystemSettings.location.open)
|
|
.buttonStyle(.plain)
|
|
.bitchatFont(size: 12)
|
|
.foregroundColor(palette.accent)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Privacy: what a locked, seized, or borrowed phone gives away
|
|
// without being unlocked.
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
SectionHeader(verbatim: Strings.Settings.privacyTitle)
|
|
|
|
settingsCard {
|
|
settingToggle(
|
|
title: Text(verbatim: Strings.Settings.hidePreviewsTitle),
|
|
subtitle: Text(verbatim: Strings.Settings.hidePreviewsSubtitle),
|
|
isOn: Binding(
|
|
get: { hideMessagePreviews },
|
|
set: { newValue in
|
|
hideMessagePreviews = newValue
|
|
NotificationPrivacySettings.hideMessagePreviews = newValue
|
|
}
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
// Danger zone
|
|
if onPanicWipe != nil {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
SectionHeader(verbatim: Strings.Settings.dangerTitle)
|
|
|
|
Button(action: { showPanicConfirmation = true }) {
|
|
Text(Strings.Settings.panicButton)
|
|
.bitchatFont(size: 12)
|
|
.foregroundColor(palette.alertRed)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 6)
|
|
.background(Color.red.opacity(0.08))
|
|
.cornerRadius(6)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.confirmationDialog(
|
|
Strings.Settings.panicConfirmTitle,
|
|
isPresented: $showPanicConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(Strings.Settings.panicConfirmAction, role: .destructive) {
|
|
onPanicWipe?()
|
|
}
|
|
Button("common.cancel", role: .cancel) {}
|
|
}
|
|
|
|
Text(Strings.Settings.panicNote)
|
|
.bitchatFont(size: 11)
|
|
.foregroundColor(secondaryTextColor)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
|
|
private func selectLanguage(_ code: String?) {
|
|
let previous = languageOverride
|
|
AppLanguageSettings.setOverride(code)
|
|
languageOverride = code ?? ""
|
|
if languageOverride != previous {
|
|
showLanguageRestartNote = true
|
|
}
|
|
}
|
|
|
|
private func menuItemLabel(_ title: String, isSelected: Bool) -> some View {
|
|
HStack {
|
|
Text(title)
|
|
if isSelected {
|
|
Image(systemName: "checkmark")
|
|
}
|
|
}
|
|
}
|
|
|
|
private var bridgeToggleBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { bridgeService.isEnabled },
|
|
set: { bridgeService.setEnabled($0) }
|
|
)
|
|
}
|
|
|
|
private var torToggleBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { locationChannelsModel.userTorEnabled },
|
|
set: { locationChannelsModel.setUserTorEnabled($0) }
|
|
)
|
|
}
|
|
|
|
/// The padded card every connectivity setting sits in (moved look from
|
|
/// LocationChannelsSheet's toggle sections).
|
|
private func settingsCard<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
|
|
VStack(alignment: .leading, spacing: 8, content: content)
|
|
.padding(12)
|
|
.background(palette.secondary.opacity(0.12))
|
|
.cornerRadius(8)
|
|
}
|
|
|
|
/// A title+subtitle row driving an IRC-style on/off pill — the one
|
|
/// toggle style every setting uses.
|
|
private func settingToggle(title: Text, subtitle: Text, isOn: Binding<Bool>) -> some View {
|
|
Toggle(isOn: isOn) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
title
|
|
.bitchatFont(size: 12, weight: .semibold)
|
|
.foregroundColor(textColor)
|
|
subtitle
|
|
.bitchatFont(size: 11)
|
|
.foregroundColor(secondaryTextColor)
|
|
}
|
|
}
|
|
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.Settings.toggleOn, offLabel: Strings.Settings.toggleOff))
|
|
}
|
|
|
|
// MARK: - Info pane
|
|
|
|
@ViewBuilder
|
|
private var infoContent: some View {
|
|
VStack(alignment: .leading, spacing: 24) {
|
|
// Header
|
|
VStack(alignment: .center, spacing: 8) {
|
|
Text(Strings.appName)
|
|
.bitchatFont(size: 32, weight: .bold)
|
|
.foregroundColor(textColor)
|
|
|
|
Text(Strings.tagline)
|
|
.bitchatFont(size: 16)
|
|
.foregroundColor(secondaryTextColor)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical)
|
|
|
|
// How to Use
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
SectionHeader(Strings.HowToUse.title)
|
|
|
|
Text(verbatim: Strings.HowToUse.paragraph)
|
|
.bitchatFont(size: 14)
|
|
.foregroundColor(textColor)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
// Network diagnostics
|
|
if topologyProvider != nil {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
SectionHeader(Strings.Network.title)
|
|
|
|
Button {
|
|
showTopology = true
|
|
} label: {
|
|
HStack(spacing: 0) {
|
|
FeatureRow(info: Strings.Network.topology)
|
|
Image(systemName: "chevron.right")
|
|
.font(.bitchatSystem(size: 12))
|
|
.foregroundColor(secondaryTextColor)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityHint(Text("app_info.network.topology.hint"))
|
|
}
|
|
}
|
|
|
|
// Features
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
SectionHeader(Strings.Features.title)
|
|
|
|
FeatureRow(info: Strings.Features.offlineComm)
|
|
|
|
FeatureRow(info: Strings.Features.encryption)
|
|
|
|
FeatureRow(info: Strings.Features.extendedRange)
|
|
|
|
FeatureRow(info: Strings.Features.bridge)
|
|
|
|
FeatureRow(info: Strings.Features.favorites)
|
|
|
|
FeatureRow(info: Strings.Features.geohash)
|
|
|
|
FeatureRow(info: Strings.Features.mentions)
|
|
}
|
|
|
|
// Privacy
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
SectionHeader(Strings.Privacy.title)
|
|
|
|
FeatureRow(info: Strings.Privacy.noTracking)
|
|
|
|
FeatureRow(info: Strings.Privacy.ephemeral)
|
|
|
|
FeatureRow(info: Strings.Privacy.panic)
|
|
}
|
|
|
|
// Symbols legend
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
SectionHeader(Strings.Legend.title)
|
|
|
|
ForEach(Strings.Legend.items, id: \.icon) { item in
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: item.icon)
|
|
.font(.bitchatSystem(size: 14))
|
|
.foregroundColor(item.color ?? textColor)
|
|
.frame(width: 30)
|
|
|
|
Text(item.text)
|
|
.bitchatFont(size: 13)
|
|
.foregroundColor(secondaryTextColor)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Spacer()
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
struct AppInfoFeatureInfo {
|
|
let icon: String
|
|
let title: Text
|
|
let description: Text
|
|
|
|
/// Catalog-backed strings (existing keys).
|
|
init(icon: String, title: LocalizedStringKey, description: LocalizedStringKey) {
|
|
self.icon = icon
|
|
self.title = Text(title)
|
|
self.description = Text(description)
|
|
}
|
|
|
|
/// Pre-resolved strings — new keys that carry their English defaultValue
|
|
/// inline until the i18n pass adds them to the catalog.
|
|
init(icon: String, resolvedTitle: String, resolvedDescription: String) {
|
|
self.icon = icon
|
|
self.title = Text(resolvedTitle)
|
|
self.description = Text(resolvedDescription)
|
|
}
|
|
}
|
|
|
|
struct SectionHeader: View {
|
|
private let title: Text
|
|
@ThemedPalette private var palette
|
|
|
|
private var textColor: Color { palette.primary }
|
|
|
|
init(_ title: LocalizedStringKey) {
|
|
self.title = Text(title)
|
|
}
|
|
|
|
/// For pre-resolved strings (new keys with inline defaultValue).
|
|
init(verbatim title: String) {
|
|
self.title = Text(title)
|
|
}
|
|
|
|
var body: some View {
|
|
title
|
|
.bitchatFont(size: 16, weight: .bold)
|
|
.foregroundColor(textColor)
|
|
.padding(.top, 8)
|
|
}
|
|
}
|
|
|
|
struct FeatureRow: View {
|
|
let info: AppInfoFeatureInfo
|
|
@ThemedPalette private var palette
|
|
|
|
private var textColor: Color { palette.primary }
|
|
|
|
private var secondaryTextColor: Color { palette.secondary }
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: info.icon)
|
|
.font(.bitchatSystem(size: 20))
|
|
.foregroundColor(textColor)
|
|
.frame(width: 30)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
info.title
|
|
.bitchatFont(size: 14, weight: .semibold)
|
|
.foregroundColor(textColor)
|
|
|
|
info.description
|
|
.bitchatFont(size: 12)
|
|
.foregroundColor(secondaryTextColor)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview("Default") {
|
|
AppInfoView()
|
|
.environmentObject(LocationChannelsModel())
|
|
}
|
|
|
|
#Preview("Dynamic Type XXL") {
|
|
AppInfoView()
|
|
.environmentObject(LocationChannelsModel())
|
|
.environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
|
|
}
|
|
|
|
#Preview("Dynamic Type XS") {
|
|
AppInfoView()
|
|
.environmentObject(LocationChannelsModel())
|
|
.environment(\.sizeCategory, .extraSmall)
|
|
}
|