1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
import Foundation
import os
private let logger = Logger(subsystem: "social.grain.grain", category: "StoryComments")
private let scvmSignposter = OSSignposter(subsystem: "social.grain.grain", category: "StoryComments")
@Observable
@MainActor
final class StoryCommentsViewModel {
var comments: [GrainComment] = []
// `firstComment` is `response.comments.first`, which is the oldest/chronological
// comment on the story. Ideally this would prefer comments from followed users,
// then fall back to most-liked, but the getCommentThread endpoint doesn't return
// viewer state on authors or likeCount on comments. Bumping the preview fetch
// from limit=1 to support client-side selection also adds ~100ms per request.
// Revisit when the backend hydrates those fields.
var firstComment: GrainComment?
var totalCount: Int = 0
var isLoading = false
var isPostingComment = false
private(set) var activeStoryUri: String?
private var commentCursor: String?
private var hasMoreComments = true
private var previewCache: [String: CachedPreview] = [:]
private let client: XRPCClient
init(client: XRPCClient) {
self.client = client
}
// MARK: - Story Switching
func switchToStory(uri: String, auth: AuthContext? = nil) {
guard uri != activeStoryUri else { return }
activeStoryUri = uri
comments = []
commentCursor = nil
hasMoreComments = true
if let cached = previewCache[uri] {
firstComment = cached.comment
totalCount = cached.count
} else {
firstComment = nil
totalCount = 0
Task { await loadPreview(storyUri: uri, auth: auth) }
}
}
// MARK: - Preview Loading
/// Fires background preview loads for the given story URIs. Already-cached URIs are skipped.
func prefetchPreviews(for storyUris: [String], auth: AuthContext? = nil) {
for uri in storyUris where previewCache[uri] == nil {
Task { await loadPreview(storyUri: uri, auth: auth) }
}
}
func loadPreview(storyUri: String, auth: AuthContext? = nil) async {
if let cached = previewCache[storyUri] {
if activeStoryUri == nil || activeStoryUri == storyUri {
firstComment = cached.comment
totalCount = cached.count
}
return
}
do {
let response = try await client.getCommentThread(subject: storyUri, limit: 1, auth: auth)
let preview = CachedPreview(
comment: response.comments.first,
count: response.totalCount ?? response.comments.count
)
previewCache[storyUri] = preview
if activeStoryUri == nil || activeStoryUri == storyUri {
firstComment = preview.comment
totalCount = preview.count
}
} catch {
logger.error("Failed to load comment preview: \(error)")
}
}
// MARK: - Full Comment Loading
func loadComments(storyUri: String, auth: AuthContext? = nil) async {
guard !isLoading else {
scvmSignposter.emitEvent("loadComments.skipped", "reason=already-loading")
return
}
let state = scvmSignposter.beginInterval("loadComments", "uri=\(storyUri)")
isLoading = true
commentCursor = nil
hasMoreComments = true
do {
let response = try await client.getCommentThread(subject: storyUri, auth: auth)
comments = response.comments
commentCursor = response.cursor
hasMoreComments = response.cursor != nil
totalCount = response.totalCount ?? response.comments.count
// Update cache with fresh data
let latest = response.comments.first
previewCache[storyUri] = CachedPreview(comment: latest, count: totalCount)
if storyUri == activeStoryUri {
firstComment = latest
}
let count = response.comments.count
scvmSignposter.endInterval("loadComments", state, "count=\(count)")
} catch {
logger.error("Failed to load comments: \(error)")
scvmSignposter.endInterval("loadComments", state, "error")
}
isLoading = false
}
func loadMoreComments(storyUri: String, auth: AuthContext? = nil) async {
guard !isLoading, hasMoreComments, let cursor = commentCursor else { return }
isLoading = true
do {
let response = try await client.getCommentThread(subject: storyUri, cursor: cursor, auth: auth)
comments.append(contentsOf: response.comments)
commentCursor = response.cursor
hasMoreComments = response.cursor != nil
} catch {
logger.error("Failed to load more comments: \(error)")
}
isLoading = false
}
// MARK: - Comment CRUD
func postComment(text: String, storyUri: String, replyTo: GrainComment? = nil, auth: AuthContext) async {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
isPostingComment = true
do {
_ = try await CommentService.create(
subject: storyUri,
text: trimmed,
replyTo: replyTo?.uri,
client: client,
auth: auth
)
previewCache.removeValue(forKey: storyUri)
await loadComments(storyUri: storyUri, auth: auth)
} catch {
logger.error("Failed to post comment: \(error)")
}
isPostingComment = false
}
func deleteComment(_ comment: GrainComment, storyUri: String, auth: AuthContext) async {
do {
try await CommentService.delete(commentUri: comment.uri, client: client, auth: auth)
comments.removeAll { $0.uri == comment.uri }
totalCount = max(totalCount - 1, 0)
// Update cache
let latest = comments.first
previewCache[storyUri] = CachedPreview(comment: latest, count: totalCount)
if storyUri == activeStoryUri {
firstComment = latest
}
} catch {
logger.error("Failed to delete comment: \(error)")
}
}
}
// MARK: - Cache
private struct CachedPreview {
let comment: GrainComment?
let count: Int
}