feat: Mark All as Read — bulk action with optimistic UI and rollback
Some checks failed
CI / Build · macos-14 · Debug (push) Has been cancelled
CI / Build · macos-15 · Debug (push) Has been cancelled
CI / Build · macos-14 · Release (push) Has been cancelled
CI / Build · macos-15 · Release (push) Has been cancelled

APIClient:
- markAllRead(storyIds💊) — POST /me/read/bulk with device ID, timestamp,
  source label, and pill name so backend can record context

Models:
- PaginatedStories gains unreadCount, readSuppressedCount, lastReadSyncAt,
  activePill — backend now returns these so the UI can report honest state

SignalFeedView:
- Header "…" menu replaces the standalone settings button; menu contains
  "Mark all as read" (disabled while pending or when nothing is unread) and
  a Settings item
- markAllRead() captures all visible unread IDs, optimistically inserts
  ReadStory for each, shows toast ("Marked N stories as read"), then fires
  the bulk API; on failure deletes the optimistic ReadStory records and
  shows an honest error toast ("Sync failed. Stories may reappear until retry.")
- Toast auto-dismisses after 3.5s, appears at the bottom of the ZStack
  with a slide-up + opacity animation

Cross-pill suppression was already in place — backend applies
unread_story_condition() to every /stories request regardless of tags,
so stories marked read in All disappear from Tech, Sport, etc. on the
next pill fetch.

Co-Authored-By: Kutesir <tqwyy79vzn@privaterelay.appleid.com>
This commit is contained in:
Robin Kutesa
2026-06-23 12:47:28 +03:00
parent 044f67cf63
commit a86284cb8a
3 changed files with 91 additions and 4 deletions

View File

@@ -136,6 +136,10 @@ struct PaginatedStories: Codable {
let nextCursor: String? let nextCursor: String?
let hasMore: Bool let hasMore: Bool
let total: Int let total: Int
let unreadCount: Int?
let readSuppressedCount: Int?
let lastReadSyncAt: Date?
let activePill: String?
} }
// MARK: - WebSocket Events // MARK: - WebSocket Events

View File

@@ -111,6 +111,16 @@ actor APIClient {
try await delete("/stories/\(id)/read") 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 // MARK: - Articles
func fetchArticle(id: String) async throws -> Article { func fetchArticle(id: String) async throws -> Article {

View File

@@ -25,6 +25,8 @@ struct SignalFeedView: View {
/// Reference-type set so marking a story seen on scroll doesn't re-render. /// Reference-type set so marking a story seen on scroll doesn't re-render.
@State private var seenTracker = SeenTracker() @State private var seenTracker = SeenTracker()
@State private var showRead = false @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. /// Stories that have full article content cached eligible for the green dot.
private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) } private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) }
@@ -120,7 +122,7 @@ struct SignalFeedView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack(alignment: .bottom) {
Palette.background.ignoresSafeArea() Palette.background.ignoresSafeArea()
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -130,6 +132,25 @@ struct SignalFeedView: View {
Divider().overlay(Palette.hairline) Divider().overlay(Palette.hairline)
feedList 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) .navigationBarHidden(true)
.navigationDestination(for: StorySummary.self) { story in .navigationDestination(for: StorySummary.self) { story in
@@ -174,10 +195,24 @@ struct SignalFeedView: View {
.foregroundStyle(Palette.secondaryText) .foregroundStyle(Palette.secondaryText)
.frame(width: 40, height: 40) .frame(width: 40, height: 40)
} }
Menu {
Button {
Task { await markAllRead() }
} label: {
Label(
markAllPending ? "Marking…" : "Mark all as read",
systemImage: markAllPending ? "hourglass" : "checkmark.circle"
)
}
.disabled(markAllPending || mainStories.isEmpty)
Divider()
Button { Button {
showSettings = true showSettings = true
} label: { } label: {
Image(systemName: "gearshape") Label("Settings", systemImage: "gearshape")
}
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 15, weight: .medium)) .font(.system(size: 15, weight: .medium))
.foregroundStyle(Palette.secondaryText) .foregroundStyle(Palette.secondaryText)
.frame(width: 40, height: 40) .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<ReadStory>(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<ReadStory>(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] { private func shareItems(_ s: StorySummary) -> [Any] {
var items: [Any] = ["\(s.headline)\n\n\(s.summary)\n\nvia Jarvis"] var items: [Any] = ["\(s.headline)\n\n\(s.summary)\n\nvia Jarvis"]
if let first = s.sources.first, let url = URL(string: first.url) { if let first = s.sources.first, let url = URL(string: first.url) {