Merge 2e872cbf1d97615e239878fa890085857586913d into 1f59e814f90c3f489f48d68262cb1bf640bf6181

This commit is contained in:
Taksh Kothari 2026-08-06 01:44:50 +00:00 committed by GitHub
commit 22bb9b5307
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 2669 additions and 4 deletions

View File

@ -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

View 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)
}
}

View File

@ -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()

View File

@ -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)

View File

@ -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: "@")

View 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)
}
}