birdclaw 0.5.0 → 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 +63 -0
- package/README.md +55 -5
- package/bin/birdclaw.mjs +50 -11
- package/package.json +9 -7
- package/public/birdclaw-mark.png +0 -0
- package/scripts/browser-perf.mjs +400 -0
- package/scripts/build-docs-site.mjs +940 -0
- package/scripts/docs-site-assets.mjs +311 -0
- package/scripts/run-vitest.mjs +21 -0
- package/scripts/sanitize-node-options.mjs +23 -0
- package/scripts/start-test-server.mjs +42 -0
- package/src/cli.ts +422 -14
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +29 -9
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +11 -10
- package/src/components/DmWorkspace.tsx +39 -14
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +14 -2
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +452 -0
- package/src/components/SavedTimelineView.tsx +64 -56
- package/src/components/SyncNowButton.tsx +137 -0
- package/src/components/ThemeSlider.tsx +49 -93
- package/src/components/TimelineCard.tsx +364 -136
- package/src/components/TimelineRouteFrame.tsx +170 -0
- package/src/components/TweetMediaGrid.tsx +170 -24
- package/src/components/TweetRichText.tsx +28 -13
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +153 -0
- package/src/lib/account-sync-job.ts +654 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +304 -0
- package/src/lib/archive-finder.ts +72 -53
- package/src/lib/archive-import.ts +1377 -1298
- 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 +205 -0
- package/src/lib/db.ts +35 -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 +1024 -189
- 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 +41 -10
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +511 -0
- 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 +63 -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 +105 -0
- package/src/routes/dms.tsx +195 -55
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/index.tsx +21 -127
- package/src/routes/links.tsx +50 -5
- package/src/routes/mentions.tsx +21 -127
- package/src/routes/today.tsx +441 -0
- package/src/styles.css +74 -11
- package/vite.config.ts +8 -0
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { Database } from "./sqlite";
|
|
2
|
-
import {
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import {
|
|
4
|
+
listBookmarkedTweetsViaBirdEffect,
|
|
5
|
+
listLikedTweetsViaBirdEffect,
|
|
6
|
+
} from "./bird";
|
|
3
7
|
import { getNativeDb } from "./db";
|
|
8
|
+
import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
4
9
|
import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
5
10
|
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
6
11
|
import type {
|
|
@@ -18,12 +23,34 @@ import {
|
|
|
18
23
|
|
|
19
24
|
export type TimelineCollectionKind = "likes" | "bookmarks";
|
|
20
25
|
export type TimelineCollectionMode = "auto" | "xurl" | "bird";
|
|
26
|
+
export interface SyncTimelineCollectionOptions {
|
|
27
|
+
kind: TimelineCollectionKind;
|
|
28
|
+
account?: string;
|
|
29
|
+
mode?: TimelineCollectionMode;
|
|
30
|
+
limit?: number;
|
|
31
|
+
all?: boolean;
|
|
32
|
+
maxPages?: number;
|
|
33
|
+
refresh?: boolean;
|
|
34
|
+
cacheTtlMs?: number;
|
|
35
|
+
earlyStop?: boolean;
|
|
36
|
+
}
|
|
21
37
|
|
|
22
38
|
const DEFAULT_COLLECTION_CACHE_TTL_MS = 2 * 60_000;
|
|
23
39
|
const DEFAULT_EARLY_STOP_MAX_PAGES = 10;
|
|
24
40
|
const MIN_XURL_LIMIT = 5;
|
|
25
41
|
const MAX_XURL_LIMIT = 100;
|
|
26
42
|
|
|
43
|
+
function toError(error: unknown) {
|
|
44
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function trySync<T>(try_: () => T) {
|
|
48
|
+
return Effect.try({
|
|
49
|
+
try: try_,
|
|
50
|
+
catch: toError,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
27
54
|
function parseCacheTtlMs(value?: number) {
|
|
28
55
|
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
29
56
|
return DEFAULT_COLLECTION_CACHE_TTL_MS;
|
|
@@ -300,7 +327,7 @@ function mergeTimelineCollectionIntoLocalStore(
|
|
|
300
327
|
})();
|
|
301
328
|
}
|
|
302
329
|
|
|
303
|
-
|
|
330
|
+
function fetchXurlCollectionEffect({
|
|
304
331
|
db,
|
|
305
332
|
kind,
|
|
306
333
|
accountId,
|
|
@@ -321,80 +348,84 @@ async function fetchXurlCollection({
|
|
|
321
348
|
maxPages: number | null;
|
|
322
349
|
earlyStop: boolean;
|
|
323
350
|
}) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
351
|
+
return Effect.gen(function* () {
|
|
352
|
+
let resolvedUserId = userId;
|
|
353
|
+
if (!resolvedUserId) {
|
|
354
|
+
const [accountUser] = yield* tryPromise(() =>
|
|
355
|
+
lookupUsersByHandles([username]),
|
|
356
|
+
);
|
|
357
|
+
if (!accountUser?.id) {
|
|
358
|
+
return yield* Effect.fail(
|
|
359
|
+
new Error(`Could not resolve Twitter user id for @${username}`),
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
resolvedUserId = String(accountUser.id);
|
|
329
363
|
}
|
|
330
|
-
resolvedUserId = String(accountUser.id);
|
|
331
|
-
}
|
|
332
364
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
pageCount += 1;
|
|
354
|
-
if (earlyStop) {
|
|
355
|
-
const tweetIds = payload.data.map((tweet) => tweet.id);
|
|
356
|
-
const { existingTweetIds, uniqueTweetCount } = getCollectionPageDedupe(
|
|
357
|
-
db,
|
|
358
|
-
accountId,
|
|
359
|
-
kind,
|
|
360
|
-
tweetIds,
|
|
365
|
+
const pages: XurlMentionsResponse[] = [];
|
|
366
|
+
let nextToken: string | undefined;
|
|
367
|
+
let pageCount = 0;
|
|
368
|
+
let saturatedAtPage: number | undefined;
|
|
369
|
+
do {
|
|
370
|
+
const payload = yield* tryPromise(() =>
|
|
371
|
+
kind === "likes"
|
|
372
|
+
? listLikedTweetsViaXurl({
|
|
373
|
+
maxResults: limit,
|
|
374
|
+
username,
|
|
375
|
+
userId: resolvedUserId,
|
|
376
|
+
paginationToken: nextToken,
|
|
377
|
+
})
|
|
378
|
+
: listBookmarkedTweetsViaXurl({
|
|
379
|
+
maxResults: limit,
|
|
380
|
+
username,
|
|
381
|
+
userId: resolvedUserId,
|
|
382
|
+
isPaginatedWalk: all,
|
|
383
|
+
paginationToken: nextToken,
|
|
384
|
+
}),
|
|
361
385
|
);
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
386
|
+
pageCount += 1;
|
|
387
|
+
if (earlyStop) {
|
|
388
|
+
const tweetIds = payload.data.map((tweet) => tweet.id);
|
|
389
|
+
const { existingTweetIds, uniqueTweetCount } = yield* trySync(() =>
|
|
390
|
+
getCollectionPageDedupe(db, accountId, kind, tweetIds),
|
|
366
391
|
);
|
|
367
|
-
|
|
392
|
+
if (tweetIds.length > 0 && existingTweetIds.size === uniqueTweetCount) {
|
|
393
|
+
saturatedAtPage = pageCount;
|
|
394
|
+
console.error(
|
|
395
|
+
`${kind} saturated at page ${pageCount} (100% existing rows)`,
|
|
396
|
+
);
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
pages.push(filterExistingCollectionTweets(payload, existingTweetIds));
|
|
400
|
+
} else {
|
|
401
|
+
pages.push(payload);
|
|
368
402
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
(all || earlyStop) &&
|
|
379
|
-
nextToken &&
|
|
380
|
-
(maxPages === null || pageCount < maxPages)
|
|
381
|
-
);
|
|
403
|
+
nextToken =
|
|
404
|
+
typeof payload.meta?.next_token === "string"
|
|
405
|
+
? payload.meta.next_token
|
|
406
|
+
: undefined;
|
|
407
|
+
} while (
|
|
408
|
+
(all || earlyStop) &&
|
|
409
|
+
nextToken &&
|
|
410
|
+
(maxPages === null || pageCount < maxPages)
|
|
411
|
+
);
|
|
382
412
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
413
|
+
const merged = mergePayloads(pages);
|
|
414
|
+
// A saturated page may expose another token, but our walk is complete.
|
|
415
|
+
const saturationMeta =
|
|
416
|
+
saturatedAtPage === undefined
|
|
417
|
+
? {}
|
|
418
|
+
: { saturated_at_page: saturatedAtPage, next_token: null };
|
|
419
|
+
merged.meta = {
|
|
420
|
+
...merged.meta,
|
|
421
|
+
page_count: pageCount,
|
|
422
|
+
...saturationMeta,
|
|
423
|
+
};
|
|
424
|
+
return merged;
|
|
425
|
+
});
|
|
395
426
|
}
|
|
396
427
|
|
|
397
|
-
|
|
428
|
+
function fetchBirdCollectionEffect({
|
|
398
429
|
kind,
|
|
399
430
|
limit,
|
|
400
431
|
all,
|
|
@@ -406,19 +437,19 @@ async function fetchBirdCollection({
|
|
|
406
437
|
maxPages: number | null;
|
|
407
438
|
}) {
|
|
408
439
|
return kind === "likes"
|
|
409
|
-
?
|
|
440
|
+
? listLikedTweetsViaBirdEffect({
|
|
410
441
|
maxResults: limit,
|
|
411
442
|
all,
|
|
412
443
|
maxPages: maxPages ?? undefined,
|
|
413
444
|
})
|
|
414
|
-
:
|
|
445
|
+
: listBookmarkedTweetsViaBirdEffect({
|
|
415
446
|
maxResults: limit,
|
|
416
447
|
all,
|
|
417
448
|
maxPages: maxPages ?? undefined,
|
|
418
449
|
});
|
|
419
450
|
}
|
|
420
451
|
|
|
421
|
-
export
|
|
452
|
+
export function syncTimelineCollectionEffect({
|
|
422
453
|
kind,
|
|
423
454
|
account,
|
|
424
455
|
mode = "auto",
|
|
@@ -428,72 +459,64 @@ export async function syncTimelineCollection({
|
|
|
428
459
|
refresh = false,
|
|
429
460
|
cacheTtlMs,
|
|
430
461
|
earlyStop = false,
|
|
431
|
-
}: {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
const parsedMaxPages = parseMaxPages(maxPages);
|
|
444
|
-
const shouldApplyEarlyStopCap =
|
|
445
|
-
earlyStop && !all && parsedMaxPages === null && mode !== "bird";
|
|
446
|
-
const xurlMaxPages = shouldApplyEarlyStopCap
|
|
447
|
-
? DEFAULT_EARLY_STOP_MAX_PAGES
|
|
448
|
-
: parsedMaxPages;
|
|
449
|
-
if (mode === "xurl" || mode === "auto") {
|
|
450
|
-
assertXurlLimit(limit);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
const db = getNativeDb();
|
|
454
|
-
const resolvedAccount = resolveAccount(db, account);
|
|
455
|
-
const cacheMaxPages = mode === "bird" ? parsedMaxPages : xurlMaxPages;
|
|
456
|
-
const cacheKey = `${kind}:${mode}:${resolvedAccount.accountId}:${String(limit)}:${all ? "all" : "single"}:${cacheMaxPages === null ? "all-pages" : String(cacheMaxPages)}${earlyStop ? ":early-stop" : ""}`;
|
|
457
|
-
const ttlMs = parseCacheTtlMs(cacheTtlMs);
|
|
458
|
-
const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
|
|
459
|
-
const cacheAgeMs = cached
|
|
460
|
-
? Date.now() - new Date(cached.updatedAt).getTime()
|
|
461
|
-
: Number.POSITIVE_INFINITY;
|
|
462
|
-
|
|
463
|
-
if (!refresh && cached && cacheAgeMs <= ttlMs) {
|
|
464
|
-
const saturatedAtPage = readSaturatedAtPage(cached.value);
|
|
465
|
-
return {
|
|
466
|
-
ok: true,
|
|
467
|
-
source: "cache",
|
|
468
|
-
kind,
|
|
469
|
-
accountId: resolvedAccount.accountId,
|
|
470
|
-
count: cached.value.data.length,
|
|
471
|
-
payload: cached.value,
|
|
472
|
-
...(saturatedAtPage === undefined
|
|
473
|
-
? {}
|
|
474
|
-
: { saturated_at_page: saturatedAtPage }),
|
|
475
|
-
};
|
|
476
|
-
}
|
|
462
|
+
}: SyncTimelineCollectionOptions) {
|
|
463
|
+
return Effect.gen(function* () {
|
|
464
|
+
yield* trySync(() => assertLimit(limit));
|
|
465
|
+
const parsedMaxPages = yield* trySync(() => parseMaxPages(maxPages));
|
|
466
|
+
const shouldApplyEarlyStopCap =
|
|
467
|
+
earlyStop && !all && parsedMaxPages === null && mode !== "bird";
|
|
468
|
+
const xurlMaxPages = shouldApplyEarlyStopCap
|
|
469
|
+
? DEFAULT_EARLY_STOP_MAX_PAGES
|
|
470
|
+
: parsedMaxPages;
|
|
471
|
+
if (mode === "xurl" || mode === "auto") {
|
|
472
|
+
yield* trySync(() => assertXurlLimit(limit));
|
|
473
|
+
}
|
|
477
474
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
475
|
+
const db = yield* trySync(() => getNativeDb());
|
|
476
|
+
const resolvedAccount = yield* trySync(() => resolveAccount(db, account));
|
|
477
|
+
const cacheMaxPages = mode === "bird" ? parsedMaxPages : xurlMaxPages;
|
|
478
|
+
const cacheKey = `${kind}:${mode}:${resolvedAccount.accountId}:${String(limit)}:${all ? "all" : "single"}:${cacheMaxPages === null ? "all-pages" : String(cacheMaxPages)}${earlyStop ? ":early-stop" : ""}`;
|
|
479
|
+
const ttlMs = parseCacheTtlMs(cacheTtlMs);
|
|
480
|
+
const cached = yield* trySync(() =>
|
|
481
|
+
readSyncCache<XurlMentionsResponse>(cacheKey, db),
|
|
481
482
|
);
|
|
482
|
-
|
|
483
|
+
const cacheAgeMs = cached
|
|
484
|
+
? Date.now() - new Date(cached.updatedAt).getTime()
|
|
485
|
+
: Number.POSITIVE_INFINITY;
|
|
486
|
+
|
|
487
|
+
if (!refresh && cached && cacheAgeMs <= ttlMs) {
|
|
488
|
+
const saturatedAtPage = readSaturatedAtPage(cached.value);
|
|
489
|
+
return {
|
|
490
|
+
ok: true,
|
|
491
|
+
source: "cache",
|
|
492
|
+
kind,
|
|
493
|
+
accountId: resolvedAccount.accountId,
|
|
494
|
+
count: cached.value.data.length,
|
|
495
|
+
payload: cached.value,
|
|
496
|
+
...(saturatedAtPage === undefined
|
|
497
|
+
? {}
|
|
498
|
+
: { saturated_at_page: saturatedAtPage }),
|
|
499
|
+
};
|
|
500
|
+
}
|
|
483
501
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
502
|
+
if (shouldApplyEarlyStopCap) {
|
|
503
|
+
console.error(
|
|
504
|
+
`${kind} early-stop capped at ${DEFAULT_EARLY_STOP_MAX_PAGES} pages by default; pass --max-pages or --all to override`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
let source: "xurl" | "bird";
|
|
509
|
+
let payload: XurlMentionsResponse;
|
|
510
|
+
if (mode === "bird") {
|
|
511
|
+
payload = yield* fetchBirdCollectionEffect({
|
|
512
|
+
kind,
|
|
513
|
+
limit,
|
|
514
|
+
all,
|
|
515
|
+
maxPages: parsedMaxPages,
|
|
516
|
+
});
|
|
517
|
+
source = "bird";
|
|
518
|
+
} else {
|
|
519
|
+
const xurlPayload = yield* fetchXurlCollectionEffect({
|
|
497
520
|
db,
|
|
498
521
|
kind,
|
|
499
522
|
accountId: resolvedAccount.accountId,
|
|
@@ -503,41 +526,55 @@ export async function syncTimelineCollection({
|
|
|
503
526
|
all,
|
|
504
527
|
maxPages: xurlMaxPages,
|
|
505
528
|
earlyStop,
|
|
506
|
-
})
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
529
|
+
}).pipe(
|
|
530
|
+
Effect.map((value) => ({ ok: true as const, value })),
|
|
531
|
+
Effect.catchAll((error) => {
|
|
532
|
+
if (mode === "xurl") {
|
|
533
|
+
return Effect.fail(error);
|
|
534
|
+
}
|
|
535
|
+
return Effect.succeed({ ok: false as const });
|
|
536
|
+
}),
|
|
537
|
+
);
|
|
538
|
+
if (xurlPayload.ok) {
|
|
539
|
+
payload = xurlPayload.value;
|
|
540
|
+
source = "xurl";
|
|
541
|
+
} else {
|
|
542
|
+
payload = yield* fetchBirdCollectionEffect({
|
|
543
|
+
kind,
|
|
544
|
+
limit,
|
|
545
|
+
all,
|
|
546
|
+
maxPages: parsedMaxPages,
|
|
547
|
+
});
|
|
548
|
+
source = "bird";
|
|
511
549
|
}
|
|
512
|
-
payload = await fetchBirdCollection({
|
|
513
|
-
kind,
|
|
514
|
-
limit,
|
|
515
|
-
all,
|
|
516
|
-
maxPages: parsedMaxPages,
|
|
517
|
-
});
|
|
518
|
-
source = "bird";
|
|
519
550
|
}
|
|
520
|
-
}
|
|
521
551
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
552
|
+
yield* trySync(() =>
|
|
553
|
+
mergeTimelineCollectionIntoLocalStore(
|
|
554
|
+
db,
|
|
555
|
+
resolvedAccount.accountId,
|
|
556
|
+
kind,
|
|
557
|
+
payload,
|
|
558
|
+
source,
|
|
559
|
+
),
|
|
560
|
+
);
|
|
561
|
+
yield* trySync(() => writeSyncCache(cacheKey, payload, db));
|
|
562
|
+
const saturatedAtPage = readSaturatedAtPage(payload);
|
|
531
563
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
564
|
+
return {
|
|
565
|
+
ok: true,
|
|
566
|
+
source,
|
|
567
|
+
kind,
|
|
568
|
+
accountId: resolvedAccount.accountId,
|
|
569
|
+
count: payload.data.length,
|
|
570
|
+
payload,
|
|
571
|
+
...(saturatedAtPage === undefined
|
|
572
|
+
? {}
|
|
573
|
+
: { saturated_at_page: saturatedAtPage }),
|
|
574
|
+
};
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export function syncTimelineCollection(options: SyncTimelineCollectionOptions) {
|
|
579
|
+
return runEffectPromise(syncTimelineCollectionEffect(options));
|
|
543
580
|
}
|
package/src/lib/timeline-live.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
1
2
|
import type { Database } from "./sqlite";
|
|
2
|
-
import {
|
|
3
|
+
import { listHomeTimelineViaBirdEffect } from "./bird";
|
|
3
4
|
import { getNativeDb } from "./db";
|
|
5
|
+
import { runEffectPromise } from "./effect-runtime";
|
|
4
6
|
import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
5
7
|
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
6
8
|
import type { XurlMentionsResponse } from "./types";
|
|
@@ -9,6 +11,14 @@ import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
|
|
|
9
11
|
|
|
10
12
|
const DEFAULT_TIMELINE_CACHE_TTL_MS = 2 * 60_000;
|
|
11
13
|
|
|
14
|
+
export interface SyncHomeTimelineOptions {
|
|
15
|
+
account?: string;
|
|
16
|
+
limit?: number;
|
|
17
|
+
following?: boolean;
|
|
18
|
+
refresh?: boolean;
|
|
19
|
+
cacheTtlMs?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
function parseCacheTtlMs(value?: number) {
|
|
13
23
|
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
14
24
|
return DEFAULT_TIMELINE_CACHE_TTL_MS;
|
|
@@ -123,55 +133,66 @@ function mergeHomeTimelineIntoLocalStore(
|
|
|
123
133
|
})();
|
|
124
134
|
}
|
|
125
135
|
|
|
126
|
-
export
|
|
136
|
+
export function syncHomeTimelineEffect({
|
|
127
137
|
account,
|
|
128
138
|
limit = 100,
|
|
129
139
|
following = true,
|
|
130
140
|
refresh = false,
|
|
131
141
|
cacheTtlMs,
|
|
132
|
-
}: {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
142
|
+
}: SyncHomeTimelineOptions = {}): Effect.Effect<
|
|
143
|
+
{
|
|
144
|
+
ok: true;
|
|
145
|
+
source: "bird" | "cache";
|
|
146
|
+
kind: "timeline";
|
|
147
|
+
accountId: string;
|
|
148
|
+
feed: "following" | "for-you";
|
|
149
|
+
count: number;
|
|
150
|
+
payload: XurlMentionsResponse;
|
|
151
|
+
},
|
|
152
|
+
unknown
|
|
153
|
+
> {
|
|
154
|
+
return Effect.gen(function* () {
|
|
155
|
+
assertLimit(limit);
|
|
156
|
+
const db = getNativeDb();
|
|
157
|
+
const accountId = resolveAccount(db, account);
|
|
158
|
+
const cacheKey = `timeline:bird:${accountId}:${following ? "following" : "for-you"}:${String(limit)}`;
|
|
159
|
+
const ttlMs = parseCacheTtlMs(cacheTtlMs);
|
|
160
|
+
const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
|
|
161
|
+
const cacheAgeMs = cached
|
|
162
|
+
? Date.now() - new Date(cached.updatedAt).getTime()
|
|
163
|
+
: Number.POSITIVE_INFINITY;
|
|
164
|
+
|
|
165
|
+
if (!refresh && cached && cacheAgeMs <= ttlMs) {
|
|
166
|
+
return {
|
|
167
|
+
ok: true,
|
|
168
|
+
source: "cache",
|
|
169
|
+
kind: "timeline",
|
|
170
|
+
accountId,
|
|
171
|
+
feed: following ? "following" : "for-you",
|
|
172
|
+
count: cached.value.data.length,
|
|
173
|
+
payload: cached.value,
|
|
174
|
+
} as const;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const payload = yield* listHomeTimelineViaBirdEffect({
|
|
178
|
+
maxResults: limit,
|
|
179
|
+
following,
|
|
180
|
+
});
|
|
181
|
+
mergeHomeTimelineIntoLocalStore(db, accountId, payload);
|
|
182
|
+
writeSyncCache(cacheKey, payload, db);
|
|
148
183
|
|
|
149
|
-
if (!refresh && cached && cacheAgeMs <= ttlMs) {
|
|
150
184
|
return {
|
|
151
185
|
ok: true,
|
|
152
|
-
source: "
|
|
186
|
+
source: "bird",
|
|
153
187
|
kind: "timeline",
|
|
154
188
|
accountId,
|
|
155
189
|
feed: following ? "following" : "for-you",
|
|
156
|
-
count:
|
|
157
|
-
payload
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const payload = await listHomeTimelineViaBird({
|
|
162
|
-
maxResults: limit,
|
|
163
|
-
following,
|
|
190
|
+
count: payload.data.length,
|
|
191
|
+
payload,
|
|
192
|
+
} as const;
|
|
164
193
|
});
|
|
165
|
-
|
|
166
|
-
writeSyncCache(cacheKey, payload, db);
|
|
194
|
+
}
|
|
167
195
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
source: "bird",
|
|
171
|
-
kind: "timeline",
|
|
172
|
-
accountId,
|
|
173
|
-
feed: following ? "following" : "for-you",
|
|
174
|
-
count: payload.data.length,
|
|
175
|
-
payload,
|
|
176
|
-
};
|
|
196
|
+
export function syncHomeTimeline(options: SyncHomeTimelineOptions = {}) {
|
|
197
|
+
return runEffectPromise(syncHomeTimelineEffect(options));
|
|
177
198
|
}
|
package/src/lib/tweet-lookup.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { lookupTweetsByIdsViaBirdEffect } from "./bird";
|
|
3
|
+
import { runEffectPromise } from "./effect-runtime";
|
|
2
4
|
import type { XurlTweetsResponse } from "./types";
|
|
3
|
-
import {
|
|
5
|
+
import { lookupTweetsByIdsEffect as lookupTweetsByIdsViaXurlEffect } from "./xurl";
|
|
4
6
|
|
|
5
7
|
export type TweetLookupMode = "auto" | "xurl" | "bird";
|
|
6
8
|
|
|
@@ -8,28 +10,37 @@ function errorMessage(error: unknown) {
|
|
|
8
10
|
return error instanceof Error ? error.message : String(error);
|
|
9
11
|
}
|
|
10
12
|
|
|
11
|
-
export
|
|
13
|
+
export function lookupTweetsByIdsEffect(
|
|
12
14
|
ids: string[],
|
|
13
15
|
mode: TweetLookupMode = "auto",
|
|
14
|
-
):
|
|
16
|
+
): Effect.Effect<XurlTweetsResponse, unknown> {
|
|
15
17
|
if (mode === "bird") {
|
|
16
|
-
return
|
|
18
|
+
return lookupTweetsByIdsViaBirdEffect(ids);
|
|
17
19
|
}
|
|
18
20
|
if (mode === "xurl") {
|
|
19
|
-
return
|
|
21
|
+
return lookupTweetsByIdsViaXurlEffect(ids);
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
24
|
+
return lookupTweetsByIdsViaXurlEffect(ids).pipe(
|
|
25
|
+
Effect.catchAll((xurlError) =>
|
|
26
|
+
lookupTweetsByIdsViaBirdEffect(ids).pipe(
|
|
27
|
+
Effect.catchAll((birdError) =>
|
|
28
|
+
Effect.fail(
|
|
29
|
+
new Error(
|
|
30
|
+
`Tweet lookup failed via xurl and bird: xurl: ${errorMessage(
|
|
31
|
+
xurlError,
|
|
32
|
+
)}; bird: ${errorMessage(birdError)}`,
|
|
33
|
+
),
|
|
34
|
+
),
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function lookupTweetsByIds(
|
|
42
|
+
ids: string[],
|
|
43
|
+
mode: TweetLookupMode = "auto",
|
|
44
|
+
): Promise<XurlTweetsResponse> {
|
|
45
|
+
return runEffectPromise(lookupTweetsByIdsEffect(ids, mode));
|
|
35
46
|
}
|