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.
- package/CHANGELOG.md +16 -0
- package/package.json +2 -1
- package/src/cli/command-context.ts +17 -0
- package/src/cli/register-analysis.ts +500 -0
- package/src/cli/register-compose.ts +40 -0
- package/src/cli/register-graph.ts +132 -0
- package/src/cli/register-inbox.ts +41 -0
- package/src/cli/register-storage.ts +106 -0
- package/src/cli.ts +30 -750
- package/src/components/AccountSwitcher.tsx +7 -15
- package/src/components/AvatarChip.tsx +1 -1
- package/src/components/AvatarPreload.ts +149 -0
- package/src/components/MarkdownCitations.tsx +680 -0
- package/src/components/MarkdownViewer.tsx +8 -674
- package/src/components/ProfileAnalysisClient.ts +191 -0
- package/src/components/ProfileAnalysisStream.tsx +16 -185
- package/src/components/ProfilePreview.tsx +2 -0
- package/src/components/links-controller.ts +162 -0
- package/src/components/links-model.ts +198 -0
- package/src/components/network-map-controller.ts +84 -0
- package/src/components/network-map-model.ts +255 -0
- package/src/components/useTimelineRouteData.ts +105 -235
- package/src/lib/analysis-runtime.ts +238 -0
- package/src/lib/api-client.ts +16 -215
- package/src/lib/api-contracts.ts +328 -0
- package/src/lib/archive-import-plan.ts +102 -0
- package/src/lib/archive-import.ts +170 -239
- package/src/lib/authored-live.ts +75 -120
- package/src/lib/backup.ts +335 -424
- package/src/lib/blocks-write.ts +30 -26
- package/src/lib/blocks.ts +18 -20
- package/src/lib/database-metrics.ts +88 -0
- package/src/lib/database-migrations.ts +34 -0
- package/src/lib/database-schema.ts +312 -0
- package/src/lib/database-writer.ts +69 -0
- package/src/lib/db.ts +84 -330
- package/src/lib/dm-read-model.ts +533 -0
- package/src/lib/dms-live.ts +34 -97
- package/src/lib/follow-graph.ts +17 -27
- package/src/lib/import-repository.ts +138 -0
- package/src/lib/inbox.ts +2 -1
- package/src/lib/live-sync-engine.ts +209 -0
- package/src/lib/live-transport-gateway.ts +128 -0
- package/src/lib/mention-threads-live.ts +90 -177
- package/src/lib/mentions-export.ts +1 -1
- package/src/lib/mentions-live.ts +57 -181
- package/src/lib/moderation-target.ts +15 -4
- package/src/lib/moderation-write.ts +1 -1
- package/src/lib/mutes-write.ts +30 -26
- package/src/lib/openai-response-runtime.ts +251 -0
- package/src/lib/paginated-sync.ts +93 -0
- package/src/lib/period-digest.ts +116 -304
- package/src/lib/profile-analysis.ts +36 -110
- package/src/lib/queries.ts +6 -2381
- package/src/lib/query-actions.ts +437 -0
- package/src/lib/query-client.tsx +47 -0
- package/src/lib/query-read-model-shared.ts +52 -0
- package/src/lib/query-read-models.ts +5 -0
- package/src/lib/query-resource.ts +41 -0
- package/src/lib/query-status.ts +164 -0
- package/src/lib/research.ts +1 -1
- package/src/lib/runtime-services.ts +20 -0
- package/src/lib/search-discussion.ts +75 -279
- package/src/lib/server-runtime-services.ts +30 -0
- package/src/lib/sqlite.ts +48 -12
- package/src/lib/streaming-ingestion.ts +240 -0
- package/src/lib/sync-cache.ts +6 -1
- package/src/lib/sync-plan.ts +175 -0
- package/src/lib/timeline-collections-live.ts +83 -257
- package/src/lib/timeline-live.ts +86 -236
- package/src/lib/timeline-read-model.ts +1191 -0
- package/src/lib/tweet-repository.ts +156 -0
- package/src/lib/tweet-search-live.ts +63 -167
- package/src/lib/web-sync.ts +67 -50
- package/src/lib/whois.ts +2 -1
- package/src/routes/__root.tsx +11 -8
- package/src/routes/api/action.tsx +1 -1
- package/src/routes/api/conversation.tsx +1 -1
- package/src/routes/api/query.tsx +32 -26
- package/src/routes/api/status.tsx +6 -4
- package/src/routes/api/sync.tsx +5 -2
- package/src/routes/blocks.tsx +97 -131
- package/src/routes/data-sources.tsx +17 -25
- package/src/routes/dms.tsx +167 -184
- package/src/routes/inbox.tsx +63 -57
- package/src/routes/links.tsx +31 -394
- package/src/routes/network-map.tsx +41 -344
- package/src/routes/rate-limits.tsx +17 -21
- package/src/lib/client-cache.ts +0 -109
|
@@ -1,677 +1,11 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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,
|