birdclaw 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +25 -0
  3. package/package.json +6 -1
  4. package/src/cli.ts +438 -26
  5. package/src/components/AppNav.tsx +10 -0
  6. package/src/components/MarkdownViewer.tsx +438 -72
  7. package/src/components/ProfileAnalysisStream.tsx +428 -0
  8. package/src/components/ProfilePreview.tsx +120 -9
  9. package/src/components/SavedTimelineView.tsx +30 -8
  10. package/src/components/SyncNowButton.tsx +5 -2
  11. package/src/components/TimelineCard.tsx +20 -4
  12. package/src/components/TimelineRouteFrame.tsx +16 -0
  13. package/src/components/TweetRichText.tsx +36 -12
  14. package/src/components/useTimelineRouteData.ts +74 -6
  15. package/src/lib/account-sync-job.ts +15 -3
  16. package/src/lib/archive-finder.ts +1 -1
  17. package/src/lib/archive-import.ts +245 -7
  18. package/src/lib/authored-live.ts +1 -0
  19. package/src/lib/avatar-cache.ts +50 -0
  20. package/src/lib/backup.ts +4 -3
  21. package/src/lib/bird.ts +33 -0
  22. package/src/lib/config.ts +35 -2
  23. package/src/lib/data-sources.ts +219 -0
  24. package/src/lib/db.ts +62 -1
  25. package/src/lib/geocoding.ts +296 -0
  26. package/src/lib/location.ts +137 -0
  27. package/src/lib/mention-threads-live.ts +94 -1
  28. package/src/lib/mentions-live.ts +187 -40
  29. package/src/lib/network-map.ts +382 -0
  30. package/src/lib/period-digest.ts +377 -13
  31. package/src/lib/profile-analysis.ts +1272 -0
  32. package/src/lib/profile-bio-entities.ts +1 -1
  33. package/src/lib/queries.ts +14 -4
  34. package/src/lib/search-discussion.ts +1016 -0
  35. package/src/lib/timeline-live.ts +272 -19
  36. package/src/lib/tweet-account-edges.ts +2 -0
  37. package/src/lib/tweet-render.ts +141 -1
  38. package/src/lib/tweet-search-live.ts +565 -0
  39. package/src/lib/types.ts +37 -2
  40. package/src/lib/ui.ts +1 -1
  41. package/src/lib/web-sync.ts +7 -2
  42. package/src/lib/xurl-rate-limits.ts +267 -0
  43. package/src/lib/xurl.ts +551 -41
  44. package/src/routeTree.gen.ts +231 -0
  45. package/src/routes/__root.tsx +5 -6
  46. package/src/routes/api/data-sources.tsx +24 -0
  47. package/src/routes/api/network-map.tsx +55 -0
  48. package/src/routes/api/period-digest.tsx +11 -1
  49. package/src/routes/api/profile-analysis.tsx +152 -0
  50. package/src/routes/api/query.tsx +1 -0
  51. package/src/routes/api/search-discussion.tsx +169 -0
  52. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  53. package/src/routes/data-sources.tsx +255 -0
  54. package/src/routes/discuss.tsx +419 -0
  55. package/src/routes/network-map.tsx +1035 -0
  56. package/src/routes/profile-analyze.tsx +112 -0
  57. package/src/routes/profiles.$handle.tsx +228 -0
  58. package/src/routes/rate-limits.tsx +309 -0
  59. package/src/routes/today.tsx +22 -8
  60. package/src/styles.css +22 -0
@@ -1,17 +1,47 @@
1
- import { Fragment, type ReactNode, useState } from "react";
1
+ import {
2
+ Fragment,
3
+ type MouseEventHandler,
4
+ type ReactNode,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ } from "react";
2
9
  import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
3
10
  import type { PeriodDigestContext } from "#/lib/period-digest";
11
+ import type { ProfileAnalysisContext } from "#/lib/profile-analysis";
12
+ import { renderTweetPlainText } from "#/lib/tweet-render";
4
13
  import type { ProfileRecord } from "#/lib/types";
5
14
  import { cx, tweetLinkClass, tweetMentionClass } from "#/lib/ui";
6
15
  import { safeHttpUrl } from "#/lib/url-safety";
7
16
  import { AvatarChip } from "./AvatarChip";
8
17
  import { ProfilePreview } from "./ProfilePreview";
9
18
 
19
+ type CitationTweet = PeriodDigestContext["tweets"][number];
20
+ type CitationContext = PeriodDigestContext | ProfileAnalysisContext;
21
+ type VerticalBounds = { top: number; bottom: number };
22
+
10
23
  type InlineLookup = {
11
- tweetsById: Map<string, PeriodDigestContext["tweets"][number]>;
24
+ tweetsById: Map<string, CitationTweet>;
12
25
  profilesByHandle: Map<string, ProfileRecord>;
13
26
  };
14
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
+
15
45
  function normalizeTweetReference(value: string) {
16
46
  return value
17
47
  .trim()
@@ -20,13 +50,35 @@ function normalizeTweetReference(value: string) {
20
50
  .replace(/^tweet_/, "");
21
51
  }
22
52
 
53
+ function isNumericTweetReference(value: string) {
54
+ return /^\d{12,25}$/.test(normalizeTweetReference(value));
55
+ }
56
+
23
57
  function tweetReferencesFromToken(token: string) {
24
58
  return Array.from(token.matchAll(/\b(?:tweet_)?[A-Za-z0-9_:-]{3,}\b/g))
25
59
  .map((match) => match[0])
26
60
  .filter((value) => value.startsWith("tweet_") || /^\d{12,25}$/.test(value));
27
61
  }
28
62
 
29
- function trailingReadableBounds(value: string) {
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
+ ) {
30
82
  let start = 0;
31
83
  for (const separator of [". ", "? ", "! ", "; ", ": "]) {
32
84
  const index = value.lastIndexOf(separator);
@@ -46,7 +98,10 @@ function trailingReadableBounds(value: string) {
46
98
  }
47
99
  }
48
100
 
49
- if (clauseStart > start || end - start > 140) {
101
+ if (
102
+ options.preferClause !== false &&
103
+ (clauseStart > start || end - start > 140)
104
+ ) {
50
105
  while (clauseStart < end && /\s/.test(value[clauseStart] ?? "")) {
51
106
  clauseStart += 1;
52
107
  }
@@ -60,47 +115,108 @@ function trimBullet(value: string) {
60
115
  return value.replace(/^[-*]\s+/, "");
61
116
  }
62
117
 
63
- function getTweetUrl(tweet: PeriodDigestContext["tweets"][number]) {
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) {
64
126
  return tweet.url || `https://x.com/${tweet.author}/status/${tweet.id}`;
65
127
  }
66
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
+
67
155
  function TweetPreviewToken({
68
156
  tweet,
69
157
  children,
70
158
  }: {
71
- tweet: PeriodDigestContext["tweets"][number];
159
+ tweet: CitationTweet;
72
160
  children: ReactNode;
73
161
  }) {
74
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]);
75
189
 
76
190
  function closePreview() {
77
191
  setOpen(false);
78
192
  }
79
193
 
194
+ const previewText = renderTweetPlainText(tweet.text, tweet.entities ?? {});
195
+
80
196
  return (
81
197
  <span
198
+ ref={shellRef}
82
199
  className="relative inline align-baseline"
83
200
  onBlur={closePreview}
84
201
  onFocus={() => setOpen(true)}
85
202
  onPointerEnter={() => setOpen(true)}
86
203
  onPointerLeave={closePreview}
87
204
  >
88
- <a
89
- className="rounded-sm px-0.5 text-[var(--accent)] hover:bg-[var(--accent-soft)] hover:no-underline"
205
+ <TweetSourceLink
90
206
  href={getTweetUrl(tweet)}
91
207
  onClick={(event) => {
92
208
  closePreview();
93
209
  event.currentTarget.blur();
94
210
  }}
95
- rel="noreferrer"
96
- target="_blank"
97
211
  >
98
212
  {children}
99
- </a>
213
+ </TweetSourceLink>
100
214
  <span
215
+ ref={cardRef}
101
216
  aria-hidden={!open}
102
217
  className={cx(
103
- "absolute left-1/2 top-[calc(100%+10px)] 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)]",
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)]",
104
220
  open ? "block" : "hidden",
105
221
  )}
106
222
  >
@@ -120,7 +236,7 @@ function TweetPreviewToken({
120
236
  </span>
121
237
  </span>
122
238
  <span className="line-clamp-6 whitespace-pre-wrap [overflow-wrap:anywhere]">
123
- {tweet.text}
239
+ {previewText}
124
240
  </span>
125
241
  <span className="mt-2 flex gap-3 text-[12px] text-[var(--ink-soft)]">
126
242
  <span>{tweet.source}</span>
@@ -134,10 +250,103 @@ function TweetPreviewToken({
134
250
  );
135
251
  }
136
252
 
137
- function additionalCitationLinks(
138
- tweets: Array<PeriodDigestContext["tweets"][number]>,
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[],
139
293
  key: string,
140
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) {
141
350
  return tweets.slice(1).flatMap((tweet, index) => [
142
351
  index === 0 ? " " : ", ",
143
352
  <TweetPreviewToken key={`${key}-source-${String(index + 2)}`} tweet={tweet}>
@@ -146,10 +355,7 @@ function additionalCitationLinks(
146
355
  ]);
147
356
  }
148
357
 
149
- function fallbackCitationLinks(
150
- tweets: Array<PeriodDigestContext["tweets"][number]>,
151
- key: string,
152
- ) {
358
+ function fallbackCitationLinks(tweets: CitationTweet[], key: string) {
153
359
  return tweets.flatMap((tweet, index) => [
154
360
  index === 0 ? "" : ", ",
155
361
  <TweetPreviewToken
@@ -163,7 +369,7 @@ function fallbackCitationLinks(
163
369
 
164
370
  function linkTrailingCitationText(
165
371
  nodes: ReactNode[],
166
- tweets: Array<PeriodDigestContext["tweets"][number]>,
372
+ tweets: CitationTweet[],
167
373
  key: string,
168
374
  ) {
169
375
  const tweet = tweets[0];
@@ -187,7 +393,9 @@ function linkTrailingCitationText(
187
393
  return true;
188
394
  }
189
395
 
190
- const bounds = trailingReadableBounds(last);
396
+ const bounds = trailingReadableBounds(last, {
397
+ preferClause: tweets.length === 1,
398
+ });
191
399
  if (!bounds) return false;
192
400
 
193
401
  const before = last.slice(0, bounds.start);
@@ -195,10 +403,32 @@ function linkTrailingCitationText(
195
403
  const trailing = last.slice(bounds.end);
196
404
  nodes[nodes.length - 1] = before;
197
405
  nodes.push(
198
- <TweetPreviewToken key={key} tweet={tweet}>
199
- {readable}
200
- </TweetPreviewToken>,
201
- ...additionalCitationLinks(tweets, key),
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),
202
432
  /^\s*$/.test(trailing) ? "" : trailing,
203
433
  );
204
434
  return true;
@@ -213,17 +443,14 @@ function renderInline(text: string, lookup: InlineLookup) {
213
443
 
214
444
  while ((match = pattern.exec(text))) {
215
445
  const token = match[0];
446
+ const tokenKey = `${token}-${String(match.index)}`;
216
447
  if (match.index > cursor) {
217
448
  nodes.push(text.slice(cursor, match.index));
218
449
  }
219
450
  cursor = match.index + token.length;
220
451
 
221
452
  if (token.startsWith("**") && token.endsWith("**")) {
222
- nodes.push(
223
- <strong key={`${token}-${String(match.index)}`}>
224
- {token.slice(2, -2)}
225
- </strong>,
226
- );
453
+ nodes.push(<strong key={tokenKey}>{token.slice(2, -2)}</strong>);
227
454
  continue;
228
455
  }
229
456
 
@@ -235,7 +462,7 @@ function renderInline(text: string, lookup: InlineLookup) {
235
462
  nodes.push(
236
463
  href ? (
237
464
  <a
238
- key={`${token}-${String(match.index)}`}
465
+ key={tokenKey}
239
466
  className={tweetLinkClass}
240
467
  href={href}
241
468
  rel="noreferrer"
@@ -254,19 +481,14 @@ function renderInline(text: string, lookup: InlineLookup) {
254
481
  const profile = lookup.profilesByHandle.get(token.slice(1).toLowerCase());
255
482
  nodes.push(
256
483
  profile ? (
257
- <ProfilePreview
258
- key={`${token}-${String(match.index)}`}
259
- profile={profile}
260
- >
484
+ <ProfilePreview key={tokenKey} profile={profile}>
261
485
  <span className={tweetMentionClass}>{token}</span>
262
486
  </ProfilePreview>
263
487
  ) : (
264
488
  <a
265
- key={`${token}-${String(match.index)}`}
489
+ key={tokenKey}
266
490
  className={tweetMentionClass}
267
- href={`https://x.com/${token.slice(1)}`}
268
- rel="noreferrer"
269
- target="_blank"
491
+ href={`/profiles/${encodeURIComponent(token.slice(1))}`}
270
492
  >
271
493
  {token}
272
494
  </a>
@@ -275,23 +497,55 @@ function renderInline(text: string, lookup: InlineLookup) {
275
497
  continue;
276
498
  }
277
499
 
278
- const references = tweetReferencesFromToken(token);
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
+ }
279
515
  const resolvedTweets = references.map((reference) =>
280
516
  lookup.tweetsById.get(normalizeTweetReference(reference)),
281
517
  );
282
518
  const allReferencesResolved =
283
519
  references.length > 0 && resolvedTweets.every(Boolean);
284
- const tweets = resolvedTweets.filter(
285
- (tweet): tweet is PeriodDigestContext["tweets"][number] => Boolean(tweet),
520
+ const tweets = resolvedTweets.filter((tweet): tweet is CitationTweet =>
521
+ Boolean(tweet),
286
522
  );
287
523
  const tweet = tweets[0];
288
- const isParenthesizedTweetRef =
289
- token.startsWith("(") && token.endsWith(")");
290
524
  if (
291
525
  isParenthesizedTweetRef &&
292
526
  references.length > 1 &&
293
527
  !allReferencesResolved
294
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
+ }
295
549
  nodes.push(token);
296
550
  continue;
297
551
  }
@@ -299,28 +553,45 @@ function renderInline(text: string, lookup: InlineLookup) {
299
553
  tweet &&
300
554
  isParenthesizedTweetRef &&
301
555
  allReferencesResolved &&
302
- linkTrailingCitationText(nodes, tweets, `${token}-${String(match.index)}`)
556
+ linkTrailingCitationText(nodes, tweets, tokenKey)
303
557
  ) {
558
+ cursor = skipRedundantSourceWords(text, cursor);
304
559
  continue;
305
560
  }
306
561
  if (tweet && isParenthesizedTweetRef && allReferencesResolved) {
562
+ nodes.push(...fallbackCitationLinks(tweets, tokenKey));
563
+ cursor = skipRedundantSourceWords(text, cursor);
564
+ continue;
565
+ }
566
+ if (tweet) {
307
567
  nodes.push(
308
- ...fallbackCitationLinks(tweets, `${token}-${String(match.index)}`),
568
+ <TweetPreviewToken key={tokenKey} tweet={tweet}>
569
+ {isParenthesizedTweetRef ? "source" : token}
570
+ </TweetPreviewToken>,
309
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;
310
591
  continue;
592
+ } else {
593
+ nodes.push(token);
311
594
  }
312
- nodes.push(
313
- tweet ? (
314
- <TweetPreviewToken
315
- key={`${token}-${String(match.index)}`}
316
- tweet={tweet}
317
- >
318
- {isParenthesizedTweetRef ? "source" : token}
319
- </TweetPreviewToken>
320
- ) : (
321
- token
322
- ),
323
- );
324
595
  }
325
596
 
326
597
  if (cursor < text.length) {
@@ -336,14 +607,109 @@ function renderInline(text: string, lookup: InlineLookup) {
336
607
  ));
337
608
  }
338
609
 
339
- function buildLookup(context?: PeriodDigestContext | null): InlineLookup {
340
- const tweetsById = new Map<string, PeriodDigestContext["tweets"][number]>();
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>();
341
686
  const profilesByHandle = new Map<string, ProfileRecord>();
342
- for (const tweet of context?.tweets ?? []) {
343
- const normalized = normalizeTweetReference(tweet.id);
344
- tweetsById.set(normalized, tweet);
345
- tweetsById.set(`tweet_${normalized}`, tweet);
346
- profilesByHandle.set(tweet.author.toLowerCase(), tweet.authorProfile);
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);
347
713
  }
348
714
  return { tweetsById, profilesByHandle };
349
715
  }
@@ -354,7 +720,7 @@ export function MarkdownViewer({
354
720
  className,
355
721
  }: {
356
722
  markdown: string;
357
- context?: PeriodDigestContext | null;
723
+ context?: CitationContext | null;
358
724
  className?: string;
359
725
  }) {
360
726
  const lookup = buildLookup(context);
@@ -370,7 +736,7 @@ export function MarkdownViewer({
370
736
  if (listItems.length === 0) return;
371
737
  nodes.push(
372
738
  <ul
373
- className="my-3 flex list-disc flex-col gap-2 pl-5 marker:text-[var(--ink-soft)]"
739
+ className="my-2.5 flex list-disc flex-col gap-1.5 pl-5 first:mt-0 marker:text-[var(--ink-soft)]"
374
740
  key={`list-${String(nodes.length)}`}
375
741
  >
376
742
  {listItems.map((item, index) => (
@@ -391,7 +757,7 @@ export function MarkdownViewer({
391
757
  flushList();
392
758
  nodes.push(
393
759
  <h3
394
- className="mt-6 mb-2 text-[14px] font-bold uppercase tracking-wide text-[var(--ink-soft)]"
760
+ className="mt-5 mb-1.5 text-[14px] font-bold uppercase tracking-wide text-[var(--ink-soft)] first:mt-0"
395
761
  key={`h3-${String(nodes.length)}`}
396
762
  >
397
763
  {renderInline(trimmed.slice(4), lookup)}
@@ -403,7 +769,7 @@ export function MarkdownViewer({
403
769
  flushList();
404
770
  nodes.push(
405
771
  <h2
406
- className="mt-7 mb-2 text-[18px] font-bold text-[var(--ink)]"
772
+ className="mt-6 mb-2 text-[18px] font-bold text-[var(--ink)] first:mt-0"
407
773
  key={`h2-${String(nodes.length)}`}
408
774
  >
409
775
  {renderInline(trimmed.slice(3), lookup)}
@@ -415,7 +781,7 @@ export function MarkdownViewer({
415
781
  flushList();
416
782
  nodes.push(
417
783
  <h1
418
- className="mt-2 mb-3 text-[20px] font-bold text-[var(--ink)]"
784
+ className="mt-0 mb-2.5 text-[20px] font-bold text-[var(--ink)]"
419
785
  key={`h1-${String(nodes.length)}`}
420
786
  >
421
787
  {renderInline(trimmed.slice(2), lookup)}
@@ -430,7 +796,7 @@ export function MarkdownViewer({
430
796
  flushList();
431
797
  nodes.push(
432
798
  <p
433
- className="my-3 whitespace-pre-wrap [overflow-wrap:anywhere]"
799
+ className="my-2.5 whitespace-pre-wrap first:mt-0 [overflow-wrap:anywhere]"
434
800
  key={`p-${String(nodes.length)}`}
435
801
  >
436
802
  {renderInline(trimmed, lookup)}
@@ -442,7 +808,7 @@ export function MarkdownViewer({
442
808
  return (
443
809
  <article
444
810
  className={cx(
445
- "max-w-none px-4 py-4 text-[15px] leading-7 text-[var(--ink)]",
811
+ "max-w-none px-4 py-3 text-[15px] leading-[1.55] text-[var(--ink)]",
446
812
  className,
447
813
  )}
448
814
  >