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: add blocks and mutes

- Block/mute actions on profile overflow menu with optimistic updates
- Muted comments shown collapsed with tap to expand
- Settings > Moderation > Blocked Users / Muted Users management
- Block OAuth scope added to auth request
- Reauth alert when block fails due to missing scope
- Block URI stored after creation for correct unblock
- Mute option hidden when user is blocked
- Profile content hidden when either party blocks

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

+585 -168
+1
Grain/API/AuthManager.swift
··· 71 71 "repo:social.grain.photo.exif", 72 72 "repo:social.grain.actor.profile", 73 73 "repo:social.grain.graph.follow", 74 + "repo:social.grain.graph.block", 74 75 "repo:social.grain.favorite", 75 76 "repo:social.grain.comment", 76 77 "repo:social.grain.story",
+74
Grain/API/Endpoints/ModerationEndpoints.swift
··· 45 45 let id: Int 46 46 } 47 47 48 + // MARK: - Block / Mute types 49 + 50 + struct MuteActorInput: Codable, Sendable { 51 + let actor: String 52 + } 53 + 54 + struct BlockItem: Codable, Sendable, Identifiable { 55 + let did: String 56 + var handle: String? 57 + var displayName: String? 58 + var avatar: String? 59 + let blockUri: String 60 + 61 + var id: String { 62 + did 63 + } 64 + } 65 + 66 + struct MuteItem: Codable, Sendable, Identifiable { 67 + let did: String 68 + var handle: String? 69 + var displayName: String? 70 + var avatar: String? 71 + 72 + var id: String { 73 + did 74 + } 75 + } 76 + 77 + struct BlockListResponse: Codable, Sendable { 78 + let items: [BlockItem]? 79 + var cursor: String? 80 + } 81 + 82 + struct MuteListResponse: Codable, Sendable { 83 + let items: [MuteItem]? 84 + var cursor: String? 85 + } 86 + 48 87 extension XRPCClient { 49 88 func describeLabels(auth: AuthContext? = nil) async throws -> [LabelDefinition] { 50 89 let response = try await query("dev.hatk.describeLabels", auth: auth, as: DescribeLabelsResponse.self) ··· 58 97 reason: reason 59 98 ) 60 99 try await procedure("dev.hatk.createReport", input: input, auth: auth) 100 + } 101 + 102 + // MARK: - Blocks 103 + 104 + @discardableResult 105 + func blockActor(did: String, auth: AuthContext) async throws -> CreateRecordResponse { 106 + let repo = TokenStorage.userDID ?? "" 107 + let record = AnyCodable([ 108 + "subject": did, 109 + "createdAt": DateFormatting.nowISO(), 110 + ]) 111 + return try await createRecord(collection: "social.grain.graph.block", repo: repo, record: record, auth: auth) 112 + } 113 + 114 + func unblockActor(blockUri: String, auth: AuthContext) async throws { 115 + let rkey = blockUri.split(separator: "/").last.map(String.init) ?? "" 116 + try await deleteRecord(collection: "social.grain.graph.block", rkey: rkey, auth: auth) 117 + } 118 + 119 + func getBlocks(auth: AuthContext) async throws -> BlockListResponse { 120 + try await query("social.grain.unspecced.getBlocks", auth: auth, as: BlockListResponse.self) 121 + } 122 + 123 + // MARK: - Mutes 124 + 125 + func muteActor(did: String, auth: AuthContext) async throws { 126 + try await procedure("social.grain.graph.muteActor", input: MuteActorInput(actor: did), auth: auth) 127 + } 128 + 129 + func unmuteActor(did: String, auth: AuthContext) async throws { 130 + try await procedure("social.grain.graph.unmuteActor", input: MuteActorInput(actor: did), auth: auth) 131 + } 132 + 133 + func getMutes(auth: AuthContext) async throws -> MuteListResponse { 134 + try await query("social.grain.unspecced.getMutes", auth: auth, as: MuteListResponse.self) 61 135 } 62 136 }
+1
Grain/Models/Views/CommentModels.swift
··· 12 12 var focus: AnyCodable? 13 13 var replyTo: String? 14 14 let createdAt: String 15 + var muted: Bool? 15 16 16 17 var id: String { 17 18 uri
+3
Grain/Models/Views/ProfileModels.swift
··· 43 43 struct ActorViewerState: Codable, Sendable { 44 44 var following: String? 45 45 var followedBy: String? 46 + var blocking: String? 47 + var blockedBy: Bool? 48 + var muted: Bool? 46 49 } 47 50 48 51 /// Germ Network messageMe declaration
+58 -2
Grain/ViewModels/ProfileDetailViewModel.swift
··· 11 11 var knownFollowers: [FollowerItem] = [] 12 12 var isLoading = false 13 13 var error: Error? 14 + var showReauthAlert = false 14 15 15 16 private var galleryCursor: String? 16 17 private var hasMoreGalleries = true ··· 118 119 isLoading = false 119 120 } 120 121 122 + /// Whether the profile content should be hidden due to a block 123 + var isBlockHidden: Bool { 124 + profile?.viewer?.blocking != nil || profile?.viewer?.blockedBy == true 125 + } 126 + 127 + func toggleBlock(auth: AuthContext?) async { 128 + guard let profile, let auth else { return } 129 + let prevViewer = profile.viewer 130 + let did = profile.did 131 + 132 + if let blockUri = profile.viewer?.blocking { 133 + // Optimistic unblock 134 + self.profile?.viewer?.blocking = nil 135 + do { 136 + try await client.unblockActor(blockUri: blockUri, auth: auth) 137 + } catch { 138 + self.profile?.viewer = prevViewer 139 + } 140 + } else { 141 + // Optimistic block 142 + self.profile?.viewer?.blocking = "pending" 143 + do { 144 + let response = try await client.blockActor(did: did, auth: auth) 145 + self.profile?.viewer?.blocking = response.uri 146 + } catch { 147 + self.profile?.viewer = prevViewer 148 + showReauthAlert = true 149 + } 150 + } 151 + } 152 + 153 + func toggleMute(auth: AuthContext?) async { 154 + guard let profile, let auth else { return } 155 + let prevViewer = profile.viewer 156 + let did = profile.did 157 + 158 + if profile.viewer?.muted == true { 159 + // Optimistic unmute 160 + self.profile?.viewer?.muted = false 161 + do { 162 + try await client.unmuteActor(did: did, auth: auth) 163 + } catch { 164 + self.profile?.viewer = prevViewer 165 + } 166 + } else { 167 + // Optimistic mute 168 + self.profile?.viewer?.muted = true 169 + do { 170 + try await client.muteActor(did: did, auth: auth) 171 + } catch { 172 + self.profile?.viewer = prevViewer 173 + } 174 + } 175 + } 176 + 121 177 func toggleFollow(auth: AuthContext?) async { 122 178 guard profile != nil, let auth else { return } 123 179 ··· 140 196 profile?.followersCount = prevCount 141 197 } 142 198 } else { 143 - // Optimistic follow 144 - profile?.viewer = ActorViewerState(following: "pending", followedBy: prevViewer?.followedBy) 199 + // Optimistic follow — preserve existing block/mute state 200 + profile?.viewer?.following = "pending" 145 201 profile?.followersCount = (prevCount ?? 0) + 1 146 202 147 203 let record = AnyCodable([
+72 -53
Grain/Views/Gallery/GalleryDetailView.swift
··· 332 332 var onStoryTap: ((GrainStoryAuthor) -> Void)? 333 333 var onReply: (() -> Void)? 334 334 var onDelete: (() -> Void)? 335 + @State private var expanded = false 335 336 336 337 var body: some View { 337 - HStack(alignment: .top, spacing: 8) { 338 - let avatarSize: CGFloat = isReply ? 24 : 28 339 - StoryRingView( 340 - hasStory: storyStatusCache.hasStory(for: comment.author.did), 341 - viewed: comment.author.did != userDID && viewedStories.hasViewedAll(did: comment.author.did, storyStatusCache: storyStatusCache), 342 - size: avatarSize 343 - ) { 344 - AvatarView(url: comment.author.avatar, size: avatarSize) 338 + if comment.muted == true, !expanded { 339 + Button { 340 + expanded = true 341 + } label: { 342 + HStack(spacing: 6) { 343 + Image(systemName: "speaker.slash") 344 + .font(.caption) 345 + Text("Muted comment") 346 + .font(.caption) 347 + } 348 + .foregroundStyle(.secondary) 349 + .padding(.leading, isReply ? 50 : 12) 350 + .padding(.trailing, 12) 351 + .padding(.vertical, 8) 345 352 } 346 - .onTapGesture { 347 - if let author = storyStatusCache.author(for: comment.author.did) { 348 - onStoryTap?(author) 349 - } else { 353 + .buttonStyle(.plain) 354 + } else { 355 + HStack(alignment: .top, spacing: 8) { 356 + let avatarSize: CGFloat = isReply ? 24 : 28 357 + StoryRingView( 358 + hasStory: storyStatusCache.hasStory(for: comment.author.did), 359 + viewed: comment.author.did != userDID && viewedStories.hasViewedAll(did: comment.author.did, storyStatusCache: storyStatusCache), 360 + size: avatarSize 361 + ) { 362 + AvatarView(url: comment.author.avatar, size: avatarSize) 363 + } 364 + .onTapGesture { 365 + if let author = storyStatusCache.author(for: comment.author.did) { 366 + onStoryTap?(author) 367 + } else { 368 + onProfileTap?(comment.author.did) 369 + } 370 + } 371 + .onLongPressGesture { 350 372 onProfileTap?(comment.author.did) 351 373 } 352 - } 353 - .onLongPressGesture { 354 - onProfileTap?(comment.author.did) 355 - } 356 374 357 - VStack(alignment: .leading, spacing: 2) { 358 - HStack(spacing: 4) { 359 - Text(comment.author.displayName ?? comment.author.handle) 360 - .font(.subheadline.weight(.semibold)) 361 - Text(DateFormatting.relativeTime(comment.createdAt)) 362 - .font(.caption) 363 - .foregroundStyle(.secondary) 364 - } 365 - RichTextView( 366 - text: comment.text, 367 - facets: comment.facets, 368 - onMentionTap: onProfileTap, 369 - onHashtagTap: onHashtagTap 370 - ) 375 + VStack(alignment: .leading, spacing: 2) { 376 + HStack(spacing: 4) { 377 + Text(comment.author.displayName ?? comment.author.handle) 378 + .font(.subheadline.weight(.semibold)) 379 + Text(DateFormatting.relativeTime(comment.createdAt)) 380 + .font(.caption) 381 + .foregroundStyle(.secondary) 382 + } 383 + RichTextView( 384 + text: comment.text, 385 + facets: comment.facets, 386 + onMentionTap: onProfileTap, 387 + onHashtagTap: onHashtagTap 388 + ) 371 389 372 - // Actions 373 - HStack(spacing: 16) { 374 - if onReply != nil { 375 - Button { 376 - onReply?() 377 - } label: { 378 - Text("Reply") 379 - .font(.caption.weight(.bold)) 380 - .foregroundStyle(.gray) 390 + // Actions 391 + HStack(spacing: 16) { 392 + if onReply != nil { 393 + Button { 394 + onReply?() 395 + } label: { 396 + Text("Reply") 397 + .font(.caption.weight(.bold)) 398 + .foregroundStyle(.gray) 399 + } 381 400 } 382 - } 383 - if isOwn { 384 - Button { 385 - onDelete?() 386 - } label: { 387 - Text("Delete") 388 - .font(.caption.weight(.bold)) 389 - .foregroundStyle(.gray) 401 + if isOwn { 402 + Button { 403 + onDelete?() 404 + } label: { 405 + Text("Delete") 406 + .font(.caption.weight(.bold)) 407 + .foregroundStyle(.gray) 408 + } 390 409 } 391 410 } 411 + .padding(.top, 2) 392 412 } 393 - .padding(.top, 2) 413 + 414 + Spacer() 394 415 } 395 - 396 - Spacer() 416 + .padding(.leading, isReply ? 50 : 12) 417 + .padding(.trailing, 12) 418 + .padding(.vertical, 8) 397 419 } 398 - .padding(.leading, isReply ? 50 : 12) 399 - .padding(.trailing, 12) 400 - .padding(.vertical, 8) 401 420 } 402 421 } 403 422
+181 -113
Grain/Views/Profile/ProfileView.swift
··· 98 98 .onEnded { _ in avatarPressed = false } 99 99 ) 100 100 101 - HStack(spacing: 0) { 102 - StatView(count: profile.galleryCount ?? 0, label: "Galleries") 101 + if !viewModel.isBlockHidden { 102 + HStack(spacing: 0) { 103 + StatView(count: profile.galleryCount ?? 0, label: "Galleries") 104 + .frame(maxWidth: .infinity) 105 + NavigationLink { 106 + FollowListView(client: client, did: did, mode: .followers) 107 + } label: { 108 + StatView(count: profile.followersCount ?? 0, label: "Followers") 109 + } 110 + .buttonStyle(.plain) 111 + .frame(maxWidth: .infinity) 112 + NavigationLink { 113 + FollowListView(client: client, did: did, mode: .following) 114 + } label: { 115 + StatView(count: profile.followsCount ?? 0, label: "Following") 116 + } 117 + .buttonStyle(.plain) 103 118 .frame(maxWidth: .infinity) 104 - NavigationLink { 105 - FollowListView(client: client, did: did, mode: .followers) 106 - } label: { 107 - StatView(count: profile.followersCount ?? 0, label: "Followers") 108 119 } 109 - .buttonStyle(.plain) 110 - .frame(maxWidth: .infinity) 111 - NavigationLink { 112 - FollowListView(client: client, did: did, mode: .following) 113 - } label: { 114 - StatView(count: profile.followsCount ?? 0, label: "Following") 115 - } 116 - .buttonStyle(.plain) 117 - .frame(maxWidth: .infinity) 118 120 } 119 121 } 120 122 .padding(.horizontal) ··· 124 126 VStack(alignment: .leading, spacing: 4) { 125 127 Text(profile.displayName ?? profile.handle) 126 128 .font(.subheadline.bold()) 127 - Text("@\(profile.handle)") 129 + 130 + HStack(spacing: 6) { 131 + if !viewModel.isBlockHidden, profile.viewer?.followedBy != nil { 132 + Text("Follows you") 133 + .font(.caption2) 134 + .padding(.horizontal, 6) 135 + .padding(.vertical, 2) 136 + .background(.quaternary) 137 + .clipShape(RoundedRectangle(cornerRadius: 4)) 138 + } 139 + Text("@\(profile.handle)") 140 + .font(.subheadline) 141 + .foregroundStyle(.secondary) 142 + } 143 + 144 + if viewModel.isBlockHidden { 145 + // Block alert 146 + HStack(spacing: 6) { 147 + Image(systemName: "nosign") 148 + .font(.caption) 149 + if profile.viewer?.blocking != nil { 150 + Text("Account blocked") 151 + } else { 152 + Text("This user has blocked you") 153 + } 154 + } 128 155 .font(.subheadline) 129 156 .foregroundStyle(.secondary) 130 - 131 - if let description = profile.description, !description.isEmpty { 132 - RichTextView( 133 - text: description, 134 - font: .subheadline, 135 - onMentionTap: { did in selectedProfileDid = did }, 136 - onHashtagTap: { tag in selectedHashtag = tag } 137 - ) 138 - .padding(.top, 2) 157 + .padding(.horizontal, 12) 158 + .padding(.vertical, 6) 159 + .background(.quaternary) 160 + .clipShape(RoundedRectangle(cornerRadius: 8)) 161 + .padding(.top, 4) 162 + } else { 163 + if let description = profile.description, !description.isEmpty { 164 + RichTextView( 165 + text: description, 166 + font: .subheadline, 167 + onMentionTap: { did in selectedProfileDid = did }, 168 + onHashtagTap: { tag in selectedHashtag = tag } 169 + ) 170 + .padding(.top, 2) 171 + } 139 172 } 140 173 } 141 174 .frame(maxWidth: .infinity, alignment: .leading) 142 175 .padding(.horizontal) 143 176 144 - // Known followers 145 - if !viewModel.knownFollowers.isEmpty, did != auth.userDID { 146 - NavigationLink { 147 - FollowListView(client: client, did: did, mode: .knownFollowers) 148 - } label: { 149 - knownFollowersRow 177 + if !viewModel.isBlockHidden { 178 + // Known followers 179 + if !viewModel.knownFollowers.isEmpty, did != auth.userDID { 180 + NavigationLink { 181 + FollowListView(client: client, did: did, mode: .knownFollowers) 182 + } label: { 183 + knownFollowersRow 184 + } 185 + .buttonStyle(.plain) 150 186 } 151 - .buttonStyle(.plain) 152 - } 153 187 154 - // Follow + Germ DM buttons 155 - if did != auth.userDID { 156 - HStack(spacing: 8) { 157 - followButton(profile: profile) 188 + // Follow + Germ DM buttons 189 + if did != auth.userDID { 190 + HStack(spacing: 8) { 191 + followButton(profile: profile) 158 192 159 - if let germUrl = germDMUrl(profile: profile) { 160 - Link(destination: germUrl) { 161 - HStack(spacing: 4) { 162 - Image("germ-logo") 163 - .resizable() 164 - .frame(width: 14, height: 14) 165 - Text("Germ DM") 166 - .font(.subheadline.weight(.semibold)) 193 + if let germUrl = germDMUrl(profile: profile) { 194 + Link(destination: germUrl) { 195 + HStack(spacing: 4) { 196 + Image("germ-logo") 197 + .resizable() 198 + .frame(width: 14, height: 14) 199 + Text("Germ DM") 200 + .font(.subheadline.weight(.semibold)) 201 + } 202 + .frame(maxWidth: .infinity) 167 203 } 168 - .frame(maxWidth: .infinity) 204 + .buttonStyle(.bordered) 205 + .tint(.primary) 206 + } 207 + } 208 + .padding(.horizontal) 209 + } else { 210 + HStack(spacing: 8) { 211 + NavigationLink { 212 + EditProfileView(client: client, onSaved: { 213 + Task { await viewModel.load(did: did) } 214 + }) 215 + } label: { 216 + Text("Edit Profile") 217 + .font(.subheadline.weight(.semibold)) 218 + .frame(maxWidth: .infinity) 169 219 } 170 220 .buttonStyle(.bordered) 171 221 .tint(.primary) 172 - } 173 - } 174 - .padding(.horizontal) 175 - } else { 176 - HStack(spacing: 8) { 177 - NavigationLink { 178 - EditProfileView(client: client, onSaved: { 179 - Task { await viewModel.load(did: did) } 180 - }) 181 - } label: { 182 - Text("Edit Profile") 183 - .font(.subheadline.weight(.semibold)) 184 - .frame(maxWidth: .infinity) 185 - } 186 - .buttonStyle(.bordered) 187 - .tint(.primary) 188 222 189 - if let germUrl = germDMUrl(profile: profile) { 190 - Link(destination: germUrl) { 191 - HStack(spacing: 4) { 192 - Image("germ-logo") 193 - .resizable() 194 - .frame(width: 14, height: 14) 195 - Text("Germ DM") 196 - .font(.subheadline.weight(.semibold)) 223 + if let germUrl = germDMUrl(profile: profile) { 224 + Link(destination: germUrl) { 225 + HStack(spacing: 4) { 226 + Image("germ-logo") 227 + .resizable() 228 + .frame(width: 14, height: 14) 229 + Text("Germ DM") 230 + .font(.subheadline.weight(.semibold)) 231 + } 232 + .frame(maxWidth: .infinity) 197 233 } 198 - .frame(maxWidth: .infinity) 234 + .buttonStyle(.bordered) 235 + .tint(.primary) 199 236 } 200 - .buttonStyle(.bordered) 201 - .tint(.primary) 202 237 } 238 + .padding(.horizontal) 203 239 } 204 - .padding(.horizontal) 205 240 } 206 241 207 242 // Tabs + grid 208 - VStack(spacing: 0) { 209 - if did == auth.userDID { 210 - HStack(spacing: 0) { 211 - tabButton(icon: "square.grid.3x3", mode: .grid) 212 - tabButton(icon: "heart", mode: .favorites) 213 - tabButton(icon: "clock", mode: .stories) 243 + if !viewModel.isBlockHidden { 244 + VStack(spacing: 0) { 245 + if did == auth.userDID { 246 + HStack(spacing: 0) { 247 + tabButton(icon: "square.grid.3x3", mode: .grid) 248 + tabButton(icon: "heart", mode: .favorites) 249 + tabButton(icon: "clock", mode: .stories) 250 + } 214 251 } 215 - } 216 252 217 - if viewMode == .grid { 218 - galleriesGrid 219 - } 253 + if viewMode == .grid { 254 + galleriesGrid 255 + } 220 256 221 - if viewMode == .favorites { 222 - favoritesGrid 223 - } 257 + if viewMode == .favorites { 258 + favoritesGrid 259 + } 224 260 225 - if viewMode == .stories { 226 - storyArchiveGrid 261 + if viewMode == .stories { 262 + storyArchiveGrid 263 + } 227 264 } 228 - } 229 - .highPriorityGesture( 230 - did == auth.userDID ? 231 - DragGesture(minimumDistance: 30, coordinateSpace: .local) 232 - .onEnded { value in 233 - let h = value.translation.width 234 - let v = value.translation.height 235 - guard abs(h) > abs(v) else { return } 236 - let modes: [ProfileViewMode] = [.grid, .favorites, .stories] 237 - guard let currentIdx = modes.firstIndex(of: viewMode) else { return } 238 - if h < 0, currentIdx < modes.count - 1 { 239 - let next = modes[currentIdx + 1] 240 - withAnimation(.easeInOut(duration: 0.2)) { viewMode = next } 241 - if next == .stories { 242 - Task { await viewModel.loadStoryArchive(did: did, auth: auth.authContext()) } 243 - } else if next == .favorites { 244 - Task { await viewModel.loadFavorites(did: did, auth: auth.authContext()) } 265 + .highPriorityGesture( 266 + did == auth.userDID ? 267 + DragGesture(minimumDistance: 30, coordinateSpace: .local) 268 + .onEnded { value in 269 + let h = value.translation.width 270 + let v = value.translation.height 271 + guard abs(h) > abs(v) else { return } 272 + let modes: [ProfileViewMode] = [.grid, .favorites, .stories] 273 + guard let currentIdx = modes.firstIndex(of: viewMode) else { return } 274 + if h < 0, currentIdx < modes.count - 1 { 275 + let next = modes[currentIdx + 1] 276 + withAnimation(.easeInOut(duration: 0.2)) { viewMode = next } 277 + if next == .stories { 278 + Task { await viewModel.loadStoryArchive(did: did, auth: auth.authContext()) } 279 + } else if next == .favorites { 280 + Task { await viewModel.loadFavorites(did: did, auth: auth.authContext()) } 281 + } 282 + } else if h > 0, currentIdx > 0 { 283 + withAnimation(.easeInOut(duration: 0.2)) { viewMode = modes[currentIdx - 1] } 245 284 } 246 - } else if h > 0, currentIdx > 0 { 247 - withAnimation(.easeInOut(duration: 0.2)) { viewMode = modes[currentIdx - 1] } 248 285 } 249 - } 250 - : nil 251 - ) 252 - } 286 + : nil 287 + ) 288 + } 289 + } // end if !isBlockHidden (tabs + grid) 253 290 } else if viewModel.error != nil { 254 291 VStack(spacing: 16) { 255 292 ContentUnavailableView( ··· 287 324 } 288 325 .tint(.primary) 289 326 } 327 + } else if viewModel.profile != nil { 328 + ToolbarItem(placement: .topBarTrailing) { 329 + Menu { 330 + if !viewModel.isBlockHidden { 331 + Button(role: viewModel.profile?.viewer?.muted == true ? nil : .destructive) { 332 + Task { await viewModel.toggleMute(auth: auth.authContext()) } 333 + } label: { 334 + Label( 335 + viewModel.profile?.viewer?.muted == true ? "Unmute" : "Mute", 336 + systemImage: viewModel.profile?.viewer?.muted == true ? "speaker.wave.2" : "speaker.slash" 337 + ) 338 + } 339 + } 340 + Button(role: viewModel.profile?.viewer?.blocking != nil ? nil : .destructive) { 341 + Task { await viewModel.toggleBlock(auth: auth.authContext()) } 342 + } label: { 343 + Label( 344 + viewModel.profile?.viewer?.blocking != nil ? "Unblock" : "Block", 345 + systemImage: viewModel.profile?.viewer?.blocking != nil ? "circle" : "nosign" 346 + ) 347 + } 348 + } label: { 349 + Image(systemName: "ellipsis") 350 + } 351 + .tint(.primary) 352 + } 290 353 } 291 354 } 292 355 .navigationDestination(item: $selectedGalleryUri) { uri in ··· 331 394 } 332 395 .fullScreenCover(item: $selectedArchivedStory) { story in 333 396 if let profile = viewModel.profile, 334 - let storyIndex = viewModel.archivedStories.firstIndex(where: { $0.id == story.id }) 397 + viewModel.archivedStories.contains(where: { $0.id == story.id }) 335 398 { 336 399 StoryViewer( 337 400 authors: [GrainStoryAuthor( ··· 387 450 viewModel.galleries.removeAll { $0.uri == uri } 388 451 deletedGalleryUri = nil 389 452 } 453 + } 454 + .alert("Sign in again to block", isPresented: $viewModel.showReauthAlert) { 455 + Button("OK", role: .cancel) {} 456 + } message: { 457 + Text("Please sign out and back in to enable blocking. This is a one-time step after the update.") 390 458 } 391 459 } 392 460
+83
Grain/Views/Settings/BlockedUsersView.swift
··· 1 + import NukeUI 2 + import SwiftUI 3 + 4 + struct BlockedUsersView: View { 5 + @Environment(AuthManager.self) private var auth 6 + let client: XRPCClient 7 + @State private var items: [BlockItem] = [] 8 + @State private var isLoading = true 9 + @State private var unblocking: Set<String> = [] 10 + 11 + var body: some View { 12 + Group { 13 + if isLoading { 14 + ProgressView() 15 + .frame(maxWidth: .infinity, maxHeight: .infinity) 16 + } else if items.isEmpty { 17 + ContentUnavailableView( 18 + "No Blocked Users", 19 + systemImage: "nosign", 20 + description: Text("You haven't blocked anyone.") 21 + ) 22 + } else { 23 + List { 24 + ForEach(items) { item in 25 + HStack(spacing: 12) { 26 + NavigationLink { 27 + ProfileView(client: client, did: item.did) 28 + } label: { 29 + HStack(spacing: 12) { 30 + AvatarView(url: item.avatar, size: 40) 31 + VStack(alignment: .leading, spacing: 2) { 32 + Text(item.displayName ?? item.handle ?? item.did) 33 + .font(.subheadline.weight(.semibold)) 34 + if let handle = item.handle { 35 + Text("@\(handle)") 36 + .font(.caption) 37 + .foregroundStyle(.secondary) 38 + } 39 + } 40 + } 41 + } 42 + .buttonStyle(.plain) 43 + 44 + Spacer() 45 + 46 + Button { 47 + Task { await unblock(item) } 48 + } label: { 49 + Text("Unblock") 50 + .font(.caption.weight(.semibold)) 51 + } 52 + .buttonStyle(.bordered) 53 + .disabled(unblocking.contains(item.did)) 54 + } 55 + } 56 + } 57 + } 58 + } 59 + .navigationTitle("Blocked Users") 60 + .task { 61 + await loadBlocks() 62 + } 63 + } 64 + 65 + private func loadBlocks() async { 66 + guard let authContext = await auth.authContext() else { return } 67 + do { 68 + let response = try await client.getBlocks(auth: authContext) 69 + items = response.items ?? [] 70 + } catch {} 71 + isLoading = false 72 + } 73 + 74 + private func unblock(_ item: BlockItem) async { 75 + guard let authContext = await auth.authContext() else { return } 76 + unblocking.insert(item.did) 77 + do { 78 + try await client.unblockActor(blockUri: item.blockUri, auth: authContext) 79 + items.removeAll { $0.did == item.did } 80 + } catch {} 81 + unblocking.remove(item.did) 82 + } 83 + }
+21
Grain/Views/Settings/ModerationView.swift
··· 1 + import SwiftUI 2 + 3 + struct ModerationView: View { 4 + let client: XRPCClient 5 + 6 + var body: some View { 7 + List { 8 + NavigationLink { 9 + BlockedUsersView(client: client) 10 + } label: { 11 + Label("Blocked Users", systemImage: "nosign") 12 + } 13 + NavigationLink { 14 + MutedUsersView(client: client) 15 + } label: { 16 + Label("Muted Users", systemImage: "speaker.slash") 17 + } 18 + } 19 + .navigationTitle("Moderation") 20 + } 21 + }
+83
Grain/Views/Settings/MutedUsersView.swift
··· 1 + import NukeUI 2 + import SwiftUI 3 + 4 + struct MutedUsersView: View { 5 + @Environment(AuthManager.self) private var auth 6 + let client: XRPCClient 7 + @State private var items: [MuteItem] = [] 8 + @State private var isLoading = true 9 + @State private var unmuting: Set<String> = [] 10 + 11 + var body: some View { 12 + Group { 13 + if isLoading { 14 + ProgressView() 15 + .frame(maxWidth: .infinity, maxHeight: .infinity) 16 + } else if items.isEmpty { 17 + ContentUnavailableView( 18 + "No Muted Users", 19 + systemImage: "speaker.slash", 20 + description: Text("You haven't muted anyone.") 21 + ) 22 + } else { 23 + List { 24 + ForEach(items) { item in 25 + HStack(spacing: 12) { 26 + NavigationLink { 27 + ProfileView(client: client, did: item.did) 28 + } label: { 29 + HStack(spacing: 12) { 30 + AvatarView(url: item.avatar, size: 40) 31 + VStack(alignment: .leading, spacing: 2) { 32 + Text(item.displayName ?? item.handle ?? item.did) 33 + .font(.subheadline.weight(.semibold)) 34 + if let handle = item.handle { 35 + Text("@\(handle)") 36 + .font(.caption) 37 + .foregroundStyle(.secondary) 38 + } 39 + } 40 + } 41 + } 42 + .buttonStyle(.plain) 43 + 44 + Spacer() 45 + 46 + Button { 47 + Task { await unmute(item) } 48 + } label: { 49 + Text("Unmute") 50 + .font(.caption.weight(.semibold)) 51 + } 52 + .buttonStyle(.bordered) 53 + .disabled(unmuting.contains(item.did)) 54 + } 55 + } 56 + } 57 + } 58 + } 59 + .navigationTitle("Muted Users") 60 + .task { 61 + await loadMutes() 62 + } 63 + } 64 + 65 + private func loadMutes() async { 66 + guard let authContext = await auth.authContext() else { return } 67 + do { 68 + let response = try await client.getMutes(auth: authContext) 69 + items = response.items ?? [] 70 + } catch {} 71 + isLoading = false 72 + } 73 + 74 + private func unmute(_ item: MuteItem) async { 75 + guard let authContext = await auth.authContext() else { return } 76 + unmuting.insert(item.did) 77 + do { 78 + try await client.unmuteActor(did: item.did, auth: authContext) 79 + items.removeAll { $0.did == item.did } 80 + } catch {} 81 + unmuting.remove(item.did) 82 + } 83 + }
+8
Grain/Views/Settings/SettingsView.swift
··· 23 23 } 24 24 } 25 25 26 + Section("Moderation") { 27 + NavigationLink { 28 + ModerationView(client: client) 29 + } label: { 30 + Label("Moderation", systemImage: "shield") 31 + } 32 + } 33 + 26 34 Section { 27 35 Toggle("Include location", isOn: $includeLocation) 28 36 .onChange(of: includeLocation) {