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