birdclaw 0.4.1 → 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 +33 -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 +450 -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 +1565 -65
  22. package/src/lib/authored-live.ts +1074 -0
  23. package/src/lib/backup.ts +316 -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 +191 -4
  29. package/src/lib/follow-graph.ts +1053 -0
  30. package/src/lib/link-index.ts +11 -98
  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 +185 -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 +20 -3
  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
@@ -1,10 +1,12 @@
1
1
  import type { Database } from "./sqlite";
2
2
  import { listBookmarkedTweetsViaBird, listLikedTweetsViaBird } from "./bird";
3
3
  import { getNativeDb } from "./db";
4
+ import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
4
5
  import { readSyncCache, writeSyncCache } from "./sync-cache";
5
6
  import type {
6
7
  XurlMentionData,
7
8
  XurlMentionsResponse,
9
+ XurlMediaItem,
8
10
  XurlMentionUser,
9
11
  } from "./types";
10
12
  import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
@@ -18,6 +20,7 @@ export type TimelineCollectionKind = "likes" | "bookmarks";
18
20
  export type TimelineCollectionMode = "auto" | "xurl" | "bird";
19
21
 
20
22
  const DEFAULT_COLLECTION_CACHE_TTL_MS = 2 * 60_000;
23
+ const DEFAULT_EARLY_STOP_MAX_PAGES = 10;
21
24
  const MIN_XURL_LIMIT = 5;
22
25
  const MAX_XURL_LIMIT = 100;
23
26
 
@@ -87,16 +90,6 @@ function resolveAccount(db: Database, accountId?: string) {
87
90
  };
88
91
  }
89
92
 
90
- function getMediaCount(tweet: XurlMentionData) {
91
- const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
92
- return urls.filter(
93
- (url) =>
94
- url &&
95
- typeof url === "object" &&
96
- typeof (url as Record<string, unknown>).media_key === "string",
97
- ).length;
98
- }
99
-
100
93
  function replaceTweetFts(db: Database, tweetId: string, text: string) {
101
94
  db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
102
95
  db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
@@ -116,6 +109,8 @@ function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
116
109
  const seenTweetIds = new Set<string>();
117
110
  const users: XurlMentionUser[] = [];
118
111
  const seenUserIds = new Set<string>();
112
+ const media: XurlMediaItem[] = [];
113
+ const seenMediaKeys = new Set<string>();
119
114
 
120
115
  for (const page of pages) {
121
116
  for (const tweet of page.data) {
@@ -133,12 +128,24 @@ function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
133
128
  seenUserIds.add(user.id);
134
129
  users.push(user);
135
130
  }
131
+
132
+ for (const item of page.includes?.media ?? []) {
133
+ if (seenMediaKeys.has(item.media_key)) {
134
+ continue;
135
+ }
136
+ seenMediaKeys.add(item.media_key);
137
+ media.push(item);
138
+ }
136
139
  }
137
140
 
138
141
  const lastPage = pages.at(-1);
142
+ const includes = {
143
+ ...(users.length > 0 ? { users } : {}),
144
+ ...(media.length > 0 ? { media } : {}),
145
+ };
139
146
  return {
140
147
  data: tweets,
141
- includes: users.length > 0 ? { users } : undefined,
148
+ includes: users.length > 0 || media.length > 0 ? includes : undefined,
142
149
  meta: {
143
150
  result_count: tweets.length,
144
151
  page_count: pages.length,
@@ -149,6 +156,52 @@ function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
149
156
  };
150
157
  }
151
158
 
159
+ function getCollectionPageDedupe(
160
+ db: Database,
161
+ accountId: string,
162
+ kind: TimelineCollectionKind,
163
+ tweetIds: string[],
164
+ ) {
165
+ const uniqueTweetIds = [...new Set(tweetIds)];
166
+ if (uniqueTweetIds.length === 0) {
167
+ return { existingTweetIds: new Set<string>(), uniqueTweetCount: 0 };
168
+ }
169
+
170
+ const rows = db
171
+ .prepare(
172
+ `
173
+ select tweet_id
174
+ from tweet_collections
175
+ where account_id = ?
176
+ and kind = ?
177
+ and tweet_id in (${uniqueTweetIds.map(() => "?").join(", ")})
178
+ `,
179
+ )
180
+ .all(accountId, kind, ...uniqueTweetIds) as { tweet_id: string }[];
181
+ return {
182
+ existingTweetIds: new Set(rows.map((row) => row.tweet_id)),
183
+ uniqueTweetCount: uniqueTweetIds.length,
184
+ };
185
+ }
186
+
187
+ function filterExistingCollectionTweets(
188
+ payload: XurlMentionsResponse,
189
+ existingTweetIds: Set<string>,
190
+ ) {
191
+ if (existingTweetIds.size === 0) {
192
+ return payload;
193
+ }
194
+ return {
195
+ ...payload,
196
+ data: payload.data.filter((tweet) => !existingTweetIds.has(tweet.id)),
197
+ };
198
+ }
199
+
200
+ function readSaturatedAtPage(payload: XurlMentionsResponse) {
201
+ const value = payload.meta?.saturated_at_page;
202
+ return typeof value === "number" ? value : undefined;
203
+ }
204
+
152
205
  function mergeTimelineCollectionIntoLocalStore(
153
206
  db: Database,
154
207
  accountId: string,
@@ -168,20 +221,23 @@ function mergeTimelineCollectionIntoLocalStore(
168
221
  id, account_id, author_profile_id, kind, text, created_at,
169
222
  is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
170
223
  entities_json, media_json, quoted_tweet_id
171
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', ?)
224
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
172
225
  on conflict(id) do update set
173
226
  account_id = tweets.account_id,
174
227
  author_profile_id = excluded.author_profile_id,
175
228
  kind = case
176
- when tweets.kind in ('home', 'mention') then tweets.kind
229
+ when tweets.kind in ('authored', 'home', 'mention') then tweets.kind
177
230
  else excluded.kind
178
231
  end,
179
232
  text = excluded.text,
180
233
  created_at = excluded.created_at,
181
234
  like_count = excluded.like_count,
182
- media_count = excluded.media_count,
235
+ media_count = max(tweets.media_count, excluded.media_count),
183
236
  entities_json = excluded.entities_json,
184
- media_json = excluded.media_json,
237
+ media_json = case
238
+ when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
239
+ else tweets.media_json
240
+ end,
185
241
  is_replied = max(tweets.is_replied, excluded.is_replied),
186
242
  reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
187
243
  quoted_tweet_id = coalesce(excluded.quoted_tweet_id, tweets.quoted_tweet_id),
@@ -224,10 +280,11 @@ function mergeTimelineCollectionIntoLocalStore(
224
280
  replyToId ? 1 : 0,
225
281
  replyToId,
226
282
  Number(tweet.public_metrics?.like_count ?? 0),
227
- getMediaCount(tweet),
283
+ countTweetMedia(tweet),
228
284
  bookmarked,
229
285
  liked,
230
286
  JSON.stringify(tweet.entities ?? {}),
287
+ buildMediaJsonFromIncludes(tweet, payload.includes?.media),
231
288
  quotedTweetId,
232
289
  );
233
290
  upsertCollection.run(
@@ -244,19 +301,25 @@ function mergeTimelineCollectionIntoLocalStore(
244
301
  }
245
302
 
246
303
  async function fetchXurlCollection({
304
+ db,
247
305
  kind,
306
+ accountId,
248
307
  username,
249
308
  userId,
250
309
  limit,
251
310
  all,
252
311
  maxPages,
312
+ earlyStop,
253
313
  }: {
314
+ db: Database;
254
315
  kind: TimelineCollectionKind;
316
+ accountId: string;
255
317
  username: string;
256
318
  userId?: string;
257
319
  limit: number;
258
320
  all: boolean;
259
321
  maxPages: number | null;
322
+ earlyStop: boolean;
260
323
  }) {
261
324
  let resolvedUserId = userId;
262
325
  if (!resolvedUserId) {
@@ -270,6 +333,7 @@ async function fetchXurlCollection({
270
333
  const pages: XurlMentionsResponse[] = [];
271
334
  let nextToken: string | undefined;
272
335
  let pageCount = 0;
336
+ let saturatedAtPage: number | undefined;
273
337
  do {
274
338
  const payload =
275
339
  kind === "likes"
@@ -283,17 +347,51 @@ async function fetchXurlCollection({
283
347
  maxResults: limit,
284
348
  username,
285
349
  userId: resolvedUserId,
350
+ isPaginatedWalk: all,
286
351
  paginationToken: nextToken,
287
352
  });
288
- pages.push(payload);
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,
361
+ );
362
+ if (tweetIds.length > 0 && existingTweetIds.size === uniqueTweetCount) {
363
+ saturatedAtPage = pageCount;
364
+ console.error(
365
+ `${kind} saturated at page ${pageCount} (100% existing rows)`,
366
+ );
367
+ break;
368
+ }
369
+ pages.push(filterExistingCollectionTweets(payload, existingTweetIds));
370
+ } else {
371
+ pages.push(payload);
372
+ }
289
373
  nextToken =
290
374
  typeof payload.meta?.next_token === "string"
291
375
  ? payload.meta.next_token
292
376
  : undefined;
293
- pageCount += 1;
294
- } while (all && nextToken && (maxPages === null || pageCount < maxPages));
377
+ } while (
378
+ (all || earlyStop) &&
379
+ nextToken &&
380
+ (maxPages === null || pageCount < maxPages)
381
+ );
295
382
 
296
- return mergePayloads(pages);
383
+ const merged = mergePayloads(pages);
384
+ // A saturated page may expose another token, but our walk is complete.
385
+ const saturationMeta =
386
+ saturatedAtPage === undefined
387
+ ? {}
388
+ : { saturated_at_page: saturatedAtPage, next_token: null };
389
+ merged.meta = {
390
+ ...merged.meta,
391
+ page_count: pageCount,
392
+ ...saturationMeta,
393
+ };
394
+ return merged;
297
395
  }
298
396
 
299
397
  async function fetchBirdCollection({
@@ -329,6 +427,7 @@ export async function syncTimelineCollection({
329
427
  maxPages,
330
428
  refresh = false,
331
429
  cacheTtlMs,
430
+ earlyStop = false,
332
431
  }: {
333
432
  kind: TimelineCollectionKind;
334
433
  account?: string;
@@ -338,16 +437,23 @@ export async function syncTimelineCollection({
338
437
  maxPages?: number;
339
438
  refresh?: boolean;
340
439
  cacheTtlMs?: number;
440
+ earlyStop?: boolean;
341
441
  }) {
342
442
  assertLimit(limit);
343
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;
344
449
  if (mode === "xurl" || mode === "auto") {
345
450
  assertXurlLimit(limit);
346
451
  }
347
452
 
348
453
  const db = getNativeDb();
349
454
  const resolvedAccount = resolveAccount(db, account);
350
- const cacheKey = `${kind}:${mode}:${resolvedAccount.accountId}:${String(limit)}:${all ? "all" : "single"}:${parsedMaxPages === null ? "all-pages" : String(parsedMaxPages)}`;
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" : ""}`;
351
457
  const ttlMs = parseCacheTtlMs(cacheTtlMs);
352
458
  const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
353
459
  const cacheAgeMs = cached
@@ -355,6 +461,7 @@ export async function syncTimelineCollection({
355
461
  : Number.POSITIVE_INFINITY;
356
462
 
357
463
  if (!refresh && cached && cacheAgeMs <= ttlMs) {
464
+ const saturatedAtPage = readSaturatedAtPage(cached.value);
358
465
  return {
359
466
  ok: true,
360
467
  source: "cache",
@@ -362,9 +469,18 @@ export async function syncTimelineCollection({
362
469
  accountId: resolvedAccount.accountId,
363
470
  count: cached.value.data.length,
364
471
  payload: cached.value,
472
+ ...(saturatedAtPage === undefined
473
+ ? {}
474
+ : { saturated_at_page: saturatedAtPage }),
365
475
  };
366
476
  }
367
477
 
478
+ if (shouldApplyEarlyStopCap) {
479
+ console.error(
480
+ `${kind} early-stop capped at ${DEFAULT_EARLY_STOP_MAX_PAGES} pages by default; pass --max-pages or --all to override`,
481
+ );
482
+ }
483
+
368
484
  let source: "xurl" | "bird";
369
485
  let payload: XurlMentionsResponse;
370
486
  if (mode === "bird") {
@@ -378,12 +494,15 @@ export async function syncTimelineCollection({
378
494
  } else {
379
495
  try {
380
496
  payload = await fetchXurlCollection({
497
+ db,
381
498
  kind,
499
+ accountId: resolvedAccount.accountId,
382
500
  username: resolvedAccount.username,
383
501
  userId: resolvedAccount.externalUserId,
384
502
  limit,
385
503
  all,
386
- maxPages: parsedMaxPages,
504
+ maxPages: xurlMaxPages,
505
+ earlyStop,
387
506
  });
388
507
  source = "xurl";
389
508
  } catch (error) {
@@ -408,6 +527,7 @@ export async function syncTimelineCollection({
408
527
  source,
409
528
  );
410
529
  writeSyncCache(cacheKey, payload, db);
530
+ const saturatedAtPage = readSaturatedAtPage(payload);
411
531
 
412
532
  return {
413
533
  ok: true,
@@ -416,5 +536,8 @@ export async function syncTimelineCollection({
416
536
  accountId: resolvedAccount.accountId,
417
537
  count: payload.data.length,
418
538
  payload,
539
+ ...(saturatedAtPage === undefined
540
+ ? {}
541
+ : { saturated_at_page: saturatedAtPage }),
419
542
  };
420
543
  }
@@ -1,8 +1,9 @@
1
1
  import type { Database } from "./sqlite";
2
2
  import { listHomeTimelineViaBird } from "./bird";
3
3
  import { getNativeDb } from "./db";
4
+ import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
4
5
  import { readSyncCache, writeSyncCache } from "./sync-cache";
5
- import type { XurlMentionData, XurlMentionsResponse } from "./types";
6
+ import type { XurlMentionsResponse } from "./types";
6
7
  import { upsertTweetAccountEdge } from "./tweet-account-edges";
7
8
  import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
8
9
 
@@ -44,16 +45,6 @@ function resolveAccount(db: Database, accountId?: string) {
44
45
  return row.id;
45
46
  }
46
47
 
47
- function getMediaCount(tweet: XurlMentionData) {
48
- const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
49
- return urls.filter(
50
- (url) =>
51
- url &&
52
- typeof url === "object" &&
53
- typeof (url as Record<string, unknown>).media_key === "string",
54
- ).length;
55
- }
56
-
57
48
  function replaceTweetFts(db: Database, tweetId: string, text: string) {
58
49
  db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
59
50
  db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
@@ -76,7 +67,7 @@ function mergeHomeTimelineIntoLocalStore(
76
67
  id, account_id, author_profile_id, kind, text, created_at,
77
68
  is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
78
69
  entities_json, media_json, quoted_tweet_id
79
- ) values (?, ?, ?, 'home', ?, ?, 0, null, ?, ?, 0, 0, ?, '[]', null)
70
+ ) values (?, ?, ?, 'home', ?, ?, 0, null, ?, ?, 0, 0, ?, ?, null)
80
71
  on conflict(id) do update set
81
72
  account_id = tweets.account_id,
82
73
  author_profile_id = excluded.author_profile_id,
@@ -84,9 +75,12 @@ function mergeHomeTimelineIntoLocalStore(
84
75
  text = excluded.text,
85
76
  created_at = excluded.created_at,
86
77
  like_count = excluded.like_count,
87
- media_count = excluded.media_count,
78
+ media_count = max(tweets.media_count, excluded.media_count),
88
79
  entities_json = excluded.entities_json,
89
- media_json = excluded.media_json,
80
+ media_json = case
81
+ when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
82
+ else tweets.media_json
83
+ end,
90
84
  bookmarked = tweets.bookmarked,
91
85
  liked = tweets.liked
92
86
  `,
@@ -112,8 +106,9 @@ function mergeHomeTimelineIntoLocalStore(
112
106
  tweet.text,
113
107
  tweet.created_at,
114
108
  Number(tweet.public_metrics?.like_count ?? 0),
115
- getMediaCount(tweet),
109
+ countTweetMedia(tweet),
116
110
  JSON.stringify(tweet.entities ?? {}),
111
+ buildMediaJsonFromIncludes(tweet, payload.includes?.media),
117
112
  );
118
113
  upsertTweetAccountEdge(db, {
119
114
  accountId,
@@ -1,6 +1,10 @@
1
1
  import type { Database } from "./sqlite";
2
2
 
3
- export type TweetAccountEdgeKind = "home" | "mention";
3
+ export type TweetAccountEdgeKind =
4
+ | "home"
5
+ | "mention"
6
+ | "authored"
7
+ | "thread_context";
4
8
 
5
9
  export function upsertTweetAccountEdge(
6
10
  db: Database,
@@ -29,7 +33,10 @@ export function upsertTweetAccountEdge(
29
33
  first_seen_at = min(tweet_account_edges.first_seen_at, excluded.first_seen_at),
30
34
  last_seen_at = max(tweet_account_edges.last_seen_at, excluded.last_seen_at),
31
35
  seen_count = tweet_account_edges.seen_count + 1,
32
- source = coalesce(nullif(excluded.source, ''), tweet_account_edges.source),
36
+ source = case
37
+ when tweet_account_edges.source = 'archive' then 'archive'
38
+ else coalesce(nullif(excluded.source, ''), tweet_account_edges.source)
39
+ end,
33
40
  raw_json = case
34
41
  when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
35
42
  else tweet_account_edges.raw_json
@@ -10,6 +10,14 @@ type TweetSegment =
10
10
  | ({ kind: "url" } & TweetUrlEntity)
11
11
  | ({ kind: "hashtag" } & TweetHashtagEntity);
12
12
 
13
+ type UrlExpansion = Pick<TweetUrlEntity, "expandedUrl" | "displayUrl"> &
14
+ Partial<
15
+ Pick<TweetUrlEntity, "title" | "description" | "imageUrl" | "siteName">
16
+ >;
17
+
18
+ const RAW_URL_PATTERN = /https?:\/\/[^\s<>"'`]+/g;
19
+ const TRAILING_URL_PUNCTUATION = /[),.;:!?]+$/;
20
+
13
21
  const MARKDOWN_ESCAPE_CHARACTERS = new Set([
14
22
  "\\",
15
23
  "`",
@@ -38,6 +46,95 @@ function escapeMarkdown(text: string) {
38
46
  .join("");
39
47
  }
40
48
 
49
+ export function displayUrlForLink(url: string) {
50
+ try {
51
+ const parsed = new URL(url);
52
+ const host = parsed.hostname.replace(/^www\./, "");
53
+ const suffix = `${parsed.pathname}${parsed.search}${parsed.hash}`;
54
+ return suffix === "/" ? host : `${host}${suffix}`;
55
+ } catch {
56
+ return url;
57
+ }
58
+ }
59
+
60
+ function spansOverlap(
61
+ leftStart: number,
62
+ leftEnd: number,
63
+ rightStart: number,
64
+ rightEnd: number,
65
+ ) {
66
+ return leftStart < rightEnd && rightStart < leftEnd;
67
+ }
68
+
69
+ export function enrichFallbackUrlEntities(
70
+ text: string,
71
+ entities: TweetEntities,
72
+ resolveExpansion?: (rawUrl: string) => UrlExpansion | null | undefined,
73
+ ): TweetEntities {
74
+ const existingUrls = entities.urls ?? [];
75
+ const enrichedExistingUrls = existingUrls.map((entry) => {
76
+ const expansion = resolveExpansion?.(entry.url);
77
+ if (!expansion) return entry;
78
+ const expandedUrl = expansion.expandedUrl || entry.expandedUrl;
79
+ return {
80
+ ...entry,
81
+ expandedUrl,
82
+ displayUrl: expansion.displayUrl || displayUrlForLink(expandedUrl),
83
+ ...(expansion.title ? { title: expansion.title } : {}),
84
+ ...(expansion && "description" in expansion
85
+ ? { description: expansion.description ?? null }
86
+ : {}),
87
+ ...(expansion.imageUrl ? { imageUrl: expansion.imageUrl } : {}),
88
+ ...(expansion.siteName ? { siteName: expansion.siteName } : {}),
89
+ };
90
+ });
91
+ const fallbackUrls: TweetUrlEntity[] = [];
92
+
93
+ for (const match of text.matchAll(RAW_URL_PATTERN)) {
94
+ const rawMatch = match[0];
95
+ const url = rawMatch.replace(TRAILING_URL_PUNCTUATION, "");
96
+ const start = match.index ?? 0;
97
+ const end = start + url.length;
98
+ if (!url) {
99
+ continue;
100
+ }
101
+ if (
102
+ enrichedExistingUrls.some((entry) =>
103
+ spansOverlap(start, end, entry.start, entry.end),
104
+ )
105
+ ) {
106
+ continue;
107
+ }
108
+
109
+ const expansion = resolveExpansion?.(url);
110
+ const expandedUrl = expansion?.expandedUrl || url;
111
+ fallbackUrls.push({
112
+ url,
113
+ expandedUrl,
114
+ displayUrl: expansion?.displayUrl || displayUrlForLink(expandedUrl),
115
+ start,
116
+ end,
117
+ ...(expansion?.title ? { title: expansion.title } : {}),
118
+ ...(expansion && "description" in expansion
119
+ ? { description: expansion.description ?? null }
120
+ : {}),
121
+ ...(expansion?.imageUrl ? { imageUrl: expansion.imageUrl } : {}),
122
+ ...(expansion?.siteName ? { siteName: expansion.siteName } : {}),
123
+ });
124
+ }
125
+
126
+ if (fallbackUrls.length === 0) {
127
+ return { ...entities, urls: enrichedExistingUrls };
128
+ }
129
+
130
+ return {
131
+ ...entities,
132
+ urls: [...enrichedExistingUrls, ...fallbackUrls].sort(
133
+ (left, right) => left.start - right.start,
134
+ ),
135
+ };
136
+ }
137
+
41
138
  export function collectTweetSegments(entities: TweetEntities): TweetSegment[] {
42
139
  return [
43
140
  ...(entities.mentions?.map((entry) => ({