ios: refresh the chat list on foreground and on incoming pushes (#126)
The native thread list only fetched /api/threads on first appear or pull-to-refresh, and creating a thread sends no push (only messages do) — so a chat someone started with you never showed up until a manual refresh or a relaunch. Re-fetch the active server's list when the app returns to the foreground, and live when a push lands for a chat other than the one on-screen. Both are guarded against stacking on an in-flight load (the cold-launch task, a pull-to-refresh, or an overlapping trigger).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 files changed,
+25
−0
ios/LChat/LChatApp.swift+15 −0ios/LChat/PushManager.swift+10 −0
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift| index 634ce00..9893675 100644 |
| --- a/ios/LChat/LChatApp.swift |
| +++ b/ios/LChat/LChatApp.swift |
| @@ -71,6 +71,7 @@ final class NavModel: ObservableObject { |
| struct RootView: View { |
| @EnvironmentObject var app: AppState |
| @EnvironmentObject private var push: PushManager |
| + @Environment(\.scenePhase) private var scenePhase |
| @StateObject private var nav = NavModel() |
| @State private var path: [String] = [] // pushed thread ids |
| @State private var showServers = false |
| @@ -132,6 +133,20 @@ struct RootView: View { |
| // 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() } |
| + // Coming back to the app re-fetches the active server's chats, so a chat |
| + // started with us while we were away (thread creation sends no push) is |
| + // there when we look. The `.task(id:)` in ThreadListView only fires on first |
| + // appear / server switch, so foregrounding otherwise never refreshed. |
| + .onChange(of: scenePhase) { _, phase in refreshInbox(when: phase == .active) } |
| + // A push landing while we're open (see PushManager.inboxPing) refreshes live. |
| + .onChange(of: push.inboxPing) { _, _ in refreshInbox(when: true) } |
| + } |
| + |
| + // Re-fetch the active server's thread list, unless one is already in flight (the |
| + // cold-launch `.task` load, a pull-to-refresh, or an overlapping trigger). |
| + private func refreshInbox(when: Bool) { |
| + guard when, let store = nav.store, !store.loading else { return } |
| + Task { await store.refresh() } |
| } |
| // A tapped notification lands in its chat: the shared key store knows which |
diff --git a/ios/LChat/PushManager.swift b/ios/LChat/PushManager.swift| index 0e652f7..02352ac 100644 |
| --- a/ios/LChat/PushManager.swift |
| +++ b/ios/LChat/PushManager.swift |
| @@ -21,6 +21,12 @@ final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUs |
| // appear, so the tap routes whether it arrives before or after the view is up. |
| @Published var pendingThreadID: String? |
| + // Bumped whenever a push lands while the app is open, for a thread other than |
| + // the one on-screen. RootView watches it and re-fetches the active thread list, |
| + // so a chat someone *just* started with us (or a message in another chat) shows |
| + // up live instead of only after a pull-to-refresh or relaunch. |
| + @Published var inboxPing = 0 |
| + |
| // 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. |
| @@ -96,6 +102,10 @@ final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUs |
| completionHandler([]) |
| return |
| } |
| + // A push for a chat we're not reading — the list may be stale (a brand-new |
| + // chat isn't in it at all). Nudge RootView to re-fetch. (UN callbacks run on |
| + // the main thread, so touching @Published here is safe.) |
| + inboxPing &+= 1 |
| completionHandler([.banner, .sound]) |
| } |