birdclaw 0.1.0 → 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.
package/src/lib/xurl.ts CHANGED
@@ -3,6 +3,7 @@ import { promisify } from "node:util";
3
3
  import type {
4
4
  TransportStatus,
5
5
  XurlMentionsResponse,
6
+ XurlMentionUser,
6
7
  XurlUserTweet,
7
8
  } from "./types";
8
9
 
@@ -131,7 +132,7 @@ export async function getTransportStatus(): Promise<TransportStatus> {
131
132
  return transportStatusCache.pending;
132
133
  }
133
134
 
134
- const pending = (async () => {
135
+ const pending: Promise<TransportStatus> = (async () => {
135
136
  const installed = await hasXurl();
136
137
  if (!installed) {
137
138
  return {
@@ -242,7 +243,7 @@ export async function lookupUsersByIds(ids: string[]) {
242
243
  });
243
244
  const payload = await runJsonCommand([`/2/users?${query.toString()}`]);
244
245
  const data = payload.data;
245
- return Array.isArray(data) ? (data as Array<Record<string, unknown>>) : [];
246
+ return Array.isArray(data) ? (data as XurlMentionUser[]) : [];
246
247
  }
247
248
 
248
249
  export async function lookupUsersByHandles(handles: string[]) {
@@ -257,7 +258,7 @@ export async function lookupUsersByHandles(handles: string[]) {
257
258
  });
258
259
  const payload = await runJsonCommand([`/2/users/by?${query.toString()}`]);
259
260
  const data = payload.data;
260
- return Array.isArray(data) ? (data as Array<Record<string, unknown>>) : [];
261
+ return Array.isArray(data) ? (data as XurlMentionUser[]) : [];
261
262
  }
262
263
 
263
264
  export async function lookupAuthenticatedUser() {
@@ -357,6 +358,91 @@ export async function listMentionsViaXurl({
357
358
  };
358
359
  }
359
360
 
361
+ async function listTimelineCollectionViaXurl({
362
+ collection,
363
+ maxResults,
364
+ username,
365
+ userId,
366
+ paginationToken,
367
+ }: {
368
+ collection: "liked_tweets" | "bookmarks";
369
+ maxResults: number;
370
+ username?: string;
371
+ userId?: string;
372
+ paginationToken?: string;
373
+ }): Promise<XurlMentionsResponse> {
374
+ let resolvedUserId = userId;
375
+ if (!resolvedUserId) {
376
+ if (username) {
377
+ const [user] = await lookupUsersByHandles([username]);
378
+ if (!user?.id) {
379
+ throw new Error(`Could not resolve X user id for @${username}`);
380
+ }
381
+ resolvedUserId = String(user.id);
382
+ } else {
383
+ const user = await lookupAuthenticatedUser();
384
+ if (!user?.id) {
385
+ throw new Error("Could not resolve authenticated X user id");
386
+ }
387
+ resolvedUserId = String(user.id);
388
+ }
389
+ }
390
+
391
+ const query = new URLSearchParams({
392
+ max_results: String(maxResults),
393
+ expansions: "author_id",
394
+ "tweet.fields": "created_at,conversation_id,entities,public_metrics",
395
+ "user.fields":
396
+ "description,public_metrics,profile_image_url,created_at,verified",
397
+ });
398
+ if (paginationToken) {
399
+ query.set("pagination_token", paginationToken);
400
+ }
401
+
402
+ const payload = await runJsonCommand([
403
+ "--auth",
404
+ "oauth2",
405
+ `/2/users/${resolvedUserId}/${collection}?${query.toString()}`,
406
+ ]);
407
+ return {
408
+ data: Array.isArray(payload.data)
409
+ ? (payload.data as XurlMentionsResponse["data"])
410
+ : [],
411
+ includes:
412
+ payload.includes && typeof payload.includes === "object"
413
+ ? (payload.includes as XurlMentionsResponse["includes"])
414
+ : undefined,
415
+ meta:
416
+ payload.meta && typeof payload.meta === "object"
417
+ ? (payload.meta as XurlMentionsResponse["meta"])
418
+ : undefined,
419
+ };
420
+ }
421
+
422
+ export async function listLikedTweetsViaXurl(options: {
423
+ maxResults: number;
424
+ username?: string;
425
+ userId?: string;
426
+ paginationToken?: string;
427
+ }): Promise<XurlMentionsResponse> {
428
+ return listTimelineCollectionViaXurl({
429
+ ...options,
430
+ collection: "liked_tweets",
431
+ });
432
+ }
433
+
434
+ export async function listBookmarkedTweetsViaXurl(options: {
435
+ maxResults: number;
436
+ username?: string;
437
+ userId?: string;
438
+ paginationToken?: string;
439
+ }): Promise<XurlMentionsResponse> {
440
+ return listTimelineCollectionViaXurl({
441
+ ...options,
442
+ collection: "bookmarks",
443
+ });
444
+ }
445
+
360
446
  export async function listBlockedUsers(
361
447
  userId: string,
362
448
  paginationToken?: string,
@@ -373,7 +459,7 @@ export async function listBlockedUsers(
373
459
  `/2/users/${userId}/blocking?${query}`,
374
460
  ]);
375
461
  const data = Array.isArray(payload.data)
376
- ? (payload.data as Array<Record<string, unknown>>)
462
+ ? (payload.data as XurlMentionUser[])
377
463
  : [];
378
464
  const meta =
379
465
  payload.meta && typeof payload.meta === "object"
@@ -10,8 +10,10 @@
10
10
 
11
11
  import { Route as rootRouteImport } from './routes/__root'
12
12
  import { Route as MentionsRouteImport } from './routes/mentions'
13
+ import { Route as LikesRouteImport } from './routes/likes'
13
14
  import { Route as InboxRouteImport } from './routes/inbox'
14
15
  import { Route as DmsRouteImport } from './routes/dms'
16
+ import { Route as BookmarksRouteImport } from './routes/bookmarks'
15
17
  import { Route as BlocksRouteImport } from './routes/blocks'
16
18
  import { Route as IndexRouteImport } from './routes/index'
17
19
  import { Route as ApiStatusRouteImport } from './routes/api/status'
@@ -26,6 +28,11 @@ const MentionsRoute = MentionsRouteImport.update({
26
28
  path: '/mentions',
27
29
  getParentRoute: () => rootRouteImport,
28
30
  } as any)
31
+ const LikesRoute = LikesRouteImport.update({
32
+ id: '/likes',
33
+ path: '/likes',
34
+ getParentRoute: () => rootRouteImport,
35
+ } as any)
29
36
  const InboxRoute = InboxRouteImport.update({
30
37
  id: '/inbox',
31
38
  path: '/inbox',
@@ -36,6 +43,11 @@ const DmsRoute = DmsRouteImport.update({
36
43
  path: '/dms',
37
44
  getParentRoute: () => rootRouteImport,
38
45
  } as any)
46
+ const BookmarksRoute = BookmarksRouteImport.update({
47
+ id: '/bookmarks',
48
+ path: '/bookmarks',
49
+ getParentRoute: () => rootRouteImport,
50
+ } as any)
39
51
  const BlocksRoute = BlocksRouteImport.update({
40
52
  id: '/blocks',
41
53
  path: '/blocks',
@@ -80,8 +92,10 @@ const ApiActionRoute = ApiActionRouteImport.update({
80
92
  export interface FileRoutesByFullPath {
81
93
  '/': typeof IndexRoute
82
94
  '/blocks': typeof BlocksRoute
95
+ '/bookmarks': typeof BookmarksRoute
83
96
  '/dms': typeof DmsRoute
84
97
  '/inbox': typeof InboxRoute
98
+ '/likes': typeof LikesRoute
85
99
  '/mentions': typeof MentionsRoute
86
100
  '/api/action': typeof ApiActionRoute
87
101
  '/api/avatar': typeof ApiAvatarRoute
@@ -93,8 +107,10 @@ export interface FileRoutesByFullPath {
93
107
  export interface FileRoutesByTo {
94
108
  '/': typeof IndexRoute
95
109
  '/blocks': typeof BlocksRoute
110
+ '/bookmarks': typeof BookmarksRoute
96
111
  '/dms': typeof DmsRoute
97
112
  '/inbox': typeof InboxRoute
113
+ '/likes': typeof LikesRoute
98
114
  '/mentions': typeof MentionsRoute
99
115
  '/api/action': typeof ApiActionRoute
100
116
  '/api/avatar': typeof ApiAvatarRoute
@@ -107,8 +123,10 @@ export interface FileRoutesById {
107
123
  __root__: typeof rootRouteImport
108
124
  '/': typeof IndexRoute
109
125
  '/blocks': typeof BlocksRoute
126
+ '/bookmarks': typeof BookmarksRoute
110
127
  '/dms': typeof DmsRoute
111
128
  '/inbox': typeof InboxRoute
129
+ '/likes': typeof LikesRoute
112
130
  '/mentions': typeof MentionsRoute
113
131
  '/api/action': typeof ApiActionRoute
114
132
  '/api/avatar': typeof ApiAvatarRoute
@@ -122,8 +140,10 @@ export interface FileRouteTypes {
122
140
  fullPaths:
123
141
  | '/'
124
142
  | '/blocks'
143
+ | '/bookmarks'
125
144
  | '/dms'
126
145
  | '/inbox'
146
+ | '/likes'
127
147
  | '/mentions'
128
148
  | '/api/action'
129
149
  | '/api/avatar'
@@ -135,8 +155,10 @@ export interface FileRouteTypes {
135
155
  to:
136
156
  | '/'
137
157
  | '/blocks'
158
+ | '/bookmarks'
138
159
  | '/dms'
139
160
  | '/inbox'
161
+ | '/likes'
140
162
  | '/mentions'
141
163
  | '/api/action'
142
164
  | '/api/avatar'
@@ -148,8 +170,10 @@ export interface FileRouteTypes {
148
170
  | '__root__'
149
171
  | '/'
150
172
  | '/blocks'
173
+ | '/bookmarks'
151
174
  | '/dms'
152
175
  | '/inbox'
176
+ | '/likes'
153
177
  | '/mentions'
154
178
  | '/api/action'
155
179
  | '/api/avatar'
@@ -162,8 +186,10 @@ export interface FileRouteTypes {
162
186
  export interface RootRouteChildren {
163
187
  IndexRoute: typeof IndexRoute
164
188
  BlocksRoute: typeof BlocksRoute
189
+ BookmarksRoute: typeof BookmarksRoute
165
190
  DmsRoute: typeof DmsRoute
166
191
  InboxRoute: typeof InboxRoute
192
+ LikesRoute: typeof LikesRoute
167
193
  MentionsRoute: typeof MentionsRoute
168
194
  ApiActionRoute: typeof ApiActionRoute
169
195
  ApiAvatarRoute: typeof ApiAvatarRoute
@@ -182,6 +208,13 @@ declare module '@tanstack/react-router' {
182
208
  preLoaderRoute: typeof MentionsRouteImport
183
209
  parentRoute: typeof rootRouteImport
184
210
  }
211
+ '/likes': {
212
+ id: '/likes'
213
+ path: '/likes'
214
+ fullPath: '/likes'
215
+ preLoaderRoute: typeof LikesRouteImport
216
+ parentRoute: typeof rootRouteImport
217
+ }
185
218
  '/inbox': {
186
219
  id: '/inbox'
187
220
  path: '/inbox'
@@ -196,6 +229,13 @@ declare module '@tanstack/react-router' {
196
229
  preLoaderRoute: typeof DmsRouteImport
197
230
  parentRoute: typeof rootRouteImport
198
231
  }
232
+ '/bookmarks': {
233
+ id: '/bookmarks'
234
+ path: '/bookmarks'
235
+ fullPath: '/bookmarks'
236
+ preLoaderRoute: typeof BookmarksRouteImport
237
+ parentRoute: typeof rootRouteImport
238
+ }
199
239
  '/blocks': {
200
240
  id: '/blocks'
201
241
  path: '/blocks'
@@ -258,8 +298,10 @@ declare module '@tanstack/react-router' {
258
298
  const rootRouteChildren: RootRouteChildren = {
259
299
  IndexRoute: IndexRoute,
260
300
  BlocksRoute: BlocksRoute,
301
+ BookmarksRoute: BookmarksRoute,
261
302
  DmsRoute: DmsRoute,
262
303
  InboxRoute: InboxRoute,
304
+ LikesRoute: LikesRoute,
263
305
  MentionsRoute: MentionsRoute,
264
306
  ApiActionRoute: ApiActionRoute,
265
307
  ApiAvatarRoute: ApiAvatarRoute,
@@ -4,7 +4,7 @@ import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
4
4
  import type { ReactNode } from "react";
5
5
  import { AppNav } from "#/components/AppNav";
6
6
  import { ThemeProvider, themeScript } from "#/lib/theme";
7
- import { bodyClass, siteShellClass } from "#/lib/ui";
7
+ import { bodyClass, pageWrapClass, siteShellClass } from "#/lib/ui";
8
8
 
9
9
  import appCss from "../styles.css?url";
10
10
 
@@ -29,9 +29,14 @@ export const Route = createRootRoute({
29
29
  },
30
30
  ],
31
31
  }),
32
+ notFoundComponent: NotFoundView,
32
33
  shellComponent: RootDocument,
33
34
  });
34
35
 
36
+ function NotFoundView() {
37
+ return <main className={pageWrapClass}>Not Found</main>;
38
+ }
39
+
35
40
  function RootDocument({ children }: { children: ReactNode }) {
36
41
  return (
37
42
  <html lang="en" suppressHydrationWarning>
@@ -3,13 +3,23 @@ import { addBlock, removeBlock, syncBlocks } from "#/lib/blocks";
3
3
  import { scoreInbox } from "#/lib/inbox";
4
4
  import { addMute, removeMute } from "#/lib/mutes";
5
5
  import { createDmReply, createPost, createTweetReply } from "#/lib/queries";
6
+ import type { ActionsTransport } from "#/lib/config";
6
7
  import type { InboxKind } from "#/lib/types";
7
8
 
9
+ function parseActionsTransport(
10
+ value: string | undefined,
11
+ ): ActionsTransport | undefined {
12
+ return value === "auto" || value === "bird" || value === "xurl"
13
+ ? value
14
+ : undefined;
15
+ }
16
+
8
17
  export const Route = createFileRoute("/api/action")({
9
18
  server: {
10
19
  handlers: {
11
20
  POST: async ({ request }) => {
12
21
  const body = (await request.json()) as Record<string, string>;
22
+ const transport = parseActionsTransport(body.transport);
13
23
  let result: unknown;
14
24
 
15
25
  if (body.kind === "post") {
@@ -38,7 +48,7 @@ export const Route = createFileRoute("/api/action")({
38
48
  body.accountId || "acct_primary",
39
49
  body.query || "",
40
50
  {
41
- transport: body.transport,
51
+ transport,
42
52
  },
43
53
  );
44
54
  } else if (body.kind === "unblockProfile") {
@@ -46,7 +56,7 @@ export const Route = createFileRoute("/api/action")({
46
56
  body.accountId || "acct_primary",
47
57
  body.query || "",
48
58
  {
49
- transport: body.transport,
59
+ transport,
50
60
  },
51
61
  );
52
62
  } else if (body.kind === "muteProfile") {
@@ -54,7 +64,7 @@ export const Route = createFileRoute("/api/action")({
54
64
  body.accountId || "acct_primary",
55
65
  body.query || "",
56
66
  {
57
- transport: body.transport,
67
+ transport,
58
68
  },
59
69
  );
60
70
  } else if (body.kind === "unmuteProfile") {
@@ -62,7 +72,7 @@ export const Route = createFileRoute("/api/action")({
62
72
  body.accountId || "acct_primary",
63
73
  body.query || "",
64
74
  {
65
- transport: body.transport,
75
+ transport,
66
76
  },
67
77
  );
68
78
  } else if (body.kind === "syncBlocks") {
@@ -1,4 +1,5 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
+ import { maybeAutoUpdateBackup } from "#/lib/backup";
2
3
  import { listInboxItems } from "#/lib/inbox";
3
4
  import type { InboxKind } from "#/lib/types";
4
5
 
@@ -11,7 +12,8 @@ function parseNumber(value: string | null) {
11
12
  export const Route = createFileRoute("/api/inbox")({
12
13
  server: {
13
14
  handlers: {
14
- GET: ({ request }) => {
15
+ GET: async ({ request }) => {
16
+ await maybeAutoUpdateBackup();
15
17
  const url = new URL(request.url);
16
18
  const kind = (url.searchParams.get("kind") ?? "mixed") as InboxKind;
17
19
  return new Response(
@@ -1,6 +1,11 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
+ import { maybeAutoUpdateBackup } from "#/lib/backup";
2
3
  import { queryResource } from "#/lib/queries";
3
- import type { ReplyFilter, ResourceKind } from "#/lib/types";
4
+ import type {
5
+ ReplyFilter,
6
+ ResourceKind,
7
+ TimelineQualityFilter,
8
+ } from "#/lib/types";
4
9
 
5
10
  function json(data: unknown) {
6
11
  return new Response(JSON.stringify(data), {
@@ -23,10 +28,15 @@ function parseNumber(value: string | null) {
23
28
  return Number.isFinite(parsed) ? parsed : undefined;
24
29
  }
25
30
 
31
+ function parseQualityFilter(value: string | null): TimelineQualityFilter {
32
+ return value === "summary" ? "summary" : "all";
33
+ }
34
+
26
35
  export const Route = createFileRoute("/api/query")({
27
36
  server: {
28
37
  handlers: {
29
- GET: ({ request }) => {
38
+ GET: async ({ request }) => {
39
+ await maybeAutoUpdateBackup();
30
40
  const url = new URL(request.url);
31
41
  const resource = (url.searchParams.get("resource") ??
32
42
  "home") as ResourceKind;
@@ -34,6 +44,14 @@ export const Route = createFileRoute("/api/query")({
34
44
  account: url.searchParams.get("account") ?? undefined,
35
45
  search: url.searchParams.get("search") ?? undefined,
36
46
  replyFilter: parseReplyFilter(url.searchParams.get("replyFilter")),
47
+ since: url.searchParams.get("since") ?? undefined,
48
+ until: url.searchParams.get("until") ?? undefined,
49
+ includeReplies: url.searchParams.get("originalsOnly") !== "true",
50
+ qualityFilter: parseQualityFilter(
51
+ url.searchParams.get("qualityFilter"),
52
+ ),
53
+ likedOnly: url.searchParams.get("liked") === "true",
54
+ bookmarkedOnly: url.searchParams.get("bookmarked") === "true",
37
55
  limit: parseNumber(url.searchParams.get("limit")) ?? undefined,
38
56
  };
39
57
 
@@ -1,15 +1,18 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
+ import { maybeAutoUpdateBackup } from "#/lib/backup";
2
3
  import { getQueryEnvelope } from "#/lib/queries";
3
4
 
4
5
  export const Route = createFileRoute("/api/status")({
5
6
  server: {
6
7
  handlers: {
7
- GET: async () =>
8
- new Response(JSON.stringify(await getQueryEnvelope()), {
8
+ GET: async () => {
9
+ await maybeAutoUpdateBackup();
10
+ return new Response(JSON.stringify(await getQueryEnvelope()), {
9
11
  headers: {
10
12
  "content-type": "application/json",
11
13
  },
12
- }),
14
+ });
15
+ },
13
16
  },
14
17
  },
15
18
  });
@@ -0,0 +1,18 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { SavedTimelineView } from "#/components/SavedTimelineView";
3
+
4
+ export const Route = createFileRoute("/bookmarks")({
5
+ component: BookmarksRoute,
6
+ });
7
+
8
+ function BookmarksRoute() {
9
+ return (
10
+ <SavedTimelineView
11
+ eyebrow="bookmarks"
12
+ filter="bookmarked"
13
+ loadingLabel="Loading bookmarks..."
14
+ searchPlaceholder="Search bookmarks"
15
+ title="Saved threads, receipts, and future rabbit holes."
16
+ />
17
+ );
18
+ }
@@ -0,0 +1,18 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { SavedTimelineView } from "#/components/SavedTimelineView";
3
+
4
+ export const Route = createFileRoute("/likes")({
5
+ component: LikesRoute,
6
+ });
7
+
8
+ function LikesRoute() {
9
+ return (
10
+ <SavedTimelineView
11
+ eyebrow="liked posts"
12
+ filter="liked"
13
+ loadingLabel="Loading liked posts..."
14
+ searchPlaceholder="Search likes"
15
+ title="Things worth keeping in the orbit."
16
+ />
17
+ );
18
+ }
package/tsconfig.json CHANGED
@@ -4,7 +4,6 @@
4
4
  "target": "ES2022",
5
5
  "jsx": "react-jsx",
6
6
  "module": "ESNext",
7
- "baseUrl": ".",
8
7
  "ignoreDeprecations": "6.0",
9
8
  "paths": {
10
9
  "#/*": ["./src/*"],