Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .forgeos/intent/pp-4542-f012-support-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
name: pp-4542-f012-support-fallback
created: 2026-06-08
author: claude-opus-4-8
---

## Summary

PP-4542 / F-012: `TPPSettingsView.supportSection` only renders a
`Section(header: "Support")` when `RemoteFeatureFlags.shared.isTriageBotEnabled`
is true. The production Firebase default for `triage_bot_enabled` is FALSE, so
in production the Support section never appears and users have no app-level
support path. Regression for 3.2.0.

Add an `else` branch so the Support section ALWAYS renders: when the triage bot
is OFF, show a legacy "Report an Issue" row that opens the legacy email
problem-report flow.

## Claims

- Adds a pure, testable decision type `SupportSectionDecision` with a static
`decide(...)` that maps `(isTriageBotEnabled, currentAccount/supportEmail)` to
`.triageBot` or `.legacyEmail(address:)`.
- Adds an `else` branch to `supportSection` so that when the bot is OFF the
Support section STILL renders with a legacy "Report an Issue" row that opens
`ProblemReportEmail.sharedInstance.beginComposing(...)`.
- Bot-OFF email resolution: current account's `supportEmail` if present, else
general fallback `support@thepalaceproject.org`. Empty/nil also falls back.
- Reuses the existing localized string `Strings.Settings.reportIssue`.

## Anti-claims

- Does NOT change the bot-ON path (the "Get Help" -> TriageBotSupportView row);
it is byte-for-byte identical.
- Does NOT introduce new user-facing copy (reuses `reportIssue`).
- Does NOT change `ProblemReportEmail`, `RemoteFeatureFlags`, or `FirebaseManager`.

## Files in scope

- Palace/Settings/NewSettings/TPPSettingsView.swift (add seam type + else branch
+ presentLegacyReportIssue / topViewController helpers)
- PalaceTests/Settings/SupportSectionDecisionTests.swift (new tests)
- Palace.xcodeproj/project.pbxproj (register the new test file)
4 changes: 4 additions & 0 deletions Palace.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
13A7D5809E780B24295767D7 /* NowPlayingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB9B49899F1C10F43D4FAAD8 /* NowPlayingCoordinator.swift */; };
13EE87F3A07444C3B7C1507A /* RetryClassificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3FF91DFB962417EA742080A /* RetryClassificationTests.swift */; };
140A6A27A650D380233EB679 /* TokenRefreshInterceptorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D96122327A49810CC4ACD7CB /* TokenRefreshInterceptorTests.swift */; };
141494C5B2EF5102AE3CDC4E /* SupportSectionDecisionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B9A4E1085F59AB6E4E0AD4C /* SupportSectionDecisionTests.swift */; };
14342D20DADCD5B6AA7ECC97 /* DownloadAnnouncementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D85D6FFA9CEBA4295D91F15 /* DownloadAnnouncementService.swift */; };
1441E97FEA8E24E5676576B5 /* UIButton+NYPLAppearanceAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68831365CC1FF46BE9A9C2C9 /* UIButton+NYPLAppearanceAdditions.swift */; };
145798F6215BE9E300F68AFD /* ProblemReportEmail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145798F5215BE9E300F68AFD /* ProblemReportEmail.swift */; };
Expand Down Expand Up @@ -2004,6 +2005,7 @@
09CAC3762A717783DDE755DD /* AudiobookSessionManagerShutdownTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudiobookSessionManagerShutdownTests.swift; sourceTree = "<group>"; };
09CF38F572AE4A88961FCA46 /* AudiobookIssueFixTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookIssueFixTests.swift; sourceTree = "<group>"; };
0ABB253AB7CB4183943EBA6A /* AccountStateStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountStateStore.swift; sourceTree = "<group>"; };
0B9A4E1085F59AB6E4E0AD4C /* SupportSectionDecisionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SupportSectionDecisionTests.swift; sourceTree = "<group>"; };
0C33E4BDBC27454AB30FAF96 /* AlertModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertModelTests.swift; sourceTree = "<group>"; };
0D1DDCD0CAAF267ED41E69DB /* LCPPassphraseReadinessTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LCPPassphraseReadinessTests.swift; sourceTree = "<group>"; };
0D614C1396A316960F405535 /* IsReaderActiveTrackingModifierTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IsReaderActiveTrackingModifierTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -6269,6 +6271,7 @@
78529336066D2340C7B0FCE0 /* DownloadOnlyOnWiFiTests.swift */,
B2D4C9FAC4974D7D0F087712 /* LibrariesSectionViewModelTests.swift */,
DE5EC7969F38A050E5947FA5 /* DeveloperSettingsTierTests.swift */,
0B9A4E1085F59AB6E4E0AD4C /* SupportSectionDecisionTests.swift */,
);
path = Settings;
sourceTree = "<group>";
Expand Down Expand Up @@ -7354,6 +7357,7 @@
32910EA3300D7728B6FF2268 /* ScopedResetTests.swift in Sources */,
151C578EEB3CBF8B804952F6 /* DeveloperSettingsTierTests.swift in Sources */,
6D575E8878F3E8BC9FA43DE3 /* ImageCacheContinuationTests.swift in Sources */,
141494C5B2EF5102AE3CDC4E /* SupportSectionDecisionTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
92 changes: 90 additions & 2 deletions Palace/Settings/NewSettings/TPPSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -269,17 +269,59 @@ struct TPPSettingsView: View {
// dependency; the effective value still folds in DEBUG-default-on
// and Firebase via `isTriageBotEnabled`.
let _ = triageBotLocalOverride
if RemoteFeatureFlags.shared.isTriageBotEnabled {
Section(header: Text("Support")) {
// PP-4542 / F-012: the Support section must ALWAYS render. When the
// triage bot is off (production Firebase default), fall back to the
// legacy email report path so support stays reachable.
let decision = SupportSectionDecision.decide(
isTriageBotEnabled: RemoteFeatureFlags.shared.isTriageBotEnabled,
currentAccount: AppContainer.production().accountsManager.currentAccount
)
Section(header: Text("Support")) {
switch decision {
case .triageBot:
let chat = TriageBotSupportView()
let wrapper = chat.anyView()
row(title: "Get Help", index: 10, selection: self.$selectedView, destination: wrapper)
.accessibilityIdentifier("settings.row.getHelp")
.accessibilityLabel("Get Help — chat with our support bot")
case .legacyEmail(let address):
Button {
presentLegacyReportIssue(to: address)
} label: {
Text(DisplayStrings.reportIssue)
.palaceFont(.body)
}
.buttonStyle(.plain)
.accessibilityIdentifier("settings.row.reportIssue")
.accessibilityLabel(DisplayStrings.reportIssue)
}
}
}

/// Opens the legacy problem-report email composer. `beginComposing`
/// already handles the no-mail-configured case with an alert, so the row
/// is always safe to offer. Mirrors `AccountDetailView.handleReportIssue`.
private func presentLegacyReportIssue(to address: String) {
guard let topVC = topViewController() else { return }
ProblemReportEmail.sharedInstance.beginComposing(
to: address,
presentingViewController: topVC,
book: nil as TPPBook?,
libraryUUID: AppContainer.production().accountsManager.currentAccount?.uuid
)
}

private func topViewController() -> UIViewController? {
guard let root = UIApplication.shared.mainKeyWindow?.rootViewController else {
return nil
}
var current = root
while let presented = current.presentedViewController {
current = presented
}
return current
}

@ViewBuilder private var infoSection: some View {
let view: AnyView = showDeveloperSettings ? EmptyView().anyView() : versionInfo.anyView()
Section(header: Text(Strings.Settings.aboutSectionHeader), footer: view) {
Expand Down Expand Up @@ -392,6 +434,52 @@ struct TPPSettingsView: View {
}
}

// MARK: - SupportSectionDecision

/// Pure decision for what the Settings "Support" section should present.
///
/// PP-4542 / F-012: the section must NEVER be empty. When the triage bot is
/// enabled it routes to the chat surface; otherwise it routes to the legacy
/// email problem-report flow with a guaranteed-non-empty address (the current
/// library's support email, or the general Palace fallback).
enum SupportSectionDecision: Equatable {
case triageBot
case legacyEmail(address: String)

/// General fallback when no library-specific support email is available.
static let generalFallbackEmail = "support@thepalaceproject.org"

/// The email the view should hand to `beginComposing(to:)`, or `nil` for
/// the triage-bot path.
var emailAddress: String? {
switch self {
case .triageBot: return nil
case .legacyEmail(let address): return address
}
}

/// Core decision. Operates on the already-resolved support-email string so
/// the branch + fallback logic is fixture-free testable.
static func decide(isTriageBotEnabled: Bool, supportEmail: String?) -> SupportSectionDecision {
if isTriageBotEnabled {
return .triageBot
}
if let email = supportEmail, !email.isEmpty {
return .legacyEmail(address: email)
}
return .legacyEmail(address: generalFallbackEmail)
}

/// Convenience used by the view: resolves the current account's support
/// email before deciding.
static func decide(isTriageBotEnabled: Bool, currentAccount: Account?) -> SupportSectionDecision {
decide(
isTriageBotEnabled: isTriageBotEnabled,
supportEmail: currentAccount?.supportEmail?.rawValue
)
}
}

// MARK: - LibraryRowView

/// One row in the inline MY LIBRARIES section. Renders the library logo,
Expand Down
73 changes: 73 additions & 0 deletions PalaceTests/Settings/SupportSectionDecisionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//
// SupportSectionDecisionTests.swift
// PalaceTests
//
// PP-4542 / F-012: when the triage bot is disabled (production Firebase
// default), the Settings Support section must still appear with a legacy
// email report path. These tests pin the pure decision that the view body
// renders from, so the fallback can't silently disappear again.
//

import XCTest
@testable import Palace

final class SupportSectionDecisionTests: XCTestCase {

private let generalFallback = "support@thepalaceproject.org"

// MARK: bot ON

func testDecide_botEnabled_choosesTriageBot_regardlessOfEmail() {
// With email present.
XCTAssertEqual(
SupportSectionDecision.decide(isTriageBotEnabled: true, supportEmail: "library@example.org"),
.triageBot
)
// And with no email — the bot path never falls back to email.
XCTAssertEqual(
SupportSectionDecision.decide(isTriageBotEnabled: true, supportEmail: nil),
.triageBot
)
}

// MARK: bot OFF — section still present, legacy email path

func testDecide_botDisabled_withAccountEmail_usesAccountEmail() {
let decision = SupportSectionDecision.decide(
isTriageBotEnabled: false,
supportEmail: "library@example.org"
)
XCTAssertEqual(decision, .legacyEmail(address: "library@example.org"))
}

/// The critical regression case: bot OFF + the current library exposes no
/// support email. Support MUST still be reachable via the general fallback,
/// never a no-op / missing section.
func testDecide_botDisabled_withoutAccountEmail_usesGeneralFallback() {
let decision = SupportSectionDecision.decide(
isTriageBotEnabled: false,
supportEmail: nil
)
XCTAssertEqual(decision, .legacyEmail(address: generalFallback))
}

/// Empty-string email is treated as "no usable address" and must fall back,
/// not produce a `.legacyEmail(address: "")` that would compose to nobody.
func testDecide_botDisabled_withEmptyEmail_usesGeneralFallback() {
let decision = SupportSectionDecision.decide(
isTriageBotEnabled: false,
supportEmail: ""
)
XCTAssertEqual(decision, .legacyEmail(address: generalFallback))
}

// MARK: address accessor used by the view to drive beginComposing(to:)

func testEmailAddress_isNilForTriageBot_andResolvedForLegacy() {
XCTAssertNil(SupportSectionDecision.triageBot.emailAddress)
XCTAssertEqual(
SupportSectionDecision.legacyEmail(address: "a@b.org").emailAddress,
"a@b.org"
)
}
}
Loading