server+ios: APNs fan-out logging; fix notification-tap race showing "Chat not found" (#71)
The tap deep-link pushed the chat before the freshly-created ThreadStore had loaded, so the destination resolved against an empty list and bailed. The destination now observes the store and waits (spinner) while it loads, keeping "not found" for genuinely unknown threads. Also: apns[msg] summary logging (tokens / after exclusions / sent) mirroring web push — the silent fan-out made this morning's key corruption undiagnosable — and the temporary registration debug prints removed.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+38
−16
apns.go+7 −1ios/LChat/PushManager.swift+2 −1ios/LChat/Views/ThreadListView.swift+29 −14
diff --git a/apns.go b/apns.go| index 4fd0001..21b9d2e 100644 |
| --- a/apns.go |
| +++ b/apns.go |
| @@ -217,7 +217,8 @@ func (s *Server) apnsFanOut(threadID, blob, exceptToken string, skipActive bool, |
| return 0 |
| } |
| tokens, err := s.db.listAPNsTokens() |
| - if err != nil || len(tokens) == 0 { |
| + if err != nil { |
| + log.Println("apns: list tokens:", err) |
| return 0 |
| } |
| senderUser := "" |
| @@ -227,6 +228,7 @@ func (s *Server) apnsFanOut(threadID, blob, exceptToken string, skipActive bool, |
| sem := make(chan struct{}, maxPushConcurrency) |
| var wg sync.WaitGroup |
| sent := 0 |
| + selected := 0 |
| var mu sync.Mutex |
| for _, t := range tokens { |
| if exceptToken != "" && t.Token == exceptToken { |
| @@ -242,6 +244,7 @@ func (s *Server) apnsFanOut(threadID, blob, exceptToken string, skipActive bool, |
| if recipients != nil && (!known || !recipients[uid]) { |
| continue |
| } |
| + selected++ |
| wg.Add(1) |
| sem <- struct{}{} |
| go func(t APNsToken) { |
| @@ -261,5 +264,8 @@ func (s *Server) apnsFanOut(threadID, blob, exceptToken string, skipActive bool, |
| }(t) |
| } |
| wg.Wait() |
| + // Mirror push.go's summary line — silence here is indistinguishable from |
| + // "no registered iPhones", which makes push failures undebuggable. |
| + log.Printf("apns[msg]: %d token(s), %d after exclusions, %d sent", len(tokens), selected, sent) |
| return sent |
| } |
diff --git a/ios/LChat/PushManager.swift b/ios/LChat/PushManager.swift| index 715bf7c..30f419f 100644 |
| --- a/ios/LChat/PushManager.swift |
| +++ b/ios/LChat/PushManager.swift |
| @@ -39,7 +39,8 @@ final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUs |
| func application(_ application: UIApplication, |
| didFailToRegisterForRemoteNotificationsWithError error: Error) { |
| - // Expected on dev builds without the push entitlement — nothing to do. |
| + // Silent on dev builds without the push entitlement; real failures are |
| + // visible as a missing /api/apns/subscribe in the server's router log. |
| } |
| /// Push the stored APNs token to an instance that was just added (the |
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift| index 704ba25..6519416 100644 |
| --- a/ios/LChat/Views/ThreadListView.swift |
| +++ b/ios/LChat/Views/ThreadListView.swift |
| @@ -53,20 +53,7 @@ struct ThreadListView: View { |
| NewChatView(store: store) { newID in path.append(newID) } |
| } |
| .navigationDestination(for: String.self) { id in |
| - if let t = store.threads.first(where: { $0.id == id }) { |
| - ChatView(instance: store.instance, thread: t, |
| - myUserId: store.myUserId, instanceKeyB64: store.instanceKey()) |
| - .id(store.instance.id + "|" + t.id) // fresh webview per thread |
| - .onAppear { |
| - store.markRead(t) |
| - PushManager.activeThreadID = t.id // suppress banners for the open chat |
| - } |
| - .onDisappear { |
| - if PushManager.activeThreadID == t.id { PushManager.activeThreadID = nil } |
| - } |
| - } else { |
| - ContentUnavailableView("Chat not found", systemImage: "bubble.left") |
| - } |
| + ThreadDestination(store: store, id: id) |
| } |
| .refreshable { await load() } |
| // Keyed on the instance: when the store swaps in place (switching server), |
| @@ -117,6 +104,34 @@ struct ThreadListView: View { |
| } |
| } |
| +// Resolves a pushed thread id against the (observed) store, tolerating the store |
| +// still loading — a notification tap routes here before the fresh ThreadStore has |
| +// fetched anything, and bailing to "not found" on that race strands the tap. |
| +struct ThreadDestination: View { |
| + @ObservedObject var store: ThreadStore |
| + let id: String |
| + |
| + var body: some View { |
| + if let t = store.threads.first(where: { $0.id == id }) { |
| + ChatView(instance: store.instance, thread: t, |
| + myUserId: store.myUserId, instanceKeyB64: store.instanceKey()) |
| + .id(store.instance.id + "|" + t.id) // fresh webview per thread |
| + .onAppear { |
| + store.markRead(t) |
| + PushManager.activeThreadID = t.id // suppress banners for the open chat |
| + } |
| + .onDisappear { |
| + if PushManager.activeThreadID == t.id { PushManager.activeThreadID = nil } |
| + } |
| + } else if store.loading || store.threads.isEmpty { |
| + ProgressView() |
| + .task { await store.refresh() } // cold-launch tap: make sure a load is in flight |
| + } else { |
| + ContentUnavailableView("Chat not found", systemImage: "bubble.left") |
| + } |
| + } |
| +} |
| + |
| // A compact "last activity" label — mirrors relTime in web/static/chat.js. |
| private func relTime(_ sec: Int64) -> String { |
| guard sec > 0 else { return "" } |