birdclaw 0.5.1 → 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 +44 -1
- package/README.md +50 -5
- package/package.json +3 -2
- package/scripts/browser-perf.mjs +1 -0
- package/scripts/start-test-server.mjs +16 -3
- package/src/cli.ts +376 -13
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +27 -7
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/DmWorkspace.tsx +18 -8
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +452 -0
- package/src/components/SyncNowButton.tsx +57 -25
- package/src/components/ThemeSlider.tsx +55 -50
- package/src/components/TimelineCard.tsx +225 -93
- package/src/components/TimelineRouteFrame.tsx +22 -8
- package/src/components/TweetMediaGrid.tsx +87 -38
- package/src/components/TweetRichText.tsx +15 -11
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +23 -7
- package/src/lib/account-sync-job.ts +654 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +128 -53
- package/src/lib/archive-finder.ts +78 -63
- package/src/lib/archive-import.ts +1364 -1300
- 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 +79 -48
- package/src/lib/db.ts +33 -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 +969 -199
- 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 +30 -7
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +216 -148
- 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 +42 -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 +75 -29
- package/src/routes/dms.tsx +95 -28
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/today.tsx +441 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { X } from "lucide-react";
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import type { TweetMediaItem } from "#/lib/types";
|
|
4
|
-
import { tweetMediaGridClass, tweetMediaTileClass } from "#/lib/ui";
|
|
4
|
+
import { cx, tweetMediaGridClass, tweetMediaTileClass } from "#/lib/ui";
|
|
5
5
|
|
|
6
6
|
export function TweetMediaGrid({ items }: { items: TweetMediaItem[] }) {
|
|
7
7
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
|
@@ -16,47 +16,83 @@ export function TweetMediaGrid({ items }: { items: TweetMediaItem[] }) {
|
|
|
16
16
|
selectedItem?.type === "video" || selectedItem?.type === "gif"
|
|
17
17
|
? (selectedItem.variants?.[0]?.url ?? playableVideoUrl(selectedItem.url))
|
|
18
18
|
: null;
|
|
19
|
+
const singleImage =
|
|
20
|
+
visibleItems.length === 1 && visibleItems[0]?.type === "image"
|
|
21
|
+
? visibleItems[0]
|
|
22
|
+
: null;
|
|
19
23
|
|
|
20
24
|
return (
|
|
21
25
|
<>
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
loading="lazy"
|
|
46
|
-
src={item.thumbnailUrl ?? item.url}
|
|
47
|
-
/>
|
|
48
|
-
) : (
|
|
49
|
-
<span className="tweet-media-fallback grid min-h-40 place-items-center font-semibold text-[var(--ink-soft)]">
|
|
50
|
-
{item.type === "video"
|
|
51
|
-
? "Video"
|
|
52
|
-
: item.type === "gif"
|
|
53
|
-
? "GIF"
|
|
54
|
-
: "Media"}
|
|
55
|
-
</span>
|
|
26
|
+
{singleImage ? (
|
|
27
|
+
<button
|
|
28
|
+
aria-label="Open tweet media 1"
|
|
29
|
+
className={cx(
|
|
30
|
+
"tweet-media-single mt-2 max-w-full overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-active)] p-0 text-left",
|
|
31
|
+
singleImage.width && singleImage.height
|
|
32
|
+
? "block"
|
|
33
|
+
: "inline-block align-top",
|
|
34
|
+
)}
|
|
35
|
+
onClick={(event) => {
|
|
36
|
+
event.stopPropagation();
|
|
37
|
+
setSelectedIndex(0);
|
|
38
|
+
}}
|
|
39
|
+
style={singleImageStyle(singleImage)}
|
|
40
|
+
type="button"
|
|
41
|
+
>
|
|
42
|
+
<img
|
|
43
|
+
alt={singleImage.altText ?? "Tweet media 1"}
|
|
44
|
+
className={cx(
|
|
45
|
+
"tweet-media-image block max-h-[720px] max-w-full",
|
|
46
|
+
singleImage.width && singleImage.height
|
|
47
|
+
? "size-full object-cover"
|
|
48
|
+
: "h-auto w-auto object-contain",
|
|
56
49
|
)}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
height={singleImage.height}
|
|
51
|
+
loading="lazy"
|
|
52
|
+
src={singleImage.thumbnailUrl ?? singleImage.url}
|
|
53
|
+
width={singleImage.width}
|
|
54
|
+
/>
|
|
55
|
+
</button>
|
|
56
|
+
) : (
|
|
57
|
+
<div className={tweetMediaGridClass(Math.min(items.length, 4))}>
|
|
58
|
+
{visibleItems.map((item, index) => (
|
|
59
|
+
<button
|
|
60
|
+
key={item.url + String(index)}
|
|
61
|
+
aria-label={`Open tweet media ${String(index + 1)}`}
|
|
62
|
+
className={tweetMediaTileClass(index, Math.min(items.length, 4))}
|
|
63
|
+
onClick={(event) => {
|
|
64
|
+
event.stopPropagation();
|
|
65
|
+
setSelectedIndex(index);
|
|
66
|
+
}}
|
|
67
|
+
style={
|
|
68
|
+
visibleItems.length === 1 && item.width && item.height
|
|
69
|
+
? {
|
|
70
|
+
aspectRatio: `${String(item.width)} / ${String(item.height)}`,
|
|
71
|
+
}
|
|
72
|
+
: undefined
|
|
73
|
+
}
|
|
74
|
+
type="button"
|
|
75
|
+
>
|
|
76
|
+
{item.type === "image" ? (
|
|
77
|
+
<img
|
|
78
|
+
alt={item.altText ?? `Tweet media ${String(index + 1)}`}
|
|
79
|
+
className="tweet-media-image block size-full object-contain"
|
|
80
|
+
loading="lazy"
|
|
81
|
+
src={item.thumbnailUrl ?? item.url}
|
|
82
|
+
/>
|
|
83
|
+
) : (
|
|
84
|
+
<span className="tweet-media-fallback grid min-h-40 place-items-center font-semibold text-[var(--ink-soft)]">
|
|
85
|
+
{item.type === "video"
|
|
86
|
+
? "Video"
|
|
87
|
+
: item.type === "gif"
|
|
88
|
+
? "GIF"
|
|
89
|
+
: "Media"}
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
92
|
+
</button>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
60
96
|
{selectedItem ? (
|
|
61
97
|
<div
|
|
62
98
|
aria-modal="true"
|
|
@@ -125,6 +161,19 @@ export function TweetMediaGrid({ items }: { items: TweetMediaItem[] }) {
|
|
|
125
161
|
);
|
|
126
162
|
}
|
|
127
163
|
|
|
164
|
+
function singleImageStyle(item: TweetMediaItem) {
|
|
165
|
+
if (!item.width || !item.height) return undefined;
|
|
166
|
+
const maxHeight = 720;
|
|
167
|
+
const width = Math.min(
|
|
168
|
+
item.width,
|
|
169
|
+
Math.round((item.width / item.height) * maxHeight),
|
|
170
|
+
);
|
|
171
|
+
return {
|
|
172
|
+
aspectRatio: `${String(item.width)} / ${String(item.height)}`,
|
|
173
|
+
width: `${String(width)}px`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
128
177
|
function playableVideoUrl(url: string) {
|
|
129
178
|
try {
|
|
130
179
|
const parsed = new URL(url);
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
tweetLinkClass,
|
|
12
12
|
tweetMentionClass,
|
|
13
13
|
} from "#/lib/ui";
|
|
14
|
+
import { safeHttpUrl } from "#/lib/url-safety";
|
|
14
15
|
import { ProfilePreview } from "./ProfilePreview";
|
|
15
16
|
|
|
16
17
|
export function TweetRichText({
|
|
@@ -65,17 +66,20 @@ export function TweetRichText({
|
|
|
65
66
|
</ProfilePreview>
|
|
66
67
|
);
|
|
67
68
|
} else if (segment.kind === "url") {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
69
|
+
const href = safeHttpUrl(segment.expandedUrl);
|
|
70
|
+
if (href) {
|
|
71
|
+
node = (
|
|
72
|
+
<a
|
|
73
|
+
key={`segment-${String(index)}`}
|
|
74
|
+
className={tweetLinkClass}
|
|
75
|
+
href={href}
|
|
76
|
+
rel="noreferrer"
|
|
77
|
+
target="_blank"
|
|
78
|
+
>
|
|
79
|
+
{segment.displayUrl}
|
|
80
|
+
</a>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
79
83
|
} else if (segment.kind === "hashtag") {
|
|
80
84
|
node = (
|
|
81
85
|
<span
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import type { AccountRecord } from "#/lib/types";
|
|
3
|
+
|
|
4
|
+
const STORAGE_KEY = "birdclaw:selected-account-id";
|
|
5
|
+
const CHANGE_EVENT = "birdclaw-account-change";
|
|
6
|
+
|
|
7
|
+
export function defaultAccountId(accounts: AccountRecord[] | undefined) {
|
|
8
|
+
if (!accounts?.length) return undefined;
|
|
9
|
+
return accounts.find((account) => account.isDefault)?.id ?? accounts[0]?.id;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function readStoredAccountId() {
|
|
13
|
+
if (typeof window === "undefined") return undefined;
|
|
14
|
+
return window.localStorage.getItem(STORAGE_KEY) ?? undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function setStoredAccountId(accountId: string) {
|
|
18
|
+
if (typeof window === "undefined") return;
|
|
19
|
+
window.localStorage.setItem(STORAGE_KEY, accountId);
|
|
20
|
+
window.dispatchEvent(
|
|
21
|
+
new CustomEvent(CHANGE_EVENT, {
|
|
22
|
+
detail: { accountId },
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useSelectedAccountId(accounts: AccountRecord[] | undefined) {
|
|
28
|
+
const fallbackAccountId = useMemo(
|
|
29
|
+
() => defaultAccountId(accounts),
|
|
30
|
+
[accounts],
|
|
31
|
+
);
|
|
32
|
+
const [selectedAccountId, setSelectedAccountId] = useState<
|
|
33
|
+
string | undefined
|
|
34
|
+
>(() => readStoredAccountId());
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const onChange = (event: Event) => {
|
|
38
|
+
const custom = event as CustomEvent<{ accountId?: string }>;
|
|
39
|
+
setSelectedAccountId(custom.detail?.accountId ?? readStoredAccountId());
|
|
40
|
+
};
|
|
41
|
+
const onStorage = () => setSelectedAccountId(readStoredAccountId());
|
|
42
|
+
window.addEventListener(CHANGE_EVENT, onChange);
|
|
43
|
+
window.addEventListener("storage", onStorage);
|
|
44
|
+
return () => {
|
|
45
|
+
window.removeEventListener(CHANGE_EVENT, onChange);
|
|
46
|
+
window.removeEventListener("storage", onStorage);
|
|
47
|
+
};
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!accounts?.length || !fallbackAccountId) return;
|
|
52
|
+
const current = readStoredAccountId();
|
|
53
|
+
const valid = current && accounts.some((account) => account.id === current);
|
|
54
|
+
if (!valid) {
|
|
55
|
+
setStoredAccountId(fallbackAccountId);
|
|
56
|
+
setSelectedAccountId(fallbackAccountId);
|
|
57
|
+
}
|
|
58
|
+
}, [accounts, fallbackAccountId]);
|
|
59
|
+
|
|
60
|
+
return selectedAccountId &&
|
|
61
|
+
accounts?.some((account) => account.id === selectedAccountId)
|
|
62
|
+
? selectedAccountId
|
|
63
|
+
: fallbackAccountId;
|
|
64
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
fetchQueryResponse,
|
|
11
11
|
postAction,
|
|
12
12
|
} from "#/lib/api-client";
|
|
13
|
+
import { useSelectedAccountId } from "./account-selection";
|
|
13
14
|
|
|
14
15
|
interface UseTimelineRouteDataOptions {
|
|
15
16
|
resource: Exclude<ResourceKind, "dms">;
|
|
@@ -32,7 +33,9 @@ export function useTimelineRouteData({
|
|
|
32
33
|
const [items, setItems] = useState<TimelineItem[]>([]);
|
|
33
34
|
const [loading, setLoading] = useState(true);
|
|
34
35
|
const [error, setError] = useState<string | null>(null);
|
|
36
|
+
const [replyError, setReplyError] = useState<string | null>(null);
|
|
35
37
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
38
|
+
const selectedAccountId = useSelectedAccountId(meta?.accounts);
|
|
36
39
|
|
|
37
40
|
async function loadStatus() {
|
|
38
41
|
setMeta(await fetchQueryEnvelope());
|
|
@@ -46,6 +49,9 @@ export function useTimelineRouteData({
|
|
|
46
49
|
const url = new URL("/api/query", window.location.origin);
|
|
47
50
|
url.searchParams.set("resource", resource);
|
|
48
51
|
url.searchParams.set("refresh", String(refreshTick));
|
|
52
|
+
if (selectedAccountId) {
|
|
53
|
+
url.searchParams.set("account", selectedAccountId);
|
|
54
|
+
}
|
|
49
55
|
if (replyFilter) {
|
|
50
56
|
url.searchParams.set("replyFilter", replyFilter);
|
|
51
57
|
}
|
|
@@ -100,6 +106,7 @@ export function useTimelineRouteData({
|
|
|
100
106
|
replyFilter,
|
|
101
107
|
resource,
|
|
102
108
|
search,
|
|
109
|
+
selectedAccountId,
|
|
103
110
|
]);
|
|
104
111
|
|
|
105
112
|
function retry() {
|
|
@@ -115,14 +122,21 @@ export function useTimelineRouteData({
|
|
|
115
122
|
const text = window.prompt("Reply text");
|
|
116
123
|
if (!text?.trim()) return;
|
|
117
124
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
125
|
+
setReplyError(null);
|
|
126
|
+
try {
|
|
127
|
+
await postAction({
|
|
128
|
+
kind: "replyTweet",
|
|
129
|
+
accountId: selectedAccountId ?? "acct_primary",
|
|
130
|
+
tweetId,
|
|
131
|
+
text,
|
|
132
|
+
});
|
|
124
133
|
|
|
125
|
-
|
|
134
|
+
retry();
|
|
135
|
+
} catch (replyError) {
|
|
136
|
+
setReplyError(
|
|
137
|
+
replyError instanceof Error ? replyError.message : "Reply failed",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
126
140
|
}
|
|
127
141
|
|
|
128
142
|
return {
|
|
@@ -130,8 +144,10 @@ export function useTimelineRouteData({
|
|
|
130
144
|
items,
|
|
131
145
|
loading,
|
|
132
146
|
error,
|
|
147
|
+
replyError,
|
|
133
148
|
retry,
|
|
134
149
|
refreshLocalView,
|
|
135
150
|
replyToTweet,
|
|
151
|
+
selectedAccountId,
|
|
136
152
|
};
|
|
137
153
|
}
|