birdclaw 0.4.1 → 0.5.1

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 (85) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +113 -7
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +30 -28
  5. package/playwright.config.ts +1 -0
  6. package/public/birdclaw-mark.png +0 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/logo192.png +0 -0
  9. package/public/logo512.png +0 -0
  10. package/public/manifest.json +2 -2
  11. package/scripts/browser-perf.mjs +399 -0
  12. package/scripts/build-docs-site.mjs +940 -0
  13. package/scripts/docs-site-assets.mjs +311 -0
  14. package/scripts/run-vitest.mjs +21 -0
  15. package/scripts/sanitize-node-options.mjs +23 -0
  16. package/scripts/start-test-server.mjs +29 -0
  17. package/src/cli.ts +496 -19
  18. package/src/components/AppNav.tsx +66 -29
  19. package/src/components/AvatarChip.tsx +10 -5
  20. package/src/components/BrandMark.tsx +67 -0
  21. package/src/components/ConversationThread.tsx +126 -0
  22. package/src/components/DmWorkspace.tsx +118 -105
  23. package/src/components/EmbeddedTweetCard.tsx +20 -14
  24. package/src/components/FeedState.tsx +147 -0
  25. package/src/components/InboxCard.tsx +104 -90
  26. package/src/components/LinkPreviewCard.tsx +270 -0
  27. package/src/components/ProfilePreview.tsx +8 -3
  28. package/src/components/SavedTimelineView.tsx +89 -71
  29. package/src/components/SyncNowButton.tsx +105 -0
  30. package/src/components/ThemeSlider.tsx +10 -59
  31. package/src/components/TimelineCard.tsx +326 -86
  32. package/src/components/TimelineRouteFrame.tsx +156 -0
  33. package/src/components/TweetMediaGrid.tsx +120 -23
  34. package/src/components/TweetRichText.tsx +19 -4
  35. package/src/components/useTimelineRouteData.ts +137 -0
  36. package/src/lib/api-client.ts +229 -0
  37. package/src/lib/archive-finder.ts +24 -20
  38. package/src/lib/archive-import.ts +1582 -67
  39. package/src/lib/authored-live.ts +1074 -0
  40. package/src/lib/backup.ts +316 -14
  41. package/src/lib/bird-actions.ts +1 -10
  42. package/src/lib/bird-command.ts +57 -0
  43. package/src/lib/bird.ts +89 -5
  44. package/src/lib/config.ts +1 -1
  45. package/src/lib/conversation-surface.ts +174 -0
  46. package/src/lib/db.ts +193 -4
  47. package/src/lib/follow-graph.ts +1053 -0
  48. package/src/lib/link-index.ts +11 -98
  49. package/src/lib/link-insights.ts +834 -0
  50. package/src/lib/link-preview-metadata.ts +334 -0
  51. package/src/lib/media-fetch.ts +787 -0
  52. package/src/lib/media-includes.ts +165 -0
  53. package/src/lib/mention-threads-live.ts +535 -43
  54. package/src/lib/mentions-live.ts +623 -19
  55. package/src/lib/moderation-target.ts +6 -0
  56. package/src/lib/profile-hydration.ts +1 -1
  57. package/src/lib/profile-resolver.ts +115 -1
  58. package/src/lib/queries.ts +326 -35
  59. package/src/lib/seed.ts +127 -8
  60. package/src/lib/timeline-collections-live.ts +145 -22
  61. package/src/lib/timeline-live.ts +10 -15
  62. package/src/lib/tweet-account-edges.ts +9 -2
  63. package/src/lib/tweet-render.ts +97 -0
  64. package/src/lib/types.ts +185 -2
  65. package/src/lib/ui.ts +383 -147
  66. package/src/lib/url-expansion-store.ts +110 -0
  67. package/src/lib/url-expansion.ts +20 -3
  68. package/src/lib/web-sync.ts +443 -0
  69. package/src/lib/x-profile.ts +7 -2
  70. package/src/lib/xurl.ts +296 -12
  71. package/src/routeTree.gen.ts +126 -0
  72. package/src/routes/__root.tsx +7 -3
  73. package/src/routes/api/conversation.tsx +34 -0
  74. package/src/routes/api/link-insights.tsx +79 -0
  75. package/src/routes/api/link-preview.tsx +43 -0
  76. package/src/routes/api/profile-hydrate.tsx +51 -0
  77. package/src/routes/api/sync.tsx +59 -0
  78. package/src/routes/blocks.tsx +111 -86
  79. package/src/routes/dms.tsx +172 -87
  80. package/src/routes/inbox.tsx +98 -86
  81. package/src/routes/index.tsx +22 -115
  82. package/src/routes/links.tsx +928 -0
  83. package/src/routes/mentions.tsx +22 -115
  84. package/src/styles.css +169 -43
  85. package/vite.config.ts +8 -0
@@ -100,6 +100,12 @@ export async function resolveProfile(
100
100
  }
101
101
 
102
102
  const local = resolveLocalProfile(db, normalizedQuery);
103
+ if (process.env.BIRDCLAW_DISABLE_LIVE_PROFILE_LOOKUP === "1") {
104
+ if (local) {
105
+ return local;
106
+ }
107
+ throw new Error(`Profile not found locally: ${query}`);
108
+ }
103
109
  if (
104
110
  local &&
105
111
  !local.profile.id.startsWith("profile_group_") &&
@@ -44,7 +44,7 @@ export async function hydrateProfilesFromX() {
44
44
 
45
45
  const candidateIds = candidateRows
46
46
  .map((row) => row.id.replace(/^profile_user_/, ""))
47
- .filter((id) => id.length > 0);
47
+ .filter((id) => /^\d+$/.test(id));
48
48
 
49
49
  const updateConversationTitle = db.prepare(`
50
50
  update dm_conversations
@@ -7,7 +7,7 @@ import {
7
7
  } from "./profile-affiliation-hydration";
8
8
  import type { ProfileRecord, XurlMentionUser } from "./types";
9
9
  import { getExternalUserId, upsertProfileFromXUser } from "./x-profile";
10
- import { lookupUsersByIds } from "./xurl";
10
+ import { lookupUsersByHandles, lookupUsersByIds } from "./xurl";
11
11
 
12
12
  const PROFILE_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
13
13
  const PROFILE_NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
@@ -39,6 +39,14 @@ export interface ProfileResolveResult {
39
39
  error?: string;
40
40
  }
41
41
 
42
+ export interface HandleProfileResolveResult {
43
+ handle: string;
44
+ status: ProfileLookupStatus;
45
+ source: Exclude<ProfileLookupSource, "local">;
46
+ profile?: ProfileRecord;
47
+ error?: string;
48
+ }
49
+
42
50
  function toProfile(row: Record<string, unknown>): ProfileRecord {
43
51
  const followingCount = Number(row.following_count ?? 0);
44
52
  let entities: Record<string, unknown> | undefined;
@@ -139,6 +147,10 @@ async function lookupViaXurl(externalUserId: string) {
139
147
  return user ?? null;
140
148
  }
141
149
 
150
+ function normalizeHandle(value: string) {
151
+ return value.trim().replace(/^@/, "").toLowerCase();
152
+ }
153
+
142
154
  async function fetchProfileUser(
143
155
  externalUserId: string,
144
156
  xurlFallback: boolean,
@@ -387,6 +399,108 @@ export async function resolveProfilesForIds(
387
399
  return results;
388
400
  }
389
401
 
402
+ export async function resolveProfilesForHandles(
403
+ handles: string[],
404
+ options: Pick<ResolveProfilesOptions, "xurlFallback"> = {},
405
+ ): Promise<HandleProfileResolveResult[]> {
406
+ const xurlFallback = options.xurlFallback ?? true;
407
+ const targets = Array.from(
408
+ new Set(handles.map(normalizeHandle).filter((handle) => handle.length > 0)),
409
+ );
410
+ if (targets.length === 0) {
411
+ return [];
412
+ }
413
+
414
+ const results = new Map<string, HandleProfileResolveResult>();
415
+ let unresolved = targets;
416
+
417
+ try {
418
+ const birdResults = await lookupProfilesViaBird(targets);
419
+ for (const item of birdResults) {
420
+ const handle = normalizeHandle(item.target);
421
+ if (item.user) {
422
+ const resolved = upsertProfileFromXUser(getNativeDb(), item.user);
423
+ updateConversationTitles(resolved.profile);
424
+ results.set(handle, {
425
+ handle,
426
+ status: "hit",
427
+ source: "bird",
428
+ profile: resolved.profile,
429
+ });
430
+ } else if (item.error && !xurlFallback) {
431
+ results.set(handle, {
432
+ handle,
433
+ status: "error",
434
+ source: "bird",
435
+ error: item.error,
436
+ });
437
+ }
438
+ }
439
+ unresolved = targets.filter((handle) => !results.has(handle));
440
+ } catch (error) {
441
+ if (!xurlFallback) {
442
+ for (const handle of targets) {
443
+ results.set(handle, {
444
+ handle,
445
+ status: "error",
446
+ source: "bird",
447
+ error: error instanceof Error ? error.message : String(error),
448
+ });
449
+ }
450
+ unresolved = [];
451
+ }
452
+ }
453
+
454
+ if (unresolved.length > 0 && xurlFallback) {
455
+ try {
456
+ const users = await lookupUsersByHandles(unresolved);
457
+ const usersByHandle = new Map(
458
+ users.map((user) => [
459
+ normalizeHandle(String(user.username ?? "")),
460
+ user,
461
+ ]),
462
+ );
463
+ for (const handle of unresolved) {
464
+ const user = usersByHandle.get(handle);
465
+ if (user) {
466
+ const resolved = upsertProfileFromXUser(getNativeDb(), user);
467
+ updateConversationTitles(resolved.profile);
468
+ results.set(handle, {
469
+ handle,
470
+ status: "hit",
471
+ source: "xurl",
472
+ profile: resolved.profile,
473
+ });
474
+ } else {
475
+ results.set(handle, {
476
+ handle,
477
+ status: "miss",
478
+ source: "xurl",
479
+ });
480
+ }
481
+ }
482
+ } catch (error) {
483
+ for (const handle of unresolved) {
484
+ results.set(handle, {
485
+ handle,
486
+ status: "error",
487
+ source: "xurl",
488
+ error: error instanceof Error ? error.message : String(error),
489
+ });
490
+ }
491
+ }
492
+ }
493
+
494
+ return targets.map(
495
+ (handle) =>
496
+ results.get(handle) ?? {
497
+ handle,
498
+ status: "miss",
499
+ source: "bird",
500
+ },
501
+ );
502
+ }
503
+
390
504
  export async function resolvePlaceholderProfiles(
391
505
  options: ResolveProfilesOptions & { limit?: number } = {},
392
506
  ) {
@@ -3,6 +3,7 @@ import type { Database } from "./sqlite";
3
3
  import { findArchives } from "./archive-finder";
4
4
  import { getDb, getNativeDb } from "./db";
5
5
  import { fetchProfileAffiliations } from "./profile-affiliations";
6
+ import { displayUrlForLink, enrichFallbackUrlEntities } from "./tweet-render";
6
7
  import type {
7
8
  AccountRecord,
8
9
  DmConversationItem,
@@ -17,7 +18,9 @@ import type {
17
18
  TimelineItem,
18
19
  TimelineQuery,
19
20
  TweetEntities,
21
+ TweetConversationResponse,
20
22
  TweetMediaItem,
23
+ TweetUrlEntity,
21
24
  } from "./types";
22
25
  import {
23
26
  dmViaXurl,
@@ -107,7 +110,78 @@ function enrichEntities(
107
110
  };
108
111
  }
109
112
 
113
+ type UrlExpansionCache = Map<
114
+ string,
115
+ | (Pick<TweetUrlEntity, "expandedUrl" | "displayUrl"> &
116
+ Partial<
117
+ Pick<TweetUrlEntity, "title" | "description" | "imageUrl" | "siteName">
118
+ >)
119
+ | null
120
+ >;
121
+
122
+ function getUrlExpansion(
123
+ db: Database,
124
+ cache: UrlExpansionCache,
125
+ rawUrl: string,
126
+ ) {
127
+ if (cache.has(rawUrl)) {
128
+ return cache.get(rawUrl);
129
+ }
130
+
131
+ const row = db
132
+ .prepare(
133
+ `
134
+ select expanded_url, final_url, title, description, image_url, site_name
135
+ from url_expansions
136
+ where short_url = ?
137
+ and status = 'hit'
138
+ `,
139
+ )
140
+ .get(rawUrl) as
141
+ | {
142
+ expanded_url: string;
143
+ final_url: string;
144
+ title: string | null;
145
+ description: string | null;
146
+ image_url: string | null;
147
+ site_name: string | null;
148
+ }
149
+ | undefined;
150
+ if (!row) {
151
+ cache.set(rawUrl, null);
152
+ return null;
153
+ }
154
+
155
+ const expandedUrl = row.final_url || row.expanded_url || rawUrl;
156
+ const expansion = {
157
+ expandedUrl,
158
+ displayUrl: displayUrlForLink(expandedUrl),
159
+ ...(row.title ? { title: row.title } : {}),
160
+ ...(row.description ? { description: row.description } : {}),
161
+ ...(row.image_url ? { imageUrl: row.image_url } : {}),
162
+ ...(row.site_name ? { siteName: row.site_name } : {}),
163
+ };
164
+ cache.set(rawUrl, expansion);
165
+ return expansion;
166
+ }
167
+
168
+ function enrichTimelineEntities(
169
+ db: Database,
170
+ urlExpansionCache: UrlExpansionCache,
171
+ text: string,
172
+ entities: TweetEntities,
173
+ profiles: Record<string, ProfileRecord>,
174
+ ): TweetEntities {
175
+ return enrichFallbackUrlEntities(
176
+ text,
177
+ enrichEntities(entities, profiles),
178
+ (rawUrl) => getUrlExpansion(db, urlExpansionCache, rawUrl),
179
+ );
180
+ }
181
+
110
182
  function buildEmbeddedTweet(
183
+ db: Database,
184
+ urlExpansionCache: UrlExpansionCache,
111
185
  row: Record<string, unknown>,
112
186
  prefix: string,
113
187
  ): EmbeddedTweet | null {
@@ -127,12 +201,20 @@ function buildEmbeddedTweet(
127
201
  created_at: row[`${prefix}profile_created_at`],
128
202
  });
129
203
 
204
+ const text = String(row[`${prefix}text`] ?? "");
130
205
  return {
131
206
  id: String(row[`${prefix}id`]),
132
- text: String(row[`${prefix}text`] ?? ""),
207
+ text,
133
208
  createdAt: String(row[`${prefix}created_at`] ?? new Date(0).toISOString()),
209
+ replyToId:
210
+ typeof row[`${prefix}reply_to_id`] === "string"
211
+ ? String(row[`${prefix}reply_to_id`])
212
+ : null,
134
213
  author,
135
- entities: enrichEntities(
214
+ entities: enrichTimelineEntities(
215
+ db,
216
+ urlExpansionCache,
217
+ text,
136
218
  parseJsonField<TweetEntities>(row[`${prefix}entities_json`], {}),
137
219
  {
138
220
  [author.id]: author,
@@ -239,32 +321,39 @@ function countTimelineEdges(db: Database, kind: "home" | "mention") {
239
321
  const row = db
240
322
  .prepare(
241
323
  `
242
- with timeline_edges as (
243
- select account_id, tweet_id, kind
244
- from tweet_account_edges
245
- where kind = ?
246
- union all
247
- select legacy.account_id, legacy.id as tweet_id, legacy.kind
248
- from tweets legacy
249
- where legacy.kind = ?
250
- and not exists (
251
- select 1
252
- from tweet_account_edges edge
253
- where edge.account_id = legacy.account_id
254
- and edge.tweet_id = legacy.id
255
- and edge.kind = legacy.kind
256
- )
257
- )
258
- select count(*) as count
259
- from timeline_edges e
260
- join tweets t on t.id = e.tweet_id
261
- where e.kind = ?
324
+ select (
325
+ (
326
+ select count(*)
327
+ from tweet_account_edges edge
328
+ where edge.kind = ?
329
+ and exists (
330
+ select 1
331
+ from tweets t
332
+ where t.id = edge.tweet_id
333
+ )
334
+ )
335
+ +
336
+ (
337
+ select count(*)
338
+ from tweets legacy
339
+ where legacy.kind = ?
340
+ and not exists (
341
+ select 1
342
+ from tweet_account_edges edge
343
+ where edge.account_id = legacy.account_id
344
+ and edge.tweet_id = legacy.id
345
+ and edge.kind = legacy.kind
346
+ )
347
+ )
348
+ ) as count
262
349
  `,
263
350
  )
264
- .get(kind, kind, kind) as { count: number | bigint } | undefined;
351
+ .get(kind, kind) as { count: number | bigint } | undefined;
265
352
  return Number(row?.count ?? 0);
266
353
  }
267
354
 
355
+ const RECENT_TIMELINE_EDGE_CANDIDATES = 5000;
356
+
268
357
  export async function getQueryEnvelope(): Promise<QueryEnvelope> {
269
358
  const db = getDb();
270
359
  const nativeDb = getNativeDb();
@@ -350,10 +439,23 @@ export function listTimelineItems({
350
439
  )
351
440
  )
352
441
  `;
442
+ const unwindowedTimelineEdgesCte = timelineEdgesCte;
443
+ let usedRecentEdgeWindow = false;
353
444
  let join = "";
354
445
  let where = "where t.kind = ?";
355
446
  let searchSnippetSelect = "";
356
447
 
448
+ const canUseRecentEdgeWindow =
449
+ !likedOnly &&
450
+ !bookmarkedOnly &&
451
+ !account &&
452
+ !search?.trim() &&
453
+ replyFilter === "all" &&
454
+ !since?.trim() &&
455
+ !until?.trim() &&
456
+ includeReplies &&
457
+ qualityFilter === "all";
458
+
357
459
  if (likedOnly || bookmarkedOnly) {
358
460
  if (likedOnly && bookmarkedOnly) {
359
461
  timelineEdgesCte = `
@@ -399,10 +501,49 @@ export function listTimelineItems({
399
501
  and collection.kind = ?
400
502
  )
401
503
  )
402
- `;
504
+ `;
403
505
  params.push(collectionKind, collectionKind);
404
506
  }
405
507
  where = "where 1 = 1";
508
+ } else if (canUseRecentEdgeWindow) {
509
+ usedRecentEdgeWindow = true;
510
+ timelineEdgesCte = `
511
+ with timeline_edges as (
512
+ select account_id, tweet_id, kind
513
+ from tweet_account_edges
514
+ where kind = ?
515
+ and tweet_id in (
516
+ select id
517
+ from tweets
518
+ order by created_at desc
519
+ limit ?
520
+ )
521
+ union all
522
+ select legacy.account_id, legacy.id as tweet_id, legacy.kind
523
+ from tweets legacy
524
+ where legacy.kind = ?
525
+ and legacy.id in (
526
+ select id
527
+ from tweets
528
+ order by created_at desc
529
+ limit ?
530
+ )
531
+ and not exists (
532
+ select 1
533
+ from tweet_account_edges edge
534
+ where edge.account_id = legacy.account_id
535
+ and edge.tweet_id = legacy.id
536
+ and edge.kind = legacy.kind
537
+ )
538
+ )
539
+ `;
540
+ const candidateLimit = Math.max(
541
+ RECENT_TIMELINE_EDGE_CANDIDATES,
542
+ limit * 50,
543
+ );
544
+ params.push(kind, candidateLimit, kind, candidateLimit);
545
+ where = "where e.kind = ?";
546
+ params.push(kind);
406
547
  } else {
407
548
  params.push(kind, kind);
408
549
  where = "where e.kind = ?";
@@ -450,10 +591,8 @@ export function listTimelineItems({
450
591
 
451
592
  params.push(limit);
452
593
 
453
- const rows = db
454
- .prepare(
455
- `
456
- ${timelineEdgesCte}
594
+ const buildTimelineSelectSql = (timelineEdgesSql: string) => `
595
+ ${timelineEdgesSql}
457
596
  select
458
597
  t.id,
459
598
  e.account_id,
@@ -461,6 +600,7 @@ export function listTimelineItems({
461
600
  e.kind,
462
601
  t.text,
463
602
  t.created_at,
603
+ t.reply_to_id,
464
604
  t.is_replied,
465
605
  t.like_count,
466
606
  t.media_count,
@@ -503,6 +643,7 @@ export function listTimelineItems({
503
643
  rt.id as reply_id,
504
644
  rt.text as reply_text,
505
645
  rt.created_at as reply_created_at,
646
+ rt.reply_to_id as reply_reply_to_id,
506
647
  rt.entities_json as reply_entities_json,
507
648
  rt.media_json as reply_media_json,
508
649
  rp.id as reply_profile_id,
@@ -517,6 +658,7 @@ export function listTimelineItems({
517
658
  qt.id as quoted_id,
518
659
  qt.text as quoted_text,
519
660
  qt.created_at as quoted_created_at,
661
+ qt.reply_to_id as quoted_reply_to_id,
520
662
  qt.entities_json as quoted_entities_json,
521
663
  qt.media_json as quoted_media_json,
522
664
  qp.id as quoted_profile_id,
@@ -541,10 +683,19 @@ export function listTimelineItems({
541
683
  ${where}
542
684
  order by t.created_at desc
543
685
  limit ?
544
- `,
545
- )
686
+ `;
687
+
688
+ let rows = db
689
+ .prepare(buildTimelineSelectSql(timelineEdgesCte))
546
690
  .all(...params) as Array<Record<string, unknown>>;
547
691
 
692
+ if (usedRecentEdgeWindow && rows.length < limit) {
693
+ rows = db
694
+ .prepare(buildTimelineSelectSql(unwindowedTimelineEdgesCte))
695
+ .all(kind, kind, kind, limit) as Array<Record<string, unknown>>;
696
+ }
697
+
698
+ const urlExpansionCache: UrlExpansionCache = new Map();
548
699
  return rows.map((row) => {
549
700
  const author = {
550
701
  id: String(row.profile_id),
@@ -558,7 +709,11 @@ export function listTimelineItems({
558
709
  typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
559
710
  createdAt: String(row.profile_created_at),
560
711
  };
561
- const entities = enrichEntities(
712
+ const text = String(row.text);
713
+ const entities = enrichTimelineEntities(
714
+ db,
715
+ urlExpansionCache,
716
+ text,
562
717
  parseJsonField<TweetEntities>(row.entities_json, {}),
563
718
  {
564
719
  [author.id]: author,
@@ -599,11 +754,13 @@ export function listTimelineItems({
599
754
  accountId: String(row.account_id),
600
755
  accountHandle: String(row.account_handle),
601
756
  kind: row.kind as TimelineItem["kind"],
602
- text: String(row.text),
757
+ text,
603
758
  ...(typeof row.search_snippet === "string"
604
759
  ? { searchSnippet: row.search_snippet }
605
760
  : {}),
606
761
  createdAt: String(row.created_at),
762
+ replyToId:
763
+ typeof row.reply_to_id === "string" ? String(row.reply_to_id) : null,
607
764
  isReplied: Boolean(row.is_replied),
608
765
  likeCount: Number(row.like_count),
609
766
  mediaCount: Number(row.media_count),
@@ -612,8 +769,8 @@ export function listTimelineItems({
612
769
  author,
613
770
  entities,
614
771
  media: parseJsonField<TweetMediaItem[]>(row.media_json, []),
615
- replyToTweet: buildEmbeddedTweet(row, "reply_"),
616
- quotedTweet: buildEmbeddedTweet(row, "quoted_"),
772
+ replyToTweet: buildEmbeddedTweet(db, urlExpansionCache, row, "reply_"),
773
+ quotedTweet: buildEmbeddedTweet(db, urlExpansionCache, row, "quoted_"),
617
774
  };
618
775
  return includeQualityReason
619
776
  ? {
@@ -627,6 +784,140 @@ export function listTimelineItems({
627
784
  });
628
785
  }
629
786
 
787
+ const conversationTweetSelect = `
788
+ select
789
+ t.id,
790
+ t.text,
791
+ t.created_at,
792
+ t.reply_to_id,
793
+ t.entities_json,
794
+ t.media_json,
795
+ p.id as profile_id,
796
+ p.handle,
797
+ p.display_name,
798
+ p.bio,
799
+ p.followers_count,
800
+ p.following_count,
801
+ p.avatar_hue,
802
+ p.avatar_url,
803
+ p.created_at as profile_created_at
804
+ from tweets t
805
+ join profiles p on p.id = t.author_profile_id
806
+ `;
807
+
808
+ function getTweetById(
809
+ db: Database,
810
+ urlExpansionCache: UrlExpansionCache,
811
+ tweetId: string,
812
+ ): EmbeddedTweet | null {
813
+ const row = db
814
+ .prepare(`${conversationTweetSelect} where t.id = ?`)
815
+ .get(tweetId) as Record<string, unknown> | undefined;
816
+ if (!row) return null;
817
+ return buildEmbeddedTweet(db, urlExpansionCache, row, "");
818
+ }
819
+
820
+ function listTweetDescendants(
821
+ db: Database,
822
+ urlExpansionCache: UrlExpansionCache,
823
+ rootId: string,
824
+ limit: number,
825
+ ) {
826
+ if (limit <= 0) return [];
827
+ const rows = db
828
+ .prepare(
829
+ `
830
+ with recursive branch(id, depth) as (
831
+ select t.id, 0
832
+ from tweets t
833
+ where t.id = ?
834
+ union all
835
+ select child.id, branch.depth + 1
836
+ from tweets child
837
+ join branch on child.reply_to_id = branch.id
838
+ where branch.depth < 8
839
+ )
840
+ ${conversationTweetSelect}
841
+ join branch on branch.id = t.id
842
+ where t.id != ?
843
+ order by t.created_at asc
844
+ limit ?
845
+ `,
846
+ )
847
+ .all(rootId, rootId, limit) as Array<Record<string, unknown>>;
848
+
849
+ return rows
850
+ .map((row) => buildEmbeddedTweet(db, urlExpansionCache, row, ""))
851
+ .filter((tweet): tweet is EmbeddedTweet => Boolean(tweet));
852
+ }
853
+
854
+ function appendConversationTweets(
855
+ target: EmbeddedTweet[],
856
+ seen: Set<string>,
857
+ items: EmbeddedTweet[],
858
+ remaining: number,
859
+ ) {
860
+ for (const tweet of items) {
861
+ if (target.length >= remaining || seen.has(tweet.id)) continue;
862
+ seen.add(tweet.id);
863
+ target.push(tweet);
864
+ }
865
+ }
866
+
867
+ export function getTweetConversation(
868
+ tweetId: string,
869
+ limit = 80,
870
+ ): TweetConversationResponse | null {
871
+ const db = getNativeDb();
872
+ const urlExpansionCache: UrlExpansionCache = new Map();
873
+ const anchor = getTweetById(db, urlExpansionCache, tweetId);
874
+ if (!anchor) return null;
875
+
876
+ const ancestors: EmbeddedTweet[] = [];
877
+ let current = anchor;
878
+ for (let depth = 0; depth < 12 && current.replyToId; depth += 1) {
879
+ const parent = getTweetById(db, urlExpansionCache, current.replyToId);
880
+ if (!parent || ancestors.some((tweet) => tweet.id === parent.id)) break;
881
+ ancestors.push(parent);
882
+ current = parent;
883
+ }
884
+
885
+ const required = [...ancestors].reverse();
886
+ required.push(anchor);
887
+ const root = required[0] ?? anchor;
888
+ const seen = new Set<string>();
889
+ const items = required.filter((tweet) => {
890
+ if (seen.has(tweet.id)) return false;
891
+ seen.add(tweet.id);
892
+ return true;
893
+ });
894
+ const remainingAfterRequired = Math.max(0, limit - items.length);
895
+ const focusedDescendants = listTweetDescendants(
896
+ db,
897
+ urlExpansionCache,
898
+ anchor.id,
899
+ remainingAfterRequired,
900
+ );
901
+ appendConversationTweets(items, seen, focusedDescendants, limit);
902
+
903
+ if (items.length < limit && root.id !== anchor.id) {
904
+ const ambientDescendants = listTweetDescendants(
905
+ db,
906
+ urlExpansionCache,
907
+ root.id,
908
+ limit,
909
+ );
910
+ appendConversationTweets(items, seen, ambientDescendants, limit);
911
+ }
912
+
913
+ items.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
914
+
915
+ return {
916
+ anchorId: anchor.id,
917
+ items,
918
+ };
919
+ }
920
+
630
921
  export function listDmConversations({
631
922
  account,
632
923
  conversationIds,
@@ -1084,7 +1375,7 @@ function getDmSearchMatches({
1084
1375
  }
1085
1376
 
1086
1377
  export function queryResource(
1087
- resource: "home" | "mentions" | "dms",
1378
+ resource: "home" | "mentions" | "authored" | "dms",
1088
1379
  filters: (TimelineQuery | DmQuery) & { conversationId?: string },
1089
1380
  ): QueryResponse {
1090
1381
  if (resource === "dms") {