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
@@ -0,0 +1,629 @@
1
+ import { getNativeDb } from "./db";
2
+ import type { Database } from "./sqlite";
3
+ import {
4
+ normalizeUrlExpansionForIndex,
5
+ upsertUrlExpansion,
6
+ } from "./url-expansion-store";
7
+ import {
8
+ expandUrls,
9
+ extractUrls,
10
+ type ExpandUrlsOptions,
11
+ } from "./url-expansion";
12
+ import type {
13
+ LinkIndexItem,
14
+ LinkOccurrenceItem,
15
+ LinkSearchItem,
16
+ ProfileRecord,
17
+ TimelineItem,
18
+ TweetEntities,
19
+ TweetMediaItem,
20
+ } from "./types";
21
+
22
+ const DEFAULT_EXPAND_CONCURRENCY = 12;
23
+
24
+ interface TweetUrlEntityLike {
25
+ url?: unknown;
26
+ expandedUrl?: unknown;
27
+ expanded_url?: unknown;
28
+ displayUrl?: unknown;
29
+ display_url?: unknown;
30
+ title?: unknown;
31
+ description?: unknown;
32
+ }
33
+
34
+ interface SourceUrl {
35
+ url: string;
36
+ expandedUrl?: string;
37
+ title?: string;
38
+ description?: string | null;
39
+ }
40
+
41
+ export interface LinkBackfillOptions {
42
+ includeAllUrls?: boolean;
43
+ refresh?: boolean;
44
+ source?: "dm" | "tweet";
45
+ limit?: number;
46
+ concurrency?: number;
47
+ fetchImpl?: ExpandUrlsOptions["fetchImpl"];
48
+ timeoutMs?: number;
49
+ }
50
+
51
+ export interface LinkBackfillResult {
52
+ occurrences: number;
53
+ uniqueUrls: number;
54
+ entityExpansions: number;
55
+ networkExpansions: number;
56
+ cacheExpansions: number;
57
+ misses: number;
58
+ errors: number;
59
+ remainingUnexpanded: number;
60
+ }
61
+
62
+ export interface LinkSearchOptions {
63
+ since?: string;
64
+ until?: string;
65
+ account?: string;
66
+ source?: "dm" | "tweet";
67
+ direction?: "inbound" | "outbound";
68
+ participant?: string;
69
+ mediaType?: "image" | "video" | "gif";
70
+ limit?: number;
71
+ }
72
+
73
+ function parseJsonField<T>(value: unknown, fallback: T): T {
74
+ if (typeof value !== "string" || value.length === 0) {
75
+ return fallback;
76
+ }
77
+
78
+ try {
79
+ return JSON.parse(value) as T;
80
+ } catch {
81
+ return fallback;
82
+ }
83
+ }
84
+
85
+ function getString(value: unknown) {
86
+ return typeof value === "string" && value.length > 0 ? value : undefined;
87
+ }
88
+
89
+ function isIndexedUrl(url: string, includeAllUrls: boolean) {
90
+ if (includeAllUrls) {
91
+ return true;
92
+ }
93
+
94
+ try {
95
+ const host = new URL(url).hostname.toLowerCase();
96
+ return host === "t.co" || host.endsWith(".t.co");
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ function toTweetEntityUrls(entitiesJson: string): SourceUrl[] {
103
+ const entities = parseJsonField<TweetEntities>(entitiesJson, {});
104
+ const urls = Array.isArray(entities.urls) ? entities.urls : [];
105
+ return urls
106
+ .map((entry: TweetUrlEntityLike) => {
107
+ const url = getString(entry.url);
108
+ const expandedUrl =
109
+ getString(entry.expandedUrl) ?? getString(entry.expanded_url);
110
+ if (!url) {
111
+ return undefined;
112
+ }
113
+ return {
114
+ url,
115
+ ...(expandedUrl ? { expandedUrl } : {}),
116
+ ...(getString(entry.title) ? { title: getString(entry.title) } : {}),
117
+ ...(entry.description !== undefined
118
+ ? { description: getString(entry.description) ?? null }
119
+ : {}),
120
+ };
121
+ })
122
+ .filter((entry): entry is SourceUrl => Boolean(entry));
123
+ }
124
+
125
+ function uniqueSourceUrls(
126
+ text: string,
127
+ entityUrls: SourceUrl[],
128
+ includeAllUrls: boolean,
129
+ ) {
130
+ const byUrl = new Map<string, SourceUrl>();
131
+ for (const url of extractUrls(text)) {
132
+ if (isIndexedUrl(url, includeAllUrls)) {
133
+ byUrl.set(url, { url });
134
+ }
135
+ }
136
+ for (const entityUrl of entityUrls) {
137
+ if (!isIndexedUrl(entityUrl.url, includeAllUrls)) {
138
+ continue;
139
+ }
140
+ byUrl.set(entityUrl.url, { ...byUrl.get(entityUrl.url), ...entityUrl });
141
+ }
142
+ return Array.from(byUrl.values());
143
+ }
144
+
145
+ function rebuildOccurrences(
146
+ db: Database,
147
+ includeAllUrls: boolean,
148
+ source: LinkBackfillOptions["source"],
149
+ ) {
150
+ const insert = db.prepare(`
151
+ insert or replace into link_occurrences (
152
+ source_kind, source_id, source_position, short_url, account_id,
153
+ conversation_id, direction, created_at
154
+ ) values (?, ?, ?, ?, ?, ?, ?, ?)
155
+ `);
156
+ let occurrences = 0;
157
+ let entityExpansions = 0;
158
+ const now = new Date().toISOString();
159
+
160
+ db.transaction(() => {
161
+ if (source) {
162
+ db.prepare("delete from link_occurrences where source_kind = ?").run(
163
+ source,
164
+ );
165
+ } else {
166
+ db.exec("delete from link_occurrences");
167
+ }
168
+
169
+ const dmRows =
170
+ source === "tweet"
171
+ ? []
172
+ : (db
173
+ .prepare(`
174
+ select m.id, m.conversation_id, c.account_id, m.direction, m.created_at, m.text
175
+ from dm_messages m
176
+ join dm_conversations c on c.id = m.conversation_id
177
+ where m.text like '%://%'
178
+ `)
179
+ .all() as Array<Record<string, unknown>>);
180
+ for (const row of dmRows) {
181
+ const urls = uniqueSourceUrls(String(row.text), [], includeAllUrls);
182
+ urls.forEach((entry, index) => {
183
+ insert.run(
184
+ "dm",
185
+ String(row.id),
186
+ index,
187
+ entry.url,
188
+ String(row.account_id),
189
+ String(row.conversation_id),
190
+ String(row.direction),
191
+ String(row.created_at),
192
+ );
193
+ occurrences++;
194
+ });
195
+ }
196
+
197
+ const tweetRows =
198
+ source === "dm"
199
+ ? []
200
+ : (db
201
+ .prepare(`
202
+ select id, account_id, created_at, text, entities_json
203
+ from tweets
204
+ where text like '%://%' or entities_json like '%://%'
205
+ `)
206
+ .all() as Array<Record<string, unknown>>);
207
+ for (const row of tweetRows) {
208
+ const entityUrls = toTweetEntityUrls(String(row.entities_json));
209
+ const urls = uniqueSourceUrls(
210
+ String(row.text),
211
+ entityUrls,
212
+ includeAllUrls,
213
+ );
214
+ urls.forEach((entry, index) => {
215
+ insert.run(
216
+ "tweet",
217
+ String(row.id),
218
+ index,
219
+ entry.url,
220
+ String(row.account_id),
221
+ null,
222
+ null,
223
+ String(row.created_at),
224
+ );
225
+ occurrences++;
226
+ if (entry.expandedUrl) {
227
+ upsertUrlExpansion(
228
+ db,
229
+ normalizeUrlExpansionForIndex({
230
+ url: entry.url,
231
+ expandedUrl: entry.expandedUrl,
232
+ finalUrl: entry.expandedUrl,
233
+ status: "hit",
234
+ title: entry.title,
235
+ description: entry.description,
236
+ source: "entity",
237
+ updatedAt: now,
238
+ }),
239
+ );
240
+ entityExpansions++;
241
+ }
242
+ });
243
+ }
244
+ })();
245
+
246
+ return { occurrences, entityExpansions };
247
+ }
248
+
249
+ async function expandWithConcurrency(
250
+ db: Database,
251
+ urls: string[],
252
+ options: LinkBackfillOptions,
253
+ ) {
254
+ const concurrency = Math.max(
255
+ 1,
256
+ Math.min(options.concurrency ?? DEFAULT_EXPAND_CONCURRENCY, 64),
257
+ );
258
+ const counts = {
259
+ networkExpansions: 0,
260
+ cacheExpansions: 0,
261
+ misses: 0,
262
+ errors: 0,
263
+ };
264
+ let nextIndex = 0;
265
+
266
+ async function worker() {
267
+ for (;;) {
268
+ const index = nextIndex++;
269
+ const url = urls[index];
270
+ if (!url) {
271
+ return;
272
+ }
273
+ const result = (
274
+ await expandUrls([url], {
275
+ refresh: options.refresh,
276
+ fetchImpl: options.fetchImpl,
277
+ timeoutMs: options.timeoutMs,
278
+ })
279
+ )[0]!;
280
+ if (result.source === "network") {
281
+ counts.networkExpansions++;
282
+ } else {
283
+ counts.cacheExpansions++;
284
+ }
285
+ if (result.status === "miss") {
286
+ counts.misses++;
287
+ }
288
+ if (result.status === "error") {
289
+ counts.errors++;
290
+ }
291
+ upsertUrlExpansion(db, normalizeUrlExpansionForIndex(result));
292
+ }
293
+ }
294
+
295
+ await Promise.all(
296
+ Array.from({ length: Math.min(concurrency, urls.length) }, () => worker()),
297
+ );
298
+ return counts;
299
+ }
300
+
301
+ export async function backfillLinkIndex(
302
+ options: LinkBackfillOptions = {},
303
+ ): Promise<LinkBackfillResult> {
304
+ const db = getNativeDb({ seedDemoData: false });
305
+ const { occurrences, entityExpansions } = rebuildOccurrences(
306
+ db,
307
+ Boolean(options.includeAllUrls),
308
+ options.source,
309
+ );
310
+ const limit =
311
+ typeof options.limit === "number" && Number.isFinite(options.limit)
312
+ ? Math.max(0, Math.trunc(options.limit))
313
+ : undefined;
314
+ const needsExpansionClause = options.refresh
315
+ ? "1 = 1"
316
+ : "(e.short_url is null or e.status in ('error', 'miss'))";
317
+
318
+ const urlsToExpand = db
319
+ .prepare(`
320
+ select distinct o.short_url
321
+ from link_occurrences o
322
+ left join url_expansions e on e.short_url = o.short_url
323
+ where ${needsExpansionClause}
324
+ ${options.source ? "and o.source_kind = ?" : ""}
325
+ order by o.short_url
326
+ ${limit === undefined ? "" : "limit ?"}
327
+ `)
328
+ .all(
329
+ ...(options.source ? [options.source] : []),
330
+ ...(limit === undefined ? [] : [limit]),
331
+ ) as Array<{
332
+ short_url: string;
333
+ }>;
334
+
335
+ const expansionCounts = await expandWithConcurrency(
336
+ db,
337
+ urlsToExpand.map((row) => row.short_url),
338
+ options,
339
+ );
340
+
341
+ const uniqueUrls = db
342
+ .prepare(`
343
+ select count(distinct short_url) as count
344
+ from link_occurrences
345
+ ${options.source ? "where source_kind = ?" : ""}
346
+ `)
347
+ .get(...(options.source ? [options.source] : [])) as { count: number };
348
+ const remaining = db
349
+ .prepare(`
350
+ select count(distinct o.short_url) as count
351
+ from link_occurrences o
352
+ left join url_expansions e on e.short_url = o.short_url
353
+ where (e.short_url is null or e.status in ('error', 'miss'))
354
+ ${options.source ? "and o.source_kind = ?" : ""}
355
+ `)
356
+ .get(...(options.source ? [options.source] : [])) as { count: number };
357
+
358
+ return {
359
+ occurrences,
360
+ uniqueUrls: Number(uniqueUrls.count),
361
+ entityExpansions,
362
+ ...expansionCounts,
363
+ remainingUnexpanded: Number(remaining.count),
364
+ };
365
+ }
366
+
367
+ function likePattern(value: string) {
368
+ return `%${value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_").toLowerCase()}%`;
369
+ }
370
+
371
+ function toProfile(
372
+ row: Record<string, unknown>,
373
+ prefix: string,
374
+ ): ProfileRecord | null {
375
+ if (!row[`${prefix}id`]) {
376
+ return null;
377
+ }
378
+ return {
379
+ id: String(row[`${prefix}id`]),
380
+ handle: String(row[`${prefix}handle`]),
381
+ displayName: String(row[`${prefix}display_name`]),
382
+ bio: String(row[`${prefix}bio`]),
383
+ followersCount: Number(row[`${prefix}followers_count`]),
384
+ followingCount: Number(row[`${prefix}following_count`]),
385
+ avatarHue: Number(row[`${prefix}avatar_hue`]),
386
+ avatarUrl: getString(row[`${prefix}avatar_url`]),
387
+ location: getString(row[`${prefix}location`]),
388
+ url: getString(row[`${prefix}url`]),
389
+ verifiedType: getString(row[`${prefix}verified_type`]),
390
+ entities: parseJsonField<Record<string, unknown>>(
391
+ row[`${prefix}entities_json`],
392
+ {},
393
+ ),
394
+ createdAt: String(row[`${prefix}created_at`]),
395
+ };
396
+ }
397
+
398
+ function toLinkedTweet(row: Record<string, unknown>): TimelineItem | null {
399
+ const author = toProfile(row, "linked_author_");
400
+ if (!row.linked_id || !author) {
401
+ return null;
402
+ }
403
+
404
+ return {
405
+ id: String(row.linked_id),
406
+ accountId: String(row.linked_account_id),
407
+ accountHandle: String(row.linked_account_handle ?? ""),
408
+ kind: String(row.linked_kind) as TimelineItem["kind"],
409
+ text: String(row.linked_text),
410
+ createdAt: String(row.linked_created_at),
411
+ isReplied: Boolean(row.linked_is_replied),
412
+ likeCount: Number(row.linked_like_count),
413
+ mediaCount: Number(row.linked_media_count),
414
+ bookmarked: Boolean(row.linked_bookmarked),
415
+ liked: Boolean(row.linked_liked),
416
+ author,
417
+ entities: parseJsonField<TweetEntities>(row.linked_entities_json, {}),
418
+ media: parseJsonField<TweetMediaItem[]>(row.linked_media_json, []),
419
+ };
420
+ }
421
+
422
+ function toLinkSearchItem(row: Record<string, unknown>): LinkSearchItem {
423
+ const occurrence: LinkOccurrenceItem = {
424
+ sourceKind: String(row.source_kind) as LinkOccurrenceItem["sourceKind"],
425
+ sourceId: String(row.source_id),
426
+ sourcePosition: Number(row.source_position),
427
+ shortUrl: String(row.short_url),
428
+ accountId: getString(row.account_id) ?? null,
429
+ conversationId: getString(row.conversation_id) ?? null,
430
+ direction: getString(row.direction) ?? null,
431
+ createdAt: String(row.created_at),
432
+ };
433
+ return {
434
+ occurrence,
435
+ expansion: {
436
+ shortUrl: String(row.short_url),
437
+ expandedUrl: String(row.expanded_url),
438
+ finalUrl: String(row.final_url),
439
+ status: String(row.status) as LinkIndexItem["status"],
440
+ expandedTweetId: getString(row.expanded_tweet_id) ?? null,
441
+ expandedHandle: getString(row.expanded_handle) ?? null,
442
+ title: getString(row.title) ?? null,
443
+ description: getString(row.description) ?? null,
444
+ imageUrl: getString(row.image_url) ?? null,
445
+ siteName: getString(row.site_name) ?? null,
446
+ error: getString(row.error) ?? null,
447
+ source: String(row.expansion_source),
448
+ updatedAt: String(row.updated_at),
449
+ },
450
+ sourceText: String(row.source_text),
451
+ sourceAuthor: toProfile(row, "source_author_"),
452
+ participant: toProfile(row, "participant_"),
453
+ linkedTweet: toLinkedTweet(row),
454
+ };
455
+ }
456
+
457
+ export function searchLinks(query: string, options: LinkSearchOptions = {}) {
458
+ const db = getNativeDb({ seedDemoData: false });
459
+ const conditions: string[] = [];
460
+ const params: unknown[] = [];
461
+ const searchFields = `
462
+ lower(
463
+ coalesce(e.short_url, '') || ' ' ||
464
+ coalesce(e.expanded_url, '') || ' ' ||
465
+ coalesce(e.final_url, '') || ' ' ||
466
+ coalesce(e.expanded_handle, '') || ' ' ||
467
+ coalesce(e.title, '') || ' ' ||
468
+ coalesce(e.description, '') || ' ' ||
469
+ coalesce(dm.text, '') || ' ' ||
470
+ coalesce(source_tweet.text, '') || ' ' ||
471
+ coalesce(linked.text, '') || ' ' ||
472
+ coalesce(linked_author.handle, '') || ' ' ||
473
+ coalesce(linked_author.display_name, '')
474
+ )
475
+ `;
476
+
477
+ for (const term of query.match(/[\p{L}\p{N}_:.@/-]+/gu) ?? []) {
478
+ conditions.push(`${searchFields} like ? escape '\\'`);
479
+ params.push(likePattern(term));
480
+ }
481
+ if (options.since) {
482
+ conditions.push("o.created_at >= ?");
483
+ params.push(options.since);
484
+ }
485
+ if (options.until) {
486
+ conditions.push("o.created_at < ?");
487
+ params.push(options.until);
488
+ }
489
+ if (options.account) {
490
+ conditions.push("(o.account_id = ? or account.handle = ?)");
491
+ params.push(options.account, options.account.replace(/^@/, ""));
492
+ }
493
+ if (options.source) {
494
+ conditions.push("o.source_kind = ?");
495
+ params.push(options.source);
496
+ }
497
+ if (options.direction) {
498
+ conditions.push("o.direction = ?");
499
+ params.push(options.direction);
500
+ }
501
+ if (options.participant) {
502
+ conditions.push(
503
+ "(participant.handle = ? or participant.display_name like ?)",
504
+ );
505
+ params.push(
506
+ options.participant.replace(/^@/, ""),
507
+ `%${options.participant}%`,
508
+ );
509
+ }
510
+ if (options.mediaType) {
511
+ conditions.push(
512
+ "(linked.media_json like ? or source_tweet.media_json like ?)",
513
+ );
514
+ params.push(
515
+ `%"type":"${options.mediaType}"%`,
516
+ `%"type":"${options.mediaType}"%`,
517
+ );
518
+ }
519
+
520
+ const limit =
521
+ options.limit && Number.isFinite(options.limit)
522
+ ? Math.max(1, Math.trunc(options.limit))
523
+ : 20;
524
+ params.push(limit);
525
+
526
+ const rows = db
527
+ .prepare(`
528
+ select
529
+ o.source_kind,
530
+ o.source_id,
531
+ o.source_position,
532
+ o.short_url,
533
+ o.account_id,
534
+ o.conversation_id,
535
+ o.direction,
536
+ o.created_at,
537
+ e.expanded_url,
538
+ e.final_url,
539
+ e.status,
540
+ e.expanded_tweet_id,
541
+ e.expanded_handle,
542
+ e.title,
543
+ e.description,
544
+ e.image_url,
545
+ e.site_name,
546
+ e.error,
547
+ e.source as expansion_source,
548
+ e.updated_at,
549
+ coalesce(dm.text, source_tweet.text, '') as source_text,
550
+ account.handle as account_handle,
551
+ source_author.id as source_author_id,
552
+ source_author.handle as source_author_handle,
553
+ source_author.display_name as source_author_display_name,
554
+ source_author.bio as source_author_bio,
555
+ source_author.followers_count as source_author_followers_count,
556
+ source_author.following_count as source_author_following_count,
557
+ source_author.avatar_hue as source_author_avatar_hue,
558
+ source_author.avatar_url as source_author_avatar_url,
559
+ source_author.location as source_author_location,
560
+ source_author.url as source_author_url,
561
+ source_author.verified_type as source_author_verified_type,
562
+ source_author.entities_json as source_author_entities_json,
563
+ source_author.created_at as source_author_created_at,
564
+ participant.id as participant_id,
565
+ participant.handle as participant_handle,
566
+ participant.display_name as participant_display_name,
567
+ participant.bio as participant_bio,
568
+ participant.followers_count as participant_followers_count,
569
+ participant.following_count as participant_following_count,
570
+ participant.avatar_hue as participant_avatar_hue,
571
+ participant.avatar_url as participant_avatar_url,
572
+ participant.location as participant_location,
573
+ participant.url as participant_url,
574
+ participant.verified_type as participant_verified_type,
575
+ participant.entities_json as participant_entities_json,
576
+ participant.created_at as participant_created_at,
577
+ linked.id as linked_id,
578
+ linked.account_id as linked_account_id,
579
+ linked_account.handle as linked_account_handle,
580
+ linked.kind as linked_kind,
581
+ linked.text as linked_text,
582
+ linked.created_at as linked_created_at,
583
+ linked.is_replied as linked_is_replied,
584
+ linked.like_count as linked_like_count,
585
+ linked.media_count as linked_media_count,
586
+ linked.bookmarked as linked_bookmarked,
587
+ linked.liked as linked_liked,
588
+ linked.entities_json as linked_entities_json,
589
+ linked.media_json as linked_media_json,
590
+ linked_author.id as linked_author_id,
591
+ linked_author.handle as linked_author_handle,
592
+ linked_author.display_name as linked_author_display_name,
593
+ linked_author.bio as linked_author_bio,
594
+ linked_author.followers_count as linked_author_followers_count,
595
+ linked_author.following_count as linked_author_following_count,
596
+ linked_author.avatar_hue as linked_author_avatar_hue,
597
+ linked_author.avatar_url as linked_author_avatar_url,
598
+ linked_author.location as linked_author_location,
599
+ linked_author.url as linked_author_url,
600
+ linked_author.verified_type as linked_author_verified_type,
601
+ linked_author.entities_json as linked_author_entities_json,
602
+ linked_author.created_at as linked_author_created_at
603
+ from link_occurrences o
604
+ join url_expansions e on e.short_url = o.short_url
605
+ left join accounts account on account.id = o.account_id
606
+ left join dm_messages dm
607
+ on o.source_kind = 'dm' and dm.id = o.source_id
608
+ left join dm_conversations conversation
609
+ on conversation.id = o.conversation_id
610
+ left join profiles participant
611
+ on participant.id = conversation.participant_profile_id
612
+ left join tweets source_tweet
613
+ on o.source_kind = 'tweet' and source_tweet.id = o.source_id
614
+ left join profiles source_author
615
+ on source_author.id = source_tweet.author_profile_id
616
+ left join tweets linked
617
+ on linked.id = e.expanded_tweet_id
618
+ left join accounts linked_account
619
+ on linked_account.id = linked.account_id
620
+ left join profiles linked_author
621
+ on linked_author.id = linked.author_profile_id
622
+ ${conditions.length > 0 ? `where ${conditions.join(" and ")}` : ""}
623
+ order by o.created_at desc
624
+ limit ?
625
+ `)
626
+ .all(...params) as Array<Record<string, unknown>>;
627
+
628
+ return rows.map(toLinkSearchItem);
629
+ }