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 StoryCommentsViewModel and StoryCommentSheet

ViewModel with preview caching (per-URI), full comment loading,
pagination, and CRUD. Sheet supports medium/large detents, threaded
comments via CommentRow, mention autocomplete, and two open modes
(input-focused vs comment-list browsing).

+329
+163
Grain/ViewModels/StoryCommentsViewModel.swift
··· 1 + import Foundation 2 + import os 3 + 4 + private let logger = Logger(subsystem: "social.grain.grain", category: "StoryComments") 5 + 6 + @Observable 7 + @MainActor 8 + final class StoryCommentsViewModel { 9 + var comments: [GrainComment] = [] 10 + var latestComment: GrainComment? 11 + var totalCount: Int = 0 12 + var isLoading = false 13 + var isPostingComment = false 14 + 15 + private(set) var activeStoryUri: String? 16 + private var commentCursor: String? 17 + private var hasMoreComments = true 18 + private var previewCache: [String: CachedPreview] = [:] 19 + private let client: XRPCClient 20 + 21 + init(client: XRPCClient) { 22 + self.client = client 23 + } 24 + 25 + // MARK: - Story Switching 26 + 27 + func switchToStory(uri: String, auth: AuthContext? = nil) { 28 + guard uri != activeStoryUri else { return } 29 + activeStoryUri = uri 30 + comments = [] 31 + commentCursor = nil 32 + hasMoreComments = true 33 + 34 + if let cached = previewCache[uri] { 35 + latestComment = cached.comment 36 + totalCount = cached.count 37 + } else { 38 + latestComment = nil 39 + totalCount = 0 40 + Task { await loadPreview(storyUri: uri, auth: auth) } 41 + } 42 + } 43 + 44 + // MARK: - Preview Loading 45 + 46 + func loadPreview(storyUri: String, auth: AuthContext? = nil) async { 47 + if let cached = previewCache[storyUri] { 48 + latestComment = cached.comment 49 + totalCount = cached.count 50 + return 51 + } 52 + 53 + do { 54 + let response = try await client.getStoryThread(story: storyUri, limit: 1, auth: auth) 55 + let preview = CachedPreview( 56 + comment: response.comments.first, 57 + count: response.totalCount ?? response.comments.count 58 + ) 59 + previewCache[storyUri] = preview 60 + if storyUri == activeStoryUri { 61 + latestComment = preview.comment 62 + totalCount = preview.count 63 + } 64 + } catch { 65 + logger.error("Failed to load comment preview: \(error)") 66 + } 67 + } 68 + 69 + // MARK: - Full Comment Loading 70 + 71 + func loadComments(storyUri: String, auth: AuthContext? = nil) async { 72 + guard !isLoading else { return } 73 + isLoading = true 74 + commentCursor = nil 75 + hasMoreComments = true 76 + 77 + do { 78 + let response = try await client.getStoryThread(story: storyUri, auth: auth) 79 + comments = response.comments 80 + commentCursor = response.cursor 81 + hasMoreComments = response.cursor != nil 82 + totalCount = response.totalCount ?? response.comments.count 83 + 84 + // Update cache with fresh data 85 + let latest = response.comments.first 86 + previewCache[storyUri] = CachedPreview(comment: latest, count: totalCount) 87 + if storyUri == activeStoryUri { 88 + latestComment = latest 89 + } 90 + } catch { 91 + logger.error("Failed to load comments: \(error)") 92 + } 93 + isLoading = false 94 + } 95 + 96 + func loadMoreComments(storyUri: String, auth: AuthContext? = nil) async { 97 + guard !isLoading, hasMoreComments, let cursor = commentCursor else { return } 98 + isLoading = true 99 + 100 + do { 101 + let response = try await client.getStoryThread(story: storyUri, cursor: cursor, auth: auth) 102 + comments.append(contentsOf: response.comments) 103 + commentCursor = response.cursor 104 + hasMoreComments = response.cursor != nil 105 + } catch { 106 + logger.error("Failed to load more comments: \(error)") 107 + } 108 + isLoading = false 109 + } 110 + 111 + // MARK: - Comment CRUD 112 + 113 + func postComment(text: String, storyUri: String, replyTo: GrainComment? = nil, auth: AuthContext) async { 114 + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) 115 + guard !trimmed.isEmpty else { return } 116 + 117 + isPostingComment = true 118 + var recordDict: [String: String] = [ 119 + "text": trimmed, 120 + "subject": storyUri, 121 + "createdAt": DateFormatting.nowISO(), 122 + ] 123 + if let replyTarget = replyTo { 124 + recordDict["replyTo"] = replyTarget.uri 125 + } 126 + let record = AnyCodable(recordDict) 127 + let repo = TokenStorage.userDID ?? "" 128 + 129 + do { 130 + _ = try await client.createRecord(collection: "social.grain.comment", repo: repo, record: record, auth: auth) 131 + previewCache.removeValue(forKey: storyUri) 132 + await loadComments(storyUri: storyUri, auth: auth) 133 + } catch { 134 + logger.error("Failed to post comment: \(error)") 135 + } 136 + isPostingComment = false 137 + } 138 + 139 + func deleteComment(_ comment: GrainComment, storyUri: String, auth: AuthContext) async { 140 + let rkey = comment.uri.split(separator: "/").last.map(String.init) ?? "" 141 + do { 142 + try await client.deleteRecord(collection: "social.grain.comment", rkey: rkey, auth: auth) 143 + comments.removeAll { $0.uri == comment.uri } 144 + totalCount = max(totalCount - 1, 0) 145 + 146 + // Update cache 147 + let latest = comments.first 148 + previewCache[storyUri] = CachedPreview(comment: latest, count: totalCount) 149 + if storyUri == activeStoryUri { 150 + latestComment = latest 151 + } 152 + } catch { 153 + logger.error("Failed to delete comment: \(error)") 154 + } 155 + } 156 + } 157 + 158 + // MARK: - Cache 159 + 160 + private struct CachedPreview { 161 + let comment: GrainComment? 162 + let count: Int 163 + }
+166
Grain/Views/Stories/StoryCommentSheet.swift
··· 1 + import SwiftUI 2 + 3 + struct StoryCommentSheet: View { 4 + @Environment(AuthManager.self) private var auth 5 + @Environment(StoryStatusCache.self) private var storyStatusCache 6 + @Environment(ViewedStoryStorage.self) private var viewedStories 7 + let viewModel: StoryCommentsViewModel 8 + let storyUri: String 9 + let client: XRPCClient 10 + var focusInput: Bool = false 11 + var onProfileTap: ((String) -> Void)? 12 + var onDismiss: (() -> Void)? 13 + 14 + @State private var commentText = "" 15 + @State private var replyingTo: GrainComment? 16 + @State private var mentionState = MentionAutocompleteState() 17 + @FocusState private var commentFocused: Bool 18 + 19 + private var threadedComments: [(root: GrainComment, replies: [GrainComment])] { 20 + let roots = viewModel.comments.filter { $0.replyTo == nil } 21 + let replyMap = Dictionary(grouping: viewModel.comments.filter { $0.replyTo != nil }, by: { $0.replyTo! }) 22 + return roots.map { root in 23 + (root: root, replies: replyMap[root.uri] ?? []) 24 + } 25 + } 26 + 27 + var body: some View { 28 + NavigationStack { 29 + VStack(spacing: 0) { 30 + if viewModel.isLoading, viewModel.comments.isEmpty { 31 + Spacer() 32 + ProgressView() 33 + Spacer() 34 + } else if viewModel.comments.isEmpty { 35 + Spacer() 36 + Text("No comments yet") 37 + .foregroundStyle(.secondary) 38 + .font(.subheadline) 39 + Spacer() 40 + } else { 41 + ScrollView { 42 + LazyVStack(alignment: .leading, spacing: 0) { 43 + ForEach(threadedComments, id: \.root.id) { thread in 44 + CommentRow( 45 + comment: thread.root, 46 + userDID: auth.userDID, 47 + isOwn: thread.root.author.did == auth.userDID, 48 + isReply: false, 49 + onProfileTap: onProfileTap, 50 + onHashtagTap: nil, 51 + onStoryTap: nil, 52 + onReply: { startReply(to: thread.root) }, 53 + onDelete: { Task { await deleteComment(thread.root) } } 54 + ) 55 + 56 + ForEach(thread.replies) { reply in 57 + CommentRow( 58 + comment: reply, 59 + userDID: auth.userDID, 60 + isOwn: reply.author.did == auth.userDID, 61 + isReply: true, 62 + onProfileTap: onProfileTap, 63 + onHashtagTap: nil, 64 + onStoryTap: nil, 65 + onReply: { startReplyToReply(reply, root: thread.root) }, 66 + onDelete: { Task { await deleteComment(reply) } } 67 + ) 68 + } 69 + } 70 + } 71 + } 72 + } 73 + 74 + Divider() 75 + 76 + // Input area 77 + VStack(spacing: 0) { 78 + if let replyTarget = replyingTo { 79 + HStack { 80 + Text("Replying to @\(replyTarget.author.handle)") 81 + .font(.caption) 82 + .foregroundStyle(.secondary) 83 + Spacer() 84 + Button { 85 + replyingTo = nil 86 + } label: { 87 + Image(systemName: "xmark.circle.fill") 88 + .foregroundStyle(.secondary) 89 + } 90 + } 91 + .padding(.horizontal) 92 + .padding(.top, 8) 93 + } 94 + 95 + HStack(alignment: .bottom, spacing: 8) { 96 + TextField(replyingTo != nil ? "Reply..." : "Add a comment...", text: $commentText, axis: .vertical) 97 + .textFieldStyle(.plain) 98 + .font(.body) 99 + .focused($commentFocused) 100 + .lineLimit(1 ... 5) 101 + .onChange(of: commentText) { mentionState.update(text: commentText) } 102 + 103 + Button { 104 + Task { await postComment() } 105 + } label: { 106 + Image(systemName: "arrow.up.circle.fill") 107 + .font(.title2) 108 + .foregroundStyle(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .secondary : Color("AccentColor")) 109 + } 110 + .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isPostingComment) 111 + } 112 + .padding(.horizontal) 113 + .padding(.vertical, 10) 114 + } 115 + } 116 + .safeAreaInset(edge: .bottom) { 117 + MentionSuggestionOverlay(state: mentionState) { suggestion in 118 + mentionState.complete(handle: suggestion.handle, in: &commentText) 119 + } 120 + } 121 + .navigationTitle("Comments") 122 + .navigationBarTitleDisplayMode(.inline) 123 + .toolbar { 124 + ToolbarItem(placement: .cancellationAction) { 125 + Button("Done") { 126 + onDismiss?() 127 + } 128 + } 129 + } 130 + } 131 + .presentationDetents([.medium, .large]) 132 + .task { 133 + await viewModel.loadComments(storyUri: storyUri, auth: auth.authContext()) 134 + if focusInput { 135 + commentFocused = true 136 + } 137 + } 138 + } 139 + 140 + private func startReply(to comment: GrainComment) { 141 + replyingTo = comment 142 + commentText = "" 143 + commentFocused = true 144 + } 145 + 146 + private func startReplyToReply(_ reply: GrainComment, root: GrainComment) { 147 + replyingTo = root 148 + commentText = "@\(reply.author.handle) " 149 + commentFocused = true 150 + } 151 + 152 + private func postComment() async { 153 + guard let authContext = await auth.authContext() else { return } 154 + let text = commentText 155 + let reply = replyingTo 156 + commentText = "" 157 + replyingTo = nil 158 + commentFocused = false 159 + await viewModel.postComment(text: text, storyUri: storyUri, replyTo: reply, auth: authContext) 160 + } 161 + 162 + private func deleteComment(_ comment: GrainComment) async { 163 + guard let authContext = await auth.authContext() else { return } 164 + await viewModel.deleteComment(comment, storyUri: storyUri, auth: authContext) 165 + } 166 + }