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