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.
- package/CHANGELOG.md +48 -0
- package/README.md +25 -0
- package/package.json +6 -1
- package/src/cli.ts +438 -26
- package/src/components/AppNav.tsx +10 -0
- package/src/components/MarkdownViewer.tsx +438 -72
- package/src/components/ProfileAnalysisStream.tsx +428 -0
- package/src/components/ProfilePreview.tsx +120 -9
- package/src/components/SavedTimelineView.tsx +30 -8
- package/src/components/SyncNowButton.tsx +5 -2
- package/src/components/TimelineCard.tsx +20 -4
- package/src/components/TimelineRouteFrame.tsx +16 -0
- package/src/components/TweetRichText.tsx +36 -12
- package/src/components/useTimelineRouteData.ts +74 -6
- package/src/lib/account-sync-job.ts +15 -3
- package/src/lib/archive-finder.ts +1 -1
- package/src/lib/archive-import.ts +245 -7
- package/src/lib/authored-live.ts +1 -0
- package/src/lib/avatar-cache.ts +50 -0
- package/src/lib/backup.ts +4 -3
- package/src/lib/bird.ts +33 -0
- package/src/lib/config.ts +35 -2
- package/src/lib/data-sources.ts +219 -0
- package/src/lib/db.ts +62 -1
- package/src/lib/geocoding.ts +296 -0
- package/src/lib/location.ts +137 -0
- package/src/lib/mention-threads-live.ts +94 -1
- package/src/lib/mentions-live.ts +187 -40
- package/src/lib/network-map.ts +382 -0
- package/src/lib/period-digest.ts +377 -13
- package/src/lib/profile-analysis.ts +1272 -0
- package/src/lib/profile-bio-entities.ts +1 -1
- package/src/lib/queries.ts +14 -4
- package/src/lib/search-discussion.ts +1016 -0
- package/src/lib/timeline-live.ts +272 -19
- package/src/lib/tweet-account-edges.ts +2 -0
- package/src/lib/tweet-render.ts +141 -1
- package/src/lib/tweet-search-live.ts +565 -0
- package/src/lib/types.ts +37 -2
- package/src/lib/ui.ts +1 -1
- package/src/lib/web-sync.ts +7 -2
- package/src/lib/xurl-rate-limits.ts +267 -0
- package/src/lib/xurl.ts +551 -41
- package/src/routeTree.gen.ts +231 -0
- package/src/routes/__root.tsx +5 -6
- package/src/routes/api/data-sources.tsx +24 -0
- package/src/routes/api/network-map.tsx +55 -0
- package/src/routes/api/period-digest.tsx +11 -1
- package/src/routes/api/profile-analysis.tsx +152 -0
- package/src/routes/api/query.tsx +1 -0
- package/src/routes/api/search-discussion.tsx +169 -0
- package/src/routes/api/xurl-rate-limits.tsx +24 -0
- package/src/routes/data-sources.tsx +255 -0
- package/src/routes/discuss.tsx +419 -0
- package/src/routes/network-map.tsx +1035 -0
- package/src/routes/profile-analyze.tsx +112 -0
- package/src/routes/profiles.$handle.tsx +228 -0
- package/src/routes/rate-limits.tsx +309 -0
- package/src/routes/today.tsx +22 -8
- package/src/styles.css +22 -0
|
@@ -1,17 +1,47 @@
|
|
|
1
|
-
import {
|
|
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,
|
|
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
|
|
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 (
|
|
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
|
|
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:
|
|
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
|
-
<
|
|
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
|
-
</
|
|
213
|
+
</TweetSourceLink>
|
|
100
214
|
<span
|
|
215
|
+
ref={cardRef}
|
|
101
216
|
aria-hidden={!open}
|
|
102
217
|
className={cx(
|
|
103
|
-
"absolute left-1/2
|
|
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
|
-
{
|
|
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
|
|
138
|
-
|
|
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:
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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={
|
|
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={
|
|
489
|
+
key={tokenKey}
|
|
266
490
|
className={tweetMentionClass}
|
|
267
|
-
href={
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
340
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
profilesByHandle.set(
|
|
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?:
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
811
|
+
"max-w-none px-4 py-3 text-[15px] leading-[1.55] text-[var(--ink)]",
|
|
446
812
|
className,
|
|
447
813
|
)}
|
|
448
814
|
>
|