birdclaw 0.1.1 → 0.2.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.
@@ -199,6 +199,7 @@ export async function getQueryEnvelope(): Promise<QueryEnvelope> {
199
199
  id: row.id,
200
200
  name: row.name,
201
201
  handle: row.handle,
202
+ externalUserId: row.external_user_id,
202
203
  transport: row.transport,
203
204
  isDefault: row.is_default,
204
205
  createdAt: row.created_at,
@@ -217,6 +218,8 @@ export function listTimelineItems({
217
218
  until,
218
219
  includeReplies = true,
219
220
  qualityFilter = "all",
221
+ likedOnly = false,
222
+ bookmarkedOnly = false,
220
223
  limit = 18,
221
224
  }: TimelineQuery): TimelineItem[] {
222
225
  const db = getNativeDb();
@@ -225,6 +228,11 @@ export function listTimelineItems({
225
228
  let join = "";
226
229
  let where = "where t.kind = ?";
227
230
 
231
+ if (likedOnly || bookmarkedOnly) {
232
+ params.length = 0;
233
+ where = "where 1 = 1";
234
+ }
235
+
228
236
  if (account && account !== "all") {
229
237
  where += " and a.id = ?";
230
238
  params.push(account);
@@ -256,6 +264,14 @@ export function listTimelineItems({
256
264
  params.push(search.trim());
257
265
  }
258
266
 
267
+ if (likedOnly) {
268
+ where += " and t.liked = 1";
269
+ }
270
+
271
+ if (bookmarkedOnly) {
272
+ where += " and t.bookmarked = 1";
273
+ }
274
+
259
275
  params.push(limit);
260
276
 
261
277
  const rows = db
@@ -630,11 +646,14 @@ export function queryResource(
630
646
  };
631
647
  }
632
648
 
649
+ const { resource: _filterResource, ...timelineFilters } =
650
+ filters as TimelineQuery;
651
+
633
652
  return {
634
653
  resource,
635
654
  items: listTimelineItems({
636
655
  resource,
637
- ...(filters as TimelineQuery),
656
+ ...timelineFilters,
638
657
  }),
639
658
  };
640
659
  }
package/src/lib/seed.ts CHANGED
@@ -34,8 +34,8 @@ export function seedDemoData(db: Database.Database) {
34
34
  }
35
35
 
36
36
  const insertAccount = db.prepare(`
37
- insert into accounts (id, name, handle, transport, is_default, created_at)
38
- values (@id, @name, @handle, @transport, @isDefault, @createdAt)
37
+ insert into accounts (id, name, handle, external_user_id, transport, is_default, created_at)
38
+ values (@id, @name, @handle, @externalUserId, @transport, @isDefault, @createdAt)
39
39
  `);
40
40
 
41
41
  const insertProfile = db.prepare(`
@@ -81,6 +81,7 @@ export function seedDemoData(db: Database.Database) {
81
81
  id: "acct_primary",
82
82
  name: "Peter",
83
83
  handle: "@steipete",
84
+ externalUserId: "25401953",
84
85
  transport: "xurl",
85
86
  isDefault: 1,
86
87
  createdAt: now.toISOString(),
@@ -89,6 +90,7 @@ export function seedDemoData(db: Database.Database) {
89
90
  id: "acct_studio",
90
91
  name: "Studio",
91
92
  handle: "@birdclaw_lab",
93
+ externalUserId: null,
92
94
  transport: "xurl",
93
95
  isDefault: 0,
94
96
  createdAt: now.toISOString(),
@@ -7,12 +7,6 @@ export interface ThemeTransitionContext {
7
7
  pointerClientY?: number;
8
8
  }
9
9
 
10
- interface DocumentWithViewTransition extends Document {
11
- startViewTransition?: (callback: () => void) => {
12
- finished: Promise<void>;
13
- };
14
- }
15
-
16
10
  const clamp01 = (value: number) => {
17
11
  if (Number.isNaN(value)) return 0.5;
18
12
  if (value <= 0) return 0;
@@ -58,10 +52,12 @@ export function startThemeTransition({
58
52
  }
59
53
 
60
54
  const root = document.documentElement;
61
- const documentWithTransition = document as DocumentWithViewTransition;
55
+ const startViewTransition =
56
+ typeof document.startViewTransition === "function"
57
+ ? document.startViewTransition.bind(document)
58
+ : undefined;
62
59
  const canUseViewTransition =
63
- Boolean(documentWithTransition.startViewTransition) &&
64
- !hasReducedMotionPreference();
60
+ Boolean(startViewTransition) && !hasReducedMotionPreference();
65
61
 
66
62
  const applyTheme = () => {
67
63
  flushSync(() => {
@@ -98,7 +94,7 @@ export function startThemeTransition({
98
94
  root.classList.add("theme-transition");
99
95
 
100
96
  try {
101
- const transition = documentWithTransition.startViewTransition?.(() => {
97
+ const transition = startViewTransition?.(() => {
102
98
  applyTheme();
103
99
  });
104
100
 
package/src/lib/theme.tsx CHANGED
@@ -111,7 +111,15 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
111
111
  return;
112
112
  }
113
113
 
114
- const media = window.matchMedia("(prefers-color-scheme: dark)");
114
+ const media = window.matchMedia(
115
+ "(prefers-color-scheme: dark)",
116
+ ) as unknown as {
117
+ matches: boolean;
118
+ addEventListener?: (type: "change", listener: () => void) => void;
119
+ removeEventListener?: (type: "change", listener: () => void) => void;
120
+ addListener?: (listener: () => void) => void;
121
+ removeListener?: (listener: () => void) => void;
122
+ };
115
123
  const onChange = () => {
116
124
  const nextResolvedTheme = media.matches ? "dark" : "light";
117
125
  setResolvedTheme(nextResolvedTheme);
@@ -119,16 +127,16 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
119
127
  document.documentElement.style.colorScheme = nextResolvedTheme;
120
128
  };
121
129
 
122
- if ("addEventListener" in media) {
130
+ if (typeof media.addEventListener === "function") {
123
131
  media.addEventListener("change", onChange);
124
132
  } else {
125
- media.addListener(onChange);
133
+ media.addListener?.(onChange);
126
134
  }
127
135
  return () => {
128
- if ("removeEventListener" in media) {
136
+ if (typeof media.removeEventListener === "function") {
129
137
  media.removeEventListener("change", onChange);
130
138
  } else {
131
- media.removeListener(onChange);
139
+ media.removeListener?.(onChange);
132
140
  }
133
141
  };
134
142
  }, [isReady, theme]);
@@ -0,0 +1,406 @@
1
+ import type Database from "better-sqlite3";
2
+ import { listBookmarkedTweetsViaBird, listLikedTweetsViaBird } from "./bird";
3
+ import { getNativeDb } from "./db";
4
+ import { readSyncCache, writeSyncCache } from "./sync-cache";
5
+ import type {
6
+ XurlMentionData,
7
+ XurlMentionsResponse,
8
+ XurlMentionUser,
9
+ } from "./types";
10
+ import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
11
+ import {
12
+ listBookmarkedTweetsViaXurl,
13
+ listLikedTweetsViaXurl,
14
+ lookupUsersByHandles,
15
+ } from "./xurl";
16
+
17
+ export type TimelineCollectionKind = "likes" | "bookmarks";
18
+ export type TimelineCollectionMode = "auto" | "xurl" | "bird";
19
+
20
+ const DEFAULT_COLLECTION_CACHE_TTL_MS = 2 * 60_000;
21
+ const MIN_XURL_LIMIT = 5;
22
+ const MAX_XURL_LIMIT = 100;
23
+
24
+ function parseCacheTtlMs(value?: number) {
25
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
26
+ return DEFAULT_COLLECTION_CACHE_TTL_MS;
27
+ }
28
+ return Math.floor(value);
29
+ }
30
+
31
+ function parseMaxPages(value?: number) {
32
+ if (value === undefined) {
33
+ return null;
34
+ }
35
+ if (!Number.isFinite(value) || value < 1) {
36
+ throw new Error("--max-pages must be at least 1");
37
+ }
38
+ return Math.floor(value);
39
+ }
40
+
41
+ function assertLimit(limit: number) {
42
+ if (!Number.isFinite(limit) || limit < 1) {
43
+ throw new Error("--limit must be at least 1");
44
+ }
45
+ }
46
+
47
+ function assertXurlLimit(limit: number) {
48
+ if (limit < MIN_XURL_LIMIT || limit > MAX_XURL_LIMIT) {
49
+ throw new Error("xurl mode requires --limit between 5 and 100");
50
+ }
51
+ }
52
+
53
+ function resolveAccount(db: Database.Database, accountId?: string) {
54
+ const row = accountId
55
+ ? (db
56
+ .prepare(
57
+ "select id, handle, external_user_id from accounts where id = ?",
58
+ )
59
+ .get(accountId) as
60
+ | { id: string; handle: string; external_user_id: string | null }
61
+ | undefined)
62
+ : (db
63
+ .prepare(
64
+ `
65
+ select id, handle, external_user_id
66
+ from accounts
67
+ order by is_default desc, created_at asc
68
+ limit 1
69
+ `,
70
+ )
71
+ .get() as
72
+ | { id: string; handle: string; external_user_id: string | null }
73
+ | undefined);
74
+
75
+ if (!row) {
76
+ throw new Error(`Unknown account: ${accountId ?? "default"}`);
77
+ }
78
+
79
+ return {
80
+ accountId: row.id,
81
+ username: row.handle.replace(/^@/, ""),
82
+ externalUserId:
83
+ typeof row.external_user_id === "string" &&
84
+ row.external_user_id.length > 0
85
+ ? row.external_user_id
86
+ : undefined,
87
+ };
88
+ }
89
+
90
+ function getMediaCount(tweet: XurlMentionData) {
91
+ const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
92
+ return urls.filter(
93
+ (url) =>
94
+ url &&
95
+ typeof url === "object" &&
96
+ typeof (url as Record<string, unknown>).media_key === "string",
97
+ ).length;
98
+ }
99
+
100
+ function replaceTweetFts(db: Database.Database, tweetId: string, text: string) {
101
+ db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
102
+ db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
103
+ tweetId,
104
+ text,
105
+ );
106
+ }
107
+
108
+ function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
109
+ const tweets: XurlMentionData[] = [];
110
+ const seenTweetIds = new Set<string>();
111
+ const users: XurlMentionUser[] = [];
112
+ const seenUserIds = new Set<string>();
113
+
114
+ for (const page of pages) {
115
+ for (const tweet of page.data) {
116
+ if (seenTweetIds.has(tweet.id)) {
117
+ continue;
118
+ }
119
+ seenTweetIds.add(tweet.id);
120
+ tweets.push(tweet);
121
+ }
122
+
123
+ for (const user of page.includes?.users ?? []) {
124
+ if (seenUserIds.has(user.id)) {
125
+ continue;
126
+ }
127
+ seenUserIds.add(user.id);
128
+ users.push(user);
129
+ }
130
+ }
131
+
132
+ const lastPage = pages.at(-1);
133
+ return {
134
+ data: tweets,
135
+ includes: users.length > 0 ? { users } : undefined,
136
+ meta: {
137
+ result_count: tweets.length,
138
+ page_count: pages.length,
139
+ next_token: lastPage?.meta?.next_token ?? null,
140
+ ...(tweets[0] ? { newest_id: tweets[0].id } : {}),
141
+ ...(tweets.at(-1) ? { oldest_id: tweets.at(-1)?.id } : {}),
142
+ },
143
+ };
144
+ }
145
+
146
+ function mergeTimelineCollectionIntoLocalStore(
147
+ db: Database.Database,
148
+ accountId: string,
149
+ kind: TimelineCollectionKind,
150
+ payload: XurlMentionsResponse,
151
+ source: "xurl" | "bird",
152
+ ) {
153
+ const usersById = new Map(
154
+ (payload.includes?.users ?? []).map((user) => [user.id, user]),
155
+ );
156
+ const tweetKind = kind === "likes" ? "like" : "bookmark";
157
+ const liked = kind === "likes" ? 1 : 0;
158
+ const bookmarked = kind === "bookmarks" ? 1 : 0;
159
+ const upsertTweet = db.prepare(
160
+ `
161
+ insert into tweets (
162
+ id, account_id, author_profile_id, kind, text, created_at,
163
+ is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
164
+ entities_json, media_json, quoted_tweet_id
165
+ ) values (?, ?, ?, ?, ?, ?, 0, null, ?, ?, ?, ?, ?, '[]', null)
166
+ on conflict(id) do update set
167
+ account_id = excluded.account_id,
168
+ author_profile_id = excluded.author_profile_id,
169
+ kind = case
170
+ when tweets.kind in ('home', 'mention') then tweets.kind
171
+ else excluded.kind
172
+ end,
173
+ text = excluded.text,
174
+ created_at = excluded.created_at,
175
+ like_count = excluded.like_count,
176
+ media_count = excluded.media_count,
177
+ entities_json = excluded.entities_json,
178
+ media_json = excluded.media_json,
179
+ bookmarked = max(tweets.bookmarked, excluded.bookmarked),
180
+ liked = max(tweets.liked, excluded.liked)
181
+ `,
182
+ );
183
+ const upsertCollection = db.prepare(`
184
+ insert into tweet_collections (
185
+ account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
186
+ ) values (?, ?, ?, null, ?, ?, ?)
187
+ on conflict(account_id, tweet_id, kind) do update set
188
+ source = excluded.source,
189
+ raw_json = excluded.raw_json,
190
+ updated_at = excluded.updated_at
191
+ `);
192
+
193
+ db.transaction(() => {
194
+ const updatedAt = new Date().toISOString();
195
+ for (const tweet of payload.data) {
196
+ const author =
197
+ usersById.get(tweet.author_id) ??
198
+ ({
199
+ id: tweet.author_id,
200
+ username: `user_${tweet.author_id}`,
201
+ name: `user_${tweet.author_id}`,
202
+ } as const);
203
+ const profile = usersById.has(tweet.author_id)
204
+ ? upsertProfileFromXUser(db, author)
205
+ : ensureStubProfileForXUser(db, tweet.author_id);
206
+ upsertTweet.run(
207
+ tweet.id,
208
+ accountId,
209
+ profile.profile.id,
210
+ tweetKind,
211
+ tweet.text,
212
+ tweet.created_at,
213
+ Number(tweet.public_metrics?.like_count ?? 0),
214
+ getMediaCount(tweet),
215
+ bookmarked,
216
+ liked,
217
+ JSON.stringify(tweet.entities ?? {}),
218
+ );
219
+ upsertCollection.run(
220
+ accountId,
221
+ tweet.id,
222
+ kind,
223
+ source,
224
+ JSON.stringify(tweet),
225
+ updatedAt,
226
+ );
227
+ replaceTweetFts(db, tweet.id, tweet.text);
228
+ }
229
+ })();
230
+ }
231
+
232
+ async function fetchXurlCollection({
233
+ kind,
234
+ username,
235
+ userId,
236
+ limit,
237
+ all,
238
+ maxPages,
239
+ }: {
240
+ kind: TimelineCollectionKind;
241
+ username: string;
242
+ userId?: string;
243
+ limit: number;
244
+ all: boolean;
245
+ maxPages: number | null;
246
+ }) {
247
+ let resolvedUserId = userId;
248
+ if (!resolvedUserId) {
249
+ const [accountUser] = await lookupUsersByHandles([username]);
250
+ if (!accountUser?.id) {
251
+ throw new Error(`Could not resolve X user id for @${username}`);
252
+ }
253
+ resolvedUserId = String(accountUser.id);
254
+ }
255
+
256
+ const pages: XurlMentionsResponse[] = [];
257
+ let nextToken: string | undefined;
258
+ let pageCount = 0;
259
+ do {
260
+ const payload =
261
+ kind === "likes"
262
+ ? await listLikedTweetsViaXurl({
263
+ maxResults: limit,
264
+ username,
265
+ userId: resolvedUserId,
266
+ paginationToken: nextToken,
267
+ })
268
+ : await listBookmarkedTweetsViaXurl({
269
+ maxResults: limit,
270
+ username,
271
+ userId: resolvedUserId,
272
+ paginationToken: nextToken,
273
+ });
274
+ pages.push(payload);
275
+ nextToken =
276
+ typeof payload.meta?.next_token === "string"
277
+ ? payload.meta.next_token
278
+ : undefined;
279
+ pageCount += 1;
280
+ } while (all && nextToken && (maxPages === null || pageCount < maxPages));
281
+
282
+ return mergePayloads(pages);
283
+ }
284
+
285
+ async function fetchBirdCollection({
286
+ kind,
287
+ limit,
288
+ all,
289
+ maxPages,
290
+ }: {
291
+ kind: TimelineCollectionKind;
292
+ limit: number;
293
+ all: boolean;
294
+ maxPages: number | null;
295
+ }) {
296
+ return kind === "likes"
297
+ ? listLikedTweetsViaBird({
298
+ maxResults: limit,
299
+ all,
300
+ maxPages: maxPages ?? undefined,
301
+ })
302
+ : listBookmarkedTweetsViaBird({
303
+ maxResults: limit,
304
+ all,
305
+ maxPages: maxPages ?? undefined,
306
+ });
307
+ }
308
+
309
+ export async function syncTimelineCollection({
310
+ kind,
311
+ account,
312
+ mode = "auto",
313
+ limit = 20,
314
+ all = false,
315
+ maxPages,
316
+ refresh = false,
317
+ cacheTtlMs,
318
+ }: {
319
+ kind: TimelineCollectionKind;
320
+ account?: string;
321
+ mode?: TimelineCollectionMode;
322
+ limit?: number;
323
+ all?: boolean;
324
+ maxPages?: number;
325
+ refresh?: boolean;
326
+ cacheTtlMs?: number;
327
+ }) {
328
+ assertLimit(limit);
329
+ const parsedMaxPages = parseMaxPages(maxPages);
330
+ if (mode === "xurl" || mode === "auto") {
331
+ assertXurlLimit(limit);
332
+ }
333
+
334
+ const db = getNativeDb();
335
+ const resolvedAccount = resolveAccount(db, account);
336
+ const cacheKey = `${kind}:${mode}:${resolvedAccount.accountId}:${String(limit)}:${all ? "all" : "single"}:${parsedMaxPages === null ? "all-pages" : String(parsedMaxPages)}`;
337
+ const ttlMs = parseCacheTtlMs(cacheTtlMs);
338
+ const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
339
+ const cacheAgeMs = cached
340
+ ? Date.now() - new Date(cached.updatedAt).getTime()
341
+ : Number.POSITIVE_INFINITY;
342
+
343
+ if (!refresh && cached && cacheAgeMs <= ttlMs) {
344
+ return {
345
+ ok: true,
346
+ source: "cache",
347
+ kind,
348
+ accountId: resolvedAccount.accountId,
349
+ count: cached.value.data.length,
350
+ payload: cached.value,
351
+ };
352
+ }
353
+
354
+ let source: "xurl" | "bird";
355
+ let payload: XurlMentionsResponse;
356
+ if (mode === "bird") {
357
+ payload = await fetchBirdCollection({
358
+ kind,
359
+ limit,
360
+ all,
361
+ maxPages: parsedMaxPages,
362
+ });
363
+ source = "bird";
364
+ } else {
365
+ try {
366
+ payload = await fetchXurlCollection({
367
+ kind,
368
+ username: resolvedAccount.username,
369
+ userId: resolvedAccount.externalUserId,
370
+ limit,
371
+ all,
372
+ maxPages: parsedMaxPages,
373
+ });
374
+ source = "xurl";
375
+ } catch (error) {
376
+ if (mode === "xurl") {
377
+ throw error;
378
+ }
379
+ payload = await fetchBirdCollection({
380
+ kind,
381
+ limit,
382
+ all,
383
+ maxPages: parsedMaxPages,
384
+ });
385
+ source = "bird";
386
+ }
387
+ }
388
+
389
+ mergeTimelineCollectionIntoLocalStore(
390
+ db,
391
+ resolvedAccount.accountId,
392
+ kind,
393
+ payload,
394
+ source,
395
+ );
396
+ writeSyncCache(cacheKey, payload, db);
397
+
398
+ return {
399
+ ok: true,
400
+ source,
401
+ kind,
402
+ accountId: resolvedAccount.accountId,
403
+ count: payload.data.length,
404
+ payload,
405
+ };
406
+ }
package/src/lib/types.ts CHANGED
@@ -8,6 +8,7 @@ export interface AccountRecord {
8
8
  id: string;
9
9
  name: string;
10
10
  handle: string;
11
+ externalUserId?: string | null;
11
12
  transport: string;
12
13
  isDefault: number;
13
14
  createdAt: string;
@@ -95,7 +96,7 @@ export interface TimelineItem {
95
96
  id: string;
96
97
  accountId: string;
97
98
  accountHandle: string;
98
- kind: Exclude<ResourceKind, "dms">;
99
+ kind: "home" | "mention" | "like" | "bookmark";
99
100
  text: string;
100
101
  createdAt: string;
101
102
  isReplied: boolean;
@@ -144,6 +145,8 @@ export interface TimelineQuery {
144
145
  until?: string;
145
146
  includeReplies?: boolean;
146
147
  qualityFilter?: TimelineQualityFilter;
148
+ likedOnly?: boolean;
149
+ bookmarkedOnly?: boolean;
147
150
  limit?: number;
148
151
  }
149
152