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.
- package/CHANGELOG.md +52 -0
- package/README.md +113 -7
- package/bin/birdclaw.mjs +50 -11
- package/package.json +30 -28
- package/playwright.config.ts +1 -0
- package/public/birdclaw-mark.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +2 -2
- package/scripts/browser-perf.mjs +399 -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 +29 -0
- package/src/cli.ts +496 -19
- package/src/components/AppNav.tsx +66 -29
- package/src/components/AvatarChip.tsx +10 -5
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +126 -0
- package/src/components/DmWorkspace.tsx +118 -105
- package/src/components/EmbeddedTweetCard.tsx +20 -14
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +104 -90
- package/src/components/LinkPreviewCard.tsx +270 -0
- package/src/components/ProfilePreview.tsx +8 -3
- package/src/components/SavedTimelineView.tsx +89 -71
- package/src/components/SyncNowButton.tsx +105 -0
- package/src/components/ThemeSlider.tsx +10 -59
- package/src/components/TimelineCard.tsx +326 -86
- package/src/components/TimelineRouteFrame.tsx +156 -0
- package/src/components/TweetMediaGrid.tsx +120 -23
- package/src/components/TweetRichText.tsx +19 -4
- package/src/components/useTimelineRouteData.ts +137 -0
- package/src/lib/api-client.ts +229 -0
- package/src/lib/archive-finder.ts +24 -20
- package/src/lib/archive-import.ts +1582 -67
- package/src/lib/authored-live.ts +1074 -0
- package/src/lib/backup.ts +316 -14
- package/src/lib/bird-actions.ts +1 -10
- package/src/lib/bird-command.ts +57 -0
- package/src/lib/bird.ts +89 -5
- package/src/lib/config.ts +1 -1
- package/src/lib/conversation-surface.ts +174 -0
- package/src/lib/db.ts +193 -4
- package/src/lib/follow-graph.ts +1053 -0
- package/src/lib/link-index.ts +11 -98
- package/src/lib/link-insights.ts +834 -0
- package/src/lib/link-preview-metadata.ts +334 -0
- package/src/lib/media-fetch.ts +787 -0
- package/src/lib/media-includes.ts +165 -0
- package/src/lib/mention-threads-live.ts +535 -43
- package/src/lib/mentions-live.ts +623 -19
- package/src/lib/moderation-target.ts +6 -0
- package/src/lib/profile-hydration.ts +1 -1
- package/src/lib/profile-resolver.ts +115 -1
- package/src/lib/queries.ts +326 -35
- package/src/lib/seed.ts +127 -8
- package/src/lib/timeline-collections-live.ts +145 -22
- package/src/lib/timeline-live.ts +10 -15
- package/src/lib/tweet-account-edges.ts +9 -2
- package/src/lib/tweet-render.ts +97 -0
- package/src/lib/types.ts +185 -2
- package/src/lib/ui.ts +383 -147
- package/src/lib/url-expansion-store.ts +110 -0
- package/src/lib/url-expansion.ts +20 -3
- package/src/lib/web-sync.ts +443 -0
- package/src/lib/x-profile.ts +7 -2
- package/src/lib/xurl.ts +296 -12
- package/src/routeTree.gen.ts +126 -0
- package/src/routes/__root.tsx +7 -3
- package/src/routes/api/conversation.tsx +34 -0
- package/src/routes/api/link-insights.tsx +79 -0
- package/src/routes/api/link-preview.tsx +43 -0
- package/src/routes/api/profile-hydrate.tsx +51 -0
- package/src/routes/api/sync.tsx +59 -0
- package/src/routes/blocks.tsx +111 -86
- package/src/routes/dms.tsx +172 -87
- package/src/routes/inbox.tsx +98 -86
- package/src/routes/index.tsx +22 -115
- package/src/routes/links.tsx +928 -0
- package/src/routes/mentions.tsx +22 -115
- package/src/styles.css +169 -43
- package/vite.config.ts +8 -0
package/src/routes/inbox.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { Sparkles } from "lucide-react";
|
|
2
3
|
import { useEffect, useMemo, useState } from "react";
|
|
3
4
|
import { InboxCard } from "#/components/InboxCard";
|
|
4
5
|
import type {
|
|
@@ -8,21 +9,19 @@ import type {
|
|
|
8
9
|
QueryEnvelope,
|
|
9
10
|
} from "#/lib/types";
|
|
10
11
|
import {
|
|
11
|
-
actionButtonClass,
|
|
12
12
|
cx,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
segmentedClass,
|
|
13
|
+
emptyStateClass,
|
|
14
|
+
feedClass,
|
|
15
|
+
pageHeaderClass,
|
|
16
|
+
pageHeaderRowClass,
|
|
17
|
+
pageSubtitleClass,
|
|
18
|
+
pageTitleClass,
|
|
19
|
+
primaryButtonClass,
|
|
20
|
+
secondaryButtonClass,
|
|
21
|
+
tabButtonActiveClass,
|
|
22
|
+
tabButtonClass,
|
|
23
|
+
tabButtonIndicatorClass,
|
|
24
|
+
tabStripClass,
|
|
26
25
|
textFieldClass,
|
|
27
26
|
textFieldShortClass,
|
|
28
27
|
timestampClass,
|
|
@@ -32,6 +31,12 @@ export const Route = createFileRoute("/inbox")({
|
|
|
32
31
|
component: InboxRoute,
|
|
33
32
|
});
|
|
34
33
|
|
|
34
|
+
const TABS: Array<{ value: InboxKind; label: string }> = [
|
|
35
|
+
{ value: "mixed", label: "Mixed" },
|
|
36
|
+
{ value: "mentions", label: "Mentions" },
|
|
37
|
+
{ value: "dms", label: "DMs" },
|
|
38
|
+
];
|
|
39
|
+
|
|
35
40
|
function InboxRoute() {
|
|
36
41
|
const [meta, setMeta] = useState<QueryEnvelope | null>(null);
|
|
37
42
|
const [items, setItems] = useState<InboxItem[]>([]);
|
|
@@ -70,7 +75,7 @@ function InboxRoute() {
|
|
|
70
75
|
|
|
71
76
|
const subtitle = useMemo(() => {
|
|
72
77
|
if (!meta || !stats) return "Ranking unreplied mentions and DMs...";
|
|
73
|
-
return `${stats.total}
|
|
78
|
+
return `${String(stats.total)} in queue · ${String(stats.openai)} OpenAI scored · ${meta.transport.statusText}`;
|
|
74
79
|
}, [meta, stats]);
|
|
75
80
|
|
|
76
81
|
async function scoreNow() {
|
|
@@ -122,80 +127,87 @@ function InboxRoute() {
|
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
return (
|
|
125
|
-
|
|
126
|
-
<
|
|
127
|
-
<
|
|
128
|
-
<div>
|
|
129
|
-
<
|
|
130
|
-
<
|
|
131
|
-
<p className={heroCopyClass}>{subtitle}</p>
|
|
132
|
-
</div>
|
|
133
|
-
<div className={heroControlsClass}>
|
|
134
|
-
<div className={segmentedClass}>
|
|
135
|
-
{(["mixed", "mentions", "dms"] as const).map((value) => (
|
|
136
|
-
<button
|
|
137
|
-
key={value}
|
|
138
|
-
className={cx(
|
|
139
|
-
segmentClass,
|
|
140
|
-
value === kind && segmentActiveClass,
|
|
141
|
-
)}
|
|
142
|
-
onClick={() => setKind(value)}
|
|
143
|
-
type="button"
|
|
144
|
-
>
|
|
145
|
-
{value}
|
|
146
|
-
</button>
|
|
147
|
-
))}
|
|
148
|
-
</div>
|
|
149
|
-
<input
|
|
150
|
-
className={cx(textFieldClass, textFieldShortClass)}
|
|
151
|
-
inputMode="numeric"
|
|
152
|
-
onChange={(event) => setMinScore(event.target.value)}
|
|
153
|
-
placeholder="Min AI score"
|
|
154
|
-
value={minScore}
|
|
155
|
-
/>
|
|
156
|
-
<button
|
|
157
|
-
className={cx(navLinkClass, hideLowSignal && navLinkActiveClass)}
|
|
158
|
-
onClick={() => setHideLowSignal((value) => !value)}
|
|
159
|
-
type="button"
|
|
160
|
-
>
|
|
161
|
-
{hideLowSignal ? "Hide low-signal" : "Show all"}
|
|
162
|
-
</button>
|
|
163
|
-
<button
|
|
164
|
-
className={actionButtonClass}
|
|
165
|
-
disabled={isScoring}
|
|
166
|
-
onClick={() => void scoreNow()}
|
|
167
|
-
type="button"
|
|
168
|
-
>
|
|
169
|
-
{isScoring ? "Scoring..." : "Score with OpenAI"}
|
|
170
|
-
</button>
|
|
130
|
+
<>
|
|
131
|
+
<header className={pageHeaderClass}>
|
|
132
|
+
<div className={pageHeaderRowClass}>
|
|
133
|
+
<div className="flex min-w-0 flex-col">
|
|
134
|
+
<h1 className={pageTitleClass}>Inbox</h1>
|
|
135
|
+
<p className={pageSubtitleClass}>{subtitle}</p>
|
|
171
136
|
</div>
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
137
|
+
<button
|
|
138
|
+
className={primaryButtonClass}
|
|
139
|
+
disabled={isScoring}
|
|
140
|
+
onClick={() => void scoreNow()}
|
|
141
|
+
type="button"
|
|
142
|
+
>
|
|
143
|
+
<Sparkles className="size-4" strokeWidth={2.2} />
|
|
144
|
+
{isScoring ? "Scoring..." : "Score with OpenAI"}
|
|
145
|
+
</button>
|
|
146
|
+
</div>
|
|
147
|
+
<div className="flex flex-wrap items-center gap-2 px-4 pb-3">
|
|
148
|
+
<input
|
|
149
|
+
className={cx(textFieldClass, textFieldShortClass)}
|
|
150
|
+
inputMode="numeric"
|
|
151
|
+
onChange={(event) => setMinScore(event.target.value)}
|
|
152
|
+
placeholder="Min AI score"
|
|
153
|
+
value={minScore}
|
|
154
|
+
/>
|
|
155
|
+
<button
|
|
156
|
+
className={secondaryButtonClass}
|
|
157
|
+
onClick={() => setHideLowSignal((value) => !value)}
|
|
158
|
+
type="button"
|
|
159
|
+
aria-pressed={hideLowSignal}
|
|
160
|
+
>
|
|
161
|
+
{hideLowSignal ? "Hide low-signal" : "Show all"}
|
|
162
|
+
</button>
|
|
163
|
+
</div>
|
|
164
|
+
<div className={tabStripClass}>
|
|
165
|
+
{TABS.map((tab) => {
|
|
166
|
+
const active = kind === tab.value;
|
|
167
|
+
return (
|
|
168
|
+
<button
|
|
169
|
+
key={tab.value}
|
|
170
|
+
type="button"
|
|
171
|
+
aria-pressed={active}
|
|
172
|
+
className={cx(tabButtonClass, active && tabButtonActiveClass)}
|
|
173
|
+
onClick={() => setKind(tab.value)}
|
|
174
|
+
>
|
|
175
|
+
<span className="relative inline-flex flex-col items-center justify-center py-1">
|
|
176
|
+
{tab.value}
|
|
177
|
+
{active ? <span className={tabButtonIndicatorClass} /> : null}
|
|
178
|
+
</span>
|
|
179
|
+
</button>
|
|
180
|
+
);
|
|
181
|
+
})}
|
|
182
|
+
</div>
|
|
183
|
+
</header>
|
|
184
|
+
<section className={feedClass}>
|
|
185
|
+
{items.length === 0 ? (
|
|
186
|
+
<div className={emptyStateClass}>Inbox clear.</div>
|
|
187
|
+
) : null}
|
|
188
|
+
{items.map((item) => (
|
|
189
|
+
<InboxCard
|
|
190
|
+
key={item.id}
|
|
191
|
+
isReplying={activeReplyId === item.id}
|
|
192
|
+
item={item}
|
|
193
|
+
onReplyChange={setReplyDraft}
|
|
194
|
+
onReplySend={() => void sendReply(item)}
|
|
195
|
+
onReplyToggle={() => {
|
|
196
|
+
if (activeReplyId === item.id) {
|
|
197
|
+
setActiveReplyId(null);
|
|
189
198
|
setReplyDraft("");
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
setActiveReplyId(item.id);
|
|
202
|
+
setReplyDraft("");
|
|
203
|
+
}}
|
|
204
|
+
replyDraft={activeReplyId === item.id ? replyDraft : ""}
|
|
205
|
+
/>
|
|
206
|
+
))}
|
|
207
|
+
</section>
|
|
196
208
|
{isSendingReply ? (
|
|
197
|
-
<p className={timestampClass}>Sending reply...</p>
|
|
209
|
+
<p className={cx(timestampClass, "px-4 py-2")}>Sending reply...</p>
|
|
198
210
|
) : null}
|
|
199
|
-
|
|
211
|
+
</>
|
|
200
212
|
);
|
|
201
213
|
}
|
package/src/routes/index.tsx
CHANGED
|
@@ -1,125 +1,32 @@
|
|
|
1
1
|
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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("/")({
|
|
28
6
|
component: HomeRoute,
|
|
29
7
|
});
|
|
30
8
|
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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", "home");
|
|
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 local context...";
|
|
60
|
-
return `${meta.stats.home} home items · ${meta.stats.needsReply} waiting on action · ${meta.transport.statusText}`;
|
|
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 homeSubtitle(meta: QueryEnvelope | null) {
|
|
10
|
+
if (!meta) return "Loading local context...";
|
|
11
|
+
return `${String(meta.stats.home)} items · ${String(meta.stats.needsReply)} waiting · ${meta.transport.statusText}`;
|
|
12
|
+
}
|
|
80
13
|
|
|
14
|
+
function HomeRoute() {
|
|
81
15
|
return (
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 a different filter or sync the timeline again."
|
|
18
|
+
emptyLabel="No posts in this view"
|
|
19
|
+
errorFallback="Timeline unavailable"
|
|
20
|
+
errorTitle="Could not load posts"
|
|
21
|
+
initialReplyFilter="all"
|
|
22
|
+
loadingDetail="Reading the local timeline store"
|
|
23
|
+
loadingLabel="Loading posts"
|
|
24
|
+
resource="home"
|
|
25
|
+
searchPlaceholder="Search local timeline"
|
|
26
|
+
subtitle={homeSubtitle}
|
|
27
|
+
syncKind="timeline"
|
|
28
|
+
syncLabel="Sync timeline"
|
|
29
|
+
title="Home"
|
|
30
|
+
/>
|
|
124
31
|
);
|
|
125
32
|
}
|