eleven / messenger Public

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>
Paul Campbell d6dc59e9ee8f94184fd4b93da360bb375abbb835 parent af46956
8 files changed, +86 −28
  • ios/LChat/Info.plist +4 −2
  • ios/LChat/LChatApp.swift +21 −12
  • ios/LChat/PushManager.swift +18 −2
  • ios/LChat/Views/ChatView.swift +18 −5
  • ios/LChatMac/Info.plist +2 −2
  • ios/LChatNSE/Info.plist +2 −2
  • ios/project.yml +15 −1
  • web/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);
},
};
}