Address Jack review: one three-way logo panic mode (#1575).

Fold confirm (#1509) and disable into PanicWipeSettings.LogoShortcutMode
(instant / confirm / off). AppChromeModel.requestPanicWipe is the single
choke point; turning the gesture off asks for confirmation and a disabled
triple-tap shows an alert instead of a silent no-op.
This commit is contained in:
Taksh 2026-07-31 18:06:53 +03:00
parent ad7f4b27f2
commit 0f2bbac9ce
6 changed files with 2035 additions and 51 deletions

View File

@ -20,6 +20,9 @@ final class AppChromeModel: ObservableObject {
@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
@ -113,13 +116,16 @@ final class AppChromeModel: ObservableObject {
prepareForPanic = preparation
}
/// Triple-tap entry point. Instant by default (seizure path); confirms
/// only when `PanicWipeSettings.confirmLogoShortcut` is on.
/// 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() {
if PanicWipeSettings.confirmLogoShortcut {
showPanicConfirmation = true
} else {
switch PanicWipeSettings.logoShortcutMode {
case .instant:
panicClearAllData()
case .confirm:
showPanicConfirmation = true
case .off:
showPanicGestureDisabledAlert = true
}
}
@ -132,6 +138,10 @@ final class AppChromeModel: ObservableObject {
showPanicConfirmation = false
}
func acknowledgePanicGestureDisabled() {
showPanicGestureDisabledAlert = false
}
func panicClearAllData() {
prepareForPanic?()
onPanicWipe()

File diff suppressed because it is too large Load Diff

View File

@ -8,33 +8,59 @@
import Foundation
/// Controls whether the undiscoverable `bitchat/` logo triple-tap asks before
/// wiping.
/// 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; that trade-off costs the seconds the
/// feature is built around.
/// 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 confirmLogoShortcutKey = "panic.confirmLogoShortcut"
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"
/// When `true`, triple-tapping the logo shows the same confirmation dialog
/// as the settings panic button. Defaults to `false` (instant wipe).
static var confirmLogoShortcut: Bool {
get { confirmLogoShortcut(in: .standard) }
set { setConfirmLogoShortcut(newValue, in: .standard) }
enum LogoShortcutMode: String, CaseIterable, Identifiable {
case instant
case confirm
case off
var id: String { rawValue }
}
static func confirmLogoShortcut(in defaults: UserDefaults) -> Bool {
defaults.object(forKey: confirmLogoShortcutKey) as? Bool ?? false
static var logoShortcutMode: LogoShortcutMode {
get { logoShortcutMode(in: .standard) }
set { setLogoShortcutMode(newValue, in: .standard) }
}
static func setConfirmLogoShortcut(_ confirm: Bool, in defaults: UserDefaults) {
defaults.set(confirm, forKey: confirmLogoShortcutKey)
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
}
/// Panic-wipe hook. Removing the key restores the instant default.
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: confirmLogoShortcutKey)
defaults.removeObject(forKey: modeKey)
defaults.removeObject(forKey: legacyConfirmKey)
defaults.removeObject(forKey: legacyEnabledKey)
}
}

View File

@ -22,7 +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 confirmLogoPanic = PanicWipeSettings.confirmLogoShortcut
@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?
@ -121,10 +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 when logo confirmation is off (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 confirm-before-logo-wipe is on")
static let confirmLogoTitle = String(localized: "app_info.settings.danger.confirm_logo.title", defaultValue: "confirm before logo wipe", comment: "Title of the setting that requires confirmation before the triple-tap logo panic wipe")
static let confirmLogoSubtitle = String(localized: "app_info.settings.danger.confirm_logo.subtitle", defaultValue: "off by default: triple-tap wipes instantly so it still works if someone grabs the phone. turn on only if accidental taps worry you more than losing those seconds.", comment: "Subtitle explaining the seizure-path trade-off of confirming before the logo panic wipe")
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")
}
@ -573,23 +581,47 @@ struct AppInfoView: View {
Button("common.cancel", role: .cancel) {}
}
Text(confirmLogoPanic ? Strings.Settings.panicNoteConfirm : Strings.Settings.panicNote)
Text(logoShortcutPanicNote)
.bitchatFont(size: 11)
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
settingsCard {
settingToggle(
title: Text(verbatim: Strings.Settings.confirmLogoTitle),
subtitle: Text(verbatim: Strings.Settings.confirmLogoSubtitle),
isOn: Binding(
get: { confirmLogoPanic },
set: { newValue in
confirmLogoPanic = newValue
PanicWipeSettings.confirmLogoShortcut = newValue
}
)
)
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
}
}
}
}
@ -597,6 +629,29 @@ struct AppInfoView: View {
.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)

View File

@ -59,7 +59,7 @@ struct ContentHeaderView: View {
// 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 and
// wipes instantly unless confirm-before-logo-wipe is on.)
// 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")
@ -82,6 +82,24 @@ struct ContentHeaderView: View {
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: "@")

View File

@ -3,25 +3,49 @@ import Foundation
@testable import bitchat
struct PanicWipeSettingsTests {
private func isolatedDefaults() -> UserDefaults {
UserDefaults(suiteName: "PanicWipeSettingsTests.\(UUID().uuidString)")!
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 defaults = isolatedDefaults()
#expect(!PanicWipeSettings.confirmLogoShortcut(in: defaults))
let (suite, defaults) = isolatedDefaults()
defer { cleanup(suite) }
#expect(PanicWipeSettings.logoShortcutMode(in: defaults) == .instant)
}
@Test func persistsOptInConfirmation() {
let defaults = isolatedDefaults()
PanicWipeSettings.setConfirmLogoShortcut(true, in: defaults)
#expect(PanicWipeSettings.confirmLogoShortcut(in: defaults))
@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 defaults = isolatedDefaults()
PanicWipeSettings.setConfirmLogoShortcut(true, in: defaults)
let (suite, defaults) = isolatedDefaults()
defer { cleanup(suite) }
PanicWipeSettings.setLogoShortcutMode(.off, in: defaults)
PanicWipeSettings.reset(in: defaults)
#expect(!PanicWipeSettings.confirmLogoShortcut(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)
}
}