birdclaw 0.5.0 → 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 (105) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +55 -5
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +9 -7
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +400 -0
  7. package/scripts/build-docs-site.mjs +940 -0
  8. package/scripts/docs-site-assets.mjs +311 -0
  9. package/scripts/run-vitest.mjs +21 -0
  10. package/scripts/sanitize-node-options.mjs +23 -0
  11. package/scripts/start-test-server.mjs +42 -0
  12. package/src/cli.ts +422 -14
  13. package/src/components/AccountSwitcher.tsx +186 -0
  14. package/src/components/AppNav.tsx +29 -9
  15. package/src/components/AvatarChip.tsx +9 -3
  16. package/src/components/BrandMark.tsx +67 -0
  17. package/src/components/ConversationThread.tsx +11 -10
  18. package/src/components/DmWorkspace.tsx +39 -14
  19. package/src/components/FeedState.tsx +147 -0
  20. package/src/components/InboxCard.tsx +14 -2
  21. package/src/components/LinkPreviewCard.tsx +40 -18
  22. package/src/components/MarkdownViewer.tsx +452 -0
  23. package/src/components/SavedTimelineView.tsx +64 -56
  24. package/src/components/SyncNowButton.tsx +137 -0
  25. package/src/components/ThemeSlider.tsx +49 -93
  26. package/src/components/TimelineCard.tsx +364 -136
  27. package/src/components/TimelineRouteFrame.tsx +170 -0
  28. package/src/components/TweetMediaGrid.tsx +170 -24
  29. package/src/components/TweetRichText.tsx +28 -13
  30. package/src/components/account-selection.ts +64 -0
  31. package/src/components/useTimelineRouteData.ts +153 -0
  32. package/src/lib/account-sync-job.ts +654 -0
  33. package/src/lib/actions-transport.ts +216 -146
  34. package/src/lib/api-client.ts +304 -0
  35. package/src/lib/archive-finder.ts +72 -53
  36. package/src/lib/archive-import.ts +1377 -1298
  37. package/src/lib/authored-live.ts +261 -204
  38. package/src/lib/avatar-cache.ts +159 -44
  39. package/src/lib/backup.ts +1532 -951
  40. package/src/lib/bird-actions.ts +139 -57
  41. package/src/lib/bird-command.ts +101 -28
  42. package/src/lib/bird.ts +549 -194
  43. package/src/lib/blocklist.ts +40 -23
  44. package/src/lib/blocks-write.ts +129 -80
  45. package/src/lib/blocks.ts +165 -97
  46. package/src/lib/bookmark-sync-job.ts +250 -160
  47. package/src/lib/conversation-surface.ts +205 -0
  48. package/src/lib/db.ts +35 -3
  49. package/src/lib/dms-live.ts +720 -66
  50. package/src/lib/effect-runtime.ts +45 -0
  51. package/src/lib/follow-graph.ts +224 -180
  52. package/src/lib/http-effect.ts +222 -0
  53. package/src/lib/inbox.ts +74 -43
  54. package/src/lib/link-index.ts +88 -76
  55. package/src/lib/link-insights.ts +24 -0
  56. package/src/lib/link-preview-metadata.ts +472 -52
  57. package/src/lib/media-fetch.ts +286 -213
  58. package/src/lib/mention-threads-live.ts +352 -288
  59. package/src/lib/mentions-live.ts +390 -342
  60. package/src/lib/moderation-target.ts +102 -65
  61. package/src/lib/moderation-write.ts +77 -18
  62. package/src/lib/mutes-write.ts +129 -80
  63. package/src/lib/mutes.ts +8 -1
  64. package/src/lib/openai.ts +84 -53
  65. package/src/lib/period-digest.ts +953 -0
  66. package/src/lib/profile-affiliation-hydration.ts +93 -54
  67. package/src/lib/profile-hydration.ts +124 -72
  68. package/src/lib/profile-replies.ts +60 -43
  69. package/src/lib/profile-resolver.ts +402 -294
  70. package/src/lib/queries.ts +1024 -189
  71. package/src/lib/research.ts +165 -120
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +60 -39
  75. package/src/lib/tweet-lookup.ts +30 -19
  76. package/src/lib/types.ts +38 -1
  77. package/src/lib/ui.ts +41 -10
  78. package/src/lib/url-expansion.ts +226 -55
  79. package/src/lib/url-safety.ts +220 -0
  80. package/src/lib/web-sync.ts +511 -0
  81. package/src/lib/whois.ts +166 -147
  82. package/src/lib/x-web.ts +102 -71
  83. package/src/lib/xurl.ts +681 -411
  84. package/src/routeTree.gen.ts +63 -0
  85. package/src/routes/__root.tsx +25 -5
  86. package/src/routes/api/action.tsx +127 -78
  87. package/src/routes/api/avatar.tsx +39 -30
  88. package/src/routes/api/blocks.tsx +26 -23
  89. package/src/routes/api/conversation.tsx +25 -14
  90. package/src/routes/api/inbox.tsx +27 -21
  91. package/src/routes/api/link-insights.tsx +31 -25
  92. package/src/routes/api/link-preview.tsx +25 -21
  93. package/src/routes/api/period-digest.tsx +123 -0
  94. package/src/routes/api/profile-hydrate.tsx +31 -28
  95. package/src/routes/api/query.tsx +79 -55
  96. package/src/routes/api/status.tsx +18 -10
  97. package/src/routes/api/sync.tsx +105 -0
  98. package/src/routes/dms.tsx +195 -55
  99. package/src/routes/inbox.tsx +32 -19
  100. package/src/routes/index.tsx +21 -127
  101. package/src/routes/links.tsx +50 -5
  102. package/src/routes/mentions.tsx +21 -127
  103. package/src/routes/today.tsx +441 -0
  104. package/src/styles.css +74 -11
  105. package/vite.config.ts +8 -0
@@ -0,0 +1,137 @@
1
+ import { RefreshCw } from "lucide-react";
2
+ import { useMemo, useState } from "react";
3
+ import { postSync } from "#/lib/api-client";
4
+ import type { AccountRecord } from "#/lib/types";
5
+ import { cx, selectFieldClass } from "#/lib/ui";
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";
16
+
17
+ interface SyncNowButtonProps {
18
+ kind: WebSyncKind;
19
+ label: string;
20
+ accounts?: AccountRecord[];
21
+ onSynced: (result: WebSyncResponse) => void;
22
+ showAccountPicker?: boolean;
23
+ syncOptions?: WebSyncOptions;
24
+ }
25
+
26
+ export function SyncNowButton({
27
+ kind,
28
+ label,
29
+ accounts,
30
+ onSynced,
31
+ showAccountPicker = false,
32
+ syncOptions,
33
+ }: SyncNowButtonProps) {
34
+ const [syncing, setSyncing] = useState(false);
35
+ const [message, setMessage] = useState<string | null>(null);
36
+ const [error, setError] = useState<string | null>(null);
37
+ const accountList = accounts ?? [];
38
+ const globalAccountId = useSelectedAccountId(accounts);
39
+ const defaultAccountId = useMemo(
40
+ () => getDefaultAccountId(accounts),
41
+ [accounts],
42
+ );
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 ?? "");
57
+
58
+ function selectAccount(accountId: string) {
59
+ setStoredAccountId(accountId);
60
+ }
61
+
62
+ async function syncNow() {
63
+ setSyncing(true);
64
+ setError(null);
65
+ setMessage(null);
66
+ try {
67
+ const data = await postSync(
68
+ kind,
69
+ accountAwareSync ? accountId : undefined,
70
+ syncOptions,
71
+ );
72
+ if (!data.ok) throw new Error(data.summary);
73
+ setMessage(data.summary);
74
+ onSynced(data);
75
+ } catch (syncError) {
76
+ setError(syncError instanceof Error ? syncError.message : "Sync failed");
77
+ } finally {
78
+ setSyncing(false);
79
+ }
80
+ }
81
+
82
+ return (
83
+ <div className="flex shrink-0 items-center gap-2">
84
+ {showAccountPicker && accountAwareSync && accountList.length > 1 ? (
85
+ <select
86
+ aria-label="Sync account"
87
+ className={cx(selectFieldClass, "h-9 w-[132px]")}
88
+ disabled={syncing}
89
+ onChange={(event) => selectAccount(event.target.value)}
90
+ value={accountId ?? ""}
91
+ >
92
+ {accountList.map((account) => (
93
+ <option key={account.id} value={account.id}>
94
+ {account.handle}
95
+ </option>
96
+ ))}
97
+ </select>
98
+ ) : null}
99
+ <button
100
+ type="button"
101
+ className={cx(
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",
103
+ syncing && "text-[var(--ink-soft)]",
104
+ birdOnlyWrongAccount
105
+ ? "disabled:cursor-not-allowed"
106
+ : "disabled:cursor-wait",
107
+ )}
108
+ aria-label={
109
+ birdOnlyWrongAccount
110
+ ? `${label}: default account only`
111
+ : syncing
112
+ ? `${label}: syncing`
113
+ : label
114
+ }
115
+ disabled={disabled}
116
+ onClick={syncNow}
117
+ >
118
+ <RefreshCw
119
+ className={cx("size-4", syncing && "animate-spin")}
120
+ strokeWidth={2}
121
+ />
122
+ <span className="hidden sm:inline">
123
+ {syncing ? "Syncing..." : label}
124
+ </span>
125
+ </button>
126
+ <span
127
+ className={cx(
128
+ "hidden max-w-[190px] truncate text-[12px] sm:inline",
129
+ error ? "text-[var(--alert)]" : "text-[var(--ink-soft)]",
130
+ )}
131
+ role="status"
132
+ >
133
+ {statusMessage}
134
+ </span>
135
+ </div>
136
+ );
137
+ }
@@ -1,6 +1,5 @@
1
1
  import { Monitor, Moon, Sun } from "lucide-react";
2
- import type { CSSProperties, MouseEvent } from "react";
3
- import { useMemo } from "react";
2
+ import type { MouseEvent } from "react";
4
3
  import { type ThemeValue, useTheme } from "#/lib/theme";
5
4
  import {
6
5
  startThemeTransition,
@@ -18,107 +17,64 @@ const THEME_OPTIONS = [
18
17
  label: string;
19
18
  }>;
20
19
 
21
- const ACTIVE_ITEM_WIDTH_PX = 30;
22
- const GAP_PX = 6;
23
- const CONTAINER_PADDING_PX = 6;
24
- const INDICATOR_SIZE_PX = 30;
25
- const INDICATOR_OVERHANG_PX = (INDICATOR_SIZE_PX - ACTIVE_ITEM_WIDTH_PX) / 2;
26
- const INDICATOR_BASE_OFFSET_PX = CONTAINER_PADDING_PX - INDICATOR_OVERHANG_PX;
27
-
28
- function toPx(value: number) {
29
- return `${String(value)}px`;
20
+ function themeIndex(theme: ThemeValue) {
21
+ return THEME_OPTIONS.findIndex((option) => option.key === theme);
30
22
  }
31
23
 
32
- export function ThemeSlider() {
33
- const { isReady, theme, resolvedTheme, setTheme } = useTheme();
24
+ export function ThemeSlider({ compact = false }: { compact?: boolean }) {
25
+ const { isReady, theme, setTheme } = useTheme();
26
+ const activeIndex = Math.max(themeIndex(theme), 0);
27
+ const activeOption = THEME_OPTIONS[activeIndex];
28
+ const nextOption = THEME_OPTIONS[(activeIndex + 1) % THEME_OPTIONS.length];
29
+ const Icon = activeOption.icon;
34
30
 
35
- const activeIndex = useMemo(() => {
36
- const index = THEME_OPTIONS.findIndex((option) => option.key === theme);
37
- return index === -1 ? 0 : index;
38
- }, [theme]);
31
+ const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
32
+ const context: ThemeTransitionContext = {
33
+ element: event.currentTarget,
34
+ pointerClientX: event.clientX,
35
+ pointerClientY: event.clientY,
36
+ };
39
37
 
40
- const indicatorOffset = activeIndex * (ACTIVE_ITEM_WIDTH_PX + GAP_PX);
41
- const indicatorStyle = useMemo<CSSProperties>(
42
- () => ({
43
- left: toPx(INDICATOR_BASE_OFFSET_PX),
44
- transform: `translate(${toPx(indicatorOffset)}, -50%)`,
45
- }),
46
- [indicatorOffset],
47
- );
48
- const sliderStyle = useMemo<CSSProperties>(
49
- () => ({
50
- gridTemplateColumns: `repeat(${String(THEME_OPTIONS.length)}, ${toPx(ACTIVE_ITEM_WIDTH_PX)})`,
51
- columnGap: toPx(GAP_PX),
52
- padding: `0 ${toPx(CONTAINER_PADDING_PX)}`,
53
- width: toPx(
54
- THEME_OPTIONS.length * ACTIVE_ITEM_WIDTH_PX +
55
- (THEME_OPTIONS.length - 1) * GAP_PX +
56
- CONTAINER_PADDING_PX * 2,
57
- ),
58
- }),
59
- [],
60
- );
38
+ startThemeTransition({
39
+ nextTheme: nextOption.key,
40
+ currentTheme: theme,
41
+ setTheme,
42
+ context,
43
+ });
44
+ };
61
45
 
62
46
  return (
63
- <fieldset
64
- className="theme-slider-shell m-0 border-0 p-0"
65
- aria-label="Theme selector"
47
+ <div
48
+ className={cx(
49
+ "theme-toggle-shell flex justify-center",
50
+ compact
51
+ ? "shrink-0"
52
+ : "px-1 py-1 min-[1100px]:justify-start min-[1100px]:px-2",
53
+ )}
54
+ title={`${activeOption.label}; click for ${nextOption.label}`}
66
55
  >
67
- <div
68
- className="theme-slider relative grid h-[42px] place-items-center overflow-hidden rounded-full border border-[var(--line)] bg-[color:color-mix(in_srgb,var(--panel-strong)_86%,transparent)] shadow-[inset_0_1px_0_color-mix(in_srgb,white_40%,transparent)] transition-[background,border-color] duration-180"
69
- style={sliderStyle}
56
+ <button
57
+ type="button"
58
+ className={cx(
59
+ "theme-toggle-button inline-flex items-center justify-center rounded-full border bg-transparent text-[var(--ink-soft)] transition-[background,border-color,color,transform,box-shadow] duration-150 hover:border-[color:color-mix(in_srgb,var(--accent)_38%,var(--line))] hover:bg-[var(--bg-hover)] hover:text-[var(--ink)] active:scale-95 disabled:cursor-default disabled:opacity-55 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:color-mix(in_srgb,var(--accent)_54%,transparent)]",
60
+ compact
61
+ ? "size-8 border-transparent"
62
+ : "size-11 border-[var(--line)] shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--line)_44%,transparent)]",
63
+ )}
64
+ onClick={handleClick}
65
+ aria-label={`Theme: ${activeOption.label}. Switch to ${nextOption.label}.`}
66
+ data-testid="theme-toggle"
67
+ disabled={!isReady}
70
68
  >
71
- <div
69
+ <Icon
72
70
  className={cx(
73
- "theme-slider-indicator pointer-events-none absolute top-1/2 z-0 size-[30px] rounded-full border border-[color:color-mix(in_srgb,var(--line-strong)_85%,transparent)] bg-[radial-gradient(circle_at_30%_30%,rgba(255,255,255,0.6),transparent_58%),var(--panel-strong)] shadow-[0_10px_24px_var(--shadow),inset_0_1px_0_rgba(255,255,255,0.35)] transition-[transform,background,border-color,box-shadow] duration-220 ease-[cubic-bezier(0.22,1,0.36,1)]",
74
- resolvedTheme === "dark" &&
75
- "theme-slider-indicator-dark shadow-[0_10px_24px_rgba(0,0,0,0.28),inset_0_1px_0_rgba(255,255,255,0.08)]",
71
+ "theme-toggle-icon",
72
+ compact ? "size-[17px]" : "size-[19px]",
76
73
  )}
77
- style={indicatorStyle}
74
+ strokeWidth={2}
75
+ aria-hidden="true"
78
76
  />
79
- {THEME_OPTIONS.map((option, index) => {
80
- const Icon = option.icon;
81
- const isActive = index === activeIndex;
82
-
83
- const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
84
- if (isActive) return;
85
-
86
- const context: ThemeTransitionContext = {
87
- element: event.currentTarget,
88
- pointerClientX: event.clientX,
89
- pointerClientY: event.clientY,
90
- };
91
-
92
- startThemeTransition({
93
- nextTheme: option.key,
94
- currentTheme: theme,
95
- setTheme,
96
- context,
97
- });
98
- };
99
-
100
- return (
101
- <button
102
- key={option.key}
103
- type="button"
104
- className={cx(
105
- "theme-slider-button relative z-[1] inline-flex size-[30px] items-center justify-center rounded-full border-0 bg-transparent text-[var(--ink-soft)] transition-[color,transform] duration-160 hover:-translate-y-px hover:text-[var(--ink)] disabled:cursor-default disabled:opacity-55 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[color:color-mix(in_srgb,var(--accent)_54%,transparent)]",
106
- isActive && "theme-slider-button-active text-[var(--ink)]",
107
- )}
108
- onClick={handleClick}
109
- aria-label={option.label}
110
- aria-pressed={isActive}
111
- data-testid={`theme-${option.key}`}
112
- disabled={!isReady}
113
- >
114
- <Icon
115
- className="theme-slider-icon size-[15px]"
116
- strokeWidth={1.8}
117
- />
118
- </button>
119
- );
120
- })}
121
- </div>
122
- </fieldset>
77
+ </button>
78
+ </div>
123
79
  );
124
80
  }