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