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.

fix: UI polish — destructive styling, story cache, settings labels

- Make delete gallery and block button red (icon + text)
- Make mute button non-destructive
- Remove double-tap-to-fav on stories
- Fix story ring not clearing after deleting last story
- Re-fetch story strip on viewer dismiss
- Filter out authors with 0 stories from strip and cache
- Rename settings: Appearance → Feeds, Upload Defaults → Privacy
- Add "Defaults for new uploads" section header
- Add long-press to copy handle/DID on account settings

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

+40 -22
+6 -1
Grain/ViewModels/StoryStatusCache.swift
··· 49 49 } 50 50 51 51 func update(from authors: [GrainStoryAuthor]) { 52 - entries = Dictionary(uniqueKeysWithValues: authors.map { author in 52 + entries = Dictionary(uniqueKeysWithValues: authors.filter { $0.storyCount > 0 }.map { author in 53 53 let expiresAt: Date = if let latestAt = Self.parseDate(author.latestAt) { 54 54 latestAt.addingTimeInterval(Self.storyLifetime) 55 55 } else { ··· 57 57 } 58 58 return (author.profile.did, CachedEntry(author: author, expiresAt: expiresAt)) 59 59 }) 60 + } 61 + 62 + /// Remove a specific author from the cache (e.g. after deleting their last story). 63 + func remove(did: String) { 64 + entries.removeValue(forKey: did) 60 65 } 61 66 62 67 /// Remove entries whose stories have expired. Call on app foreground and background.
+1 -1
Grain/ViewModels/StoryStripViewModel.swift
··· 18 18 isLoading = true 19 19 do { 20 20 let response = try await client.getStoryAuthors(auth: auth) 21 - authors = response.authors 21 + authors = response.authors.filter { $0.storyCount > 0 } 22 22 storyStatusCache?.update(from: response.authors) 23 23 } catch { 24 24 // Silently fail — strip just won't show
+1 -1
Grain/Views/Components/GalleryCardView.swift
··· 661 661 onDelete() 662 662 } label: { 663 663 Label("Delete Gallery", systemImage: "trash") 664 - .foregroundStyle(.primary) 664 + .foregroundStyle(.red) 665 665 } 666 666 } 667 667 }
+1
Grain/Views/Feed/FeedView.swift
··· 84 84 .onChange(of: storyViewerDid) { 85 85 if storyViewerDid == nil { 86 86 storyViewModel.invalidate() 87 + Task { await storyViewModel.load(auth: auth.authContext(), storyStatusCache: storyStatusCache) } 87 88 } 88 89 } 89 90 .fullScreenCover(isPresented: Binding(
+11 -8
Grain/Views/Profile/ProfileView.swift
··· 353 353 Divider() 354 354 } 355 355 if !viewModel.isBlockHidden { 356 - Button(role: profile.viewer?.muted == true ? nil : .destructive) { 356 + Button { 357 357 Task { await viewModel.toggleMute(auth: auth.authContext()) } 358 358 } label: { 359 359 Label( ··· 362 362 ) 363 363 } 364 364 } 365 - Button(role: profile.viewer?.blocking != nil ? nil : .destructive) { 366 - Task { await viewModel.toggleBlock(auth: auth.authContext()) } 367 - } label: { 368 - Label( 369 - profile.viewer?.blocking != nil ? "Unblock" : "Block", 370 - systemImage: profile.viewer?.blocking != nil ? "circle" : "nosign" 371 - ) 365 + Section { 366 + Button(role: profile.viewer?.blocking != nil ? nil : .destructive) { 367 + Task { await viewModel.toggleBlock(auth: auth.authContext()) } 368 + } label: { 369 + Label( 370 + profile.viewer?.blocking != nil ? "Unblock" : "Block", 371 + systemImage: profile.viewer?.blocking != nil ? "circle" : "nosign" 372 + ) 373 + } 372 374 } 375 + .tint(profile.viewer?.blocking != nil ? .primary : .red) 373 376 } label: { 374 377 Image(systemName: "ellipsis") 375 378 }
+19 -5
Grain/Views/Settings/SettingsView.swift
··· 21 21 NavigationLink("Moderation") { 22 22 ModerationView(client: client) 23 23 } 24 - NavigationLink("Appearance") { 24 + NavigationLink("Feeds") { 25 25 AppearanceSettingsView() 26 26 } 27 - NavigationLink("Upload Defaults") { 27 + NavigationLink("Privacy") { 28 28 UploadDefaultsView(client: client) 29 29 } 30 30 } ··· 118 118 Section { 119 119 if let handle = auth.userHandle { 120 120 LabeledContent("Handle", value: "@\(handle)") 121 + .contextMenu { 122 + Button { 123 + UIPasteboard.general.string = "@\(handle)" 124 + } label: { 125 + Label("Copy Handle", systemImage: "doc.on.doc") 126 + } 127 + } 121 128 } 122 129 if let did = auth.userDID { 123 130 LabeledContent("DID", value: did) 124 131 .font(.caption) 132 + .contextMenu { 133 + Button { 134 + UIPasteboard.general.string = did 135 + } label: { 136 + Label("Copy DID", systemImage: "doc.on.doc") 137 + } 138 + } 125 139 } 126 140 } 127 141 ··· 160 174 Toggle("Show suggested users", isOn: $showSuggestedUsers) 161 175 } 162 176 } 163 - .navigationTitle("Appearance") 177 + .navigationTitle("Feeds") 164 178 .tint(Color("AccentColor")) 165 179 } 166 180 } ··· 174 188 175 189 var body: some View { 176 190 List { 177 - Section { 191 + Section("Defaults for new uploads") { 178 192 Toggle(isOn: $includeLocation) { 179 193 VStack(alignment: .leading, spacing: 2) { 180 194 Text("Include location") ··· 207 221 } 208 222 } 209 223 } 210 - .navigationTitle("Upload Defaults") 224 + .navigationTitle("Privacy") 211 225 .tint(Color("AccentColor")) 212 226 .task { 213 227 if let authContext = await auth.authContext(),
+1 -6
Grain/Views/Stories/StoryViewer.swift
··· 483 483 HStack(spacing: 0) { 484 484 Color.clear 485 485 .contentShape(Rectangle()) 486 - .onTapGesture(count: 2, coordinateSpace: .named("storyHearts")) { location in 487 - doubleTapLike(at: location) 488 - } 489 486 .onTapGesture { goToPrevious() } 490 487 .frame(width: geo.size.width / 3) 491 488 Color.clear 492 489 .contentShape(Rectangle()) 493 - .onTapGesture(count: 2, coordinateSpace: .named("storyHearts")) { location in 494 - doubleTapLike(at: location) 495 - } 496 490 .onTapGesture { goToNext() } 497 491 .frame(maxWidth: .infinity) 498 492 } ··· 1006 1000 try await client.deleteRecord(collection: "social.grain.story", rkey: rkey, auth: authContext) 1007 1001 stories.removeAll { $0.uri == story.uri } 1008 1002 if stories.isEmpty { 1003 + storyStatusCache.remove(did: story.creator.did) 1009 1004 goToNextAuthor() 1010 1005 } else { 1011 1006 currentStoryIndex = min(currentStoryIndex, stories.count - 1)