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 My Feeds management screen with camera/location discovery

- Add FeedsManagementView with pin/unpin, drag-to-reorder, and
camera/location browsing sections
- Add CameraFeedView for previewing camera feeds before pinning
- Add "My Feeds" entry to feed switcher menu
- Add For You to default pinned feeds
- Add reorderFeeds to FeedPreferencesViewModel

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

+413
+1
Grain/API/Endpoints/FeedEndpoints.swift
··· 36 36 static let defaults: [PinnedFeed] = [ 37 37 PinnedFeed(id: "recent", label: "Recent", type: "feed", path: "/"), 38 38 PinnedFeed(id: "following", label: "Following", type: "feed", path: "/feeds/following"), 39 + PinnedFeed(id: "foryou", label: "For You", type: "feed", path: "/feeds/for-you"), 39 40 ] 40 41 41 42 /// The feed name parameter for the API (e.g. "recent", "following", "camera", "location", "hashtag")
+10
Grain/ViewModels/FeedPreferencesViewModel.swift
··· 66 66 } 67 67 } 68 68 69 + func reorderFeeds(_ feeds: [PinnedFeed], auth: AuthContext?) async { 70 + let original = pinnedFeeds 71 + pinnedFeeds = feeds 72 + do { 73 + try await client.putPinnedFeeds(feeds, auth: auth) 74 + } catch { 75 + pinnedFeeds = original 76 + } 77 + } 78 + 69 79 func unpinFeed(_ id: String, auth: AuthContext?) async { 70 80 let original = pinnedFeeds 71 81 pinnedFeeds.removeAll { $0.id == id }
+161
Grain/Views/Feed/CameraFeedView.swift
··· 1 + import SwiftUI 2 + 3 + struct CameraFeedView: View { 4 + @Environment(AuthManager.self) private var auth 5 + @State private var galleries: [GrainGallery] = [] 6 + @State private var cursor: String? 7 + @State private var isLoading = false 8 + @State private var isPinned = false 9 + @State private var selectedUri: String? 10 + @State private var selectedProfileDid: String? 11 + @State private var selectedHashtag: String? 12 + @State private var selectedLocation: LocationDestination? 13 + @State private var zoomState = ImageZoomState() 14 + @State private var cardStoryAuthor: GrainStoryAuthor? 15 + 16 + let client: XRPCClient 17 + let camera: String 18 + 19 + private var feedId: String { 20 + "camera:\(camera)" 21 + } 22 + 23 + var body: some View { 24 + ScrollView { 25 + LazyVStack(spacing: 0) { 26 + ForEach($galleries) { $gallery in 27 + GalleryCardView(gallery: $gallery, client: client, onNavigate: { 28 + selectedUri = gallery.uri 29 + }, onProfileTap: { did in 30 + selectedProfileDid = did 31 + }, onHashtagTap: { tag in 32 + selectedHashtag = tag 33 + }, onLocationTap: { h3, name in 34 + selectedLocation = LocationDestination(h3Index: h3, name: name) 35 + }, onStoryTap: { author in 36 + cardStoryAuthor = author 37 + }) 38 + .onAppear { 39 + if gallery.id == galleries.last?.id { 40 + Task { await loadMore() } 41 + } 42 + } 43 + } 44 + 45 + if isLoading { 46 + ProgressView() 47 + .padding() 48 + } 49 + } 50 + } 51 + .environment(zoomState) 52 + .modifier(ImageZoomOverlay(zoomState: zoomState)) 53 + .navigationTitle(camera) 54 + .navigationBarTitleDisplayMode(.inline) 55 + .toolbar { 56 + ToolbarItem(placement: .topBarTrailing) { 57 + Menu { 58 + Button { 59 + Task { await togglePin() } 60 + } label: { 61 + Label(isPinned ? "Unpin Feed" : "Pin Feed", 62 + systemImage: isPinned ? "pin.slash" : "pin") 63 + } 64 + } label: { 65 + Image(systemName: "ellipsis") 66 + .font(.system(size: 16, weight: .medium)) 67 + } 68 + .tint(.primary) 69 + } 70 + } 71 + .task { 72 + guard !isPreview else { return } 73 + await checkPinned() 74 + } 75 + .navigationDestination(item: $selectedUri) { uri in 76 + GalleryDetailView(client: client, galleryUri: uri) 77 + } 78 + .navigationDestination(item: $selectedProfileDid) { did in 79 + ProfileView(client: client, did: did) 80 + } 81 + .navigationDestination(item: $selectedHashtag) { tag in 82 + HashtagFeedView(client: client, tag: tag) 83 + } 84 + .navigationDestination(item: $selectedLocation) { loc in 85 + LocationFeedView(client: client, h3Index: loc.h3Index, locationName: loc.name) 86 + } 87 + .fullScreenCover(item: $cardStoryAuthor) { author in 88 + StoryViewer( 89 + authors: [author], 90 + client: client, 91 + onProfileTap: { did in 92 + cardStoryAuthor = nil 93 + selectedProfileDid = did 94 + }, 95 + onDismiss: { cardStoryAuthor = nil } 96 + ) 97 + .environment(auth) 98 + } 99 + .task { 100 + guard !isPreview else { 101 + #if DEBUG 102 + galleries = PreviewData.galleries 103 + #endif 104 + return 105 + } 106 + if galleries.isEmpty { 107 + await loadInitial() 108 + } 109 + } 110 + } 111 + 112 + private func loadInitial() async { 113 + isLoading = true 114 + do { 115 + let response = try await client.getFeed(feed: "camera", camera: camera, auth: auth.authContext()) 116 + galleries = response.items ?? [] 117 + cursor = response.cursor 118 + } catch {} 119 + isLoading = false 120 + } 121 + 122 + private func loadMore() async { 123 + guard !isLoading, let cursor else { return } 124 + isLoading = true 125 + do { 126 + let response = try await client.getFeed(feed: "camera", cursor: cursor, camera: camera, auth: auth.authContext()) 127 + galleries.append(contentsOf: response.items ?? []) 128 + self.cursor = response.cursor 129 + } catch {} 130 + isLoading = false 131 + } 132 + 133 + private func checkPinned() async { 134 + do { 135 + let response = try await client.getPreferences(auth: auth.authContext()) 136 + isPinned = response.preferences.pinnedFeeds?.contains(where: { $0.id == feedId }) ?? false 137 + } catch {} 138 + } 139 + 140 + private func togglePin() async { 141 + do { 142 + let response = try await client.getPreferences(auth: auth.authContext()) 143 + var feeds = response.preferences.pinnedFeeds ?? PinnedFeed.defaults 144 + if isPinned { 145 + feeds.removeAll { $0.id == feedId } 146 + } else { 147 + feeds.append(PinnedFeed(id: feedId, label: camera, type: "camera", path: "/camera/\(camera)")) 148 + } 149 + try await client.putPinnedFeeds(feeds, auth: auth.authContext()) 150 + isPinned.toggle() 151 + } catch {} 152 + } 153 + } 154 + 155 + #Preview { 156 + CameraFeedView(client: XRPCClient(baseURL: AuthManager.serverURL), camera: "Sony A7III") 157 + .environment(AuthManager()) 158 + .environment(StoryStatusCache()) 159 + .environment(ViewedStoryStorage()) 160 + .environment(LabelDefinitionsCache()) 161 + }
+11
Grain/Views/Feed/FeedView.swift
··· 12 12 @State private var deepLinkProfileDid: String? 13 13 @State private var deepLinkGalleryUri: String? 14 14 @State private var deepLinkStoryAuthor: GrainStoryAuthor? 15 + @State private var showFeedsManagement = false 15 16 16 17 let client: XRPCClient 17 18 @Binding var pendingDeepLink: DeepLink? ··· 58 59 trailingToolbarContent 59 60 } 60 61 .sharedBackgroundVisibility(.hidden) 62 + } 63 + .navigationDestination(isPresented: $showFeedsManagement) { 64 + FeedsManagementView(prefsViewModel: prefsViewModel, client: client) 61 65 } 62 66 .task { 63 67 guard !isPreview else { return } ··· 151 155 } label: { 152 156 Label("Unpin", systemImage: "pin.slash") 153 157 } 158 + } 159 + 160 + Divider() 161 + Button { 162 + showFeedsManagement = true 163 + } label: { 164 + Label("My Feeds", systemImage: "list.bullet") 154 165 } 155 166 } label: { 156 167 HStack(spacing: 4) {
+230
Grain/Views/Feed/FeedsManagementView.swift
··· 1 + import SwiftUI 2 + 3 + struct FeedsManagementView: View { 4 + @Environment(AuthManager.self) private var auth 5 + @Bindable var prefsViewModel: FeedPreferencesViewModel 6 + let client: XRPCClient 7 + 8 + @State private var cameras: [CameraItem] = [] 9 + @State private var locations: [LocationItem] = [] 10 + @State private var isLoadingDiscovery = false 11 + @State private var selectedCamera: String? 12 + @State private var selectedLocation: LocationDestination? 13 + 14 + private var coreIds: Set<String> { 15 + Set(PinnedFeed.defaults.map(\.id)) 16 + } 17 + 18 + private var unpinnedDefaults: [PinnedFeed] { 19 + let pinnedIds = Set(prefsViewModel.pinnedFeeds.map(\.id)) 20 + return PinnedFeed.defaults.filter { !pinnedIds.contains($0.id) } 21 + } 22 + 23 + var body: some View { 24 + List { 25 + Section { 26 + ForEach(prefsViewModel.pinnedFeeds) { feed in 27 + feedRow(feed: feed, showPin: true) 28 + } 29 + .onMove { from, to in 30 + var feeds = prefsViewModel.pinnedFeeds 31 + feeds.move(fromOffsets: from, toOffset: to) 32 + Task { await prefsViewModel.reorderFeeds(feeds, auth: auth.authContext()) } 33 + } 34 + } header: { 35 + Text("Pinned Feeds") 36 + } 37 + 38 + if !unpinnedDefaults.isEmpty { 39 + Section { 40 + ForEach(unpinnedDefaults) { feed in 41 + feedRow(feed: feed, showPin: false) 42 + } 43 + } header: { 44 + Text("Available Feeds") 45 + } 46 + } 47 + 48 + if !cameras.isEmpty { 49 + Section { 50 + ForEach(cameras) { camera in 51 + cameraRow(camera: camera) 52 + } 53 + } header: { 54 + Text("Cameras") 55 + } 56 + } 57 + 58 + if !locations.isEmpty { 59 + Section { 60 + ForEach(locations) { location in 61 + locationRow(location: location) 62 + } 63 + } header: { 64 + Text("Locations") 65 + } 66 + } 67 + } 68 + .navigationTitle("My Feeds") 69 + .navigationBarTitleDisplayMode(.inline) 70 + .environment(\.editMode, .constant(.active)) 71 + .navigationDestination(item: $selectedCamera) { camera in 72 + CameraFeedView(client: client, camera: camera) 73 + } 74 + .navigationDestination(item: $selectedLocation) { loc in 75 + LocationFeedView(client: client, h3Index: loc.h3Index, locationName: loc.name) 76 + } 77 + .task { 78 + guard !isLoadingDiscovery else { return } 79 + isLoadingDiscovery = true 80 + async let camerasReq = client.getCameras(auth: auth.authContext()) 81 + async let locationsReq = client.getLocations(auth: auth.authContext()) 82 + do { 83 + let (c, l) = try await (camerasReq, locationsReq) 84 + cameras = c.cameras ?? [] 85 + locations = l.locations ?? [] 86 + } catch {} 87 + isLoadingDiscovery = false 88 + } 89 + } 90 + 91 + private func feedRow(feed: PinnedFeed, showPin: Bool) -> some View { 92 + HStack(spacing: 12) { 93 + Image(systemName: iconName(for: feed)) 94 + .font(.body) 95 + .foregroundStyle(Color.accentColor) 96 + .frame(width: 32, height: 32) 97 + .background(Color(.secondarySystemGroupedBackground)) 98 + .clipShape(RoundedRectangle(cornerRadius: 8)) 99 + 100 + Text(feed.label) 101 + .font(.body) 102 + 103 + Spacer() 104 + 105 + if showPin { 106 + Button { 107 + Task { await prefsViewModel.unpinFeed(feed.id, auth: auth.authContext()) } 108 + } label: { 109 + Image(systemName: "pin.slash") 110 + .font(.subheadline) 111 + .foregroundStyle(.secondary) 112 + } 113 + .buttonStyle(.plain) 114 + } else { 115 + Button { 116 + Task { await prefsViewModel.pinFeed(feed, auth: auth.authContext()) } 117 + } label: { 118 + Image(systemName: "pin") 119 + .font(.subheadline) 120 + .foregroundStyle(Color.accentColor) 121 + } 122 + .buttonStyle(.plain) 123 + } 124 + } 125 + .moveDisabled(!showPin) 126 + } 127 + 128 + private func cameraRow(camera: CameraItem) -> some View { 129 + let feedId = "camera:\(camera.camera)" 130 + let pinned = prefsViewModel.isPinned(feedId) 131 + return Button { 132 + selectedCamera = camera.camera 133 + } label: { 134 + HStack(spacing: 12) { 135 + Image(systemName: "camera") 136 + .font(.body) 137 + .foregroundStyle(Color.accentColor) 138 + .frame(width: 32, height: 32) 139 + .background(Color(.secondarySystemGroupedBackground)) 140 + .clipShape(RoundedRectangle(cornerRadius: 8)) 141 + 142 + VStack(alignment: .leading) { 143 + Text(camera.camera) 144 + .font(.body) 145 + Text("\(camera.photoCount) photos") 146 + .font(.caption) 147 + .foregroundStyle(.secondary) 148 + } 149 + 150 + Spacer() 151 + 152 + if pinned { 153 + Image(systemName: "pin.fill") 154 + .font(.caption) 155 + .foregroundStyle(Color.accentColor) 156 + } 157 + 158 + Image(systemName: "chevron.right") 159 + .font(.caption.weight(.semibold)) 160 + .foregroundStyle(.tertiary) 161 + } 162 + } 163 + .buttonStyle(.plain) 164 + .moveDisabled(true) 165 + } 166 + 167 + private func locationRow(location: LocationItem) -> some View { 168 + let feedId = "location:\(location.h3Index)" 169 + let pinned = prefsViewModel.isPinned(feedId) 170 + return Button { 171 + selectedLocation = LocationDestination(h3Index: location.h3Index, name: location.name) 172 + } label: { 173 + HStack(spacing: 12) { 174 + Image(systemName: "mappin.and.ellipse") 175 + .font(.body) 176 + .foregroundStyle(Color.accentColor) 177 + .frame(width: 32, height: 32) 178 + .background(Color(.secondarySystemGroupedBackground)) 179 + .clipShape(RoundedRectangle(cornerRadius: 8)) 180 + 181 + VStack(alignment: .leading) { 182 + Text(location.name) 183 + .font(.body) 184 + Text("\(location.galleryCount) galleries") 185 + .font(.caption) 186 + .foregroundStyle(.secondary) 187 + } 188 + 189 + Spacer() 190 + 191 + if pinned { 192 + Image(systemName: "pin.fill") 193 + .font(.caption) 194 + .foregroundStyle(Color.accentColor) 195 + } 196 + 197 + Image(systemName: "chevron.right") 198 + .font(.caption.weight(.semibold)) 199 + .foregroundStyle(.tertiary) 200 + } 201 + } 202 + .buttonStyle(.plain) 203 + .moveDisabled(true) 204 + } 205 + 206 + private func iconName(for feed: PinnedFeed) -> String { 207 + switch feed.id { 208 + case "recent": "clock" 209 + case "following": "person.2" 210 + case "foryou": "sparkles" 211 + default: 212 + switch feed.type { 213 + case "camera": "camera" 214 + case "location": "mappin.and.ellipse" 215 + case "hashtag": "number" 216 + default: "pin" 217 + } 218 + } 219 + } 220 + } 221 + 222 + #Preview { 223 + NavigationStack { 224 + FeedsManagementView( 225 + prefsViewModel: FeedPreferencesViewModel(client: XRPCClient(baseURL: AuthManager.serverURL)), 226 + client: XRPCClient(baseURL: AuthManager.serverURL) 227 + ) 228 + } 229 + .environment(AuthManager()) 230 + }