birdclaw 0.4.1 → 0.5.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 +33 -1
- package/README.md +108 -7
- package/package.json +24 -23
- package/playwright.config.ts +1 -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/src/cli.ts +450 -18
- package/src/components/AppNav.tsx +66 -29
- package/src/components/AvatarChip.tsx +10 -5
- package/src/components/ConversationThread.tsx +125 -0
- package/src/components/DmWorkspace.tsx +96 -98
- package/src/components/EmbeddedTweetCard.tsx +20 -14
- package/src/components/InboxCard.tsx +92 -90
- package/src/components/LinkPreviewCard.tsx +270 -0
- package/src/components/ProfilePreview.tsx +8 -3
- package/src/components/SavedTimelineView.tsx +48 -38
- package/src/components/TimelineCard.tsx +228 -84
- package/src/components/TweetRichText.tsx +6 -2
- package/src/lib/archive-import.ts +1565 -65
- 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/db.ts +191 -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 +233 -7
- 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 +375 -147
- package/src/lib/url-expansion-store.ts +110 -0
- package/src/lib/url-expansion.ts +20 -3
- package/src/lib/x-profile.ts +7 -2
- package/src/lib/xurl.ts +296 -12
- package/src/routeTree.gen.ts +105 -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/blocks.tsx +111 -86
- package/src/routes/dms.tsx +90 -78
- package/src/routes/inbox.tsx +98 -86
- package/src/routes/index.tsx +63 -50
- package/src/routes/links.tsx +883 -0
- package/src/routes/mentions.tsx +63 -50
- package/src/styles.css +106 -43
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { getNativeDb } from "./db";
|
|
2
|
+
import type { Database } from "./sqlite";
|
|
3
|
+
import {
|
|
4
|
+
normalizeUrlExpansionForIndex,
|
|
5
|
+
upsertUrlExpansion,
|
|
6
|
+
} from "./url-expansion-store";
|
|
7
|
+
|
|
8
|
+
const FETCH_TIMEOUT_MS = 12_000;
|
|
9
|
+
const MAX_HTML_CHARS = 2_000_000;
|
|
10
|
+
|
|
11
|
+
export interface LinkPreviewMetadata {
|
|
12
|
+
url: string;
|
|
13
|
+
title: string | null;
|
|
14
|
+
description: string | null;
|
|
15
|
+
imageUrl: string | null;
|
|
16
|
+
siteName: string | null;
|
|
17
|
+
error?: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface GetLinkPreviewOptions {
|
|
21
|
+
shortUrl?: string | null;
|
|
22
|
+
refresh?: boolean;
|
|
23
|
+
fetchImpl?: typeof fetch;
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type UrlExpansionPreviewRow = {
|
|
28
|
+
short_url: string;
|
|
29
|
+
expanded_url: string;
|
|
30
|
+
final_url: string;
|
|
31
|
+
status: "hit" | "miss" | "error";
|
|
32
|
+
title: string | null;
|
|
33
|
+
description: string | null;
|
|
34
|
+
image_url: string | null;
|
|
35
|
+
site_name: string | null;
|
|
36
|
+
error: string | null;
|
|
37
|
+
source: string;
|
|
38
|
+
updated_at: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function cleanText(value: string | null | undefined) {
|
|
42
|
+
if (!value) return null;
|
|
43
|
+
const cleaned = decodeHtmlEntities(value).replace(/\s+/g, " ").trim();
|
|
44
|
+
return cleaned.length > 0 ? cleaned : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function decodeHtmlEntities(value: string) {
|
|
48
|
+
return value
|
|
49
|
+
.replace(/&#(\d+);/g, (_, code: string) =>
|
|
50
|
+
String.fromCodePoint(Number(code)),
|
|
51
|
+
)
|
|
52
|
+
.replace(/&#x([a-f0-9]+);/gi, (_, code: string) =>
|
|
53
|
+
String.fromCodePoint(Number.parseInt(code, 16)),
|
|
54
|
+
)
|
|
55
|
+
.replace(/&/g, "&")
|
|
56
|
+
.replace(/</g, "<")
|
|
57
|
+
.replace(/>/g, ">")
|
|
58
|
+
.replace(/"/g, '"')
|
|
59
|
+
.replace(/'/g, "'")
|
|
60
|
+
.replace(/'/g, "'");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseAttributes(tag: string) {
|
|
64
|
+
const attributes = new Map<string, string>();
|
|
65
|
+
for (const match of tag.matchAll(
|
|
66
|
+
/([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g,
|
|
67
|
+
)) {
|
|
68
|
+
const key = match[1]?.toLowerCase();
|
|
69
|
+
const value = match[3] ?? match[4] ?? match[5] ?? "";
|
|
70
|
+
if (key) {
|
|
71
|
+
attributes.set(key, value);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return attributes;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function metaContents(html: string) {
|
|
78
|
+
const values = new Map<string, string>();
|
|
79
|
+
for (const match of html.matchAll(/<meta\b[^>]*>/gi)) {
|
|
80
|
+
const attributes = parseAttributes(match[0]);
|
|
81
|
+
const key = (
|
|
82
|
+
attributes.get("property") ??
|
|
83
|
+
attributes.get("name") ??
|
|
84
|
+
""
|
|
85
|
+
).toLowerCase();
|
|
86
|
+
const content = cleanText(
|
|
87
|
+
attributes.get("content") ?? attributes.get("value") ?? "",
|
|
88
|
+
);
|
|
89
|
+
if (key && content && !values.has(key)) {
|
|
90
|
+
values.set(key, content);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return values;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function pick(values: Map<string, string>, keys: string[]) {
|
|
97
|
+
for (const key of keys) {
|
|
98
|
+
const value = cleanText(values.get(key));
|
|
99
|
+
if (value) return value;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function titleFromHtml(html: string) {
|
|
105
|
+
const match = html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
|
|
106
|
+
return cleanText(match?.[1]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function absoluteUrl(value: string | null, baseUrl: string) {
|
|
110
|
+
if (!value) return null;
|
|
111
|
+
try {
|
|
112
|
+
return new URL(value, baseUrl).toString();
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function hostLabel(url: string) {
|
|
119
|
+
try {
|
|
120
|
+
return new URL(url).hostname.replace(/^www\./, "");
|
|
121
|
+
} catch {
|
|
122
|
+
return url;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function youtubeThumbnail(url: string) {
|
|
127
|
+
try {
|
|
128
|
+
const parsed = new URL(url);
|
|
129
|
+
let videoId: string | null = null;
|
|
130
|
+
if (parsed.hostname === "youtu.be") {
|
|
131
|
+
videoId = parsed.pathname.split("/").filter(Boolean)[0] ?? null;
|
|
132
|
+
}
|
|
133
|
+
if (
|
|
134
|
+
parsed.hostname.endsWith("youtube.com") ||
|
|
135
|
+
parsed.hostname.endsWith("youtube-nocookie.com")
|
|
136
|
+
) {
|
|
137
|
+
videoId = parsed.searchParams.get("v");
|
|
138
|
+
if (!videoId && parsed.pathname.startsWith("/shorts/")) {
|
|
139
|
+
videoId = parsed.pathname.split("/").filter(Boolean)[1] ?? null;
|
|
140
|
+
}
|
|
141
|
+
if (!videoId && parsed.pathname.startsWith("/embed/")) {
|
|
142
|
+
videoId = parsed.pathname.split("/").filter(Boolean)[1] ?? null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!videoId || !/^[\w-]{6,}$/.test(videoId)) return null;
|
|
146
|
+
return `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function extractLinkPreviewMetadata(
|
|
153
|
+
html: string,
|
|
154
|
+
url: string,
|
|
155
|
+
): LinkPreviewMetadata {
|
|
156
|
+
const meta = metaContents(html);
|
|
157
|
+
const title =
|
|
158
|
+
pick(meta, ["og:title", "twitter:title"]) ??
|
|
159
|
+
titleFromHtml(html) ??
|
|
160
|
+
hostLabel(url);
|
|
161
|
+
const description = pick(meta, [
|
|
162
|
+
"og:description",
|
|
163
|
+
"twitter:description",
|
|
164
|
+
"description",
|
|
165
|
+
]);
|
|
166
|
+
const siteName =
|
|
167
|
+
pick(meta, ["og:site_name", "application-name"]) ?? hostLabel(url);
|
|
168
|
+
const image =
|
|
169
|
+
pick(meta, [
|
|
170
|
+
"og:image:secure_url",
|
|
171
|
+
"og:image:url",
|
|
172
|
+
"og:image",
|
|
173
|
+
"twitter:image:src",
|
|
174
|
+
"twitter:image",
|
|
175
|
+
]) ?? youtubeThumbnail(url);
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
url,
|
|
179
|
+
title,
|
|
180
|
+
description,
|
|
181
|
+
imageUrl: absoluteUrl(image, url),
|
|
182
|
+
siteName,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function fetchLinkPreviewMetadata(
|
|
187
|
+
url: string,
|
|
188
|
+
options: Pick<GetLinkPreviewOptions, "fetchImpl" | "timeoutMs"> = {},
|
|
189
|
+
): Promise<LinkPreviewMetadata> {
|
|
190
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
191
|
+
const timeoutMs = options.timeoutMs ?? FETCH_TIMEOUT_MS;
|
|
192
|
+
const headers = {
|
|
193
|
+
"user-agent":
|
|
194
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 birdclaw/0.4",
|
|
195
|
+
accept:
|
|
196
|
+
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
|
197
|
+
"accept-language": "en-US,en;q=0.9",
|
|
198
|
+
} satisfies HeadersInit;
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const response = await fetchImpl(url, {
|
|
202
|
+
headers,
|
|
203
|
+
redirect: "follow",
|
|
204
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
205
|
+
});
|
|
206
|
+
const finalUrl = response.url || url;
|
|
207
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
208
|
+
if (!response.ok) {
|
|
209
|
+
throw new Error(`HTTP ${response.status}`);
|
|
210
|
+
}
|
|
211
|
+
if (contentType.toLowerCase().startsWith("image/")) {
|
|
212
|
+
return {
|
|
213
|
+
url: finalUrl,
|
|
214
|
+
title: hostLabel(finalUrl),
|
|
215
|
+
description: null,
|
|
216
|
+
imageUrl: finalUrl,
|
|
217
|
+
siteName: hostLabel(finalUrl),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const html = (await response.text()).slice(0, MAX_HTML_CHARS);
|
|
221
|
+
return extractLinkPreviewMetadata(html, finalUrl);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
return {
|
|
224
|
+
url,
|
|
225
|
+
title: hostLabel(url),
|
|
226
|
+
description: null,
|
|
227
|
+
imageUrl: youtubeThumbnail(url),
|
|
228
|
+
siteName: hostLabel(url),
|
|
229
|
+
error: error instanceof Error ? error.message : String(error),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function readCachedPreview(
|
|
235
|
+
db: Database,
|
|
236
|
+
url: string,
|
|
237
|
+
shortUrl: string | null | undefined,
|
|
238
|
+
) {
|
|
239
|
+
return db
|
|
240
|
+
.prepare(
|
|
241
|
+
`
|
|
242
|
+
select short_url, expanded_url, final_url, status, title, description,
|
|
243
|
+
image_url, site_name, error, source, updated_at
|
|
244
|
+
from url_expansions
|
|
245
|
+
where short_url in (?, ?)
|
|
246
|
+
or expanded_url in (?, ?)
|
|
247
|
+
or final_url in (?, ?)
|
|
248
|
+
order by
|
|
249
|
+
case
|
|
250
|
+
when short_url = ? then 0
|
|
251
|
+
when final_url = ? then 1
|
|
252
|
+
else 2
|
|
253
|
+
end
|
|
254
|
+
limit 1
|
|
255
|
+
`,
|
|
256
|
+
)
|
|
257
|
+
.get(
|
|
258
|
+
shortUrl ?? url,
|
|
259
|
+
url,
|
|
260
|
+
shortUrl ?? url,
|
|
261
|
+
url,
|
|
262
|
+
shortUrl ?? url,
|
|
263
|
+
url,
|
|
264
|
+
shortUrl ?? url,
|
|
265
|
+
url,
|
|
266
|
+
) as UrlExpansionPreviewRow | undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function hasUsefulPreview(row: UrlExpansionPreviewRow) {
|
|
270
|
+
return Boolean(
|
|
271
|
+
row.title || row.description || row.image_url || row.site_name,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function rowToPreview(row: UrlExpansionPreviewRow): LinkPreviewMetadata {
|
|
276
|
+
const url = row.final_url || row.expanded_url || row.short_url;
|
|
277
|
+
return {
|
|
278
|
+
url,
|
|
279
|
+
title: row.title ?? null,
|
|
280
|
+
description: row.description ?? null,
|
|
281
|
+
imageUrl: row.image_url ?? null,
|
|
282
|
+
siteName: row.site_name ?? null,
|
|
283
|
+
...(row.error ? { error: row.error } : {}),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function persistPreview(
|
|
288
|
+
db: Database,
|
|
289
|
+
url: string,
|
|
290
|
+
shortUrl: string | null | undefined,
|
|
291
|
+
cached: UrlExpansionPreviewRow | undefined,
|
|
292
|
+
preview: LinkPreviewMetadata,
|
|
293
|
+
) {
|
|
294
|
+
const now = new Date().toISOString();
|
|
295
|
+
upsertUrlExpansion(
|
|
296
|
+
db,
|
|
297
|
+
normalizeUrlExpansionForIndex({
|
|
298
|
+
url: cached?.short_url ?? shortUrl ?? url,
|
|
299
|
+
expandedUrl: cached?.expanded_url ?? preview.url,
|
|
300
|
+
finalUrl: preview.url,
|
|
301
|
+
status: preview.error ? "error" : "hit",
|
|
302
|
+
title: preview.title,
|
|
303
|
+
description: preview.description,
|
|
304
|
+
imageUrl: preview.imageUrl,
|
|
305
|
+
siteName: preview.siteName,
|
|
306
|
+
...(preview.error ? { error: preview.error } : {}),
|
|
307
|
+
source: "metadata",
|
|
308
|
+
updatedAt: now,
|
|
309
|
+
}),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function getOrFetchLinkPreview(
|
|
314
|
+
url: string,
|
|
315
|
+
options: GetLinkPreviewOptions = {},
|
|
316
|
+
): Promise<LinkPreviewMetadata> {
|
|
317
|
+
const db = getNativeDb({ seedDemoData: false });
|
|
318
|
+
const cached = readCachedPreview(db, url, options.shortUrl);
|
|
319
|
+
if (cached && hasUsefulPreview(cached) && !options.refresh) {
|
|
320
|
+
return rowToPreview(cached);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const preview = await fetchLinkPreviewMetadata(
|
|
324
|
+
cached?.final_url || cached?.expanded_url || url,
|
|
325
|
+
options,
|
|
326
|
+
);
|
|
327
|
+
persistPreview(db, url, options.shortUrl, cached, preview);
|
|
328
|
+
return preview;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export const __test__ = {
|
|
332
|
+
decodeHtmlEntities,
|
|
333
|
+
youtubeThumbnail,
|
|
334
|
+
};
|