birdclaw 0.5.1 → 0.6.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 (89) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +50 -5
  3. package/package.json +3 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +376 -13
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +27 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +452 -0
  13. package/src/components/SyncNowButton.tsx +57 -25
  14. package/src/components/ThemeSlider.tsx +55 -50
  15. package/src/components/TimelineCard.tsx +225 -93
  16. package/src/components/TimelineRouteFrame.tsx +22 -8
  17. package/src/components/TweetMediaGrid.tsx +87 -38
  18. package/src/components/TweetRichText.tsx +15 -11
  19. package/src/components/account-selection.ts +64 -0
  20. package/src/components/useTimelineRouteData.ts +23 -7
  21. package/src/lib/account-sync-job.ts +654 -0
  22. package/src/lib/actions-transport.ts +216 -146
  23. package/src/lib/api-client.ts +128 -53
  24. package/src/lib/archive-finder.ts +78 -63
  25. package/src/lib/archive-import.ts +1364 -1300
  26. package/src/lib/authored-live.ts +261 -204
  27. package/src/lib/avatar-cache.ts +159 -44
  28. package/src/lib/backup.ts +1532 -951
  29. package/src/lib/bird-actions.ts +139 -57
  30. package/src/lib/bird-command.ts +101 -28
  31. package/src/lib/bird.ts +549 -194
  32. package/src/lib/blocklist.ts +40 -23
  33. package/src/lib/blocks-write.ts +129 -80
  34. package/src/lib/blocks.ts +165 -97
  35. package/src/lib/bookmark-sync-job.ts +250 -160
  36. package/src/lib/conversation-surface.ts +79 -48
  37. package/src/lib/db.ts +33 -3
  38. package/src/lib/dms-live.ts +720 -66
  39. package/src/lib/effect-runtime.ts +45 -0
  40. package/src/lib/follow-graph.ts +224 -180
  41. package/src/lib/http-effect.ts +222 -0
  42. package/src/lib/inbox.ts +74 -43
  43. package/src/lib/link-index.ts +88 -76
  44. package/src/lib/link-insights.ts +24 -0
  45. package/src/lib/link-preview-metadata.ts +472 -52
  46. package/src/lib/media-fetch.ts +286 -213
  47. package/src/lib/mention-threads-live.ts +352 -288
  48. package/src/lib/mentions-live.ts +390 -342
  49. package/src/lib/moderation-target.ts +102 -65
  50. package/src/lib/moderation-write.ts +77 -18
  51. package/src/lib/mutes-write.ts +129 -80
  52. package/src/lib/mutes.ts +8 -1
  53. package/src/lib/openai.ts +84 -53
  54. package/src/lib/period-digest.ts +953 -0
  55. package/src/lib/profile-affiliation-hydration.ts +93 -54
  56. package/src/lib/profile-hydration.ts +124 -72
  57. package/src/lib/profile-replies.ts +60 -43
  58. package/src/lib/profile-resolver.ts +402 -294
  59. package/src/lib/queries.ts +969 -199
  60. package/src/lib/research.ts +165 -120
  61. package/src/lib/sqlite.ts +1 -0
  62. package/src/lib/timeline-collections-live.ts +204 -167
  63. package/src/lib/timeline-live.ts +60 -39
  64. package/src/lib/tweet-lookup.ts +30 -19
  65. package/src/lib/types.ts +38 -1
  66. package/src/lib/ui.ts +30 -7
  67. package/src/lib/url-expansion.ts +226 -55
  68. package/src/lib/url-safety.ts +220 -0
  69. package/src/lib/web-sync.ts +216 -148
  70. package/src/lib/whois.ts +166 -147
  71. package/src/lib/x-web.ts +102 -71
  72. package/src/lib/xurl.ts +681 -411
  73. package/src/routeTree.gen.ts +42 -0
  74. package/src/routes/__root.tsx +25 -5
  75. package/src/routes/api/action.tsx +127 -78
  76. package/src/routes/api/avatar.tsx +39 -30
  77. package/src/routes/api/blocks.tsx +26 -23
  78. package/src/routes/api/conversation.tsx +25 -14
  79. package/src/routes/api/inbox.tsx +27 -21
  80. package/src/routes/api/link-insights.tsx +31 -25
  81. package/src/routes/api/link-preview.tsx +25 -21
  82. package/src/routes/api/period-digest.tsx +123 -0
  83. package/src/routes/api/profile-hydrate.tsx +31 -28
  84. package/src/routes/api/query.tsx +79 -55
  85. package/src/routes/api/status.tsx +18 -10
  86. package/src/routes/api/sync.tsx +75 -29
  87. package/src/routes/dms.tsx +95 -28
  88. package/src/routes/inbox.tsx +32 -19
  89. package/src/routes/today.tsx +441 -0
@@ -1,7 +1,7 @@
1
1
  import { X } from "lucide-react";
2
2
  import { useState } from "react";
3
3
  import type { TweetMediaItem } from "#/lib/types";
4
- import { tweetMediaGridClass, tweetMediaTileClass } from "#/lib/ui";
4
+ import { cx, tweetMediaGridClass, tweetMediaTileClass } from "#/lib/ui";
5
5
 
6
6
  export function TweetMediaGrid({ items }: { items: TweetMediaItem[] }) {
7
7
  const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
@@ -16,47 +16,83 @@ export function TweetMediaGrid({ items }: { items: TweetMediaItem[] }) {
16
16
  selectedItem?.type === "video" || selectedItem?.type === "gif"
17
17
  ? (selectedItem.variants?.[0]?.url ?? playableVideoUrl(selectedItem.url))
18
18
  : null;
19
+ const singleImage =
20
+ visibleItems.length === 1 && visibleItems[0]?.type === "image"
21
+ ? visibleItems[0]
22
+ : null;
19
23
 
20
24
  return (
21
25
  <>
22
- <div className={tweetMediaGridClass(Math.min(items.length, 4))}>
23
- {visibleItems.map((item, index) => (
24
- <button
25
- key={item.url + String(index)}
26
- aria-label={`Open tweet media ${String(index + 1)}`}
27
- className={tweetMediaTileClass(index, Math.min(items.length, 4))}
28
- onClick={(event) => {
29
- event.stopPropagation();
30
- setSelectedIndex(index);
31
- }}
32
- style={
33
- visibleItems.length === 1 && item.width && item.height
34
- ? {
35
- aspectRatio: `${String(item.width)} / ${String(item.height)}`,
36
- }
37
- : undefined
38
- }
39
- type="button"
40
- >
41
- {item.type === "image" ? (
42
- <img
43
- alt={item.altText ?? `Tweet media ${String(index + 1)}`}
44
- className="tweet-media-image block size-full object-contain"
45
- loading="lazy"
46
- src={item.thumbnailUrl ?? item.url}
47
- />
48
- ) : (
49
- <span className="tweet-media-fallback grid min-h-40 place-items-center font-semibold text-[var(--ink-soft)]">
50
- {item.type === "video"
51
- ? "Video"
52
- : item.type === "gif"
53
- ? "GIF"
54
- : "Media"}
55
- </span>
26
+ {singleImage ? (
27
+ <button
28
+ aria-label="Open tweet media 1"
29
+ className={cx(
30
+ "tweet-media-single mt-2 max-w-full overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-active)] p-0 text-left",
31
+ singleImage.width && singleImage.height
32
+ ? "block"
33
+ : "inline-block align-top",
34
+ )}
35
+ onClick={(event) => {
36
+ event.stopPropagation();
37
+ setSelectedIndex(0);
38
+ }}
39
+ style={singleImageStyle(singleImage)}
40
+ type="button"
41
+ >
42
+ <img
43
+ alt={singleImage.altText ?? "Tweet media 1"}
44
+ className={cx(
45
+ "tweet-media-image block max-h-[720px] max-w-full",
46
+ singleImage.width && singleImage.height
47
+ ? "size-full object-cover"
48
+ : "h-auto w-auto object-contain",
56
49
  )}
57
- </button>
58
- ))}
59
- </div>
50
+ height={singleImage.height}
51
+ loading="lazy"
52
+ src={singleImage.thumbnailUrl ?? singleImage.url}
53
+ width={singleImage.width}
54
+ />
55
+ </button>
56
+ ) : (
57
+ <div className={tweetMediaGridClass(Math.min(items.length, 4))}>
58
+ {visibleItems.map((item, index) => (
59
+ <button
60
+ key={item.url + String(index)}
61
+ aria-label={`Open tweet media ${String(index + 1)}`}
62
+ className={tweetMediaTileClass(index, Math.min(items.length, 4))}
63
+ onClick={(event) => {
64
+ event.stopPropagation();
65
+ setSelectedIndex(index);
66
+ }}
67
+ style={
68
+ visibleItems.length === 1 && item.width && item.height
69
+ ? {
70
+ aspectRatio: `${String(item.width)} / ${String(item.height)}`,
71
+ }
72
+ : undefined
73
+ }
74
+ type="button"
75
+ >
76
+ {item.type === "image" ? (
77
+ <img
78
+ alt={item.altText ?? `Tweet media ${String(index + 1)}`}
79
+ className="tweet-media-image block size-full object-contain"
80
+ loading="lazy"
81
+ src={item.thumbnailUrl ?? item.url}
82
+ />
83
+ ) : (
84
+ <span className="tweet-media-fallback grid min-h-40 place-items-center font-semibold text-[var(--ink-soft)]">
85
+ {item.type === "video"
86
+ ? "Video"
87
+ : item.type === "gif"
88
+ ? "GIF"
89
+ : "Media"}
90
+ </span>
91
+ )}
92
+ </button>
93
+ ))}
94
+ </div>
95
+ )}
60
96
  {selectedItem ? (
61
97
  <div
62
98
  aria-modal="true"
@@ -125,6 +161,19 @@ export function TweetMediaGrid({ items }: { items: TweetMediaItem[] }) {
125
161
  );
126
162
  }
127
163
 
164
+ function singleImageStyle(item: TweetMediaItem) {
165
+ if (!item.width || !item.height) return undefined;
166
+ const maxHeight = 720;
167
+ const width = Math.min(
168
+ item.width,
169
+ Math.round((item.width / item.height) * maxHeight),
170
+ );
171
+ return {
172
+ aspectRatio: `${String(item.width)} / ${String(item.height)}`,
173
+ width: `${String(width)}px`,
174
+ };
175
+ }
176
+
128
177
  function playableVideoUrl(url: string) {
129
178
  try {
130
179
  const parsed = new URL(url);
@@ -11,6 +11,7 @@ import {
11
11
  tweetLinkClass,
12
12
  tweetMentionClass,
13
13
  } from "#/lib/ui";
14
+ import { safeHttpUrl } from "#/lib/url-safety";
14
15
  import { ProfilePreview } from "./ProfilePreview";
15
16
 
16
17
  export function TweetRichText({
@@ -65,17 +66,20 @@ export function TweetRichText({
65
66
  </ProfilePreview>
66
67
  );
67
68
  } else if (segment.kind === "url") {
68
- node = (
69
- <a
70
- key={`segment-${String(index)}`}
71
- className={tweetLinkClass}
72
- href={segment.expandedUrl}
73
- rel="noreferrer"
74
- target="_blank"
75
- >
76
- {segment.displayUrl}
77
- </a>
78
- );
69
+ const href = safeHttpUrl(segment.expandedUrl);
70
+ if (href) {
71
+ node = (
72
+ <a
73
+ key={`segment-${String(index)}`}
74
+ className={tweetLinkClass}
75
+ href={href}
76
+ rel="noreferrer"
77
+ target="_blank"
78
+ >
79
+ {segment.displayUrl}
80
+ </a>
81
+ );
82
+ }
79
83
  } else if (segment.kind === "hashtag") {
80
84
  node = (
81
85
  <span
@@ -0,0 +1,64 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import type { AccountRecord } from "#/lib/types";
3
+
4
+ const STORAGE_KEY = "birdclaw:selected-account-id";
5
+ const CHANGE_EVENT = "birdclaw-account-change";
6
+
7
+ export function defaultAccountId(accounts: AccountRecord[] | undefined) {
8
+ if (!accounts?.length) return undefined;
9
+ return accounts.find((account) => account.isDefault)?.id ?? accounts[0]?.id;
10
+ }
11
+
12
+ function readStoredAccountId() {
13
+ if (typeof window === "undefined") return undefined;
14
+ return window.localStorage.getItem(STORAGE_KEY) ?? undefined;
15
+ }
16
+
17
+ export function setStoredAccountId(accountId: string) {
18
+ if (typeof window === "undefined") return;
19
+ window.localStorage.setItem(STORAGE_KEY, accountId);
20
+ window.dispatchEvent(
21
+ new CustomEvent(CHANGE_EVENT, {
22
+ detail: { accountId },
23
+ }),
24
+ );
25
+ }
26
+
27
+ export function useSelectedAccountId(accounts: AccountRecord[] | undefined) {
28
+ const fallbackAccountId = useMemo(
29
+ () => defaultAccountId(accounts),
30
+ [accounts],
31
+ );
32
+ const [selectedAccountId, setSelectedAccountId] = useState<
33
+ string | undefined
34
+ >(() => readStoredAccountId());
35
+
36
+ useEffect(() => {
37
+ const onChange = (event: Event) => {
38
+ const custom = event as CustomEvent<{ accountId?: string }>;
39
+ setSelectedAccountId(custom.detail?.accountId ?? readStoredAccountId());
40
+ };
41
+ const onStorage = () => setSelectedAccountId(readStoredAccountId());
42
+ window.addEventListener(CHANGE_EVENT, onChange);
43
+ window.addEventListener("storage", onStorage);
44
+ return () => {
45
+ window.removeEventListener(CHANGE_EVENT, onChange);
46
+ window.removeEventListener("storage", onStorage);
47
+ };
48
+ }, []);
49
+
50
+ useEffect(() => {
51
+ if (!accounts?.length || !fallbackAccountId) return;
52
+ const current = readStoredAccountId();
53
+ const valid = current && accounts.some((account) => account.id === current);
54
+ if (!valid) {
55
+ setStoredAccountId(fallbackAccountId);
56
+ setSelectedAccountId(fallbackAccountId);
57
+ }
58
+ }, [accounts, fallbackAccountId]);
59
+
60
+ return selectedAccountId &&
61
+ accounts?.some((account) => account.id === selectedAccountId)
62
+ ? selectedAccountId
63
+ : fallbackAccountId;
64
+ }
@@ -10,6 +10,7 @@ import {
10
10
  fetchQueryResponse,
11
11
  postAction,
12
12
  } from "#/lib/api-client";
13
+ import { useSelectedAccountId } from "./account-selection";
13
14
 
14
15
  interface UseTimelineRouteDataOptions {
15
16
  resource: Exclude<ResourceKind, "dms">;
@@ -32,7 +33,9 @@ export function useTimelineRouteData({
32
33
  const [items, setItems] = useState<TimelineItem[]>([]);
33
34
  const [loading, setLoading] = useState(true);
34
35
  const [error, setError] = useState<string | null>(null);
36
+ const [replyError, setReplyError] = useState<string | null>(null);
35
37
  const [refreshTick, setRefreshTick] = useState(0);
38
+ const selectedAccountId = useSelectedAccountId(meta?.accounts);
36
39
 
37
40
  async function loadStatus() {
38
41
  setMeta(await fetchQueryEnvelope());
@@ -46,6 +49,9 @@ export function useTimelineRouteData({
46
49
  const url = new URL("/api/query", window.location.origin);
47
50
  url.searchParams.set("resource", resource);
48
51
  url.searchParams.set("refresh", String(refreshTick));
52
+ if (selectedAccountId) {
53
+ url.searchParams.set("account", selectedAccountId);
54
+ }
49
55
  if (replyFilter) {
50
56
  url.searchParams.set("replyFilter", replyFilter);
51
57
  }
@@ -100,6 +106,7 @@ export function useTimelineRouteData({
100
106
  replyFilter,
101
107
  resource,
102
108
  search,
109
+ selectedAccountId,
103
110
  ]);
104
111
 
105
112
  function retry() {
@@ -115,14 +122,21 @@ export function useTimelineRouteData({
115
122
  const text = window.prompt("Reply text");
116
123
  if (!text?.trim()) return;
117
124
 
118
- await postAction({
119
- kind: "replyTweet",
120
- accountId: "acct_primary",
121
- tweetId,
122
- text,
123
- });
125
+ setReplyError(null);
126
+ try {
127
+ await postAction({
128
+ kind: "replyTweet",
129
+ accountId: selectedAccountId ?? "acct_primary",
130
+ tweetId,
131
+ text,
132
+ });
124
133
 
125
- retry();
134
+ retry();
135
+ } catch (replyError) {
136
+ setReplyError(
137
+ replyError instanceof Error ? replyError.message : "Reply failed",
138
+ );
139
+ }
126
140
  }
127
141
 
128
142
  return {
@@ -130,8 +144,10 @@ export function useTimelineRouteData({
130
144
  items,
131
145
  loading,
132
146
  error,
147
+ replyError,
133
148
  retry,
134
149
  refreshLocalView,
135
150
  replyToTweet,
151
+ selectedAccountId,
136
152
  };
137
153
  }