birdclaw 0.5.0 → 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 (105) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +55 -5
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +9 -7
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +400 -0
  7. package/scripts/build-docs-site.mjs +940 -0
  8. package/scripts/docs-site-assets.mjs +311 -0
  9. package/scripts/run-vitest.mjs +21 -0
  10. package/scripts/sanitize-node-options.mjs +23 -0
  11. package/scripts/start-test-server.mjs +42 -0
  12. package/src/cli.ts +422 -14
  13. package/src/components/AccountSwitcher.tsx +186 -0
  14. package/src/components/AppNav.tsx +29 -9
  15. package/src/components/AvatarChip.tsx +9 -3
  16. package/src/components/BrandMark.tsx +67 -0
  17. package/src/components/ConversationThread.tsx +11 -10
  18. package/src/components/DmWorkspace.tsx +39 -14
  19. package/src/components/FeedState.tsx +147 -0
  20. package/src/components/InboxCard.tsx +14 -2
  21. package/src/components/LinkPreviewCard.tsx +40 -18
  22. package/src/components/MarkdownViewer.tsx +452 -0
  23. package/src/components/SavedTimelineView.tsx +64 -56
  24. package/src/components/SyncNowButton.tsx +137 -0
  25. package/src/components/ThemeSlider.tsx +49 -93
  26. package/src/components/TimelineCard.tsx +364 -136
  27. package/src/components/TimelineRouteFrame.tsx +170 -0
  28. package/src/components/TweetMediaGrid.tsx +170 -24
  29. package/src/components/TweetRichText.tsx +28 -13
  30. package/src/components/account-selection.ts +64 -0
  31. package/src/components/useTimelineRouteData.ts +153 -0
  32. package/src/lib/account-sync-job.ts +654 -0
  33. package/src/lib/actions-transport.ts +216 -146
  34. package/src/lib/api-client.ts +304 -0
  35. package/src/lib/archive-finder.ts +72 -53
  36. package/src/lib/archive-import.ts +1377 -1298
  37. package/src/lib/authored-live.ts +261 -204
  38. package/src/lib/avatar-cache.ts +159 -44
  39. package/src/lib/backup.ts +1532 -951
  40. package/src/lib/bird-actions.ts +139 -57
  41. package/src/lib/bird-command.ts +101 -28
  42. package/src/lib/bird.ts +549 -194
  43. package/src/lib/blocklist.ts +40 -23
  44. package/src/lib/blocks-write.ts +129 -80
  45. package/src/lib/blocks.ts +165 -97
  46. package/src/lib/bookmark-sync-job.ts +250 -160
  47. package/src/lib/conversation-surface.ts +205 -0
  48. package/src/lib/db.ts +35 -3
  49. package/src/lib/dms-live.ts +720 -66
  50. package/src/lib/effect-runtime.ts +45 -0
  51. package/src/lib/follow-graph.ts +224 -180
  52. package/src/lib/http-effect.ts +222 -0
  53. package/src/lib/inbox.ts +74 -43
  54. package/src/lib/link-index.ts +88 -76
  55. package/src/lib/link-insights.ts +24 -0
  56. package/src/lib/link-preview-metadata.ts +472 -52
  57. package/src/lib/media-fetch.ts +286 -213
  58. package/src/lib/mention-threads-live.ts +352 -288
  59. package/src/lib/mentions-live.ts +390 -342
  60. package/src/lib/moderation-target.ts +102 -65
  61. package/src/lib/moderation-write.ts +77 -18
  62. package/src/lib/mutes-write.ts +129 -80
  63. package/src/lib/mutes.ts +8 -1
  64. package/src/lib/openai.ts +84 -53
  65. package/src/lib/period-digest.ts +953 -0
  66. package/src/lib/profile-affiliation-hydration.ts +93 -54
  67. package/src/lib/profile-hydration.ts +124 -72
  68. package/src/lib/profile-replies.ts +60 -43
  69. package/src/lib/profile-resolver.ts +402 -294
  70. package/src/lib/queries.ts +1024 -189
  71. package/src/lib/research.ts +165 -120
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +60 -39
  75. package/src/lib/tweet-lookup.ts +30 -19
  76. package/src/lib/types.ts +38 -1
  77. package/src/lib/ui.ts +41 -10
  78. package/src/lib/url-expansion.ts +226 -55
  79. package/src/lib/url-safety.ts +220 -0
  80. package/src/lib/web-sync.ts +511 -0
  81. package/src/lib/whois.ts +166 -147
  82. package/src/lib/x-web.ts +102 -71
  83. package/src/lib/xurl.ts +681 -411
  84. package/src/routeTree.gen.ts +63 -0
  85. package/src/routes/__root.tsx +25 -5
  86. package/src/routes/api/action.tsx +127 -78
  87. package/src/routes/api/avatar.tsx +39 -30
  88. package/src/routes/api/blocks.tsx +26 -23
  89. package/src/routes/api/conversation.tsx +25 -14
  90. package/src/routes/api/inbox.tsx +27 -21
  91. package/src/routes/api/link-insights.tsx +31 -25
  92. package/src/routes/api/link-preview.tsx +25 -21
  93. package/src/routes/api/period-digest.tsx +123 -0
  94. package/src/routes/api/profile-hydrate.tsx +31 -28
  95. package/src/routes/api/query.tsx +79 -55
  96. package/src/routes/api/status.tsx +18 -10
  97. package/src/routes/api/sync.tsx +105 -0
  98. package/src/routes/dms.tsx +195 -55
  99. package/src/routes/inbox.tsx +32 -19
  100. package/src/routes/index.tsx +21 -127
  101. package/src/routes/links.tsx +50 -5
  102. package/src/routes/mentions.tsx +21 -127
  103. package/src/routes/today.tsx +441 -0
  104. package/src/styles.css +74 -11
  105. package/vite.config.ts +8 -0
@@ -0,0 +1,170 @@
1
+ import { Search } from "lucide-react";
2
+ import { useMemo, useState } from "react";
3
+ import {
4
+ FeedEmpty,
5
+ FeedError,
6
+ FeedLoading,
7
+ TweetSkeletonRows,
8
+ } from "#/components/FeedState";
9
+ import { SyncNowButton } from "#/components/SyncNowButton";
10
+ import { TimelineCard } from "#/components/TimelineCard";
11
+ import { ConversationSurfaceScope } from "#/lib/conversation-surface";
12
+ import type { QueryEnvelope, ReplyFilter } from "#/lib/types";
13
+ import type { WebSyncKind } from "#/lib/web-sync";
14
+ import {
15
+ cx,
16
+ feedClass,
17
+ pageHeaderClass,
18
+ pageHeaderRowClass,
19
+ pageSubtitleClass,
20
+ pageTitleClass,
21
+ searchFieldIconClass,
22
+ searchFieldInputClass,
23
+ searchFieldShellClass,
24
+ tabButtonActiveClass,
25
+ tabButtonClass,
26
+ tabButtonIndicatorClass,
27
+ tabStripClass,
28
+ timestampClass,
29
+ } from "#/lib/ui";
30
+ import { useTimelineRouteData } from "./useTimelineRouteData";
31
+
32
+ const TABS: Array<{ value: ReplyFilter; label: string }> = [
33
+ { value: "all", label: "All" },
34
+ { value: "unreplied", label: "Unreplied" },
35
+ { value: "replied", label: "Replied" },
36
+ ];
37
+
38
+ interface TimelineRouteFrameProps {
39
+ title: string;
40
+ resource: "home" | "mentions";
41
+ initialReplyFilter: ReplyFilter;
42
+ searchPlaceholder: string;
43
+ syncKind: WebSyncKind;
44
+ syncLabel: string;
45
+ loadingLabel: string;
46
+ loadingDetail: string;
47
+ errorTitle: string;
48
+ errorFallback: string;
49
+ emptyLabel: string;
50
+ emptyDetail: string;
51
+ subtitle: (meta: QueryEnvelope | null) => string;
52
+ }
53
+
54
+ export function TimelineRouteFrame({
55
+ title,
56
+ resource,
57
+ initialReplyFilter,
58
+ searchPlaceholder,
59
+ syncKind,
60
+ syncLabel,
61
+ loadingLabel,
62
+ loadingDetail,
63
+ errorTitle,
64
+ errorFallback,
65
+ emptyLabel,
66
+ emptyDetail,
67
+ subtitle,
68
+ }: TimelineRouteFrameProps) {
69
+ const [replyFilter, setReplyFilter] =
70
+ useState<ReplyFilter>(initialReplyFilter);
71
+ const [search, setSearch] = useState("");
72
+ const {
73
+ meta,
74
+ items,
75
+ loading,
76
+ error,
77
+ replyError,
78
+ retry,
79
+ refreshLocalView,
80
+ replyToTweet,
81
+ } = useTimelineRouteData({
82
+ resource,
83
+ replyFilter,
84
+ search,
85
+ errorFallback,
86
+ });
87
+ const subtitleText = useMemo(() => subtitle(meta), [meta, subtitle]);
88
+
89
+ return (
90
+ <>
91
+ <header className={pageHeaderClass}>
92
+ <div className={pageHeaderRowClass}>
93
+ <div className="flex min-w-0 flex-col">
94
+ <h1 className={pageTitleClass}>{title}</h1>
95
+ <p className={pageSubtitleClass}>{subtitleText}</p>
96
+ </div>
97
+ <SyncNowButton
98
+ accounts={meta?.accounts}
99
+ kind={syncKind}
100
+ label={syncLabel}
101
+ onSynced={refreshLocalView}
102
+ />
103
+ </div>
104
+ <div className="px-4 pb-3">
105
+ <label className={searchFieldShellClass}>
106
+ <Search className={searchFieldIconClass} strokeWidth={2} />
107
+ <input
108
+ className={searchFieldInputClass}
109
+ onChange={(event) => setSearch(event.target.value)}
110
+ placeholder={searchPlaceholder}
111
+ value={search}
112
+ />
113
+ </label>
114
+ </div>
115
+ <div className={tabStripClass}>
116
+ {TABS.map((tab) => {
117
+ const active = replyFilter === tab.value;
118
+ return (
119
+ <button
120
+ key={tab.value}
121
+ type="button"
122
+ aria-pressed={active}
123
+ className={cx(tabButtonClass, active && tabButtonActiveClass)}
124
+ onClick={() => setReplyFilter(tab.value)}
125
+ >
126
+ <span className="relative inline-flex flex-col items-center justify-center py-1">
127
+ {tab.label}
128
+ {active ? <span className={tabButtonIndicatorClass} /> : null}
129
+ </span>
130
+ </button>
131
+ );
132
+ })}
133
+ </div>
134
+ </header>
135
+ {replyError ? (
136
+ <p className={cx(timestampClass, "px-4 py-2 text-red-500")}>
137
+ {replyError}
138
+ </p>
139
+ ) : null}
140
+ <ConversationSurfaceScope>
141
+ <section className={feedClass}>
142
+ {loading ? (
143
+ <FeedLoading detail={loadingDetail} label={loadingLabel}>
144
+ <TweetSkeletonRows />
145
+ </FeedLoading>
146
+ ) : error ? (
147
+ <FeedError
148
+ action={
149
+ <button
150
+ className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
151
+ onClick={retry}
152
+ type="button"
153
+ >
154
+ Retry
155
+ </button>
156
+ }
157
+ message={error}
158
+ title={errorTitle}
159
+ />
160
+ ) : items.length === 0 ? (
161
+ <FeedEmpty detail={emptyDetail} label={emptyLabel} />
162
+ ) : null}
163
+ {items.map((item) => (
164
+ <TimelineCard key={item.id} item={item} onReply={replyToTweet} />
165
+ ))}
166
+ </section>
167
+ </ConversationSurfaceScope>
168
+ </>
169
+ );
170
+ }
@@ -1,39 +1,185 @@
1
+ import { X } from "lucide-react";
2
+ import { useState } from "react";
1
3
  import type { TweetMediaItem } from "#/lib/types";
2
- import { tweetMediaGridClass, tweetMediaTileClass } from "#/lib/ui";
4
+ import { cx, tweetMediaGridClass, tweetMediaTileClass } from "#/lib/ui";
3
5
 
4
6
  export function TweetMediaGrid({ items }: { items: TweetMediaItem[] }) {
7
+ const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
5
8
  if (items.length === 0) {
6
9
  return null;
7
10
  }
8
11
 
12
+ const visibleItems = items.slice(0, 4);
13
+ const selectedItem =
14
+ selectedIndex === null ? null : (visibleItems[selectedIndex] ?? null);
15
+ const selectedVideoUrl =
16
+ selectedItem?.type === "video" || selectedItem?.type === "gif"
17
+ ? (selectedItem.variants?.[0]?.url ?? playableVideoUrl(selectedItem.url))
18
+ : null;
19
+ const singleImage =
20
+ visibleItems.length === 1 && visibleItems[0]?.type === "image"
21
+ ? visibleItems[0]
22
+ : null;
23
+
9
24
  return (
10
- <div className={tweetMediaGridClass(Math.min(items.length, 4))}>
11
- {items.slice(0, 4).map((item, index) => (
12
- <a
13
- key={item.url + String(index)}
14
- className={tweetMediaTileClass(index, Math.min(items.length, 4))}
15
- href={item.url}
16
- rel="noreferrer"
17
- target="_blank"
25
+ <>
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",
49
+ )}
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
+ )}
96
+ {selectedItem ? (
97
+ <div
98
+ aria-modal="true"
99
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 p-4"
100
+ onClick={(event) => {
101
+ event.stopPropagation();
102
+ setSelectedIndex(null);
103
+ }}
104
+ role="dialog"
18
105
  >
19
- {item.type === "image" ? (
106
+ <button
107
+ aria-label="Close media viewer"
108
+ className="absolute right-4 top-4 grid size-10 place-items-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
109
+ onClick={(event) => {
110
+ event.stopPropagation();
111
+ setSelectedIndex(null);
112
+ }}
113
+ type="button"
114
+ >
115
+ <X className="size-5" strokeWidth={1.8} />
116
+ </button>
117
+ {selectedItem.type === "image" ? (
20
118
  <img
21
- alt={item.altText ?? `Tweet media ${String(index + 1)}`}
22
- className="tweet-media-image block size-full object-cover"
23
- loading="lazy"
24
- src={item.thumbnailUrl ?? item.url}
119
+ alt={selectedItem.altText ?? "Tweet media"}
120
+ className="max-h-[92vh] max-w-[92vw] object-contain"
121
+ onClick={(event) => event.stopPropagation()}
122
+ src={selectedItem.url}
123
+ />
124
+ ) : selectedVideoUrl ? (
125
+ <video
126
+ autoPlay={selectedItem.type === "gif"}
127
+ className="max-h-[92vh] max-w-[92vw]"
128
+ controls
129
+ loop={selectedItem.type === "gif"}
130
+ muted={selectedItem.type === "gif"}
131
+ onClick={(event) => event.stopPropagation()}
132
+ playsInline
133
+ poster={selectedItem.thumbnailUrl}
134
+ src={selectedVideoUrl}
25
135
  />
26
136
  ) : (
27
- <span className="tweet-media-fallback grid min-h-40 place-items-center font-semibold text-[var(--ink-soft)]">
28
- {item.type === "video"
29
- ? "Video"
30
- : item.type === "gif"
31
- ? "GIF"
32
- : "Media"}
33
- </span>
137
+ <div
138
+ className="grid min-h-64 min-w-80 place-items-center gap-3 rounded-2xl border border-white/20 bg-black p-6 text-white"
139
+ onClick={(event) => event.stopPropagation()}
140
+ >
141
+ <span>
142
+ {selectedItem.type === "video"
143
+ ? "Video"
144
+ : selectedItem.type === "gif"
145
+ ? "GIF"
146
+ : "Media"}
147
+ </span>
148
+ <a
149
+ className="rounded-full bg-white/10 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-white/20"
150
+ href={selectedItem.url}
151
+ rel="noreferrer"
152
+ target="_blank"
153
+ >
154
+ Open media
155
+ </a>
156
+ </div>
34
157
  )}
35
- </a>
36
- ))}
37
- </div>
158
+ </div>
159
+ ) : null}
160
+ </>
161
+ );
162
+ }
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),
38
170
  );
171
+ return {
172
+ aspectRatio: `${String(item.width)} / ${String(item.height)}`,
173
+ width: `${String(width)}px`,
174
+ };
175
+ }
176
+
177
+ function playableVideoUrl(url: string) {
178
+ try {
179
+ const parsed = new URL(url);
180
+ if (parsed.hostname === "video.twimg.com") return url;
181
+ return /\.(?:mp4|m3u8)(?:$|[?#])/i.test(parsed.pathname) ? url : undefined;
182
+ } catch {
183
+ return /\.(?:mp4|m3u8)(?:$|[?#])/i.test(url) ? url : undefined;
184
+ }
39
185
  }
@@ -1,4 +1,5 @@
1
1
  import { Fragment } from "react";
2
+ import type { ReactNode } from "react";
2
3
  import {
3
4
  collectTweetSegments,
4
5
  enrichFallbackUrlEntities,
@@ -10,16 +11,19 @@ import {
10
11
  tweetLinkClass,
11
12
  tweetMentionClass,
12
13
  } from "#/lib/ui";
14
+ import { safeHttpUrl } from "#/lib/url-safety";
13
15
  import { ProfilePreview } from "./ProfilePreview";
14
16
 
15
17
  export function TweetRichText({
16
18
  text,
17
19
  entities,
18
20
  className = "body-copy",
21
+ hiddenUrlRanges = [],
19
22
  }: {
20
23
  text: string;
21
24
  entities: TweetEntities;
22
25
  className?: string;
26
+ hiddenUrlRanges?: Array<{ start: number; end: number }>;
23
27
  }) {
24
28
  const richEntities = enrichFallbackUrlEntities(text, entities);
25
29
  const segments = collectTweetSegments(richEntities);
@@ -39,12 +43,20 @@ export function TweetRichText({
39
43
  const prefix = text.slice(cursor, segment.start);
40
44
  cursor = segment.end;
41
45
 
42
- let node = (
46
+ let node: ReactNode = (
43
47
  <Fragment key={`segment-${String(index)}`}>
44
48
  {text.slice(segment.start, segment.end)}
45
49
  </Fragment>
46
50
  );
47
- if (segment.kind === "mention" && segment.profile) {
51
+ if (
52
+ segment.kind === "url" &&
53
+ hiddenUrlRanges.some(
54
+ (range) =>
55
+ range.start === segment.start && range.end === segment.end,
56
+ )
57
+ ) {
58
+ node = null;
59
+ } else if (segment.kind === "mention" && segment.profile) {
48
60
  node = (
49
61
  <ProfilePreview
50
62
  key={`segment-${String(index)}`}
@@ -54,17 +66,20 @@ export function TweetRichText({
54
66
  </ProfilePreview>
55
67
  );
56
68
  } else if (segment.kind === "url") {
57
- node = (
58
- <a
59
- key={`segment-${String(index)}`}
60
- className={tweetLinkClass}
61
- href={segment.expandedUrl}
62
- rel="noreferrer"
63
- target="_blank"
64
- >
65
- {segment.displayUrl}
66
- </a>
67
- );
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
+ }
68
83
  } else if (segment.kind === "hashtag") {
69
84
  node = (
70
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
+ }
@@ -0,0 +1,153 @@
1
+ import { useEffect, useState } from "react";
2
+ import type {
3
+ QueryEnvelope,
4
+ ReplyFilter,
5
+ ResourceKind,
6
+ TimelineItem,
7
+ } from "#/lib/types";
8
+ import {
9
+ fetchQueryEnvelope,
10
+ fetchQueryResponse,
11
+ postAction,
12
+ } from "#/lib/api-client";
13
+ import { useSelectedAccountId } from "./account-selection";
14
+
15
+ interface UseTimelineRouteDataOptions {
16
+ resource: Exclude<ResourceKind, "dms">;
17
+ search: string;
18
+ errorFallback: string;
19
+ replyFilter?: ReplyFilter;
20
+ likedOnly?: boolean;
21
+ bookmarkedOnly?: boolean;
22
+ }
23
+
24
+ export function useTimelineRouteData({
25
+ resource,
26
+ search,
27
+ errorFallback,
28
+ replyFilter,
29
+ likedOnly = false,
30
+ bookmarkedOnly = false,
31
+ }: UseTimelineRouteDataOptions) {
32
+ const [meta, setMeta] = useState<QueryEnvelope | null>(null);
33
+ const [items, setItems] = useState<TimelineItem[]>([]);
34
+ const [loading, setLoading] = useState(true);
35
+ const [error, setError] = useState<string | null>(null);
36
+ const [replyError, setReplyError] = useState<string | null>(null);
37
+ const [refreshTick, setRefreshTick] = useState(0);
38
+ const selectedAccountId = useSelectedAccountId(meta?.accounts);
39
+
40
+ async function loadStatus() {
41
+ setMeta(await fetchQueryEnvelope());
42
+ }
43
+
44
+ useEffect(() => {
45
+ void loadStatus();
46
+ }, []);
47
+
48
+ useEffect(() => {
49
+ const url = new URL("/api/query", window.location.origin);
50
+ url.searchParams.set("resource", resource);
51
+ url.searchParams.set("refresh", String(refreshTick));
52
+ if (selectedAccountId) {
53
+ url.searchParams.set("account", selectedAccountId);
54
+ }
55
+ if (replyFilter) {
56
+ url.searchParams.set("replyFilter", replyFilter);
57
+ }
58
+ if (likedOnly) {
59
+ url.searchParams.set("liked", "true");
60
+ }
61
+ if (bookmarkedOnly) {
62
+ url.searchParams.set("bookmarked", "true");
63
+ }
64
+ if (search.trim()) {
65
+ url.searchParams.set("search", search.trim());
66
+ }
67
+
68
+ const controller = new AbortController();
69
+ let active = true;
70
+ setError(null);
71
+ setLoading(true);
72
+ fetchQueryResponse(url, { signal: controller.signal })
73
+ .then((data) => {
74
+ if (active) {
75
+ setItems(data.items as TimelineItem[]);
76
+ }
77
+ })
78
+ .catch((fetchError: unknown) => {
79
+ if (
80
+ fetchError instanceof DOMException &&
81
+ fetchError.name === "AbortError"
82
+ ) {
83
+ return;
84
+ }
85
+ if (!active) return;
86
+ setError(
87
+ fetchError instanceof Error ? fetchError.message : errorFallback,
88
+ );
89
+ setItems([]);
90
+ })
91
+ .finally(() => {
92
+ if (active) {
93
+ setLoading(false);
94
+ }
95
+ });
96
+
97
+ return () => {
98
+ active = false;
99
+ controller.abort();
100
+ };
101
+ }, [
102
+ bookmarkedOnly,
103
+ errorFallback,
104
+ likedOnly,
105
+ refreshTick,
106
+ replyFilter,
107
+ resource,
108
+ search,
109
+ selectedAccountId,
110
+ ]);
111
+
112
+ function retry() {
113
+ setRefreshTick((value) => value + 1);
114
+ }
115
+
116
+ function refreshLocalView() {
117
+ setRefreshTick((value) => value + 1);
118
+ void loadStatus();
119
+ }
120
+
121
+ async function replyToTweet(tweetId: string) {
122
+ const text = window.prompt("Reply text");
123
+ if (!text?.trim()) return;
124
+
125
+ setReplyError(null);
126
+ try {
127
+ await postAction({
128
+ kind: "replyTweet",
129
+ accountId: selectedAccountId ?? "acct_primary",
130
+ tweetId,
131
+ text,
132
+ });
133
+
134
+ retry();
135
+ } catch (replyError) {
136
+ setReplyError(
137
+ replyError instanceof Error ? replyError.message : "Reply failed",
138
+ );
139
+ }
140
+ }
141
+
142
+ return {
143
+ meta,
144
+ items,
145
+ loading,
146
+ error,
147
+ replyError,
148
+ retry,
149
+ refreshLocalView,
150
+ replyToTweet,
151
+ selectedAccountId,
152
+ };
153
+ }