iOS notification-tap routing + scroll-to-newest (+ TestFlight release config) (#119)
* ios+web: reliable notification-tap routing + scroll-to-newest A tapped push now survives a cold launch: PushManager publishes the thread id (instead of an ephemeral NotificationCenter post that the OS can deliver before SwiftUI subscribes), and RootView drains it on first appear and on change. A notification tap also force-scrolls the feed to the newest message (didAttach(force)), so it never lands mid-history on a chat you'd scrolled up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * build: wire bundle version to build settings + declare encryption exemption xcodegen baked a literal CFBundleVersion "1", so bumping CURRENT_PROJECT_VERSION for a new TestFlight build silently did nothing. Reference the build settings from the app/NSE/Mac Info.plists (and bump to 2). Add ITSAppUsesNonExemptEncryption:false (OS-standard crypto only) so uploads skip the per-build compliance prompt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> ---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8 files changed,
+86
−28
ios/LChat/Info.plist+4 −2ios/LChat/LChatApp.swift+21 −12ios/LChat/PushManager.swift+18 −2ios/LChat/Views/ChatView.swift+18 −5ios/LChatMac/Info.plist+2 −2ios/LChatNSE/Info.plist+2 −2ios/project.yml+15 −1web/static/chat.js+6 −2
diff --git a/ios/LChat/Info.plist b/ios/LChat/Info.plist| index 2ced245..66c9150 100644 |
| --- a/ios/LChat/Info.plist |
| +++ b/ios/LChat/Info.plist |
| @@ -17,7 +17,7 @@ |
| <key>CFBundlePackageType</key> |
| <string>APPL</string> |
| <key>CFBundleShortVersionString</key> |
| - <string>1.0</string> |
| + <string>$(MARKETING_VERSION)</string> |
| <key>CFBundleURLTypes</key> |
| <array> |
| <dict> |
| @@ -30,7 +30,9 @@ |
| </dict> |
| </array> |
| <key>CFBundleVersion</key> |
| - <string>1</string> |
| + <string>$(CURRENT_PROJECT_VERSION)</string> |
| + <key>ITSAppUsesNonExemptEncryption</key> |
| + <false/> |
| <key>NSAppTransportSecurity</key> |
| <dict> |
| <key>NSAllowsLocalNetworking</key> |
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift| index 8558a0a..634ce00 100644 |
| --- a/ios/LChat/LChatApp.swift |
| +++ b/ios/LChat/LChatApp.swift |
| @@ -125,20 +125,29 @@ struct RootView: View { |
| // Someone with chats wants to hear about them (no-op without the |
| // push entitlement; asks permission the first time). |
| if !app.instances.isEmpty { push.enablePush() } |
| + // A cold-launch notification tap is already waiting here (delivered |
| + // before this view could subscribe) — route it now. |
| + routeNotificationTap() |
| } |
| - // A tapped notification lands in its chat: the shared key store knows |
| - // which instance the thread belongs to. |
| - .onReceive(NotificationCenter.default.publisher(for: PushManager.openThread)) { note in |
| - guard let threadID = note.object as? String else { return } |
| - if let entry = SharedThreadKeys.lookup(threadID: threadID), |
| - let inst = app.instances.first(where: { $0.origin == entry.origin }) { |
| - if nav.instanceID != inst.id { |
| - path = [] |
| - nav.selectInstance(inst) |
| - } |
| - if path.last != threadID { path.append(threadID) } |
| - } |
| + // A tap that arrives while we're already up: the published id changes and |
| + // this fires. (onAppear covers the cold-launch case where it was set first.) |
| + .onChange(of: push.pendingThreadID) { _, _ in routeNotificationTap() } |
| + } |
| + |
| + // A tapped notification lands in its chat: the shared key store knows which |
| + // instance the thread belongs to, so we switch servers if needed and push the |
| + // chat. Idempotent — clears the pending id so re-firing (incl. our own nil-out) |
| + // is a no-op. |
| + 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 } |
| + if nav.instanceID != inst.id { |
| + path = [] |
| + nav.selectInstance(inst) |
| } |
| + if path.last != threadID { path.append(threadID) } |
| } |
| private func select(_ inst: SavedInstance) { |
diff --git a/ios/LChat/PushManager.swift b/ios/LChat/PushManager.swift| index 30f419f..0e652f7 100644 |
| --- a/ios/LChat/PushManager.swift |
| +++ b/ios/LChat/PushManager.swift |
| @@ -12,9 +12,20 @@ import UserNotifications |
| // Note: receiving real pushes needs the aps-environment entitlement (paid team, |
| // see ios/README.md go-live notes); registration silently no-ops without it. |
| final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { |
| - static let openThread = Notification.Name("lchat.openThread") |
| private static let tokenKey = "lchat.apnsToken" // last token, for re-subscribing new instances |
| + // A tapped notification's thread id, awaiting routing to its chat. Published — |
| + // NOT an ephemeral NotificationCenter post — so a COLD-LAUNCH tap survives: the |
| + // OS can deliver the tap before SwiftUI has subscribed, which would drop a post. |
| + // RootView both reacts to changes and drains any value already waiting on first |
| + // appear, so the tap routes whether it arrives before or after the view is up. |
| + @Published var pendingThreadID: String? |
| + |
| + // A thread that should jump straight to its newest message the next time it |
| + // appears — set on a notification tap, so "you have a new message" always lands |
| + // on that message even if the parked feed had been scrolled up into history. |
| + static var forceScrollThreadID: String? |
| + |
| func application(_ application: UIApplication, |
| didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { |
| UNUserNotificationCenter.current().delegate = self |
| @@ -92,7 +103,12 @@ final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUs |
| didReceive response: UNNotificationResponse, |
| withCompletionHandler completionHandler: @escaping () -> Void) { |
| if let thread = response.notification.request.content.userInfo["thread"] as? String { |
| - NotificationCenter.default.post(name: Self.openThread, object: thread) |
| + // Tapping a notification means "show me that message": mark the thread |
| + // so its feed jumps to the newest message, then hand it to RootView to |
| + // route to the right instance + chat. (UN delegate callbacks are on the |
| + // main thread, so touching @Published / this static here is safe.) |
| + Self.forceScrollThreadID = thread |
| + pendingThreadID = thread |
| } |
| completionHandler() |
| } |
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift| index 26baf13..34af9b3 100644 |
| --- a/ios/LChat/Views/ChatView.swift |
| +++ b/ios/LChat/Views/ChatView.swift |
| @@ -104,14 +104,16 @@ struct ChatView: View { |
| .navigationTitle(isMac ? "" : thread.title) |
| .navigationBarTitleDisplayMode(.inline) |
| .onAppear { |
| - bridge.didAttach() // a cached feed may need re-pinning to its newest message |
| + bridge.didAttach(force: consumeNotificationScroll()) // a cached feed may need re-pinning to its newest message |
| if isMac { composerFocused = true } |
| } |
| // A notification tap can foreground the app with this chat already on top — |
| // no onAppear fires, but the feed was parked all the same and sits shy of |
| - // the newest message until it's re-pinned. |
| + // the newest message until it's re-pinned. Whichever of these two fires |
| + // first consumes the force-scroll marker (a notification tap wants the |
| + // newest message even if the feed had been scrolled up into history). |
| .onChange(of: scenePhase) { _, phase in |
| - if phase == .active { bridge.didAttach() } |
| + if phase == .active { bridge.didAttach(force: consumeNotificationScroll()) } |
| } |
| #if targetEnvironment(macCatalyst) |
| .background(MacWindowTitle(title: thread.title)) |
| @@ -133,6 +135,14 @@ struct ChatView: View { |
| bottom: max(0, fullFrame.maxY - safeFrame.maxY)) |
| } |
| + // True (once) if this chat was opened by a notification tap — the feed should |
| + // then jump to the newest message, overriding a parked "scrolled up" position. |
| + private func consumeNotificationScroll() -> Bool { |
| + guard PushManager.forceScrollThreadID == thread.id else { return false } |
| + PushManager.forceScrollThreadID = nil |
| + return true |
| + } |
| + |
| // Toggling a picker's isPresented binding directly inside a Menu action races with |
| // the menu's own dismissal (the picker can fail to appear on Mac Catalyst). Defer |
| // the toggle until after the menu has gone. |
| @@ -371,9 +381,12 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav |
| // one-shot scrollTop write from here got clamped by WebKit's resume-time |
| // layout) and knows whether the user had deliberately scrolled up into |
| // history (a live gap check here misreads a parked backlog as that). |
| - func didAttach() { |
| + // |
| + // `force` (a notification tap) overrides that "scrolled up" hold: the message |
| + // you were pinged about is the newest one, so jump to it regardless. |
| + func didAttach(force: Bool = false) { |
| webView?.evaluateJavaScript( |
| - "window.lchatNative && window.lchatNative.didAttach && window.lchatNative.didAttach()") |
| + "window.lchatNative && window.lchatNative.didAttach && window.lchatNative.didAttach(\(force))") |
| } |
| // Whether the web composer currently has a photo staged. Drives the native send |
diff --git a/ios/LChatMac/Info.plist b/ios/LChatMac/Info.plist| index f6bc6fc..7905eba 100644 |
| --- a/ios/LChatMac/Info.plist |
| +++ b/ios/LChatMac/Info.plist |
| @@ -17,7 +17,7 @@ |
| <key>CFBundlePackageType</key> |
| <string>APPL</string> |
| <key>CFBundleShortVersionString</key> |
| - <string>1.0</string> |
| + <string>$(MARKETING_VERSION)</string> |
| <key>CFBundleURLTypes</key> |
| <array> |
| <dict> |
| @@ -30,7 +30,7 @@ |
| </dict> |
| </array> |
| <key>CFBundleVersion</key> |
| - <string>1</string> |
| + <string>$(CURRENT_PROJECT_VERSION)</string> |
| <key>LSApplicationCategoryType</key> |
| <string>public.app-category.social-networking</string> |
| <key>NSAppTransportSecurity</key> |
diff --git a/ios/LChatNSE/Info.plist b/ios/LChatNSE/Info.plist| index 0c24d25..751bf7b 100644 |
| --- a/ios/LChatNSE/Info.plist |
| +++ b/ios/LChatNSE/Info.plist |
| @@ -17,9 +17,9 @@ |
| <key>CFBundlePackageType</key> |
| <string>XPC!</string> |
| <key>CFBundleShortVersionString</key> |
| - <string>1.0</string> |
| + <string>$(MARKETING_VERSION)</string> |
| <key>CFBundleVersion</key> |
| - <string>1</string> |
| + <string>$(CURRENT_PROJECT_VERSION)</string> |
| <key>NSExtension</key> |
| <dict> |
| <key>NSExtensionPointIdentifier</key> |
diff --git a/ios/project.yml b/ios/project.yml| index dee2119..72223ee 100644 |
| --- a/ios/project.yml |
| +++ b/ios/project.yml |
| @@ -8,7 +8,7 @@ settings: |
| base: |
| SWIFT_VERSION: "5.0" |
| MARKETING_VERSION: "1.0" |
| - CURRENT_PROJECT_VERSION: "1" |
| + CURRENT_PROJECT_VERSION: "2" |
| TARGETED_DEVICE_FAMILY: "1,2" |
| # The Eleven Messenger Apple Developer team. Overridable on the xcodebuild |
| # line for building with a personal team instead. |
| @@ -38,6 +38,14 @@ targets: |
| info: |
| path: LChat/Info.plist |
| properties: |
| + # Drive the bundle version from the build settings (single source of |
| + # truth) — xcodegen otherwise bakes a literal "1", so bumping |
| + # CURRENT_PROJECT_VERSION for a new TestFlight build had no effect. |
| + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" |
| + CFBundleShortVersionString: "$(MARKETING_VERSION)" |
| + # Only OS-standard crypto (TLS + CryptoKit/WebCrypto) — exempt, so TestFlight |
| + # builds don't stall on the per-build "Missing Compliance" prompt. |
| + ITSAppUsesNonExemptEncryption: false |
| CFBundleDisplayName: Eleven |
| NSCameraUsageDescription: Camera access is needed to scan QR codes for joining chats |
| UILaunchScreen: {} |
| @@ -82,6 +90,8 @@ targets: |
| info: |
| path: LChatMac/Info.plist |
| properties: |
| + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" |
| + CFBundleShortVersionString: "$(MARKETING_VERSION)" |
| CFBundleDisplayName: Eleven |
| CFBundleName: Eleven |
| LSApplicationCategoryType: public.app-category.social-networking |
| @@ -111,6 +121,10 @@ targets: |
| info: |
| path: LChatNSE/Info.plist |
| properties: |
| + # Must match the host app's version, or App Store validation rejects the |
| + # bundle (extension/app version mismatch). |
| + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" |
| + CFBundleShortVersionString: "$(MARKETING_VERSION)" |
| CFBundleDisplayName: Eleven Notifications |
| NSExtension: |
| NSExtensionPointIdentifier: com.apple.usernotifications.service |
diff --git a/web/static/chat.js b/web/static/chat.js| index 4166fa6..4bc17bc 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -527,9 +527,13 @@ function installNativeBridge() { |
| // shy of the newest message. Re-pin via the settle loop — a one-shot |
| // scrollTop write here gets clamped by WebKit's resume-time layout — |
| // unless the user had deliberately scrolled up into history. |
| - didAttach() { |
| + // |
| + // `force` (a notification tap) overrides that hold: the message you were |
| + // pinged about is the newest one, so jump to it even if the parked feed |
| + // had been scrolled up. |
| + didAttach(force) { |
| const feed = document.getElementById("feed"); |
| - if (feed && feedAtBottom) pinFeedToBottom(feed, openSeq); |
| + if (feed && (force || feedAtBottom)) pinFeedToBottom(feed, openSeq); |
| }, |
| }; |
| } |