birdclaw 0.5.1 → 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.
Files changed (89) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +50 -5
  3. package/package.json +3 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +376 -13
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +27 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +452 -0
  13. package/src/components/SyncNowButton.tsx +57 -25
  14. package/src/components/ThemeSlider.tsx +55 -50
  15. package/src/components/TimelineCard.tsx +225 -93
  16. package/src/components/TimelineRouteFrame.tsx +22 -8
  17. package/src/components/TweetMediaGrid.tsx +87 -38
  18. package/src/components/TweetRichText.tsx +15 -11
  19. package/src/components/account-selection.ts +64 -0
  20. package/src/components/useTimelineRouteData.ts +23 -7
  21. package/src/lib/account-sync-job.ts +654 -0
  22. package/src/lib/actions-transport.ts +216 -146
  23. package/src/lib/api-client.ts +128 -53
  24. package/src/lib/archive-finder.ts +78 -63
  25. package/src/lib/archive-import.ts +1364 -1300
  26. package/src/lib/authored-live.ts +261 -204
  27. package/src/lib/avatar-cache.ts +159 -44
  28. package/src/lib/backup.ts +1532 -951
  29. package/src/lib/bird-actions.ts +139 -57
  30. package/src/lib/bird-command.ts +101 -28
  31. package/src/lib/bird.ts +549 -194
  32. package/src/lib/blocklist.ts +40 -23
  33. package/src/lib/blocks-write.ts +129 -80
  34. package/src/lib/blocks.ts +165 -97
  35. package/src/lib/bookmark-sync-job.ts +250 -160
  36. package/src/lib/conversation-surface.ts +79 -48
  37. package/src/lib/db.ts +33 -3
  38. package/src/lib/dms-live.ts +720 -66
  39. package/src/lib/effect-runtime.ts +45 -0
  40. package/src/lib/follow-graph.ts +224 -180
  41. package/src/lib/http-effect.ts +222 -0
  42. package/src/lib/inbox.ts +74 -43
  43. package/src/lib/link-index.ts +88 -76
  44. package/src/lib/link-insights.ts +24 -0
  45. package/src/lib/link-preview-metadata.ts +472 -52
  46. package/src/lib/media-fetch.ts +286 -213
  47. package/src/lib/mention-threads-live.ts +352 -288
  48. package/src/lib/mentions-live.ts +390 -342
  49. package/src/lib/moderation-target.ts +102 -65
  50. package/src/lib/moderation-write.ts +77 -18
  51. package/src/lib/mutes-write.ts +129 -80
  52. package/src/lib/mutes.ts +8 -1
  53. package/src/lib/openai.ts +84 -53
  54. package/src/lib/period-digest.ts +953 -0
  55. package/src/lib/profile-affiliation-hydration.ts +93 -54
  56. package/src/lib/profile-hydration.ts +124 -72
  57. package/src/lib/profile-replies.ts +60 -43
  58. package/src/lib/profile-resolver.ts +402 -294
  59. package/src/lib/queries.ts +969 -199
  60. package/src/lib/research.ts +165 -120
  61. package/src/lib/sqlite.ts +1 -0
  62. package/src/lib/timeline-collections-live.ts +204 -167
  63. package/src/lib/timeline-live.ts +60 -39
  64. package/src/lib/tweet-lookup.ts +30 -19
  65. package/src/lib/types.ts +38 -1
  66. package/src/lib/ui.ts +30 -7
  67. package/src/lib/url-expansion.ts +226 -55
  68. package/src/lib/url-safety.ts +220 -0
  69. package/src/lib/web-sync.ts +216 -148
  70. package/src/lib/whois.ts +166 -147
  71. package/src/lib/x-web.ts +102 -71
  72. package/src/lib/xurl.ts +681 -411
  73. package/src/routeTree.gen.ts +42 -0
  74. package/src/routes/__root.tsx +25 -5
  75. package/src/routes/api/action.tsx +127 -78
  76. package/src/routes/api/avatar.tsx +39 -30
  77. package/src/routes/api/blocks.tsx +26 -23
  78. package/src/routes/api/conversation.tsx +25 -14
  79. package/src/routes/api/inbox.tsx +27 -21
  80. package/src/routes/api/link-insights.tsx +31 -25
  81. package/src/routes/api/link-preview.tsx +25 -21
  82. package/src/routes/api/period-digest.tsx +123 -0
  83. package/src/routes/api/profile-hydrate.tsx +31 -28
  84. package/src/routes/api/query.tsx +79 -55
  85. package/src/routes/api/status.tsx +18 -10
  86. package/src/routes/api/sync.tsx +75 -29
  87. package/src/routes/dms.tsx +95 -28
  88. package/src/routes/inbox.tsx +32 -19
  89. package/src/routes/today.tsx +441 -0
@@ -3,12 +3,17 @@ import fs from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
6
+ import { Effect } from "effect";
6
7
  import type { Database } from "./sqlite";
7
- import { maybeAutoSyncBackup, type BackupAutoUpdateResult } from "./backup";
8
+ import {
9
+ maybeAutoSyncBackupEffect,
10
+ type BackupAutoUpdateResult,
11
+ } from "./backup";
8
12
  import { ensureBirdclawDirs, getBirdclawPaths } from "./config";
9
13
  import { getNativeDb } from "./db";
14
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
10
15
  import {
11
- syncTimelineCollection,
16
+ syncTimelineCollectionEffect,
12
17
  type TimelineCollectionMode,
13
18
  } from "./timeline-collections-live";
14
19
 
@@ -124,53 +129,98 @@ function countBookmarks(db: Database) {
124
129
  return row.count;
125
130
  }
126
131
 
127
- async function appendAuditEntry(
132
+ function toError(error: unknown) {
133
+ return error instanceof Error ? error : new Error(String(error));
134
+ }
135
+
136
+ function trySync<T>(try_: () => T) {
137
+ return Effect.try({
138
+ try: try_,
139
+ catch: toError,
140
+ });
141
+ }
142
+
143
+ function appendAuditEntryEffect(
128
144
  logPath: string,
129
145
  entry: BookmarkSyncAuditEntry,
130
146
  ) {
131
- await fs.mkdir(path.dirname(logPath), { recursive: true });
132
- await fs.appendFile(logPath, `${JSON.stringify(entry)}\n`, "utf8");
147
+ return Effect.gen(function* () {
148
+ yield* tryPromise(() =>
149
+ fs.mkdir(path.dirname(logPath), { recursive: true }),
150
+ );
151
+ yield* tryPromise(() =>
152
+ fs.appendFile(logPath, `${JSON.stringify(entry)}\n`, "utf8"),
153
+ );
154
+ });
133
155
  }
134
156
 
135
157
  function messageFromError(error: unknown) {
136
158
  return error instanceof Error ? error.message : String(error);
137
159
  }
138
160
 
139
- async function acquireLock(lockPath: string) {
140
- await fs.mkdir(path.dirname(lockPath), { recursive: true });
141
- try {
142
- const handle = await fs.open(lockPath, "wx");
143
- await handle.writeFile(
144
- `${JSON.stringify({
145
- pid: process.pid,
146
- host: os.hostname(),
147
- startedAt: new Date().toISOString(),
148
- })}\n`,
149
- "utf8",
161
+ function isFileExistsError(error: unknown) {
162
+ return (
163
+ typeof error === "object" &&
164
+ error !== null &&
165
+ "code" in error &&
166
+ error.code === "EEXIST"
167
+ );
168
+ }
169
+
170
+ function acquireLockEffect(
171
+ lockPath: string,
172
+ ): Effect.Effect<(() => Effect.Effect<void, never>) | undefined, unknown> {
173
+ return Effect.gen(function* () {
174
+ yield* tryPromise(() =>
175
+ fs.mkdir(path.dirname(lockPath), { recursive: true }),
150
176
  );
151
- await handle.close();
152
- return async () => {
153
- await fs.rm(lockPath, { force: true });
154
- };
155
- } catch (error) {
156
- if (
157
- typeof error === "object" &&
158
- error !== null &&
159
- "code" in error &&
160
- error.code === "EEXIST"
161
- ) {
162
- const stats = await fs.stat(lockPath).catch(() => undefined);
177
+ const handleResult = yield* tryPromise(() => fs.open(lockPath, "wx")).pipe(
178
+ Effect.map((handle) => ({ handle, ok: true as const })),
179
+ Effect.catchAll((error) => {
180
+ if (isFileExistsError(error)) {
181
+ return Effect.succeed({ error, ok: false as const });
182
+ }
183
+ return Effect.fail(error);
184
+ }),
185
+ );
186
+
187
+ if (!handleResult.ok) {
188
+ const stats = yield* tryPromise(() => fs.stat(lockPath)).pipe(
189
+ Effect.catchAll(() => Effect.succeed(undefined)),
190
+ );
163
191
  if (stats && Date.now() - stats.mtimeMs > DEFAULT_LOCK_STALE_MS) {
164
- await fs.rm(lockPath, { force: true });
165
- return acquireLock(lockPath);
192
+ yield* tryPromise(() => fs.rm(lockPath, { force: true }));
193
+ return yield* acquireLockEffect(lockPath);
166
194
  }
167
195
  return undefined;
168
196
  }
169
- throw error;
170
- }
197
+
198
+ const { handle } = handleResult;
199
+ yield* tryPromise(() =>
200
+ handle.writeFile(
201
+ `${JSON.stringify({
202
+ pid: process.pid,
203
+ host: os.hostname(),
204
+ startedAt: new Date().toISOString(),
205
+ })}\n`,
206
+ "utf8",
207
+ ),
208
+ ).pipe(
209
+ Effect.ensuring(
210
+ tryPromise(() => handle.close()).pipe(
211
+ Effect.catchAll(() => Effect.void),
212
+ ),
213
+ ),
214
+ );
215
+ return () =>
216
+ tryPromise(() => fs.rm(lockPath, { force: true })).pipe(
217
+ Effect.asVoid,
218
+ Effect.catchAll(() => Effect.void),
219
+ );
220
+ });
171
221
  }
172
222
 
173
- export async function runBookmarkSyncJob({
223
+ export function runBookmarkSyncJobEffect({
174
224
  account,
175
225
  mode = "auto",
176
226
  limit = DEFAULT_BOOKMARK_SYNC_LIMIT,
@@ -181,107 +231,127 @@ export async function runBookmarkSyncJob({
181
231
  logPath,
182
232
  lockPath,
183
233
  db,
184
- }: BookmarkSyncJobOptions = {}): Promise<BookmarkSyncAuditEntry> {
185
- ensureBirdclawDirs();
186
- const database = db ?? getNativeDb({ seedDemoData: false });
187
- const resolvedLogPath = resolvePath(
188
- logPath ?? getDefaultBookmarkSyncAuditLogPath(),
189
- );
190
- const resolvedLockPath = resolvePath(
191
- lockPath ?? getDefaultBookmarkSyncLockPath(),
192
- );
193
- const effectiveAll = all ?? maxPages !== undefined;
194
- const started = Date.now();
195
- const startedAt = new Date(started).toISOString();
196
- const before = { bookmarks: countBookmarks(database) };
197
- const options = {
198
- ...(account ? { account } : {}),
199
- mode,
200
- limit,
201
- all: effectiveAll,
202
- ...(maxPages === undefined ? {} : { maxPages }),
203
- refresh,
204
- ...(cacheTtlMs === undefined ? {} : { cacheTtlMs }),
205
- };
206
-
207
- const releaseLock = await acquireLock(resolvedLockPath);
208
- if (!releaseLock) {
209
- const finished = Date.now();
210
- const entry: BookmarkSyncAuditEntry = {
211
- job: "bookmarks-sync",
212
- ok: true,
213
- startedAt,
214
- finishedAt: new Date(finished).toISOString(),
215
- durationMs: finished - started,
216
- host: os.hostname(),
217
- pid: process.pid,
218
- options,
219
- before,
220
- after: before,
221
- added: 0,
222
- skipped: "already-running",
223
- };
224
- await appendAuditEntry(resolvedLogPath, entry);
225
- return entry;
226
- }
227
-
228
- try {
229
- const sync = await syncTimelineCollection({
230
- kind: "bookmarks",
231
- account,
234
+ }: BookmarkSyncJobOptions = {}): Effect.Effect<
235
+ BookmarkSyncAuditEntry,
236
+ unknown
237
+ > {
238
+ return Effect.gen(function* () {
239
+ yield* trySync(() => ensureBirdclawDirs());
240
+ const database =
241
+ db ?? (yield* trySync(() => getNativeDb({ seedDemoData: false })));
242
+ const resolvedLogPath = yield* trySync(() =>
243
+ resolvePath(logPath ?? getDefaultBookmarkSyncAuditLogPath()),
244
+ );
245
+ const resolvedLockPath = yield* trySync(() =>
246
+ resolvePath(lockPath ?? getDefaultBookmarkSyncLockPath()),
247
+ );
248
+ const effectiveAll = all ?? maxPages !== undefined;
249
+ const started = Date.now();
250
+ const startedAt = new Date(started).toISOString();
251
+ const before = yield* trySync(() => ({
252
+ bookmarks: countBookmarks(database),
253
+ }));
254
+ const options = {
255
+ ...(account ? { account } : {}),
232
256
  mode,
233
257
  limit,
234
258
  all: effectiveAll,
235
- maxPages,
259
+ ...(maxPages === undefined ? {} : { maxPages }),
236
260
  refresh,
237
- cacheTtlMs,
238
- });
239
- const backup = await maybeAutoSyncBackup(database);
240
- const finished = Date.now();
241
- const after = { bookmarks: countBookmarks(database) };
242
- const entry: BookmarkSyncAuditEntry = {
243
- job: "bookmarks-sync",
244
- ok: true,
245
- startedAt,
246
- finishedAt: new Date(finished).toISOString(),
247
- durationMs: finished - started,
248
- host: os.hostname(),
249
- pid: process.pid,
250
- options,
251
- before,
252
- after,
253
- added: Math.max(0, after.bookmarks - before.bookmarks),
254
- sync: {
255
- source: sync.source,
256
- count: sync.count,
257
- accountId: sync.accountId,
258
- },
259
- backup,
261
+ ...(cacheTtlMs === undefined ? {} : { cacheTtlMs }),
260
262
  };
261
- await appendAuditEntry(resolvedLogPath, entry);
262
- return entry;
263
- } catch (error) {
264
- const finished = Date.now();
265
- const after = { bookmarks: countBookmarks(database) };
266
- const entry: BookmarkSyncAuditEntry = {
267
- job: "bookmarks-sync",
268
- ok: false,
269
- startedAt,
270
- finishedAt: new Date(finished).toISOString(),
271
- durationMs: finished - started,
272
- host: os.hostname(),
273
- pid: process.pid,
274
- options,
275
- before,
276
- after,
277
- added: Math.max(0, after.bookmarks - before.bookmarks),
278
- error: messageFromError(error),
279
- };
280
- await appendAuditEntry(resolvedLogPath, entry);
281
- return entry;
282
- } finally {
283
- await releaseLock();
284
- }
263
+
264
+ const releaseLock = yield* acquireLockEffect(resolvedLockPath);
265
+ if (!releaseLock) {
266
+ const finished = Date.now();
267
+ const entry: BookmarkSyncAuditEntry = {
268
+ job: "bookmarks-sync",
269
+ ok: true,
270
+ startedAt,
271
+ finishedAt: new Date(finished).toISOString(),
272
+ durationMs: finished - started,
273
+ host: os.hostname(),
274
+ pid: process.pid,
275
+ options,
276
+ before,
277
+ after: before,
278
+ added: 0,
279
+ skipped: "already-running",
280
+ };
281
+ yield* appendAuditEntryEffect(resolvedLogPath, entry);
282
+ return entry;
283
+ }
284
+
285
+ return yield* Effect.gen(function* () {
286
+ const result = yield* Effect.gen(function* () {
287
+ const sync = yield* syncTimelineCollectionEffect({
288
+ kind: "bookmarks",
289
+ account,
290
+ mode,
291
+ limit,
292
+ all: effectiveAll,
293
+ maxPages,
294
+ refresh,
295
+ cacheTtlMs,
296
+ });
297
+ const backup = yield* maybeAutoSyncBackupEffect(database);
298
+ const finished = Date.now();
299
+ const after = yield* trySync(() => ({
300
+ bookmarks: countBookmarks(database),
301
+ }));
302
+ return {
303
+ job: "bookmarks-sync",
304
+ ok: true,
305
+ startedAt,
306
+ finishedAt: new Date(finished).toISOString(),
307
+ durationMs: finished - started,
308
+ host: os.hostname(),
309
+ pid: process.pid,
310
+ options,
311
+ before,
312
+ after,
313
+ added: Math.max(0, after.bookmarks - before.bookmarks),
314
+ sync: {
315
+ source: sync.source,
316
+ count: sync.count,
317
+ accountId: sync.accountId,
318
+ },
319
+ backup,
320
+ } satisfies BookmarkSyncAuditEntry;
321
+ }).pipe(
322
+ Effect.catchAll((error) =>
323
+ Effect.gen(function* () {
324
+ const finished = Date.now();
325
+ const after = yield* trySync(() => ({
326
+ bookmarks: countBookmarks(database),
327
+ }));
328
+ return {
329
+ job: "bookmarks-sync",
330
+ ok: false,
331
+ startedAt,
332
+ finishedAt: new Date(finished).toISOString(),
333
+ durationMs: finished - started,
334
+ host: os.hostname(),
335
+ pid: process.pid,
336
+ options,
337
+ before,
338
+ after,
339
+ added: Math.max(0, after.bookmarks - before.bookmarks),
340
+ error: messageFromError(error),
341
+ } satisfies BookmarkSyncAuditEntry;
342
+ }),
343
+ ),
344
+ );
345
+ yield* appendAuditEntryEffect(resolvedLogPath, result);
346
+ return result;
347
+ }).pipe(Effect.ensuring(releaseLock()));
348
+ });
349
+ }
350
+
351
+ export function runBookmarkSyncJob(
352
+ options: BookmarkSyncJobOptions = {},
353
+ ): Promise<BookmarkSyncAuditEntry> {
354
+ return runEffectPromise(runBookmarkSyncJobEffect(options));
285
355
  }
286
356
 
287
357
  function xmlEscape(value: string) {
@@ -409,38 +479,58 @@ export function buildBookmarkSyncLaunchAgentPlist(
409
479
  };
410
480
  }
411
481
 
412
- export async function installBookmarkSyncLaunchAgent(
482
+ export function installBookmarkSyncLaunchAgentEffect(
413
483
  options: BookmarkSyncLaunchAgentOptions = {},
414
- ): Promise<BookmarkSyncLaunchAgentInstallResult> {
415
- ensureBirdclawDirs();
416
- const agent = buildBookmarkSyncLaunchAgentPlist(options);
417
- const launchAgentsDir = resolvePath(
418
- options.launchAgentsDir ?? "~/Library/LaunchAgents",
419
- );
420
- const plistPath = path.join(launchAgentsDir, `${agent.label}.plist`);
421
- await fs.mkdir(launchAgentsDir, { recursive: true });
422
- await fs.mkdir(path.dirname(agent.logPath), { recursive: true });
423
- await fs.mkdir(path.dirname(agent.stdoutPath), { recursive: true });
424
- await fs.mkdir(path.dirname(agent.stderrPath), { recursive: true });
425
- await fs.writeFile(plistPath, agent.plist, "utf8");
484
+ ): Effect.Effect<BookmarkSyncLaunchAgentInstallResult, unknown> {
485
+ return Effect.gen(function* () {
486
+ yield* trySync(() => ensureBirdclawDirs());
487
+ const agent = yield* trySync(() =>
488
+ buildBookmarkSyncLaunchAgentPlist(options),
489
+ );
490
+ const launchAgentsDir = yield* trySync(() =>
491
+ resolvePath(options.launchAgentsDir ?? "~/Library/LaunchAgents"),
492
+ );
493
+ const plistPath = path.join(launchAgentsDir, `${agent.label}.plist`);
494
+ yield* tryPromise(() => fs.mkdir(launchAgentsDir, { recursive: true }));
495
+ yield* tryPromise(() =>
496
+ fs.mkdir(path.dirname(agent.logPath), { recursive: true }),
497
+ );
498
+ yield* tryPromise(() =>
499
+ fs.mkdir(path.dirname(agent.stdoutPath), { recursive: true }),
500
+ );
501
+ yield* tryPromise(() =>
502
+ fs.mkdir(path.dirname(agent.stderrPath), { recursive: true }),
503
+ );
504
+ yield* tryPromise(() => fs.writeFile(plistPath, agent.plist, "utf8"));
426
505
 
427
- let loaded = false;
428
- if (options.load !== false) {
429
- await execFileAsync("launchctl", ["unload", plistPath]).catch(() => {});
430
- await execFileAsync("launchctl", ["load", "-w", plistPath]);
431
- loaded = true;
432
- }
506
+ let loaded = false;
507
+ if (options.load !== false) {
508
+ yield* tryPromise(() =>
509
+ execFileAsync("launchctl", ["unload", plistPath]),
510
+ ).pipe(Effect.catchAll(() => Effect.void));
511
+ yield* tryPromise(() =>
512
+ execFileAsync("launchctl", ["load", "-w", plistPath]),
513
+ );
514
+ loaded = true;
515
+ }
433
516
 
434
- return {
435
- ok: true,
436
- label: agent.label,
437
- plistPath,
438
- loaded,
439
- programArguments: agent.programArguments,
440
- logPath: agent.logPath,
441
- stdoutPath: agent.stdoutPath,
442
- stderrPath: agent.stderrPath,
443
- intervalSeconds: agent.intervalSeconds,
444
- ...(agent.envFile ? { envFile: agent.envFile } : {}),
445
- };
517
+ return {
518
+ ok: true,
519
+ label: agent.label,
520
+ plistPath,
521
+ loaded,
522
+ programArguments: agent.programArguments,
523
+ logPath: agent.logPath,
524
+ stdoutPath: agent.stdoutPath,
525
+ stderrPath: agent.stderrPath,
526
+ intervalSeconds: agent.intervalSeconds,
527
+ ...(agent.envFile ? { envFile: agent.envFile } : {}),
528
+ };
529
+ });
530
+ }
531
+
532
+ export function installBookmarkSyncLaunchAgent(
533
+ options: BookmarkSyncLaunchAgentOptions = {},
534
+ ): Promise<BookmarkSyncLaunchAgentInstallResult> {
535
+ return runEffectPromise(installBookmarkSyncLaunchAgentEffect(options));
446
536
  }
@@ -1,6 +1,8 @@
1
1
  import { useCallback, useEffect, useSyncExternalStore } from "react";
2
2
  import type { ReactNode } from "react";
3
+ import { Effect } from "effect";
3
4
  import type { EmbeddedTweet } from "#/lib/types";
5
+ import { runEffectPromise } from "./effect-runtime";
4
6
 
5
7
  type ConversationStatus = "idle" | "loading" | "ready" | "error";
6
8
 
@@ -60,53 +62,81 @@ function getSnapshot() {
60
62
  return snapshot;
61
63
  }
62
64
 
63
- async function loadConversation(tweetId: string) {
64
- const current = snapshot.records.get(tweetId);
65
- if (current?.status === "ready" || inFlight.has(tweetId)) {
66
- return;
67
- }
68
-
69
- const loadGeneration = generation;
70
- inFlight.add(tweetId);
71
- updateRecord(tweetId, {
72
- error: null,
73
- items: current?.items ?? [],
74
- status: "loading",
75
- });
76
-
77
- try {
78
- const response = await fetch(
79
- `/api/conversation?tweetId=${encodeURIComponent(tweetId)}`,
80
- );
81
- const data = (await response.json()) as {
65
+ function fetchConversationItemsEffect(tweetId: string) {
66
+ return Effect.gen(function* () {
67
+ const response = yield* Effect.tryPromise({
68
+ try: () =>
69
+ fetch(`/api/conversation?tweetId=${encodeURIComponent(tweetId)}`),
70
+ catch: (error) => error,
71
+ });
72
+ const data = (yield* Effect.tryPromise({
73
+ try: () => response.json(),
74
+ catch: (error) => error,
75
+ })) as {
82
76
  error?: string;
83
77
  items?: EmbeddedTweet[];
84
78
  ok?: boolean;
85
79
  };
86
80
  if (!response.ok || data.ok === false) {
87
- throw new Error(data.error ?? "Conversation unavailable");
81
+ return yield* Effect.fail(
82
+ new Error(data.error ?? "Conversation unavailable"),
83
+ );
88
84
  }
89
- if (loadGeneration !== generation) {
90
- return;
91
- }
92
- updateRecord(tweetId, {
93
- error: null,
94
- items: (data.items ?? []).filter(Boolean),
95
- status: "ready",
96
- });
97
- } catch (error) {
98
- if (loadGeneration !== generation) {
85
+ return (data.items ?? []).filter(Boolean);
86
+ });
87
+ }
88
+
89
+ export function loadConversationEffect(
90
+ surfaceId: string,
91
+ tweetId = surfaceId,
92
+ ): Effect.Effect<void, never> {
93
+ return Effect.gen(function* () {
94
+ const current = snapshot.records.get(surfaceId);
95
+ if (current?.status === "ready" || inFlight.has(surfaceId)) {
99
96
  return;
100
97
  }
101
- updateRecord(tweetId, {
102
- error:
103
- error instanceof Error ? error.message : "Conversation unavailable",
104
- items: [],
105
- status: "error",
106
- });
107
- } finally {
108
- inFlight.delete(tweetId);
109
- }
98
+
99
+ const loadGeneration = generation;
100
+ inFlight.add(surfaceId);
101
+ yield* Effect.gen(function* () {
102
+ updateRecord(surfaceId, {
103
+ error: null,
104
+ items: current?.items ?? [],
105
+ status: "loading",
106
+ });
107
+
108
+ const result = yield* fetchConversationItemsEffect(tweetId).pipe(
109
+ Effect.match({
110
+ onFailure: (error) => ({ error, ok: false as const }),
111
+ onSuccess: (items) => ({ items, ok: true as const }),
112
+ }),
113
+ );
114
+
115
+ if (loadGeneration !== generation) {
116
+ return;
117
+ }
118
+ if (result.ok) {
119
+ updateRecord(surfaceId, {
120
+ error: null,
121
+ items: result.items,
122
+ status: "ready",
123
+ });
124
+ } else {
125
+ updateRecord(surfaceId, {
126
+ error:
127
+ result.error instanceof Error
128
+ ? result.error.message
129
+ : "Conversation unavailable",
130
+ items: [],
131
+ status: "error",
132
+ });
133
+ }
134
+ }).pipe(Effect.ensuring(Effect.sync(() => inFlight.delete(surfaceId))));
135
+ });
136
+ }
137
+
138
+ function loadConversation(surfaceId: string, tweetId = surfaceId) {
139
+ return runEffectPromise(loadConversationEffect(surfaceId, tweetId));
110
140
  }
111
141
 
112
142
  export function retainConversationSurfaceScope() {
@@ -137,30 +167,31 @@ export function ConversationSurfaceScope({
137
167
  return children;
138
168
  }
139
169
 
140
- export function useConversationSurface(tweetId: string) {
170
+ export function useConversationSurface(surfaceId: string, tweetId = surfaceId) {
141
171
  const currentSnapshot = useSyncExternalStore(
142
172
  subscribe,
143
173
  getSnapshot,
144
174
  getSnapshot,
145
175
  );
146
- const record = currentSnapshot.records.get(tweetId) ?? emptyRecord;
147
- const isOpen = currentSnapshot.expandedTweetId === tweetId;
176
+ const record = currentSnapshot.records.get(surfaceId) ?? emptyRecord;
177
+ const isOpen = currentSnapshot.expandedTweetId === surfaceId;
148
178
 
149
179
  const toggle = useCallback(() => {
150
- const nextExpanded = snapshot.expandedTweetId === tweetId ? null : tweetId;
180
+ const nextExpanded =
181
+ snapshot.expandedTweetId === surfaceId ? null : surfaceId;
151
182
  setSnapshot({ ...snapshot, expandedTweetId: nextExpanded });
152
183
  if (nextExpanded) {
153
- void loadConversation(tweetId);
184
+ void loadConversation(surfaceId, tweetId);
154
185
  }
155
- }, [tweetId]);
186
+ }, [surfaceId, tweetId]);
156
187
 
157
188
  const prefetch = useCallback(() => {
158
- const current = snapshot.records.get(tweetId);
189
+ const current = snapshot.records.get(surfaceId);
159
190
  if (current?.status === "ready" || current?.status === "loading") {
160
191
  return;
161
192
  }
162
- void loadConversation(tweetId);
163
- }, [tweetId]);
193
+ void loadConversation(surfaceId, tweetId);
194
+ }, [surfaceId, tweetId]);
164
195
 
165
196
  return {
166
197
  error: record.error,