From 9c01cd4fa12fd885b4404fafaf525bd283788860 Mon Sep 17 00:00:00 2001 From: Rod Bahmanyari Date: Wed, 22 Jul 2026 16:02:00 -0700 Subject: [PATCH 1/2] Header: add a gear button that opens settings, on the settings pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings were reachable only by tapping the "bitchat/" wordmark, which reads as static text — new users have no way to find it. Add a gearshape button at the right end of the header icon cluster that opens the App Info sheet directly on its settings pane: the sheet's pane memory is sticky and defaults to info, so a control labeled "settings" would otherwise land first-time users on the info tab. The logo tap stays as a shortcut and keeps the sticky last-used pane, and the deliberately undiscoverable triple-tap panic wipe is unchanged. The label/tooltip ships as a new content.header.settings key seeded with copies of the catalog's existing "settings" translations, so it is localized in all 30 supported languages from the start. Co-Authored-By: Claude Fable 5 --- bitchat/App/AppChromeModel.swift | 8 +++++- bitchat/Localizable.xcstrings | 36 +++++++++++++++++++++++++++ bitchat/Views/AppInfoView.swift | 14 +++++++++-- bitchat/Views/ContentHeaderView.swift | 27 +++++++++++++++++--- 4 files changed, 79 insertions(+), 6 deletions(-) diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index 51cadfb0..e37615b1 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -80,7 +80,13 @@ final class AppChromeModel: ObservableObject { showingFingerprintFor = nil } - func presentAppInfo() { + /// Presents the App Info sheet. Pass a pane when the launching control + /// promises one (the header gear is labeled "settings"); nil keeps the + /// sheet's own sticky last-used pane. + func presentAppInfo(pane: AppInfoView.Pane? = nil) { + if let pane { + AppInfoView.setSelectedPane(pane) + } isAppInfoPresented = true } diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index e498a20e..ae8335a5 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -56139,6 +56139,42 @@ } } }, + "content.header.settings" : { + "comment" : "Accessibility label and tooltip for the header gear button that opens the settings/app info sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "الإعدادات" } }, + "bn" : { "stringUnit" : { "state" : "translated", "value" : "সেটিংস" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "einstellungen" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "settings" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "ajustes" } }, + "fa" : { "stringUnit" : { "state" : "translated", "value" : "تنظیمات" } }, + "fil" : { "stringUnit" : { "state" : "translated", "value" : "settings" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "réglages" } }, + "he" : { "stringUnit" : { "state" : "translated", "value" : "הגדרות" } }, + "hi" : { "stringUnit" : { "state" : "translated", "value" : "सेटिंग्स" } }, + "id" : { "stringUnit" : { "state" : "translated", "value" : "pengaturan" } }, + "it" : { "stringUnit" : { "state" : "translated", "value" : "impostazioni" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "設定" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "설정" } }, + "ms" : { "stringUnit" : { "state" : "translated", "value" : "tetapan" } }, + "ne" : { "stringUnit" : { "state" : "translated", "value" : "सेटिङ" } }, + "nl" : { "stringUnit" : { "state" : "translated", "value" : "instellingen" } }, + "pl" : { "stringUnit" : { "state" : "translated", "value" : "ustawienia" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "definições" } }, + "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "ajustes" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "настройки" } }, + "sv" : { "stringUnit" : { "state" : "translated", "value" : "inställningar" } }, + "ta" : { "stringUnit" : { "state" : "translated", "value" : "அமைப்புகள்" } }, + "th" : { "stringUnit" : { "state" : "translated", "value" : "ตั้งค่า" } }, + "tr" : { "stringUnit" : { "state" : "translated", "value" : "ayarlar" } }, + "uk" : { "stringUnit" : { "state" : "translated", "value" : "налаштування" } }, + "ur" : { "stringUnit" : { "state" : "translated", "value" : "سیٹنگز" } }, + "vi" : { "stringUnit" : { "state" : "translated", "value" : "cài đặt" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "设置" } }, + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "設定" } } + } + }, "content.help.verification" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index cb99fbac..7a56bd53 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -28,18 +28,28 @@ struct AppInfoView: View { @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 + @AppStorage(AppInfoView.selectedPaneStorageKey) 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 { + enum Pane: String { case settings case info } + private static let selectedPaneStorageKey = "appInfo.selectedPane" + + /// Overwrites the remembered pane so the next presentation opens there. + /// `selectedPane` is sticky by design; a caller whose control names a + /// specific pane (the header gear says "settings") uses this so the + /// sheet doesn't land first-time users on the Info default instead. + static func setSelectedPane(_ pane: Pane) { + UserDefaults.standard.set(pane.rawValue, forKey: selectedPaneStorageKey) + } + private var selectedTheme: AppTheme { AppTheme(rawValue: appThemeRawValue) ?? .matrix } diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index f857ba38..1e465be7 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -65,9 +65,10 @@ struct ContentHeaderView: View { // (next to the failed-wipe banner), not on this Text: a host // that can be covered or removed could take the pending // dialog down with it. - // 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.) + // The gear in the icon cluster is the discoverable entry + // point to App Info; the logo tap stays as a shortcut but + // reads as static text, so surface it. (The triple-tap panic + // wipe stays undiscoverable on purpose — it's destructive.) .accessibilityAddTraits(.isButton) .accessibilityHint( String(localized: "content.accessibility.app_info_hint", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info") @@ -309,6 +310,26 @@ struct ContentHeaderView: View { ? String(localized: "content.accessibility.peers_connected", comment: "Accessibility value when peers are reachable") : String(localized: "content.accessibility.peers_none", comment: "Accessibility value when no peers are reachable")) ) + + // Settings entry point new users can actually find; the + // logo tap remains as a shortcut for those who know it. + // Opens on the settings pane explicitly: the sheet's pane + // memory defaults to info, and a button labeled "settings" + // must not land there. + Button(action: { appChromeModel.presentAppInfo(pane: .settings) }) { + Image(systemName: "gearshape") + .font(.bitchatSystem(size: 12)) + .foregroundColor(palette.secondary.opacity(0.9)) + .headerTapTarget() + } + .buttonStyle(.plain) + .padding(.leading, 4) + .accessibilityLabel( + String(localized: "content.header.settings", defaultValue: "settings", comment: "Accessibility label and tooltip for the header gear button that opens the settings/app info sheet") + ) + .help( + String(localized: "content.header.settings", defaultValue: "settings", comment: "Accessibility label and tooltip for the header gear button that opens the settings/app info sheet") + ) } .layoutPriority(3) .sheet(isPresented: $showVerifySheet) { From 9f2dcedb1a4764f01f969b76402dfa5ab3ebc89a Mon Sep 17 00:00:00 2001 From: Rod Bahmanyari Date: Tue, 25 Aug 2026 17:54:09 -0700 Subject: [PATCH 2/2] Review follow-ups: globe deep-link, fil translation, pane-routing test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three items from review feedback on the gear PR: - The gateway globe's accessibility hint promises "opens settings", but its action presented the sheet on the sticky pane. It now passes .settings like the gear. - fil had English "settings" marked translated (Chessing234's catch); now "mga setting", matching the catalog's own fil precedent in content.accessibility.gateway_settings_hint. Also applied to app_info.tab.settings — the label of the very tab both controls deep-link to — so fil users don't hear "mga setting" and land on a tab labeled "settings". (content.alert.bluetooth_required.settings names the iOS Settings app and is left for a separate pass.) - A regression test pins the routing contract: a promised pane overwrites the sticky appInfo.selectedPane default, a plain present never touches it, and first-open leaves the key absent so new users still land on Info. Co-Authored-By: Claude Fable 5 --- bitchat/Localizable.xcstrings | 4 +-- bitchat/Views/ContentHeaderView.swift | 2 +- bitchatTests/AppArchitectureTests.swift | 43 +++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index ae8335a5..739c9d8e 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -32410,7 +32410,7 @@ "fil" : { "stringUnit" : { "state" : "translated", - "value" : "settings" + "value" : "mga setting" } }, "fr" : { @@ -56149,7 +56149,7 @@ "en" : { "stringUnit" : { "state" : "translated", "value" : "settings" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "ajustes" } }, "fa" : { "stringUnit" : { "state" : "translated", "value" : "تنظیمات" } }, - "fil" : { "stringUnit" : { "state" : "translated", "value" : "settings" } }, + "fil" : { "stringUnit" : { "state" : "translated", "value" : "mga setting" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "réglages" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הגדרות" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "सेटिंग्स" } }, diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index 1e465be7..6988c9b3 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -129,7 +129,7 @@ struct ContentHeaderView: View { if locationChannelsModel.gatewayEnabled { // The gateway toggle lives in the App Info settings pane // now, so the indicator deep-links there. - Button(action: { appChromeModel.presentAppInfo() }) { + Button(action: { appChromeModel.presentAppInfo(pane: .settings) }) { Image(systemName: "globe") .font(.bitchatSystem(size: 12)) .foregroundColor(palette.secondary.opacity(0.8)) diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 747e737b..222794e8 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -462,6 +462,49 @@ struct AppArchitectureTests { #expect(chromeModel.showingFingerprintFor == nil) } + @Test("App Info pane routing: a promised pane overrides the sticky memory, a plain present never touches it") + @MainActor + func presentAppInfoPaneRouting() { + let viewModel = makeArchitectureViewModel() + let privateInboxModel = PrivateInboxModel(conversations: ConversationStore()) + let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel) + + // The sticky pane lives in standard defaults (@AppStorage's store); + // restore whatever was there so this test can't leak state. + let key = "appInfo.selectedPane" + let defaults = UserDefaults.standard + let original = defaults.string(forKey: key) + defer { + if let original { + defaults.set(original, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + // First-ever open: the logo tap passes no pane, and the absent key is + // what lands new users on the gentler Info default — a plain present + // must not create it. + defaults.removeObject(forKey: key) + chromeModel.presentAppInfo() + #expect(defaults.string(forKey: key) == nil) + #expect(chromeModel.isAppInfoPresented) + + // Later opens: the logo tap keeps whatever pane the sheet was left on. + chromeModel.isAppInfoPresented = false + defaults.set(AppInfoView.Pane.info.rawValue, forKey: key) + chromeModel.presentAppInfo() + #expect(defaults.string(forKey: key) == AppInfoView.Pane.info.rawValue) + #expect(chromeModel.isAppInfoPresented) + + // Controls that promise a pane (the header gear and the gateway globe + // both say "settings") must land there even against sticky memory. + chromeModel.isAppInfoPresented = false + chromeModel.presentAppInfo(pane: .settings) + #expect(defaults.string(forKey: key) == AppInfoView.Pane.settings.rawValue) + #expect(chromeModel.isAppInfoPresented) + } + @Test("Triple-tap panic entry point only raises the confirmation dialog") @MainActor func requestPanicWipeAsksBeforeDestroying() {