iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: local story viewed state tracking with sorted strip and consistent rings

Track viewed stories locally via ViewedStoryStorage (UserDefaults-backed).
Stories are marked viewed at 50% progress or on tap-to-advance. The story
strip sorts unviewed authors first and reorders on viewer dismiss. Story
rings across all views (feed cards, comments, profile, search, notifications,
follow lists) show gray ring for fully-viewed authors. StoryViewer skips to
first unviewed story when opening an author.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+230 -46
+3
Grain/GrainApp.swift
··· 6 6 @State private var authManager = AuthManager() 7 7 @State private var pushManager = PushManager() 8 8 @State private var storyStatusCache = StoryStatusCache() 9 + @State private var viewedStoryStorage = ViewedStoryStorage() 9 10 @State private var labelDefsCache = LabelDefinitionsCache() 10 11 @State private var pendingDeepLink: DeepLink? 11 12 ··· 17 18 .environment(authManager) 18 19 .environment(pushManager) 19 20 .environment(storyStatusCache) 21 + .environment(viewedStoryStorage) 20 22 .environment(labelDefsCache) 21 23 .tint(Color("AccentColor")) 22 24 .onAppear { 25 + viewedStoryStorage.cleanup() 23 26 pushManager.configure(authManager: authManager) 24 27 appDelegate.pushManager = pushManager 25 28 appDelegate.onNotificationTap = { deepLink in
+3
Grain/Models/Views/StoryModels.swift
··· 15 15 var crossPost: CrossPostInfo? 16 16 17 17 var id: String { uri } 18 + var storyUri: String { uri } 18 19 } 20 + 21 + extension GrainStory: StoryIdentifiable {} 19 22 20 23 /// social.grain.unspecced.getStoryAuthors#storyAuthor 21 24 struct GrainStoryAuthor: Codable, Sendable, Identifiable {
+112
Grain/Utilities/ViewedStoryStorage.swift
··· 1 + import Foundation 2 + 3 + @Observable 4 + @MainActor 5 + final class ViewedStoryStorage { 6 + private var viewedUris: Set<String> = [] 7 + private var authorLastViewed: [String: String] = [:] // DID → latest story createdAt 8 + 9 + private static let urisKey = "viewedStoryUris" 10 + private static let authorKey = "viewedStoryAuthors" 11 + 12 + init() { 13 + load() 14 + } 15 + 16 + private static let dateFormatter: ISO8601DateFormatter = { 17 + let f = ISO8601DateFormatter() 18 + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 19 + return f 20 + }() 21 + 22 + private static let dateFormatterNoFrac: ISO8601DateFormatter = { 23 + let f = ISO8601DateFormatter() 24 + f.formatOptions = [.withInternetDateTime] 25 + return f 26 + }() 27 + 28 + private static func parseDate(_ string: String) -> Date? { 29 + dateFormatter.date(from: string) ?? dateFormatterNoFrac.date(from: string) 30 + } 31 + 32 + /// Mark a story as viewed, updating both the URI set and author timestamp. 33 + func markViewed(uri: String, authorDid: String, createdAt: String) { 34 + viewedUris.insert(uri) 35 + if let existing = authorLastViewed[authorDid], 36 + let existingDate = Self.parseDate(existing), 37 + let newDate = Self.parseDate(createdAt) { 38 + if newDate > existingDate { 39 + authorLastViewed[authorDid] = createdAt 40 + } 41 + } else { 42 + authorLastViewed[authorDid] = createdAt 43 + } 44 + save() 45 + } 46 + 47 + /// Check if a specific story has been viewed. 48 + func isViewed(uri: String) -> Bool { 49 + viewedUris.contains(uri) 50 + } 51 + 52 + /// Convenience: check viewed state using StoryStatusCache to resolve `latestAt`. 53 + func hasViewedAll(did: String, storyStatusCache: StoryStatusCache) -> Bool { 54 + guard let author = storyStatusCache.author(for: did) else { return false } 55 + return hasViewedAll(authorDid: did, latestAt: author.latestAt) 56 + } 57 + 58 + /// Check if all stories from an author have been viewed. 59 + func hasViewedAll(authorDid: String, latestAt: String) -> Bool { 60 + guard let lastViewed = authorLastViewed[authorDid], 61 + let lastViewedDate = Self.parseDate(lastViewed), 62 + let latestDate = Self.parseDate(latestAt) else { return false } 63 + return lastViewedDate >= latestDate 64 + } 65 + 66 + /// Find the index of the first unviewed story in a list. 67 + /// Returns 0 if all stories have been viewed (replay from start). 68 + func firstUnviewedIndex(in stories: [any StoryIdentifiable]) -> Int { 69 + for (index, story) in stories.enumerated() { 70 + if !viewedUris.contains(story.storyUri) { 71 + return index 72 + } 73 + } 74 + return 0 75 + } 76 + 77 + /// Clean up entries older than 24 hours (stories expire). 78 + func cleanup() { 79 + let cutoff = ISO8601DateFormatter().string(from: Date().addingTimeInterval(-86400)) 80 + authorLastViewed = authorLastViewed.filter { $0.value > cutoff } 81 + // URIs can't be time-filtered easily, but limit set size 82 + if viewedUris.count > 500 { 83 + viewedUris = Set(viewedUris.suffix(200)) 84 + } 85 + save() 86 + } 87 + 88 + private func load() { 89 + if let data = UserDefaults.standard.data(forKey: Self.urisKey), 90 + let decoded = try? JSONDecoder().decode(Set<String>.self, from: data) { 91 + viewedUris = decoded 92 + } 93 + if let data = UserDefaults.standard.data(forKey: Self.authorKey), 94 + let decoded = try? JSONDecoder().decode([String: String].self, from: data) { 95 + authorLastViewed = decoded 96 + } 97 + } 98 + 99 + private func save() { 100 + if let data = try? JSONEncoder().encode(viewedUris) { 101 + UserDefaults.standard.set(data, forKey: Self.urisKey) 102 + } 103 + if let data = try? JSONEncoder().encode(authorLastViewed) { 104 + UserDefaults.standard.set(data, forKey: Self.authorKey) 105 + } 106 + } 107 + } 108 + 109 + /// Protocol so we can pass different story types to `firstUnviewedIndex`. 110 + protocol StoryIdentifiable { 111 + var storyUri: String { get } 112 + }
+7
Grain/ViewModels/StoryStripViewModel.swift
··· 5 5 final class StoryStripViewModel { 6 6 var authors: [GrainStoryAuthor] = [] 7 7 var isLoading = false 8 + /// Bumped to trigger re-render without changing author order. 9 + var version: Int = 0 8 10 9 11 private let client: XRPCClient 10 12 ··· 22 24 // Silently fail — strip just won't show 23 25 } 24 26 isLoading = false 27 + } 28 + 29 + /// Signal that viewed state changed so views re-evaluate sort order. 30 + func invalidate() { 31 + version += 1 25 32 } 26 33 }
+2 -1
Grain/Views/Components/GalleryCardView.swift
··· 94 94 struct GalleryCardView: View { 95 95 @Environment(AuthManager.self) private var auth 96 96 @Environment(StoryStatusCache.self) private var storyStatusCache 97 + @Environment(ViewedStoryStorage.self) private var viewedStories 97 98 @Environment(LabelDefinitionsCache.self) private var labelDefsCache 98 99 @Binding var gallery: GrainGallery 99 100 let client: XRPCClient ··· 143 144 VStack(alignment: .leading, spacing: 0) { 144 145 // Header — tappable for navigation 145 146 HStack(spacing: 8) { 146 - StoryRingView(hasStory: storyStatusCache.hasStory(for: gallery.creator.did), size: 32) { 147 + StoryRingView(hasStory: storyStatusCache.hasStory(for: gallery.creator.did), viewed: viewedStories.hasViewedAll(did: gallery.creator.did, storyStatusCache: storyStatusCache), size: 32) { 147 148 AvatarView(url: gallery.creator.avatar, size: 32) 148 149 } 149 150 .onTapGesture {
+33 -18
Grain/Views/Feed/FeedView.swift
··· 3 3 struct FeedView: View { 4 4 @Environment(AuthManager.self) private var auth 5 5 @Environment(StoryStatusCache.self) private var storyStatusCache 6 + @Environment(ViewedStoryStorage.self) private var viewedStories 6 7 @State private var prefsViewModel: FeedPreferencesViewModel 7 8 @State private var storyViewModel: StoryStripViewModel 8 - @State private var showStoryViewer = false 9 - @State private var storyViewerStartIndex = 0 9 + @State private var storyViewerDid: String? 10 10 @State private var showStoryCreate = false 11 11 @State private var deepLinkProfileDid: String? 12 12 @State private var deepLinkGalleryUri: String? ··· 25 25 } 26 26 27 27 var body: some View { 28 + // Read version so Observation tracks it and re-renders on invalidate() 29 + let _ = storyViewModel.version 28 30 NavigationStack { 29 31 ForEach(prefsViewModel.pinnedFeeds) { feed in 30 32 if feed.id == prefsViewModel.selectedFeedId { ··· 34 36 userDID: auth.userDID, 35 37 storyAuthors: storyViewModel.authors, 36 38 userAvatar: auth.userAvatar, 37 - onStoryAuthorTap: { _, index in 38 - storyViewerStartIndex = index 39 - showStoryViewer = true 39 + onStoryAuthorTap: { author, _ in 40 + storyViewerDid = author.profile.did 40 41 }, 41 42 onStoryCreateTap: { showStoryCreate = true }, 42 43 onRefresh: { [storyStatusCache] in ··· 64 65 .onAppear { 65 66 Task { await prefsViewModel.refresh(auth: auth.authContext()) } 66 67 } 67 - .customFullScreenCover(isPresented: $showStoryViewer) { 68 - StoryViewer( 69 - authors: storyViewModel.authors, 70 - startIndex: storyViewerStartIndex, 71 - client: client, 72 - onProfileTap: { did in 73 - showStoryViewer = false 74 - deepLinkProfileDid = did 75 - }, 76 - onDismiss: { showStoryViewer = false } 77 - ) 68 + .onChange(of: storyViewerDid) { 69 + if storyViewerDid == nil { 70 + storyViewModel.invalidate() 71 + } 72 + } 73 + .fullScreenCover(isPresented: Binding( 74 + get: { storyViewerDid != nil }, 75 + set: { if !$0 { storyViewerDid = nil } } 76 + )) { 77 + if let did = storyViewerDid { 78 + StoryViewer( 79 + authors: storyViewModel.authors, 80 + startAuthorDid: did, 81 + client: client, 82 + onProfileTap: { profileDid in 83 + storyViewerDid = nil 84 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { 85 + deepLinkProfileDid = profileDid 86 + } 87 + }, 88 + onDismiss: { 89 + storyViewerDid = nil 90 + } 91 + ) 92 + } 78 93 } 79 94 .sheet(isPresented: $showStoryCreate) { 80 95 StoryCreateView(client: client) { 81 - Task { await storyViewModel.load(auth: auth.authContext()) } 96 + Task { await storyViewModel.load(auth: auth.authContext(), storyStatusCache: storyStatusCache) } 82 97 } 83 98 } 84 99 .navigationDestination(item: $deepLinkProfileDid) { did in ··· 186 201 deepLinkStoryAuthor = GrainStoryAuthor( 187 202 profile: creator, 188 203 storyCount: count, 189 - latestAt: response.stories.first?.createdAt ?? "" 204 + latestAt: response.stories.last?.createdAt ?? "" 190 205 ) 191 206 } else { 192 207 // Story expired — fall back to profile
+2 -1
Grain/Views/Gallery/GalleryDetailView.swift
··· 306 306 307 307 struct CommentRow: View { 308 308 @Environment(StoryStatusCache.self) private var storyStatusCache 309 + @Environment(ViewedStoryStorage.self) private var viewedStories 309 310 let comment: GrainComment 310 311 var isOwn: Bool = false 311 312 var isReply: Bool = false ··· 318 319 var body: some View { 319 320 HStack(alignment: .top, spacing: 8) { 320 321 let avatarSize: CGFloat = isReply ? 24 : 28 321 - StoryRingView(hasStory: storyStatusCache.hasStory(for: comment.author.did), size: avatarSize) { 322 + StoryRingView(hasStory: storyStatusCache.hasStory(for: comment.author.did), viewed: viewedStories.hasViewedAll(did: comment.author.did, storyStatusCache: storyStatusCache), size: avatarSize) { 322 323 AvatarView(url: comment.author.avatar, size: avatarSize) 323 324 } 324 325 .onTapGesture {
+2 -1
Grain/Views/Notifications/NotificationsView.swift
··· 88 88 89 89 struct NotificationRow: View { 90 90 @Environment(StoryStatusCache.self) private var storyStatusCache 91 + @Environment(ViewedStoryStorage.self) private var viewedStories 91 92 let notification: GrainNotification 92 93 var onProfileTap: ((String) -> Void)? 93 94 var onStoryTap: ((GrainStoryAuthor) -> Void)? 94 95 95 96 var body: some View { 96 97 HStack(alignment: .top, spacing: 12) { 97 - StoryRingView(hasStory: storyStatusCache.hasStory(for: notification.author.did), size: 36) { 98 + StoryRingView(hasStory: storyStatusCache.hasStory(for: notification.author.did), viewed: viewedStories.hasViewedAll(did: notification.author.did, storyStatusCache: storyStatusCache), size: 36) { 98 99 AvatarView(url: notification.author.avatar, size: 36) 99 100 } 100 101 .onTapGesture {
+2 -1
Grain/Views/Profile/FollowListView.swift
··· 20 20 @State private var selectedProfileDid: String? 21 21 @State private var cardStoryAuthor: GrainStoryAuthor? 22 22 @Environment(StoryStatusCache.self) private var storyStatusCache 23 + @Environment(ViewedStoryStorage.self) private var viewedStories 23 24 24 25 private var title: String { 25 26 switch mode { ··· 104 105 @ViewBuilder 105 106 private func rowContent(item: FollowListItem) -> some View { 106 107 HStack(alignment: .center, spacing: 14) { 107 - StoryRingView(hasStory: storyStatusCache.hasStory(for: item.did), size: 50) { 108 + StoryRingView(hasStory: storyStatusCache.hasStory(for: item.did), viewed: viewedStories.hasViewedAll(did: item.did, storyStatusCache: storyStatusCache), size: 50) { 108 109 AvatarView(url: item.avatar, size: 50) 109 110 } 110 111 .onTapGesture {
+3 -2
Grain/Views/Profile/ProfileView.swift
··· 8 8 struct ProfileView: View { 9 9 @Namespace private var viewModeNS 10 10 @Environment(AuthManager.self) private var auth 11 + @Environment(ViewedStoryStorage.self) private var viewedStories 11 12 @State private var showStoryViewer = false 12 13 @State private var showAvatarOverlay = false 13 14 @State private var viewModel: ProfileDetailViewModel ··· 48 49 VStack(spacing: 12) { 49 50 // Avatar + stats row 50 51 HStack(alignment: .center, spacing: 16) { 51 - StoryRingView(hasStory: !viewModel.stories.isEmpty, size: 80) { 52 + StoryRingView(hasStory: !viewModel.stories.isEmpty, viewed: viewedStories.hasViewedAll(authorDid: did, latestAt: viewModel.stories.last?.createdAt ?? ""), size: 80) { 52 53 AvatarView(url: profile.avatar, size: 80) 53 54 .liquidGlassCircle() 54 55 } ··· 260 261 authors: [GrainStoryAuthor( 261 262 profile: GrainProfile(cid: "", did: did, handle: profile.handle, displayName: profile.displayName, avatar: profile.avatar), 262 263 storyCount: viewModel.stories.count, 263 - latestAt: viewModel.stories.first?.createdAt ?? "" 264 + latestAt: viewModel.stories.last?.createdAt ?? "" 264 265 )], 265 266 startIndex: 0, 266 267 client: client,
+2 -1
Grain/Views/Search/SearchView.swift
··· 3 3 struct SearchView: View { 4 4 @Environment(AuthManager.self) private var auth 5 5 @Environment(StoryStatusCache.self) private var storyStatusCache 6 + @Environment(ViewedStoryStorage.self) private var viewedStories 6 7 @State private var viewModel: SearchViewModel 7 8 @State private var searchText = "" 8 9 @State private var searchNavigationUri: String? ··· 53 54 selectedProfileDid = profile.did 54 55 } label: { 55 56 HStack { 56 - StoryRingView(hasStory: storyStatusCache.hasStory(for: profile.did), size: 40) { 57 + StoryRingView(hasStory: storyStatusCache.hasStory(for: profile.did), viewed: viewedStories.hasViewedAll(did: profile.did, storyStatusCache: storyStatusCache), size: 40) { 57 58 AvatarView(url: profile.avatar, size: 40) 58 59 } 59 60 VStack(alignment: .leading) {
+24 -14
Grain/Views/Stories/StoryRingView.swift
··· 2 2 3 3 struct StoryRingView<Content: View>: View { 4 4 let hasStory: Bool 5 + var viewed: Bool = false 5 6 let size: CGFloat 6 7 @ViewBuilder let content: () -> Content 7 8 9 + private var lineWidth: CGFloat { size <= 28 ? 1.5 : size <= 40 ? 2.5 : 3.5 } 10 + private var ringSize: CGFloat { size + (size <= 28 ? 4 : size <= 40 ? 6 : 8) } 11 + 8 12 var body: some View { 9 13 content() 10 14 .overlay { 11 15 if hasStory { 12 - Circle() 13 - .strokeBorder( 14 - LinearGradient( 15 - colors: [ 16 - Color(red: 0xc9/255, green: 0x7c/255, blue: 0xf8/255), 17 - Color(red: 0x85/255, green: 0xa1/255, blue: 0xff/255), 18 - Color(red: 0x5b/255, green: 0xf0/255, blue: 0xd6/255) 19 - ], 20 - startPoint: .topLeading, 21 - endPoint: .bottomTrailing 22 - ), 23 - lineWidth: size <= 28 ? 1.5 : size <= 40 ? 2.5 : 3.5 24 - ) 25 - .frame(width: size + (size <= 28 ? 4 : size <= 40 ? 6 : 8), height: size + (size <= 28 ? 4 : size <= 40 ? 6 : 8)) 16 + if viewed { 17 + Circle() 18 + .strokeBorder(Color.secondary.opacity(0.4), lineWidth: lineWidth) 19 + .frame(width: ringSize, height: ringSize) 20 + } else { 21 + Circle() 22 + .strokeBorder( 23 + LinearGradient( 24 + colors: [ 25 + Color(red: 0xc9/255, green: 0x7c/255, blue: 0xf8/255), 26 + Color(red: 0x85/255, green: 0xa1/255, blue: 0xff/255), 27 + Color(red: 0x5b/255, green: 0xf0/255, blue: 0xd6/255) 28 + ], 29 + startPoint: .topLeading, 30 + endPoint: .bottomTrailing 31 + ), 32 + lineWidth: lineWidth 33 + ) 34 + .frame(width: ringSize, height: ringSize) 35 + } 26 36 } 27 37 } 28 38 }
+11 -4
Grain/Views/Stories/StoryStripView.swift
··· 1 1 import SwiftUI 2 2 3 3 struct StoryStripView: View { 4 + @Environment(ViewedStoryStorage.self) private var viewedStories 4 5 let authors: [GrainStoryAuthor] 5 6 let userAvatar: String? 6 7 let onAuthorTap: (GrainStoryAuthor, Int) -> Void ··· 10 11 private let avatarSize: CGFloat = 68 11 12 12 13 var body: some View { 14 + let unviewed = authors.filter { !viewedStories.hasViewedAll(authorDid: $0.profile.did, latestAt: $0.latestAt) } 15 + let viewed = authors.filter { viewedStories.hasViewedAll(authorDid: $0.profile.did, latestAt: $0.latestAt) } 16 + let sorted = unviewed + viewed 17 + let orderKey = sorted.map(\.id).joined(separator: ",") 18 + 13 19 ScrollView(.horizontal, showsIndicators: false) { 14 20 HStack(spacing: 16) { 15 21 // Create button ··· 28 34 } 29 35 .onTapGesture { onCreateTap() } 30 36 31 - // Author avatars 32 - ForEach(Array(authors.enumerated()), id: \.element.id) { index, author in 37 + ForEach(sorted, id: \.id) { author in 38 + let isViewed = viewedStories.hasViewedAll(authorDid: author.profile.did, latestAt: author.latestAt) 33 39 VStack(spacing: 4) { 34 - StoryRingView(hasStory: true, size: avatarSize) { 40 + StoryRingView(hasStory: true, viewed: isViewed, size: avatarSize) { 35 41 AvatarView(url: author.profile.avatar, size: avatarSize) 36 42 } 37 43 Text(author.profile.displayName ?? author.profile.handle) ··· 40 46 .lineLimit(1) 41 47 .frame(width: avatarSize + 8) 42 48 } 43 - .onTapGesture { onAuthorTap(author, index) } 49 + .onTapGesture { onAuthorTap(author, 0) } 44 50 .onLongPressGesture { onAuthorLongPress?(author.profile.did) } 45 51 } 46 52 } 53 + .id(orderKey) 47 54 .padding(.horizontal) 48 55 .padding(.vertical, 8) 49 56 }
+24 -3
Grain/Views/Stories/StoryViewer.swift
··· 13 13 stop() 14 14 progress = 0 15 15 isRunning = true 16 + halfwayFired = false 16 17 task = Task { 17 18 let tickInterval: TimeInterval = 0.05 18 19 let totalTicks = Int(duration / tickInterval) ··· 22 23 } catch { return } 23 24 guard !Task.isCancelled else { return } 24 25 progress = CGFloat(tick) / CGFloat(totalTicks) 26 + if !halfwayFired && progress >= 0.5 { 27 + halfwayFired = true 28 + onHalfway?() 29 + } 25 30 } 26 31 guard !Task.isCancelled else { return } 27 32 isRunning = false ··· 36 41 } 37 42 38 43 var onComplete: (() -> Void)? 44 + var onHalfway: (() -> Void)? 45 + private var halfwayFired = false 39 46 } 40 47 41 48 struct StoryViewer: View { 42 49 @Environment(AuthManager.self) private var auth 43 50 @Environment(LabelDefinitionsCache.self) private var labelDefsCache 51 + @Environment(ViewedStoryStorage.self) private var viewedStories 44 52 let authors: [GrainStoryAuthor] 45 53 let client: XRPCClient 46 54 var onProfileTap: ((String) -> Void)? ··· 57 65 @State private var lastNavTime: Date = .distantPast 58 66 @State private var labelRevealed = false 59 67 60 - init(authors: [GrainStoryAuthor], startIndex: Int = 0, client: XRPCClient, onProfileTap: ((String) -> Void)? = nil, onDismiss: (() -> Void)? = nil) { 68 + init(authors: [GrainStoryAuthor], startIndex: Int = 0, startAuthorDid: String? = nil, client: XRPCClient, onProfileTap: ((String) -> Void)? = nil, onDismiss: (() -> Void)? = nil) { 61 69 self.authors = authors 62 70 self.client = client 63 71 self.onProfileTap = onProfileTap 64 72 self.onDismiss = onDismiss 65 - _currentAuthorIndex = State(initialValue: startIndex) 73 + let resolvedIndex: Int 74 + if let did = startAuthorDid { 75 + resolvedIndex = authors.firstIndex(where: { $0.profile.did == did }) ?? 0 76 + } else { 77 + resolvedIndex = startIndex 78 + } 79 + _currentAuthorIndex = State(initialValue: resolvedIndex) 66 80 } 67 81 68 82 private var currentStory: GrainStory? { ··· 258 272 } 259 273 .task { 260 274 timer.onComplete = { [self] in goToNext() } 275 + timer.onHalfway = { [self] in markCurrentStoryViewed() } 261 276 await loadStoriesForCurrentAuthor() 262 277 } 263 278 } ··· 273 288 guard !isLoadingStories, !stories.isEmpty else { return } 274 289 guard !showReportSheet, !showDeleteConfirm else { return } 275 290 guard Date().timeIntervalSince(lastNavTime) > 0.3 else { return } 291 + markCurrentStoryViewed() 276 292 timer.stop() 277 293 lastNavTime = Date() 278 294 if currentStoryIndex < stories.count - 1 { ··· 336 352 do { 337 353 let response = try await client.getStories(actor: did, auth: auth.authContext()) 338 354 stories = response.stories 339 - currentStoryIndex = 0 355 + currentStoryIndex = viewedStories.firstUnviewedIndex(in: response.stories) 340 356 labelRevealed = false 341 357 let lr = storyLabelResult 342 358 if lr.action == .none || lr.action == .badge { ··· 346 362 stories = [] 347 363 } 348 364 isLoadingStories = false 365 + } 366 + 367 + private func markCurrentStoryViewed() { 368 + guard let story = currentStory else { return } 369 + viewedStories.markViewed(uri: story.uri, authorDid: story.creator.did, createdAt: story.createdAt) 349 370 } 350 371 351 372 private func deleteStory(_ story: GrainStory) async {