birdclaw 0.8.1 → 0.8.3

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