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 comment favorites with notifications

Add heart button to CommentRow for favoriting comments with optimistic
updates. Add comment-favorite notification reason with proper icon,
text, grouping, and navigation to parent gallery/story.

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

+99 -5
+7
Grain/Models/Views/CommentModels.swift
··· 12 12 var focus: AnyCodable? 13 13 var replyTo: String? 14 14 let createdAt: String 15 + var favCount: Int? 16 + var viewer: CommentViewerState? 15 17 var muted: Bool? 16 18 17 19 var id: String { 18 20 uri 19 21 } 20 22 } 23 + 24 + /// social.grain.comment.defs#viewerState 25 + struct CommentViewerState: Codable, Sendable { 26 + var fav: String? 27 + }
+2 -1
Grain/Models/Views/NotificationModels.swift
··· 28 28 case galleryComment = "gallery-comment" 29 29 case galleryCommentMention = "gallery-comment-mention" 30 30 case galleryMention = "gallery-mention" 31 + case commentFavorite = "comment-favorite" 31 32 case storyFavorite = "story-favorite" 32 33 case storyComment = "story-comment" 33 34 case reply ··· 36 37 37 38 var isGroupable: Bool { 38 39 switch self { 39 - case .galleryFavorite, .storyFavorite, .follow: true 40 + case .galleryFavorite, .storyFavorite, .commentFavorite, .follow: true 40 41 default: false 41 42 } 42 43 }
+3
Grain/Views/Comments/CommentSheetContent.swift
··· 9 9 let comments: [GrainComment] 10 10 let isLoading: Bool 11 11 let isPostingComment: Bool 12 + var client: XRPCClient? 12 13 13 14 var onPost: (String, GrainComment?) async -> Void 14 15 var onDelete: (GrainComment) async -> Void ··· 101 102 ForEach(threadedComments, id: \.root.id) { thread in 102 103 CommentRow( 103 104 comment: thread.root, 105 + client: client ?? XRPCClient(baseURL: AuthManager.serverURL), 104 106 userDID: auth.userDID, 105 107 isOwn: thread.root.author.did == auth.userDID, 106 108 isReply: false, ··· 114 116 ForEach(thread.replies) { reply in 115 117 CommentRow( 116 118 comment: reply, 119 + client: client ?? XRPCClient(baseURL: AuthManager.serverURL), 117 120 userDID: auth.userDID, 118 121 isOwn: reply.author.did == auth.userDID, 119 122 isReply: true,
+1
Grain/Views/Gallery/CommentSheetView.swift
··· 37 37 comments: viewModel.comments, 38 38 isLoading: viewModel.isLoading, 39 39 isPostingComment: isPostingComment, 40 + client: client, 40 41 onPost: { text, replyTo in 41 42 guard let authContext = await auth.authContext() else { return } 42 43 isPostingComment = true
+68 -1
Grain/Views/Gallery/GalleryDetailView.swift
··· 169 169 struct CommentRow: View { 170 170 @Environment(StoryStatusCache.self) private var storyStatusCache 171 171 @Environment(ViewedStoryStorage.self) private var viewedStories 172 + @Environment(AuthManager.self) private var auth 172 173 let comment: GrainComment 174 + let client: XRPCClient 173 175 let userDID: String? 174 176 var isOwn: Bool = false 175 177 var isReply: Bool = false ··· 179 181 var onReply: (() -> Void)? 180 182 var onDelete: (() -> Void)? 181 183 @State private var expanded = false 184 + @State private var favUri: String? 185 + @State private var favCountOffset: Int = 0 186 + @State private var isMutating = false 187 + 188 + private var isFaved: Bool { 189 + favUri != nil 190 + } 191 + 192 + private var displayFavCount: Int { 193 + (comment.favCount ?? 0) + favCountOffset 194 + } 182 195 183 196 var body: some View { 184 197 if comment.muted == true, !expanded { ··· 256 269 .padding(.top, 2) 257 270 } 258 271 259 - Spacer() 272 + Spacer(minLength: 0) 273 + 274 + VStack(spacing: 2) { 275 + Button { 276 + Task { await toggleFav() } 277 + } label: { 278 + Image(systemName: isFaved ? "heart.fill" : "heart") 279 + .font(.system(size: 16)) 280 + .foregroundStyle(isFaved ? Color.heart : .secondary) 281 + } 282 + .buttonStyle(.plain) 283 + .disabled(isMutating) 284 + 285 + if displayFavCount > 0 { 286 + Text("\(displayFavCount)") 287 + .font(.system(size: 11)) 288 + .foregroundStyle(.secondary) 289 + } 290 + } 291 + .frame(maxHeight: .infinity) 260 292 } 261 293 .padding(.leading, isReply ? 50 : 12) 262 294 .padding(.trailing, 12) 263 295 .padding(.vertical, 8) 296 + .task { 297 + favUri = comment.viewer?.fav 298 + } 299 + } 300 + } 301 + 302 + private func toggleFav() async { 303 + guard !isMutating else { return } 304 + guard let authCtx = await auth.authContext() else { return } 305 + isMutating = true 306 + defer { isMutating = false } 307 + 308 + if let uri = favUri { 309 + // Optimistic unfavorite 310 + favUri = nil 311 + favCountOffset -= 1 312 + do { 313 + try await FavoriteService.delete(favoriteUri: uri, client: client, auth: authCtx) 314 + } catch { 315 + // Revert 316 + favUri = uri 317 + favCountOffset += 1 318 + } 319 + } else { 320 + // Optimistic favorite 321 + favUri = "pending" 322 + favCountOffset += 1 323 + do { 324 + let result = try await FavoriteService.create(subject: comment.uri, client: client, auth: authCtx) 325 + favUri = result.uri 326 + } catch { 327 + // Revert 328 + favUri = nil 329 + favCountOffset -= 1 330 + } 264 331 } 265 332 } 266 333 }
+17 -3
Grain/Views/Notifications/NotificationsView.swift
··· 174 174 private func handleTap(_ notification: GrainNotification) { 175 175 if notification.reasonType == .follow { 176 176 onProfileTap(notification.author.did) 177 + } else if notification.reasonType == .commentFavorite { 178 + // Navigate to the parent gallery or story 179 + if let galleryUri = notification.galleryUri { 180 + onGalleryTap(galleryUri) 181 + } else if let storyUri = notification.storyUri { 182 + Task { 183 + if let story = try? await client.getStory(uri: storyUri, auth: authContext()).story { 184 + onStoryTap(story) 185 + } 186 + } 187 + } 177 188 } else if notification.reasonType == .storyFavorite || notification.reasonType == .storyComment { 178 189 if let storyUri = notification.storyUri { 179 190 Task { ··· 197 208 198 209 private var iconName: String { 199 210 switch reason { 200 - case .galleryFavorite, .storyFavorite: "heart.fill" 211 + case .galleryFavorite, .storyFavorite, .commentFavorite: "heart.fill" 201 212 case .follow: "person.fill.badge.plus" 202 213 case .galleryComment, .storyComment: "text.bubble.fill" 203 214 case .reply: "arrowshape.turn.up.backward.fill" ··· 208 219 209 220 private var label: String { 210 221 switch reason { 211 - case .galleryFavorite, .storyFavorite: "Liked" 222 + case .galleryFavorite, .storyFavorite, .commentFavorite: "Liked" 212 223 case .follow: "Followed" 213 224 case .galleryComment, .storyComment: "Commented" 214 225 case .reply: "Replied" ··· 219 230 220 231 private var iconColor: Color { 221 232 switch reason { 222 - case .galleryFavorite, .storyFavorite: .heart 233 + case .galleryFavorite, .storyFavorite, .commentFavorite: .heart 223 234 default: .accentColor 224 235 } 225 236 } ··· 318 329 private var reasonText: String { 319 330 switch group.notification.reasonType { 320 331 case .galleryFavorite: "favorited your gallery" 332 + case .commentFavorite: "favorited your comment" 321 333 case .storyFavorite: "favorited your story" 322 334 case .follow: "followed you" 323 335 default: "" ··· 390 402 case .galleryComment: "commented on your gallery" 391 403 case .galleryCommentMention: "mentioned you in a comment" 392 404 case .galleryMention: "mentioned you in a gallery" 405 + case .commentFavorite: "favorited your comment" 393 406 case .storyFavorite: "favorited your story" 394 407 case .storyComment: "commented on your story" 395 408 case .reply: "replied to your comment" ··· 472 485 let count = group.authorCount 473 486 switch group.notification.reasonType { 474 487 case .galleryFavorite: return "\(count) Favorites" 488 + case .commentFavorite: return "\(count) Favorites" 475 489 case .storyFavorite: return "\(count) Favorites" 476 490 case .follow: return "\(count) Followers" 477 491 default: return "\(count) People"
+1
Grain/Views/Stories/StoryCommentSheet.swift
··· 20 20 comments: viewModel.comments, 21 21 isLoading: viewModel.isLoading, 22 22 isPostingComment: viewModel.isPostingComment, 23 + client: client, 23 24 onPost: { text, replyTo in 24 25 guard let authContext = await auth.authContext() else { return } 25 26 await viewModel.postComment(text: text, storyUri: storyUri, replyTo: replyTo, auth: authContext)