birdclaw 0.4.0 → 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 +39 -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 +563 -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 +1567 -65
- package/src/lib/authored-live.ts +1074 -0
- package/src/lib/backup.ts +318 -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 +282 -4
- package/src/lib/follow-graph.ts +1053 -0
- package/src/lib/link-index.ts +629 -0
- 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 +219 -2
- package/src/lib/ui.ts +375 -147
- package/src/lib/url-expansion-store.ts +110 -0
- package/src/lib/url-expansion.ts +33 -8
- 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,110 @@
|
|
|
1
|
+
import type { Database } from "./sqlite";
|
|
2
|
+
import type { LinkIndexItem } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface UrlExpansionRecordInput {
|
|
5
|
+
url: string;
|
|
6
|
+
expandedUrl: string;
|
|
7
|
+
finalUrl: string;
|
|
8
|
+
status: "hit" | "miss" | "error";
|
|
9
|
+
title?: string | null;
|
|
10
|
+
description?: string | null;
|
|
11
|
+
imageUrl?: string | null;
|
|
12
|
+
siteName?: string | null;
|
|
13
|
+
error?: string;
|
|
14
|
+
source: string;
|
|
15
|
+
updatedAt: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getTweetTarget(url: string) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = new URL(url);
|
|
21
|
+
const host = parsed.hostname.toLowerCase();
|
|
22
|
+
if (
|
|
23
|
+
host !== "x.com" &&
|
|
24
|
+
host !== "twitter.com" &&
|
|
25
|
+
host !== "mobile.twitter.com" &&
|
|
26
|
+
host !== "www.x.com" &&
|
|
27
|
+
host !== "www.twitter.com"
|
|
28
|
+
) {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const parts = parsed.pathname.split("/").filter(Boolean);
|
|
33
|
+
const statusIndex = parts.findIndex(
|
|
34
|
+
(part) => part === "status" || part === "statuses",
|
|
35
|
+
);
|
|
36
|
+
if (statusIndex === -1 || !parts[statusIndex + 1]) {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const tweetId = parts[statusIndex + 1]?.match(/^\d+/)?.[0];
|
|
41
|
+
const handle =
|
|
42
|
+
parts[statusIndex - 1] && parts[statusIndex - 1] !== "i"
|
|
43
|
+
? parts[statusIndex - 1]
|
|
44
|
+
: undefined;
|
|
45
|
+
return {
|
|
46
|
+
expandedTweetId: tweetId,
|
|
47
|
+
expandedHandle: handle,
|
|
48
|
+
};
|
|
49
|
+
} catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function normalizeUrlExpansionForIndex(
|
|
55
|
+
item: UrlExpansionRecordInput,
|
|
56
|
+
): LinkIndexItem {
|
|
57
|
+
const target = getTweetTarget(item.finalUrl);
|
|
58
|
+
return {
|
|
59
|
+
shortUrl: item.url,
|
|
60
|
+
expandedUrl: item.expandedUrl,
|
|
61
|
+
finalUrl: item.finalUrl,
|
|
62
|
+
status: item.status,
|
|
63
|
+
expandedTweetId: target.expandedTweetId ?? null,
|
|
64
|
+
expandedHandle: target.expandedHandle ?? null,
|
|
65
|
+
title: item.title ?? null,
|
|
66
|
+
description: item.description ?? null,
|
|
67
|
+
imageUrl: item.imageUrl ?? null,
|
|
68
|
+
siteName: item.siteName ?? null,
|
|
69
|
+
error: item.error ?? null,
|
|
70
|
+
source: item.source,
|
|
71
|
+
updatedAt: item.updatedAt,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function upsertUrlExpansion(db: Database, item: LinkIndexItem) {
|
|
76
|
+
db.prepare(`
|
|
77
|
+
insert into url_expansions (
|
|
78
|
+
short_url, expanded_url, final_url, status, expanded_tweet_id,
|
|
79
|
+
expanded_handle, title, description, image_url, site_name, error, source,
|
|
80
|
+
updated_at
|
|
81
|
+
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
82
|
+
on conflict(short_url) do update set
|
|
83
|
+
expanded_url = excluded.expanded_url,
|
|
84
|
+
final_url = excluded.final_url,
|
|
85
|
+
status = excluded.status,
|
|
86
|
+
expanded_tweet_id = excluded.expanded_tweet_id,
|
|
87
|
+
expanded_handle = excluded.expanded_handle,
|
|
88
|
+
title = excluded.title,
|
|
89
|
+
description = excluded.description,
|
|
90
|
+
image_url = excluded.image_url,
|
|
91
|
+
site_name = excluded.site_name,
|
|
92
|
+
error = excluded.error,
|
|
93
|
+
source = excluded.source,
|
|
94
|
+
updated_at = excluded.updated_at
|
|
95
|
+
`).run(
|
|
96
|
+
item.shortUrl,
|
|
97
|
+
item.expandedUrl,
|
|
98
|
+
item.finalUrl,
|
|
99
|
+
item.status,
|
|
100
|
+
item.expandedTweetId,
|
|
101
|
+
item.expandedHandle,
|
|
102
|
+
item.title,
|
|
103
|
+
item.description,
|
|
104
|
+
item.imageUrl,
|
|
105
|
+
item.siteName,
|
|
106
|
+
item.error,
|
|
107
|
+
item.source,
|
|
108
|
+
item.updatedAt,
|
|
109
|
+
);
|
|
110
|
+
}
|
package/src/lib/url-expansion.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
import { getNativeDb } from "./db";
|
|
1
2
|
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
2
3
|
import type { UrlExpansionItem } from "./types";
|
|
4
|
+
import {
|
|
5
|
+
normalizeUrlExpansionForIndex,
|
|
6
|
+
upsertUrlExpansion,
|
|
7
|
+
} from "./url-expansion-store";
|
|
3
8
|
|
|
4
9
|
const SUCCESS_CACHE_TTL_MS = 365 * 24 * 60 * 60 * 1000;
|
|
5
10
|
const FAILURE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 15_000;
|
|
6
12
|
const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
|
|
7
13
|
|
|
8
14
|
interface CachedUrlExpansion {
|
|
@@ -19,6 +25,7 @@ export interface ExpandUrlsOptions {
|
|
|
19
25
|
successMaxAgeMs?: number;
|
|
20
26
|
failureMaxAgeMs?: number;
|
|
21
27
|
fetchImpl?: typeof fetch;
|
|
28
|
+
timeoutMs?: number;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
function cacheKeyForUrl(url: string) {
|
|
@@ -64,22 +71,32 @@ function toExpansionItem(
|
|
|
64
71
|
};
|
|
65
72
|
}
|
|
66
73
|
|
|
74
|
+
function persistExpansion(item: UrlExpansionItem) {
|
|
75
|
+
const db = getNativeDb({ seedDemoData: false });
|
|
76
|
+
upsertUrlExpansion(db, normalizeUrlExpansionForIndex(item));
|
|
77
|
+
}
|
|
78
|
+
|
|
67
79
|
async function fetchExpansion(
|
|
68
80
|
url: string,
|
|
69
81
|
fetchImpl: typeof fetch,
|
|
82
|
+
timeoutMs: number,
|
|
70
83
|
): Promise<CachedUrlExpansion> {
|
|
84
|
+
const requestInit = {
|
|
85
|
+
redirect: "follow",
|
|
86
|
+
headers: { "user-agent": "birdclaw/0.3 url-expander" },
|
|
87
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
88
|
+
} satisfies RequestInit;
|
|
89
|
+
|
|
71
90
|
try {
|
|
72
91
|
let response = await fetchImpl(url, {
|
|
92
|
+
...requestInit,
|
|
73
93
|
method: "HEAD",
|
|
74
|
-
redirect: "follow",
|
|
75
|
-
headers: { "user-agent": "birdclaw/0.3 url-expander" },
|
|
76
94
|
});
|
|
77
95
|
|
|
78
96
|
if (!response.url || response.url === url || response.status >= 400) {
|
|
79
97
|
response = await fetchImpl(url, {
|
|
98
|
+
...requestInit,
|
|
80
99
|
method: "GET",
|
|
81
|
-
redirect: "follow",
|
|
82
|
-
headers: { "user-agent": "birdclaw/0.3 url-expander" },
|
|
83
100
|
});
|
|
84
101
|
}
|
|
85
102
|
|
|
@@ -106,6 +123,7 @@ export async function expandUrls(
|
|
|
106
123
|
): Promise<UrlExpansionItem[]> {
|
|
107
124
|
const uniqueUrls = Array.from(new Set(urls));
|
|
108
125
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
126
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
109
127
|
const results: UrlExpansionItem[] = [];
|
|
110
128
|
|
|
111
129
|
for (const url of uniqueUrls) {
|
|
@@ -116,16 +134,23 @@ export async function expandUrls(
|
|
|
116
134
|
? (options.successMaxAgeMs ?? SUCCESS_CACHE_TTL_MS)
|
|
117
135
|
: (options.failureMaxAgeMs ?? FAILURE_CACHE_TTL_MS);
|
|
118
136
|
if (isFresh(cached.updatedAt, maxAge)) {
|
|
119
|
-
|
|
120
|
-
|
|
137
|
+
const item = toExpansionItem(
|
|
138
|
+
url,
|
|
139
|
+
cached.value,
|
|
140
|
+
"cache",
|
|
141
|
+
cached.updatedAt,
|
|
121
142
|
);
|
|
143
|
+
persistExpansion(item);
|
|
144
|
+
results.push(item);
|
|
122
145
|
continue;
|
|
123
146
|
}
|
|
124
147
|
}
|
|
125
148
|
|
|
126
|
-
const value = await fetchExpansion(url, fetchImpl);
|
|
149
|
+
const value = await fetchExpansion(url, fetchImpl, timeoutMs);
|
|
127
150
|
const updatedAt = writeSyncCache(cacheKeyForUrl(url), value);
|
|
128
|
-
|
|
151
|
+
const item = toExpansionItem(url, value, "network", updatedAt);
|
|
152
|
+
persistExpansion(item);
|
|
153
|
+
results.push(item);
|
|
129
154
|
}
|
|
130
155
|
|
|
131
156
|
return results;
|
package/src/lib/x-profile.ts
CHANGED
|
@@ -150,6 +150,7 @@ function updateExistingProfileFromUser(
|
|
|
150
150
|
bio = ?,
|
|
151
151
|
followers_count = ?,
|
|
152
152
|
following_count = coalesce(?, following_count),
|
|
153
|
+
public_metrics_json = ?,
|
|
153
154
|
avatar_url = coalesce(?, avatar_url),
|
|
154
155
|
location = coalesce(?, location),
|
|
155
156
|
url = coalesce(?, url),
|
|
@@ -167,6 +168,7 @@ function updateExistingProfileFromUser(
|
|
|
167
168
|
bio,
|
|
168
169
|
followersCount,
|
|
169
170
|
followingCount,
|
|
171
|
+
JSON.stringify(user.public_metrics ?? {}),
|
|
170
172
|
avatarUrl,
|
|
171
173
|
metadata.location,
|
|
172
174
|
metadata.url,
|
|
@@ -245,13 +247,15 @@ export function upsertProfileFromXUser(
|
|
|
245
247
|
`
|
|
246
248
|
insert into profiles (
|
|
247
249
|
id, handle, display_name, bio, followers_count, following_count, avatar_hue,
|
|
248
|
-
avatar_url, location, url, verified_type, entities_json,
|
|
249
|
-
|
|
250
|
+
public_metrics_json, avatar_url, location, url, verified_type, entities_json,
|
|
251
|
+
raw_json, created_at
|
|
252
|
+
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
250
253
|
on conflict(id) do update set
|
|
251
254
|
handle = excluded.handle,
|
|
252
255
|
display_name = excluded.display_name,
|
|
253
256
|
bio = excluded.bio,
|
|
254
257
|
followers_count = excluded.followers_count,
|
|
258
|
+
public_metrics_json = excluded.public_metrics_json,
|
|
255
259
|
following_count = case
|
|
256
260
|
when ? then excluded.following_count
|
|
257
261
|
else profiles.following_count
|
|
@@ -274,6 +278,7 @@ export function upsertProfileFromXUser(
|
|
|
274
278
|
followersCount,
|
|
275
279
|
followingCount ?? 0,
|
|
276
280
|
avatarHue,
|
|
281
|
+
JSON.stringify(user.public_metrics ?? {}),
|
|
277
282
|
avatarUrl,
|
|
278
283
|
metadata.location,
|
|
279
284
|
metadata.url,
|