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.

refactor: extract CommentService and FavoriteService

Both gallery and story code duplicated the record-building logic for
comment/favorite creation and the rkey extraction for deletion. Pull
these into stateless service enums so callers only handle their own
optimistic state updates.

Subtle differences (favCount tracking on galleries, viewer state types,
UI layouts, thread endpoint names) stay at call sites to keep gallery
and story concerns independent.

+100 -32
+47
Grain/Services/CommentService.swift
··· 1 + import Foundation 2 + 3 + /// Shared helpers for creating and deleting `social.grain.comment` records. 4 + /// Used by both gallery and story comment flows. 5 + enum CommentService { 6 + static let collection = "social.grain.comment" 7 + 8 + /// Creates a comment record with optional reply-to. 9 + static func create( 10 + subject: String, 11 + text: String, 12 + replyTo: String? = nil, 13 + client: XRPCClient, 14 + auth: AuthContext 15 + ) async throws -> CreateRecordResponse { 16 + var recordDict: [String: String] = [ 17 + "text": text, 18 + "subject": subject, 19 + "createdAt": DateFormatting.nowISO(), 20 + ] 21 + if let replyTo { 22 + recordDict["replyTo"] = replyTo 23 + } 24 + let record = AnyCodable(recordDict) 25 + let repo = TokenStorage.userDID ?? "" 26 + return try await client.createRecord( 27 + collection: collection, 28 + repo: repo, 29 + record: record, 30 + auth: auth 31 + ) 32 + } 33 + 34 + /// Deletes a comment by its full URI (extracts the rkey). 35 + static func delete( 36 + commentUri: String, 37 + client: XRPCClient, 38 + auth: AuthContext 39 + ) async throws { 40 + let rkey = commentUri.split(separator: "/").last.map(String.init) ?? "" 41 + try await client.deleteRecord( 42 + collection: collection, 43 + rkey: rkey, 44 + auth: auth 45 + ) 46 + } 47 + }
+41
Grain/Services/FavoriteService.swift
··· 1 + import Foundation 2 + 3 + /// Shared helpers for creating and deleting `social.grain.favorite` records. 4 + /// Used by both gallery and story favorite flows. Callers manage their own 5 + /// optimistic state updates (e.g. viewer.fav, favCount). 6 + enum FavoriteService { 7 + static let collection = "social.grain.favorite" 8 + 9 + /// Creates a favorite record pointing at the given subject URI. 10 + static func create( 11 + subject: String, 12 + client: XRPCClient, 13 + auth: AuthContext 14 + ) async throws -> CreateRecordResponse { 15 + let record = AnyCodable([ 16 + "subject": subject, 17 + "createdAt": DateFormatting.nowISO(), 18 + ]) 19 + let repo = TokenStorage.userDID ?? "" 20 + return try await client.createRecord( 21 + collection: collection, 22 + repo: repo, 23 + record: record, 24 + auth: auth 25 + ) 26 + } 27 + 28 + /// Deletes a favorite by its full URI (extracts the rkey). 29 + static func delete( 30 + favoriteUri: String, 31 + client: XRPCClient, 32 + auth: AuthContext 33 + ) async throws { 34 + let rkey = favoriteUri.split(separator: "/").last.map(String.init) ?? "" 35 + try await client.deleteRecord( 36 + collection: collection, 37 + rkey: rkey, 38 + auth: auth 39 + ) 40 + } 41 + }
+8 -14
Grain/ViewModels/StoryCommentsViewModel.swift
··· 117 117 guard !trimmed.isEmpty else { return } 118 118 119 119 isPostingComment = true 120 - var recordDict: [String: String] = [ 121 - "text": trimmed, 122 - "subject": storyUri, 123 - "createdAt": DateFormatting.nowISO(), 124 - ] 125 - if let replyTarget = replyTo { 126 - recordDict["replyTo"] = replyTarget.uri 127 - } 128 - let record = AnyCodable(recordDict) 129 - let repo = TokenStorage.userDID ?? "" 130 - 131 120 do { 132 - _ = try await client.createRecord(collection: "social.grain.comment", repo: repo, record: record, auth: auth) 121 + _ = try await CommentService.create( 122 + subject: storyUri, 123 + text: trimmed, 124 + replyTo: replyTo?.uri, 125 + client: client, 126 + auth: auth 127 + ) 133 128 previewCache.removeValue(forKey: storyUri) 134 129 await loadComments(storyUri: storyUri, auth: auth) 135 130 } catch { ··· 139 134 } 140 135 141 136 func deleteComment(_ comment: GrainComment, storyUri: String, auth: AuthContext) async { 142 - let rkey = comment.uri.split(separator: "/").last.map(String.init) ?? "" 143 137 do { 144 - try await client.deleteRecord(collection: "social.grain.comment", rkey: rkey, auth: auth) 138 + try await CommentService.delete(commentUri: comment.uri, client: client, auth: auth) 145 139 comments.removeAll { $0.uri == comment.uri } 146 140 totalCount = max(totalCount - 1, 0) 147 141
+2 -10
Grain/Views/Components/GalleryCardView.swift
··· 580 580 if let favUri = gallery.viewer?.fav { 581 581 gallery.viewer?.fav = nil 582 582 gallery.favCount = max((gallery.favCount ?? 1) - 1, 0) 583 - 584 - let rkey = favUri.split(separator: "/").last.map(String.init) ?? "" 585 583 do { 586 - try await client.deleteRecord(collection: "social.grain.favorite", rkey: rkey, auth: authContext) 584 + try await FavoriteService.delete(favoriteUri: favUri, client: client, auth: authContext) 587 585 } catch { 588 586 logger.error("Unfavorite failed: \(error)") 589 587 gallery.viewer?.fav = favUri ··· 594 592 let prevCount = gallery.favCount 595 593 gallery.viewer = GalleryViewerState(fav: "pending") 596 594 gallery.favCount = (gallery.favCount ?? 0) + 1 597 - 598 - let record = AnyCodable([ 599 - "subject": gallery.uri, 600 - "createdAt": DateFormatting.nowISO(), 601 - ]) 602 - let repo = TokenStorage.userDID ?? "" 603 595 do { 604 - let response = try await client.createRecord(collection: "social.grain.favorite", repo: repo, record: record, auth: authContext) 596 + let response = try await FavoriteService.create(subject: gallery.uri, client: client, auth: authContext) 605 597 gallery.viewer = GalleryViewerState(fav: response.uri) 606 598 } catch { 607 599 logger.error("Favorite failed: \(error)")
+2 -8
Grain/Views/Stories/StoryViewer.swift
··· 1069 1069 if let favUri = story.viewer?.fav { 1070 1070 // Unfavorite — optimistic 1071 1071 stories[currentStoryIndex].viewer?.fav = nil 1072 - let rkey = favUri.split(separator: "/").last.map(String.init) ?? "" 1073 1072 do { 1074 - try await client.deleteRecord(collection: "social.grain.favorite", rkey: rkey, auth: authContext) 1073 + try await FavoriteService.delete(favoriteUri: favUri, client: client, auth: authContext) 1075 1074 } catch { 1076 1075 stories[currentStoryIndex].viewer?.fav = favUri 1077 1076 } ··· 1079 1078 // Favorite — optimistic 1080 1079 let prevViewer = stories[currentStoryIndex].viewer 1081 1080 stories[currentStoryIndex].viewer = StoryViewerState(fav: "pending") 1082 - let record = AnyCodable([ 1083 - "subject": story.uri, 1084 - "createdAt": DateFormatting.nowISO(), 1085 - ]) 1086 - let repo = TokenStorage.userDID ?? "" 1087 1081 do { 1088 - let response = try await client.createRecord(collection: "social.grain.favorite", repo: repo, record: record, auth: authContext) 1082 + let response = try await FavoriteService.create(subject: story.uri, client: client, auth: authContext) 1089 1083 stories[currentStoryIndex].viewer = StoryViewerState(fav: response.uri) 1090 1084 } catch { 1091 1085 stories[currentStoryIndex].viewer = prevViewer