birdclaw 0.5.1 → 0.6.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 +44 -1
- package/README.md +50 -5
- package/package.json +3 -2
- package/scripts/browser-perf.mjs +1 -0
- package/scripts/start-test-server.mjs +16 -3
- package/src/cli.ts +376 -13
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +27 -7
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/DmWorkspace.tsx +18 -8
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +452 -0
- package/src/components/SyncNowButton.tsx +57 -25
- package/src/components/ThemeSlider.tsx +55 -50
- package/src/components/TimelineCard.tsx +225 -93
- package/src/components/TimelineRouteFrame.tsx +22 -8
- package/src/components/TweetMediaGrid.tsx +87 -38
- package/src/components/TweetRichText.tsx +15 -11
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +23 -7
- package/src/lib/account-sync-job.ts +654 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +128 -53
- package/src/lib/archive-finder.ts +78 -63
- package/src/lib/archive-import.ts +1364 -1300
- package/src/lib/authored-live.ts +261 -204
- package/src/lib/avatar-cache.ts +159 -44
- package/src/lib/backup.ts +1532 -951
- package/src/lib/bird-actions.ts +139 -57
- package/src/lib/bird-command.ts +101 -28
- package/src/lib/bird.ts +549 -194
- package/src/lib/blocklist.ts +40 -23
- package/src/lib/blocks-write.ts +129 -80
- package/src/lib/blocks.ts +165 -97
- package/src/lib/bookmark-sync-job.ts +250 -160
- package/src/lib/conversation-surface.ts +79 -48
- package/src/lib/db.ts +33 -3
- package/src/lib/dms-live.ts +720 -66
- package/src/lib/effect-runtime.ts +45 -0
- package/src/lib/follow-graph.ts +224 -180
- package/src/lib/http-effect.ts +222 -0
- package/src/lib/inbox.ts +74 -43
- package/src/lib/link-index.ts +88 -76
- package/src/lib/link-insights.ts +24 -0
- package/src/lib/link-preview-metadata.ts +472 -52
- package/src/lib/media-fetch.ts +286 -213
- package/src/lib/mention-threads-live.ts +352 -288
- package/src/lib/mentions-live.ts +390 -342
- package/src/lib/moderation-target.ts +102 -65
- package/src/lib/moderation-write.ts +77 -18
- package/src/lib/mutes-write.ts +129 -80
- package/src/lib/mutes.ts +8 -1
- package/src/lib/openai.ts +84 -53
- package/src/lib/period-digest.ts +953 -0
- package/src/lib/profile-affiliation-hydration.ts +93 -54
- package/src/lib/profile-hydration.ts +124 -72
- package/src/lib/profile-replies.ts +60 -43
- package/src/lib/profile-resolver.ts +402 -294
- package/src/lib/queries.ts +969 -199
- package/src/lib/research.ts +165 -120
- package/src/lib/sqlite.ts +1 -0
- package/src/lib/timeline-collections-live.ts +204 -167
- package/src/lib/timeline-live.ts +60 -39
- package/src/lib/tweet-lookup.ts +30 -19
- package/src/lib/types.ts +38 -1
- package/src/lib/ui.ts +30 -7
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +216 -148
- package/src/lib/whois.ts +166 -147
- package/src/lib/x-web.ts +102 -71
- package/src/lib/xurl.ts +681 -411
- package/src/routeTree.gen.ts +42 -0
- package/src/routes/__root.tsx +25 -5
- package/src/routes/api/action.tsx +127 -78
- package/src/routes/api/avatar.tsx +39 -30
- package/src/routes/api/blocks.tsx +26 -23
- package/src/routes/api/conversation.tsx +25 -14
- package/src/routes/api/inbox.tsx +27 -21
- package/src/routes/api/link-insights.tsx +31 -25
- package/src/routes/api/link-preview.tsx +25 -21
- package/src/routes/api/period-digest.tsx +123 -0
- package/src/routes/api/profile-hydrate.tsx +31 -28
- package/src/routes/api/query.tsx +79 -55
- package/src/routes/api/status.tsx +18 -10
- package/src/routes/api/sync.tsx +75 -29
- package/src/routes/dms.tsx +95 -28
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/today.tsx +441 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { Fragment, type ReactNode, useState } from "react";
|
|
2
|
+
import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
|
|
3
|
+
import type { PeriodDigestContext } from "#/lib/period-digest";
|
|
4
|
+
import type { ProfileRecord } from "#/lib/types";
|
|
5
|
+
import { cx, tweetLinkClass, tweetMentionClass } from "#/lib/ui";
|
|
6
|
+
import { safeHttpUrl } from "#/lib/url-safety";
|
|
7
|
+
import { AvatarChip } from "./AvatarChip";
|
|
8
|
+
import { ProfilePreview } from "./ProfilePreview";
|
|
9
|
+
|
|
10
|
+
type InlineLookup = {
|
|
11
|
+
tweetsById: Map<string, PeriodDigestContext["tweets"][number]>;
|
|
12
|
+
profilesByHandle: Map<string, ProfileRecord>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function normalizeTweetReference(value: string) {
|
|
16
|
+
return value
|
|
17
|
+
.trim()
|
|
18
|
+
.replace(/^\(/, "")
|
|
19
|
+
.replace(/\)$/, "")
|
|
20
|
+
.replace(/^tweet_/, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function tweetReferencesFromToken(token: string) {
|
|
24
|
+
return Array.from(token.matchAll(/\b(?:tweet_)?[A-Za-z0-9_:-]{3,}\b/g))
|
|
25
|
+
.map((match) => match[0])
|
|
26
|
+
.filter((value) => value.startsWith("tweet_") || /^\d{12,25}$/.test(value));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function trailingReadableBounds(value: string) {
|
|
30
|
+
let start = 0;
|
|
31
|
+
for (const separator of [". ", "? ", "! ", "; ", ": "]) {
|
|
32
|
+
const index = value.lastIndexOf(separator);
|
|
33
|
+
if (index >= 0) start = Math.max(start, index + separator.length);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let end = value.length;
|
|
37
|
+
while (start < end && /\s/.test(value[start] ?? "")) start += 1;
|
|
38
|
+
while (end > start && /\s/.test(value[end - 1] ?? "")) end -= 1;
|
|
39
|
+
|
|
40
|
+
let clauseStart = start;
|
|
41
|
+
for (const separator of [", with ", ", while ", ", and "]) {
|
|
42
|
+
const index = value.lastIndexOf(separator, end);
|
|
43
|
+
if (index >= start) {
|
|
44
|
+
clauseStart = index + 2;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (clauseStart > start || end - start > 140) {
|
|
50
|
+
while (clauseStart < end && /\s/.test(value[clauseStart] ?? "")) {
|
|
51
|
+
clauseStart += 1;
|
|
52
|
+
}
|
|
53
|
+
if (end > clauseStart) start = clauseStart;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return end > start ? { start, end } : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function trimBullet(value: string) {
|
|
60
|
+
return value.replace(/^[-*]\s+/, "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getTweetUrl(tweet: PeriodDigestContext["tweets"][number]) {
|
|
64
|
+
return tweet.url || `https://x.com/${tweet.author}/status/${tweet.id}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function TweetPreviewToken({
|
|
68
|
+
tweet,
|
|
69
|
+
children,
|
|
70
|
+
}: {
|
|
71
|
+
tweet: PeriodDigestContext["tweets"][number];
|
|
72
|
+
children: ReactNode;
|
|
73
|
+
}) {
|
|
74
|
+
const [open, setOpen] = useState(false);
|
|
75
|
+
|
|
76
|
+
function closePreview() {
|
|
77
|
+
setOpen(false);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<span
|
|
82
|
+
className="relative inline align-baseline"
|
|
83
|
+
onBlur={closePreview}
|
|
84
|
+
onFocus={() => setOpen(true)}
|
|
85
|
+
onPointerEnter={() => setOpen(true)}
|
|
86
|
+
onPointerLeave={closePreview}
|
|
87
|
+
>
|
|
88
|
+
<a
|
|
89
|
+
className="rounded-sm px-0.5 text-[var(--accent)] hover:bg-[var(--accent-soft)] hover:no-underline"
|
|
90
|
+
href={getTweetUrl(tweet)}
|
|
91
|
+
onClick={(event) => {
|
|
92
|
+
closePreview();
|
|
93
|
+
event.currentTarget.blur();
|
|
94
|
+
}}
|
|
95
|
+
rel="noreferrer"
|
|
96
|
+
target="_blank"
|
|
97
|
+
>
|
|
98
|
+
{children}
|
|
99
|
+
</a>
|
|
100
|
+
<span
|
|
101
|
+
aria-hidden={!open}
|
|
102
|
+
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)]",
|
|
104
|
+
open ? "block" : "hidden",
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
<span className="mb-2 flex items-center gap-2">
|
|
108
|
+
<AvatarChip
|
|
109
|
+
avatarUrl={tweet.authorProfile.avatarUrl}
|
|
110
|
+
hue={tweet.authorProfile.avatarHue}
|
|
111
|
+
name={tweet.name}
|
|
112
|
+
profileId={tweet.authorProfile.id}
|
|
113
|
+
size="small"
|
|
114
|
+
/>
|
|
115
|
+
<span className="min-w-0">
|
|
116
|
+
<span className="block truncate font-bold">{tweet.name}</span>
|
|
117
|
+
<span className="block truncate text-[12px] text-[var(--ink-soft)]">
|
|
118
|
+
@{tweet.author} · {formatShortTimestamp(tweet.createdAt)}
|
|
119
|
+
</span>
|
|
120
|
+
</span>
|
|
121
|
+
</span>
|
|
122
|
+
<span className="line-clamp-6 whitespace-pre-wrap [overflow-wrap:anywhere]">
|
|
123
|
+
{tweet.text}
|
|
124
|
+
</span>
|
|
125
|
+
<span className="mt-2 flex gap-3 text-[12px] text-[var(--ink-soft)]">
|
|
126
|
+
<span>{tweet.source}</span>
|
|
127
|
+
{tweet.likeCount > 0 ? (
|
|
128
|
+
<span>{formatCompactNumber(tweet.likeCount)} likes</span>
|
|
129
|
+
) : null}
|
|
130
|
+
{tweet.needsReply ? <span>reply open</span> : null}
|
|
131
|
+
</span>
|
|
132
|
+
</span>
|
|
133
|
+
</span>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function additionalCitationLinks(
|
|
138
|
+
tweets: Array<PeriodDigestContext["tweets"][number]>,
|
|
139
|
+
key: string,
|
|
140
|
+
) {
|
|
141
|
+
return tweets.slice(1).flatMap((tweet, index) => [
|
|
142
|
+
index === 0 ? " " : ", ",
|
|
143
|
+
<TweetPreviewToken key={`${key}-source-${String(index + 2)}`} tweet={tweet}>
|
|
144
|
+
{`source ${String(index + 2)}`}
|
|
145
|
+
</TweetPreviewToken>,
|
|
146
|
+
]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function fallbackCitationLinks(
|
|
150
|
+
tweets: Array<PeriodDigestContext["tweets"][number]>,
|
|
151
|
+
key: string,
|
|
152
|
+
) {
|
|
153
|
+
return tweets.flatMap((tweet, index) => [
|
|
154
|
+
index === 0 ? "" : ", ",
|
|
155
|
+
<TweetPreviewToken
|
|
156
|
+
key={`${key}-fallback-${String(index + 1)}`}
|
|
157
|
+
tweet={tweet}
|
|
158
|
+
>
|
|
159
|
+
{tweets.length === 1 ? "source" : `source ${String(index + 1)}`}
|
|
160
|
+
</TweetPreviewToken>,
|
|
161
|
+
]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function linkTrailingCitationText(
|
|
165
|
+
nodes: ReactNode[],
|
|
166
|
+
tweets: Array<PeriodDigestContext["tweets"][number]>,
|
|
167
|
+
key: string,
|
|
168
|
+
) {
|
|
169
|
+
const tweet = tweets[0];
|
|
170
|
+
if (!tweet) return false;
|
|
171
|
+
const last = nodes.at(-1);
|
|
172
|
+
if (typeof last !== "string") return false;
|
|
173
|
+
|
|
174
|
+
const match = /(["“][^"”]+["”])(\s*)$/.exec(last);
|
|
175
|
+
if (match) {
|
|
176
|
+
const quoted = match[1];
|
|
177
|
+
const trailing = match[2] ?? "";
|
|
178
|
+
const before = last.slice(0, match.index);
|
|
179
|
+
nodes[nodes.length - 1] = before;
|
|
180
|
+
nodes.push(
|
|
181
|
+
<TweetPreviewToken key={key} tweet={tweet}>
|
|
182
|
+
{quoted}
|
|
183
|
+
</TweetPreviewToken>,
|
|
184
|
+
...additionalCitationLinks(tweets, key),
|
|
185
|
+
/^\s*$/.test(trailing) ? "" : trailing,
|
|
186
|
+
);
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const bounds = trailingReadableBounds(last);
|
|
191
|
+
if (!bounds) return false;
|
|
192
|
+
|
|
193
|
+
const before = last.slice(0, bounds.start);
|
|
194
|
+
const readable = last.slice(bounds.start, bounds.end);
|
|
195
|
+
const trailing = last.slice(bounds.end);
|
|
196
|
+
nodes[nodes.length - 1] = before;
|
|
197
|
+
nodes.push(
|
|
198
|
+
<TweetPreviewToken key={key} tweet={tweet}>
|
|
199
|
+
{readable}
|
|
200
|
+
</TweetPreviewToken>,
|
|
201
|
+
...additionalCitationLinks(tweets, key),
|
|
202
|
+
/^\s*$/.test(trailing) ? "" : trailing,
|
|
203
|
+
);
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function renderInline(text: string, lookup: InlineLookup) {
|
|
208
|
+
const pattern =
|
|
209
|
+
/(\[[^\]\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;
|
|
210
|
+
const nodes: ReactNode[] = [];
|
|
211
|
+
let cursor = 0;
|
|
212
|
+
let match: RegExpExecArray | null;
|
|
213
|
+
|
|
214
|
+
while ((match = pattern.exec(text))) {
|
|
215
|
+
const token = match[0];
|
|
216
|
+
if (match.index > cursor) {
|
|
217
|
+
nodes.push(text.slice(cursor, match.index));
|
|
218
|
+
}
|
|
219
|
+
cursor = match.index + token.length;
|
|
220
|
+
|
|
221
|
+
if (token.startsWith("**") && token.endsWith("**")) {
|
|
222
|
+
nodes.push(
|
|
223
|
+
<strong key={`${token}-${String(match.index)}`}>
|
|
224
|
+
{token.slice(2, -2)}
|
|
225
|
+
</strong>,
|
|
226
|
+
);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const markdownLink = /^\[([^\]\n]+)\]\s*\((https?:\/\/[^\s)]+)\)$/.exec(
|
|
231
|
+
token,
|
|
232
|
+
);
|
|
233
|
+
if (markdownLink) {
|
|
234
|
+
const href = safeHttpUrl(markdownLink[2]);
|
|
235
|
+
nodes.push(
|
|
236
|
+
href ? (
|
|
237
|
+
<a
|
|
238
|
+
key={`${token}-${String(match.index)}`}
|
|
239
|
+
className={tweetLinkClass}
|
|
240
|
+
href={href}
|
|
241
|
+
rel="noreferrer"
|
|
242
|
+
target="_blank"
|
|
243
|
+
>
|
|
244
|
+
{markdownLink[1]}
|
|
245
|
+
</a>
|
|
246
|
+
) : (
|
|
247
|
+
markdownLink[1]
|
|
248
|
+
),
|
|
249
|
+
);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (token.startsWith("@")) {
|
|
254
|
+
const profile = lookup.profilesByHandle.get(token.slice(1).toLowerCase());
|
|
255
|
+
nodes.push(
|
|
256
|
+
profile ? (
|
|
257
|
+
<ProfilePreview
|
|
258
|
+
key={`${token}-${String(match.index)}`}
|
|
259
|
+
profile={profile}
|
|
260
|
+
>
|
|
261
|
+
<span className={tweetMentionClass}>{token}</span>
|
|
262
|
+
</ProfilePreview>
|
|
263
|
+
) : (
|
|
264
|
+
<a
|
|
265
|
+
key={`${token}-${String(match.index)}`}
|
|
266
|
+
className={tweetMentionClass}
|
|
267
|
+
href={`https://x.com/${token.slice(1)}`}
|
|
268
|
+
rel="noreferrer"
|
|
269
|
+
target="_blank"
|
|
270
|
+
>
|
|
271
|
+
{token}
|
|
272
|
+
</a>
|
|
273
|
+
),
|
|
274
|
+
);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const references = tweetReferencesFromToken(token);
|
|
279
|
+
const resolvedTweets = references.map((reference) =>
|
|
280
|
+
lookup.tweetsById.get(normalizeTweetReference(reference)),
|
|
281
|
+
);
|
|
282
|
+
const allReferencesResolved =
|
|
283
|
+
references.length > 0 && resolvedTweets.every(Boolean);
|
|
284
|
+
const tweets = resolvedTweets.filter(
|
|
285
|
+
(tweet): tweet is PeriodDigestContext["tweets"][number] => Boolean(tweet),
|
|
286
|
+
);
|
|
287
|
+
const tweet = tweets[0];
|
|
288
|
+
const isParenthesizedTweetRef =
|
|
289
|
+
token.startsWith("(") && token.endsWith(")");
|
|
290
|
+
if (
|
|
291
|
+
isParenthesizedTweetRef &&
|
|
292
|
+
references.length > 1 &&
|
|
293
|
+
!allReferencesResolved
|
|
294
|
+
) {
|
|
295
|
+
nodes.push(token);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (
|
|
299
|
+
tweet &&
|
|
300
|
+
isParenthesizedTweetRef &&
|
|
301
|
+
allReferencesResolved &&
|
|
302
|
+
linkTrailingCitationText(nodes, tweets, `${token}-${String(match.index)}`)
|
|
303
|
+
) {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (tweet && isParenthesizedTweetRef && allReferencesResolved) {
|
|
307
|
+
nodes.push(
|
|
308
|
+
...fallbackCitationLinks(tweets, `${token}-${String(match.index)}`),
|
|
309
|
+
);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
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
|
+
}
|
|
325
|
+
|
|
326
|
+
if (cursor < text.length) {
|
|
327
|
+
nodes.push(text.slice(cursor));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return nodes.map((node, index) => (
|
|
331
|
+
<Fragment
|
|
332
|
+
key={typeof node === "string" ? `${node}-${String(index)}` : index}
|
|
333
|
+
>
|
|
334
|
+
{node}
|
|
335
|
+
</Fragment>
|
|
336
|
+
));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function buildLookup(context?: PeriodDigestContext | null): InlineLookup {
|
|
340
|
+
const tweetsById = new Map<string, PeriodDigestContext["tweets"][number]>();
|
|
341
|
+
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);
|
|
347
|
+
}
|
|
348
|
+
return { tweetsById, profilesByHandle };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function MarkdownViewer({
|
|
352
|
+
markdown,
|
|
353
|
+
context,
|
|
354
|
+
className,
|
|
355
|
+
}: {
|
|
356
|
+
markdown: string;
|
|
357
|
+
context?: PeriodDigestContext | null;
|
|
358
|
+
className?: string;
|
|
359
|
+
}) {
|
|
360
|
+
const lookup = buildLookup(context);
|
|
361
|
+
const normalizedMarkdown = markdown.replace(
|
|
362
|
+
/\]\s*\r?\n\s*\((https?:\/\/[^\s)]+)\)/g,
|
|
363
|
+
"]($1)",
|
|
364
|
+
);
|
|
365
|
+
const lines = normalizedMarkdown.split(/\r?\n/);
|
|
366
|
+
const nodes: ReactNode[] = [];
|
|
367
|
+
let listItems: ReactNode[][] = [];
|
|
368
|
+
|
|
369
|
+
const flushList = () => {
|
|
370
|
+
if (listItems.length === 0) return;
|
|
371
|
+
nodes.push(
|
|
372
|
+
<ul
|
|
373
|
+
className="my-3 flex list-disc flex-col gap-2 pl-5 marker:text-[var(--ink-soft)]"
|
|
374
|
+
key={`list-${String(nodes.length)}`}
|
|
375
|
+
>
|
|
376
|
+
{listItems.map((item, index) => (
|
|
377
|
+
<li key={String(index)}>{item}</li>
|
|
378
|
+
))}
|
|
379
|
+
</ul>,
|
|
380
|
+
);
|
|
381
|
+
listItems = [];
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
for (const line of lines) {
|
|
385
|
+
const trimmed = line.trim();
|
|
386
|
+
if (!trimmed) {
|
|
387
|
+
flushList();
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (trimmed.startsWith("### ")) {
|
|
391
|
+
flushList();
|
|
392
|
+
nodes.push(
|
|
393
|
+
<h3
|
|
394
|
+
className="mt-6 mb-2 text-[14px] font-bold uppercase tracking-wide text-[var(--ink-soft)]"
|
|
395
|
+
key={`h3-${String(nodes.length)}`}
|
|
396
|
+
>
|
|
397
|
+
{renderInline(trimmed.slice(4), lookup)}
|
|
398
|
+
</h3>,
|
|
399
|
+
);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (trimmed.startsWith("## ")) {
|
|
403
|
+
flushList();
|
|
404
|
+
nodes.push(
|
|
405
|
+
<h2
|
|
406
|
+
className="mt-7 mb-2 text-[18px] font-bold text-[var(--ink)]"
|
|
407
|
+
key={`h2-${String(nodes.length)}`}
|
|
408
|
+
>
|
|
409
|
+
{renderInline(trimmed.slice(3), lookup)}
|
|
410
|
+
</h2>,
|
|
411
|
+
);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (trimmed.startsWith("# ")) {
|
|
415
|
+
flushList();
|
|
416
|
+
nodes.push(
|
|
417
|
+
<h1
|
|
418
|
+
className="mt-2 mb-3 text-[20px] font-bold text-[var(--ink)]"
|
|
419
|
+
key={`h1-${String(nodes.length)}`}
|
|
420
|
+
>
|
|
421
|
+
{renderInline(trimmed.slice(2), lookup)}
|
|
422
|
+
</h1>,
|
|
423
|
+
);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (/^[-*]\s+/.test(trimmed)) {
|
|
427
|
+
listItems.push(renderInline(trimBullet(trimmed), lookup));
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
flushList();
|
|
431
|
+
nodes.push(
|
|
432
|
+
<p
|
|
433
|
+
className="my-3 whitespace-pre-wrap [overflow-wrap:anywhere]"
|
|
434
|
+
key={`p-${String(nodes.length)}`}
|
|
435
|
+
>
|
|
436
|
+
{renderInline(trimmed, lookup)}
|
|
437
|
+
</p>,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
flushList();
|
|
441
|
+
|
|
442
|
+
return (
|
|
443
|
+
<article
|
|
444
|
+
className={cx(
|
|
445
|
+
"max-w-none px-4 py-4 text-[15px] leading-7 text-[var(--ink)]",
|
|
446
|
+
className,
|
|
447
|
+
)}
|
|
448
|
+
>
|
|
449
|
+
{nodes}
|
|
450
|
+
</article>
|
|
451
|
+
);
|
|
452
|
+
}
|
|
@@ -1,51 +1,74 @@
|
|
|
1
1
|
import { RefreshCw } from "lucide-react";
|
|
2
|
-
import {
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
3
|
import { postSync } from "#/lib/api-client";
|
|
4
4
|
import type { AccountRecord } from "#/lib/types";
|
|
5
5
|
import { cx, selectFieldClass } from "#/lib/ui";
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
WebSyncKind,
|
|
8
|
+
WebSyncOptions,
|
|
9
|
+
WebSyncResponse,
|
|
10
|
+
} from "#/lib/web-sync";
|
|
11
|
+
import {
|
|
12
|
+
defaultAccountId as getDefaultAccountId,
|
|
13
|
+
setStoredAccountId,
|
|
14
|
+
useSelectedAccountId,
|
|
15
|
+
} from "./account-selection";
|
|
7
16
|
|
|
8
17
|
interface SyncNowButtonProps {
|
|
9
18
|
kind: WebSyncKind;
|
|
10
19
|
label: string;
|
|
11
20
|
accounts?: AccountRecord[];
|
|
12
21
|
onSynced: (result: WebSyncResponse) => void;
|
|
22
|
+
showAccountPicker?: boolean;
|
|
23
|
+
syncOptions?: WebSyncOptions;
|
|
13
24
|
}
|
|
14
25
|
|
|
15
26
|
export function SyncNowButton({
|
|
16
27
|
kind,
|
|
17
28
|
label,
|
|
18
|
-
accounts
|
|
29
|
+
accounts,
|
|
19
30
|
onSynced,
|
|
31
|
+
showAccountPicker = false,
|
|
32
|
+
syncOptions,
|
|
20
33
|
}: SyncNowButtonProps) {
|
|
21
34
|
const [syncing, setSyncing] = useState(false);
|
|
22
35
|
const [message, setMessage] = useState<string | null>(null);
|
|
23
36
|
const [error, setError] = useState<string | null>(null);
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
>();
|
|
37
|
+
const accountList = accounts ?? [];
|
|
38
|
+
const globalAccountId = useSelectedAccountId(accounts);
|
|
27
39
|
const defaultAccountId = useMemo(
|
|
28
|
-
() => accounts
|
|
40
|
+
() => getDefaultAccountId(accounts),
|
|
29
41
|
[accounts],
|
|
30
42
|
);
|
|
31
|
-
const accountId =
|
|
43
|
+
const accountId = globalAccountId ?? defaultAccountId;
|
|
44
|
+
const accountAwareSync = kind !== "timeline" && kind !== "dms";
|
|
45
|
+
const waitingForAccount = accountAwareSync && accounts === undefined;
|
|
46
|
+
const birdOnlyWrongAccount =
|
|
47
|
+
!accountAwareSync &&
|
|
48
|
+
accountId !== undefined &&
|
|
49
|
+
defaultAccountId !== undefined &&
|
|
50
|
+
accountId !== defaultAccountId;
|
|
51
|
+
const disabled = syncing || waitingForAccount || birdOnlyWrongAccount;
|
|
52
|
+
const statusMessage = birdOnlyWrongAccount
|
|
53
|
+
? "Switch to default to sync"
|
|
54
|
+
: waitingForAccount
|
|
55
|
+
? "Loading account"
|
|
56
|
+
: (error ?? message ?? "");
|
|
32
57
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
if (!accountId || !accounts.some((account) => account.id === accountId)) {
|
|
39
|
-
setSelectedAccountId(defaultAccountId);
|
|
40
|
-
}
|
|
41
|
-
}, [accountId, accounts, defaultAccountId]);
|
|
58
|
+
function selectAccount(accountId: string) {
|
|
59
|
+
setStoredAccountId(accountId);
|
|
60
|
+
}
|
|
42
61
|
|
|
43
62
|
async function syncNow() {
|
|
44
63
|
setSyncing(true);
|
|
45
64
|
setError(null);
|
|
46
65
|
setMessage(null);
|
|
47
66
|
try {
|
|
48
|
-
const data = await postSync(
|
|
67
|
+
const data = await postSync(
|
|
68
|
+
kind,
|
|
69
|
+
accountAwareSync ? accountId : undefined,
|
|
70
|
+
syncOptions,
|
|
71
|
+
);
|
|
49
72
|
if (!data.ok) throw new Error(data.summary);
|
|
50
73
|
setMessage(data.summary);
|
|
51
74
|
onSynced(data);
|
|
@@ -58,15 +81,15 @@ export function SyncNowButton({
|
|
|
58
81
|
|
|
59
82
|
return (
|
|
60
83
|
<div className="flex shrink-0 items-center gap-2">
|
|
61
|
-
{
|
|
84
|
+
{showAccountPicker && accountAwareSync && accountList.length > 1 ? (
|
|
62
85
|
<select
|
|
63
86
|
aria-label="Sync account"
|
|
64
87
|
className={cx(selectFieldClass, "h-9 w-[132px]")}
|
|
65
88
|
disabled={syncing}
|
|
66
|
-
onChange={(event) =>
|
|
89
|
+
onChange={(event) => selectAccount(event.target.value)}
|
|
67
90
|
value={accountId ?? ""}
|
|
68
91
|
>
|
|
69
|
-
{
|
|
92
|
+
{accountList.map((account) => (
|
|
70
93
|
<option key={account.id} value={account.id}>
|
|
71
94
|
{account.handle}
|
|
72
95
|
</option>
|
|
@@ -76,11 +99,20 @@ export function SyncNowButton({
|
|
|
76
99
|
<button
|
|
77
100
|
type="button"
|
|
78
101
|
className={cx(
|
|
79
|
-
"inline-flex h-9 shrink-0 items-center gap-1.5 rounded-full border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-semibold text-[var(--ink)] transition-[background,border-color,color,transform] duration-150 hover:border-[color:color-mix(in_srgb,var(--accent)_45%,var(--line))] hover:bg-[var(--accent-soft)] hover:text-[var(--accent)] active:scale-[0.98] disabled:
|
|
102
|
+
"inline-flex h-9 shrink-0 items-center gap-1.5 rounded-full border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-semibold text-[var(--ink)] transition-[background,border-color,color,transform] duration-150 hover:border-[color:color-mix(in_srgb,var(--accent)_45%,var(--line))] hover:bg-[var(--accent-soft)] hover:text-[var(--accent)] active:scale-[0.98] disabled:opacity-65",
|
|
80
103
|
syncing && "text-[var(--ink-soft)]",
|
|
104
|
+
birdOnlyWrongAccount
|
|
105
|
+
? "disabled:cursor-not-allowed"
|
|
106
|
+
: "disabled:cursor-wait",
|
|
81
107
|
)}
|
|
82
|
-
aria-label={
|
|
83
|
-
|
|
108
|
+
aria-label={
|
|
109
|
+
birdOnlyWrongAccount
|
|
110
|
+
? `${label}: default account only`
|
|
111
|
+
: syncing
|
|
112
|
+
? `${label}: syncing`
|
|
113
|
+
: label
|
|
114
|
+
}
|
|
115
|
+
disabled={disabled}
|
|
84
116
|
onClick={syncNow}
|
|
85
117
|
>
|
|
86
118
|
<RefreshCw
|
|
@@ -98,7 +130,7 @@ export function SyncNowButton({
|
|
|
98
130
|
)}
|
|
99
131
|
role="status"
|
|
100
132
|
>
|
|
101
|
-
{
|
|
133
|
+
{statusMessage}
|
|
102
134
|
</span>
|
|
103
135
|
</div>
|
|
104
136
|
);
|