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,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 { ExternalLink, MessageCircle } from "lucide-react";
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>{item.needsReply ? "needs reply" : "resolved"}</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 IMAGE_EXTENSION_PATTERN.test(url);
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 displayUrl = entry.displayUrl || displayHost(targetUrl, targetUrl);
132
- const directImageUrl = isDirectImageUrl(targetUrl) ? targetUrl : null;
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: targetUrl,
153
+ expandedUrl: previewUrl,
136
154
  displayUrl,
137
155
  title:
138
156
  entry.title ??
139
- (directImageUrl ? displayHost(targetUrl, displayUrl) : undefined),
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(targetUrl, displayUrl) : undefined),
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
- targetUrl,
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 || current.expandedUrl,
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 showImage = Boolean(preview.imageUrl && !imageFailed);
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={preview.expandedUrl}
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={preview.imageUrl ?? ""}
280
+ src={imageUrl ?? ""}
259
281
  />
260
282
  ) : (
261
283
  <ImageIcon