birdclaw 0.4.0 → 0.5.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 (64) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +108 -7
  3. package/package.json +24 -23
  4. package/playwright.config.ts +1 -0
  5. package/public/favicon.ico +0 -0
  6. package/public/logo192.png +0 -0
  7. package/public/logo512.png +0 -0
  8. package/public/manifest.json +2 -2
  9. package/src/cli.ts +563 -18
  10. package/src/components/AppNav.tsx +66 -29
  11. package/src/components/AvatarChip.tsx +10 -5
  12. package/src/components/ConversationThread.tsx +125 -0
  13. package/src/components/DmWorkspace.tsx +96 -98
  14. package/src/components/EmbeddedTweetCard.tsx +20 -14
  15. package/src/components/InboxCard.tsx +92 -90
  16. package/src/components/LinkPreviewCard.tsx +270 -0
  17. package/src/components/ProfilePreview.tsx +8 -3
  18. package/src/components/SavedTimelineView.tsx +48 -38
  19. package/src/components/TimelineCard.tsx +228 -84
  20. package/src/components/TweetRichText.tsx +6 -2
  21. package/src/lib/archive-import.ts +1567 -65
  22. package/src/lib/authored-live.ts +1074 -0
  23. package/src/lib/backup.ts +318 -14
  24. package/src/lib/bird-actions.ts +1 -10
  25. package/src/lib/bird-command.ts +57 -0
  26. package/src/lib/bird.ts +89 -5
  27. package/src/lib/config.ts +1 -1
  28. package/src/lib/db.ts +282 -4
  29. package/src/lib/follow-graph.ts +1053 -0
  30. package/src/lib/link-index.ts +629 -0
  31. package/src/lib/link-insights.ts +834 -0
  32. package/src/lib/link-preview-metadata.ts +334 -0
  33. package/src/lib/media-fetch.ts +787 -0
  34. package/src/lib/media-includes.ts +165 -0
  35. package/src/lib/mention-threads-live.ts +535 -43
  36. package/src/lib/mentions-live.ts +623 -19
  37. package/src/lib/moderation-target.ts +6 -0
  38. package/src/lib/profile-hydration.ts +1 -1
  39. package/src/lib/profile-resolver.ts +115 -1
  40. package/src/lib/queries.ts +233 -7
  41. package/src/lib/seed.ts +127 -8
  42. package/src/lib/timeline-collections-live.ts +145 -22
  43. package/src/lib/timeline-live.ts +10 -15
  44. package/src/lib/tweet-account-edges.ts +9 -2
  45. package/src/lib/tweet-render.ts +97 -0
  46. package/src/lib/types.ts +219 -2
  47. package/src/lib/ui.ts +375 -147
  48. package/src/lib/url-expansion-store.ts +110 -0
  49. package/src/lib/url-expansion.ts +33 -8
  50. package/src/lib/x-profile.ts +7 -2
  51. package/src/lib/xurl.ts +296 -12
  52. package/src/routeTree.gen.ts +105 -0
  53. package/src/routes/__root.tsx +7 -3
  54. package/src/routes/api/conversation.tsx +34 -0
  55. package/src/routes/api/link-insights.tsx +79 -0
  56. package/src/routes/api/link-preview.tsx +43 -0
  57. package/src/routes/api/profile-hydrate.tsx +51 -0
  58. package/src/routes/blocks.tsx +111 -86
  59. package/src/routes/dms.tsx +90 -78
  60. package/src/routes/inbox.tsx +98 -86
  61. package/src/routes/index.tsx +63 -50
  62. package/src/routes/links.tsx +883 -0
  63. package/src/routes/mentions.tsx +63 -50
  64. package/src/styles.css +106 -43
@@ -2,14 +2,16 @@ import type { Database } from "./sqlite";
2
2
  import { listMentionsViaBird } from "./bird";
3
3
  import type { MentionsDataSource } from "./config";
4
4
  import { getNativeDb } from "./db";
5
+ import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
5
6
  import { serializeMentionItemsAsXurlCompatible } from "./mentions-export";
6
7
  import { listTimelineItems } from "./queries";
7
- import { readSyncCache, writeSyncCache } from "./sync-cache";
8
+ import { deleteSyncCache, readSyncCache, writeSyncCache } from "./sync-cache";
8
9
  import type {
9
10
  ReplyFilter,
10
11
  TweetEntities,
11
12
  XurlMentionData,
12
13
  XurlMentionsResponse,
14
+ XurlMediaItem,
13
15
  XurlMentionUser,
14
16
  } from "./types";
15
17
  import { upsertTweetAccountEdge } from "./tweet-account-edges";
@@ -19,21 +21,187 @@ import { listMentionsViaXurl, lookupUsersByHandles } from "./xurl";
19
21
  export const DEFAULT_MENTIONS_CACHE_TTL_MS = 2 * 60_000;
20
22
  const MIN_XURL_MENTIONS_LIMIT = 5;
21
23
  const MAX_XURL_MENTIONS_LIMIT = 100;
24
+ type MentionSyncMode = Exclude<MentionsDataSource, "birdclaw">;
25
+ type MentionScanBoundary =
26
+ | { kind: "auto" }
27
+ | { kind: "since"; sinceId: string }
28
+ | { kind: "start"; startTime: string }
29
+ | { kind: "unbounded" };
30
+ interface MentionScanShape {
31
+ endpoint: "mentions";
32
+ mode: MentionSyncMode;
33
+ accountId: string;
34
+ pageSize: number;
35
+ boundary: MentionScanBoundary;
36
+ }
37
+ interface MentionCursorValue extends XurlMentionsResponse {
38
+ birdclaw?: {
39
+ boundary?: MentionScanBoundary;
40
+ pendingNewestId?: string | null;
41
+ };
42
+ }
43
+ interface MentionHighWaterValue {
44
+ sinceId: string;
45
+ }
22
46
 
23
47
  function getMentionsFetchModeKey({
48
+ scope,
49
+ mode,
50
+ accountId,
51
+ pageSize,
52
+ all,
53
+ maxPages,
54
+ sinceId,
55
+ startTime,
56
+ }: {
57
+ scope: "sync" | "export";
58
+ mode: MentionsDataSource;
59
+ accountId: string;
60
+ pageSize: number;
61
+ all: boolean;
62
+ maxPages: number | null;
63
+ sinceId: string | null;
64
+ startTime: string | null;
65
+ }) {
66
+ return `mentions:${scope}:${mode}:${accountId}:${String(pageSize)}:${all ? "all" : "single"}:${maxPages === null ? "all-pages" : String(maxPages)}:${sinceId ?? "no-since"}:${startTime ?? "no-start"}`;
67
+ }
68
+
69
+ function getLegacyMentionsFetchModeKeyWithoutStart({
70
+ scope,
24
71
  mode,
25
72
  accountId,
26
73
  pageSize,
27
74
  all,
28
75
  maxPages,
76
+ sinceId,
29
77
  }: {
78
+ scope: "sync" | "export";
30
79
  mode: MentionsDataSource;
31
80
  accountId: string;
32
81
  pageSize: number;
33
82
  all: boolean;
34
83
  maxPages: number | null;
84
+ sinceId: string | null;
85
+ }) {
86
+ return `mentions:${scope}:${mode}:${accountId}:${String(pageSize)}:${all ? "all" : "single"}:${maxPages === null ? "all-pages" : String(maxPages)}:${sinceId ?? "no-since"}`;
87
+ }
88
+
89
+ function encodeCacheKeyPart(value: string) {
90
+ return encodeURIComponent(value);
91
+ }
92
+
93
+ function getMentionScanBoundaryKey(boundary: MentionScanBoundary) {
94
+ switch (boundary.kind) {
95
+ case "auto":
96
+ return "auto";
97
+ case "since":
98
+ return `since=${encodeCacheKeyPart(boundary.sinceId)}`;
99
+ case "start":
100
+ return `start=${encodeCacheKeyPart(boundary.startTime)}`;
101
+ case "unbounded":
102
+ return "unbounded";
103
+ }
104
+ }
105
+
106
+ function getMentionScanShapeKey(shape: MentionScanShape) {
107
+ return [
108
+ `endpoint=${shape.endpoint}`,
109
+ `mode=${shape.mode}`,
110
+ `account=${encodeCacheKeyPart(shape.accountId)}`,
111
+ `page=${String(shape.pageSize)}`,
112
+ `boundary=${getMentionScanBoundaryKey(shape.boundary)}`,
113
+ ].join(":");
114
+ }
115
+
116
+ function getMentionCursorKey(shape: MentionScanShape) {
117
+ return `mentions:sync:cursor:v2:${getMentionScanShapeKey(shape)}`;
118
+ }
119
+
120
+ function getMentionResultCacheKey({
121
+ shape,
122
+ all,
123
+ maxPages,
124
+ }: {
125
+ shape: MentionScanShape;
126
+ all: boolean;
127
+ maxPages: number | null;
128
+ }) {
129
+ return `mentions:sync:result:v2:${getMentionScanShapeKey(shape)}:${all ? "all" : "single"}:${maxPages === null ? "all-pages" : String(maxPages)}`;
130
+ }
131
+
132
+ function getMentionHighWaterKey({
133
+ mode,
134
+ accountId,
135
+ }: {
136
+ mode: MentionSyncMode;
137
+ accountId: string;
35
138
  }) {
36
- return `mentions:${mode}:${accountId}:${String(pageSize)}:${all ? "all" : "single"}:${maxPages === null ? "all-pages" : String(maxPages)}`;
139
+ return `mentions:sync:high-water:v1:mode=${mode}:account=${encodeCacheKeyPart(accountId)}`;
140
+ }
141
+
142
+ function getMentionCursorBoundary({
143
+ explicitSinceId,
144
+ explicitStartTime,
145
+ }: {
146
+ explicitSinceId?: string;
147
+ explicitStartTime?: string;
148
+ }): MentionScanBoundary {
149
+ if (explicitSinceId) {
150
+ return { kind: "since", sinceId: explicitSinceId };
151
+ }
152
+ if (explicitStartTime) {
153
+ return { kind: "start", startTime: explicitStartTime };
154
+ }
155
+ return { kind: "auto" };
156
+ }
157
+
158
+ function getMentionRequestBoundary({
159
+ sinceId,
160
+ startTime,
161
+ }: {
162
+ sinceId?: string;
163
+ startTime?: string;
164
+ }): MentionScanBoundary {
165
+ if (sinceId) {
166
+ return { kind: "since", sinceId };
167
+ }
168
+ if (startTime) {
169
+ return { kind: "start", startTime };
170
+ }
171
+ return { kind: "unbounded" };
172
+ }
173
+
174
+ function getLegacyMentionCursorKeys(shape: MentionScanShape) {
175
+ const sinceId =
176
+ shape.boundary.kind === "since" ? shape.boundary.sinceId : null;
177
+ const startTime =
178
+ shape.boundary.kind === "start" ? shape.boundary.startTime : null;
179
+ const keys = [
180
+ getMentionsFetchModeKey({
181
+ scope: "sync",
182
+ mode: shape.mode,
183
+ accountId: shape.accountId,
184
+ pageSize: shape.pageSize,
185
+ all: false,
186
+ maxPages: null,
187
+ sinceId,
188
+ startTime,
189
+ }),
190
+ ];
191
+ if (!startTime) {
192
+ keys.push(
193
+ getLegacyMentionsFetchModeKeyWithoutStart({
194
+ scope: "sync",
195
+ mode: shape.mode,
196
+ accountId: shape.accountId,
197
+ pageSize: shape.pageSize,
198
+ all: false,
199
+ maxPages: null,
200
+ sinceId,
201
+ }),
202
+ );
203
+ }
204
+ return [...new Set(keys)];
37
205
  }
38
206
 
39
207
  function parseCacheTtlMs(value?: number) {
@@ -65,6 +233,155 @@ function parseMaxPages(value?: number) {
65
233
  return Math.floor(value);
66
234
  }
67
235
 
236
+ function parseSyncMode(value?: string): MentionSyncMode {
237
+ const mode = value ?? "xurl";
238
+ if (mode !== "bird" && mode !== "xurl") {
239
+ throw new Error("--mode must be bird or xurl");
240
+ }
241
+ return mode;
242
+ }
243
+
244
+ function getCachedPaginationToken(
245
+ cached?: { value: XurlMentionsResponse } | null,
246
+ ) {
247
+ return typeof cached?.value.meta?.next_token === "string" &&
248
+ cached.value.meta.next_token.length > 0
249
+ ? cached.value.meta.next_token
250
+ : undefined;
251
+ }
252
+
253
+ function parseCachedMentionBoundary(
254
+ value: MentionCursorValue | XurlMentionsResponse,
255
+ fallbackBoundary?: MentionScanBoundary,
256
+ ) {
257
+ const boundary = (value as MentionCursorValue).birdclaw?.boundary;
258
+ if (!boundary || typeof boundary !== "object") {
259
+ return fallbackBoundary;
260
+ }
261
+ if (boundary.kind === "unbounded" || boundary.kind === "auto") {
262
+ return boundary;
263
+ }
264
+ if (boundary.kind === "since" && typeof boundary.sinceId === "string") {
265
+ return boundary;
266
+ }
267
+ if (boundary.kind === "start" && typeof boundary.startTime === "string") {
268
+ return boundary;
269
+ }
270
+ return fallbackBoundary;
271
+ }
272
+
273
+ function getCachedMentionPendingNewestId(
274
+ value: MentionCursorValue | XurlMentionsResponse | undefined,
275
+ ) {
276
+ const pendingNewestId = (value as MentionCursorValue | undefined)?.birdclaw
277
+ ?.pendingNewestId;
278
+ return isNumericTweetId(pendingNewestId) ? pendingNewestId : undefined;
279
+ }
280
+
281
+ function addMentionCursorState(
282
+ payload: XurlMentionsResponse,
283
+ boundary: MentionScanBoundary,
284
+ pendingNewestId: string | undefined,
285
+ ): MentionCursorValue {
286
+ return {
287
+ ...payload,
288
+ birdclaw: { boundary, pendingNewestId: pendingNewestId ?? null },
289
+ };
290
+ }
291
+
292
+ function readMentionCursor({
293
+ db,
294
+ shape,
295
+ cursorKey,
296
+ legacyCursorKeys,
297
+ }: {
298
+ db: Database;
299
+ shape: MentionScanShape;
300
+ cursorKey: string;
301
+ legacyCursorKeys: string[];
302
+ }) {
303
+ const fallbackBoundary =
304
+ shape.boundary.kind === "auto" ? undefined : shape.boundary;
305
+ const current = readSyncCache<MentionCursorValue>(cursorKey, db);
306
+ const currentToken = getCachedPaginationToken(current);
307
+ if (current && currentToken) {
308
+ return {
309
+ key: cursorKey,
310
+ token: currentToken,
311
+ boundary: parseCachedMentionBoundary(current.value, fallbackBoundary),
312
+ pendingNewestId: getCachedMentionPendingNewestId(current.value),
313
+ legacyKeys: [] as string[],
314
+ };
315
+ }
316
+
317
+ for (const legacyKey of legacyCursorKeys) {
318
+ const legacy = readSyncCache<MentionCursorValue>(legacyKey, db);
319
+ const legacyToken = getCachedPaginationToken(legacy);
320
+ if (legacy && legacyToken) {
321
+ return {
322
+ key: legacyKey,
323
+ token: legacyToken,
324
+ boundary: parseCachedMentionBoundary(legacy.value, fallbackBoundary),
325
+ pendingNewestId: getCachedMentionPendingNewestId(legacy.value),
326
+ legacyKeys: [legacyKey],
327
+ };
328
+ }
329
+ }
330
+
331
+ return undefined;
332
+ }
333
+
334
+ function isNumericTweetId(value: string | undefined | null): value is string {
335
+ return typeof value === "string" && /^[0-9]+$/.test(value);
336
+ }
337
+
338
+ function maxNumericTweetId(...ids: Array<string | undefined | null>) {
339
+ return ids.filter(isNumericTweetId).reduce<string | undefined>((max, id) => {
340
+ if (!max) {
341
+ return id;
342
+ }
343
+ if (id.length !== max.length) {
344
+ return id.length > max.length ? id : max;
345
+ }
346
+ return id > max ? id : max;
347
+ }, undefined);
348
+ }
349
+
350
+ function getNewestMentionId(payload: XurlMentionsResponse) {
351
+ return maxNumericTweetId(
352
+ typeof payload.meta?.newest_id === "string"
353
+ ? payload.meta.newest_id
354
+ : undefined,
355
+ ...payload.data.map((tweet) => tweet.id),
356
+ );
357
+ }
358
+
359
+ function readMentionHighWaterId(
360
+ db: Database,
361
+ mode: MentionSyncMode,
362
+ accountId: string,
363
+ ) {
364
+ const cached = readSyncCache<MentionHighWaterValue>(
365
+ getMentionHighWaterKey({ mode, accountId }),
366
+ db,
367
+ );
368
+ return isNumericTweetId(cached?.value.sinceId)
369
+ ? cached.value.sinceId
370
+ : undefined;
371
+ }
372
+
373
+ function writeMentionHighWaterId(
374
+ db: Database,
375
+ mode: MentionSyncMode,
376
+ accountId: string,
377
+ sinceId: string | undefined,
378
+ ) {
379
+ if (!isNumericTweetId(sinceId)) {
380
+ return;
381
+ }
382
+ writeSyncCache(getMentionHighWaterKey({ mode, accountId }), { sinceId }, db);
383
+ }
384
+
68
385
  function resolveAccount(db: Database, accountId?: string) {
69
386
  const row = accountId
70
387
  ? (db
@@ -91,6 +408,28 @@ function resolveAccount(db: Database, accountId?: string) {
91
408
  };
92
409
  }
93
410
 
411
+ function findNewestArchiveMentionId(db: Database, accountId: string) {
412
+ const row = db
413
+ .prepare(
414
+ `
415
+ select t.id
416
+ from tweets t
417
+ join tweet_account_edges e
418
+ on e.tweet_id = t.id
419
+ where e.account_id = ?
420
+ and e.kind = 'mention'
421
+ and e.source in ('archive', 'legacy')
422
+ and length(t.id) > 0
423
+ and t.id glob '[0-9]*'
424
+ and t.id not glob '*[^0-9]*'
425
+ order by length(t.id) desc, t.id desc
426
+ limit 1
427
+ `,
428
+ )
429
+ .get(accountId) as { id: string } | undefined;
430
+ return row?.id;
431
+ }
432
+
94
433
  function toLocalEntities(tweet: XurlMentionData): TweetEntities {
95
434
  const raw = tweet.entities;
96
435
  if (!raw || typeof raw !== "object") {
@@ -140,16 +479,6 @@ function toLocalEntities(tweet: XurlMentionData): TweetEntities {
140
479
  };
141
480
  }
142
481
 
143
- function getMediaCount(tweet: XurlMentionData) {
144
- const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
145
- return urls.filter(
146
- (url) =>
147
- url &&
148
- typeof url === "object" &&
149
- typeof (url as Record<string, unknown>).media_key === "string",
150
- ).length;
151
- }
152
-
153
482
  function replaceTweetFts(db: Database, tweetId: string, text: string) {
154
483
  db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
155
484
  db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
@@ -173,17 +502,24 @@ function mergeMentionsIntoLocalStore(
173
502
  id, account_id, author_profile_id, kind, text, created_at,
174
503
  is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
175
504
  entities_json, media_json, quoted_tweet_id
176
- ) values (?, ?, ?, 'mention', ?, ?, 0, null, ?, ?, 0, 0, ?, '[]', null)
505
+ ) values (?, ?, ?, 'mention', ?, ?, 0, null, ?, ?, 0, 0, ?, ?, null)
177
506
  on conflict(id) do update set
178
507
  account_id = tweets.account_id,
179
508
  author_profile_id = excluded.author_profile_id,
180
- kind = case when tweets.kind = 'home' then tweets.kind else excluded.kind end,
509
+ kind = case
510
+ when tweets.kind in ('authored', 'home', 'mention') then tweets.kind
511
+ when excluded.kind in ('authored', 'home', 'mention') then excluded.kind
512
+ else excluded.kind
513
+ end,
181
514
  text = excluded.text,
182
515
  created_at = excluded.created_at,
183
516
  like_count = excluded.like_count,
184
- media_count = excluded.media_count,
517
+ media_count = max(tweets.media_count, excluded.media_count),
185
518
  entities_json = excluded.entities_json,
186
- media_json = excluded.media_json,
519
+ media_json = case
520
+ when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
521
+ else tweets.media_json
522
+ end,
187
523
  is_replied = max(tweets.is_replied, excluded.is_replied),
188
524
  bookmarked = max(tweets.bookmarked, excluded.bookmarked),
189
525
  liked = max(tweets.liked, excluded.liked)
@@ -210,8 +546,9 @@ function mergeMentionsIntoLocalStore(
210
546
  tweet.text,
211
547
  tweet.created_at,
212
548
  Number(tweet.public_metrics?.like_count ?? 0),
213
- getMediaCount(tweet),
549
+ countTweetMedia(tweet),
214
550
  JSON.stringify(toLocalEntities(tweet)),
551
+ buildMediaJsonFromIncludes(tweet, payload.includes?.media),
215
552
  );
216
553
  upsertTweetAccountEdge(db, {
217
554
  accountId,
@@ -271,6 +608,8 @@ function mergeMentionPayloads(
271
608
  const seenTweetIds = new Set<string>();
272
609
  const users: XurlMentionUser[] = [];
273
610
  const seenUserIds = new Set<string>();
611
+ const media: XurlMediaItem[] = [];
612
+ const seenMediaKeys = new Set<string>();
274
613
 
275
614
  for (const page of pages) {
276
615
  for (const tweet of page.data) {
@@ -288,12 +627,24 @@ function mergeMentionPayloads(
288
627
  seenUserIds.add(user.id);
289
628
  users.push(user);
290
629
  }
630
+
631
+ for (const item of page.includes?.media ?? []) {
632
+ if (seenMediaKeys.has(item.media_key)) {
633
+ continue;
634
+ }
635
+ seenMediaKeys.add(item.media_key);
636
+ media.push(item);
637
+ }
291
638
  }
292
639
 
293
640
  const lastMeta = pages.at(-1)?.meta;
641
+ const includes = {
642
+ ...(users.length > 0 ? { users } : {}),
643
+ ...(media.length > 0 ? { media } : {}),
644
+ };
294
645
  return {
295
646
  data: tweets,
296
- includes: users.length > 0 ? { users } : undefined,
647
+ includes: users.length > 0 || media.length > 0 ? includes : undefined,
297
648
  meta: {
298
649
  ...lastMeta,
299
650
  result_count: tweets.length,
@@ -309,11 +660,17 @@ async function fetchMentionsViaXurl({
309
660
  limit,
310
661
  all,
311
662
  parsedMaxPages,
663
+ sinceId,
664
+ startPaginationToken,
665
+ startTime,
312
666
  }: {
313
667
  resolvedAccount: ReturnType<typeof resolveAccount>;
314
668
  limit: number;
315
669
  all: boolean;
316
670
  parsedMaxPages: number | null;
671
+ sinceId?: string;
672
+ startPaginationToken?: string;
673
+ startTime?: string;
317
674
  }) {
318
675
  const [accountUser] = await lookupUsersByHandles([resolvedAccount.username]);
319
676
  if (!accountUser?.id) {
@@ -323,7 +680,7 @@ async function fetchMentionsViaXurl({
323
680
  }
324
681
 
325
682
  const pages: XurlMentionsResponse[] = [];
326
- let nextToken: string | undefined;
683
+ let nextToken: string | undefined = startPaginationToken;
327
684
  let pageCount = 0;
328
685
  do {
329
686
  const payload = await listMentionsViaXurl({
@@ -331,6 +688,8 @@ async function fetchMentionsViaXurl({
331
688
  username: resolvedAccount.username,
332
689
  userId: String(accountUser.id),
333
690
  paginationToken: nextToken,
691
+ ...(sinceId ? { sinceId } : {}),
692
+ ...(startTime ? { startTime } : {}),
334
693
  });
335
694
  pages.push(payload);
336
695
  const metaNextToken =
@@ -348,6 +707,248 @@ async function fetchMentionsViaXurl({
348
707
  return mergeMentionPayloads(pages);
349
708
  }
350
709
 
710
+ async function fetchMentionsViaBird({ limit }: { limit: number }) {
711
+ return listMentionsViaBird({ maxResults: limit });
712
+ }
713
+
714
+ function isMaxPagesPartial({
715
+ payload,
716
+ maxPages,
717
+ }: {
718
+ payload: XurlMentionsResponse;
719
+ maxPages: number | null;
720
+ }) {
721
+ return (
722
+ maxPages !== null &&
723
+ typeof payload.meta?.next_token === "string" &&
724
+ payload.meta.next_token.length > 0
725
+ );
726
+ }
727
+
728
+ export async function syncMentions({
729
+ account,
730
+ mode,
731
+ limit = 20,
732
+ maxPages,
733
+ refresh = false,
734
+ cacheTtlMs,
735
+ sinceId,
736
+ startTime,
737
+ }: {
738
+ account?: string;
739
+ mode?: string;
740
+ limit?: number;
741
+ maxPages?: number;
742
+ refresh?: boolean;
743
+ cacheTtlMs?: number;
744
+ sinceId?: string;
745
+ startTime?: string;
746
+ }) {
747
+ const parsedMode = parseSyncMode(mode);
748
+ const explicitSinceId = sinceId?.trim() || undefined;
749
+ const explicitStartTime = startTime?.trim() || undefined;
750
+ if (parsedMode === "bird" && (explicitSinceId || explicitStartTime)) {
751
+ throw new Error("bird mode does not support --since-id or --start-time");
752
+ }
753
+ if (parsedMode === "xurl") {
754
+ assertXurlLimit(limit);
755
+ } else {
756
+ assertBirdLimit(limit);
757
+ }
758
+ const parsedMaxPages = parseMaxPages(maxPages);
759
+ const fetchAll = parsedMode === "xurl" && parsedMaxPages !== null;
760
+ const db = getNativeDb();
761
+ const resolvedAccount = resolveAccount(db, account);
762
+ const cursorShape: MentionScanShape = {
763
+ endpoint: "mentions",
764
+ mode: parsedMode,
765
+ accountId: resolvedAccount.accountId,
766
+ pageSize: limit,
767
+ boundary: getMentionCursorBoundary({
768
+ explicitSinceId,
769
+ explicitStartTime,
770
+ }),
771
+ };
772
+ const cursorKey = getMentionCursorKey(cursorShape);
773
+ const legacyCursorKeys = getLegacyMentionCursorKeys(cursorShape);
774
+ const cursor =
775
+ parsedMode === "xurl"
776
+ ? readMentionCursor({
777
+ db,
778
+ shape: cursorShape,
779
+ cursorKey,
780
+ legacyCursorKeys,
781
+ })
782
+ : undefined;
783
+ const startPaginationToken = cursor?.token;
784
+ const cursorSinceId =
785
+ cursor?.boundary?.kind === "since" ? cursor.boundary.sinceId : undefined;
786
+ const cursorStartTime =
787
+ cursor?.boundary?.kind === "start" ? cursor.boundary.startTime : undefined;
788
+ const committedSinceId =
789
+ parsedMode === "xurl" &&
790
+ cursorShape.boundary.kind === "auto" &&
791
+ !startPaginationToken
792
+ ? readMentionHighWaterId(db, parsedMode, resolvedAccount.accountId)
793
+ : undefined;
794
+ const seededSinceId =
795
+ parsedMode === "xurl" &&
796
+ !explicitSinceId &&
797
+ !explicitStartTime &&
798
+ !startPaginationToken
799
+ ? (committedSinceId ??
800
+ findNewestArchiveMentionId(db, resolvedAccount.accountId))
801
+ : undefined;
802
+ const resolvedSinceId = startPaginationToken
803
+ ? cursorSinceId
804
+ : (explicitSinceId ?? seededSinceId);
805
+ const resolvedStartTime = startPaginationToken
806
+ ? cursorStartTime
807
+ : !resolvedSinceId
808
+ ? explicitStartTime
809
+ : undefined;
810
+ const resultShape: MentionScanShape = {
811
+ endpoint: "mentions",
812
+ mode: parsedMode,
813
+ accountId: resolvedAccount.accountId,
814
+ pageSize: limit,
815
+ boundary: getMentionRequestBoundary({
816
+ sinceId: resolvedSinceId,
817
+ startTime: resolvedStartTime,
818
+ }),
819
+ };
820
+ const resultCacheKey = getMentionResultCacheKey({
821
+ shape: resultShape,
822
+ all: fetchAll,
823
+ maxPages: parsedMaxPages,
824
+ });
825
+ const ttlMs = parseCacheTtlMs(cacheTtlMs);
826
+ const cached = startPaginationToken
827
+ ? null
828
+ : readSyncCache<XurlMentionsResponse>(resultCacheKey, db);
829
+ const cachedPaginationToken = getCachedPaginationToken(cached);
830
+ const cacheAgeMs = cached
831
+ ? Date.now() - new Date(cached.updatedAt).getTime()
832
+ : Number.POSITIVE_INFINITY;
833
+
834
+ if (
835
+ !startPaginationToken &&
836
+ !cachedPaginationToken &&
837
+ !refresh &&
838
+ cached &&
839
+ cacheAgeMs <= ttlMs
840
+ ) {
841
+ mergeMentionsIntoLocalStore(
842
+ db,
843
+ resolvedAccount.accountId,
844
+ cached.value,
845
+ parsedMode,
846
+ );
847
+ return {
848
+ ok: true,
849
+ source: "cache",
850
+ kind: "mentions",
851
+ accountId: resolvedAccount.accountId,
852
+ count: cached.value.data.length,
853
+ partial: isMaxPagesPartial({
854
+ payload: cached.value,
855
+ maxPages: parsedMaxPages,
856
+ }),
857
+ payload: cached.value,
858
+ };
859
+ }
860
+
861
+ if (
862
+ parsedMode === "xurl" &&
863
+ !explicitSinceId &&
864
+ !explicitStartTime &&
865
+ !startPaginationToken &&
866
+ !seededSinceId
867
+ ) {
868
+ console.error(
869
+ "No local mention baseline found; syncing mentions from the newest page backwards.",
870
+ );
871
+ }
872
+
873
+ const payload =
874
+ parsedMode === "bird"
875
+ ? await fetchMentionsViaBird({ limit })
876
+ : await fetchMentionsViaXurl({
877
+ resolvedAccount,
878
+ limit,
879
+ all: fetchAll,
880
+ parsedMaxPages,
881
+ sinceId: resolvedSinceId,
882
+ startPaginationToken,
883
+ startTime: resolvedStartTime,
884
+ });
885
+ mergeMentionsIntoLocalStore(
886
+ db,
887
+ resolvedAccount.accountId,
888
+ payload,
889
+ parsedMode,
890
+ );
891
+ const payloadPaginationToken = getCachedPaginationToken({ value: payload });
892
+ if (parsedMode === "xurl") {
893
+ if (payloadPaginationToken) {
894
+ writeSyncCache(
895
+ cursorKey,
896
+ addMentionCursorState(
897
+ payload,
898
+ getMentionRequestBoundary({
899
+ sinceId: resolvedSinceId,
900
+ startTime: resolvedStartTime,
901
+ }),
902
+ maxNumericTweetId(resolvedSinceId, getNewestMentionId(payload)),
903
+ ),
904
+ db,
905
+ );
906
+ deleteSyncCache(resultCacheKey, db);
907
+ deleteSyncCache(
908
+ getMentionResultCacheKey({
909
+ shape: cursorShape,
910
+ all: fetchAll,
911
+ maxPages: parsedMaxPages,
912
+ }),
913
+ db,
914
+ );
915
+ for (const legacyKey of cursor?.legacyKeys ?? []) {
916
+ deleteSyncCache(legacyKey, db);
917
+ }
918
+ } else {
919
+ deleteSyncCache(cursorKey, db);
920
+ for (const legacyKey of legacyCursorKeys) {
921
+ deleteSyncCache(legacyKey, db);
922
+ }
923
+ if (cursorShape.boundary.kind === "auto") {
924
+ writeMentionHighWaterId(
925
+ db,
926
+ parsedMode,
927
+ resolvedAccount.accountId,
928
+ maxNumericTweetId(
929
+ resolvedSinceId,
930
+ cursor?.pendingNewestId,
931
+ getNewestMentionId(payload),
932
+ ),
933
+ );
934
+ }
935
+ }
936
+ }
937
+ if (!payloadPaginationToken && !startPaginationToken) {
938
+ writeSyncCache(resultCacheKey, payload, db);
939
+ }
940
+
941
+ return {
942
+ ok: true,
943
+ source: parsedMode,
944
+ kind: "mentions",
945
+ accountId: resolvedAccount.accountId,
946
+ count: payload.data.length,
947
+ partial: isMaxPagesPartial({ payload, maxPages: parsedMaxPages }),
948
+ payload,
949
+ };
950
+ }
951
+
351
952
  async function exportMentionsViaCachedLiveSource({
352
953
  mode,
353
954
  account,
@@ -380,11 +981,14 @@ async function exportMentionsViaCachedLiveSource({
380
981
  const db = getNativeDb();
381
982
  const resolvedAccount = resolveAccount(db, account);
382
983
  const cacheKey = getMentionsFetchModeKey({
984
+ scope: "export",
383
985
  mode,
384
986
  accountId: resolvedAccount.accountId,
385
987
  pageSize: limit,
386
988
  all: fetchAll,
387
989
  maxPages: parsedMaxPages,
990
+ sinceId: null,
991
+ startTime: null,
388
992
  });
389
993
  const ttlMs = parseCacheTtlMs(cacheTtlMs);
390
994
  const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);