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
@@ -1,125 +1,32 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
- import { useEffect, useMemo, useState } from "react";
3
- import { TimelineCard } from "#/components/TimelineCard";
4
- import type {
5
- QueryEnvelope,
6
- QueryResponse,
7
- ReplyFilter,
8
- TimelineItem,
9
- } from "#/lib/types";
10
- import {
11
- cx,
12
- eyebrowClass,
13
- feedPageClass,
14
- heroControlsClass,
15
- heroCopyClass,
16
- heroShellClass,
17
- heroTitleClass,
18
- pageWrapClass,
19
- segmentActiveClass,
20
- segmentClass,
21
- segmentedClass,
22
- textFieldClass,
23
- textFieldWideClass,
24
- timelineLaneClass,
25
- } from "#/lib/ui";
2
+ import { TimelineRouteFrame } from "#/components/TimelineRouteFrame";
3
+ import type { QueryEnvelope } from "#/lib/types";
26
4
 
27
5
  export const Route = createFileRoute("/mentions")({
28
6
  component: MentionsRoute,
29
7
  });
30
8
 
31
- function MentionsRoute() {
32
- const [meta, setMeta] = useState<QueryEnvelope | null>(null);
33
- const [items, setItems] = useState<TimelineItem[]>([]);
34
- const [replyFilter, setReplyFilter] = useState<ReplyFilter>("unreplied");
35
- 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", "mentions");
47
- url.searchParams.set("replyFilter", replyFilter);
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
- }, [refreshTick, replyFilter, search]);
57
-
58
- const subtitle = useMemo(() => {
59
- if (!meta) return "Loading mentions...";
60
- return `${meta.stats.mentions} mention/reply items in local store`;
61
- }, [meta]);
62
-
63
- async function replyToTweet(tweetId: string) {
64
- const text = window.prompt("Reply text");
65
- if (!text?.trim()) return;
66
-
67
- await fetch("/api/action", {
68
- method: "POST",
69
- headers: { "content-type": "application/json" },
70
- body: JSON.stringify({
71
- kind: "replyTweet",
72
- accountId: "acct_primary",
73
- tweetId,
74
- text,
75
- }),
76
- });
77
-
78
- setRefreshTick((value) => value + 1);
79
- }
9
+ function mentionsSubtitle(meta: QueryEnvelope | null) {
10
+ if (!meta) return "Loading mentions...";
11
+ return `${String(meta.stats.mentions)} mention/reply items in local store`;
12
+ }
80
13
 
14
+ function MentionsRoute() {
81
15
  return (
82
- <main className={pageWrapClass}>
83
- <div className={feedPageClass}>
84
- <section className={heroShellClass}>
85
- <div>
86
- <p className={eyebrowClass}>mentions and replies</p>
87
- <h2 className={heroTitleClass}>
88
- Keep the actionable queue small and visible.
89
- </h2>
90
- <p className={heroCopyClass}>{subtitle}</p>
91
- </div>
92
- <div className={heroControlsClass}>
93
- <input
94
- className={cx(textFieldClass, textFieldWideClass)}
95
- onChange={(event) => setSearch(event.target.value)}
96
- placeholder="Search mentions"
97
- value={search}
98
- />
99
- <div className={segmentedClass}>
100
- {(["all", "replied", "unreplied"] as const).map((value) => (
101
- <button
102
- key={value}
103
- className={cx(
104
- segmentClass,
105
- value === replyFilter && segmentActiveClass,
106
- )}
107
- onClick={() => setReplyFilter(value)}
108
- type="button"
109
- >
110
- {value}
111
- </button>
112
- ))}
113
- </div>
114
- </div>
115
- </section>
116
-
117
- <section className={timelineLaneClass}>
118
- {items.map((item) => (
119
- <TimelineCard key={item.id} item={item} onReply={replyToTweet} />
120
- ))}
121
- </section>
122
- </div>
123
- </main>
16
+ <TimelineRouteFrame
17
+ emptyDetail="Try All, search less narrowly, or sync mentions."
18
+ emptyLabel="No mentions in this view"
19
+ errorFallback="Mentions unavailable"
20
+ errorTitle="Could not load mentions"
21
+ initialReplyFilter="unreplied"
22
+ loadingDetail="Checking local mentions and reply context"
23
+ loadingLabel="Loading mentions"
24
+ resource="mentions"
25
+ searchPlaceholder="Search mentions"
26
+ subtitle={mentionsSubtitle}
27
+ syncKind="mentions"
28
+ syncLabel="Sync mentions"
29
+ title="Mentions"
30
+ />
124
31
  );
125
32
  }
package/src/styles.css CHANGED
@@ -1,42 +1,70 @@
1
- @import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Instrument+Sans:wght@400;500;600;700&display=swap");
2
1
  @import "tailwindcss";
3
2
 
4
3
  @theme inline {
5
- --font-sans: "Instrument Sans", sans-serif;
6
- --font-display: "Fraunces", serif;
4
+ --font-sans:
5
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
6
+ Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif;
7
+ --font-display: var(--font-sans);
8
+ --font-mono:
9
+ ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
7
10
  }
8
11
 
9
12
  :root {
10
- --bg: #f5f0e8;
11
- --panel: rgb(255 252 247 / 92%);
12
- --panel-strong: rgb(255 255 255 / 82%);
13
- --line: rgb(32 24 17 / 12%);
14
- --line-strong: rgb(32 24 17 / 24%);
15
- --ink: #19130e;
16
- --ink-soft: rgb(25 19 14 / 68%);
17
- --accent: #1d6f53;
18
- --accent-soft: rgb(29 111 83 / 12%);
19
- --alert: #a34a2d;
20
- --alert-soft: rgb(163 74 45 / 14%);
21
- --shadow: rgb(32 24 17 / 8%);
13
+ --bg: #ffffff;
14
+ --bg-elevated: #ffffff;
15
+ --bg-hover: #f7f9f9;
16
+ --bg-active: #eff3f4;
17
+ --panel: #ffffff;
18
+ --panel-strong: #ffffff;
19
+ --line: #eff3f4;
20
+ --line-strong: #cfd9de;
21
+ --ink: #0f1419;
22
+ --ink-soft: #536471;
23
+ --ink-mute: #8b98a5;
24
+ --accent: #1d9bf0;
25
+ --accent-hover: #1a8cd8;
26
+ --accent-press: #177cc1;
27
+ --accent-soft: rgb(29 155 240 / 10%);
28
+ --accent-text: #ffffff;
29
+ --alert: #f4212e;
30
+ --alert-soft: rgb(244 33 46 / 10%);
31
+ --like: #f91880;
32
+ --like-soft: rgb(249 24 128 / 10%);
33
+ --accent-on-ink: #1d9bf0;
34
+ --ring: rgb(29 155 240 / 35%);
35
+ --shadow: rgb(15 20 25 / 6%);
36
+ --shadow-strong: rgb(15 20 25 / 12%);
37
+ --brand-shadow: rgb(29 155 240 / 22%);
22
38
  --theme-switch-x: 50%;
23
39
  --theme-switch-y: 50%;
24
40
  color-scheme: light;
25
41
  }
26
42
 
27
43
  [data-theme="dark"] {
28
- --bg: #151311;
29
- --panel: rgb(29 24 19 / 88%);
30
- --panel-strong: rgb(39 32 27 / 94%);
31
- --line: rgb(245 232 214 / 10%);
32
- --line-strong: rgb(245 232 214 / 18%);
33
- --ink: #f4ede3;
34
- --ink-soft: rgb(244 237 227 / 68%);
35
- --accent: #68c4a0;
36
- --accent-soft: rgb(104 196 160 / 14%);
37
- --alert: #d98a69;
38
- --alert-soft: rgb(217 138 105 / 16%);
39
- --shadow: rgb(0 0 0 / 34%);
44
+ --bg: #0b0f14;
45
+ --bg-elevated: #10151b;
46
+ --bg-hover: #141a21;
47
+ --bg-active: #18212a;
48
+ --panel: #0b0f14;
49
+ --panel-strong: #121820;
50
+ --line: #26323d;
51
+ --line-strong: #44515d;
52
+ --ink: #d8dee5;
53
+ --ink-soft: #8b98a5;
54
+ --ink-mute: #687480;
55
+ --accent: #1d9bf0;
56
+ --accent-hover: #1a8cd8;
57
+ --accent-press: #177cc1;
58
+ --accent-soft: rgb(29 155 240 / 18%);
59
+ --accent-text: #ffffff;
60
+ --alert: #f4212e;
61
+ --alert-soft: rgb(244 33 46 / 18%);
62
+ --like: #f91880;
63
+ --like-soft: rgb(249 24 128 / 18%);
64
+ --ring: rgb(29 155 240 / 45%);
65
+ --shadow: rgb(0 0 0 / 60%);
66
+ --shadow-strong: rgb(0 0 0 / 80%);
67
+ --brand-shadow: rgb(29 155 240 / 36%);
40
68
  color-scheme: dark;
41
69
  }
42
70
 
@@ -45,23 +73,60 @@ body {
45
73
  min-height: 100%;
46
74
  }
47
75
 
76
+ html {
77
+ scrollbar-gutter: stable;
78
+ }
79
+
48
80
  body {
49
- background:
50
- radial-gradient(
51
- circle at top left,
52
- color-mix(in srgb, var(--accent) 16%, transparent),
53
- transparent 28%
54
- ),
55
- radial-gradient(
56
- circle at right 18%,
57
- color-mix(in srgb, var(--alert) 12%, transparent),
58
- transparent 24%
59
- ),
60
- linear-gradient(
61
- 180deg,
62
- color-mix(in srgb, var(--bg) 82%, white) 0%,
63
- var(--bg) 100%
64
- );
81
+ background: var(--bg);
82
+ font-feature-settings: "ss01" on, "cv11" on;
83
+ }
84
+
85
+ ::selection {
86
+ background: color-mix(in srgb, var(--accent) 28%, transparent);
87
+ color: var(--ink);
88
+ }
89
+
90
+ * {
91
+ -webkit-tap-highlight-color: transparent;
92
+ }
93
+
94
+ *:focus-visible {
95
+ outline: 2px solid var(--ring);
96
+ outline-offset: 2px;
97
+ border-radius: 4px;
98
+ }
99
+
100
+ button,
101
+ a {
102
+ -webkit-user-select: none;
103
+ user-select: none;
104
+ }
105
+
106
+ input,
107
+ textarea {
108
+ -webkit-user-select: text;
109
+ user-select: text;
110
+ }
111
+
112
+ /* Subtle thin scrollbar for app-owned internal scroll panes. */
113
+ .custom-scrollbar::-webkit-scrollbar {
114
+ width: 10px;
115
+ height: 10px;
116
+ }
117
+
118
+ .custom-scrollbar::-webkit-scrollbar-track {
119
+ background: transparent;
120
+ }
121
+
122
+ .custom-scrollbar::-webkit-scrollbar-thumb {
123
+ background: color-mix(in srgb, var(--ink-soft) 30%, transparent);
124
+ border-radius: 999px;
125
+ border: 2px solid var(--bg);
126
+ }
127
+
128
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover {
129
+ background: color-mix(in srgb, var(--ink-soft) 55%, transparent);
65
130
  }
66
131
 
67
132
  @keyframes theme-circle-transition {
@@ -78,6 +143,62 @@ body {
78
143
  }
79
144
  }
80
145
 
146
+ @keyframes birdclaw-load-sway {
147
+ 0%,
148
+ 100% {
149
+ transform: translateY(0) rotate(-2deg) scale(1);
150
+ }
151
+
152
+ 40% {
153
+ transform: translateY(-5px) rotate(3deg) scale(1.04);
154
+ }
155
+
156
+ 70% {
157
+ transform: translateY(1px) rotate(-1deg) scale(0.99);
158
+ }
159
+ }
160
+
161
+ @keyframes birdclaw-load-glow {
162
+ 0%,
163
+ 100% {
164
+ opacity: 0.24;
165
+ transform: scale(0.82);
166
+ }
167
+
168
+ 50% {
169
+ opacity: 0.46;
170
+ transform: scale(1.08);
171
+ }
172
+ }
173
+
174
+ .birdclaw-state {
175
+ display: flex;
176
+ flex-direction: column;
177
+ align-items: center;
178
+ justify-content: center;
179
+ min-height: 168px;
180
+ }
181
+
182
+ .birdclaw-mark::before {
183
+ content: "";
184
+ position: absolute;
185
+ inset: 16%;
186
+ z-index: -1;
187
+ border-radius: 999px;
188
+ background: var(--accent-soft);
189
+ filter: blur(14px);
190
+ opacity: 0.34;
191
+ }
192
+
193
+ .birdclaw-mark-animated {
194
+ animation: birdclaw-load-sway 1.7s ease-in-out infinite;
195
+ transform-origin: 50% 56%;
196
+ }
197
+
198
+ .birdclaw-mark-animated::before {
199
+ animation: birdclaw-load-glow 1.7s ease-in-out infinite;
200
+ }
201
+
81
202
  html.theme-transition {
82
203
  view-transition-name: theme;
83
204
  }
@@ -106,4 +227,9 @@ html.theme-transition::view-transition-new(theme) {
106
227
  html.theme-transition::view-transition-new(theme) {
107
228
  animation: none !important;
108
229
  }
230
+
231
+ .birdclaw-mark-animated,
232
+ .birdclaw-mark-animated::before {
233
+ animation: none !important;
234
+ }
109
235
  }
package/vite.config.ts CHANGED
@@ -4,6 +4,11 @@ import { tanstackStart } from "@tanstack/react-start/plugin/vite";
4
4
  import viteReact from "@vitejs/plugin-react";
5
5
  import { defineConfig } from "vite";
6
6
 
7
+ const extraAllowedHosts =
8
+ process.env.BIRDCLAW_ALLOWED_HOSTS?.split(",")
9
+ .map((host) => host.trim())
10
+ .filter(Boolean) ?? [];
11
+
7
12
  const config = defineConfig({
8
13
  plugins: [
9
14
  devtools(),
@@ -18,6 +23,9 @@ const config = defineConfig({
18
23
  resolve: {
19
24
  tsconfigPaths: true,
20
25
  },
26
+ server: {
27
+ allowedHosts: ["clawmac.sheep-coho.ts.net", ...extraAllowedHosts],
28
+ },
21
29
  });
22
30
 
23
31
  export default config;