Introduce notifications once after native account setup
5 files changed,
+53
−1
apple/Infinity/NativeNotifications.swift+15 −0apple/Infinity/OchoRootView.swift+16 −0apple/InfinityUITests/NativeChatTests.swift+6 −0apple/project.yml+1 −1docs/infinity/status.md+15 −0
diff --git a/apple/Infinity/NativeNotifications.swift b/apple/Infinity/NativeNotifications.swift| index ced7593..e17725b 100644 |
| --- a/apple/Infinity/NativeNotifications.swift |
| +++ b/apple/Infinity/NativeNotifications.swift |
| @@ -13,18 +13,33 @@ import AppKit |
| @Published var enabled = UserDefaults.standard.bool(forKey: "ocho.notifications.enabled") |
| @Published var status = "Notifications are off on this device." |
| @Published var busy = false |
| + @Published private(set) var shouldOfferIntroduction = false |
| + private let introductionKey = "ocho.notifications.introductionDismissed" |
| + func dismissIntroduction() { |
| + UserDefaults.standard.set(true, forKey: introductionKey) |
| + shouldOfferIntroduction = false |
| + } |
| private weak var account: AccountStore? |
| private var token: String? |
| private var registered = "" |
| private var retryNeeded = false |
| func configure(_ account: AccountStore) async { |
| self.account = account |
| + #if DEBUG |
| + if ProcessInfo.processInfo.environment["OCHO_UI_IDENTITY"] != nil, |
| + ProcessInfo.processInfo.environment["OCHO_UI_RESET_NOTIFICATION_INTRO"] == "1" { |
| + UserDefaults.standard.removeObject(forKey: introductionKey) |
| + } |
| + #endif |
| UNUserNotificationCenter.current().delegate = self |
| let settings = await UNUserNotificationCenter.current().notificationSettings() |
| + shouldOfferIntroduction = !enabled && settings.authorizationStatus == .notDetermined |
| + && !UserDefaults.standard.bool(forKey: introductionKey) |
| if enabled && (settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional) { register() } |
| else if enabled { await disable() } |
| } |
| func enable() async { |
| + dismissIntroduction() |
| do { |
| guard try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) else { |
| status = "Allow Ocho notifications in system Settings to receive alerts."; return |
diff --git a/apple/Infinity/OchoRootView.swift b/apple/Infinity/OchoRootView.swift| index beaab46..0679387 100644 |
| --- a/apple/Infinity/OchoRootView.swift |
| +++ b/apple/Infinity/OchoRootView.swift |
| @@ -3,6 +3,7 @@ import SwiftUI |
| import OchoCore |
| struct OchoRootView: View { |
| @EnvironmentObject var account: AccountStore |
| + @ObservedObject private var notifications = NativeNotifications.shared |
| @State private var sheet: Sheet? |
| @State private var shareURL: URL? |
| @State private var search = "" |
| @@ -13,6 +14,21 @@ struct OchoRootView: View { |
| else { |
| NavigationSplitView { |
| List(selection: $account.selected) { |
| + if account.hasLoadedConversations && notifications.shouldOfferIntroduction { |
| + Section { |
| + VStack(alignment: .leading, spacing: 10) { |
| + Label("Know when someone messages you", systemImage: "bell.badge").font(.headline) |
| + Text("Enable notifications to get alerts when Ocho is closed. Message contents stay private.").font(.subheadline).foregroundStyle(.secondary) |
| + HStack { |
| + Button("Enable notifications") { |
| + sheet = .notifications |
| + Task { await notifications.enable() } |
| + }.buttonStyle(.borderedProminent) |
| + Button("Not now") { notifications.dismissIntroduction() }.buttonStyle(.borderless) |
| + } |
| + }.padding(.vertical, 8).accessibilityIdentifier("notifications-introduction") |
| + } |
| + } |
| if !account.requests.isEmpty { |
| Section("Invitations") { |
| ForEach(account.requests, id: \.thread) { request in |
diff --git a/apple/InfinityUITests/NativeChatTests.swift b/apple/InfinityUITests/NativeChatTests.swift| index bd76ee1..c644454 100644 |
| --- a/apple/InfinityUITests/NativeChatTests.swift |
| +++ b/apple/InfinityUITests/NativeChatTests.swift |
| @@ -13,12 +13,16 @@ import OchoCore |
| try await b.message(thread: thread, text: "Hello from the other native client") |
| let app = XCUIApplication() |
| app.launchEnvironment["OCHO_UI_IDENTITY"] = try JSONEncoder().encode(me).base64URL |
| + app.launchEnvironment["OCHO_UI_RESET_NOTIFICATION_INTRO"] = "1" |
| app.launchEnvironment["OCHO_UI_REFRESH_DELAY"] = "15" |
| app.launchArguments += ["-AppleInterfaceStyle", "Dark"] |
| app.launch() |
| XCTAssertTrue(app.descendants(matching: .any)["conversations-loading"].firstMatch.waitForExistence(timeout: 3)) |
| XCTAssertFalse(app.staticTexts["Your people, right here"].exists) |
| XCTAssertTrue(app.staticTexts["Native UI Bob"].firstMatch.waitForExistence(timeout: 20)) |
| + XCTAssertTrue(app.buttons["Not now"].waitForExistence(timeout: 5)) |
| + app.buttons["Not now"].tap() |
| + XCTAssertFalse(app.buttons["Not now"].exists) |
| app.staticTexts["Native UI Bob"].firstMatch.tap() |
| XCTAssertTrue(app.staticTexts["Hello from the other native client"].waitForExistence(timeout: 10)) |
| let field = app.textFields["Message"].exists ? app.textFields["Message"] : app.textViews.firstMatch |
| @@ -30,9 +34,11 @@ import OchoCore |
| XCTAssertEqual(received.threads.first?.messages.last?.text, "Sent from the native SwiftUI composer") |
| XCTAssertEqual(app.webViews.count, 0) |
| app.terminate() |
| + app.launchEnvironment.removeValue(forKey: "OCHO_UI_RESET_NOTIFICATION_INTRO") |
| app.launchEnvironment["OCHO_UI_REFRESH_DELAY"] = "30" |
| app.launch() |
| XCTAssertTrue(app.staticTexts["Native UI Bob"].firstMatch.waitForExistence(timeout: 5)) |
| + XCTAssertFalse(app.buttons["Not now"].exists) |
| XCTAssertFalse(app.descendants(matching: .any)["conversations-loading"].firstMatch.exists) |
| XCTAssertFalse(app.staticTexts["Your people, right here"].exists) |
| app.staticTexts["Native UI Bob"].firstMatch.tap() |
diff --git a/apple/project.yml b/apple/project.yml| index 5fb1ca5..3f97fc8 100644 |
| --- a/apple/project.yml |
| +++ b/apple/project.yml |
| @@ -8,7 +8,7 @@ settings: |
| base: |
| SWIFT_VERSION: '5.0' |
| MARKETING_VERSION: '0.2.0' |
| - CURRENT_PROJECT_VERSION: '13' |
| + CURRENT_PROJECT_VERSION: '14' |
| GENERATE_INFOPLIST_FILE: YES |
| ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon |
| CODE_SIGN_STYLE: Automatic |
diff --git a/docs/infinity/status.md b/docs/infinity/status.md| index 585ad5a..1e7384a 100644 |
| --- a/docs/infinity/status.md |
| +++ b/docs/infinity/status.md |
| @@ -177,3 +177,18 @@ and message restoration pass in the same run. TestFlight upload succeeded at |
| 22:42 UTC. Apple reports processing; build 13 is not yet returned by its builds |
| API, so its compliance confirmation and group assignment remain pending. Build |
| 12 remains an intermediate build; use build 13 for the final visual correction. |
| + |
| +## Notification introduction (Codex) |
| + |
| +Offer a one-time notification explanation in the native chat list after account |
| +loading. Show it only when system permission is undetermined and the user has |
| +not already opted in or dismissed it. Enable opens the existing notification |
| +status sheet and requests system permission; Not now persists dismissal locally. |
| +No permission prompt runs during welcome/signup or without an explicit tap. |
| +Existing users who have never been asked receive the same single introduction. |
| + |
| +Build 14 compiles for iPhone and Mac. Actual iPhone UI regression verifies the |
| +introduction, Not now dismissal and persistence across relaunch, alongside cached |
| +conversations and sending. The full server/browser suite passes. Build 14 was |
| +installed in place on Paul's iPhone on September 14; no app data was cleared. |
| +This build has not yet been uploaded to TestFlight. |