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,147 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { BirdclawEmpty, BirdclawLoading } from "./BrandMark";
|
|
3
|
+
|
|
4
|
+
function SkeletonBlock({
|
|
5
|
+
className,
|
|
6
|
+
muted = false,
|
|
7
|
+
}: {
|
|
8
|
+
className: string;
|
|
9
|
+
muted?: boolean;
|
|
10
|
+
}) {
|
|
11
|
+
return (
|
|
12
|
+
<span
|
|
13
|
+
aria-hidden="true"
|
|
14
|
+
className={`block animate-pulse rounded-full ${muted ? "bg-[color:color-mix(in_srgb,var(--ink-soft)_16%,transparent)]" : "bg-[color:color-mix(in_srgb,var(--ink-soft)_24%,transparent)]"} ${className}`}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function TweetSkeletonRows({ count = 4 }: { count?: number }) {
|
|
20
|
+
return (
|
|
21
|
+
<div aria-hidden="true" className="divide-y divide-[var(--line)]">
|
|
22
|
+
{Array.from({ length: count }, (_unused, index) => {
|
|
23
|
+
const hasMedia = index % 3 === 0;
|
|
24
|
+
const hasQuote = index % 4 === 2;
|
|
25
|
+
return (
|
|
26
|
+
<article
|
|
27
|
+
className="flex gap-3 px-4 py-3"
|
|
28
|
+
data-perf="tweet-skeleton-row"
|
|
29
|
+
key={`tweet-skeleton-${index}`}
|
|
30
|
+
>
|
|
31
|
+
<SkeletonBlock className="size-10 shrink-0 rounded-full" />
|
|
32
|
+
<div className="min-w-0 flex-1 space-y-3">
|
|
33
|
+
<div className="flex items-center gap-2">
|
|
34
|
+
<SkeletonBlock className="h-3.5 w-32" />
|
|
35
|
+
<SkeletonBlock className="h-3 w-20" muted />
|
|
36
|
+
<SkeletonBlock className="ml-auto h-5 w-16" muted />
|
|
37
|
+
</div>
|
|
38
|
+
<div className="space-y-2">
|
|
39
|
+
<SkeletonBlock className="h-3 w-full" />
|
|
40
|
+
<SkeletonBlock className="h-3 w-11/12" muted />
|
|
41
|
+
{index % 2 === 0 ? (
|
|
42
|
+
<SkeletonBlock className="h-3 w-7/12" muted />
|
|
43
|
+
) : null}
|
|
44
|
+
</div>
|
|
45
|
+
{hasMedia ? (
|
|
46
|
+
<SkeletonBlock className="h-32 w-full rounded-2xl" muted />
|
|
47
|
+
) : null}
|
|
48
|
+
{hasQuote ? (
|
|
49
|
+
<div className="space-y-2 rounded-2xl border border-[var(--line)] px-3 py-2">
|
|
50
|
+
<SkeletonBlock className="h-3 w-28" muted />
|
|
51
|
+
<SkeletonBlock className="h-3 w-full" muted />
|
|
52
|
+
</div>
|
|
53
|
+
) : null}
|
|
54
|
+
<div className="flex max-w-md items-center justify-between">
|
|
55
|
+
{["thread", "reply", "repost", "like"].map((key) => (
|
|
56
|
+
<SkeletonBlock className="h-3 w-11" key={key} muted />
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</article>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function LinkSkeletonRows({ count = 4 }: { count?: number }) {
|
|
68
|
+
return (
|
|
69
|
+
<div aria-hidden="true" className="divide-y divide-[var(--line)]">
|
|
70
|
+
{Array.from({ length: count }, (_unused, index) => (
|
|
71
|
+
<article
|
|
72
|
+
className="flex gap-3 px-4 py-3"
|
|
73
|
+
data-perf="link-skeleton-row"
|
|
74
|
+
key={`link-skeleton-${index}`}
|
|
75
|
+
>
|
|
76
|
+
<SkeletonBlock className="size-9 shrink-0 rounded-full" muted />
|
|
77
|
+
<div className="min-w-0 flex-1 space-y-3">
|
|
78
|
+
<SkeletonBlock className="h-3.5 w-8/12" />
|
|
79
|
+
<SkeletonBlock className="h-3 w-6/12" muted />
|
|
80
|
+
<div className="flex flex-wrap gap-2">
|
|
81
|
+
<SkeletonBlock className="h-3 w-20" muted />
|
|
82
|
+
<SkeletonBlock className="h-3 w-14" muted />
|
|
83
|
+
<SkeletonBlock className="h-3 w-24" muted />
|
|
84
|
+
</div>
|
|
85
|
+
{index % 2 === 0 ? (
|
|
86
|
+
<SkeletonBlock
|
|
87
|
+
className="h-20 w-72 max-w-full rounded-2xl"
|
|
88
|
+
muted
|
|
89
|
+
/>
|
|
90
|
+
) : null}
|
|
91
|
+
</div>
|
|
92
|
+
</article>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function FeedLoading({
|
|
99
|
+
children,
|
|
100
|
+
detail,
|
|
101
|
+
label,
|
|
102
|
+
}: {
|
|
103
|
+
children?: ReactNode;
|
|
104
|
+
detail?: string;
|
|
105
|
+
label: string;
|
|
106
|
+
}) {
|
|
107
|
+
return (
|
|
108
|
+
<div className="border-b border-[var(--line)]">
|
|
109
|
+
<BirdclawLoading detail={detail} label={label} />
|
|
110
|
+
{children}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function FeedError({
|
|
116
|
+
action,
|
|
117
|
+
message,
|
|
118
|
+
title = "Could not load this view",
|
|
119
|
+
}: {
|
|
120
|
+
action?: ReactNode;
|
|
121
|
+
message: string;
|
|
122
|
+
title?: string;
|
|
123
|
+
}) {
|
|
124
|
+
return (
|
|
125
|
+
<div className="border-b border-[var(--line)] px-6 py-10 text-center">
|
|
126
|
+
<div className="mx-auto max-w-sm">
|
|
127
|
+
<div className="text-[14px] font-bold text-[var(--alert)]">{title}</div>
|
|
128
|
+
<p className="mt-2 text-[13px] leading-[1.45] text-[var(--ink-soft)]">
|
|
129
|
+
{message}
|
|
130
|
+
</p>
|
|
131
|
+
{action ? (
|
|
132
|
+
<div className="mt-4 flex justify-center">{action}</div>
|
|
133
|
+
) : null}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function FeedEmpty({
|
|
140
|
+
detail,
|
|
141
|
+
label,
|
|
142
|
+
}: {
|
|
143
|
+
detail?: string;
|
|
144
|
+
label: string;
|
|
145
|
+
}) {
|
|
146
|
+
return <BirdclawEmpty detail={detail} label={label} />;
|
|
147
|
+
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { Link } from "@tanstack/react-router";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
CheckCircle2,
|
|
4
|
+
Circle,
|
|
5
|
+
ExternalLink,
|
|
6
|
+
MessageCircle,
|
|
7
|
+
} from "lucide-react";
|
|
3
8
|
import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
|
|
4
9
|
import type { InboxItem } from "#/lib/types";
|
|
5
10
|
import {
|
|
@@ -86,7 +91,14 @@ export function InboxCard({
|
|
|
86
91
|
<span className={mutedDotClass} />
|
|
87
92
|
<span>influence {formatCompactNumber(item.influenceScore)}</span>
|
|
88
93
|
<span className={mutedDotClass} />
|
|
89
|
-
<span
|
|
94
|
+
<span className="inline-flex items-center gap-1">
|
|
95
|
+
{item.needsReply ? (
|
|
96
|
+
<Circle className="size-3" strokeWidth={2.2} />
|
|
97
|
+
) : (
|
|
98
|
+
<CheckCircle2 className="size-3.5" strokeWidth={2} />
|
|
99
|
+
)}
|
|
100
|
+
{item.needsReply ? "open" : "replied"}
|
|
101
|
+
</span>
|
|
90
102
|
</div>
|
|
91
103
|
<div className="flex items-center gap-2">
|
|
92
104
|
<button
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
linkPreviewHostClass,
|
|
10
10
|
linkPreviewTitleClass,
|
|
11
11
|
} from "#/lib/ui";
|
|
12
|
+
import { safeHttpUrl } from "#/lib/url-safety";
|
|
12
13
|
|
|
13
14
|
type LinkPreviewState = Pick<
|
|
14
15
|
TweetUrlEntity,
|
|
@@ -21,7 +22,6 @@ type LinkPreviewState = Pick<
|
|
|
21
22
|
>;
|
|
22
23
|
|
|
23
24
|
const previewCache = new Map<string, Promise<LinkPreviewMetadata | null>>();
|
|
24
|
-
const IMAGE_EXTENSION_PATTERN = /\.(?:avif|gif|jpe?g|png|webp)(?:[?#].*)?$/i;
|
|
25
25
|
const MAX_CONCURRENT_PREVIEW_FETCHES = 2;
|
|
26
26
|
let activePreviewFetches = 0;
|
|
27
27
|
const queuedPreviewFetches: Array<() => void> = [];
|
|
@@ -72,16 +72,13 @@ function schedulePreviewFetch(task: () => Promise<LinkPreviewMetadata | null>) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
function fetchPreview(entry: TweetUrlEntity) {
|
|
75
|
-
const targetUrl = entry.expandedUrl || entry.url;
|
|
75
|
+
const targetUrl = safeHttpUrl(entry.expandedUrl || entry.url);
|
|
76
76
|
if (!targetUrl) return Promise.resolve(null);
|
|
77
77
|
const key = `${entry.url} ${targetUrl}`;
|
|
78
78
|
const cached = previewCache.get(key);
|
|
79
79
|
if (cached) return cached;
|
|
80
80
|
|
|
81
81
|
const params = new URLSearchParams({ url: targetUrl });
|
|
82
|
-
if (entry.url && entry.url !== targetUrl) {
|
|
83
|
-
params.set("shortUrl", entry.url);
|
|
84
|
-
}
|
|
85
82
|
const promise = schedulePreviewFetch(() =>
|
|
86
83
|
fetch(`/api/link-preview?${params.toString()}`)
|
|
87
84
|
.then(async (response) => {
|
|
@@ -109,14 +106,32 @@ function displayHost(url: string, fallback: string) {
|
|
|
109
106
|
function isDirectImageUrl(url: string) {
|
|
110
107
|
try {
|
|
111
108
|
const parsed = new URL(url);
|
|
112
|
-
if (IMAGE_EXTENSION_PATTERN.test(parsed.pathname)) return true;
|
|
113
109
|
return (
|
|
110
|
+
parsed.protocol === "https:" &&
|
|
114
111
|
parsed.hostname === "pbs.twimg.com" &&
|
|
115
112
|
(parsed.pathname.startsWith("/media/") ||
|
|
116
113
|
parsed.pathname.startsWith("/amplify_video_thumb/"))
|
|
117
114
|
);
|
|
118
115
|
} catch {
|
|
119
|
-
return
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function safePreviewImageUrl(url: string | null | undefined) {
|
|
121
|
+
if (!url) return null;
|
|
122
|
+
try {
|
|
123
|
+
const parsed = new URL(url);
|
|
124
|
+
if (
|
|
125
|
+
parsed.protocol === "https:" &&
|
|
126
|
+
parsed.hostname === "pbs.twimg.com" &&
|
|
127
|
+
(parsed.pathname.startsWith("/media/") ||
|
|
128
|
+
parsed.pathname.startsWith("/amplify_video_thumb/"))
|
|
129
|
+
) {
|
|
130
|
+
return parsed.toString();
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
120
135
|
}
|
|
121
136
|
}
|
|
122
137
|
|
|
@@ -127,22 +142,25 @@ export function LinkPreviewCard({
|
|
|
127
142
|
entry: TweetUrlEntity;
|
|
128
143
|
index: number;
|
|
129
144
|
}) {
|
|
130
|
-
const targetUrl = entry.expandedUrl || entry.url;
|
|
131
|
-
const
|
|
132
|
-
const
|
|
145
|
+
const targetUrl = safeHttpUrl(entry.expandedUrl || entry.url);
|
|
146
|
+
const previewUrl = targetUrl ?? "";
|
|
147
|
+
const displayUrl =
|
|
148
|
+
entry.displayUrl || (targetUrl ? displayHost(targetUrl, targetUrl) : "");
|
|
149
|
+
const directImageUrl =
|
|
150
|
+
targetUrl && isDirectImageUrl(targetUrl) ? targetUrl : null;
|
|
133
151
|
const initialPreview = useMemo<LinkPreviewState>(
|
|
134
152
|
() => ({
|
|
135
|
-
expandedUrl:
|
|
153
|
+
expandedUrl: previewUrl,
|
|
136
154
|
displayUrl,
|
|
137
155
|
title:
|
|
138
156
|
entry.title ??
|
|
139
|
-
(directImageUrl ? displayHost(
|
|
157
|
+
(directImageUrl ? displayHost(previewUrl, displayUrl) : undefined),
|
|
140
158
|
description:
|
|
141
159
|
entry.description ?? (directImageUrl ? displayUrl : undefined),
|
|
142
160
|
imageUrl: entry.imageUrl ?? directImageUrl,
|
|
143
161
|
siteName:
|
|
144
162
|
entry.siteName ??
|
|
145
|
-
(directImageUrl ? displayHost(
|
|
163
|
+
(directImageUrl ? displayHost(previewUrl, displayUrl) : undefined),
|
|
146
164
|
}),
|
|
147
165
|
[
|
|
148
166
|
directImageUrl,
|
|
@@ -151,7 +169,7 @@ export function LinkPreviewCard({
|
|
|
151
169
|
entry.imageUrl,
|
|
152
170
|
entry.siteName,
|
|
153
171
|
entry.title,
|
|
154
|
-
|
|
172
|
+
previewUrl,
|
|
155
173
|
],
|
|
156
174
|
);
|
|
157
175
|
const [preview, setPreview] = useState(initialPreview);
|
|
@@ -199,7 +217,7 @@ export function LinkPreviewCard({
|
|
|
199
217
|
void fetchPreview(entry).then((metadata) => {
|
|
200
218
|
if (cancelled || !metadata) return;
|
|
201
219
|
setPreview((current) => ({
|
|
202
|
-
expandedUrl: metadata.url
|
|
220
|
+
expandedUrl: safeHttpUrl(metadata.url) ?? current.expandedUrl,
|
|
203
221
|
displayUrl: current.displayUrl,
|
|
204
222
|
title: metadata.title ?? current.title,
|
|
205
223
|
description: metadata.description ?? current.description,
|
|
@@ -214,6 +232,8 @@ export function LinkPreviewCard({
|
|
|
214
232
|
};
|
|
215
233
|
}, [cacheKey, canHydrate, entry, hydratedKey, preview, targetUrl]);
|
|
216
234
|
|
|
235
|
+
if (!targetUrl) return null;
|
|
236
|
+
|
|
217
237
|
const title = preview.title || entry.displayUrl;
|
|
218
238
|
const description =
|
|
219
239
|
preview.description && preview.description !== title
|
|
@@ -221,14 +241,16 @@ export function LinkPreviewCard({
|
|
|
221
241
|
: preview.siteName || displayHost(preview.expandedUrl, entry.displayUrl);
|
|
222
242
|
const host =
|
|
223
243
|
preview.siteName || displayHost(preview.expandedUrl, entry.displayUrl);
|
|
224
|
-
const
|
|
244
|
+
const imageUrl = safePreviewImageUrl(preview.imageUrl);
|
|
245
|
+
const showImage = Boolean(imageUrl && !imageFailed);
|
|
246
|
+
const previewHref = safeHttpUrl(preview.expandedUrl) ?? targetUrl;
|
|
225
247
|
|
|
226
248
|
return (
|
|
227
249
|
<a
|
|
228
250
|
key={`${entry.expandedUrl}-${String(index)}`}
|
|
229
251
|
className={linkPreviewCardClass}
|
|
230
252
|
data-perf="link-preview-card"
|
|
231
|
-
href={
|
|
253
|
+
href={previewHref}
|
|
232
254
|
ref={cardRef}
|
|
233
255
|
rel="noreferrer"
|
|
234
256
|
target="_blank"
|
|
@@ -255,7 +277,7 @@ export function LinkPreviewCard({
|
|
|
255
277
|
className="size-full object-cover transition-transform duration-200 group-hover/link-preview:scale-[1.03]"
|
|
256
278
|
loading="lazy"
|
|
257
279
|
onError={() => setImageFailed(true)}
|
|
258
|
-
src={
|
|
280
|
+
src={imageUrl ?? ""}
|
|
259
281
|
/>
|
|
260
282
|
) : (
|
|
261
283
|
<ImageIcon
|