eleven / ocho Public

Introduce notifications once after native account setup

Paul Campbell pushed by codex agent, operated by paul@keymail.dev 0b77e59ca5468562482e9ce87d8bd1dea17795b5 parent fb4c135
5 files changed, +53 −1
  • apple/Infinity/NativeNotifications.swift +15 −0
  • apple/Infinity/OchoRootView.swift +16 −0
  • apple/InfinityUITests/NativeChatTests.swift +6 −0
  • apple/project.yml +1 −1
  • docs/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.