mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-08 06:56:10 +00:00
Merge 2e872cbf1d97615e239878fa890085857586913d into 1f59e814f90c3f489f48d68262cb1bf640bf6181
This commit is contained in:
commit
22bb9b5307
@ -18,6 +18,11 @@ final class AppChromeModel: ObservableObject {
|
||||
@Published var bluetoothAlertMessage = ""
|
||||
@Published var bluetoothState: CBManagerState = .unknown
|
||||
@Published var showScreenshotPrivacyWarning = false
|
||||
/// Confirmation gate for the triple-tap logo panic shortcut (#152).
|
||||
@Published var showPanicConfirmation = false
|
||||
/// Triple-tap while the gesture is set to off — silent no-op would be
|
||||
/// worse in a panic, so surface that nothing was wiped.
|
||||
@Published var showPanicGestureDisabledAlert = false
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let onPanicWipe: () -> Void
|
||||
@ -111,6 +116,32 @@ final class AppChromeModel: ObservableObject {
|
||||
prepareForPanic = preparation
|
||||
}
|
||||
|
||||
/// Triple-tap entry point. Mode is decided here (not at the gesture site):
|
||||
/// instant wipe by default, optional confirm, or an explicit disabled alert.
|
||||
func requestPanicWipe() {
|
||||
switch PanicWipeSettings.logoShortcutMode {
|
||||
case .instant:
|
||||
panicClearAllData()
|
||||
case .confirm:
|
||||
showPanicConfirmation = true
|
||||
case .off:
|
||||
showPanicGestureDisabledAlert = true
|
||||
}
|
||||
}
|
||||
|
||||
func confirmPanicWipe() {
|
||||
showPanicConfirmation = false
|
||||
panicClearAllData()
|
||||
}
|
||||
|
||||
func cancelPanicWipe() {
|
||||
showPanicConfirmation = false
|
||||
}
|
||||
|
||||
func acknowledgePanicGestureDisabled() {
|
||||
showPanicGestureDisabledAlert = false
|
||||
}
|
||||
|
||||
func panicClearAllData() {
|
||||
prepareForPanic?()
|
||||
onPanicWipe()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
66
bitchat/Services/PanicWipeSettings.swift
Normal file
66
bitchat/Services/PanicWipeSettings.swift
Normal file
@ -0,0 +1,66 @@
|
||||
//
|
||||
// PanicWipeSettings.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Controls how the undiscoverable `bitchat/` logo triple-tap behaves.
|
||||
///
|
||||
/// The shortcut exists for the seizure path — someone taking the phone out of
|
||||
/// your hand — so it defaults to an **instant** wipe. People who fear
|
||||
/// accidental taps can opt into a confirmation step, or turn the gesture off
|
||||
/// entirely (the settings panic button still wipes after its own confirm).
|
||||
///
|
||||
/// One type, one `reset()`, one danger-zone control — do not split this across
|
||||
/// parallel settings enums.
|
||||
enum PanicWipeSettings {
|
||||
private static let modeKey = "panic.logoShortcutMode"
|
||||
/// Legacy keys from the confirm-only (#1509) and enable-only (#1575) shapes.
|
||||
private static let legacyConfirmKey = "panic.confirmLogoShortcut"
|
||||
private static let legacyEnabledKey = "panic.logoShortcutEnabled"
|
||||
|
||||
enum LogoShortcutMode: String, CaseIterable, Identifiable {
|
||||
case instant
|
||||
case confirm
|
||||
case off
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
static var logoShortcutMode: LogoShortcutMode {
|
||||
get { logoShortcutMode(in: .standard) }
|
||||
set { setLogoShortcutMode(newValue, in: .standard) }
|
||||
}
|
||||
|
||||
static func logoShortcutMode(in defaults: UserDefaults) -> LogoShortcutMode {
|
||||
if let raw = defaults.string(forKey: modeKey),
|
||||
let mode = LogoShortcutMode(rawValue: raw) {
|
||||
return mode
|
||||
}
|
||||
// Migrate older single-boolean shapes without losing the person's choice.
|
||||
if defaults.object(forKey: legacyEnabledKey) as? Bool == false {
|
||||
return .off
|
||||
}
|
||||
if defaults.object(forKey: legacyConfirmKey) as? Bool == true {
|
||||
return .confirm
|
||||
}
|
||||
return .instant
|
||||
}
|
||||
|
||||
static func setLogoShortcutMode(_ mode: LogoShortcutMode, in defaults: UserDefaults) {
|
||||
defaults.set(mode.rawValue, forKey: modeKey)
|
||||
defaults.removeObject(forKey: legacyConfirmKey)
|
||||
defaults.removeObject(forKey: legacyEnabledKey)
|
||||
}
|
||||
|
||||
/// Panic-wipe hook. Removing the keys restores the instant default.
|
||||
static func reset(in defaults: UserDefaults = .standard) {
|
||||
defaults.removeObject(forKey: modeKey)
|
||||
defaults.removeObject(forKey: legacyConfirmKey)
|
||||
defaults.removeObject(forKey: legacyEnabledKey)
|
||||
}
|
||||
}
|
||||
@ -1637,6 +1637,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
||||
MeshSightingsTracker.shared.clear()
|
||||
MeshEchoSettings.reset()
|
||||
NotificationPrivacySettings.reset()
|
||||
PanicWipeSettings.reset()
|
||||
// A hand-added relay names an operator someone chose to route through,
|
||||
// which is the kind of trace a wipe should not leave behind.
|
||||
NostrRelaySettings.reset()
|
||||
|
||||
@ -22,6 +22,9 @@ struct AppInfoView: View {
|
||||
@State private var liveVoiceEnabled = PTTSettings.liveVoiceEnabled
|
||||
@State private var locationNotesEnabled = LocationNotesSettings.enabled
|
||||
@State private var hideMessagePreviews = NotificationPrivacySettings.hideMessagePreviews
|
||||
@State private var logoShortcutMode = PanicWipeSettings.logoShortcutMode
|
||||
@State private var pendingLogoShortcutMode: PanicWipeSettings.LogoShortcutMode?
|
||||
@State private var showDisableLogoShortcutConfirm = false
|
||||
@State private var customRelays = NostrRelaySettings.customRelays()
|
||||
@State private var relayInput = ""
|
||||
@State private var relayError: String?
|
||||
@ -120,7 +123,16 @@ struct AppInfoView: View {
|
||||
|
||||
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 panicNoteInstant = 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 when logo gesture is instant (default)")
|
||||
static let panicNoteConfirm = String(localized: "app_info.settings.danger.panic_note_confirm", defaultValue: "erases all messages, keys, and identity. triple-tapping the bitchat/ logo asks for the same confirmation.", comment: "Caption under the panic wipe button when logo gesture confirms first")
|
||||
static let panicNoteDisabled = String(localized: "app_info.settings.danger.panic_note_disabled", defaultValue: "erases all messages, keys, and identity. the bitchat/ logo triple-tap is off — only this button wipes.", comment: "Caption under the panic wipe button when logo gesture is disabled")
|
||||
static let logoModeTitle = String(localized: "app_info.settings.danger.logo_mode.title", defaultValue: "logo triple-tap wipe", comment: "Title of the setting that chooses instant / confirm / off for the logo panic gesture")
|
||||
static let logoModeSubtitle = String(localized: "app_info.settings.danger.logo_mode.subtitle", defaultValue: "instant by default for the seizure path. confirm if accidental taps worry you. off removes the gesture — turning off asks for confirmation because a disabled triple-tap is a silent failure in a panic.", comment: "Subtitle explaining the three logo panic gesture modes")
|
||||
static let logoModeInstant = String(localized: "app_info.settings.danger.logo_mode.instant", defaultValue: "instant", comment: "Logo panic mode: wipe immediately on triple-tap")
|
||||
static let logoModeConfirm = String(localized: "app_info.settings.danger.logo_mode.confirm", defaultValue: "confirm first", comment: "Logo panic mode: ask before wiping on triple-tap")
|
||||
static let logoModeOff = String(localized: "app_info.settings.danger.logo_mode.off", defaultValue: "off", comment: "Logo panic mode: disable the triple-tap gesture")
|
||||
static let disableLogoConfirmTitle = String(localized: "app_info.settings.danger.logo_disable_confirm_title", defaultValue: "turn off logo wipe?", comment: "Title asking to confirm disabling the logo triple-tap panic gesture")
|
||||
static let disableLogoConfirmAction = String(localized: "app_info.settings.danger.logo_disable_confirm_action", defaultValue: "turn off", comment: "Confirm button that disables the logo triple-tap panic gesture")
|
||||
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")
|
||||
}
|
||||
@ -569,16 +581,77 @@ struct AppInfoView: View {
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
}
|
||||
|
||||
Text(Strings.Settings.panicNote)
|
||||
Text(logoShortcutPanicNote)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
settingsCard {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(verbatim: Strings.Settings.logoModeTitle)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.primary)
|
||||
Text(verbatim: Strings.Settings.logoModeSubtitle)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Picker(
|
||||
selection: Binding(
|
||||
get: { logoShortcutMode },
|
||||
set: { requestLogoShortcutMode($0) }
|
||||
)
|
||||
) {
|
||||
Text(Strings.Settings.logoModeInstant).tag(PanicWipeSettings.LogoShortcutMode.instant)
|
||||
Text(Strings.Settings.logoModeConfirm).tag(PanicWipeSettings.LogoShortcutMode.confirm)
|
||||
Text(Strings.Settings.logoModeOff).tag(PanicWipeSettings.LogoShortcutMode.off)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
Strings.Settings.disableLogoConfirmTitle,
|
||||
isPresented: $showDisableLogoShortcutConfirm,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(Strings.Settings.disableLogoConfirmAction, role: .destructive) {
|
||||
applyLogoShortcutMode(.off)
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {
|
||||
pendingLogoShortcutMode = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var logoShortcutPanicNote: String {
|
||||
switch logoShortcutMode {
|
||||
case .instant: return Strings.Settings.panicNoteInstant
|
||||
case .confirm: return Strings.Settings.panicNoteConfirm
|
||||
case .off: return Strings.Settings.panicNoteDisabled
|
||||
}
|
||||
}
|
||||
|
||||
private func requestLogoShortcutMode(_ mode: PanicWipeSettings.LogoShortcutMode) {
|
||||
if mode == .off, logoShortcutMode != .off {
|
||||
pendingLogoShortcutMode = mode
|
||||
showDisableLogoShortcutConfirm = true
|
||||
return
|
||||
}
|
||||
applyLogoShortcutMode(mode)
|
||||
}
|
||||
|
||||
private func applyLogoShortcutMode(_ mode: PanicWipeSettings.LogoShortcutMode) {
|
||||
logoShortcutMode = mode
|
||||
PanicWipeSettings.logoShortcutMode = mode
|
||||
pendingLogoShortcutMode = nil
|
||||
}
|
||||
|
||||
private func selectLanguage(_ code: String?) {
|
||||
let previous = languageOverride
|
||||
AppLanguageSettings.setOverride(code)
|
||||
|
||||
@ -51,14 +51,15 @@ struct ContentHeaderView: View {
|
||||
// cluster at priority 3 never gives up width.
|
||||
.layoutPriority(2)
|
||||
.onTapGesture(count: 3) {
|
||||
appChromeModel.panicClearAllData()
|
||||
appChromeModel.requestPanicWipe()
|
||||
}
|
||||
.onTapGesture(count: 1) {
|
||||
appChromeModel.presentAppInfo()
|
||||
}
|
||||
// This is the only entry point to App Info, but it reads as
|
||||
// static text; surface the tap. (The triple-tap panic wipe
|
||||
// stays undiscoverable on purpose — it's destructive.)
|
||||
// stays undiscoverable on purpose — it's destructive — and
|
||||
// follows PanicWipeSettings: instant / confirm / off.)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.app_info_hint", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info")
|
||||
@ -66,6 +67,39 @@ struct ContentHeaderView: View {
|
||||
.accessibilityAction {
|
||||
appChromeModel.presentAppInfo()
|
||||
}
|
||||
.confirmationDialog(
|
||||
String(localized: "app_info.settings.danger.panic_confirm_title", defaultValue: "wipe all data?", comment: "Title of the confirmation dialog before a panic wipe"),
|
||||
isPresented: $appChromeModel.showPanicConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(
|
||||
String(localized: "app_info.settings.danger.panic_confirm_action", defaultValue: "wipe everything", comment: "Destructive confirmation button that performs the panic wipe"),
|
||||
role: .destructive
|
||||
) {
|
||||
appChromeModel.confirmPanicWipe()
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {
|
||||
appChromeModel.cancelPanicWipe()
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
String(
|
||||
localized: "app_info.settings.danger.logo_disabled_title",
|
||||
defaultValue: "logo wipe is off",
|
||||
comment: "Title of the alert when triple-tapping the logo while the gesture is disabled"
|
||||
),
|
||||
isPresented: $appChromeModel.showPanicGestureDisabledAlert
|
||||
) {
|
||||
Button("common.ok", role: .cancel) {
|
||||
appChromeModel.acknowledgePanicGestureDisabled()
|
||||
}
|
||||
} message: {
|
||||
Text(String(
|
||||
localized: "app_info.settings.danger.logo_disabled_message",
|
||||
defaultValue: "nothing was wiped. turn the logo gesture back on in settings, or use the panic wipe button there.",
|
||||
comment: "Message explaining that the logo triple-tap did nothing because it is disabled"
|
||||
))
|
||||
}
|
||||
|
||||
HStack(spacing: 0) {
|
||||
Text(verbatim: "@")
|
||||
|
||||
51
bitchatTests/Services/PanicWipeSettingsTests.swift
Normal file
51
bitchatTests/Services/PanicWipeSettingsTests.swift
Normal file
@ -0,0 +1,51 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct PanicWipeSettingsTests {
|
||||
private func isolatedDefaults() -> (suite: String, defaults: UserDefaults) {
|
||||
let suite = "PanicWipeSettingsTests.\(UUID().uuidString)"
|
||||
return (suite, UserDefaults(suiteName: suite)!)
|
||||
}
|
||||
|
||||
private func cleanup(_ suite: String) {
|
||||
UserDefaults().removePersistentDomain(forName: suite)
|
||||
}
|
||||
|
||||
@Test func defaultsToInstantWipe() {
|
||||
let (suite, defaults) = isolatedDefaults()
|
||||
defer { cleanup(suite) }
|
||||
#expect(PanicWipeSettings.logoShortcutMode(in: defaults) == .instant)
|
||||
}
|
||||
|
||||
@Test func persistsConfirmAndOffModes() {
|
||||
let (suite, defaults) = isolatedDefaults()
|
||||
defer { cleanup(suite) }
|
||||
PanicWipeSettings.setLogoShortcutMode(.confirm, in: defaults)
|
||||
#expect(PanicWipeSettings.logoShortcutMode(in: defaults) == .confirm)
|
||||
PanicWipeSettings.setLogoShortcutMode(.off, in: defaults)
|
||||
#expect(PanicWipeSettings.logoShortcutMode(in: defaults) == .off)
|
||||
}
|
||||
|
||||
@Test func resetRestoresInstantDefault() {
|
||||
let (suite, defaults) = isolatedDefaults()
|
||||
defer { cleanup(suite) }
|
||||
PanicWipeSettings.setLogoShortcutMode(.off, in: defaults)
|
||||
PanicWipeSettings.reset(in: defaults)
|
||||
#expect(PanicWipeSettings.logoShortcutMode(in: defaults) == .instant)
|
||||
}
|
||||
|
||||
@Test func migratesLegacyConfirmFlag() {
|
||||
let (suite, defaults) = isolatedDefaults()
|
||||
defer { cleanup(suite) }
|
||||
defaults.set(true, forKey: "panic.confirmLogoShortcut")
|
||||
#expect(PanicWipeSettings.logoShortcutMode(in: defaults) == .confirm)
|
||||
}
|
||||
|
||||
@Test func migratesLegacyDisabledFlag() {
|
||||
let (suite, defaults) = isolatedDefaults()
|
||||
defer { cleanup(suite) }
|
||||
defaults.set(false, forKey: "panic.logoShortcutEnabled")
|
||||
#expect(PanicWipeSettings.logoShortcutMode(in: defaults) == .off)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user