feat: Mark All as Read — bulk action with optimistic UI and rollback
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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String> { 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<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] {
|
||||
var items: [Any] = ["\(s.headline)\n\n\(s.summary)\n\nvia Jarvis"]
|
||||
if let first = s.sources.first, let url = URL(string: first.url) {
|
||||
|
||||
Reference in New Issue
Block a user