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 story favorites, comments, and notifications

- Add expired field to GrainStory model
- Add storyUri/storyThumb and story notification types to notifications
- Story notification tap fetches and opens specific story in StoryViewer
- Handle expired story deep links with single-story viewer
- Remove StoryFavoriteCache (server now provides viewer state)
- Comment sheet locked to .large detent for smooth keyboard behavior
- Remove comment bubble icon, comment preview gets subtle background
- Full comment input capsule is tappable

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

+93 -121
+6 -6
Grain/API/Endpoints/FeedEndpoints.swift
··· 11 11 let gallery: GrainGallery 12 12 } 13 13 14 - struct GetGalleryThreadResponse: Codable, Sendable { 14 + struct GetCommentThreadResponse: Codable, Sendable { 15 15 let comments: [GrainComment] 16 16 var cursor: String? 17 17 var totalCount: Int? ··· 91 91 try await query("social.grain.unspecced.getGallery", params: ["gallery": uri], auth: auth, as: GetGalleryResponse.self) 92 92 } 93 93 94 - func getGalleryThread( 95 - gallery: String, 94 + func getCommentThread( 95 + subject: String, 96 96 limit: Int = 20, 97 97 cursor: String? = nil, 98 98 auth: AuthContext? = nil 99 - ) async throws -> GetGalleryThreadResponse { 100 - var params = ["gallery": gallery, "limit": String(limit)] 99 + ) async throws -> GetCommentThreadResponse { 100 + var params = ["subject": subject, "limit": String(limit)] 101 101 if let cursor { params["cursor"] = cursor } 102 - return try await query("social.grain.unspecced.getGalleryThread", params: params, auth: auth, as: GetGalleryThreadResponse.self) 102 + return try await query("social.grain.unspecced.getCommentThread", params: params, auth: auth, as: GetCommentThreadResponse.self) 103 103 } 104 104 105 105 func putPinnedFeeds(_ feeds: [PinnedFeed], auth: AuthContext? = nil) async throws {
-2
Grain/GrainApp.swift
··· 18 18 @State private var storyStatusCache = StoryStatusCache() 19 19 @State private var viewedStoryStorage = ViewedStoryStorage() 20 20 @State private var labelDefsCache = LabelDefinitionsCache() 21 - @State private var storyFavoriteCache = StoryFavoriteCache() 22 21 @State private var pendingDeepLink: DeepLink? 23 22 24 23 var body: some Scene { ··· 31 30 .environment(storyStatusCache) 32 31 .environment(viewedStoryStorage) 33 32 .environment(labelDefsCache) 34 - .environment(storyFavoriteCache) 35 33 .tint(Color("AccentColor")) 36 34 .onAppear { 37 35 viewedStoryStorage.cleanup()
+4
Grain/Models/Views/NotificationModels.swift
··· 9 9 var galleryUri: String? 10 10 var galleryTitle: String? 11 11 var galleryThumb: String? 12 + var storyUri: String? 13 + var storyThumb: String? 12 14 var commentText: String? 13 15 var replyToText: String? 14 16 ··· 26 28 case galleryComment = "gallery-comment" 27 29 case galleryCommentMention = "gallery-comment-mention" 28 30 case galleryMention = "gallery-mention" 31 + case storyFavorite = "story-favorite" 32 + case storyComment = "story-comment" 29 33 case reply 30 34 case follow 31 35 case unknown
+1
Grain/Models/Views/StoryModels.swift
··· 12 12 var address: Address? 13 13 let createdAt: String 14 14 var labels: [ATLabel]? 15 + var expired: Bool? 15 16 var crossPost: CrossPostInfo? 16 17 var viewer: StoryViewerState? 17 18
+2 -2
Grain/ViewModels/GalleryDetailViewModel.swift
··· 22 22 23 23 do { 24 24 let galleryResult = try await client.getGallery(uri: uri, auth: auth) 25 - let commentsResult = try await client.getGalleryThread(gallery: uri, auth: auth) 25 + let commentsResult = try await client.getCommentThread(subject: uri, auth: auth) 26 26 gallery = galleryResult.gallery 27 27 comments = commentsResult.comments 28 28 commentCursor = commentsResult.cursor ··· 38 38 isLoading = true 39 39 40 40 do { 41 - let response = try await client.getGalleryThread(gallery: galleryUri, cursor: cursor, auth: auth) 41 + let response = try await client.getCommentThread(subject: galleryUri, cursor: cursor, auth: auth) 42 42 comments.append(contentsOf: response.comments) 43 43 commentCursor = response.cursor 44 44 hasMoreComments = response.cursor != nil
+4 -4
Grain/ViewModels/StoryCommentsViewModel.swift
··· 10 10 var comments: [GrainComment] = [] 11 11 // `firstComment` is `response.comments.first`, which is the oldest/chronological 12 12 // comment on the story. Ideally this would prefer comments from followed users, 13 - // then fall back to most-liked, but the getGalleryThread endpoint doesn't return 13 + // then fall back to most-liked, but the getCommentThread endpoint doesn't return 14 14 // viewer state on authors or likeCount on comments. Bumping the preview fetch 15 15 // from limit=1 to support client-side selection also adds ~100ms per request. 16 16 // Revisit when the backend hydrates those fields. ··· 67 67 } 68 68 69 69 do { 70 - let response = try await client.getGalleryThread(gallery: storyUri, limit: 1, auth: auth) 70 + let response = try await client.getCommentThread(subject: storyUri, limit: 1, auth: auth) 71 71 let preview = CachedPreview( 72 72 comment: response.comments.first, 73 73 count: response.totalCount ?? response.comments.count ··· 95 95 hasMoreComments = true 96 96 97 97 do { 98 - let response = try await client.getGalleryThread(gallery: storyUri, auth: auth) 98 + let response = try await client.getCommentThread(subject: storyUri, auth: auth) 99 99 comments = response.comments 100 100 commentCursor = response.cursor 101 101 hasMoreComments = response.cursor != nil ··· 121 121 isLoading = true 122 122 123 123 do { 124 - let response = try await client.getGalleryThread(gallery: storyUri, cursor: cursor, auth: auth) 124 + let response = try await client.getCommentThread(subject: storyUri, cursor: cursor, auth: auth) 125 125 comments.append(contentsOf: response.comments) 126 126 commentCursor = response.cursor 127 127 hasMoreComments = response.cursor != nil
-44
Grain/ViewModels/StoryFavoriteCache.swift
··· 1 - import Foundation 2 - 3 - /// Persistent cache of story favorites, keyed by story URI. Bridges the gap 4 - /// until the grain appview returns `viewer.fav` on story responses — once it 5 - /// does, this becomes redundant and can be removed. 6 - @Observable 7 - @MainActor 8 - final class StoryFavoriteCache { 9 - /// storyUri → favorite record URI. Presence means liked. 10 - private(set) var favorites: [String: String] = [:] 11 - 12 - private static let key = "storyFavorites" 13 - 14 - init() { 15 - load() 16 - } 17 - 18 - func isLiked(_ storyUri: String) -> Bool { 19 - favorites[storyUri] != nil 20 - } 21 - 22 - func favUri(for storyUri: String) -> String? { 23 - favorites[storyUri] 24 - } 25 - 26 - func like(_ storyUri: String, favUri: String) { 27 - favorites[storyUri] = favUri 28 - save() 29 - } 30 - 31 - func unlike(_ storyUri: String) { 32 - favorites.removeValue(forKey: storyUri) 33 - save() 34 - } 35 - 36 - private func load() { 37 - guard let data = UserDefaults.standard.dictionary(forKey: Self.key) as? [String: String] else { return } 38 - favorites = data 39 - } 40 - 41 - private func save() { 42 - UserDefaults.standard.set(favorites, forKey: Self.key) 43 - } 44 - }
+28 -6
Grain/Views/Feed/FeedView.swift
··· 15 15 @State private var deepLinkProfileDid: String? 16 16 @State private var deepLinkGalleryUri: String? 17 17 @State private var deepLinkStoryAuthor: GrainStoryAuthor? 18 + @State private var deepLinkStory: GrainStory? 18 19 @State private var showFeedsManagement = false 19 20 @State private var feedRefreshID = UUID() 20 21 ··· 138 139 ) 139 140 .environment(auth) 140 141 } 142 + .fullScreenCover(item: $deepLinkStory) { story in 143 + StoryViewer( 144 + authors: [GrainStoryAuthor( 145 + profile: story.creator, 146 + storyCount: 1, 147 + latestAt: story.createdAt 148 + )], 149 + initialStories: [story], 150 + client: client, 151 + onProfileTap: { did in 152 + deepLinkStory = nil 153 + deepLinkProfileDid = did 154 + }, 155 + onDismiss: { deepLinkStory = nil } 156 + ) 157 + .environment(auth) 158 + } 141 159 .task { 142 160 consumeDeepLink() 143 161 } ··· 217 235 deepLinkProfileDid = did 218 236 case .gallery: 219 237 deepLinkGalleryUri = link.galleryUri 220 - case let .story(did, _): 221 - Task { await openStoryDeepLink(did: did) } 238 + case let .story(did, rkey): 239 + Task { await openStoryDeepLink(did: did, rkey: rkey) } 222 240 } 223 241 } 224 242 225 - private func openStoryDeepLink(did: String) async { 243 + private func openStoryDeepLink(did: String, rkey: String) async { 226 244 do { 227 245 let response = try await client.getStories(actor: did, auth: auth.authContext()) 228 246 let count = response.stories.count ··· 233 251 latestAt: response.stories.last?.createdAt ?? "" 234 252 ) 235 253 } else { 236 - // Story expired — fall back to profile 237 - deepLinkProfileDid = did 254 + // Story expired — fetch the specific story 255 + let storyUri = "at://\(did)/social.grain.story/\(rkey)" 256 + if let story = try await client.getStory(uri: storyUri, auth: auth.authContext()).story { 257 + deepLinkStory = story 258 + } else { 259 + deepLinkProfileDid = did 260 + } 238 261 } 239 262 } catch { 240 - // Fall back to profile on error 241 263 deepLinkProfileDid = did 242 264 } 243 265 }
+32 -1
Grain/Views/Notifications/NotificationsView.swift
··· 3 3 4 4 struct NotificationsView: View { 5 5 @Environment(AuthManager.self) private var auth 6 + @Environment(StoryStatusCache.self) private var storyStatusCache 6 7 var viewModel: NotificationsViewModel 7 8 @State private var selectedGalleryUri: String? 8 9 @State private var selectedProfileDid: String? 9 10 @State private var cardStoryAuthor: GrainStoryAuthor? 11 + @State private var selectedStory: GrainStory? 10 12 let client: XRPCClient 11 13 12 14 init(client: XRPCClient, viewModel: NotificationsViewModel) { ··· 27 29 .onTapGesture { 28 30 if notification.reasonType == .follow { 29 31 selectedProfileDid = notification.author.did 32 + } else if notification.reasonType == .storyFavorite || notification.reasonType == .storyComment { 33 + if let storyUri = notification.storyUri { 34 + Task { 35 + if let story = try? await client.getStory(uri: storyUri, auth: auth.authContext()).story { 36 + selectedStory = story 37 + } 38 + } 39 + } else { 40 + selectedProfileDid = notification.author.did 41 + } 30 42 } else if let galleryUri = notification.galleryUri { 31 43 selectedGalleryUri = galleryUri 32 44 } ··· 76 88 ) 77 89 .environment(auth) 78 90 } 91 + .fullScreenCover(item: $selectedStory) { story in 92 + StoryViewer( 93 + authors: [GrainStoryAuthor( 94 + profile: story.creator, 95 + storyCount: 1, 96 + latestAt: story.createdAt 97 + )], 98 + initialStories: [story], 99 + client: client, 100 + onProfileTap: { did in 101 + selectedStory = nil 102 + selectedProfileDid = did 103 + }, 104 + onDismiss: { selectedStory = nil } 105 + ) 106 + .environment(auth) 107 + } 79 108 .task(id: viewModel.unseenCount) { 80 109 if viewModel.notifications.isEmpty || viewModel.unseenCount > 0 { 81 110 await viewModel.loadInitial(auth: auth.authContext()) ··· 129 158 130 159 Spacer() 131 160 132 - if let thumb = notification.galleryThumb, let url = URL(string: thumb) { 161 + if let thumb = notification.galleryThumb ?? notification.storyThumb, let url = URL(string: thumb) { 133 162 LazyImage(url: url) { state in 134 163 if let image = state.image { 135 164 image.resizable() ··· 149 178 case .galleryComment: "commented on your gallery" 150 179 case .galleryCommentMention: "mentioned you in a comment" 151 180 case .galleryMention: "mentioned you" 181 + case .storyFavorite: "favorited your story" 182 + case .storyComment: "commented on your story" 152 183 case .reply: "replied to your comment" 153 184 case .follow: "followed you" 154 185 case .unknown: ""
+4 -10
Grain/Views/Stories/StoryCommentSheet.swift
··· 15 15 var onProfileTap: ((String) -> Void)? 16 16 var onDismiss: (() -> Void)? 17 17 18 - @State private var selectedDetent: PresentationDetent = .medium 19 - 20 18 var body: some View { 21 19 CommentSheetContent( 22 20 comments: viewModel.comments, ··· 33 31 onDismiss: { onDismiss?() }, 34 32 onProfileTap: onProfileTap, 35 33 dismissStyle: .done, 36 - focusOnAppear: focusInput 34 + focusOnAppear: false 37 35 ) 38 - .presentationDetents([.medium, .large], selection: $selectedDetent) 36 + .presentationDetents([.large]) 39 37 .onAppear { 40 38 scsLogger.info("[onAppear] uri=\(storyUri) focusInput=\(focusInput)") 41 39 scsSignposter.emitEvent("sheet.onAppear", "focusInput=\(focusInput)") 42 40 } 43 41 .onDisappear { 44 - scsLogger.info("[onDisappear] uri=\(storyUri) lastDetent=\(String(describing: selectedDetent))") 45 - scsSignposter.emitEvent("sheet.onDisappear", "detent=\(String(describing: selectedDetent))") 46 - } 47 - .onChange(of: selectedDetent) { _, newValue in 48 - scsLogger.info("[detent.change] now=\(String(describing: newValue))") 49 - scsSignposter.emitEvent("detent.change", "detent=\(String(describing: newValue))") 42 + scsLogger.info("[onDisappear] uri=\(storyUri)") 43 + scsSignposter.emitEvent("sheet.onDisappear") 50 44 } 51 45 .task { 52 46 scsSignposter.emitEvent("task.load.begin")
+12 -46
Grain/Views/Stories/StoryViewer.swift
··· 31 31 func resume() { 32 32 guard !isRunning else { return } 33 33 guard progress < 1.0 else { start(); return } 34 - svLogger.info("[timer.resume] called progress=\(progress)") 35 - svSignposter.emitEvent("timer.resume", "progress=\(progress)") 36 34 run(fromProgress: progress) 37 35 } 38 36 ··· 93 91 @Environment(LabelDefinitionsCache.self) private var labelDefsCache 94 92 @Environment(ViewedStoryStorage.self) private var viewedStories 95 93 @Environment(StoryStatusCache.self) private var storyStatusCache 96 - @Environment(StoryFavoriteCache.self) private var storyFavoriteCache 97 94 @Environment(StoryCommentPresenter.self) private var commentPresenter 98 95 let authors: [GrainStoryAuthor] 99 96 let client: XRPCClient ··· 585 582 586 583 // MARK: Comment preview + input bar 587 584 588 - if let currentStory { 585 + if let currentStory, currentStory.expired != true { 589 586 Group { 590 587 if let latest = commentsViewModel.firstComment, 591 588 commentsViewModel.activeStoryUri == currentStory.uri ··· 595 592 } label: { 596 593 HStack(spacing: 6) { 597 594 AvatarView(url: latest.author.avatar, size: 20, animated: false) 598 - .padding(.leading, 8) 599 - Text("**\(latest.author.displayName ?? latest.author.handle)** \(latest.text)") 595 + Text(latest.text) 600 596 .font(.caption) 601 597 .foregroundStyle(.white) 602 598 .lineLimit(1) 603 - Spacer(minLength: 0) 599 + .padding(.horizontal, 10) 600 + .padding(.vertical, 5) 601 + .background(.white.opacity(0.15), in: .capsule) 604 602 } 605 603 } 606 604 .buttonStyle(.plain) 607 605 .padding(.leading, 16) 608 606 .padding(.trailing, 64) 609 607 .padding(.bottom, 4) 610 - .transition(.opacity) 608 + .transition(.move(edge: .bottom).combined(with: .opacity)) 611 609 } 612 610 } 613 611 .animation(.easeInOut(duration: 0.2), value: commentsViewModel.firstComment?.uri) ··· 1049 1047 1050 1048 private var isFavorited: Bool { 1051 1049 guard let story = currentStory else { return false } 1052 - return story.viewer?.fav != nil || storyFavoriteCache.isLiked(story.uri) 1050 + return story.viewer?.fav != nil 1053 1051 } 1054 1052 1055 - private func bottomInputBar(interactive: Bool, story: GrainStory?) -> some View { 1056 - let isFavorited: Bool = { 1057 - guard let story else { return false } 1058 - return story.viewer?.fav != nil || storyFavoriteCache.isLiked(story.uri) 1059 - }() 1060 - 1061 - return HStack(spacing: 12) { 1062 - // Comment bubble — opens comment list 1063 - if interactive { 1064 - Button { 1065 - openCommentSheet(focusInput: false) 1066 - } label: { 1067 - Image(systemName: "bubble") 1068 - .font(.body) 1069 - .foregroundStyle(.white) 1070 - .frame(width: 36, height: 36) 1071 - .contentShape(Rectangle()) 1072 - } 1073 - .buttonStyle(.plain) 1074 - } else { 1075 - Image(systemName: "bubble") 1076 - .font(.body) 1077 - .foregroundStyle(.white) 1078 - .frame(width: 36, height: 36) 1079 - } 1080 - 1053 + private func bottomInputBar(interactive: Bool, story _: GrainStory?) -> some View { 1054 + HStack(spacing: 12) { 1081 1055 // "Add a comment..." — opens sheet with keyboard 1082 1056 if interactive { 1083 1057 Button { ··· 1089 1063 .frame(maxWidth: .infinity, alignment: .leading) 1090 1064 .padding(.horizontal, 18) 1091 1065 .padding(.vertical, 12) 1066 + .contentShape(Capsule()) 1092 1067 } 1093 1068 .buttonStyle(.plain) 1094 1069 .glassEffect(.regular, in: .capsule) ··· 1173 1148 } 1174 1149 1175 1150 let capturedViewer = stories.first(where: { $0.uri == storyUri })?.viewer 1176 - let existingFavUri = capturedViewer?.fav ?? storyFavoriteCache.favUri(for: storyUri) 1151 + let existingFavUri = capturedViewer?.fav 1177 1152 let op = existingFavUri == nil ? "like" : "unlike" 1178 1153 1179 1154 let toggleState = svSignposter.beginInterval( ··· 1197 1172 } else { 1198 1173 prevViewer = nil 1199 1174 } 1200 - storyFavoriteCache.unlike(storyUri) 1201 1175 svSignposter.emitEvent("toggleStoryFavorite.optimistic", "op=unlike uri=\(storyUri)") 1202 1176 do { 1203 1177 try await FavoriteService.delete(favoriteUri: favUri, client: client, auth: authContext) ··· 1209 1183 if let idx = indexOfCapturedStory() { 1210 1184 stories[idx].viewer = prevViewer 1211 1185 } 1212 - storyFavoriteCache.like(storyUri, favUri: favUri) 1213 1186 } 1214 1187 } else { 1215 - // Favorite — optimistic on the in-memory story only. We intentionally do 1216 - // NOT write a "pending" favUri into storyFavoriteCache because the cache 1217 - // persists to UserDefaults; a backgrounded/killed app would leave the 1218 - // bogus "pending" URI behind and break the next unfavorite attempt. 1188 + // Favorite — optimistic 1219 1189 let prevViewer: StoryViewerState? 1220 1190 if let idx = indexOfCapturedStory() { 1221 1191 prevViewer = stories[idx].viewer ··· 1230 1200 if let idx = indexOfCapturedStory() { 1231 1201 stories[idx].viewer = StoryViewerState(fav: newFavUri) 1232 1202 } 1233 - storyFavoriteCache.like(storyUri, favUri: newFavUri) 1234 1203 svSignposter.emitEvent("toggleStoryFavorite.success", "op=like uri=\(storyUri)") 1235 1204 svLogger.info("[toggleStoryFavorite] success op=like uri=\(storyUri)") 1236 1205 } else { ··· 1239 1208 if let idx = indexOfCapturedStory() { 1240 1209 stories[idx].viewer = prevViewer 1241 1210 } 1242 - storyFavoriteCache.unlike(storyUri) 1243 1211 } 1244 1212 } catch { 1245 1213 svSignposter.emitEvent("toggleStoryFavorite.error", "op=like uri=\(storyUri)") ··· 1247 1215 if let idx = indexOfCapturedStory() { 1248 1216 stories[idx].viewer = prevViewer 1249 1217 } 1250 - storyFavoriteCache.unlike(storyUri) 1251 1218 } 1252 1219 } 1253 1220 } ··· 1303 1270 .environment(LabelDefinitionsCache()) 1304 1271 .environment(ViewedStoryStorage()) 1305 1272 .environment(StoryStatusCache()) 1306 - .environment(StoryFavoriteCache()) 1307 1273 .environment(StoryCommentPresenter()) 1308 1274 }