birdclaw 0.5.1 → 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 (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. package/src/styles.css +22 -0
@@ -0,0 +1,818 @@
1
+ import {
2
+ Fragment,
3
+ type MouseEventHandler,
4
+ type ReactNode,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ } from "react";
9
+ import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
10
+ import type { PeriodDigestContext } from "#/lib/period-digest";
11
+ import type { ProfileAnalysisContext } from "#/lib/profile-analysis";
12
+ import { renderTweetPlainText } from "#/lib/tweet-render";
13
+ import type { ProfileRecord } from "#/lib/types";
14
+ import { cx, tweetLinkClass, tweetMentionClass } from "#/lib/ui";
15
+ import { safeHttpUrl } from "#/lib/url-safety";
16
+ import { AvatarChip } from "./AvatarChip";
17
+ import { ProfilePreview } from "./ProfilePreview";
18
+
19
+ type CitationTweet = PeriodDigestContext["tweets"][number];
20
+ type CitationContext = PeriodDigestContext | ProfileAnalysisContext;
21
+ type VerticalBounds = { top: number; bottom: number };
22
+
23
+ type InlineLookup = {
24
+ tweetsById: Map<string, CitationTweet>;
25
+ profilesByHandle: Map<string, ProfileRecord>;
26
+ };
27
+
28
+ function nearestVerticalClipBounds(element: HTMLElement): VerticalBounds {
29
+ let top = 0;
30
+ let bottom = window.innerHeight;
31
+ for (
32
+ let current = element.parentElement;
33
+ current;
34
+ current = current.parentElement
35
+ ) {
36
+ const style = window.getComputedStyle(current);
37
+ if (!/(auto|scroll|hidden|clip)/.test(style.overflowY)) continue;
38
+ const rect = current.getBoundingClientRect();
39
+ top = Math.max(top, rect.top);
40
+ bottom = Math.min(bottom, rect.bottom);
41
+ }
42
+ return { top, bottom };
43
+ }
44
+
45
+ function normalizeTweetReference(value: string) {
46
+ return value
47
+ .trim()
48
+ .replace(/^\(/, "")
49
+ .replace(/\)$/, "")
50
+ .replace(/^tweet_/, "");
51
+ }
52
+
53
+ function isNumericTweetReference(value: string) {
54
+ return /^\d{12,25}$/.test(normalizeTweetReference(value));
55
+ }
56
+
57
+ function tweetReferencesFromToken(token: string) {
58
+ return Array.from(token.matchAll(/\b(?:tweet_)?[A-Za-z0-9_:-]{3,}\b/g))
59
+ .map((match) => match[0])
60
+ .filter((value) => value.startsWith("tweet_") || /^\d{12,25}$/.test(value));
61
+ }
62
+
63
+ function adjacentParenthesizedTweetReferences(value: string, cursor: number) {
64
+ const references: string[] = [];
65
+ let nextCursor = cursor;
66
+ while (nextCursor < value.length) {
67
+ const match =
68
+ /^\s+\((?:\s*(?:tweet_[A-Za-z0-9_:-]+|\d{12,25})\s*,?)+\)/.exec(
69
+ value.slice(nextCursor),
70
+ );
71
+ if (!match) break;
72
+ references.push(...tweetReferencesFromToken(match[0]));
73
+ nextCursor += match[0].length;
74
+ }
75
+ return { references, cursor: nextCursor };
76
+ }
77
+
78
+ function trailingReadableBounds(
79
+ value: string,
80
+ options: { preferClause?: boolean } = {},
81
+ ) {
82
+ let start = 0;
83
+ for (const separator of [". ", "? ", "! ", "; ", ": "]) {
84
+ const index = value.lastIndexOf(separator);
85
+ if (index >= 0) start = Math.max(start, index + separator.length);
86
+ }
87
+
88
+ let end = value.length;
89
+ while (start < end && /\s/.test(value[start] ?? "")) start += 1;
90
+ while (end > start && /\s/.test(value[end - 1] ?? "")) end -= 1;
91
+
92
+ let clauseStart = start;
93
+ for (const separator of [", with ", ", while ", ", and "]) {
94
+ const index = value.lastIndexOf(separator, end);
95
+ if (index >= start) {
96
+ clauseStart = index + 2;
97
+ break;
98
+ }
99
+ }
100
+
101
+ if (
102
+ options.preferClause !== false &&
103
+ (clauseStart > start || end - start > 140)
104
+ ) {
105
+ while (clauseStart < end && /\s/.test(value[clauseStart] ?? "")) {
106
+ clauseStart += 1;
107
+ }
108
+ if (end > clauseStart) start = clauseStart;
109
+ }
110
+
111
+ return end > start ? { start, end } : null;
112
+ }
113
+
114
+ function trimBullet(value: string) {
115
+ return value.replace(/^[-*]\s+/, "");
116
+ }
117
+
118
+ function skipRedundantSourceWords(value: string, cursor: number) {
119
+ const match = /^((?:\s+source\b)+)(?=\s*(?:[.,;:!?)]|$))/i.exec(
120
+ value.slice(cursor),
121
+ );
122
+ return match ? cursor + match[0].length : cursor;
123
+ }
124
+
125
+ function getTweetUrl(tweet: CitationTweet) {
126
+ return tweet.url || `https://x.com/${tweet.author}/status/${tweet.id}`;
127
+ }
128
+
129
+ function getFallbackTweetUrl(tweetId: string) {
130
+ return `https://x.com/i/status/${normalizeTweetReference(tweetId)}`;
131
+ }
132
+
133
+ function TweetSourceLink({
134
+ children,
135
+ href,
136
+ onClick,
137
+ }: {
138
+ children: ReactNode;
139
+ href: string;
140
+ onClick?: MouseEventHandler<HTMLAnchorElement>;
141
+ }) {
142
+ return (
143
+ <a
144
+ className="rounded-sm px-0.5 text-[var(--accent)] hover:bg-[var(--accent-soft)] hover:no-underline"
145
+ href={href}
146
+ onClick={onClick}
147
+ rel="noreferrer"
148
+ target="_blank"
149
+ >
150
+ {children}
151
+ </a>
152
+ );
153
+ }
154
+
155
+ function TweetPreviewToken({
156
+ tweet,
157
+ children,
158
+ }: {
159
+ tweet: CitationTweet;
160
+ children: ReactNode;
161
+ }) {
162
+ const [open, setOpen] = useState(false);
163
+ const [placeAbove, setPlaceAbove] = useState(false);
164
+ const shellRef = useRef<HTMLSpanElement | null>(null);
165
+ const cardRef = useRef<HTMLSpanElement | null>(null);
166
+
167
+ useLayoutEffect(() => {
168
+ if (!open) return;
169
+ const updatePlacement = () => {
170
+ const shell = shellRef.current;
171
+ if (!shell) return;
172
+ const shellRect = shell.getBoundingClientRect();
173
+ const card = cardRef.current;
174
+ const cardRect = card?.getBoundingClientRect();
175
+ const cardHeight = Math.max(
176
+ card?.offsetHeight ?? 0,
177
+ cardRect?.height ?? 0,
178
+ 220,
179
+ );
180
+ const bounds = nearestVerticalClipBounds(shell);
181
+ const belowSpace = bounds.bottom - shellRect.bottom;
182
+ const aboveSpace = shellRect.top - bounds.top;
183
+ setPlaceAbove(belowSpace < cardHeight + 18 && aboveSpace >= belowSpace);
184
+ };
185
+ updatePlacement();
186
+ const frame = window.requestAnimationFrame(updatePlacement);
187
+ return () => window.cancelAnimationFrame(frame);
188
+ }, [open]);
189
+
190
+ function closePreview() {
191
+ setOpen(false);
192
+ }
193
+
194
+ const previewText = renderTweetPlainText(tweet.text, tweet.entities ?? {});
195
+
196
+ return (
197
+ <span
198
+ ref={shellRef}
199
+ className="relative inline align-baseline"
200
+ onBlur={closePreview}
201
+ onFocus={() => setOpen(true)}
202
+ onPointerEnter={() => setOpen(true)}
203
+ onPointerLeave={closePreview}
204
+ >
205
+ <TweetSourceLink
206
+ href={getTweetUrl(tweet)}
207
+ onClick={(event) => {
208
+ closePreview();
209
+ event.currentTarget.blur();
210
+ }}
211
+ >
212
+ {children}
213
+ </TweetSourceLink>
214
+ <span
215
+ ref={cardRef}
216
+ aria-hidden={!open}
217
+ className={cx(
218
+ "absolute left-1/2 z-40 w-[360px] -translate-x-1/2 rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 text-left text-[14px] leading-[1.4] text-[var(--ink)] shadow-[0_14px_40px_var(--shadow-strong)]",
219
+ placeAbove ? "bottom-[calc(100%+10px)]" : "top-[calc(100%+10px)]",
220
+ open ? "block" : "hidden",
221
+ )}
222
+ >
223
+ <span className="mb-2 flex items-center gap-2">
224
+ <AvatarChip
225
+ avatarUrl={tweet.authorProfile.avatarUrl}
226
+ hue={tweet.authorProfile.avatarHue}
227
+ name={tweet.name}
228
+ profileId={tweet.authorProfile.id}
229
+ size="small"
230
+ />
231
+ <span className="min-w-0">
232
+ <span className="block truncate font-bold">{tweet.name}</span>
233
+ <span className="block truncate text-[12px] text-[var(--ink-soft)]">
234
+ @{tweet.author} · {formatShortTimestamp(tweet.createdAt)}
235
+ </span>
236
+ </span>
237
+ </span>
238
+ <span className="line-clamp-6 whitespace-pre-wrap [overflow-wrap:anywhere]">
239
+ {previewText}
240
+ </span>
241
+ <span className="mt-2 flex gap-3 text-[12px] text-[var(--ink-soft)]">
242
+ <span>{tweet.source}</span>
243
+ {tweet.likeCount > 0 ? (
244
+ <span>{formatCompactNumber(tweet.likeCount)} likes</span>
245
+ ) : null}
246
+ {tweet.needsReply ? <span>reply open</span> : null}
247
+ </span>
248
+ </span>
249
+ </span>
250
+ );
251
+ }
252
+
253
+ function additionalDirectCitationLinks(references: string[], key: string) {
254
+ return references.slice(1).flatMap((reference, index) => [
255
+ index === 0 ? " " : ", ",
256
+ <TweetSourceLink
257
+ key={`${key}-direct-source-${String(index + 2)}`}
258
+ href={getFallbackTweetUrl(reference)}
259
+ >
260
+ {`source ${String(index + 2)}`}
261
+ </TweetSourceLink>,
262
+ ]);
263
+ }
264
+
265
+ function splitReadableForSourceLinks(value: string, count: number) {
266
+ if (count < 2) return null;
267
+ const separators = Array.from(
268
+ value.matchAll(/,\s+and\s+|,\s+or\s+|;\s+|,\s+|\s+and\s+|\s+or\s+/g),
269
+ );
270
+ if (separators.length < count - 1) return null;
271
+
272
+ const selected = separators.slice(-(count - 1));
273
+ const parts: Array<{ text: string; separatorAfter: string }> = [];
274
+ let cursor = 0;
275
+ for (const separator of selected) {
276
+ const index = separator.index;
277
+ if (index === undefined) return null;
278
+ const text = value.slice(cursor, index);
279
+ if (!text.trim()) return null;
280
+ parts.push({ text, separatorAfter: separator[0] });
281
+ cursor = index + separator[0].length;
282
+ }
283
+
284
+ const lastText = value.slice(cursor);
285
+ if (!lastText.trim()) return null;
286
+ parts.push({ text: lastText, separatorAfter: "" });
287
+ return parts.length === count ? parts : null;
288
+ }
289
+
290
+ function linkedCitationParts(
291
+ readable: string,
292
+ tweets: CitationTweet[],
293
+ key: string,
294
+ ) {
295
+ const parts = splitReadableForSourceLinks(readable, tweets.length);
296
+ if (!parts) {
297
+ const tweet = tweets[0];
298
+ if (!tweet) return [];
299
+ return [
300
+ <TweetPreviewToken key={key} tweet={tweet}>
301
+ {readable}
302
+ </TweetPreviewToken>,
303
+ ...additionalCitationLinks(tweets, key),
304
+ ];
305
+ }
306
+ return parts.flatMap((part, index) => {
307
+ const tweet = tweets[index];
308
+ if (!tweet) return [];
309
+ return [
310
+ <TweetPreviewToken key={`${key}-part-${String(index)}`} tweet={tweet}>
311
+ {part.text}
312
+ </TweetPreviewToken>,
313
+ part.separatorAfter,
314
+ ];
315
+ });
316
+ }
317
+
318
+ function linkedDirectCitationParts(
319
+ readable: string,
320
+ references: string[],
321
+ key: string,
322
+ ) {
323
+ const parts = splitReadableForSourceLinks(readable, references.length);
324
+ if (!parts) {
325
+ const reference = references[0];
326
+ if (!reference) return [];
327
+ return [
328
+ <TweetSourceLink key={key} href={getFallbackTweetUrl(reference)}>
329
+ {readable}
330
+ </TweetSourceLink>,
331
+ ...additionalDirectCitationLinks(references, key),
332
+ ];
333
+ }
334
+ return parts.flatMap((part, index) => {
335
+ const reference = references[index];
336
+ if (!reference) return [];
337
+ return [
338
+ <TweetSourceLink
339
+ key={`${key}-direct-part-${String(index)}`}
340
+ href={getFallbackTweetUrl(reference)}
341
+ >
342
+ {part.text}
343
+ </TweetSourceLink>,
344
+ part.separatorAfter,
345
+ ];
346
+ });
347
+ }
348
+
349
+ function additionalCitationLinks(tweets: CitationTweet[], key: string) {
350
+ return tweets.slice(1).flatMap((tweet, index) => [
351
+ index === 0 ? " " : ", ",
352
+ <TweetPreviewToken key={`${key}-source-${String(index + 2)}`} tweet={tweet}>
353
+ {`source ${String(index + 2)}`}
354
+ </TweetPreviewToken>,
355
+ ]);
356
+ }
357
+
358
+ function fallbackCitationLinks(tweets: CitationTweet[], key: string) {
359
+ return tweets.flatMap((tweet, index) => [
360
+ index === 0 ? "" : ", ",
361
+ <TweetPreviewToken
362
+ key={`${key}-fallback-${String(index + 1)}`}
363
+ tweet={tweet}
364
+ >
365
+ {tweets.length === 1 ? "source" : `source ${String(index + 1)}`}
366
+ </TweetPreviewToken>,
367
+ ]);
368
+ }
369
+
370
+ function linkTrailingCitationText(
371
+ nodes: ReactNode[],
372
+ tweets: CitationTweet[],
373
+ key: string,
374
+ ) {
375
+ const tweet = tweets[0];
376
+ if (!tweet) return false;
377
+ const last = nodes.at(-1);
378
+ if (typeof last !== "string") return false;
379
+
380
+ const match = /(["“][^"”]+["”])(\s*)$/.exec(last);
381
+ if (match) {
382
+ const quoted = match[1];
383
+ const trailing = match[2] ?? "";
384
+ const before = last.slice(0, match.index);
385
+ nodes[nodes.length - 1] = before;
386
+ nodes.push(
387
+ <TweetPreviewToken key={key} tweet={tweet}>
388
+ {quoted}
389
+ </TweetPreviewToken>,
390
+ ...additionalCitationLinks(tweets, key),
391
+ /^\s*$/.test(trailing) ? "" : trailing,
392
+ );
393
+ return true;
394
+ }
395
+
396
+ const bounds = trailingReadableBounds(last, {
397
+ preferClause: tweets.length === 1,
398
+ });
399
+ if (!bounds) return false;
400
+
401
+ const before = last.slice(0, bounds.start);
402
+ const readable = last.slice(bounds.start, bounds.end);
403
+ const trailing = last.slice(bounds.end);
404
+ nodes[nodes.length - 1] = before;
405
+ nodes.push(
406
+ ...linkedCitationParts(readable, tweets, key),
407
+ /^\s*$/.test(trailing) ? "" : trailing,
408
+ );
409
+ return true;
410
+ }
411
+
412
+ function linkTrailingDirectCitationText(
413
+ nodes: ReactNode[],
414
+ references: string[],
415
+ key: string,
416
+ ) {
417
+ const reference = references[0];
418
+ if (!reference) return false;
419
+ const last = nodes.at(-1);
420
+ if (typeof last !== "string") return false;
421
+ const bounds = trailingReadableBounds(last, {
422
+ preferClause: references.length === 1,
423
+ });
424
+ if (!bounds) return false;
425
+
426
+ const before = last.slice(0, bounds.start);
427
+ const readable = last.slice(bounds.start, bounds.end);
428
+ const trailing = last.slice(bounds.end);
429
+ nodes[nodes.length - 1] = before;
430
+ nodes.push(
431
+ ...linkedDirectCitationParts(readable, references, key),
432
+ /^\s*$/.test(trailing) ? "" : trailing,
433
+ );
434
+ return true;
435
+ }
436
+
437
+ function renderInline(text: string, lookup: InlineLookup) {
438
+ const pattern =
439
+ /(\[[^\]\n]+\]\s*\(https?:\/\/[^\s)]+\)|\*\*[^*]+\*\*|@[A-Za-z0-9_]{1,20}|\((?:\s*(?:tweet_[A-Za-z0-9_:-]+|\d{12,25})\s*,?)+\)|\btweet_[A-Za-z0-9_:-]+\b|\b\d{12,25}\b)/g;
440
+ const nodes: ReactNode[] = [];
441
+ let cursor = 0;
442
+ let match: RegExpExecArray | null;
443
+
444
+ while ((match = pattern.exec(text))) {
445
+ const token = match[0];
446
+ const tokenKey = `${token}-${String(match.index)}`;
447
+ if (match.index > cursor) {
448
+ nodes.push(text.slice(cursor, match.index));
449
+ }
450
+ cursor = match.index + token.length;
451
+
452
+ if (token.startsWith("**") && token.endsWith("**")) {
453
+ nodes.push(<strong key={tokenKey}>{token.slice(2, -2)}</strong>);
454
+ continue;
455
+ }
456
+
457
+ const markdownLink = /^\[([^\]\n]+)\]\s*\((https?:\/\/[^\s)]+)\)$/.exec(
458
+ token,
459
+ );
460
+ if (markdownLink) {
461
+ const href = safeHttpUrl(markdownLink[2]);
462
+ nodes.push(
463
+ href ? (
464
+ <a
465
+ key={tokenKey}
466
+ className={tweetLinkClass}
467
+ href={href}
468
+ rel="noreferrer"
469
+ target="_blank"
470
+ >
471
+ {markdownLink[1]}
472
+ </a>
473
+ ) : (
474
+ markdownLink[1]
475
+ ),
476
+ );
477
+ continue;
478
+ }
479
+
480
+ if (token.startsWith("@")) {
481
+ const profile = lookup.profilesByHandle.get(token.slice(1).toLowerCase());
482
+ nodes.push(
483
+ profile ? (
484
+ <ProfilePreview key={tokenKey} profile={profile}>
485
+ <span className={tweetMentionClass}>{token}</span>
486
+ </ProfilePreview>
487
+ ) : (
488
+ <a
489
+ key={tokenKey}
490
+ className={tweetMentionClass}
491
+ href={`/profiles/${encodeURIComponent(token.slice(1))}`}
492
+ >
493
+ {token}
494
+ </a>
495
+ ),
496
+ );
497
+ continue;
498
+ }
499
+
500
+ const isParenthesizedTweetRef =
501
+ token.startsWith("(") && token.endsWith(")");
502
+ let references = tweetReferencesFromToken(token);
503
+ if (isParenthesizedTweetRef) {
504
+ const adjacent = adjacentParenthesizedTweetReferences(text, cursor);
505
+ const groupedReferences = [...references, ...adjacent.references];
506
+ if (
507
+ adjacent.references.length > 0 &&
508
+ groupedReferences.every(isNumericTweetReference)
509
+ ) {
510
+ references = groupedReferences;
511
+ cursor = adjacent.cursor;
512
+ pattern.lastIndex = cursor;
513
+ }
514
+ }
515
+ const resolvedTweets = references.map((reference) =>
516
+ lookup.tweetsById.get(normalizeTweetReference(reference)),
517
+ );
518
+ const allReferencesResolved =
519
+ references.length > 0 && resolvedTweets.every(Boolean);
520
+ const tweets = resolvedTweets.filter((tweet): tweet is CitationTweet =>
521
+ Boolean(tweet),
522
+ );
523
+ const tweet = tweets[0];
524
+ if (
525
+ isParenthesizedTweetRef &&
526
+ references.length > 1 &&
527
+ !allReferencesResolved
528
+ ) {
529
+ if (references.every(isNumericTweetReference)) {
530
+ const cursorAfterSourceWords = skipRedundantSourceWords(text, cursor);
531
+ if (linkTrailingDirectCitationText(nodes, references, tokenKey)) {
532
+ cursor = cursorAfterSourceWords;
533
+ continue;
534
+ }
535
+ nodes.push(
536
+ ...references.flatMap((reference, index) => [
537
+ index === 0 ? "" : ", ",
538
+ <TweetSourceLink
539
+ key={`${tokenKey}-direct-${String(index)}`}
540
+ href={getFallbackTweetUrl(reference)}
541
+ >
542
+ {`source ${String(index + 1)}`}
543
+ </TweetSourceLink>,
544
+ ]),
545
+ );
546
+ cursor = cursorAfterSourceWords;
547
+ continue;
548
+ }
549
+ nodes.push(token);
550
+ continue;
551
+ }
552
+ if (
553
+ tweet &&
554
+ isParenthesizedTweetRef &&
555
+ allReferencesResolved &&
556
+ linkTrailingCitationText(nodes, tweets, tokenKey)
557
+ ) {
558
+ cursor = skipRedundantSourceWords(text, cursor);
559
+ continue;
560
+ }
561
+ if (tweet && isParenthesizedTweetRef && allReferencesResolved) {
562
+ nodes.push(...fallbackCitationLinks(tweets, tokenKey));
563
+ cursor = skipRedundantSourceWords(text, cursor);
564
+ continue;
565
+ }
566
+ if (tweet) {
567
+ nodes.push(
568
+ <TweetPreviewToken key={tokenKey} tweet={tweet}>
569
+ {isParenthesizedTweetRef ? "source" : token}
570
+ </TweetPreviewToken>,
571
+ );
572
+ } else if (
573
+ isParenthesizedTweetRef &&
574
+ references.length === 1 &&
575
+ isNumericTweetReference(references[0] ?? "")
576
+ ) {
577
+ const cursorAfterSourceWords = skipRedundantSourceWords(text, cursor);
578
+ if (linkTrailingDirectCitationText(nodes, references, tokenKey)) {
579
+ cursor = cursorAfterSourceWords;
580
+ continue;
581
+ }
582
+ nodes.push(
583
+ <TweetSourceLink
584
+ key={`${tokenKey}-direct`}
585
+ href={getFallbackTweetUrl(references[0])}
586
+ >
587
+ source
588
+ </TweetSourceLink>,
589
+ );
590
+ cursor = cursorAfterSourceWords;
591
+ continue;
592
+ } else {
593
+ nodes.push(token);
594
+ }
595
+ }
596
+
597
+ if (cursor < text.length) {
598
+ nodes.push(text.slice(cursor));
599
+ }
600
+
601
+ return nodes.map((node, index) => (
602
+ <Fragment
603
+ key={typeof node === "string" ? `${node}-${String(index)}` : index}
604
+ >
605
+ {node}
606
+ </Fragment>
607
+ ));
608
+ }
609
+
610
+ function isProfileAnalysisContext(
611
+ context: CitationContext,
612
+ ): context is ProfileAnalysisContext {
613
+ return "conversations" in context && "profile" in context;
614
+ }
615
+
616
+ function addLookupTweet(
617
+ tweetsById: Map<string, CitationTweet>,
618
+ profilesByHandle: Map<string, ProfileRecord>,
619
+ tweet: CitationTweet,
620
+ ) {
621
+ const normalized = normalizeTweetReference(tweet.id);
622
+ tweetsById.set(normalized, tweet);
623
+ tweetsById.set(`tweet_${normalized}`, tweet);
624
+ profilesByHandle.set(tweet.author.toLowerCase(), tweet.authorProfile);
625
+ }
626
+
627
+ function syntheticProfileForConversationTweet(
628
+ tweet: ProfileAnalysisContext["conversations"][number],
629
+ ): ProfileRecord {
630
+ return {
631
+ id: tweet.profileId,
632
+ handle: tweet.author,
633
+ displayName: tweet.name || tweet.author,
634
+ bio: tweet.bio,
635
+ followersCount: tweet.followersCount,
636
+ avatarHue: 210,
637
+ avatarUrl: tweet.avatarUrl,
638
+ createdAt: tweet.createdAt,
639
+ };
640
+ }
641
+
642
+ function profileAnalysisTweetToCitation(
643
+ tweet: ProfileAnalysisContext["tweets"][number],
644
+ profile: ProfileRecord,
645
+ ): CitationTweet {
646
+ return {
647
+ id: tweet.id,
648
+ url: tweet.url,
649
+ source: "authored",
650
+ author: profile.handle,
651
+ name: profile.displayName,
652
+ authorProfile: profile,
653
+ createdAt: tweet.createdAt,
654
+ text: tweet.text,
655
+ entities: tweet.entities,
656
+ likeCount: tweet.likeCount,
657
+ liked: false,
658
+ bookmarked: false,
659
+ needsReply: false,
660
+ };
661
+ }
662
+
663
+ function conversationTweetToCitation(
664
+ tweet: ProfileAnalysisContext["conversations"][number],
665
+ ): CitationTweet {
666
+ const authorProfile = syntheticProfileForConversationTweet(tweet);
667
+ return {
668
+ id: tweet.id,
669
+ url: tweet.url,
670
+ source: "mentions",
671
+ author: tweet.author,
672
+ name: tweet.name || tweet.author,
673
+ authorProfile,
674
+ createdAt: tweet.createdAt,
675
+ text: tweet.text,
676
+ entities: tweet.entities,
677
+ likeCount: tweet.likeCount,
678
+ liked: false,
679
+ bookmarked: false,
680
+ needsReply: false,
681
+ };
682
+ }
683
+
684
+ function buildLookup(context?: CitationContext | null): InlineLookup {
685
+ const tweetsById = new Map<string, CitationTweet>();
686
+ const profilesByHandle = new Map<string, ProfileRecord>();
687
+ if (!context) {
688
+ return { tweetsById, profilesByHandle };
689
+ }
690
+ if (isProfileAnalysisContext(context)) {
691
+ profilesByHandle.set(context.profile.handle.toLowerCase(), context.profile);
692
+ for (const profile of context.profiles ?? []) {
693
+ profilesByHandle.set(profile.handle.toLowerCase(), profile);
694
+ }
695
+ for (const tweet of context.tweets) {
696
+ addLookupTweet(
697
+ tweetsById,
698
+ profilesByHandle,
699
+ profileAnalysisTweetToCitation(tweet, context.profile),
700
+ );
701
+ }
702
+ for (const tweet of context.conversations) {
703
+ addLookupTweet(
704
+ tweetsById,
705
+ profilesByHandle,
706
+ conversationTweetToCitation(tweet),
707
+ );
708
+ }
709
+ return { tweetsById, profilesByHandle };
710
+ }
711
+ for (const tweet of context.tweets) {
712
+ addLookupTweet(tweetsById, profilesByHandle, tweet);
713
+ }
714
+ return { tweetsById, profilesByHandle };
715
+ }
716
+
717
+ export function MarkdownViewer({
718
+ markdown,
719
+ context,
720
+ className,
721
+ }: {
722
+ markdown: string;
723
+ context?: CitationContext | null;
724
+ className?: string;
725
+ }) {
726
+ const lookup = buildLookup(context);
727
+ const normalizedMarkdown = markdown.replace(
728
+ /\]\s*\r?\n\s*\((https?:\/\/[^\s)]+)\)/g,
729
+ "]($1)",
730
+ );
731
+ const lines = normalizedMarkdown.split(/\r?\n/);
732
+ const nodes: ReactNode[] = [];
733
+ let listItems: ReactNode[][] = [];
734
+
735
+ const flushList = () => {
736
+ if (listItems.length === 0) return;
737
+ nodes.push(
738
+ <ul
739
+ className="my-2.5 flex list-disc flex-col gap-1.5 pl-5 first:mt-0 marker:text-[var(--ink-soft)]"
740
+ key={`list-${String(nodes.length)}`}
741
+ >
742
+ {listItems.map((item, index) => (
743
+ <li key={String(index)}>{item}</li>
744
+ ))}
745
+ </ul>,
746
+ );
747
+ listItems = [];
748
+ };
749
+
750
+ for (const line of lines) {
751
+ const trimmed = line.trim();
752
+ if (!trimmed) {
753
+ flushList();
754
+ continue;
755
+ }
756
+ if (trimmed.startsWith("### ")) {
757
+ flushList();
758
+ nodes.push(
759
+ <h3
760
+ className="mt-5 mb-1.5 text-[14px] font-bold uppercase tracking-wide text-[var(--ink-soft)] first:mt-0"
761
+ key={`h3-${String(nodes.length)}`}
762
+ >
763
+ {renderInline(trimmed.slice(4), lookup)}
764
+ </h3>,
765
+ );
766
+ continue;
767
+ }
768
+ if (trimmed.startsWith("## ")) {
769
+ flushList();
770
+ nodes.push(
771
+ <h2
772
+ className="mt-6 mb-2 text-[18px] font-bold text-[var(--ink)] first:mt-0"
773
+ key={`h2-${String(nodes.length)}`}
774
+ >
775
+ {renderInline(trimmed.slice(3), lookup)}
776
+ </h2>,
777
+ );
778
+ continue;
779
+ }
780
+ if (trimmed.startsWith("# ")) {
781
+ flushList();
782
+ nodes.push(
783
+ <h1
784
+ className="mt-0 mb-2.5 text-[20px] font-bold text-[var(--ink)]"
785
+ key={`h1-${String(nodes.length)}`}
786
+ >
787
+ {renderInline(trimmed.slice(2), lookup)}
788
+ </h1>,
789
+ );
790
+ continue;
791
+ }
792
+ if (/^[-*]\s+/.test(trimmed)) {
793
+ listItems.push(renderInline(trimBullet(trimmed), lookup));
794
+ continue;
795
+ }
796
+ flushList();
797
+ nodes.push(
798
+ <p
799
+ className="my-2.5 whitespace-pre-wrap first:mt-0 [overflow-wrap:anywhere]"
800
+ key={`p-${String(nodes.length)}`}
801
+ >
802
+ {renderInline(trimmed, lookup)}
803
+ </p>,
804
+ );
805
+ }
806
+ flushList();
807
+
808
+ return (
809
+ <article
810
+ className={cx(
811
+ "max-w-none px-4 py-3 text-[15px] leading-[1.55] text-[var(--ink)]",
812
+ className,
813
+ )}
814
+ >
815
+ {nodes}
816
+ </article>
817
+ );
818
+ }