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.
- package/CHANGELOG.md +44 -1
- package/README.md +50 -5
- package/package.json +3 -2
- package/scripts/browser-perf.mjs +1 -0
- package/scripts/start-test-server.mjs +16 -3
- package/src/cli.ts +376 -13
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +27 -7
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/DmWorkspace.tsx +18 -8
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +452 -0
- package/src/components/SyncNowButton.tsx +57 -25
- package/src/components/ThemeSlider.tsx +55 -50
- package/src/components/TimelineCard.tsx +225 -93
- package/src/components/TimelineRouteFrame.tsx +22 -8
- package/src/components/TweetMediaGrid.tsx +87 -38
- package/src/components/TweetRichText.tsx +15 -11
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +23 -7
- package/src/lib/account-sync-job.ts +654 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +128 -53
- package/src/lib/archive-finder.ts +78 -63
- package/src/lib/archive-import.ts +1364 -1300
- package/src/lib/authored-live.ts +261 -204
- package/src/lib/avatar-cache.ts +159 -44
- package/src/lib/backup.ts +1532 -951
- package/src/lib/bird-actions.ts +139 -57
- package/src/lib/bird-command.ts +101 -28
- package/src/lib/bird.ts +549 -194
- package/src/lib/blocklist.ts +40 -23
- package/src/lib/blocks-write.ts +129 -80
- package/src/lib/blocks.ts +165 -97
- package/src/lib/bookmark-sync-job.ts +250 -160
- package/src/lib/conversation-surface.ts +79 -48
- package/src/lib/db.ts +33 -3
- package/src/lib/dms-live.ts +720 -66
- package/src/lib/effect-runtime.ts +45 -0
- package/src/lib/follow-graph.ts +224 -180
- package/src/lib/http-effect.ts +222 -0
- package/src/lib/inbox.ts +74 -43
- package/src/lib/link-index.ts +88 -76
- package/src/lib/link-insights.ts +24 -0
- package/src/lib/link-preview-metadata.ts +472 -52
- package/src/lib/media-fetch.ts +286 -213
- package/src/lib/mention-threads-live.ts +352 -288
- package/src/lib/mentions-live.ts +390 -342
- package/src/lib/moderation-target.ts +102 -65
- package/src/lib/moderation-write.ts +77 -18
- package/src/lib/mutes-write.ts +129 -80
- package/src/lib/mutes.ts +8 -1
- package/src/lib/openai.ts +84 -53
- package/src/lib/period-digest.ts +953 -0
- package/src/lib/profile-affiliation-hydration.ts +93 -54
- package/src/lib/profile-hydration.ts +124 -72
- package/src/lib/profile-replies.ts +60 -43
- package/src/lib/profile-resolver.ts +402 -294
- package/src/lib/queries.ts +969 -199
- package/src/lib/research.ts +165 -120
- package/src/lib/sqlite.ts +1 -0
- package/src/lib/timeline-collections-live.ts +204 -167
- package/src/lib/timeline-live.ts +60 -39
- package/src/lib/tweet-lookup.ts +30 -19
- package/src/lib/types.ts +38 -1
- package/src/lib/ui.ts +30 -7
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +216 -148
- package/src/lib/whois.ts +166 -147
- package/src/lib/x-web.ts +102 -71
- package/src/lib/xurl.ts +681 -411
- package/src/routeTree.gen.ts +42 -0
- package/src/routes/__root.tsx +25 -5
- package/src/routes/api/action.tsx +127 -78
- package/src/routes/api/avatar.tsx +39 -30
- package/src/routes/api/blocks.tsx +26 -23
- package/src/routes/api/conversation.tsx +25 -14
- package/src/routes/api/inbox.tsx +27 -21
- package/src/routes/api/link-insights.tsx +31 -25
- package/src/routes/api/link-preview.tsx +25 -21
- package/src/routes/api/period-digest.tsx +123 -0
- package/src/routes/api/profile-hydrate.tsx +31 -28
- package/src/routes/api/query.tsx +79 -55
- package/src/routes/api/status.tsx +18 -10
- package/src/routes/api/sync.tsx +75 -29
- package/src/routes/dms.tsx +95 -28
- package/src/routes/inbox.tsx +32 -19
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
) {
|
|
162
|
-
const stats =
|
|
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
|
-
|
|
165
|
-
return
|
|
192
|
+
yield* tryPromise(() => fs.rm(lockPath, { force: true }));
|
|
193
|
+
return yield* acquireLockEffect(lockPath);
|
|
166
194
|
}
|
|
167
195
|
return undefined;
|
|
168
196
|
}
|
|
169
|
-
|
|
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
|
|
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 = {}):
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
482
|
+
export function installBookmarkSyncLaunchAgentEffect(
|
|
413
483
|
options: BookmarkSyncLaunchAgentOptions = {},
|
|
414
|
-
):
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
81
|
+
return yield* Effect.fail(
|
|
82
|
+
new Error(data.error ?? "Conversation unavailable"),
|
|
83
|
+
);
|
|
88
84
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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(
|
|
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(
|
|
147
|
-
const isOpen = currentSnapshot.expandedTweetId ===
|
|
176
|
+
const record = currentSnapshot.records.get(surfaceId) ?? emptyRecord;
|
|
177
|
+
const isOpen = currentSnapshot.expandedTweetId === surfaceId;
|
|
148
178
|
|
|
149
179
|
const toggle = useCallback(() => {
|
|
150
|
-
const nextExpanded =
|
|
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(
|
|
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,
|