@codingfactory/socialkit-vue 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1477 @@
1
+ /**
2
+ * Generic content/feed store factory for SocialKit-powered frontends.
3
+ */
4
+
5
+ import { isAxiosError } from 'axios'
6
+ import { defineStore } from 'pinia'
7
+ import { computed, ref } from 'vue'
8
+ import type { FeedResponse } from '../types/content-api.js'
9
+ import type {
10
+ Comment,
11
+ ContentActiveRecipeMeta,
12
+ ContentStoreConfig,
13
+ FeedEntry,
14
+ PendingPost,
15
+ PendingVideoRender,
16
+ Post,
17
+ PostAuthor,
18
+ PostFeeling,
19
+ PostMedia,
20
+ PostMeta,
21
+ PostShareType,
22
+ ProcessPendingPostResult,
23
+ RecoverablePendingDraft,
24
+ ScheduledPostsPage,
25
+ } from '../types/content.js'
26
+ import type { LocalMediaItem } from '../types/media.js'
27
+
28
+ type FeedMode = 'following' | 'for_you' | 'discover'
29
+
30
+ type RecentCreatedPostRecord = {
31
+ post: Post
32
+ rememberedAt: string
33
+ }
34
+
35
+ type FeedHomeResponse = {
36
+ data?: FeedEntry[] | { entries?: FeedEntry[] }
37
+ entries?: FeedEntry[]
38
+ next_cursor?: string | null
39
+ meta?: {
40
+ next_cursor?: string | null
41
+ has_more?: boolean
42
+ active_recipe?: ContentActiveRecipeMeta | null
43
+ }
44
+ }
45
+
46
+ export type ContentStoreReturn = ReturnType<ReturnType<typeof createContentStoreDefinition>>
47
+
48
+ const PENDING_POSTS_STORAGE_KEY = 'socialkit_pending_posts'
49
+ const RECENT_CREATED_POSTS_STORAGE_KEY = 'socialkit_recent_created_posts'
50
+ const MAX_RETRY_ATTEMPTS = 5
51
+ const MAX_PENDING_AGE_MS = 24 * 60 * 60 * 1000
52
+ const MAX_RECENT_CREATED_POST_AGE_MS = 6 * 60 * 60 * 1000
53
+ const MAX_RECENT_CREATED_POSTS = 8
54
+ const FEED_IN_FLIGHT_REQUEST_STALE_MS = 12_000
55
+ const LOST_MEDIA_ERROR_MESSAGE = 'Attached media was lost after page reload. Please cancel and re-create this post with your photos/videos.'
56
+ const LEGACY_CLIENT_RETRYABLE_ERROR_FRAGMENTS = [
57
+ 'ensureCsrf is not a function',
58
+ ]
59
+
60
+ function normalizePageSize(pageSize: number | undefined): number {
61
+ const parsed = Number(pageSize ?? 10)
62
+ if (!Number.isFinite(parsed) || parsed <= 0) {
63
+ return 10
64
+ }
65
+
66
+ return Math.trunc(parsed)
67
+ }
68
+
69
+ export function createContentStoreDefinition(config: ContentStoreConfig) {
70
+ const {
71
+ client,
72
+ mediaUploadService,
73
+ getCurrentUser,
74
+ syncActiveRecipe,
75
+ storage = null,
76
+ pageSize,
77
+ logger = console,
78
+ storeId = 'content',
79
+ } = config
80
+
81
+ const resolvedPageSize = normalizePageSize(pageSize)
82
+
83
+ return defineStore(storeId, () => {
84
+ const entries = ref<FeedEntry[]>([])
85
+ const nextCursor = ref<string | null>(null)
86
+ const loading = ref(false)
87
+ const error = ref<string | null>(null)
88
+ const feedMode = ref<FeedMode>('for_you')
89
+ let feedRequestSequence = 0
90
+ let latestFreshFeedRequestToken = 0
91
+ let activeFeedRequests = 0
92
+ const inFlightFeedRequests = new Map<string, { promise: Promise<FeedResponse>; startedAt: number }>()
93
+
94
+ const pendingPosts = ref<PendingPost[]>([])
95
+ const recentCreatedPosts = ref<RecentCreatedPostRecord[]>([])
96
+ const composerFeeling = ref<PostFeeling | null>(null)
97
+
98
+ const hasPendingPosts = computed(() => pendingPosts.value.length > 0)
99
+ const PAGE_SIZE = resolvedPageSize
100
+
101
+ const isPostEntryType = (type: string | undefined | null): boolean => {
102
+ if (!type) {
103
+ return false
104
+ }
105
+
106
+ const normalized = type.toLowerCase()
107
+ return normalized === 'post'
108
+ || normalized.endsWith('\\post')
109
+ || normalized.endsWith('.post')
110
+ || normalized.includes('content.post')
111
+ }
112
+
113
+ const entryKey = (entry: FeedEntry): string => `${(entry.entity_type || '').toLowerCase()}|${entry.entity_id}`
114
+ const feedRequestKey = (mode: FeedMode, cursor?: string): string => `${mode}|${cursor ?? '__fresh__'}`
115
+
116
+ const removeDuplicateEntriesForKey = (keepIndex: number): void => {
117
+ const keepEntry = entries.value[keepIndex]
118
+ if (!keepEntry) {
119
+ return
120
+ }
121
+
122
+ const keepKey = entryKey(keepEntry)
123
+ entries.value = entries.value.filter((entry, index) => (
124
+ index === keepIndex || entryKey(entry) !== keepKey
125
+ ))
126
+ }
127
+
128
+ const DISCIPLINE_ERROR_CODES = new Set([
129
+ 'ACCOUNT_WRITE_TIMEOUT',
130
+ 'ACCOUNT_BANNED',
131
+ ])
132
+
133
+ const DISCIPLINE_ERROR_MESSAGES: Record<string, string> = {
134
+ ACCOUNT_WRITE_TIMEOUT: 'Your account is temporarily suspended. You cannot post at this time.',
135
+ ACCOUNT_BANNED: 'Your account has been suspended. You cannot post.',
136
+ }
137
+
138
+ const DISCIPLINE_ERROR_STRINGS = new Set([
139
+ ...Object.values(DISCIPLINE_ERROR_MESSAGES),
140
+ 'account write-timeout active',
141
+ 'account banned',
142
+ ])
143
+
144
+ const isDisciplineError = (errorMessage: string): boolean => {
145
+ const lower = errorMessage.toLowerCase()
146
+ for (const known of DISCIPLINE_ERROR_STRINGS) {
147
+ if (lower === known.toLowerCase()) {
148
+ return true
149
+ }
150
+ }
151
+
152
+ return false
153
+ }
154
+
155
+ const isLegacyClientRetryableError = (errorMessage: string): boolean => {
156
+ const lower = errorMessage.toLowerCase()
157
+ return LEGACY_CLIENT_RETRYABLE_ERROR_FRAGMENTS.some((fragment) => lower.includes(fragment.toLowerCase()))
158
+ }
159
+
160
+ const isDisciplineAxiosError = (err: unknown): boolean => {
161
+ const status = (err as { response?: { status?: number } }).response?.status
162
+ if (status === 423) {
163
+ return true
164
+ }
165
+
166
+ const code = (err as { response?: { data?: { code?: string } } }).response?.data?.code
167
+ return typeof code === 'string' && DISCIPLINE_ERROR_CODES.has(code)
168
+ }
169
+
170
+ const resolvePendingPostErrorMessage = (err: unknown): string => {
171
+ const responseData = (err as {
172
+ response?: {
173
+ data?: {
174
+ message?: string
175
+ code?: string
176
+ errors?: Record<string, string[] | string>
177
+ }
178
+ }
179
+ }).response?.data
180
+
181
+ if (typeof responseData?.code === 'string' && responseData.code in DISCIPLINE_ERROR_MESSAGES) {
182
+ return DISCIPLINE_ERROR_MESSAGES[responseData.code] ?? 'Failed to create post'
183
+ }
184
+
185
+ const fieldErrors = responseData?.errors
186
+ ? Object.values(responseData.errors).flatMap((fieldError) => (
187
+ Array.isArray(fieldError) ? fieldError : [fieldError]
188
+ ))
189
+ : []
190
+
191
+ const firstFieldError = fieldErrors.find((message): message is string => (
192
+ typeof message === 'string' && message.trim() !== ''
193
+ ))
194
+ if (firstFieldError) {
195
+ return firstFieldError
196
+ }
197
+
198
+ if (typeof responseData?.message === 'string' && responseData.message.trim() !== '') {
199
+ return responseData.message
200
+ }
201
+
202
+ if (err instanceof Error && err.message.trim() !== '') {
203
+ return err.message
204
+ }
205
+
206
+ return 'Failed to create post'
207
+ }
208
+
209
+ function setFeedMode(mode: FeedMode): void {
210
+ if (feedMode.value === mode) {
211
+ return
212
+ }
213
+
214
+ feedMode.value = mode
215
+ entries.value = []
216
+ nextCursor.value = null
217
+ }
218
+
219
+ function generateTempId(): string {
220
+ return `pending-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
221
+ }
222
+
223
+ function persistPendingPosts(): void {
224
+ if (storage === null) {
225
+ return
226
+ }
227
+
228
+ try {
229
+ const serializable = pendingPosts.value.map((pending) => ({
230
+ tempId: pending.tempId,
231
+ body: pending.body,
232
+ share_type: pending.share_type,
233
+ quoted_post_id: pending.quoted_post_id,
234
+ visibility: pending.visibility,
235
+ content_warning: pending.content_warning,
236
+ meta: pending.meta,
237
+ media_ids: pending.media_ids,
238
+ hadUnuploadedMedia: pending.hadUnuploadedMedia || (pending.mediaItems != null && pending.mediaItems.length > 0) || undefined,
239
+ container_type: pending.container_type,
240
+ container_id: pending.container_id,
241
+ status: pending.status,
242
+ createdAt: pending.createdAt,
243
+ retryCount: pending.retryCount,
244
+ error: pending.error,
245
+ }))
246
+ storage.setItem(PENDING_POSTS_STORAGE_KEY, JSON.stringify(serializable))
247
+ } catch (storageError) {
248
+ logger.error('[ContentStore] Failed to persist pending posts:', storageError)
249
+ }
250
+ }
251
+
252
+ const normalizeStoredRecentCreatedPosts = (stored: unknown): RecentCreatedPostRecord[] => {
253
+ if (!Array.isArray(stored)) {
254
+ return []
255
+ }
256
+
257
+ const normalized: RecentCreatedPostRecord[] = []
258
+ for (const candidate of stored) {
259
+ if (candidate === null || typeof candidate !== 'object') {
260
+ continue
261
+ }
262
+
263
+ const rememberedAtRaw = Reflect.get(candidate, 'remembered_at')
264
+ const postRaw = Reflect.get(candidate, 'post')
265
+
266
+ if (typeof rememberedAtRaw !== 'string') {
267
+ continue
268
+ }
269
+
270
+ const rememberedAtMs = Date.parse(rememberedAtRaw)
271
+ if (!Number.isFinite(rememberedAtMs)) {
272
+ continue
273
+ }
274
+
275
+ if (postRaw === null || typeof postRaw !== 'object') {
276
+ continue
277
+ }
278
+
279
+ const postIdRaw = Reflect.get(postRaw, 'id')
280
+ if (typeof postIdRaw !== 'string' || postIdRaw.trim() === '') {
281
+ continue
282
+ }
283
+
284
+ normalized.push({
285
+ post: postRaw as Post,
286
+ rememberedAt: rememberedAtRaw,
287
+ })
288
+ }
289
+
290
+ return normalized
291
+ }
292
+
293
+ const isRecentCreatedPostExpired = (rememberedAt: string): boolean => {
294
+ const rememberedAtMs = Date.parse(rememberedAt)
295
+ if (!Number.isFinite(rememberedAtMs)) {
296
+ return true
297
+ }
298
+
299
+ return Date.now() - rememberedAtMs > MAX_RECENT_CREATED_POST_AGE_MS
300
+ }
301
+
302
+ const isRecentCreatedPostOwnedByCurrentActor = (post: Post): boolean => {
303
+ const actorIdRaw = getCurrentUser()?.id
304
+ if (typeof actorIdRaw !== 'string' || actorIdRaw.trim() === '') {
305
+ return true
306
+ }
307
+
308
+ const postAuthorId = typeof post.author_id === 'string' ? post.author_id.trim() : ''
309
+ if (postAuthorId === '') {
310
+ return true
311
+ }
312
+
313
+ return postAuthorId === actorIdRaw.trim()
314
+ }
315
+
316
+ const pruneRecentCreatedPosts = (): void => {
317
+ const next = recentCreatedPosts.value
318
+ .filter((entry) => !isRecentCreatedPostExpired(entry.rememberedAt))
319
+ .filter((entry) => isRecentCreatedPostOwnedByCurrentActor(entry.post))
320
+ .slice(0, MAX_RECENT_CREATED_POSTS)
321
+
322
+ if (next.length === recentCreatedPosts.value.length) {
323
+ return
324
+ }
325
+
326
+ recentCreatedPosts.value = next
327
+ persistRecentCreatedPosts()
328
+ }
329
+
330
+ function persistRecentCreatedPosts(): void {
331
+ if (storage === null) {
332
+ return
333
+ }
334
+
335
+ try {
336
+ const serializable = recentCreatedPosts.value
337
+ .slice(0, MAX_RECENT_CREATED_POSTS)
338
+ .map((entry) => ({
339
+ post: entry.post,
340
+ remembered_at: entry.rememberedAt,
341
+ }))
342
+ storage.setItem(RECENT_CREATED_POSTS_STORAGE_KEY, JSON.stringify(serializable))
343
+ } catch (storageError) {
344
+ logger.error('[ContentStore] Failed to persist recent created posts:', storageError)
345
+ }
346
+ }
347
+
348
+ function restoreRecentCreatedPosts(): void {
349
+ if (storage === null) {
350
+ return
351
+ }
352
+
353
+ try {
354
+ const stored = storage.getItem(RECENT_CREATED_POSTS_STORAGE_KEY)
355
+ if (!stored) {
356
+ recentCreatedPosts.value = []
357
+ return
358
+ }
359
+
360
+ const parsed = JSON.parse(stored) as unknown
361
+ recentCreatedPosts.value = normalizeStoredRecentCreatedPosts(parsed)
362
+ pruneRecentCreatedPosts()
363
+ } catch (storageError) {
364
+ logger.error('[ContentStore] Failed to restore recent created posts:', storageError)
365
+ recentCreatedPosts.value = []
366
+ }
367
+ }
368
+
369
+ function rememberRecentCreatedPost(post: Post): void {
370
+ const postId = typeof post.id === 'string' ? post.id.trim() : ''
371
+ if (postId === '') {
372
+ return
373
+ }
374
+
375
+ if (!isRecentCreatedPostOwnedByCurrentActor(post)) {
376
+ return
377
+ }
378
+
379
+ const next = recentCreatedPosts.value.filter((entry) => entry.post.id !== postId)
380
+ next.unshift({
381
+ post,
382
+ rememberedAt: new Date().toISOString(),
383
+ })
384
+
385
+ recentCreatedPosts.value = next.slice(0, MAX_RECENT_CREATED_POSTS)
386
+ pruneRecentCreatedPosts()
387
+ persistRecentCreatedPosts()
388
+ }
389
+
390
+ const buildRecentCreatedFeedEntries = (existingKeys: Set<string>): FeedEntry[] => {
391
+ pruneRecentCreatedPosts()
392
+
393
+ if (recentCreatedPosts.value.length === 0) {
394
+ return []
395
+ }
396
+
397
+ const remainingRecentPosts: RecentCreatedPostRecord[] = []
398
+ const recentEntries: FeedEntry[] = []
399
+
400
+ for (const entry of recentCreatedPosts.value) {
401
+ const postId = typeof entry.post.id === 'string' ? entry.post.id.trim() : ''
402
+ if (postId === '') {
403
+ continue
404
+ }
405
+
406
+ const key = `post|${postId}`
407
+ if (existingKeys.has(key)) {
408
+ continue
409
+ }
410
+
411
+ existingKeys.add(key)
412
+ remainingRecentPosts.push(entry)
413
+ recentEntries.push({
414
+ id: `entry-${postId}`,
415
+ entity_id: postId,
416
+ entity_type: 'post',
417
+ created_at: entry.post.created_at,
418
+ post: entry.post,
419
+ })
420
+ }
421
+
422
+ if (remainingRecentPosts.length !== recentCreatedPosts.value.length) {
423
+ recentCreatedPosts.value = remainingRecentPosts
424
+ persistRecentCreatedPosts()
425
+ }
426
+
427
+ return recentEntries
428
+ }
429
+
430
+ const refreshRecentCreatedPostsFromApi = async (): Promise<void> => {
431
+ pruneRecentCreatedPosts()
432
+
433
+ if (recentCreatedPosts.value.length === 0) {
434
+ return
435
+ }
436
+
437
+ const refreshedRecentPosts = await Promise.all(
438
+ recentCreatedPosts.value.map(async (entry): Promise<RecentCreatedPostRecord | null> => {
439
+ const postId = typeof entry.post.id === 'string' ? entry.post.id.trim() : ''
440
+ if (postId === '') {
441
+ return null
442
+ }
443
+
444
+ try {
445
+ const response = await client.get<Post>(`/v1/content/posts/${postId}`)
446
+ const refreshedPost = (response as { data?: Post }).data
447
+
448
+ if (refreshedPost && typeof refreshedPost.id === 'string' && refreshedPost.id.trim() !== '') {
449
+ return {
450
+ post: refreshedPost,
451
+ rememberedAt: entry.rememberedAt,
452
+ }
453
+ }
454
+ } catch (refreshError) {
455
+ if (isAxiosError(refreshError)) {
456
+ const statusCode = refreshError.response?.status
457
+ if (statusCode === 403 || statusCode === 404) {
458
+ return null
459
+ }
460
+ }
461
+
462
+ logger.error(`[ContentStore] Failed to refresh recent post ${postId}:`, refreshError)
463
+ }
464
+
465
+ return entry
466
+ }),
467
+ )
468
+
469
+ const nextRecentPosts = refreshedRecentPosts.filter((entry): entry is RecentCreatedPostRecord => entry !== null)
470
+ const hasChanges = nextRecentPosts.length !== recentCreatedPosts.value.length
471
+ || nextRecentPosts.some((entry, index) => {
472
+ const previousEntry = recentCreatedPosts.value[index]
473
+ if (!previousEntry) {
474
+ return true
475
+ }
476
+
477
+ return previousEntry.post.id !== entry.post.id
478
+ || previousEntry.post.updated_at !== entry.post.updated_at
479
+ || previousEntry.post.visibility !== entry.post.visibility
480
+ || previousEntry.post.is_quarantined !== entry.post.is_quarantined
481
+ })
482
+
483
+ if (!hasChanges) {
484
+ return
485
+ }
486
+
487
+ recentCreatedPosts.value = nextRecentPosts
488
+ persistRecentCreatedPosts()
489
+ }
490
+
491
+ function restorePendingPosts(): void {
492
+ if (storage === null) {
493
+ return
494
+ }
495
+
496
+ try {
497
+ const stored = storage.getItem(PENDING_POSTS_STORAGE_KEY)
498
+ if (!stored) {
499
+ return
500
+ }
501
+
502
+ const parsed = JSON.parse(stored) as PendingPost[]
503
+ const now = Date.now()
504
+
505
+ const freshPosts = parsed.filter((pending) => {
506
+ const age = now - new Date(pending.createdAt).getTime()
507
+ return age < MAX_PENDING_AGE_MS
508
+ })
509
+
510
+ const nonDisciplinePosts = freshPosts.filter((pending) => {
511
+ if (pending.status === 'failed' && typeof pending.error === 'string' && isDisciplineError(pending.error)) {
512
+ logger.info('[ContentStore] Removing non-retryable discipline-failed post from storage:', pending.tempId)
513
+ return false
514
+ }
515
+
516
+ return true
517
+ })
518
+
519
+ const autoRetryLegacyFailedPostIds: string[] = []
520
+ const restoredPosts = nonDisciplinePosts.map((pending) => {
521
+ const lostMedia = pending.hadUnuploadedMedia === true
522
+ && (pending.mediaItems == null || pending.mediaItems.length === 0)
523
+ && (pending.media_ids == null || pending.media_ids.length === 0)
524
+ if (lostMedia) {
525
+ return { ...pending, status: 'failed' as const, error: LOST_MEDIA_ERROR_MESSAGE }
526
+ }
527
+
528
+ if (
529
+ pending.status === 'failed'
530
+ && typeof pending.error === 'string'
531
+ && isLegacyClientRetryableError(pending.error)
532
+ && pending.retryCount < MAX_RETRY_ATTEMPTS
533
+ ) {
534
+ autoRetryLegacyFailedPostIds.push(pending.tempId)
535
+ return {
536
+ ...pending,
537
+ status: 'pending' as const,
538
+ error: undefined,
539
+ }
540
+ }
541
+
542
+ return pending
543
+ })
544
+
545
+ pendingPosts.value = restoredPosts
546
+
547
+ restoredPosts.forEach((pending) => {
548
+ const existingEntry = entries.value.find((entry) => entry.post?._tempId === pending.tempId)
549
+ if (!existingEntry) {
550
+ addOptimisticEntry(pending)
551
+ }
552
+ })
553
+
554
+ const removedDiscipline = nonDisciplinePosts.length !== freshPosts.length
555
+ if (
556
+ freshPosts.length !== parsed.length
557
+ || removedDiscipline
558
+ || autoRetryLegacyFailedPostIds.length > 0
559
+ || restoredPosts.some((pending) => pending.status === 'failed' && pending.error === LOST_MEDIA_ERROR_MESSAGE)
560
+ ) {
561
+ persistPendingPosts()
562
+ }
563
+
564
+ for (const tempId of autoRetryLegacyFailedPostIds) {
565
+ void retryPendingPost(tempId)
566
+ }
567
+ } catch (storageError) {
568
+ logger.error('[ContentStore] Failed to restore pending posts:', storageError)
569
+ pendingPosts.value = []
570
+ }
571
+ }
572
+
573
+ function addOptimisticEntry(pending: PendingPost): void {
574
+ const currentUser = getCurrentUser()
575
+ const optimisticAuthor: PostAuthor = {
576
+ id: currentUser?.id ?? '',
577
+ name: currentUser?.name ?? '',
578
+ username: currentUser?.handle ?? currentUser?.name ?? '',
579
+ }
580
+
581
+ if (currentUser?.avatar) {
582
+ optimisticAuthor.avatar = currentUser.avatar
583
+ }
584
+
585
+ const optimisticPost: Post & { _isPending?: boolean; _tempId?: string } = {
586
+ id: pending.tempId,
587
+ body: pending.body,
588
+ ...(pending.share_type && { share_type: pending.share_type }),
589
+ ...(pending.quoted_post_id && { quoted_post_id: pending.quoted_post_id }),
590
+ author: optimisticAuthor,
591
+ author_id: currentUser?.id ?? '',
592
+ reactions_count: 0,
593
+ comments_count: 0,
594
+ user_has_reacted: false,
595
+ visibility: pending.visibility as 'public' | 'private' | 'friends',
596
+ created_at: pending.createdAt,
597
+ _isPending: true,
598
+ _tempId: pending.tempId,
599
+ }
600
+
601
+ if (pending.meta !== undefined) {
602
+ optimisticPost.meta = pending.meta
603
+ }
604
+
605
+ if (pending.content_warning !== undefined) {
606
+ optimisticPost.content_warning = pending.content_warning
607
+ }
608
+
609
+ const entry: FeedEntry = {
610
+ id: `entry-${pending.tempId}`,
611
+ entity_id: pending.tempId,
612
+ entity_type: 'post',
613
+ created_at: pending.createdAt,
614
+ post: optimisticPost,
615
+ }
616
+
617
+ entries.value.unshift(entry)
618
+ }
619
+
620
+ function enqueuePost(input: {
621
+ body: string
622
+ share_type?: PostShareType
623
+ quoted_post_id?: string
624
+ visibility?: string
625
+ content_warning?: string
626
+ meta?: PostMeta
627
+ media_ids?: string[]
628
+ mediaItems?: LocalMediaItem[]
629
+ container_type?: string
630
+ container_id?: string
631
+ pendingVideoRenders?: PendingVideoRender[]
632
+ }): PendingPost {
633
+ const hasUnuploadedMedia = input.mediaItems != null && input.mediaItems.length > 0
634
+ const pending: PendingPost = {
635
+ tempId: generateTempId(),
636
+ body: input.body,
637
+ share_type: input.share_type,
638
+ quoted_post_id: input.quoted_post_id,
639
+ visibility: input.visibility || 'public',
640
+ content_warning: input.content_warning,
641
+ ...(input.meta && { meta: input.meta }),
642
+ media_ids: input.media_ids ?? undefined,
643
+ mediaItems: input.mediaItems ?? undefined,
644
+ hadUnuploadedMedia: hasUnuploadedMedia || undefined,
645
+ container_type: input.container_type,
646
+ container_id: input.container_id,
647
+ pendingVideoRenders: input.pendingVideoRenders,
648
+ status: 'pending',
649
+ createdAt: new Date().toISOString(),
650
+ retryCount: 0,
651
+ error: undefined,
652
+ }
653
+
654
+ pendingPosts.value.push(pending)
655
+ persistPendingPosts()
656
+ addOptimisticEntry(pending)
657
+
658
+ return pending
659
+ }
660
+
661
+ async function processPendingPost(tempId: string): Promise<ProcessPendingPostResult> {
662
+ const pendingIndex = pendingPosts.value.findIndex((pending) => pending.tempId === tempId)
663
+ if (pendingIndex === -1) {
664
+ return {
665
+ ok: false,
666
+ retryable: true,
667
+ error: 'Post draft no longer exists. Please try again.',
668
+ }
669
+ }
670
+
671
+ if (pendingPosts.value[pendingIndex]) {
672
+ pendingPosts.value[pendingIndex].status = 'uploading'
673
+ pendingPosts.value[pendingIndex].error = undefined
674
+ persistPendingPosts()
675
+ } else {
676
+ return {
677
+ ok: false,
678
+ retryable: true,
679
+ error: 'Post draft no longer exists. Please try again.',
680
+ }
681
+ }
682
+
683
+ const pending = pendingPosts.value[pendingIndex]
684
+
685
+ try {
686
+ let finalMediaIds: string[] = pending.media_ids || []
687
+
688
+ if (pending.mediaItems && pending.mediaItems.length > 0) {
689
+ const uploadedIds = await mediaUploadService.uploadLocalMediaItems(pending.mediaItems)
690
+ finalMediaIds = [...finalMediaIds, ...uploadedIds]
691
+ }
692
+
693
+ const response = await client.post('/v1/content/posts', {
694
+ body: pending.body,
695
+ ...(pending.share_type && { share_type: pending.share_type }),
696
+ ...(pending.quoted_post_id && { quoted_post_id: pending.quoted_post_id }),
697
+ visibility: pending.visibility,
698
+ ...(pending.content_warning && { content_warning: pending.content_warning }),
699
+ ...(pending.meta && { meta: pending.meta }),
700
+ ...(finalMediaIds.length > 0 && { media_ids: finalMediaIds }),
701
+ ...(pending.container_type && { container_type: pending.container_type }),
702
+ ...(pending.container_id && { container_id: pending.container_id }),
703
+ })
704
+
705
+ const serverPost: Post | undefined = (response as { data?: Post })?.data
706
+ if (!serverPost || typeof serverPost.id !== 'string' || serverPost.id.trim() === '') {
707
+ throw new Error('Server did not confirm post creation. Please try again.')
708
+ }
709
+
710
+ const entryIndex = entries.value.findIndex((entry) => entry.post?._tempId === tempId)
711
+ if (entryIndex !== -1) {
712
+ const optimisticEntry = entries.value[entryIndex]
713
+ const optimisticPost = optimisticEntry?.post
714
+
715
+ if (optimisticEntry && optimisticPost) {
716
+ const reconciledPost: Post & { _isPending?: boolean; _tempId?: string } = optimisticPost
717
+ Object.assign(reconciledPost, serverPost)
718
+ delete reconciledPost._isPending
719
+ delete reconciledPost._tempId
720
+
721
+ optimisticEntry.id = `entry-${serverPost.id}`
722
+ optimisticEntry.entity_id = serverPost.id
723
+ optimisticEntry.entity_type = 'post'
724
+ optimisticEntry.created_at = serverPost.created_at
725
+ optimisticEntry.post = reconciledPost
726
+ } else {
727
+ entries.value[entryIndex] = {
728
+ id: `entry-${serverPost.id}`,
729
+ entity_id: serverPost.id,
730
+ entity_type: 'post',
731
+ created_at: serverPost.created_at,
732
+ post: serverPost,
733
+ }
734
+ }
735
+
736
+ removeDuplicateEntriesForKey(entryIndex)
737
+ } else {
738
+ entries.value.unshift({
739
+ id: `entry-${serverPost.id}`,
740
+ entity_id: serverPost.id,
741
+ entity_type: 'post',
742
+ created_at: serverPost.created_at,
743
+ post: serverPost,
744
+ })
745
+ }
746
+
747
+ pendingPosts.value = pendingPosts.value.filter((currentPending) => currentPending.tempId !== tempId)
748
+ persistPendingPosts()
749
+ rememberRecentCreatedPost(serverPost)
750
+
751
+ return {
752
+ ok: true,
753
+ postId: serverPost.id,
754
+ isQuarantined: serverPost.is_quarantined === true,
755
+ }
756
+ } catch (err) {
757
+ const errorMessage = resolvePendingPostErrorMessage(err)
758
+
759
+ if (isDisciplineAxiosError(err)) {
760
+ const currentIndex = pendingPosts.value.findIndex((pendingItem) => pendingItem.tempId === tempId)
761
+ if (currentIndex !== -1 && pendingPosts.value[currentIndex]) {
762
+ pendingPosts.value[currentIndex] = {
763
+ ...pendingPosts.value[currentIndex],
764
+ status: 'failed',
765
+ error: errorMessage,
766
+ }
767
+ persistPendingPosts()
768
+ }
769
+
770
+ logger.warn('[ContentStore] Post blocked by discipline enforcement:', errorMessage)
771
+ return {
772
+ ok: false,
773
+ retryable: false,
774
+ error: errorMessage,
775
+ }
776
+ }
777
+
778
+ const currentIndex = pendingPosts.value.findIndex((pendingItem) => pendingItem.tempId === tempId)
779
+ if (currentIndex !== -1 && pendingPosts.value[currentIndex]) {
780
+ pendingPosts.value[currentIndex] = {
781
+ ...pendingPosts.value[currentIndex],
782
+ status: 'failed',
783
+ retryCount: pendingPosts.value[currentIndex].retryCount + 1,
784
+ error: errorMessage,
785
+ }
786
+ persistPendingPosts()
787
+ }
788
+
789
+ logger.error('[ContentStore] Failed to process pending post:', err)
790
+ return {
791
+ ok: false,
792
+ retryable: true,
793
+ error: errorMessage,
794
+ }
795
+ }
796
+ }
797
+
798
+ async function retryPendingPost(tempId: string): Promise<void> {
799
+ const pendingIndex = pendingPosts.value.findIndex((pending) => pending.tempId === tempId)
800
+ if (pendingIndex === -1) {
801
+ return
802
+ }
803
+
804
+ const pending = pendingPosts.value[pendingIndex]
805
+ if (!pending) {
806
+ return
807
+ }
808
+
809
+ if (pending.retryCount >= MAX_RETRY_ATTEMPTS) {
810
+ if (pendingPosts.value[pendingIndex]) {
811
+ pendingPosts.value[pendingIndex] = {
812
+ ...pending,
813
+ status: 'failed',
814
+ error: 'Maximum retry attempts reached. Please cancel and try again.',
815
+ }
816
+ persistPendingPosts()
817
+ }
818
+
819
+ return
820
+ }
821
+
822
+ if (pendingPosts.value[pendingIndex]) {
823
+ pendingPosts.value[pendingIndex] = {
824
+ ...pending,
825
+ status: 'pending',
826
+ error: undefined,
827
+ }
828
+ persistPendingPosts()
829
+ }
830
+
831
+ await processPendingPost(tempId)
832
+ }
833
+
834
+ function cancelPendingPost(tempId: string): void {
835
+ pendingPosts.value = pendingPosts.value.filter((pending) => pending.tempId !== tempId)
836
+ persistPendingPosts()
837
+ entries.value = entries.value.filter((entry) => entry.post?._tempId !== tempId)
838
+ }
839
+
840
+ function claimLostMediaDraft(): RecoverablePendingDraft | null {
841
+ const recoverablePending = pendingPosts.value.find((pending) => (
842
+ pending.status === 'failed'
843
+ && pending.error === LOST_MEDIA_ERROR_MESSAGE
844
+ ))
845
+ if (!recoverablePending) {
846
+ return null
847
+ }
848
+
849
+ const recoveredDraft: RecoverablePendingDraft = {
850
+ tempId: recoverablePending.tempId,
851
+ body: recoverablePending.body,
852
+ visibility: recoverablePending.visibility,
853
+ ...(recoverablePending.content_warning && { contentWarning: recoverablePending.content_warning }),
854
+ }
855
+
856
+ cancelPendingPost(recoverablePending.tempId)
857
+ return recoveredDraft
858
+ }
859
+
860
+ function setComposerFeeling(feeling: PostFeeling | null): void {
861
+ composerFeeling.value = feeling
862
+ }
863
+
864
+ function claimComposerFeeling(): PostFeeling | null {
865
+ const selectedFeeling = composerFeeling.value
866
+ composerFeeling.value = null
867
+ return selectedFeeling
868
+ }
869
+
870
+ async function loadUserPosts(userId: string, cursor?: string): Promise<FeedResponse> {
871
+ loading.value = true
872
+ error.value = null
873
+
874
+ try {
875
+ const params = new URLSearchParams()
876
+ params.set('limit', String(PAGE_SIZE))
877
+ params.set('author_id', userId)
878
+ if (cursor) {
879
+ params.set('after', cursor)
880
+ }
881
+
882
+ const url = `/v1/content/posts?${params.toString()}`
883
+ const response = await client.get(url)
884
+
885
+ const postsData = ((response as { data?: Post[] })?.data) || []
886
+ const cursorValue = ((response as { meta?: { next_cursor?: string } })?.meta?.next_cursor) || null
887
+ const feedEntries: FeedEntry[] = postsData.map((post) => ({
888
+ id: `entry-${post.id}`,
889
+ entity_type: 'post',
890
+ entity_id: post.id,
891
+ activity_type: 'post_published',
892
+ actor_id: post.author_id || post.author?.id,
893
+ owner_user_id: post.author_id || post.author?.id,
894
+ created_at: post.created_at,
895
+ visibility: post.visibility || 'public',
896
+ is_hidden: false,
897
+ }))
898
+
899
+ return {
900
+ data: {
901
+ entries: feedEntries,
902
+ meta: {
903
+ next_cursor: cursorValue,
904
+ },
905
+ next_cursor: cursorValue,
906
+ },
907
+ entries: feedEntries,
908
+ posts: postsData,
909
+ hasMore: ((response as { meta?: { has_more?: boolean } })?.meta?.has_more) || false,
910
+ nextCursor: cursorValue,
911
+ }
912
+ } catch (err) {
913
+ logger.error('[ContentStore] Failed to load user posts:', err)
914
+ const requestError = err as { response?: { data?: { message?: string } } }
915
+ error.value = requestError.response?.data?.message || 'Failed to load posts'
916
+ throw err
917
+ } finally {
918
+ loading.value = false
919
+ }
920
+ }
921
+
922
+ async function loadFeed(cursor?: string): Promise<FeedResponse> {
923
+ const requestedMode = feedMode.value
924
+ const requestKey = feedRequestKey(requestedMode, cursor)
925
+ const existingRequest = inFlightFeedRequests.get(requestKey)
926
+ if (existingRequest) {
927
+ const requestAgeMs = Date.now() - existingRequest.startedAt
928
+ if (requestAgeMs < FEED_IN_FLIGHT_REQUEST_STALE_MS) {
929
+ return existingRequest.promise
930
+ }
931
+
932
+ inFlightFeedRequests.delete(requestKey)
933
+ }
934
+
935
+ const requestToken = ++feedRequestSequence
936
+ const isFreshLoad = !cursor
937
+ if (isFreshLoad) {
938
+ latestFreshFeedRequestToken = requestToken
939
+ }
940
+ const requestedCursor = cursor
941
+
942
+ const wasFeedRequestSuperseded = (): boolean => {
943
+ const modeChangedSinceRequest = requestedMode !== feedMode.value
944
+ const supersededByNewerFreshRequest = requestToken < latestFreshFeedRequestToken
945
+ const cursorLineageChanged = requestedCursor !== undefined && requestedCursor !== nextCursor.value
946
+
947
+ return modeChangedSinceRequest || supersededByNewerFreshRequest || cursorLineageChanged
948
+ }
949
+
950
+ const requestPromise = (async (): Promise<FeedResponse> => {
951
+ activeFeedRequests += 1
952
+ loading.value = true
953
+ error.value = null
954
+
955
+ try {
956
+ const params = new URLSearchParams()
957
+ params.set('limit', String(PAGE_SIZE))
958
+ params.set('mode', requestedMode)
959
+ if (cursor) {
960
+ params.set('cursor', cursor)
961
+ }
962
+
963
+ const url = `/v1/feed/home?${params.toString()}`
964
+ const response = await client.get(url)
965
+ const responseData = response as FeedHomeResponse
966
+ const serverEntriesRaw: FeedEntry[] = Array.isArray(responseData?.data)
967
+ ? responseData.data
968
+ : responseData?.data?.entries ?? responseData?.entries ?? []
969
+
970
+ let pageEntries: FeedEntry[] = serverEntriesRaw
971
+ if (pageEntries.length > 0) {
972
+ pageEntries = pageEntries.map((entry) => {
973
+ if (!isPostEntryType(entry.entity_type)) {
974
+ return entry
975
+ }
976
+
977
+ return {
978
+ ...entry,
979
+ entity_type: 'post',
980
+ post: entry.post ?? null,
981
+ }
982
+ })
983
+ }
984
+
985
+ if (wasFeedRequestSuperseded()) {
986
+ return {
987
+ data: {
988
+ entries: entries.value,
989
+ meta: {
990
+ next_cursor: nextCursor.value,
991
+ has_more: nextCursor.value !== null,
992
+ },
993
+ next_cursor: nextCursor.value,
994
+ },
995
+ page_entries: [],
996
+ next_cursor: nextCursor.value,
997
+ }
998
+ }
999
+
1000
+ if (isFreshLoad && requestedMode === 'for_you') {
1001
+ await refreshRecentCreatedPostsFromApi()
1002
+ }
1003
+
1004
+ const rawNextCursor = responseData?.next_cursor ?? responseData?.meta?.next_cursor ?? null
1005
+ const explicitHasMore = responseData?.meta?.has_more
1006
+ const hasMorePages = explicitHasMore === true
1007
+ const resolvedNextCursor = explicitHasMore === false ? null : rawNextCursor
1008
+ const normalizedHasMore = explicitHasMore ?? (resolvedNextCursor !== null)
1009
+ const hasActiveRecipe = responseData?.meta?.active_recipe !== null
1010
+ && responseData?.meta?.active_recipe !== undefined
1011
+
1012
+ if (
1013
+ requestedMode === 'for_you'
1014
+ && !cursor
1015
+ && pageEntries.length === 0
1016
+ && resolvedNextCursor === null
1017
+ && !hasMorePages
1018
+ && !hasActiveRecipe
1019
+ ) {
1020
+ setFeedMode('following')
1021
+ return loadFeed()
1022
+ }
1023
+
1024
+ nextCursor.value = resolvedNextCursor
1025
+
1026
+ if (cursor) {
1027
+ const existing = new Set(entries.value.map(entryKey))
1028
+ for (const entry of pageEntries) {
1029
+ if (!existing.has(entryKey(entry))) {
1030
+ entries.value.push(entry)
1031
+ }
1032
+ }
1033
+ } else {
1034
+ const pendingEntries = entries.value.filter((entry) => entry.post?._isPending)
1035
+ const seen = new Set<string>(pendingEntries.map(entryKey))
1036
+ const freshEntries = pageEntries.filter((entry) => {
1037
+ const key = entryKey(entry)
1038
+ if (seen.has(key)) {
1039
+ return false
1040
+ }
1041
+
1042
+ seen.add(key)
1043
+ return true
1044
+ })
1045
+ const recentCreatedEntries = requestedMode === 'for_you'
1046
+ ? buildRecentCreatedFeedEntries(seen)
1047
+ : []
1048
+ entries.value = [...pendingEntries, ...recentCreatedEntries, ...freshEntries]
1049
+ }
1050
+
1051
+ if (requestedMode === 'for_you' && syncActiveRecipe) {
1052
+ const recipeMeta = responseData?.meta?.active_recipe ?? null
1053
+ syncActiveRecipe(recipeMeta)
1054
+ }
1055
+
1056
+ return {
1057
+ data: {
1058
+ entries: entries.value,
1059
+ meta: {
1060
+ next_cursor: nextCursor.value,
1061
+ has_more: normalizedHasMore,
1062
+ },
1063
+ next_cursor: nextCursor.value,
1064
+ },
1065
+ page_entries: pageEntries,
1066
+ next_cursor: nextCursor.value,
1067
+ }
1068
+ } catch (err) {
1069
+ if (wasFeedRequestSuperseded()) {
1070
+ return {
1071
+ data: {
1072
+ entries: entries.value,
1073
+ meta: {
1074
+ next_cursor: nextCursor.value,
1075
+ has_more: nextCursor.value !== null,
1076
+ },
1077
+ next_cursor: nextCursor.value,
1078
+ },
1079
+ page_entries: [],
1080
+ next_cursor: nextCursor.value,
1081
+ }
1082
+ }
1083
+
1084
+ logger.error('[ContentStore] Failed to load feed:', err)
1085
+ const requestError = err as { response?: { data?: { message?: string } } }
1086
+ error.value = requestError.response?.data?.message || 'Failed to load feed'
1087
+
1088
+ return {
1089
+ data: {
1090
+ entries: [],
1091
+ meta: {},
1092
+ next_cursor: null,
1093
+ },
1094
+ page_entries: [],
1095
+ next_cursor: null,
1096
+ }
1097
+ } finally {
1098
+ activeFeedRequests = Math.max(0, activeFeedRequests - 1)
1099
+ loading.value = activeFeedRequests > 0
1100
+ }
1101
+ })()
1102
+
1103
+ inFlightFeedRequests.set(requestKey, {
1104
+ promise: requestPromise,
1105
+ startedAt: Date.now(),
1106
+ })
1107
+
1108
+ return requestPromise.finally(() => {
1109
+ const requestStillTracked = inFlightFeedRequests.get(requestKey)
1110
+ if (requestStillTracked?.promise === requestPromise) {
1111
+ inFlightFeedRequests.delete(requestKey)
1112
+ }
1113
+ })
1114
+ }
1115
+
1116
+ async function loadMore(): Promise<FeedResponse> {
1117
+ if (!nextCursor.value) {
1118
+ return {
1119
+ data: {
1120
+ entries: entries.value,
1121
+ next_cursor: null,
1122
+ },
1123
+ page_entries: [],
1124
+ next_cursor: null,
1125
+ }
1126
+ }
1127
+
1128
+ return loadFeed(nextCursor.value)
1129
+ }
1130
+
1131
+ async function createPost(input: {
1132
+ body: string
1133
+ share_type?: PostShareType
1134
+ quoted_post_id?: string
1135
+ visibility?: string
1136
+ content_warning?: string
1137
+ meta?: PostMeta
1138
+ media_ids?: string[]
1139
+ media_urls?: string[]
1140
+ }): Promise<{ data: Post }> {
1141
+ loading.value = true
1142
+ error.value = null
1143
+
1144
+ try {
1145
+ const response = await client.post('/v1/content/posts', {
1146
+ body: input.body,
1147
+ ...(input.share_type && { share_type: input.share_type }),
1148
+ ...(input.quoted_post_id && { quoted_post_id: input.quoted_post_id }),
1149
+ visibility: input.visibility || 'public',
1150
+ ...(input.content_warning && { content_warning: input.content_warning }),
1151
+ ...(input.meta && { meta: input.meta }),
1152
+ ...(input.media_ids && { media_ids: input.media_ids }),
1153
+ ...(input.media_urls && { media_urls: input.media_urls }),
1154
+ })
1155
+
1156
+ const created: Post | undefined = (response as { data?: Post })?.data
1157
+ if (created) {
1158
+ rememberRecentCreatedPost(created)
1159
+ entries.value.unshift({
1160
+ entity_id: created.id,
1161
+ entity_type: 'post',
1162
+ created_at: created.created_at,
1163
+ post: created,
1164
+ })
1165
+ } else {
1166
+ await loadFeed()
1167
+ }
1168
+
1169
+ return response as { data: Post }
1170
+ } catch (err) {
1171
+ logger.error('[ContentStore] Failed to create post:', err)
1172
+ const requestError = err as { response?: { data?: { message?: string } } }
1173
+ error.value = requestError.response?.data?.message || 'Failed to create post'
1174
+ throw err
1175
+ } finally {
1176
+ loading.value = false
1177
+ }
1178
+ }
1179
+
1180
+ async function updatePost(postId: string, input: {
1181
+ body?: string
1182
+ visibility?: string
1183
+ content_warning?: string | null
1184
+ }): Promise<{ data: Post }> {
1185
+ loading.value = true
1186
+ error.value = null
1187
+
1188
+ try {
1189
+ const response = await client.patch(`/v1/content/posts/${postId}`, input)
1190
+ const entry = entries.value.find((candidate) => candidate.post?.id === postId)
1191
+ const updated = (response as { data?: Post })?.data
1192
+ if (entry?.post && updated) {
1193
+ Object.assign(entry.post, updated)
1194
+ }
1195
+
1196
+ return response as { data: Post }
1197
+ } catch (err) {
1198
+ logger.error('[ContentStore] Failed to update post:', err)
1199
+ const requestError = err as { response?: { data?: { message?: string } } }
1200
+ error.value = requestError.response?.data?.message || 'Failed to update post'
1201
+ throw err
1202
+ } finally {
1203
+ loading.value = false
1204
+ }
1205
+ }
1206
+
1207
+ async function deletePost(postId: string): Promise<boolean> {
1208
+ loading.value = true
1209
+ error.value = null
1210
+
1211
+ try {
1212
+ await client.delete(`/v1/content/posts/${postId}`)
1213
+ entries.value = entries.value.filter((entry) => entry.post?.id !== postId)
1214
+ return true
1215
+ } catch (err) {
1216
+ logger.error('[ContentStore] Failed to delete post:', err)
1217
+ const requestError = err as { response?: { data?: { message?: string } } }
1218
+ error.value = requestError.response?.data?.message || 'Failed to delete post'
1219
+ throw err
1220
+ } finally {
1221
+ loading.value = false
1222
+ }
1223
+ }
1224
+
1225
+ async function toggleReaction(postId: string, _action: 'like' | 'unlike'): Promise<{ data: { added: boolean } }> {
1226
+ try {
1227
+ const response = await client.post('/v1/reactions/toggle', {
1228
+ target_type: 'post',
1229
+ target_id: postId,
1230
+ kind: 'like',
1231
+ })
1232
+
1233
+ const entry = entries.value.find((candidate) => candidate.post?.id === postId)
1234
+ const responseData = (response as { data?: { added?: boolean } })?.data
1235
+ const added = responseData?.added ?? false
1236
+ if (entry?.post) {
1237
+ const wasReacted = entry.post.user_has_reacted
1238
+ entry.post.user_has_reacted = added
1239
+
1240
+ if (added && !wasReacted) {
1241
+ entry.post.reactions_count = (entry.post.reactions_count || 0) + 1
1242
+ } else if (!added && wasReacted) {
1243
+ entry.post.reactions_count = Math.max(0, (entry.post.reactions_count || 0) - 1)
1244
+ }
1245
+ }
1246
+
1247
+ return response as { data: { added: boolean } }
1248
+ } catch (err) {
1249
+ logger.error('[ContentStore] Failed to toggle reaction:', err)
1250
+ throw err
1251
+ }
1252
+ }
1253
+
1254
+ const shouldFallback = (requestError: unknown): boolean => {
1255
+ const status = (requestError as { response?: { status?: number } }).response?.status
1256
+ return status === 404 || status === 405 || status === 410
1257
+ }
1258
+
1259
+ async function loadComments(postId: string, cursor?: string): Promise<{ data: Comment[] }> {
1260
+ try {
1261
+ const params = new URLSearchParams()
1262
+ if (cursor) {
1263
+ params.set('after', cursor)
1264
+ }
1265
+ const query = params.toString()
1266
+
1267
+ const primaryUrl = query === ''
1268
+ ? `/v1/content/posts/${postId}/comments`
1269
+ : `/v1/content/posts/${postId}/comments?${query}`
1270
+ const legacyUrl = query === ''
1271
+ ? `/v1/posts/${postId}/comments`
1272
+ : `/v1/posts/${postId}/comments?${query}`
1273
+
1274
+ let response
1275
+ try {
1276
+ response = await client.get(primaryUrl)
1277
+ } catch (requestError) {
1278
+ if (shouldFallback(requestError)) {
1279
+ response = await client.get(legacyUrl)
1280
+ } else {
1281
+ throw requestError
1282
+ }
1283
+ }
1284
+
1285
+ return response as { data: Comment[] }
1286
+ } catch (err) {
1287
+ logger.error('[ContentStore] Failed to load comments:', err)
1288
+ return { data: [] }
1289
+ }
1290
+ }
1291
+
1292
+ async function addComment(postId: string, input: { body: string }): Promise<{ data: Comment }> {
1293
+ try {
1294
+ const response = await client.post('/v1/comments', {
1295
+ ...input,
1296
+ post_id: postId,
1297
+ })
1298
+
1299
+ const entry = entries.value.find((candidate) => candidate.post?.id === postId)
1300
+ if (entry?.post) {
1301
+ entry.post.comments_count = (entry.post.comments_count || 0) + 1
1302
+ }
1303
+
1304
+ return response as { data: Comment }
1305
+ } catch (err) {
1306
+ logger.error('[ContentStore] Failed to add comment:', err)
1307
+ throw err
1308
+ }
1309
+ }
1310
+
1311
+ async function loadScheduledPosts(cursor?: string): Promise<ScheduledPostsPage> {
1312
+ try {
1313
+ const params = new URLSearchParams()
1314
+ if (cursor) {
1315
+ params.set('cursor', cursor)
1316
+ }
1317
+
1318
+ const query = params.toString()
1319
+ const url = query === '' ? '/v1/schedule/mine' : `/v1/schedule/mine?${query}`
1320
+ const response = await client.get(url)
1321
+ const responseData = response as {
1322
+ data?: {
1323
+ items?: ScheduledPostsPage['data']
1324
+ next_cursor?: string | null
1325
+ }
1326
+ }
1327
+
1328
+ return {
1329
+ data: responseData.data?.items ?? [],
1330
+ meta: {
1331
+ next_cursor: responseData.data?.next_cursor ?? null,
1332
+ },
1333
+ }
1334
+ } catch (err) {
1335
+ logger.error('[ContentStore] Failed to load scheduled posts:', err)
1336
+ throw err
1337
+ }
1338
+ }
1339
+
1340
+ async function cancelScheduledPost(id: string): Promise<void> {
1341
+ try {
1342
+ await client.delete(`/v1/schedule/${id}`)
1343
+ } catch (err) {
1344
+ logger.error('[ContentStore] Failed to cancel scheduled post:', err)
1345
+ throw err
1346
+ }
1347
+ }
1348
+
1349
+ function resetForLogout(): void {
1350
+ entries.value = []
1351
+ nextCursor.value = null
1352
+ loading.value = false
1353
+ error.value = null
1354
+ feedMode.value = 'for_you'
1355
+ pendingPosts.value = []
1356
+ recentCreatedPosts.value = []
1357
+ composerFeeling.value = null
1358
+
1359
+ if (storage === null) {
1360
+ return
1361
+ }
1362
+
1363
+ try {
1364
+ storage.removeItem(PENDING_POSTS_STORAGE_KEY)
1365
+ } catch (storageError) {
1366
+ logger.warn('[ContentStore] Failed to clear pending posts on logout', storageError)
1367
+ }
1368
+
1369
+ try {
1370
+ storage.removeItem(RECENT_CREATED_POSTS_STORAGE_KEY)
1371
+ } catch (storageError) {
1372
+ logger.warn('[ContentStore] Failed to clear recent created posts on logout', storageError)
1373
+ }
1374
+ }
1375
+
1376
+ function updatePostMediaProcessingStatus(input: {
1377
+ postId: string
1378
+ sourceMediaUuid: string
1379
+ status: 'ready' | 'failed'
1380
+ outputMediaId?: string | null
1381
+ playbackUrl?: string | null
1382
+ thumbnailUrl?: string | null
1383
+ errorMessage?: string | null
1384
+ }): void {
1385
+ const entry = entries.value.find((candidate) => candidate.entity_id === input.postId || candidate.post?.id === input.postId)
1386
+ if (!entry?.post?.media || !Array.isArray(entry.post.media)) {
1387
+ return
1388
+ }
1389
+
1390
+ const mediaIndex = entry.post.media.findIndex((media) => (
1391
+ media.id === input.sourceMediaUuid
1392
+ || media.uuid === input.sourceMediaUuid
1393
+ || media.source_media_id === input.sourceMediaUuid
1394
+ ))
1395
+ if (mediaIndex === -1) {
1396
+ return
1397
+ }
1398
+
1399
+ const mediaItem = entry.post.media[mediaIndex]
1400
+ if (!mediaItem) {
1401
+ return
1402
+ }
1403
+
1404
+ const updatedMedia: PostMedia = { ...mediaItem }
1405
+ if (input.status === 'ready') {
1406
+ if (input.outputMediaId) {
1407
+ updatedMedia.id = input.outputMediaId
1408
+ updatedMedia.uuid = input.outputMediaId
1409
+ updatedMedia.output_media_id = input.outputMediaId
1410
+ }
1411
+ if (input.playbackUrl) {
1412
+ updatedMedia.playback_url = input.playbackUrl
1413
+ }
1414
+ if (input.thumbnailUrl) {
1415
+ updatedMedia.thumbnail_url = input.thumbnailUrl
1416
+ }
1417
+ updatedMedia.processing_status = 'ready'
1418
+ updatedMedia.video_render_status = 'ready'
1419
+ updatedMedia.video_render_progress = 100
1420
+ updatedMedia.video_render_error = null
1421
+ } else {
1422
+ updatedMedia.processing_status = 'failed'
1423
+ updatedMedia.video_render_status = 'failed'
1424
+ updatedMedia.video_render_error = input.errorMessage ?? 'Video rendering failed.'
1425
+ }
1426
+
1427
+ const newMedia = [...entry.post.media]
1428
+ newMedia[mediaIndex] = updatedMedia
1429
+ entry.post.media = newMedia
1430
+ }
1431
+
1432
+ if (storage !== null) {
1433
+ restoreRecentCreatedPosts()
1434
+ restorePendingPosts()
1435
+ }
1436
+
1437
+ return {
1438
+ entries,
1439
+ nextCursor,
1440
+ loading,
1441
+ error,
1442
+ pendingPosts,
1443
+ composerFeeling,
1444
+ feedMode,
1445
+
1446
+ hasPendingPosts,
1447
+ PAGE_SIZE,
1448
+
1449
+ loadFeed,
1450
+ loadUserPosts,
1451
+ loadMore,
1452
+ setFeedMode,
1453
+
1454
+ createPost,
1455
+ updatePost,
1456
+ deletePost,
1457
+
1458
+ loadScheduledPosts,
1459
+ cancelScheduledPost,
1460
+
1461
+ enqueuePost,
1462
+ processPendingPost,
1463
+ retryPendingPost,
1464
+ cancelPendingPost,
1465
+ claimLostMediaDraft,
1466
+ setComposerFeeling,
1467
+ claimComposerFeeling,
1468
+ restorePendingPosts,
1469
+ resetForLogout,
1470
+ updatePostMediaProcessingStatus,
1471
+
1472
+ toggleReaction,
1473
+ loadComments,
1474
+ addComment,
1475
+ }
1476
+ })
1477
+ }