eleven / messenger Public

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>
Paul Campbell 57cbdc6673a52b3880fb2623753f13ef831765d1 parent 74424a4
3 files changed, +38 −16
  • apns.go +7 −1
  • ios/LChat/PushManager.swift +2 −1
  • ios/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 "" }