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.
Files changed (64) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +108 -7
  3. package/package.json +24 -23
  4. package/playwright.config.ts +1 -0
  5. package/public/favicon.ico +0 -0
  6. package/public/logo192.png +0 -0
  7. package/public/logo512.png +0 -0
  8. package/public/manifest.json +2 -2
  9. package/src/cli.ts +563 -18
  10. package/src/components/AppNav.tsx +66 -29
  11. package/src/components/AvatarChip.tsx +10 -5
  12. package/src/components/ConversationThread.tsx +125 -0
  13. package/src/components/DmWorkspace.tsx +96 -98
  14. package/src/components/EmbeddedTweetCard.tsx +20 -14
  15. package/src/components/InboxCard.tsx +92 -90
  16. package/src/components/LinkPreviewCard.tsx +270 -0
  17. package/src/components/ProfilePreview.tsx +8 -3
  18. package/src/components/SavedTimelineView.tsx +48 -38
  19. package/src/components/TimelineCard.tsx +228 -84
  20. package/src/components/TweetRichText.tsx +6 -2
  21. package/src/lib/archive-import.ts +1567 -65
  22. package/src/lib/authored-live.ts +1074 -0
  23. package/src/lib/backup.ts +318 -14
  24. package/src/lib/bird-actions.ts +1 -10
  25. package/src/lib/bird-command.ts +57 -0
  26. package/src/lib/bird.ts +89 -5
  27. package/src/lib/config.ts +1 -1
  28. package/src/lib/db.ts +282 -4
  29. package/src/lib/follow-graph.ts +1053 -0
  30. package/src/lib/link-index.ts +629 -0
  31. package/src/lib/link-insights.ts +834 -0
  32. package/src/lib/link-preview-metadata.ts +334 -0
  33. package/src/lib/media-fetch.ts +787 -0
  34. package/src/lib/media-includes.ts +165 -0
  35. package/src/lib/mention-threads-live.ts +535 -43
  36. package/src/lib/mentions-live.ts +623 -19
  37. package/src/lib/moderation-target.ts +6 -0
  38. package/src/lib/profile-hydration.ts +1 -1
  39. package/src/lib/profile-resolver.ts +115 -1
  40. package/src/lib/queries.ts +233 -7
  41. package/src/lib/seed.ts +127 -8
  42. package/src/lib/timeline-collections-live.ts +145 -22
  43. package/src/lib/timeline-live.ts +10 -15
  44. package/src/lib/tweet-account-edges.ts +9 -2
  45. package/src/lib/tweet-render.ts +97 -0
  46. package/src/lib/types.ts +219 -2
  47. package/src/lib/ui.ts +375 -147
  48. package/src/lib/url-expansion-store.ts +110 -0
  49. package/src/lib/url-expansion.ts +33 -8
  50. package/src/lib/x-profile.ts +7 -2
  51. package/src/lib/xurl.ts +296 -12
  52. package/src/routeTree.gen.ts +105 -0
  53. package/src/routes/__root.tsx +7 -3
  54. package/src/routes/api/conversation.tsx +34 -0
  55. package/src/routes/api/link-insights.tsx +79 -0
  56. package/src/routes/api/link-preview.tsx +43 -0
  57. package/src/routes/api/profile-hydrate.tsx +51 -0
  58. package/src/routes/blocks.tsx +111 -86
  59. package/src/routes/dms.tsx +90 -78
  60. package/src/routes/inbox.tsx +98 -86
  61. package/src/routes/index.tsx +63 -50
  62. package/src/routes/links.tsx +883 -0
  63. package/src/routes/mentions.tsx +63 -50
  64. 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
+ }
@@ -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
- results.push(
120
- toExpansionItem(url, cached.value, "cache", cached.updatedAt),
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
- results.push(toExpansionItem(url, value, "network", updatedAt));
151
+ const item = toExpansionItem(url, value, "network", updatedAt);
152
+ persistExpansion(item);
153
+ results.push(item);
129
154
  }
130
155
 
131
156
  return results;
@@ -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, raw_json, created_at
249
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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,