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.
Files changed (89) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +50 -5
  3. package/package.json +3 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +376 -13
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +27 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +452 -0
  13. package/src/components/SyncNowButton.tsx +57 -25
  14. package/src/components/ThemeSlider.tsx +55 -50
  15. package/src/components/TimelineCard.tsx +225 -93
  16. package/src/components/TimelineRouteFrame.tsx +22 -8
  17. package/src/components/TweetMediaGrid.tsx +87 -38
  18. package/src/components/TweetRichText.tsx +15 -11
  19. package/src/components/account-selection.ts +64 -0
  20. package/src/components/useTimelineRouteData.ts +23 -7
  21. package/src/lib/account-sync-job.ts +654 -0
  22. package/src/lib/actions-transport.ts +216 -146
  23. package/src/lib/api-client.ts +128 -53
  24. package/src/lib/archive-finder.ts +78 -63
  25. package/src/lib/archive-import.ts +1364 -1300
  26. package/src/lib/authored-live.ts +261 -204
  27. package/src/lib/avatar-cache.ts +159 -44
  28. package/src/lib/backup.ts +1532 -951
  29. package/src/lib/bird-actions.ts +139 -57
  30. package/src/lib/bird-command.ts +101 -28
  31. package/src/lib/bird.ts +549 -194
  32. package/src/lib/blocklist.ts +40 -23
  33. package/src/lib/blocks-write.ts +129 -80
  34. package/src/lib/blocks.ts +165 -97
  35. package/src/lib/bookmark-sync-job.ts +250 -160
  36. package/src/lib/conversation-surface.ts +79 -48
  37. package/src/lib/db.ts +33 -3
  38. package/src/lib/dms-live.ts +720 -66
  39. package/src/lib/effect-runtime.ts +45 -0
  40. package/src/lib/follow-graph.ts +224 -180
  41. package/src/lib/http-effect.ts +222 -0
  42. package/src/lib/inbox.ts +74 -43
  43. package/src/lib/link-index.ts +88 -76
  44. package/src/lib/link-insights.ts +24 -0
  45. package/src/lib/link-preview-metadata.ts +472 -52
  46. package/src/lib/media-fetch.ts +286 -213
  47. package/src/lib/mention-threads-live.ts +352 -288
  48. package/src/lib/mentions-live.ts +390 -342
  49. package/src/lib/moderation-target.ts +102 -65
  50. package/src/lib/moderation-write.ts +77 -18
  51. package/src/lib/mutes-write.ts +129 -80
  52. package/src/lib/mutes.ts +8 -1
  53. package/src/lib/openai.ts +84 -53
  54. package/src/lib/period-digest.ts +953 -0
  55. package/src/lib/profile-affiliation-hydration.ts +93 -54
  56. package/src/lib/profile-hydration.ts +124 -72
  57. package/src/lib/profile-replies.ts +60 -43
  58. package/src/lib/profile-resolver.ts +402 -294
  59. package/src/lib/queries.ts +969 -199
  60. package/src/lib/research.ts +165 -120
  61. package/src/lib/sqlite.ts +1 -0
  62. package/src/lib/timeline-collections-live.ts +204 -167
  63. package/src/lib/timeline-live.ts +60 -39
  64. package/src/lib/tweet-lookup.ts +30 -19
  65. package/src/lib/types.ts +38 -1
  66. package/src/lib/ui.ts +30 -7
  67. package/src/lib/url-expansion.ts +226 -55
  68. package/src/lib/url-safety.ts +220 -0
  69. package/src/lib/web-sync.ts +216 -148
  70. package/src/lib/whois.ts +166 -147
  71. package/src/lib/x-web.ts +102 -71
  72. package/src/lib/xurl.ts +681 -411
  73. package/src/routeTree.gen.ts +42 -0
  74. package/src/routes/__root.tsx +25 -5
  75. package/src/routes/api/action.tsx +127 -78
  76. package/src/routes/api/avatar.tsx +39 -30
  77. package/src/routes/api/blocks.tsx +26 -23
  78. package/src/routes/api/conversation.tsx +25 -14
  79. package/src/routes/api/inbox.tsx +27 -21
  80. package/src/routes/api/link-insights.tsx +31 -25
  81. package/src/routes/api/link-preview.tsx +25 -21
  82. package/src/routes/api/period-digest.tsx +123 -0
  83. package/src/routes/api/profile-hydrate.tsx +31 -28
  84. package/src/routes/api/query.tsx +79 -55
  85. package/src/routes/api/status.tsx +18 -10
  86. package/src/routes/api/sync.tsx +75 -29
  87. package/src/routes/dms.tsx +95 -28
  88. package/src/routes/inbox.tsx +32 -19
  89. 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 { useEffect, useMemo, useState } from "react";
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 { WebSyncKind, WebSyncResponse } from "#/lib/web-sync";
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 [selectedAccountId, setSelectedAccountId] = useState<
25
- string | undefined
26
- >();
37
+ const accountList = accounts ?? [];
38
+ const globalAccountId = useSelectedAccountId(accounts);
27
39
  const defaultAccountId = useMemo(
28
- () => accounts.find((account) => account.isDefault)?.id ?? accounts[0]?.id,
40
+ () => getDefaultAccountId(accounts),
29
41
  [accounts],
30
42
  );
31
- const accountId = selectedAccountId ?? defaultAccountId;
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
- useEffect(() => {
34
- if (!accounts.length) {
35
- setSelectedAccountId(undefined);
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(kind, accountId);
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
- {accounts.length > 1 ? (
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) => setSelectedAccountId(event.target.value)}
89
+ onChange={(event) => selectAccount(event.target.value)}
67
90
  value={accountId ?? ""}
68
91
  >
69
- {accounts.map((account) => (
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:cursor-wait disabled:opacity-65",
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={syncing ? `${label}: syncing` : label}
83
- disabled={syncing}
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
- {error ?? message ?? ""}
133
+ {statusMessage}
102
134
  </span>
103
135
  </div>
104
136
  );