eleven / messenger Public

ios: notification taps route resiliently — never a silent strand on the chat list (#1368)

A tapped notification's route hung entirely on the parked SharedThreadKeys
entry: missing or stale (origin no longer matching a saved instance), the
guard bailed silently and the app cold-launched to the chat list with the
tap swallowed — the 'tap opens the message list and hangs' report
(2026-07-28). Reproduced in the simulator via the new unattended rig.

- NotificationRoute (LChatCore): the resolution ladder — parked key →
  per-space thread-snapshot scan → only-signed-in-space — with seams for
  unit tests (6 cases). An unresolvable multi-space tap logs why instead
  of vanishing.
- RootView.routeNotificationTap folds through the ladder; worst case now
  lands on the pushed chat screen's own hydrate/refresh ('Chat not found'
  with a working back button, verified) instead of a stranded list.
- DEBUG tap door (LCHAT_TEST_TAP_THREAD/_DELAY, the LCHAT_DEV_JOIN
  pattern): replays a tap's exact effect after launch so the cold-launch
  route runs unattended in the simulator, where no script can press a
  springboard banner.
- NotificationTapUITests: dev-server rig — sign in, visit the chat, cold
  relaunch with the tap waiting, assert the feed lands and back works.
- RowLadderTests: catch up with MDContext's mentions parameter (#1344) —
  the test target had stopped compiling.

Mac deliberately unscoped: its tap route selects an instance from the
payload directly and has no SharedThreadKeys dependency.


Claude-Session: https://claude.ai/code/session_01KSHZzDxuWsLEnoVv4XhSB1
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 7f85b9dd9db18761036d0b36670b14ae4e675115 parent f825405
6 files changed, +249 −3
  • ios/LChat/LChatApp.swift +16 −2
  • ios/LChat/PushManager.swift +16 −0
  • ios/LChatCore/Sources/LChatCore/NotificationRoute.swift +50 −0
  • ios/LChatCore/Tests/LChatCoreTests/NotificationRouteTests.swift +79 −0
  • ios/LChatFeedTests/RowLadderTests.swift +1 −1
  • ios/LChatUITests/NotificationTapUITests.swift +87 −0
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index e1684b9..0c09738 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -560,8 +560,22 @@ struct RootView: View {
private func routeNotificationTap() {
guard let threadID = push.pendingThreadID else { return }
push.pendingThreadID = nil
- guard let entry = SharedThreadKeys.lookup(threadID: threadID),
- let inst = app.instances.first(where: { $0.origin == entry.origin }) else { return }
+ // The parked SharedThreadKeys entry is the fast path; a stale or
+ // missing park falls back to the spaces' thread snapshots, then to
+ // the only signed-in space (NotificationRoute) — a tap must never
+ // silently strand the member on the chat list.
+ let inst: SavedInstance
+ switch NotificationRoute.resolve(threadID: threadID, instances: app.instances) {
+ case .parkedKey(let i), .snapshot(let i), .onlySpace(let i):
+ inst = i
+ case .unresolved:
+ // Multi-space and the thread is in none of their records — no
+ // safe guess exists. Leave the member where the launch landed
+ // them, and say why in the log (the one place field reports of
+ // "the tap did nothing" can be diagnosed from).
+ NSLog("notification tap: thread not resolvable to a space, dropped")
+ return
+ }
if nav.instanceID != inst.id {
nav.selectInstance(inst)
}
diff --git a/ios/LChat/PushManager.swift b/ios/LChat/PushManager.swift
index fa9410a..7e7f6a8 100644
--- a/ios/LChat/PushManager.swift
+++ b/ios/LChat/PushManager.swift
@@ -47,6 +47,22 @@ final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUs
BackgroundRefresh.register()
// Signposts + (consent-gated) perf recording — apps only, never the NSE.
Perf.install()
+ #if DEBUG
+ // The sim rig's notification-tap door (the LCHAT_DEV_JOIN pattern): a
+ // thread id in the environment replays a tap's exact effect — what
+ // didReceive below does — shortly after launch, so the cold-launch
+ // tap route can run unattended in the simulator, where no script can
+ // press a springboard banner.
+ if let thread = ProcessInfo.processInfo.environment["LCHAT_TEST_TAP_THREAD"],
+ !thread.isEmpty {
+ let delay = Double(ProcessInfo.processInfo
+ .environment["LCHAT_TEST_TAP_DELAY"] ?? "") ?? 0.5
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
+ Self.forceScrollThreadID = thread
+ self?.pendingThreadID = thread
+ }
+ }
+ #endif
return true
}
diff --git a/ios/LChatCore/Sources/LChatCore/NotificationRoute.swift b/ios/LChatCore/Sources/LChatCore/NotificationRoute.swift
new file mode 100644
index 0000000..09b0af1
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/NotificationRoute.swift
@@ -0,0 +1,50 @@
+import Foundation
+
+// 🤖 Resolves a tapped notification's thread id to the space that owns it.
+// The tap route used to hang everything on one source — the SharedThreadKeys
+// entry the ThreadStore folds park — and bailed SILENTLY when it was missing
+// or its origin no longer matched a saved instance (stale park, re-added
+// space, wiped keychain): the app cold-launched to the chat list and the tap
+// just… did nothing (the 2026-07-28 report). Resolution now falls back down
+// the same ladder the data actually lives in, and the caller can always tell
+// why a tap still couldn't route.
+public enum NotificationRoute {
+ /// Where a resolution came from — the caller logs it so a stranded tap in
+ /// the field names its failing rung instead of vanishing.
+ public enum Resolution {
+ case parkedKey(SavedInstance) // SharedThreadKeys entry matched
+ case snapshot(SavedInstance) // found in a space's thread snapshot
+ case onlySpace(SavedInstance) // one signed-in space — must be it
+ case unresolved
+ }
+
+ /// The production ladder. `entryOrigin` and `snapshotHasThread` are seams
+ /// so the ladder's order and edge cases stay unit-testable without a
+ /// keychain (ThreadStoreTests pattern).
+ public static func resolve(threadID: String,
+ instances: [SavedInstance],
+ entryOrigin: (String) -> String? = {
+ SharedThreadKeys.lookup(threadID: $0)?.origin
+ },
+ snapshotHasThread: (String, String) -> Bool = { origin, thread in
+ ThreadListSnapshot.load(origin: origin)?
+ .rows.contains { $0.id == thread } ?? false
+ }) -> Resolution {
+ if let origin = entryOrigin(threadID),
+ let inst = instances.first(where: { $0.origin == origin }) {
+ return .parkedKey(inst)
+ }
+ // Stale or missing park: each space's last folded thread list also
+ // knows its thread ids (and survives the park being wiped).
+ if let inst = instances.first(where: { snapshotHasThread($0.origin, threadID) }) {
+ return .snapshot(inst)
+ }
+ // One signed-in space — the thread can only be there. A genuinely new
+ // thread resolves inside the chat screen's own hydrate + refresh once
+ // pushed; that beats dropping the tap on the floor.
+ let active = instances.filter { !$0.isArchived }
+ if active.count == 1 { return .onlySpace(active[0]) }
+ if instances.count == 1 { return .onlySpace(instances[0]) }
+ return .unresolved
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/NotificationRouteTests.swift b/ios/LChatCore/Tests/LChatCoreTests/NotificationRouteTests.swift
new file mode 100644
index 0000000..5639ae5
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/NotificationRouteTests.swift
@@ -0,0 +1,79 @@
+import XCTest
+@testable import LChatCore
+
+// 🤖 The notification-tap resolution ladder (NotificationRoute): parked key →
+// snapshot scan → only-space. The silent-drop bug this pins: a stale or
+// missing SharedThreadKeys park used to strand the tap on the chat list.
+final class NotificationRouteTests: XCTestCase {
+
+ private func inst(_ origin: String, archived: Bool = false) -> SavedInstance {
+ var i = SavedInstance(origin: origin, deviceToken: "t", screenName: "s",
+ userPub: "pub", userPriv: "priv")
+ i.archived = archived
+ return i
+ }
+
+ func testParkedKeyWins() {
+ let a = inst("https://a.example"), b = inst("https://b.example")
+ let r = NotificationRoute.resolve(
+ threadID: "th1", instances: [a, b],
+ entryOrigin: { _ in "https://b.example" },
+ snapshotHasThread: { _, _ in XCTFail("snapshot consulted despite park"); return false })
+ guard case .parkedKey(let hit) = r else { return XCTFail("\(r)") }
+ XCTAssertEqual(hit.origin, b.origin)
+ }
+
+ func testStaleParkFallsBackToSnapshot() {
+ // The park points at an origin that's no longer signed in (re-added
+ // space) — the snapshot scan must still find the thread's real home.
+ let a = inst("https://a.example"), b = inst("https://b.example")
+ let r = NotificationRoute.resolve(
+ threadID: "th1", instances: [a, b],
+ entryOrigin: { _ in "https://gone.example" },
+ snapshotHasThread: { origin, thread in origin == "https://b.example" && thread == "th1" })
+ guard case .snapshot(let hit) = r else { return XCTFail("\(r)") }
+ XCTAssertEqual(hit.origin, b.origin)
+ }
+
+ func testMissingParkFallsBackToSnapshot() {
+ let a = inst("https://a.example"), b = inst("https://b.example")
+ let r = NotificationRoute.resolve(
+ threadID: "th1", instances: [a, b],
+ entryOrigin: { _ in nil },
+ snapshotHasThread: { origin, _ in origin == "https://a.example" })
+ guard case .snapshot(let hit) = r else { return XCTFail("\(r)") }
+ XCTAssertEqual(hit.origin, a.origin)
+ }
+
+ func testSingleSpaceAlwaysResolves() {
+ // Nothing recorded anywhere (wiped keychain, brand-new thread): with
+ // one signed-in space the tap still routes there.
+ let a = inst("https://a.example")
+ let r = NotificationRoute.resolve(
+ threadID: "new-thread", instances: [a],
+ entryOrigin: { _ in nil },
+ snapshotHasThread: { _, _ in false })
+ guard case .onlySpace(let hit) = r else { return XCTFail("\(r)") }
+ XCTAssertEqual(hit.origin, a.origin)
+ }
+
+ func testSingleActiveSpaceSkipsArchived() {
+ let live = inst("https://live.example")
+ let parked = inst("https://old.example", archived: true)
+ let r = NotificationRoute.resolve(
+ threadID: "th1", instances: [live, parked],
+ entryOrigin: { _ in nil },
+ snapshotHasThread: { _, _ in false })
+ guard case .onlySpace(let hit) = r else { return XCTFail("\(r)") }
+ XCTAssertEqual(hit.origin, live.origin)
+ }
+
+ func testMultiSpaceUnknownThreadStaysUnresolved() {
+ let a = inst("https://a.example"), b = inst("https://b.example")
+ let r = NotificationRoute.resolve(
+ threadID: "th1", instances: [a, b],
+ entryOrigin: { _ in nil },
+ snapshotHasThread: { _, _ in false })
+ guard case .unresolved = r else { return XCTFail("\(r)") }
+ }
+}
diff --git a/ios/LChatFeedTests/RowLadderTests.swift b/ios/LChatFeedTests/RowLadderTests.swift
index beef59a..5610c35 100644
--- a/ios/LChatFeedTests/RowLadderTests.swift
+++ b/ios/LChatFeedTests/RowLadderTests.swift
@@ -132,7 +132,7 @@ final class RowLadderTests: XCTestCase {
blocks: MessageBubble.cachedTree(m.text),
ctx: .init(fontSize: 15,
ink: m.mine ? .white : .primary,
- mine: m.mine)))
+ mine: m.mine, mentions: nil)))
default:
inner = AnyView(Text(m.text).font(.system(size: 15)))
}
diff --git a/ios/LChatUITests/NotificationTapUITests.swift b/ios/LChatUITests/NotificationTapUITests.swift
new file mode 100644
index 0000000..e1de3f3
--- /dev/null
+++ b/ios/LChatUITests/NotificationTapUITests.swift
@@ -0,0 +1,87 @@
+import XCTest
+
+// The notification-tap route, end to end in the simulator (2026-07-28): a
+// tapped banner must land IN the chat, cold launch included. No script can
+// press a real springboard banner, so the app's DEBUG tap door
+// (LCHAT_TEST_TAP_THREAD, PushManager) replays the tap's exact effect —
+// forceScrollThreadID + pendingThreadID — a beat after launch, and this test
+// drives the two launches around it. Runs against the local dev server like
+// FeedMenuUITests: pass TEST_RUNNER_DEV_LINK and TEST_RUNNER_THREAD_ID (both
+// from .lchat-dev.json) on the xcodebuild invocation; without them it skips.
+final class NotificationTapUITests: XCTestCase {
+
+ override func setUpWithError() throws {
+ continueAfterFailure = false
+ }
+
+ func testColdLaunchNotificationTapLandsInThread() throws {
+ let env = ProcessInfo.processInfo.environment
+ guard let link = env["DEV_LINK"], !link.isEmpty,
+ let threadID = env["THREAD_ID"], !threadID.isEmpty else {
+ throw XCTSkip("Set TEST_RUNNER_DEV_LINK + TEST_RUNNER_THREAD_ID from .lchat-dev.json.")
+ }
+
+ // ---- run 1: sign in and visit the chat once ------------------------
+ // A real phone that receives pushes has been in the space: the thread
+ // list refresh parks the SharedThreadKeys entries the tap route
+ // resolves against, and opening the space stamps lastInstanceID.
+ let app = XCUIApplication()
+ app.launchEnvironment["LCHAT_DEV_JOIN"] = link
+ app.launch()
+ allowNotificationsIfAsked(app)
+
+ // The dev-join claim lands on the spaces home screen — enter the space
+ // (its row wears the dev server's host as its name).
+ let devRow = app.staticTexts["Dev chat"].firstMatch
+ if !devRow.waitForExistence(timeout: 5) {
+ let spaceRow = app.staticTexts["127.0.0.1"].firstMatch
+ if spaceRow.waitForExistence(timeout: 25) { spaceRow.tap() }
+ }
+ XCTAssertTrue(devRow.waitForExistence(timeout: 25), "Dev chat not in thread list")
+ devRow.tap()
+ let bubble = app.staticTexts
+ .containing(NSPredicate(format: "label CONTAINS %@", "seeded by lchat-dev-up"))
+ .firstMatch
+ XCTAssertTrue(bubble.waitForExistence(timeout: 20), "seeded bubble never rendered")
+ shot(app, "tap-01-visited")
+ app.terminate()
+
+ // ---- run 2: cold launch with the tap waiting -----------------------
+ // The door fires 1s after launch — didReceive's timing on a cold
+ // launch (delivered right after the first frame).
+ app.launchEnvironment["LCHAT_TEST_TAP_THREAD"] = threadID
+ app.launchEnvironment["LCHAT_TEST_TAP_DELAY"] = "1.0"
+ app.launch()
+ shot(app, "tap-02-relaunched")
+
+ // The verdict: the tapped chat's feed is on screen — and stays there.
+ let landed = bubble.waitForExistence(timeout: 20)
+ shot(app, landed ? "tap-03-landed" : "tap-03-STRANDED")
+ XCTAssertTrue(landed, "notification tap never landed in the chat")
+
+ // A wedged NavigationStack still shows the feed it dropped into —
+ // prove the UI is alive by leaving the chat and coming back.
+ let back = app.navigationBars.buttons.firstMatch
+ XCTAssertTrue(back.waitForExistence(timeout: 5), "no back button on the chat screen")
+ back.tap()
+ let row = app.staticTexts["Dev chat"].firstMatch
+ let listAlive = row.waitForExistence(timeout: 10)
+ shot(app, listAlive ? "tap-04-back-alive" : "tap-04-back-DEAD")
+ XCTAssertTrue(listAlive, "back from the tapped chat never reached the list")
+ }
+
+ private func allowNotificationsIfAsked(_ app: XCUIApplication) {
+ let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
+ let allow = springboard.alerts.buttons["Allow"].firstMatch
+ if allow.waitForExistence(timeout: 5) { allow.tap() }
+ // Whether or not the alert showed, poke the app so the run continues.
+ app.activate()
+ }
+
+ private func shot(_ app: XCUIApplication, _ name: String) {
+ let a = XCTAttachment(screenshot: app.screenshot())
+ a.name = name
+ a.lifetime = .keepAlways
+ add(a)
+ }
+}