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.
Files changed (64) hide show
  1. package/CHANGELOG.md +33 -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 +450 -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 +1565 -65
  22. package/src/lib/authored-live.ts +1074 -0
  23. package/src/lib/backup.ts +316 -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 +191 -4
  29. package/src/lib/follow-graph.ts +1053 -0
  30. package/src/lib/link-index.ts +11 -98
  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 +185 -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 +20 -3
  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
@@ -9,22 +9,21 @@ import type {
9
9
  QueryEnvelope,
10
10
  } from "#/lib/types";
11
11
  import {
12
- actionButtonClass,
13
- cardHeaderClass,
14
- contentCardClass,
12
+ blockRowBodyClass,
13
+ blockRowClass,
15
14
  cx,
15
+ dangerButtonClass,
16
+ emptyStateClass,
16
17
  errorCopyClass,
17
- eyebrowClass,
18
- heroControlsBlocksClass,
19
- heroControlsClass,
20
- heroCopyClass,
21
- heroShellClass,
22
- heroTitleClass,
23
- identityBlockClass,
24
- metaRowClass,
25
18
  mutedDotClass,
26
- pageWrapClass,
27
- stackGridClass,
19
+ pageHeaderClass,
20
+ pageHeaderRowClass,
21
+ pageSubtitleClass,
22
+ pageTitleClass,
23
+ primaryButtonClass,
24
+ secondaryButtonClass,
25
+ selectFieldClass,
26
+ statusCopyClass,
28
27
  textFieldClass,
29
28
  textFieldShortClass,
30
29
  textFieldWideClass,
@@ -142,7 +141,7 @@ function BlocksRoute() {
142
141
  }
143
142
  setMessage(
144
143
  data.transport?.output ??
145
- `Synced ${data.syncedCount ?? 0} remote blocks`,
144
+ `Synced ${String(data.syncedCount ?? 0)} remote blocks`,
146
145
  );
147
146
  },
148
147
  )
@@ -162,12 +161,12 @@ function BlocksRoute() {
162
161
  const subtitle = useMemo(() => {
163
162
  if (!meta) {
164
163
  return items.length > 0
165
- ? `${items.length} blocked profiles in view · loading transport status...`
164
+ ? `${String(items.length)} blocked profiles · loading transport...`
166
165
  : "Loading local blocklist...";
167
166
  }
168
167
  if (isSyncing)
169
168
  return `Syncing remote blocklist · ${meta.transport.statusText}`;
170
- return `${items.length} blocked profiles in view · ${meta.transport.statusText}`;
169
+ return `${String(items.length)} blocked profiles · ${meta.transport.statusText}`;
171
170
  }, [isSyncing, items.length, meta]);
172
171
 
173
172
  async function submit(
@@ -192,9 +191,14 @@ function BlocksRoute() {
192
191
  }),
193
192
  });
194
193
  const data = (await response.json()) as {
194
+ ok?: boolean;
195
195
  profile?: { handle?: string };
196
- transport?: { output?: string };
196
+ transport?: { ok?: boolean; output?: string };
197
197
  };
198
+ if (data.ok === false || data.transport?.ok === false) {
199
+ setError(data.transport?.output ?? "Blocklist action failed");
200
+ return;
201
+ }
198
202
 
199
203
  setMessage(
200
204
  `${kind === "blockProfile" ? "Blocked" : "Unblocked"} @${
@@ -214,18 +218,20 @@ function BlocksRoute() {
214
218
  }
215
219
 
216
220
  return (
217
- <main className={pageWrapClass}>
218
- <section className={heroShellClass}>
219
- <div>
220
- <p className={eyebrowClass}>blocks</p>
221
- <h2 className={heroTitleClass}>
222
- Maintain a clean blocklist locally.
223
- </h2>
224
- <p className={heroCopyClass}>{subtitle}</p>
221
+ <>
222
+ <header className={pageHeaderClass}>
223
+ <div className={pageHeaderRowClass}>
224
+ <div className="flex min-w-0 flex-col">
225
+ <h1 className={pageTitleClass}>Blocks</h1>
226
+ <h2 className={cx(pageSubtitleClass, "text-[14px]")}>
227
+ Maintain a clean blocklist locally.
228
+ </h2>
229
+ <p className={pageSubtitleClass}>{subtitle}</p>
230
+ </div>
225
231
  </div>
226
- <div className={cx(heroControlsClass, heroControlsBlocksClass)}>
232
+ <div className="flex flex-wrap items-center gap-2 px-4 pb-3">
227
233
  <select
228
- className={cx(textFieldClass, textFieldShortClass)}
234
+ className={cx(selectFieldClass, textFieldShortClass)}
229
235
  disabled={!isReady}
230
236
  onChange={(event) => setAccountId(event.target.value)}
231
237
  value={accountId}
@@ -237,14 +243,18 @@ function BlocksRoute() {
237
243
  ))}
238
244
  </select>
239
245
  <input
240
- className={cx(textFieldClass, textFieldWideClass)}
246
+ className={cx(
247
+ textFieldClass,
248
+ textFieldWideClass,
249
+ "flex-1 min-w-[200px]",
250
+ )}
241
251
  disabled={!hasAccountId}
242
252
  onChange={(event) => setSearch(event.target.value)}
243
253
  placeholder="Handle, name, bio, or Twitter URL"
244
254
  value={search}
245
255
  />
246
256
  <button
247
- className={actionButtonClass}
257
+ className={primaryButtonClass}
248
258
  disabled={!hasAccountId || isSubmitting || !search.trim()}
249
259
  onClick={() => void submit("blockProfile", search)}
250
260
  type="button"
@@ -252,29 +262,31 @@ function BlocksRoute() {
252
262
  {isSubmitting ? "Working..." : "Block"}
253
263
  </button>
254
264
  </div>
255
- </section>
265
+ </header>
256
266
 
257
- {message ? <p className={timestampClass}>{message}</p> : null}
267
+ {message ? <p className={statusCopyClass}>{message}</p> : null}
258
268
  {error ? <p className={errorCopyClass}>{error}</p> : null}
259
269
 
260
270
  {matches.length > 0 ? (
261
- <section className={stackGridClass}>
271
+ <section className="flex flex-col">
272
+ <h2 className="px-4 pt-3 pb-1 text-[13px] font-semibold uppercase tracking-wide text-[var(--ink-soft)]">
273
+ Search matches
274
+ </h2>
262
275
  {matches.map((match) => (
263
- <article
264
- className={cx(contentCardClass, "block-card")}
265
- key={match.profile.id}
266
- >
267
- <div className={cardHeaderClass}>
268
- <div className={identityBlockClass}>
269
- <AvatarChip
270
- avatarUrl={match.profile.avatarUrl}
271
- hue={match.profile.avatarHue}
272
- name={match.profile.displayName}
273
- profileId={match.profile.id}
274
- />
275
- <div>
276
- <strong>{match.profile.displayName}</strong>
277
- <div className={metaRowClass}>
276
+ <article className={blockRowClass} key={match.profile.id}>
277
+ <AvatarChip
278
+ avatarUrl={match.profile.avatarUrl}
279
+ hue={match.profile.avatarHue}
280
+ name={match.profile.displayName}
281
+ profileId={match.profile.id}
282
+ />
283
+ <div className={blockRowBodyClass}>
284
+ <div className="flex items-center justify-between gap-2">
285
+ <div className="flex min-w-0 flex-col">
286
+ <strong className="truncate text-[15px] text-[var(--ink)]">
287
+ {match.profile.displayName}
288
+ </strong>
289
+ <div className="flex flex-wrap items-center gap-1.5 text-[13px] text-[var(--ink-soft)]">
278
290
  <span>@{match.profile.handle}</span>
279
291
  <span className={mutedDotClass} />
280
292
  <span>
@@ -283,43 +295,52 @@ function BlocksRoute() {
283
295
  </span>
284
296
  </div>
285
297
  </div>
298
+ <button
299
+ className={
300
+ match.isBlocked ? secondaryButtonClass : dangerButtonClass
301
+ }
302
+ onClick={() =>
303
+ void submit(
304
+ match.isBlocked ? "unblockProfile" : "blockProfile",
305
+ match.profile.id,
306
+ )
307
+ }
308
+ type="button"
309
+ >
310
+ {match.isBlocked ? "Unblock" : "Block"}
311
+ </button>
286
312
  </div>
287
- <button
288
- className={actionButtonClass}
289
- onClick={() =>
290
- void submit(
291
- match.isBlocked ? "unblockProfile" : "blockProfile",
292
- match.profile.id,
293
- )
294
- }
295
- type="button"
296
- >
297
- {match.isBlocked ? "Unblock" : "Block"}
298
- </button>
313
+ <p className="text-[14px] leading-[1.4] text-[var(--ink)]">
314
+ {match.profile.bio}
315
+ </p>
299
316
  </div>
300
- <p className="mt-2.5">{match.profile.bio}</p>
301
317
  </article>
302
318
  ))}
303
319
  </section>
304
320
  ) : null}
305
321
 
306
- <section className={stackGridClass}>
322
+ <section className="flex flex-col">
323
+ {items.length === 0 && matches.length === 0 ? (
324
+ <div className={emptyStateClass}>No blocks in this account.</div>
325
+ ) : null}
307
326
  {items.map((item) => (
308
327
  <article
309
- className={cx(contentCardClass, "block-card")}
328
+ className={blockRowClass}
310
329
  key={item.accountId + item.profile.id}
311
330
  >
312
- <div className={cardHeaderClass}>
313
- <div className={identityBlockClass}>
314
- <AvatarChip
315
- avatarUrl={item.profile.avatarUrl}
316
- hue={item.profile.avatarHue}
317
- name={item.profile.displayName}
318
- profileId={item.profile.id}
319
- />
320
- <div>
321
- <strong>{item.profile.displayName}</strong>
322
- <div className={metaRowClass}>
331
+ <AvatarChip
332
+ avatarUrl={item.profile.avatarUrl}
333
+ hue={item.profile.avatarHue}
334
+ name={item.profile.displayName}
335
+ profileId={item.profile.id}
336
+ />
337
+ <div className={blockRowBodyClass}>
338
+ <div className="flex items-center justify-between gap-2">
339
+ <div className="flex min-w-0 flex-col">
340
+ <strong className="truncate text-[15px] text-[var(--ink)]">
341
+ {item.profile.displayName}
342
+ </strong>
343
+ <div className="flex flex-wrap items-center gap-1.5 text-[13px] text-[var(--ink-soft)]">
323
344
  <span>@{item.profile.handle}</span>
324
345
  <span className={mutedDotClass} />
325
346
  <span>{item.accountHandle}</span>
@@ -330,23 +351,27 @@ function BlocksRoute() {
330
351
  </span>
331
352
  </div>
332
353
  </div>
354
+ <button
355
+ className={secondaryButtonClass}
356
+ onClick={() => void submit("unblockProfile", item.profile.id)}
357
+ type="button"
358
+ >
359
+ Unblock
360
+ </button>
333
361
  </div>
334
- <button
335
- className={actionButtonClass}
336
- onClick={() => void submit("unblockProfile", item.profile.id)}
337
- type="button"
338
- >
339
- Unblock
340
- </button>
362
+ {item.profile.bio ? (
363
+ <p className="text-[14px] leading-[1.4] text-[var(--ink)]">
364
+ {item.profile.bio}
365
+ </p>
366
+ ) : null}
367
+ <p className={timestampClass}>
368
+ Blocked {new Date(item.blockedAt).toLocaleString()} ·{" "}
369
+ {item.source}
370
+ </p>
341
371
  </div>
342
- <p className="mt-2.5">{item.profile.bio}</p>
343
- <p className={timestampClass}>
344
- Blocked {new Date(item.blockedAt).toLocaleString()} ·{" "}
345
- {item.source}
346
- </p>
347
372
  </article>
348
373
  ))}
349
374
  </section>
350
- </main>
375
+ </>
351
376
  );
352
377
  }
@@ -1,4 +1,5 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
+ import { Search } from "lucide-react";
2
3
  import { useEffect, useMemo, useState } from "react";
3
4
  import { DmWorkspace } from "#/components/DmWorkspace";
4
5
  import type {
@@ -10,27 +11,34 @@ import type {
10
11
  } from "#/lib/types";
11
12
  import {
12
13
  cx,
13
- dmPageClass,
14
- eyebrowClass,
15
- heroControlsClass,
16
- heroControlsDmClass,
17
- heroCopyClass,
18
- heroShellClass,
19
- heroShellDmClass,
20
- heroTitleClass,
21
- pageWrapClass,
14
+ pageHeaderClass,
15
+ pageHeaderRowClass,
16
+ pageSubtitleClass,
17
+ pageTitleClass,
18
+ searchFieldIconClass,
19
+ searchFieldInputClass,
20
+ searchFieldShellClass,
22
21
  segmentActiveClass,
23
22
  segmentClass,
24
23
  segmentedClass,
24
+ tabButtonActiveClass,
25
+ tabButtonClass,
26
+ tabButtonIndicatorClass,
27
+ tabStripClass,
25
28
  textFieldClass,
26
29
  textFieldShortClass,
27
- textFieldWideClass,
28
30
  } from "#/lib/ui";
29
31
 
30
32
  export const Route = createFileRoute("/dms")({
31
33
  component: DmsRoute,
32
34
  });
33
35
 
36
+ const TABS: Array<{ value: ReplyFilter; label: string }> = [
37
+ { value: "all", label: "All" },
38
+ { value: "unreplied", label: "Unreplied" },
39
+ { value: "replied", label: "Replied" },
40
+ ];
41
+
34
42
  function DmsRoute() {
35
43
  const [meta, setMeta] = useState<QueryEnvelope | null>(null);
36
44
  const [items, setItems] = useState<DmConversationItem[]>([]);
@@ -110,7 +118,7 @@ function DmsRoute() {
110
118
 
111
119
  const subtitle = useMemo(() => {
112
120
  if (!meta) return "Loading direct messages...";
113
- return `${meta.stats.dms} conversations cached locally · filter by follower load or derived influence score`;
121
+ return `${String(meta.stats.dms)} conversations cached locally`;
114
122
  }, [meta]);
115
123
 
116
124
  async function replyToConversation(conversationId: string) {
@@ -183,80 +191,84 @@ function DmsRoute() {
183
191
  }
184
192
 
185
193
  return (
186
- <main className={pageWrapClass}>
187
- <div className={dmPageClass}>
188
- <section className={cx(heroShellClass, heroShellDmClass)}>
189
- <div>
190
- <p className={eyebrowClass}>direct messages</p>
191
- <h2 className={heroTitleClass}>
192
- Influence, bio, and reply state. No hunting.
193
- </h2>
194
- <p className={heroCopyClass}>{subtitle}</p>
194
+ <>
195
+ <header className={pageHeaderClass}>
196
+ <div className={pageHeaderRowClass}>
197
+ <div className="flex min-w-0 flex-col">
198
+ <h1 className={pageTitleClass}>Messages</h1>
199
+ <p className={pageSubtitleClass}>{subtitle}</p>
195
200
  </div>
196
- <div className={cx(heroControlsClass, heroControlsDmClass)}>
201
+ </div>
202
+ <div className="flex flex-wrap items-center gap-2 px-4 pb-3">
203
+ <label className={cx(searchFieldShellClass, "flex-1 min-w-[200px]")}>
204
+ <Search className={searchFieldIconClass} strokeWidth={2} />
197
205
  <input
198
- className={cx(textFieldClass, textFieldWideClass)}
206
+ className={searchFieldInputClass}
199
207
  onChange={(event) => setSearch(event.target.value)}
200
208
  placeholder="Search DMs"
201
209
  value={search}
202
210
  />
203
- <input
204
- className={cx(textFieldClass, textFieldShortClass)}
205
- inputMode="numeric"
206
- onChange={(event) => setMinFollowers(event.target.value)}
207
- placeholder="Min followers"
208
- value={minFollowers}
209
- />
210
- <input
211
- className={cx(textFieldClass, textFieldShortClass)}
212
- inputMode="numeric"
213
- onChange={(event) => setMinInfluenceScore(event.target.value)}
214
- placeholder="Min score"
215
- value={minInfluenceScore}
216
- />
217
- <div className={segmentedClass}>
218
- {(["recent", "influence"] as const).map((value) => (
219
- <button
220
- key={value}
221
- className={cx(
222
- segmentClass,
223
- value === sort && segmentActiveClass,
224
- )}
225
- onClick={() => setSort(value)}
226
- type="button"
227
- >
228
- {value}
229
- </button>
230
- ))}
231
- </div>
232
- <div className={segmentedClass}>
233
- {(["all", "replied", "unreplied"] as const).map((value) => (
234
- <button
235
- key={value}
236
- className={cx(
237
- segmentClass,
238
- value === replyFilter && segmentActiveClass,
239
- )}
240
- onClick={() => setReplyFilter(value)}
241
- type="button"
242
- >
243
- {value}
244
- </button>
245
- ))}
246
- </div>
211
+ </label>
212
+ <input
213
+ className={cx(textFieldClass, textFieldShortClass)}
214
+ inputMode="numeric"
215
+ onChange={(event) => setMinFollowers(event.target.value)}
216
+ placeholder="Min followers"
217
+ value={minFollowers}
218
+ />
219
+ <input
220
+ className={cx(textFieldClass, textFieldShortClass)}
221
+ inputMode="numeric"
222
+ onChange={(event) => setMinInfluenceScore(event.target.value)}
223
+ placeholder="Min score"
224
+ value={minInfluenceScore}
225
+ />
226
+ <div className={segmentedClass}>
227
+ {(["recent", "influence"] as const).map((value) => (
228
+ <button
229
+ key={value}
230
+ className={cx(
231
+ segmentClass,
232
+ value === sort && segmentActiveClass,
233
+ )}
234
+ onClick={() => setSort(value)}
235
+ type="button"
236
+ >
237
+ {value}
238
+ </button>
239
+ ))}
247
240
  </div>
248
- </section>
241
+ </div>
242
+ <div className={tabStripClass}>
243
+ {TABS.map((tab) => {
244
+ const active = replyFilter === tab.value;
245
+ return (
246
+ <button
247
+ key={tab.value}
248
+ type="button"
249
+ aria-pressed={active}
250
+ className={cx(tabButtonClass, active && tabButtonActiveClass)}
251
+ onClick={() => setReplyFilter(tab.value)}
252
+ >
253
+ <span className="relative inline-flex flex-col items-center justify-center py-1">
254
+ {tab.value}
255
+ {active ? <span className={tabButtonIndicatorClass} /> : null}
256
+ </span>
257
+ </button>
258
+ );
259
+ })}
260
+ </div>
261
+ </header>
249
262
 
250
- <DmWorkspace
251
- conversations={items}
252
- onReplyDraftChange={setReplyDraft}
253
- onReplySend={replyToConversation}
254
- onSelectConversation={setSelectedConversationId}
255
- replyDraft={replyDraft}
256
- selectedConversation={selectedConversation}
257
- selectedMessages={messages}
258
- />
259
- </div>
260
- </main>
263
+ <DmWorkspace
264
+ conversations={items}
265
+ onReplyDraftChange={setReplyDraft}
266
+ onReplySend={replyToConversation}
267
+ onSelectConversation={setSelectedConversationId}
268
+ replyDraft={replyDraft}
269
+ selectedConversation={selectedConversation}
270
+ selectedMessages={messages}
271
+ />
272
+ </>
261
273
  );
262
274
  }