diff --git a/Jarvis/Models/Models.swift b/Jarvis/Models/Models.swift index 0f50bd1..8b7ce32 100644 --- a/Jarvis/Models/Models.swift +++ b/Jarvis/Models/Models.swift @@ -136,6 +136,10 @@ struct PaginatedStories: Codable { let nextCursor: String? let hasMore: Bool let total: Int + let unreadCount: Int? + let readSuppressedCount: Int? + let lastReadSyncAt: Date? + let activePill: String? } // MARK: - WebSocket Events diff --git a/Jarvis/Networking/APIClient.swift b/Jarvis/Networking/APIClient.swift index 14d75f1..5f217a7 100644 --- a/Jarvis/Networking/APIClient.swift +++ b/Jarvis/Networking/APIClient.swift @@ -111,6 +111,16 @@ actor APIClient { try await delete("/stories/\(id)/read") } + func markAllRead(storyIds: [String], pill: String) async throws { + try await postVoid("/me/read/bulk", body: [ + "storyIds": storyIds, + "readAt": ISO8601DateFormatter().string(from: Date()), + "source": "mark_all_as_read", + "pill": pill, + "deviceId": deviceId, + ]) + } + // MARK: - Articles func fetchArticle(id: String) async throws -> Article { diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index 77084f6..3125587 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -25,6 +25,8 @@ struct SignalFeedView: View { /// Reference-type set so marking a story seen on scroll doesn't re-render. @State private var seenTracker = SeenTracker() @State private var showRead = false + @State private var markAllToast: String? + @State private var markAllPending = false /// Stories that have full article content cached → eligible for the green dot. private var cachedStoryIds: Set { Set(cachedArticles.map(\.storyId)) } @@ -120,7 +122,7 @@ struct SignalFeedView: View { var body: some View { NavigationStack { - ZStack { + ZStack(alignment: .bottom) { Palette.background.ignoresSafeArea() VStack(spacing: 0) { @@ -130,6 +132,25 @@ struct SignalFeedView: View { Divider().overlay(Palette.hairline) feedList } + + if let toast = markAllToast { + Text(toast) + .font(.system(size: 13, weight: .medium, design: .monospaced)) + .foregroundStyle(Color(hex: "E0E0E0")) + .padding(.horizontal, 18) + .padding(.vertical, 11) + .background(Color(hex: "1E1E1E")) + .clipShape(Capsule()) + .overlay(Capsule().stroke(Color(hex: "333333"), lineWidth: 0.5)) + .shadow(color: .black.opacity(0.4), radius: 12, y: 4) + .padding(.bottom, 24) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { + withAnimation(.easeOut(duration: 0.25)) { markAllToast = nil } + } + } + } } .navigationBarHidden(true) .navigationDestination(for: StorySummary.self) { story in @@ -174,10 +195,24 @@ struct SignalFeedView: View { .foregroundStyle(Palette.secondaryText) .frame(width: 40, height: 40) } - Button { - showSettings = true + Menu { + Button { + Task { await markAllRead() } + } label: { + Label( + markAllPending ? "Marking…" : "Mark all as read", + systemImage: markAllPending ? "hourglass" : "checkmark.circle" + ) + } + .disabled(markAllPending || mainStories.isEmpty) + Divider() + Button { + showSettings = true + } label: { + Label("Settings", systemImage: "gearshape") + } } label: { - Image(systemName: "gearshape") + Image(systemName: "ellipsis") .font(.system(size: 15, weight: .medium)) .foregroundStyle(Palette.secondaryText) .frame(width: 40, height: 40) @@ -638,6 +673,44 @@ struct SignalFeedView: View { } } + private func markAllRead() async { + let ids = mainStories.map(\.id).filter { !readStoryIds.contains($0) } + guard !ids.isEmpty else { + withAnimation { markAllToast = "No unread \(selectedPill.label) stories right now." } + return + } + markAllPending = true + + // Optimistic: insert ReadStory for every ID being marked. + for id in ids { + let exists = (try? modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first + if exists == nil { modelContext.insert(ReadStory(id: id)) } + } + try? modelContext.save() + withAnimation(.snappy) { + showRead = true + markAllToast = "Marked \(ids.count) \(ids.count == 1 ? "story" : "stories") as read." + } + + do { + try await APIClient.shared.markAllRead(storyIds: ids, pill: selectedPill.label) + } catch { + // Rollback optimistic state. + for id in ids { + if let row = (try? modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first { + modelContext.delete(row) + } + } + try? modelContext.save() + withAnimation { + markAllToast = "Sync failed. Stories may reappear until retry." + } + } + markAllPending = false + } + private func shareItems(_ s: StorySummary) -> [Any] { var items: [Any] = ["\(s.headline)\n\n\(s.summary)\n\nvia Jarvis"] if let first = s.sources.first, let url = URL(string: first.url) {