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: label/content moderation and story viewer improvements

Add full label handling (hide, warnContent, warnMedia blur, badge)
matching the web client across gallery cards and stories. Rewrite
story viewer with extracted @Observable timer and progress bar to
fix sheet presentation issues. Add story report via flag button.

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

+428 -100
+2
Grain/GrainApp.swift
··· 4 4 struct GrainApp: App { 5 5 @State private var authManager = AuthManager() 6 6 @State private var storyStatusCache = StoryStatusCache() 7 + @State private var labelDefsCache = LabelDefinitionsCache() 7 8 @State private var pendingDeepLink: DeepLink? 8 9 9 10 var body: some Scene { ··· 13 14 MainTabView(pendingDeepLink: $pendingDeepLink) 14 15 .environment(authManager) 15 16 .environment(storyStatusCache) 17 + .environment(labelDefsCache) 16 18 .tint(Color("AccentColor")) 17 19 } else { 18 20 LoginView()
+100
Grain/Utilities/LabelResolution.swift
··· 1 + import Foundation 2 + 3 + enum LabelAction: Comparable { 4 + case none 5 + case badge 6 + case warnMedia 7 + case warnContent 8 + case hide 9 + 10 + var severity: Int { 11 + switch self { 12 + case .none: 0 13 + case .badge: 1 14 + case .warnMedia: 2 15 + case .warnContent: 3 16 + case .hide: 4 17 + } 18 + } 19 + 20 + static func < (lhs: LabelAction, rhs: LabelAction) -> Bool { 21 + lhs.severity < rhs.severity 22 + } 23 + } 24 + 25 + struct LabelResolution { 26 + let action: LabelAction 27 + let label: String 28 + let name: String 29 + 30 + static let none = LabelResolution(action: .none, label: "", name: "") 31 + } 32 + 33 + /// Well-known ATProto label fallbacks (used when server definitions unavailable) 34 + private let fallbackDefinitions: [String: (blurs: String, defaultSetting: String, name: String)] = [ 35 + "porn": ("media", "warn", "Adult Content"), 36 + "sexual": ("media", "warn", "Sexual Content"), 37 + "nudity": ("media", "warn", "Nudity"), 38 + "nsfl": ("media", "hide", "NSFL"), 39 + "gore": ("media", "hide", "Graphic Violence"), 40 + "dmca-violation": ("content", "hide", "DMCA Violation"), 41 + "doxxing": ("content", "hide", "Doxxing"), 42 + "!hide": ("content", "hide", "Hidden"), 43 + "!warn": ("content", "warn", "Warning"), 44 + ] 45 + 46 + /// Resolve the most restrictive label action for a set of labels. 47 + func resolveLabels(_ labels: [ATLabel]?, definitions: [LabelDefinition]) -> LabelResolution { 48 + guard let labels, !labels.isEmpty else { return .none } 49 + 50 + var worst = LabelResolution.none 51 + 52 + for label in labels { 53 + guard let val = label.val, !val.isEmpty else { continue } 54 + 55 + let resolution: LabelResolution 56 + if let def = definitions.first(where: { $0.identifier == val }) { 57 + resolution = resolveFromDefinition(val: val, def: def) 58 + } else if let fallback = fallbackDefinitions[val] { 59 + resolution = resolveFromFallback(val: val, fallback: fallback) 60 + } else { 61 + // Unknown label — treat as badge warning 62 + resolution = LabelResolution(action: .badge, label: val, name: val) 63 + } 64 + 65 + if resolution.action > worst.action { 66 + worst = resolution 67 + } 68 + } 69 + 70 + return worst 71 + } 72 + 73 + private func resolveFromDefinition(val: String, def: LabelDefinition) -> LabelResolution { 74 + let blurs = def.blurs ?? "none" 75 + let setting = def.defaultSetting ?? "ignore" 76 + let name = def.displayName 77 + 78 + return resolveAction(val: val, name: name, blurs: blurs, setting: setting) 79 + } 80 + 81 + private func resolveFromFallback(val: String, fallback: (blurs: String, defaultSetting: String, name: String)) -> LabelResolution { 82 + return resolveAction(val: val, name: fallback.name, blurs: fallback.blurs, setting: fallback.defaultSetting) 83 + } 84 + 85 + private func resolveAction(val: String, name: String, blurs: String, setting: String) -> LabelResolution { 86 + let action: LabelAction 87 + switch (blurs, setting) { 88 + case (_, "hide") where blurs != "none": 89 + action = blurs == "media" ? .warnMedia : .hide 90 + case ("content", "warn"): 91 + action = .warnContent 92 + case ("media", "warn"): 93 + action = .warnMedia 94 + case ("none", "warn"): 95 + action = .badge 96 + default: 97 + action = .none 98 + } 99 + return LabelResolution(action: action, label: val, name: name) 100 + }
+18
Grain/ViewModels/LabelDefinitionsCache.swift
··· 1 + import Foundation 2 + 3 + @Observable 4 + @MainActor 5 + final class LabelDefinitionsCache { 6 + private(set) var definitions: [LabelDefinition] = [] 7 + private var hasLoaded = false 8 + 9 + func loadIfNeeded(client: XRPCClient, auth: AuthContext?) async { 10 + guard !hasLoaded else { return } 11 + do { 12 + definitions = try await client.describeLabels(auth: auth) 13 + hasLoaded = true 14 + } catch { 15 + // Use empty definitions — fallbacks will handle well-known labels 16 + } 17 + } 18 + }
+71
Grain/Views/Components/ContentWarningOverlay.swift
··· 1 + import SwiftUI 2 + 3 + /// Full content warning overlay — hides all content behind a reveal button. 4 + struct ContentWarningOverlay: View { 5 + let name: String 6 + let action: LabelAction 7 + let onReveal: () -> Void 8 + 9 + var body: some View { 10 + VStack(spacing: 12) { 11 + Image(systemName: action == .hide ? "eye.slash.fill" : "exclamationmark.triangle.fill") 12 + .font(.title2) 13 + .foregroundStyle(.secondary) 14 + Text(name) 15 + .font(.subheadline.weight(.semibold)) 16 + .foregroundStyle(.primary) 17 + Text("This content has been flagged.") 18 + .font(.caption) 19 + .foregroundStyle(.secondary) 20 + Button("Show anyway") { 21 + onReveal() 22 + } 23 + .font(.caption.weight(.medium)) 24 + .buttonStyle(.bordered) 25 + .tint(.secondary) 26 + } 27 + .frame(maxWidth: .infinity, maxHeight: .infinity) 28 + .background(.ultraThinMaterial) 29 + } 30 + } 31 + 32 + /// Media blur overlay — blurs the media with a reveal button on top. 33 + struct MediaWarningOverlay: View { 34 + let name: String 35 + let onReveal: () -> Void 36 + 37 + var body: some View { 38 + Button { 39 + onReveal() 40 + } label: { 41 + HStack(spacing: 6) { 42 + Image(systemName: "exclamationmark.triangle.fill") 43 + .font(.caption) 44 + Text(name) 45 + .font(.caption.weight(.medium)) 46 + } 47 + .padding(.horizontal, 12) 48 + .padding(.vertical, 8) 49 + .background(.ultraThinMaterial, in: .capsule) 50 + } 51 + .buttonStyle(.plain) 52 + } 53 + } 54 + 55 + /// Small inline badge for low-severity labels. 56 + struct LabelBadge: View { 57 + let name: String 58 + 59 + var body: some View { 60 + HStack(spacing: 4) { 61 + Image(systemName: "exclamationmark.triangle.fill") 62 + .font(.system(size: 10)) 63 + Text(name) 64 + .font(.caption2) 65 + } 66 + .foregroundStyle(.secondary) 67 + .padding(.horizontal, 8) 68 + .padding(.vertical, 3) 69 + .background(.quaternary, in: .capsule) 70 + } 71 + }
+34
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(LabelDefinitionsCache.self) private var labelDefsCache 97 98 @Binding var gallery: GrainGallery 98 99 let client: XRPCClient 99 100 var onNavigate: () -> Void = {} ··· 108 109 @State private var showCopiedToast = false 109 110 @State private var shareWiggle = false 110 111 @State private var didLongPressShare = false 112 + @State private var labelRevealed = false 111 113 112 114 private var isFavorited: Bool { 113 115 gallery.viewer?.fav != nil 114 116 } 115 117 118 + private var labelResult: LabelResolution { 119 + resolveLabels(gallery.labels, definitions: labelDefsCache.definitions) 120 + } 121 + 116 122 private var galleryShareURL: URL { 117 123 let rkey = gallery.uri.split(separator: "/").last.map(String.init) ?? "" 118 124 return URL(string: "https://grain.social/profile/\(gallery.creator.did)/gallery/\(rkey)") ?? URL(string: "https://grain.social")! 119 125 } 120 126 121 127 var body: some View { 128 + let lr = labelResult 129 + if (lr.action == .hide || lr.action == .warnContent) && !labelRevealed { 130 + VStack(spacing: 0) { 131 + ContentWarningOverlay(name: lr.name, action: lr.action) { 132 + labelRevealed = true 133 + } 134 + .frame(height: 200) 135 + } 136 + } else { 137 + cardContent(lr: lr) 138 + } 139 + } 140 + 141 + @ViewBuilder 142 + private func cardContent(lr: LabelResolution) -> some View { 122 143 VStack(alignment: .leading, spacing: 0) { 123 144 // Header — tappable for navigation 124 145 HStack(spacing: 8) { ··· 191 212 } 192 213 } 193 214 .tabViewStyle(.page(indexDisplayMode: .never)) 215 + .blur(radius: lr.action == .warnMedia && !labelRevealed ? 40 : 0) 216 + .allowsHitTesting(lr.action != .warnMedia || labelRevealed) 194 217 195 218 // Page indicator (abbreviated like web — max 5 visible dots) 196 219 if photos.count > 1 { ··· 264 287 hearts.removeAll { $0.isComplete } 265 288 } 266 289 } 290 + 291 + // Media warning overlay 292 + if lr.action == .warnMedia && !labelRevealed { 293 + MediaWarningOverlay(name: lr.name) { 294 + withAnimation { labelRevealed = true } 295 + } 296 + } 267 297 } 268 298 .frame(height: height) 269 299 } ··· 366 396 onMentionTap: onProfileTap, 367 397 onHashtagTap: onHashtagTap 368 398 ) 399 + } 400 + 401 + if lr.action == .badge { 402 + LabelBadge(name: lr.name) 369 403 } 370 404 } 371 405 .padding(.horizontal, 12)
+6 -1
Grain/Views/MainTabView.swift
··· 6 6 7 7 struct MainTabView: View { 8 8 @Environment(AuthManager.self) private var auth 9 + @Environment(LabelDefinitionsCache.self) private var labelDefsCache 9 10 @Environment(\.scenePhase) private var scenePhase 10 11 @State private var selectedTab: AppTab = .feed 11 12 @State private var client = XRPCClient(baseURL: AuthManager.serverURL) ··· 78 79 avatarTabImage = circularAvatar(uiImage, size: 26) 79 80 } 80 81 await notificationsVM.fetchUnseenCount(auth: auth.authContext()) 82 + await labelDefsCache.loadIfNeeded(client: c, auth: auth.authContext()) 81 83 } 82 84 .onChange(of: auth.avatarImage) { 83 85 if let uiImage = auth.avatarImage { ··· 91 93 } 92 94 .onChange(of: scenePhase) { 93 95 if scenePhase == .active { 94 - Task { await notificationsVM.fetchUnseenCount(auth: auth.authContext()) } 96 + Task { 97 + await notificationsVM.fetchUnseenCount(auth: auth.authContext()) 98 + await labelDefsCache.loadIfNeeded(client: client, auth: auth.authContext()) 99 + } 95 100 } 96 101 } 97 102 .sheet(isPresented: $showCreate) {
+197 -99
Grain/Views/Stories/StoryViewer.swift
··· 1 1 import SwiftUI 2 2 import NukeUI 3 3 4 + @Observable 5 + @MainActor 6 + private final class StoryTimer { 7 + var progress: CGFloat = 0 8 + var isRunning = false 9 + private var task: Task<Void, Never>? 10 + private let duration: TimeInterval = 5.0 11 + 12 + func start() { 13 + stop() 14 + progress = 0 15 + isRunning = true 16 + task = Task { 17 + let tickInterval: TimeInterval = 0.05 18 + let totalTicks = Int(duration / tickInterval) 19 + for tick in 0...totalTicks { 20 + do { 21 + try await Task.sleep(for: .milliseconds(Int(tickInterval * 1000))) 22 + } catch { return } 23 + guard !Task.isCancelled else { return } 24 + progress = CGFloat(tick) / CGFloat(totalTicks) 25 + } 26 + guard !Task.isCancelled else { return } 27 + isRunning = false 28 + onComplete?() 29 + } 30 + } 31 + 32 + func stop() { 33 + task?.cancel() 34 + task = nil 35 + isRunning = false 36 + } 37 + 38 + var onComplete: (() -> Void)? 39 + } 40 + 4 41 struct StoryViewer: View { 5 42 @Environment(AuthManager.self) private var auth 43 + @Environment(LabelDefinitionsCache.self) private var labelDefsCache 6 44 let authors: [GrainStoryAuthor] 7 45 let client: XRPCClient 8 46 var onProfileTap: ((String) -> Void)? ··· 11 49 @State private var currentStoryIndex = 0 12 50 @State private var stories: [GrainStory] = [] 13 51 @State private var isLoadingStories = false 14 - @State private var progress: CGFloat = 0 15 - @State private var timerTask: Task<Void, Never>? 52 + @State private var timer = StoryTimer() 16 53 @State private var showDeleteConfirm = false 54 + @State private var showReportSheet = false 55 + @State private var reportStoryUri = "" 56 + @State private var reportStoryCid = "" 17 57 @State private var lastNavTime: Date = .distantPast 18 - 19 - private let storyDuration: TimeInterval = 5.0 58 + @State private var labelRevealed = false 20 59 21 60 init(authors: [GrainStoryAuthor], startIndex: Int = 0, client: XRPCClient, onProfileTap: ((String) -> Void)? = nil, onDismiss: (() -> Void)? = nil) { 22 61 self.authors = authors ··· 31 70 return stories[currentStoryIndex] 32 71 } 33 72 73 + private var storyLabelResult: LabelResolution { 74 + resolveLabels(currentStory?.labels, definitions: labelDefsCache.definitions) 75 + } 76 + 34 77 var body: some View { 35 78 ZStack { 36 79 Color.black.ignoresSafeArea() 37 80 38 81 if let story = currentStory { 82 + let lr = storyLabelResult 83 + 39 84 // Story image 40 - LazyImage(url: URL(string: story.fullsize)) { state in 41 - if let image = state.image { 42 - image 43 - .resizable() 44 - .aspectRatio(story.aspectRatio.ratio, contentMode: .fit) 45 - } else if state.isLoading { 46 - ProgressView() 85 + ZStack { 86 + LazyImage(url: lr.action == .hide && !labelRevealed ? nil : URL(string: story.fullsize)) { state in 87 + if let image = state.image { 88 + image 89 + .resizable() 90 + .aspectRatio(story.aspectRatio.ratio, contentMode: .fit) 91 + } else if state.isLoading { 92 + ProgressView() 93 + .tint(.white) 94 + } 95 + } 96 + .blur(radius: (lr.action == .warnMedia || lr.action == .warnContent) && !labelRevealed ? 24 : 0) 97 + 98 + if (lr.action == .warnContent || lr.action == .hide) && !labelRevealed { 99 + VStack(spacing: 12) { 100 + Image(systemName: "exclamationmark.triangle.fill") 101 + .font(.title) 102 + .foregroundStyle(.white.opacity(0.7)) 103 + Text(lr.name) 104 + .font(.subheadline.weight(.semibold)) 105 + .foregroundStyle(.white) 106 + Text("This content has been flagged.") 107 + .font(.caption) 108 + .foregroundStyle(.white.opacity(0.6)) 109 + Button("Show content") { 110 + withAnimation { labelRevealed = true } 111 + timer.start() 112 + } 113 + .font(.caption.weight(.medium)) 114 + .buttonStyle(.bordered) 47 115 .tint(.white) 116 + } 117 + } else if lr.action == .warnMedia && !labelRevealed { 118 + MediaWarningOverlay(name: lr.name) { 119 + withAnimation { labelRevealed = true } 120 + timer.start() 121 + } 48 122 } 49 123 } 50 124 51 - // Overlay UI 125 + // Tap zones 52 126 VStack(spacing: 0) { 53 - // Progress bars 54 - HStack(spacing: 4) { 55 - ForEach(0..<stories.count, id: \.self) { index in 56 - GeometryReader { geo in 57 - Capsule() 58 - .fill(Color.white.opacity(0.3)) 59 - Capsule() 60 - .fill(Color.white) 61 - .frame(width: barWidth(for: index, totalWidth: geo.size.width)) 62 - } 63 - .frame(height: 2) 127 + Color.clear 128 + .frame(height: 80) 129 + .allowsHitTesting(false) 130 + GeometryReader { geo in 131 + HStack(spacing: 0) { 132 + Color.clear 133 + .contentShape(Rectangle()) 134 + .onTapGesture { goToPrevious() } 135 + .frame(width: geo.size.width / 3) 136 + Color.clear 137 + .contentShape(Rectangle()) 138 + .onTapGesture { goToNext() } 139 + .frame(maxWidth: .infinity) 64 140 } 65 141 } 66 - .padding(.horizontal) 67 - .padding(.top, 8) 142 + .simultaneousGesture( 143 + DragGesture(minimumDistance: 80) 144 + .onEnded { value in 145 + if value.translation.width < -80 { 146 + goToNextAuthor() 147 + } else if value.translation.width > 80 { 148 + goToPreviousAuthor() 149 + } 150 + } 151 + ) 152 + } 153 + .allowsHitTesting(!showReportSheet && !showDeleteConfirm && (labelRevealed || storyLabelResult.action == .none || storyLabelResult.action == .badge)) 68 154 69 - // Creator info 155 + // Header overlay (on top of tap zones) 156 + VStack(spacing: 0) { 157 + StoryProgressBars(timer: timer, stories: stories, currentStoryIndex: currentStoryIndex) 158 + .padding(.horizontal) 159 + .padding(.top, 8) 160 + 161 + // Creator info + actions 70 162 HStack(alignment: .center) { 71 163 Button { 72 164 close() ··· 88 180 89 181 if story.creator.did == auth.userDID { 90 182 Button { 91 - timerTask?.cancel() 183 + timer.stop() 92 184 showDeleteConfirm = true 93 185 } label: { 94 186 Image(systemName: "trash") 95 187 .foregroundStyle(.white) 188 + .frame(width: 36, height: 36) 189 + } 190 + } else { 191 + Button { 192 + timer.stop() 193 + reportStoryUri = story.uri 194 + reportStoryCid = story.cid 195 + showReportSheet = true 196 + } label: { 197 + Image(systemName: "flag") 198 + .foregroundStyle(.white) 199 + .frame(width: 36, height: 36) 96 200 } 97 201 } 98 202 ··· 100 204 Image(systemName: "xmark") 101 205 .foregroundStyle(.white) 102 206 .font(.body.weight(.semibold)) 207 + .frame(width: 36, height: 36) 103 208 } 104 209 } 105 210 .padding(.horizontal, 16) 106 211 .padding(.vertical, 8) 107 212 108 213 Spacer() 214 + .allowsHitTesting(false) 109 215 110 216 // Location pill 111 217 if let locationText = storyLocationText(story) { ··· 125 231 .padding(.bottom, 32) 126 232 } 127 233 } 128 - 129 - // Tap zones (below header area) 130 - VStack(spacing: 0) { 131 - Color.clear 132 - .frame(height: 80) 133 - .allowsHitTesting(false) 134 - GeometryReader { geo in 135 - HStack(spacing: 0) { 136 - Color.clear 137 - .contentShape(Rectangle()) 138 - .onTapGesture { goToPrevious() } 139 - .frame(width: geo.size.width / 3) 140 - Color.clear 141 - .contentShape(Rectangle()) 142 - .onTapGesture { goToNext() } 143 - .frame(maxWidth: .infinity) 144 - } 145 - } 146 - .simultaneousGesture( 147 - DragGesture(minimumDistance: 80) 148 - .onEnded { value in 149 - if value.translation.width < -80 { 150 - goToNextAuthor() 151 - } else if value.translation.width > 80 { 152 - goToPreviousAuthor() 153 - } 154 - } 155 - ) 156 - } 157 234 } else if isLoadingStories { 158 235 ProgressView() 159 236 .tint(.white) ··· 167 244 } 168 245 } 169 246 Button("Cancel", role: .cancel) { 170 - startTimer() 247 + timer.start() 248 + } 249 + } 250 + .fullScreenCover(isPresented: $showReportSheet) { 251 + ReportView(client: client, subjectUri: reportStoryUri, subjectCid: reportStoryCid) 252 + .environment(auth) 253 + } 254 + .onChange(of: showReportSheet) { 255 + if !showReportSheet { 256 + timer.start() 171 257 } 172 258 } 173 259 .task { 260 + timer.onComplete = { [self] in goToNext() } 174 261 await loadStoriesForCurrentAuthor() 175 262 } 176 263 } 177 264 178 - // MARK: - Progress Bar 179 - 180 - private func barWidth(for index: Int, totalWidth: CGFloat) -> CGFloat { 181 - if index < currentStoryIndex { 182 - return totalWidth 183 - } else if index == currentStoryIndex { 184 - return totalWidth * progress 185 - } else { 186 - return 0 187 - } 188 - } 189 - 190 - // MARK: - Timer 191 - 192 - private func startTimer() { 193 - timerTask?.cancel() 194 - progress = 0 195 - timerTask = Task { 196 - let tickInterval: TimeInterval = 0.05 197 - let totalTicks = Int(storyDuration / tickInterval) 198 - for tick in 0...totalTicks { 199 - try? await Task.sleep(for: .milliseconds(Int(tickInterval * 1000))) 200 - guard !Task.isCancelled else { return } 201 - progress = CGFloat(tick) / CGFloat(totalTicks) 202 - } 203 - guard !Task.isCancelled else { return } 204 - goToNext() 205 - } 206 - } 265 + // MARK: - Navigation 207 266 208 267 private func close() { 209 - timerTask?.cancel() 268 + timer.stop() 210 269 onDismiss?() 211 270 } 212 - 213 - // MARK: - Navigation 214 271 215 272 private func goToNext() { 216 273 guard !isLoadingStories, !stories.isEmpty else { return } 274 + guard !showReportSheet, !showDeleteConfirm else { return } 217 275 guard Date().timeIntervalSince(lastNavTime) > 0.3 else { return } 218 - timerTask?.cancel() 276 + timer.stop() 219 277 lastNavTime = Date() 220 278 if currentStoryIndex < stories.count - 1 { 221 279 currentStoryIndex += 1 222 - startTimer() 280 + labelRevealed = false 281 + let lr = storyLabelResult 282 + if lr.action == .none || lr.action == .badge { timer.start() } 223 283 } else { 224 284 goToNextAuthor() 225 285 } ··· 227 287 228 288 private func goToPrevious() { 229 289 guard !isLoadingStories, !stories.isEmpty else { return } 290 + guard !showReportSheet, !showDeleteConfirm else { return } 230 291 guard Date().timeIntervalSince(lastNavTime) > 0.3 else { return } 231 - timerTask?.cancel() 292 + timer.stop() 232 293 lastNavTime = Date() 233 294 if currentStoryIndex > 0 { 234 295 currentStoryIndex -= 1 235 - startTimer() 296 + labelRevealed = false 297 + let lr = storyLabelResult 298 + if lr.action == .none || lr.action == .badge { timer.start() } 236 299 } else { 237 300 goToPreviousAuthor() 238 301 } ··· 244 307 currentStoryIndex = 0 245 308 stories = [] 246 309 isLoadingStories = true 247 - timerTask?.cancel() 310 + timer.stop() 248 311 Task { await loadStoriesForCurrentAuthor() } 249 312 } else { 250 313 close() ··· 257 320 currentStoryIndex = 0 258 321 stories = [] 259 322 isLoadingStories = true 260 - timerTask?.cancel() 323 + timer.stop() 261 324 Task { await loadStoriesForCurrentAuthor() } 262 325 } 263 326 } ··· 268 331 guard currentAuthorIndex < authors.count else { return } 269 332 let did = authors[currentAuthorIndex].profile.did 270 333 isLoadingStories = true 271 - timerTask?.cancel() 334 + timer.stop() 272 335 273 336 do { 274 337 let response = try await client.getStories(actor: did, auth: auth.authContext()) 275 338 stories = response.stories 276 339 currentStoryIndex = 0 277 - startTimer() 340 + labelRevealed = false 341 + let lr = storyLabelResult 342 + if lr.action == .none || lr.action == .badge { 343 + timer.start() 344 + } 278 345 } catch { 279 346 stories = [] 280 347 } ··· 291 358 goToNextAuthor() 292 359 } else { 293 360 currentStoryIndex = min(currentStoryIndex, stories.count - 1) 294 - startTimer() 361 + timer.start() 295 362 } 296 363 } catch { 297 364 // Silently fail ··· 299 366 } 300 367 301 368 private func storyLocationText(_ story: GrainStory) -> String? { 302 - // Prefer location.name as the primary display (e.g. "Fimmvörðuháls Trail") 303 369 if let name = story.location?.name, !name.isEmpty { 304 370 return name 305 371 } ··· 327 393 return "\(Int(interval / 86400))d" 328 394 } 329 395 } 396 + 397 + // Extracted so progress ticks only redraw this view, not the entire StoryViewer 398 + private struct StoryProgressBars: View { 399 + let timer: StoryTimer 400 + let stories: [GrainStory] 401 + let currentStoryIndex: Int 402 + 403 + var body: some View { 404 + HStack(spacing: 4) { 405 + ForEach(0..<stories.count, id: \.self) { index in 406 + GeometryReader { geo in 407 + Capsule() 408 + .fill(Color.white.opacity(0.3)) 409 + Capsule() 410 + .fill(Color.white) 411 + .frame(width: barWidth(for: index, totalWidth: geo.size.width)) 412 + } 413 + .frame(height: 2) 414 + } 415 + } 416 + } 417 + 418 + private func barWidth(for index: Int, totalWidth: CGFloat) -> CGFloat { 419 + if index < currentStoryIndex { 420 + return totalWidth 421 + } else if index == currentStoryIndex { 422 + return totalWidth * timer.progress 423 + } else { 424 + return 0 425 + } 426 + } 427 + }