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: content labels with self-label support and improved warning UX

Add ContentLabelPicker (DisclosureGroup) to gallery and story create forms.
Replace blur with opaque overlay and Bluesky-style warning bar across feed,
stories, and profile grid. Move labelRevealed to model layer so state resets
on feed refresh. Stories no longer pause timer on labeled content.

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

+131 -55
+5
Grain/Models/Views/GalleryModels.swift
··· 20 20 let indexedAt: String 21 21 var viewer: GalleryViewerState? 22 22 var crossPost: CrossPostInfo? 23 + var labelRevealed: Bool = false 23 24 24 25 var id: String { uri } 26 + 27 + private enum CodingKeys: String, CodingKey { 28 + case uri, cid, title, description, cameras, location, address, facets, creator, record, items, favCount, commentCount, labels, createdAt, indexedAt, viewer, crossPost 29 + } 25 30 } 26 31 27 32 /// social.grain.gallery.defs#viewerState
+53
Grain/Views/Components/ContentLabelPicker.swift
··· 1 + import SwiftUI 2 + 3 + struct ContentLabelPicker: View { 4 + @Environment(LabelDefinitionsCache.self) private var labelDefs 5 + @Binding var selectedLabels: Set<String> 6 + 7 + private static let selfLabelValues = ["nudity", "sexual", "gore"] 8 + 9 + private var options: [(value: String, label: String)] { 10 + Self.selfLabelValues.map { value in 11 + let name = labelDefs.definitions 12 + .first(where: { $0.identifier == value })? 13 + .displayName ?? value.capitalized 14 + return (value: value, label: name) 15 + } 16 + } 17 + 18 + @State private var isExpanded = false 19 + 20 + var body: some View { 21 + Section { 22 + DisclosureGroup(isExpanded: $isExpanded) { 23 + ForEach(options, id: \.value) { option in 24 + Button { 25 + if selectedLabels.contains(option.value) { 26 + selectedLabels.remove(option.value) 27 + } else { 28 + selectedLabels.insert(option.value) 29 + } 30 + } label: { 31 + HStack { 32 + Text(option.label) 33 + .foregroundStyle(.primary) 34 + Spacer() 35 + if selectedLabels.contains(option.value) { 36 + Image(systemName: "checkmark") 37 + .fontWeight(.semibold) 38 + } 39 + } 40 + } 41 + } 42 + } label: { 43 + Text("Content Warning") 44 + .foregroundStyle(.primary) 45 + } 46 + } 47 + .onChange(of: selectedLabels) { 48 + if !selectedLabels.isEmpty { 49 + isExpanded = true 50 + } 51 + } 52 + } 53 + }
+16 -11
Grain/Views/Components/ContentWarningOverlay.swift
··· 29 29 } 30 30 } 31 31 32 - /// Media blur overlay — blurs the media with a reveal button on top. 32 + /// Media warning overlay — centered bar with label name and Show button (Bluesky-style). 33 33 struct MediaWarningOverlay: View { 34 34 let name: String 35 35 let onReveal: () -> Void 36 36 37 37 var body: some View { 38 - Button { 39 - onReveal() 40 - } label: { 41 - HStack(spacing: 6) { 42 - Image(systemName: "exclamationmark.triangle.fill") 43 - .font(.caption) 38 + VStack { 39 + Spacer() 40 + HStack { 41 + Image(systemName: "info.circle.fill") 42 + .foregroundStyle(.secondary) 44 43 Text(name) 45 - .font(.caption.weight(.medium)) 44 + .font(.subheadline.weight(.medium)) 45 + Spacer() 46 + Button("Show") { 47 + onReveal() 48 + } 49 + .font(.subheadline.weight(.medium)) 46 50 } 51 + .padding(.horizontal, 16) 52 + .padding(.vertical, 12) 53 + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10)) 47 54 .padding(.horizontal, 12) 48 - .padding(.vertical, 8) 49 - .background(.ultraThinMaterial, in: .capsule) 55 + Spacer() 50 56 } 51 - .buttonStyle(.plain) 52 57 } 53 58 } 54 59
+10 -7
Grain/Views/Components/GalleryCardView.swift
··· 110 110 @State private var showCopiedToast = false 111 111 @State private var shareWiggle = false 112 112 @State private var didLongPressShare = false 113 - @State private var labelRevealed = false 114 113 115 114 private var isFavorited: Bool { 116 115 gallery.viewer?.fav != nil ··· 127 126 128 127 var body: some View { 129 128 let lr = labelResult 130 - if (lr.action == .hide || lr.action == .warnContent) && !labelRevealed { 129 + if (lr.action == .hide || lr.action == .warnContent) && !gallery.labelRevealed { 131 130 VStack(spacing: 0) { 132 131 ContentWarningOverlay(name: lr.name, action: lr.action) { 133 - labelRevealed = true 132 + gallery.labelRevealed = true 134 133 } 135 134 .frame(height: 200) 136 135 } ··· 213 212 } 214 213 } 215 214 .tabViewStyle(.page(indexDisplayMode: .never)) 216 - .blur(radius: lr.action == .warnMedia && !labelRevealed ? 40 : 0) 217 - .allowsHitTesting(lr.action != .warnMedia || labelRevealed) 215 + .overlay { 216 + if lr.action == .warnMedia && !gallery.labelRevealed { 217 + Rectangle().fill(Color(.secondarySystemBackground)) 218 + } 219 + } 220 + .allowsHitTesting(lr.action != .warnMedia || gallery.labelRevealed) 218 221 219 222 // Page indicator (abbreviated like web — max 5 visible dots) 220 223 if photos.count > 1 { ··· 290 293 } 291 294 292 295 // Media warning overlay 293 - if lr.action == .warnMedia && !labelRevealed { 296 + if lr.action == .warnMedia && !gallery.labelRevealed { 294 297 MediaWarningOverlay(name: lr.name) { 295 - withAnimation { labelRevealed = true } 298 + withAnimation { gallery.labelRevealed = true } 296 299 } 297 300 } 298 301 }
+10
Grain/Views/Create/CreateGalleryView.swift
··· 24 24 @State private var mentionState = MentionAutocompleteState() 25 25 @State private var postToBluesky = false 26 26 @State private var includeExif = true 27 + @State private var selectedLabels: Set<String> = [] 27 28 28 29 let client: XRPCClient 29 30 var onCreated: (() -> Void)? ··· 147 148 } 148 149 } 149 150 } 151 + 152 + ContentLabelPicker(selectedLabels: $selectedLabels) 150 153 151 154 Section { 152 155 Toggle("Post to Bluesky", isOn: $postToBluesky) ··· 358 361 "createdAt": AnyCodable(now) 359 362 ] 360 363 if !description.isEmpty { galleryRecord["description"] = AnyCodable(description) } 364 + if !selectedLabels.isEmpty { 365 + let labelValues = selectedLabels.map { ["val": AnyCodable($0)] as [String: AnyCodable] } 366 + galleryRecord["labels"] = AnyCodable([ 367 + "$type": AnyCodable("com.atproto.label.defs#selfLabels"), 368 + "values": AnyCodable(labelValues as [[String: AnyCodable]]) 369 + ] as [String: AnyCodable]) 370 + } 361 371 if let loc = resolvedLocation { 362 372 galleryRecord["location"] = AnyCodable([ 363 373 "value": AnyCodable(loc.h3),
+14
Grain/Views/Profile/ProfileView.swift
··· 9 9 @Namespace private var viewModeNS 10 10 @Environment(AuthManager.self) private var auth 11 11 @Environment(ViewedStoryStorage.self) private var viewedStories 12 + @Environment(LabelDefinitionsCache.self) private var labelDefsCache 12 13 @State private var showStoryViewer = false 13 14 @State private var showAvatarOverlay = false 14 15 @State private var viewModel: ProfileDetailViewModel ··· 193 194 } 194 195 } 195 196 .clipped() 197 + .overlay { 198 + let lr = resolveLabels(gallery.labels, definitions: labelDefsCache.definitions) 199 + if lr.action >= .warnMedia { 200 + Rectangle().fill(Color(.secondarySystemBackground)) 201 + HStack(spacing: 4) { 202 + Image(systemName: "info.circle.fill") 203 + .font(.caption2) 204 + Text(lr.name) 205 + .font(.system(size: 9)) 206 + } 207 + .foregroundStyle(.secondary) 208 + } 209 + } 196 210 .overlay(alignment: .topTrailing) { 197 211 if (gallery.items?.count ?? 0) > 1 { 198 212 Image(systemName: "square.on.square.fill")
+10
Grain/Views/Stories/StoryCreateView.swift
··· 20 20 @State private var isUploading = false 21 21 @State private var errorMessage: String? 22 22 @State private var postToBluesky = false 23 + @State private var selectedLabels: Set<String> = [] 23 24 24 25 var body: some View { 25 26 NavigationStack { ··· 98 99 } 99 100 } 100 101 } 102 + 103 + ContentLabelPicker(selectedLabels: $selectedLabels) 101 104 102 105 Section { 103 106 Toggle("Post to Bluesky", isOn: $postToBluesky) ··· 216 219 if let addr = loc.address { 217 220 record["address"] = AnyCodable(addr) 218 221 } 222 + } 223 + if !selectedLabels.isEmpty { 224 + let labelValues = selectedLabels.map { ["val": AnyCodable($0)] as [String: AnyCodable] } 225 + record["labels"] = AnyCodable([ 226 + "$type": AnyCodable("com.atproto.label.defs#selfLabels"), 227 + "values": AnyCodable(labelValues as [[String: AnyCodable]]) 228 + ] as [String: AnyCodable]) 219 229 } 220 230 221 231 let storyResult = try await client.createRecord(
+13 -37
Grain/Views/Stories/StoryViewer.swift
··· 97 97 handle: fadeDismissHandle, 98 98 onDismiss: { onDismiss?() }, 99 99 onDragStart: { timer.stop() }, 100 - onDragCancel: { 101 - let lr = storyLabelResult 102 - if lr.action == .none || lr.action == .badge { timer.start() } 103 - }, 100 + onDragCancel: { timer.start() }, 104 101 onSwipeLeft: { goToNextAuthor() }, 105 102 onSwipeRight: { goToPreviousAuthor() } 106 103 ) ··· 142 139 143 140 // Story image 144 141 ZStack { 145 - LazyImage(url: lr.action == .hide && !labelRevealed ? nil : URL(string: story.fullsize)) { state in 142 + LazyImage(url: URL(string: story.fullsize)) { state in 146 143 if let image = state.image { 147 144 image 148 145 .resizable() ··· 153 150 .tint(.white) 154 151 } 155 152 } 156 - .blur(radius: (lr.action == .warnMedia || lr.action == .warnContent) && !labelRevealed ? 24 : 0) 157 - 158 - if (lr.action == .warnContent || lr.action == .hide) && !labelRevealed { 159 - VStack(spacing: 12) { 160 - Image(systemName: "exclamationmark.triangle.fill") 161 - .font(.title) 162 - .foregroundStyle(.white.opacity(0.7)) 163 - Text(lr.name) 164 - .font(.subheadline.weight(.semibold)) 165 - .foregroundStyle(.white) 166 - Text("This content has been flagged.") 167 - .font(.caption) 168 - .foregroundStyle(.white.opacity(0.6)) 169 - Button("Show content") { 170 - withAnimation { labelRevealed = true } 171 - timer.start() 172 - } 173 - .font(.caption.weight(.medium)) 174 - .buttonStyle(.bordered) 175 - .tint(.white) 153 + .overlay { 154 + if (lr.action == .warnMedia || lr.action == .warnContent || lr.action == .hide) && !labelRevealed { 155 + Rectangle().fill(Color(.secondarySystemBackground)) 176 156 } 177 - } else if lr.action == .warnMedia && !labelRevealed { 157 + } 158 + 159 + if (lr.action == .warnContent || lr.action == .warnMedia || lr.action == .hide) && !labelRevealed { 178 160 MediaWarningOverlay(name: lr.name) { 179 161 withAnimation { labelRevealed = true } 180 162 timer.start() ··· 200 182 } 201 183 } 202 184 } 203 - .allowsHitTesting(!showReportSheet && !showDeleteConfirm && (labelRevealed || storyLabelResult.action == .none || storyLabelResult.action == .badge)) 185 + .allowsHitTesting(!showReportSheet && !showDeleteConfirm) 204 186 205 187 // Header overlay 206 188 VStack(spacing: 0) { ··· 303 285 if currentStoryIndex < stories.count - 1 { 304 286 currentStoryIndex += 1 305 287 labelRevealed = false 306 - let lr = storyLabelResult 307 - if lr.action == .none || lr.action == .badge { timer.start() } 288 + timer.start() 308 289 } else { 309 290 goToNextAuthor() 310 291 } ··· 319 300 if currentStoryIndex > 0 { 320 301 currentStoryIndex -= 1 321 302 labelRevealed = false 322 - let lr = storyLabelResult 323 - if lr.action == .none || lr.action == .badge { timer.start() } 303 + timer.start() 324 304 } else { 325 305 goToPreviousAuthor() 326 306 } ··· 350 330 currentStoryIndex = viewedStories.firstUnviewedIndex(in: cached) 351 331 labelRevealed = false 352 332 isLoadingStories = false 353 - let lr = storyLabelResult 354 - if lr.action == .none || lr.action == .badge { timer.start() } 333 + timer.start() 355 334 prefetchAdjacentAuthors() 356 335 } else { 357 336 currentStoryIndex = 0 ··· 379 358 stories = fetched 380 359 currentStoryIndex = viewedStories.firstUnviewedIndex(in: fetched) 381 360 labelRevealed = false 382 - let lr = storyLabelResult 383 - if lr.action == .none || lr.action == .badge { 384 - timer.start() 385 - } 361 + timer.start() 386 362 } catch { 387 363 stories = [] 388 364 }