birdclaw 0.4.1 → 0.5.1

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 (85) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +113 -7
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +30 -28
  5. package/playwright.config.ts +1 -0
  6. package/public/birdclaw-mark.png +0 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/logo192.png +0 -0
  9. package/public/logo512.png +0 -0
  10. package/public/manifest.json +2 -2
  11. package/scripts/browser-perf.mjs +399 -0
  12. package/scripts/build-docs-site.mjs +940 -0
  13. package/scripts/docs-site-assets.mjs +311 -0
  14. package/scripts/run-vitest.mjs +21 -0
  15. package/scripts/sanitize-node-options.mjs +23 -0
  16. package/scripts/start-test-server.mjs +29 -0
  17. package/src/cli.ts +496 -19
  18. package/src/components/AppNav.tsx +66 -29
  19. package/src/components/AvatarChip.tsx +10 -5
  20. package/src/components/BrandMark.tsx +67 -0
  21. package/src/components/ConversationThread.tsx +126 -0
  22. package/src/components/DmWorkspace.tsx +118 -105
  23. package/src/components/EmbeddedTweetCard.tsx +20 -14
  24. package/src/components/FeedState.tsx +147 -0
  25. package/src/components/InboxCard.tsx +104 -90
  26. package/src/components/LinkPreviewCard.tsx +270 -0
  27. package/src/components/ProfilePreview.tsx +8 -3
  28. package/src/components/SavedTimelineView.tsx +89 -71
  29. package/src/components/SyncNowButton.tsx +105 -0
  30. package/src/components/ThemeSlider.tsx +10 -59
  31. package/src/components/TimelineCard.tsx +326 -86
  32. package/src/components/TimelineRouteFrame.tsx +156 -0
  33. package/src/components/TweetMediaGrid.tsx +120 -23
  34. package/src/components/TweetRichText.tsx +19 -4
  35. package/src/components/useTimelineRouteData.ts +137 -0
  36. package/src/lib/api-client.ts +229 -0
  37. package/src/lib/archive-finder.ts +24 -20
  38. package/src/lib/archive-import.ts +1582 -67
  39. package/src/lib/authored-live.ts +1074 -0
  40. package/src/lib/backup.ts +316 -14
  41. package/src/lib/bird-actions.ts +1 -10
  42. package/src/lib/bird-command.ts +57 -0
  43. package/src/lib/bird.ts +89 -5
  44. package/src/lib/config.ts +1 -1
  45. package/src/lib/conversation-surface.ts +174 -0
  46. package/src/lib/db.ts +193 -4
  47. package/src/lib/follow-graph.ts +1053 -0
  48. package/src/lib/link-index.ts +11 -98
  49. package/src/lib/link-insights.ts +834 -0
  50. package/src/lib/link-preview-metadata.ts +334 -0
  51. package/src/lib/media-fetch.ts +787 -0
  52. package/src/lib/media-includes.ts +165 -0
  53. package/src/lib/mention-threads-live.ts +535 -43
  54. package/src/lib/mentions-live.ts +623 -19
  55. package/src/lib/moderation-target.ts +6 -0
  56. package/src/lib/profile-hydration.ts +1 -1
  57. package/src/lib/profile-resolver.ts +115 -1
  58. package/src/lib/queries.ts +326 -35
  59. package/src/lib/seed.ts +127 -8
  60. package/src/lib/timeline-collections-live.ts +145 -22
  61. package/src/lib/timeline-live.ts +10 -15
  62. package/src/lib/tweet-account-edges.ts +9 -2
  63. package/src/lib/tweet-render.ts +97 -0
  64. package/src/lib/types.ts +185 -2
  65. package/src/lib/ui.ts +383 -147
  66. package/src/lib/url-expansion-store.ts +110 -0
  67. package/src/lib/url-expansion.ts +20 -3
  68. package/src/lib/web-sync.ts +443 -0
  69. package/src/lib/x-profile.ts +7 -2
  70. package/src/lib/xurl.ts +296 -12
  71. package/src/routeTree.gen.ts +126 -0
  72. package/src/routes/__root.tsx +7 -3
  73. package/src/routes/api/conversation.tsx +34 -0
  74. package/src/routes/api/link-insights.tsx +79 -0
  75. package/src/routes/api/link-preview.tsx +43 -0
  76. package/src/routes/api/profile-hydrate.tsx +51 -0
  77. package/src/routes/api/sync.tsx +59 -0
  78. package/src/routes/blocks.tsx +111 -86
  79. package/src/routes/dms.tsx +172 -87
  80. package/src/routes/inbox.tsx +98 -86
  81. package/src/routes/index.tsx +22 -115
  82. package/src/routes/links.tsx +928 -0
  83. package/src/routes/mentions.tsx +22 -115
  84. package/src/styles.css +169 -43
  85. package/vite.config.ts +8 -0
@@ -9,6 +9,7 @@ import {
9
9
  profilePreviewHandleClass,
10
10
  profilePreviewHeaderClass,
11
11
  profilePreviewMetaClass,
12
+ profilePreviewNameClass,
12
13
  profilePreviewTriggerClass,
13
14
  } from "#/lib/ui";
14
15
  import { AvatarChip } from "./AvatarChip";
@@ -40,12 +41,16 @@ export function ProfilePreview({
40
41
  name={profile.displayName}
41
42
  profileId={profile.id}
42
43
  />
43
- <span>
44
- <strong>{profile.displayName}</strong>
44
+ <span className="flex min-w-0 flex-col">
45
+ <span className={profilePreviewNameClass}>
46
+ {profile.displayName}
47
+ </span>
45
48
  <span className={profilePreviewHandleClass}>@{profile.handle}</span>
46
49
  </span>
47
50
  </span>
48
- <span className={profilePreviewBioClass}>{profile.bio}</span>
51
+ {profile.bio ? (
52
+ <span className={profilePreviewBioClass}>{profile.bio}</span>
53
+ ) : null}
49
54
  <span className={profilePreviewMetaClass}>
50
55
  {formatCompactNumber(profile.followersCount)} followers
51
56
  </span>
@@ -1,18 +1,24 @@
1
- import { useEffect, useMemo, useState } from "react";
1
+ import { Search } from "lucide-react";
2
+ import { useMemo, useState } from "react";
3
+ import {
4
+ FeedEmpty,
5
+ FeedError,
6
+ FeedLoading,
7
+ TweetSkeletonRows,
8
+ } from "#/components/FeedState";
9
+ import { SyncNowButton } from "#/components/SyncNowButton";
2
10
  import { TimelineCard } from "#/components/TimelineCard";
3
- import type { QueryEnvelope, QueryResponse, TimelineItem } from "#/lib/types";
11
+ import { useTimelineRouteData } from "#/components/useTimelineRouteData";
12
+ import { ConversationSurfaceScope } from "#/lib/conversation-surface";
4
13
  import {
5
- cx,
6
- eyebrowClass,
7
- feedPageClass,
8
- heroControlsClass,
9
- heroCopyClass,
10
- heroShellClass,
11
- heroTitleClass,
12
- pageWrapClass,
13
- textFieldClass,
14
- textFieldWideClass,
15
- timelineLaneClass,
14
+ feedClass,
15
+ pageHeaderClass,
16
+ pageHeaderRowClass,
17
+ pageSubtitleClass,
18
+ pageTitleClass,
19
+ searchFieldIconClass,
20
+ searchFieldInputClass,
21
+ searchFieldShellClass,
16
22
  } from "#/lib/ui";
17
23
 
18
24
  interface SavedTimelineViewProps {
@@ -23,83 +29,95 @@ interface SavedTimelineViewProps {
23
29
  searchPlaceholder: string;
24
30
  }
25
31
 
32
+ const TITLES: Record<SavedTimelineViewProps["filter"], string> = {
33
+ liked: "Likes",
34
+ bookmarked: "Bookmarks",
35
+ };
36
+
26
37
  export function SavedTimelineView({
27
38
  filter,
28
- eyebrow,
29
39
  title,
30
40
  loadingLabel,
31
41
  searchPlaceholder,
32
42
  }: SavedTimelineViewProps) {
33
- const [meta, setMeta] = useState<QueryEnvelope | null>(null);
34
- const [items, setItems] = useState<TimelineItem[]>([]);
35
43
  const [search, setSearch] = useState("");
36
- const [refreshTick, setRefreshTick] = useState(0);
37
-
38
- useEffect(() => {
39
- fetch("/api/status")
40
- .then((response) => response.json())
41
- .then((data: QueryEnvelope) => setMeta(data));
42
- }, []);
43
-
44
- useEffect(() => {
45
- const url = new URL("/api/query", window.location.origin);
46
- url.searchParams.set("resource", "home");
47
- url.searchParams.set(filter, "true");
48
- url.searchParams.set("refresh", String(refreshTick));
49
- if (search.trim()) {
50
- url.searchParams.set("search", search.trim());
51
- }
52
-
53
- fetch(url)
54
- .then((response) => response.json())
55
- .then((data: QueryResponse) => setItems(data.items as TimelineItem[]));
56
- }, [filter, refreshTick, search]);
44
+ const { meta, items, loading, error, retry, refreshLocalView, replyToTweet } =
45
+ useTimelineRouteData({
46
+ resource: "home",
47
+ search,
48
+ errorFallback: `${TITLES[filter]} unavailable`,
49
+ likedOnly: filter === "liked",
50
+ bookmarkedOnly: filter === "bookmarked",
51
+ });
57
52
 
58
53
  const subtitle = useMemo(() => {
59
54
  if (!meta) {
60
- return items.length > 0 ? `${items.length} visible` : loadingLabel;
55
+ return items.length > 0
56
+ ? `${String(items.length)} visible`
57
+ : loadingLabel;
61
58
  }
62
- return `${items.length} visible · ${meta.transport.statusText}`;
59
+ return `${String(items.length)} visible · ${meta.transport.statusText}`;
63
60
  }, [items.length, loadingLabel, meta]);
64
61
 
65
- async function replyToTweet(tweetId: string) {
66
- const text = window.prompt("Reply text");
67
- if (!text?.trim()) return;
68
-
69
- await fetch("/api/action", {
70
- method: "POST",
71
- headers: { "content-type": "application/json" },
72
- body: JSON.stringify({
73
- kind: "replyTweet",
74
- accountId: "acct_primary",
75
- tweetId,
76
- text,
77
- }),
78
- });
79
-
80
- setRefreshTick((value) => value + 1);
81
- }
62
+ const syncKind = filter === "liked" ? "likes" : "bookmarks";
82
63
 
83
64
  return (
84
- <main className={pageWrapClass}>
85
- <div className={feedPageClass}>
86
- <section className={heroShellClass}>
87
- <div>
88
- <p className={eyebrowClass}>{eyebrow}</p>
89
- <h2 className={heroTitleClass}>{title}</h2>
90
- <p className={heroCopyClass}>{subtitle}</p>
65
+ <>
66
+ <header className={pageHeaderClass}>
67
+ <div className={pageHeaderRowClass}>
68
+ <div className="flex min-w-0 flex-col">
69
+ <h1 className={pageTitleClass}>{TITLES[filter]}</h1>
70
+ <p className={pageSubtitleClass}>{title}</p>
71
+ <p className={pageSubtitleClass}>{subtitle}</p>
91
72
  </div>
92
- <div className={heroControlsClass}>
73
+ <SyncNowButton
74
+ accounts={meta?.accounts}
75
+ kind={syncKind}
76
+ label={filter === "liked" ? "Sync likes" : "Sync bookmarks"}
77
+ onSynced={refreshLocalView}
78
+ />
79
+ </div>
80
+ <div className="px-4 pb-3">
81
+ <label className={searchFieldShellClass}>
82
+ <Search className={searchFieldIconClass} strokeWidth={2} />
93
83
  <input
94
- className={cx(textFieldClass, textFieldWideClass)}
84
+ className={searchFieldInputClass}
95
85
  onChange={(event) => setSearch(event.target.value)}
96
86
  placeholder={searchPlaceholder}
97
87
  value={search}
98
88
  />
99
- </div>
100
- </section>
101
-
102
- <section className={timelineLaneClass}>
89
+ </label>
90
+ </div>
91
+ </header>
92
+ <ConversationSurfaceScope>
93
+ <section className={feedClass}>
94
+ {loading ? (
95
+ <FeedLoading
96
+ detail={`Reading local ${TITLES[filter].toLowerCase()}`}
97
+ label={loadingLabel}
98
+ >
99
+ <TweetSkeletonRows />
100
+ </FeedLoading>
101
+ ) : error ? (
102
+ <FeedError
103
+ action={
104
+ <button
105
+ className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
106
+ onClick={retry}
107
+ type="button"
108
+ >
109
+ Retry
110
+ </button>
111
+ }
112
+ message={error}
113
+ title={`Could not load ${TITLES[filter].toLowerCase()}`}
114
+ />
115
+ ) : items.length === 0 ? (
116
+ <FeedEmpty
117
+ detail="Sync this collection or broaden the search."
118
+ label="Nothing saved here yet"
119
+ />
120
+ ) : null}
103
121
  {items.map((item) => (
104
122
  <TimelineCard
105
123
  key={item.id}
@@ -109,7 +127,7 @@ export function SavedTimelineView({
109
127
  />
110
128
  ))}
111
129
  </section>
112
- </div>
113
- </main>
130
+ </ConversationSurfaceScope>
131
+ </>
114
132
  );
115
133
  }
@@ -0,0 +1,105 @@
1
+ import { RefreshCw } from "lucide-react";
2
+ import { useEffect, 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 { WebSyncKind, WebSyncResponse } from "#/lib/web-sync";
7
+
8
+ interface SyncNowButtonProps {
9
+ kind: WebSyncKind;
10
+ label: string;
11
+ accounts?: AccountRecord[];
12
+ onSynced: (result: WebSyncResponse) => void;
13
+ }
14
+
15
+ export function SyncNowButton({
16
+ kind,
17
+ label,
18
+ accounts = [],
19
+ onSynced,
20
+ }: SyncNowButtonProps) {
21
+ const [syncing, setSyncing] = useState(false);
22
+ const [message, setMessage] = useState<string | null>(null);
23
+ const [error, setError] = useState<string | null>(null);
24
+ const [selectedAccountId, setSelectedAccountId] = useState<
25
+ string | undefined
26
+ >();
27
+ const defaultAccountId = useMemo(
28
+ () => accounts.find((account) => account.isDefault)?.id ?? accounts[0]?.id,
29
+ [accounts],
30
+ );
31
+ const accountId = selectedAccountId ?? defaultAccountId;
32
+
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]);
42
+
43
+ async function syncNow() {
44
+ setSyncing(true);
45
+ setError(null);
46
+ setMessage(null);
47
+ try {
48
+ const data = await postSync(kind, accountId);
49
+ if (!data.ok) throw new Error(data.summary);
50
+ setMessage(data.summary);
51
+ onSynced(data);
52
+ } catch (syncError) {
53
+ setError(syncError instanceof Error ? syncError.message : "Sync failed");
54
+ } finally {
55
+ setSyncing(false);
56
+ }
57
+ }
58
+
59
+ return (
60
+ <div className="flex shrink-0 items-center gap-2">
61
+ {accounts.length > 1 ? (
62
+ <select
63
+ aria-label="Sync account"
64
+ className={cx(selectFieldClass, "h-9 w-[132px]")}
65
+ disabled={syncing}
66
+ onChange={(event) => setSelectedAccountId(event.target.value)}
67
+ value={accountId ?? ""}
68
+ >
69
+ {accounts.map((account) => (
70
+ <option key={account.id} value={account.id}>
71
+ {account.handle}
72
+ </option>
73
+ ))}
74
+ </select>
75
+ ) : null}
76
+ <button
77
+ type="button"
78
+ 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",
80
+ syncing && "text-[var(--ink-soft)]",
81
+ )}
82
+ aria-label={syncing ? `${label}: syncing` : label}
83
+ disabled={syncing}
84
+ onClick={syncNow}
85
+ >
86
+ <RefreshCw
87
+ className={cx("size-4", syncing && "animate-spin")}
88
+ strokeWidth={2}
89
+ />
90
+ <span className="hidden sm:inline">
91
+ {syncing ? "Syncing..." : label}
92
+ </span>
93
+ </button>
94
+ <span
95
+ className={cx(
96
+ "hidden max-w-[190px] truncate text-[12px] sm:inline",
97
+ error ? "text-[var(--alert)]" : "text-[var(--ink-soft)]",
98
+ )}
99
+ role="status"
100
+ >
101
+ {error ?? message ?? ""}
102
+ </span>
103
+ </div>
104
+ );
105
+ }
@@ -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,67 +17,18 @@ 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`;
30
- }
31
-
32
20
  export function ThemeSlider() {
33
- const { isReady, theme, resolvedTheme, setTheme } = useTheme();
34
-
35
- const activeIndex = useMemo(() => {
36
- const index = THEME_OPTIONS.findIndex((option) => option.key === theme);
37
- return index === -1 ? 0 : index;
38
- }, [theme]);
39
-
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
- );
21
+ const { isReady, theme, setTheme } = useTheme();
61
22
 
62
23
  return (
63
24
  <fieldset
64
- className="theme-slider-shell m-0 border-0 p-0"
25
+ className="theme-slider-shell m-0 flex justify-center border-0 px-2 py-1 min-[1100px]:justify-start min-[1100px]:px-3"
65
26
  aria-label="Theme selector"
66
27
  >
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}
70
- >
71
- <div
72
- 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)]",
76
- )}
77
- style={indicatorStyle}
78
- />
28
+ <div className="theme-slider flex flex-col items-center gap-1.5 min-[1100px]:flex-row">
79
29
  {THEME_OPTIONS.map((option, index) => {
80
30
  const Icon = option.icon;
81
- const isActive = index === activeIndex;
31
+ const isActive = option.key === theme || (index === 0 && !theme);
82
32
 
83
33
  const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
84
34
  if (isActive) return;
@@ -102,8 +52,9 @@ export function ThemeSlider() {
102
52
  key={option.key}
103
53
  type="button"
104
54
  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)]",
55
+ "theme-slider-button inline-flex size-9 items-center justify-center rounded-full border-0 bg-transparent text-[var(--ink-soft)] transition-[background,color,transform] duration-150 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)]",
56
+ isActive &&
57
+ "theme-slider-button-active bg-[var(--bg-active)] text-[var(--ink)] shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--line)_72%,transparent)]",
107
58
  )}
108
59
  onClick={handleClick}
109
60
  aria-label={option.label}
@@ -112,8 +63,8 @@ export function ThemeSlider() {
112
63
  disabled={!isReady}
113
64
  >
114
65
  <Icon
115
- className="theme-slider-icon size-[15px]"
116
- strokeWidth={1.8}
66
+ className="theme-slider-icon size-[17px]"
67
+ strokeWidth={isActive ? 2.1 : 1.8}
117
68
  />
118
69
  </button>
119
70
  );