birdclaw 0.6.0 → 0.7.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 (60) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +25 -0
  3. package/package.json +6 -1
  4. package/src/cli.ts +438 -26
  5. package/src/components/AppNav.tsx +10 -0
  6. package/src/components/MarkdownViewer.tsx +438 -72
  7. package/src/components/ProfileAnalysisStream.tsx +428 -0
  8. package/src/components/ProfilePreview.tsx +120 -9
  9. package/src/components/SavedTimelineView.tsx +30 -8
  10. package/src/components/SyncNowButton.tsx +5 -2
  11. package/src/components/TimelineCard.tsx +20 -4
  12. package/src/components/TimelineRouteFrame.tsx +16 -0
  13. package/src/components/TweetRichText.tsx +36 -12
  14. package/src/components/useTimelineRouteData.ts +74 -6
  15. package/src/lib/account-sync-job.ts +15 -3
  16. package/src/lib/archive-finder.ts +1 -1
  17. package/src/lib/archive-import.ts +245 -7
  18. package/src/lib/authored-live.ts +1 -0
  19. package/src/lib/avatar-cache.ts +50 -0
  20. package/src/lib/backup.ts +4 -3
  21. package/src/lib/bird.ts +33 -0
  22. package/src/lib/config.ts +35 -2
  23. package/src/lib/data-sources.ts +219 -0
  24. package/src/lib/db.ts +62 -1
  25. package/src/lib/geocoding.ts +296 -0
  26. package/src/lib/location.ts +137 -0
  27. package/src/lib/mention-threads-live.ts +94 -1
  28. package/src/lib/mentions-live.ts +187 -40
  29. package/src/lib/network-map.ts +382 -0
  30. package/src/lib/period-digest.ts +377 -13
  31. package/src/lib/profile-analysis.ts +1272 -0
  32. package/src/lib/profile-bio-entities.ts +1 -1
  33. package/src/lib/queries.ts +14 -4
  34. package/src/lib/search-discussion.ts +1016 -0
  35. package/src/lib/timeline-live.ts +272 -19
  36. package/src/lib/tweet-account-edges.ts +2 -0
  37. package/src/lib/tweet-render.ts +141 -1
  38. package/src/lib/tweet-search-live.ts +565 -0
  39. package/src/lib/types.ts +37 -2
  40. package/src/lib/ui.ts +1 -1
  41. package/src/lib/web-sync.ts +7 -2
  42. package/src/lib/xurl-rate-limits.ts +267 -0
  43. package/src/lib/xurl.ts +551 -41
  44. package/src/routeTree.gen.ts +231 -0
  45. package/src/routes/__root.tsx +5 -6
  46. package/src/routes/api/data-sources.tsx +24 -0
  47. package/src/routes/api/network-map.tsx +55 -0
  48. package/src/routes/api/period-digest.tsx +11 -1
  49. package/src/routes/api/profile-analysis.tsx +152 -0
  50. package/src/routes/api/query.tsx +1 -0
  51. package/src/routes/api/search-discussion.tsx +169 -0
  52. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  53. package/src/routes/data-sources.tsx +255 -0
  54. package/src/routes/discuss.tsx +419 -0
  55. package/src/routes/network-map.tsx +1035 -0
  56. package/src/routes/profile-analyze.tsx +112 -0
  57. package/src/routes/profiles.$handle.tsx +228 -0
  58. package/src/routes/rate-limits.tsx +309 -0
  59. package/src/routes/today.tsx +22 -8
  60. package/src/styles.css +22 -0
@@ -5,18 +5,39 @@ import { getNativeDb } from "./db";
5
5
  import { runEffectPromise } from "./effect-runtime";
6
6
  import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
7
7
  import { readSyncCache, writeSyncCache } from "./sync-cache";
8
- import type { XurlMentionsResponse } from "./types";
8
+ import type {
9
+ XurlMediaItem,
10
+ XurlMentionUser,
11
+ XurlMentionsResponse,
12
+ } from "./types";
9
13
  import { upsertTweetAccountEdge } from "./tweet-account-edges";
10
14
  import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
15
+ import { listHomeTimelineViaXurlEffect } from "./xurl";
11
16
 
12
17
  const DEFAULT_TIMELINE_CACHE_TTL_MS = 2 * 60_000;
18
+ const MAX_XURL_TIMELINE_PAGE_SIZE = 100;
13
19
 
20
+ export type HomeTimelineMode = "bird" | "xurl" | "auto";
21
+ export interface HomeTimelineProgress {
22
+ source: "bird" | "xurl" | "cache";
23
+ fetched: number;
24
+ total?: number;
25
+ page?: number;
26
+ maxPages?: number;
27
+ pageSize?: number;
28
+ done: boolean;
29
+ }
14
30
  export interface SyncHomeTimelineOptions {
15
31
  account?: string;
32
+ mode?: HomeTimelineMode;
16
33
  limit?: number;
34
+ maxPages?: number;
35
+ startTime?: string;
17
36
  following?: boolean;
18
37
  refresh?: boolean;
19
38
  cacheTtlMs?: number;
39
+ timeoutMs?: number;
40
+ onProgress?: (progress: HomeTimelineProgress) => void;
20
41
  }
21
42
 
22
43
  function parseCacheTtlMs(value?: number) {
@@ -27,32 +48,130 @@ function parseCacheTtlMs(value?: number) {
27
48
  }
28
49
 
29
50
  function assertLimit(limit: number) {
30
- if (!Number.isFinite(limit) || limit < 1) {
51
+ if ((!Number.isFinite(limit) && limit !== Infinity) || limit < 1) {
31
52
  throw new Error("--limit must be at least 1");
32
53
  }
33
54
  }
34
55
 
56
+ function parseMode(mode: HomeTimelineMode | undefined) {
57
+ const parsed = mode ?? "bird";
58
+ if (parsed !== "bird" && parsed !== "xurl" && parsed !== "auto") {
59
+ throw new Error("--mode must be bird, xurl, or auto");
60
+ }
61
+ return parsed;
62
+ }
63
+
64
+ function parseMaxPages(maxPages: number | undefined) {
65
+ if (maxPages === undefined) return 1;
66
+ if (!Number.isFinite(maxPages) || maxPages < 1) {
67
+ throw new Error("--max-pages must be at least 1");
68
+ }
69
+ return Math.floor(maxPages);
70
+ }
71
+
72
+ function parseStartTime(value: string | undefined) {
73
+ if (!value?.trim()) return undefined;
74
+ const time = new Date(value).getTime();
75
+ if (!Number.isFinite(time)) {
76
+ throw new Error("--start-time must be a valid date");
77
+ }
78
+ return { iso: new Date(time).toISOString(), time };
79
+ }
80
+
81
+ function reachedStartTimeBoundary(
82
+ payload: XurlMentionsResponse,
83
+ startTimeMs: number | undefined,
84
+ ) {
85
+ if (startTimeMs === undefined) return false;
86
+ return payload.data.some((tweet) => {
87
+ const createdAt = new Date(tweet.created_at).getTime();
88
+ return Number.isFinite(createdAt) && createdAt <= startTimeMs;
89
+ });
90
+ }
91
+
92
+ function getReferencedTweetId(
93
+ tweet: XurlMentionsResponse["data"][number],
94
+ type: "replied_to" | "quoted",
95
+ ) {
96
+ return tweet.referenced_tweets?.find((reference) => reference.type === type)
97
+ ?.id;
98
+ }
99
+
100
+ function mergeTimelinePayloads(
101
+ payloads: XurlMentionsResponse[],
102
+ limit: number,
103
+ ) {
104
+ const data: XurlMentionsResponse["data"] = [];
105
+ const usersById = new Map<string, XurlMentionUser>();
106
+ const mediaByKey = new Map<string, XurlMediaItem>();
107
+ let meta: XurlMentionsResponse["meta"] | undefined;
108
+
109
+ for (const payload of payloads) {
110
+ meta = payload.meta;
111
+ for (const tweet of payload.data) {
112
+ if (data.some((existing) => existing.id === tweet.id)) continue;
113
+ data.push(tweet);
114
+ if (data.length >= limit) break;
115
+ }
116
+ for (const user of payload.includes?.users ?? []) {
117
+ usersById.set(user.id, user);
118
+ }
119
+ for (const media of payload.includes?.media ?? []) {
120
+ mediaByKey.set(media.media_key, media);
121
+ }
122
+ if (data.length >= limit) break;
123
+ }
124
+
125
+ return {
126
+ data,
127
+ includes: {
128
+ users: [...usersById.values()],
129
+ media: [...mediaByKey.values()],
130
+ },
131
+ meta,
132
+ } satisfies XurlMentionsResponse;
133
+ }
134
+
35
135
  function resolveAccount(db: Database, accountId?: string) {
36
136
  const row = accountId
37
- ? (db.prepare("select id from accounts where id = ?").get(accountId) as
38
- | { id: string }
137
+ ? (db
138
+ .prepare(
139
+ "select id, handle, external_user_id, is_default as isDefault from accounts where id = ?",
140
+ )
141
+ .get(accountId) as
142
+ | ({ id: string; handle: string; external_user_id: string | null } & {
143
+ isDefault: number;
144
+ })
39
145
  | undefined)
40
146
  : (db
41
147
  .prepare(
42
148
  `
43
- select id
149
+ select id, handle, external_user_id, is_default as isDefault
44
150
  from accounts
45
151
  order by is_default desc, created_at asc
46
152
  limit 1
47
153
  `,
48
154
  )
49
- .get() as { id: string } | undefined);
155
+ .get() as
156
+ | ({ id: string; handle: string; external_user_id: string | null } & {
157
+ isDefault: number;
158
+ })
159
+ | undefined);
50
160
 
51
161
  if (!row) {
52
162
  throw new Error(`Unknown account: ${accountId ?? "default"}`);
53
163
  }
54
164
 
55
- return row.id;
165
+ return {
166
+ accountId: row.id,
167
+ isDefault: row.isDefault === 1,
168
+ username: row.handle.replace(/^@/, ""),
169
+ externalUserId:
170
+ typeof row.external_user_id === "string" &&
171
+ row.external_user_id.trim().length > 0
172
+ ? row.external_user_id.trim()
173
+ : undefined,
174
+ };
56
175
  }
57
176
 
58
177
  function replaceTweetFts(db: Database, tweetId: string, text: string) {
@@ -67,6 +186,7 @@ function mergeHomeTimelineIntoLocalStore(
67
186
  db: Database,
68
187
  accountId: string,
69
188
  payload: XurlMentionsResponse,
189
+ source: "bird" | "xurl",
70
190
  ) {
71
191
  const usersById = new Map(
72
192
  (payload.includes?.users ?? []).map((user) => [user.id, user]),
@@ -77,13 +197,15 @@ function mergeHomeTimelineIntoLocalStore(
77
197
  id, account_id, author_profile_id, kind, text, created_at,
78
198
  is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
79
199
  entities_json, media_json, quoted_tweet_id
80
- ) values (?, ?, ?, 'home', ?, ?, 0, null, ?, ?, 0, 0, ?, ?, null)
200
+ ) values (?, ?, ?, 'home', ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
81
201
  on conflict(id) do update set
82
202
  account_id = tweets.account_id,
83
203
  author_profile_id = excluded.author_profile_id,
84
204
  kind = tweets.kind,
85
205
  text = excluded.text,
86
206
  created_at = excluded.created_at,
207
+ is_replied = max(tweets.is_replied, excluded.is_replied),
208
+ reply_to_id = coalesce(tweets.reply_to_id, excluded.reply_to_id),
87
209
  like_count = excluded.like_count,
88
210
  media_count = max(tweets.media_count, excluded.media_count),
89
211
  entities_json = excluded.entities_json,
@@ -92,7 +214,8 @@ function mergeHomeTimelineIntoLocalStore(
92
214
  else tweets.media_json
93
215
  end,
94
216
  bookmarked = tweets.bookmarked,
95
- liked = tweets.liked
217
+ liked = tweets.liked,
218
+ quoted_tweet_id = coalesce(tweets.quoted_tweet_id, excluded.quoted_tweet_id)
96
219
  `,
97
220
  );
98
221
 
@@ -109,22 +232,27 @@ function mergeHomeTimelineIntoLocalStore(
109
232
  const profile = usersById.has(tweet.author_id)
110
233
  ? upsertProfileFromXUser(db, author)
111
234
  : ensureStubProfileForXUser(db, tweet.author_id);
235
+ const replyToId = getReferencedTweetId(tweet, "replied_to") ?? null;
236
+ const quotedTweetId = getReferencedTweetId(tweet, "quoted") ?? null;
112
237
  upsertTweet.run(
113
238
  tweet.id,
114
239
  accountId,
115
240
  profile.profile.id,
116
241
  tweet.text,
117
242
  tweet.created_at,
243
+ 0,
244
+ replyToId,
118
245
  Number(tweet.public_metrics?.like_count ?? 0),
119
246
  countTweetMedia(tweet),
120
247
  JSON.stringify(tweet.entities ?? {}),
121
248
  buildMediaJsonFromIncludes(tweet, payload.includes?.media),
249
+ quotedTweetId,
122
250
  );
123
251
  upsertTweetAccountEdge(db, {
124
252
  accountId,
125
253
  tweetId: tweet.id,
126
254
  kind: "home",
127
- source: "bird",
255
+ source,
128
256
  seenAt,
129
257
  rawJson: JSON.stringify(tweet),
130
258
  });
@@ -135,14 +263,19 @@ function mergeHomeTimelineIntoLocalStore(
135
263
 
136
264
  export function syncHomeTimelineEffect({
137
265
  account,
138
- limit = 100,
266
+ mode,
267
+ limit,
268
+ maxPages,
269
+ startTime,
139
270
  following = true,
140
271
  refresh = false,
141
272
  cacheTtlMs,
273
+ timeoutMs,
274
+ onProgress,
142
275
  }: SyncHomeTimelineOptions = {}): Effect.Effect<
143
276
  {
144
277
  ok: true;
145
- source: "bird" | "cache";
278
+ source: "bird" | "xurl" | "cache";
146
279
  kind: "timeline";
147
280
  accountId: string;
148
281
  feed: "following" | "for-you";
@@ -152,10 +285,32 @@ export function syncHomeTimelineEffect({
152
285
  unknown
153
286
  > {
154
287
  return Effect.gen(function* () {
155
- assertLimit(limit);
288
+ const parsedStartTime = yield* Effect.try({
289
+ try: () => parseStartTime(startTime),
290
+ catch: (error) => error,
291
+ });
292
+ const parsedMode = parseMode(mode);
293
+ const finiteFallbackLimit = limit ?? (parsedStartTime ? 300 : 100);
294
+ const effectiveLimit =
295
+ limit ??
296
+ (parsedStartTime && (parsedMode === "xurl" || parsedMode === "auto")
297
+ ? Infinity
298
+ : finiteFallbackLimit);
299
+ assertLimit(effectiveLimit);
300
+ const parsedMaxPages =
301
+ maxPages === undefined && parsedStartTime
302
+ ? Infinity
303
+ : parseMaxPages(maxPages);
156
304
  const db = getNativeDb();
157
- const accountId = resolveAccount(db, account);
158
- const cacheKey = `timeline:bird:${accountId}:${following ? "following" : "for-you"}:${String(limit)}`;
305
+ const resolvedAccount = resolveAccount(db, account);
306
+ const accountId = resolvedAccount.accountId;
307
+ const effectiveMode =
308
+ parsedMode === "auto" &&
309
+ account !== undefined &&
310
+ !resolvedAccount.isDefault
311
+ ? "xurl"
312
+ : parsedMode;
313
+ const cacheKey = `timeline:${effectiveMode}:${accountId}:${following ? "following" : "for-you"}:${Number.isFinite(effectiveLimit) ? String(effectiveLimit) : "all"}:${Number.isFinite(parsedMaxPages) ? String(parsedMaxPages) : "all-pages"}:${parsedStartTime?.iso ?? "no-start"}`;
159
314
  const ttlMs = parseCacheTtlMs(cacheTtlMs);
160
315
  const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
161
316
  const cacheAgeMs = cached
@@ -163,6 +318,14 @@ export function syncHomeTimelineEffect({
163
318
  : Number.POSITIVE_INFINITY;
164
319
 
165
320
  if (!refresh && cached && cacheAgeMs <= ttlMs) {
321
+ yield* Effect.sync(() =>
322
+ onProgress?.({
323
+ source: "cache",
324
+ fetched: cached.value.data.length,
325
+ total: Number.isFinite(effectiveLimit) ? effectiveLimit : undefined,
326
+ done: true,
327
+ }),
328
+ );
166
329
  return {
167
330
  ok: true,
168
331
  source: "cache",
@@ -174,16 +337,106 @@ export function syncHomeTimelineEffect({
174
337
  } as const;
175
338
  }
176
339
 
177
- const payload = yield* listHomeTimelineViaBirdEffect({
178
- maxResults: limit,
340
+ const fetchViaXurl = Effect.gen(function* () {
341
+ if (!following) {
342
+ return yield* Effect.fail(
343
+ new Error("xurl home timeline mode does not support --for-you"),
344
+ );
345
+ }
346
+ const pages: XurlMentionsResponse[] = [];
347
+ let nextToken: string | undefined;
348
+ for (let page = 0; page < parsedMaxPages; page += 1) {
349
+ const fetchedCount = pages.reduce(
350
+ (sum, item) => sum + item.data.length,
351
+ 0,
352
+ );
353
+ const remaining = Number.isFinite(effectiveLimit)
354
+ ? Math.max(1, effectiveLimit - fetchedCount)
355
+ : Infinity;
356
+ const pageSize = Math.min(
357
+ MAX_XURL_TIMELINE_PAGE_SIZE,
358
+ Math.max(5, remaining),
359
+ );
360
+ const pagePayload = yield* listHomeTimelineViaXurlEffect({
361
+ maxResults: pageSize,
362
+ userId: resolvedAccount.externalUserId,
363
+ username: resolvedAccount.username,
364
+ paginationToken: nextToken,
365
+ timeoutMs,
366
+ });
367
+ pages.push(pagePayload);
368
+ nextToken =
369
+ typeof pagePayload.meta?.next_token === "string"
370
+ ? pagePayload.meta.next_token
371
+ : undefined;
372
+ const totalFetched = fetchedCount + pagePayload.data.length;
373
+ const done =
374
+ !nextToken ||
375
+ (Number.isFinite(parsedMaxPages) && page + 1 >= parsedMaxPages) ||
376
+ (Number.isFinite(effectiveLimit) && totalFetched >= effectiveLimit) ||
377
+ reachedStartTimeBoundary(pagePayload, parsedStartTime?.time);
378
+ yield* Effect.sync(() =>
379
+ onProgress?.({
380
+ source: "xurl",
381
+ fetched: totalFetched,
382
+ total: Number.isFinite(effectiveLimit) ? effectiveLimit : undefined,
383
+ page: page + 1,
384
+ maxPages: Number.isFinite(parsedMaxPages)
385
+ ? parsedMaxPages
386
+ : undefined,
387
+ pageSize,
388
+ done,
389
+ }),
390
+ );
391
+ if (done) {
392
+ break;
393
+ }
394
+ }
395
+ return mergeTimelinePayloads(pages, effectiveLimit);
396
+ });
397
+ const fetchViaBird = listHomeTimelineViaBirdEffect({
398
+ maxResults: finiteFallbackLimit,
179
399
  following,
180
400
  });
181
- mergeHomeTimelineIntoLocalStore(db, accountId, payload);
401
+ let source: "bird" | "xurl";
402
+ let payload: XurlMentionsResponse;
403
+ if (effectiveMode === "xurl") {
404
+ payload = yield* fetchViaXurl;
405
+ source = "xurl";
406
+ } else if (effectiveMode === "auto") {
407
+ const fetched = yield* fetchViaXurl.pipe(
408
+ Effect.map((value) => ({ source: "xurl" as const, value })),
409
+ Effect.catchAll(() =>
410
+ fetchViaBird.pipe(
411
+ Effect.map((value) => ({ source: "bird" as const, value })),
412
+ ),
413
+ ),
414
+ );
415
+ payload = fetched.value;
416
+ source = fetched.source;
417
+ } else {
418
+ payload = yield* listHomeTimelineViaBirdEffect({
419
+ maxResults: finiteFallbackLimit,
420
+ following,
421
+ });
422
+ source = "bird";
423
+ }
424
+ if (source === "bird") {
425
+ yield* Effect.sync(() =>
426
+ onProgress?.({
427
+ source: "bird",
428
+ fetched: payload.data.length,
429
+ total: finiteFallbackLimit,
430
+ done: true,
431
+ }),
432
+ );
433
+ }
434
+ mergeHomeTimelineIntoLocalStore(db, accountId, payload, source);
182
435
  writeSyncCache(cacheKey, payload, db);
183
436
 
184
437
  return {
185
438
  ok: true,
186
- source: "bird",
439
+ source,
187
440
  kind: "timeline",
188
441
  accountId,
189
442
  feed: following ? "following" : "for-you",
@@ -4,6 +4,8 @@ export type TweetAccountEdgeKind =
4
4
  | "home"
5
5
  | "mention"
6
6
  | "authored"
7
+ | "search"
8
+ | "profile"
7
9
  | "thread_context";
8
10
 
9
11
  export function upsertTweetAccountEdge(
@@ -57,6 +57,77 @@ export function displayUrlForLink(url: string) {
57
57
  }
58
58
  }
59
59
 
60
+ function asRecord(value: unknown) {
61
+ return value && typeof value === "object"
62
+ ? (value as Record<string, unknown>)
63
+ : {};
64
+ }
65
+
66
+ export function tweetEntitiesFromXurl(raw: unknown): TweetEntities {
67
+ const entities = asRecord(raw);
68
+ const rawMentions = Array.isArray(entities.mentions) ? entities.mentions : [];
69
+ const rawUrls = Array.isArray(entities.urls) ? entities.urls : [];
70
+ const rawHashtags = Array.isArray(entities.hashtags) ? entities.hashtags : [];
71
+
72
+ return {
73
+ ...(rawMentions.length
74
+ ? {
75
+ mentions: rawMentions.map((mention) => {
76
+ const value = asRecord(mention);
77
+ return {
78
+ username: String(value.username ?? ""),
79
+ id: typeof value.id === "string" ? String(value.id) : undefined,
80
+ start: Number(value.start ?? 0),
81
+ end: Number(value.end ?? 0),
82
+ };
83
+ }),
84
+ }
85
+ : {}),
86
+ ...(rawUrls.length
87
+ ? {
88
+ urls: rawUrls.map((url) => {
89
+ const value = asRecord(url);
90
+ const expandedUrl = String(
91
+ value.expandedUrl ?? value.expanded_url ?? value.url ?? "",
92
+ );
93
+ return {
94
+ url: String(value.url ?? ""),
95
+ expandedUrl,
96
+ displayUrl: String(
97
+ value.displayUrl ??
98
+ value.display_url ??
99
+ expandedUrl ??
100
+ value.url ??
101
+ "",
102
+ ),
103
+ start: Number(value.start ?? 0),
104
+ end: Number(value.end ?? 0),
105
+ };
106
+ }),
107
+ }
108
+ : {}),
109
+ ...(rawHashtags.length
110
+ ? {
111
+ hashtags: rawHashtags.map((hashtag) => {
112
+ const value = asRecord(hashtag);
113
+ return {
114
+ tag: String(value.tag ?? value.text ?? ""),
115
+ start: Number(value.start ?? 0),
116
+ end: Number(value.end ?? 0),
117
+ };
118
+ }),
119
+ }
120
+ : {}),
121
+ };
122
+ }
123
+
124
+ export function profileDescriptionEntitiesFromXurl(
125
+ raw: unknown,
126
+ ): TweetEntities {
127
+ const entities = asRecord(raw);
128
+ return tweetEntitiesFromXurl(entities.description);
129
+ }
130
+
60
131
  function spansOverlap(
61
132
  leftStart: number,
62
133
  leftEnd: number,
@@ -66,6 +137,66 @@ function spansOverlap(
66
137
  return leftStart < rightEnd && rightStart < leftEnd;
67
138
  }
68
139
 
140
+ function stringIndexFromCodePointIndex(text: string, index: number) {
141
+ if (index <= 0) return 0;
142
+ let stringIndex = 0;
143
+ let codePointIndex = 0;
144
+ for (const character of text) {
145
+ if (codePointIndex >= index) break;
146
+ stringIndex += character.length;
147
+ codePointIndex += 1;
148
+ }
149
+ return stringIndex;
150
+ }
151
+
152
+ function segmentTextMatches(
153
+ text: string,
154
+ segment: TweetSegment,
155
+ start: number,
156
+ end: number,
157
+ ) {
158
+ const slice = text.slice(start, end);
159
+ if (segment.kind === "mention") {
160
+ return slice.toLowerCase() === `@${segment.username}`.toLowerCase();
161
+ }
162
+ if (segment.kind === "hashtag") {
163
+ return slice.toLowerCase() === `#${segment.tag}`.toLowerCase();
164
+ }
165
+ return slice === segment.url;
166
+ }
167
+
168
+ function normalizeSegmentTextRange(text: string, segment: TweetSegment) {
169
+ if (
170
+ segment.start >= 0 &&
171
+ segment.end > segment.start &&
172
+ segment.end <= text.length &&
173
+ segmentTextMatches(text, segment, segment.start, segment.end)
174
+ ) {
175
+ return segment;
176
+ }
177
+
178
+ const start = stringIndexFromCodePointIndex(text, segment.start);
179
+ const end = stringIndexFromCodePointIndex(text, segment.end);
180
+ if (
181
+ start >= 0 &&
182
+ end > start &&
183
+ end <= text.length &&
184
+ segmentTextMatches(text, segment, start, end)
185
+ ) {
186
+ return { ...segment, start, end };
187
+ }
188
+
189
+ return segment;
190
+ }
191
+
192
+ export function normalizeTweetUrlEntityRangeForText(
193
+ text: string,
194
+ entry: TweetUrlEntity,
195
+ ) {
196
+ const normalized = normalizeSegmentTextRange(text, { ...entry, kind: "url" });
197
+ return { start: normalized.start, end: normalized.end };
198
+ }
199
+
69
200
  export function enrichFallbackUrlEntities(
70
201
  text: string,
71
202
  entities: TweetEntities,
@@ -150,12 +281,21 @@ export function collectTweetSegments(entities: TweetEntities): TweetSegment[] {
150
281
  ].sort((left, right) => left.start - right.start);
151
282
  }
152
283
 
284
+ export function collectTweetSegmentsForText(
285
+ text: string,
286
+ entities: TweetEntities,
287
+ ) {
288
+ return collectTweetSegments(entities)
289
+ .map((segment) => normalizeSegmentTextRange(text, segment))
290
+ .sort((left, right) => left.start - right.start);
291
+ }
292
+
153
293
  function renderTweetText(
154
294
  text: string,
155
295
  entities: TweetEntities,
156
296
  renderSegment: (segment: TweetSegment, fallback: string) => string,
157
297
  ) {
158
- const segments = collectTweetSegments(entities);
298
+ const segments = collectTweetSegmentsForText(text, entities);
159
299
  let cursor = 0;
160
300
  let output = "";
161
301