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.
- package/CHANGELOG.md +63 -0
- package/README.md +55 -5
- package/bin/birdclaw.mjs +50 -11
- package/package.json +9 -7
- package/public/birdclaw-mark.png +0 -0
- package/scripts/browser-perf.mjs +400 -0
- package/scripts/build-docs-site.mjs +940 -0
- package/scripts/docs-site-assets.mjs +311 -0
- package/scripts/run-vitest.mjs +21 -0
- package/scripts/sanitize-node-options.mjs +23 -0
- package/scripts/start-test-server.mjs +42 -0
- package/src/cli.ts +422 -14
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +29 -9
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +11 -10
- package/src/components/DmWorkspace.tsx +39 -14
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +14 -2
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +452 -0
- package/src/components/SavedTimelineView.tsx +64 -56
- package/src/components/SyncNowButton.tsx +137 -0
- package/src/components/ThemeSlider.tsx +49 -93
- package/src/components/TimelineCard.tsx +364 -136
- package/src/components/TimelineRouteFrame.tsx +170 -0
- package/src/components/TweetMediaGrid.tsx +170 -24
- package/src/components/TweetRichText.tsx +28 -13
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +153 -0
- package/src/lib/account-sync-job.ts +654 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +304 -0
- package/src/lib/archive-finder.ts +72 -53
- package/src/lib/archive-import.ts +1377 -1298
- 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 +205 -0
- package/src/lib/db.ts +35 -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 +1024 -189
- 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 +41 -10
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +511 -0
- 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 +63 -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 +105 -0
- package/src/routes/dms.tsx +195 -55
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/index.tsx +21 -127
- package/src/routes/links.tsx +50 -5
- package/src/routes/mentions.tsx +21 -127
- package/src/routes/today.tsx +441 -0
- package/src/styles.css +74 -11
- 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 {
|
|
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
|
-
|
|
22
|
-
|
|
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,
|
|
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
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
})
|
|
46
|
-
|
|
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
|
-
<
|
|
64
|
-
className=
|
|
65
|
-
|
|
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
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
<
|
|
69
|
+
<Icon
|
|
72
70
|
className={cx(
|
|
73
|
-
"theme-
|
|
74
|
-
|
|
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
|
-
|
|
74
|
+
strokeWidth={2}
|
|
75
|
+
aria-hidden="true"
|
|
78
76
|
/>
|
|
79
|
-
|
|
80
|
-
|
|
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
|
}
|