birdclaw 0.5.1 → 0.7.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 (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -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 +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  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 +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. package/src/styles.css +22 -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);
@@ -1,8 +1,9 @@
1
1
  import { Fragment } from "react";
2
2
  import type { ReactNode } from "react";
3
3
  import {
4
- collectTweetSegments,
4
+ collectTweetSegmentsForText,
5
5
  enrichFallbackUrlEntities,
6
+ normalizeTweetUrlEntityRangeForText,
6
7
  } from "#/lib/tweet-render";
7
8
  import type { TweetEntities } from "#/lib/types";
8
9
  import {
@@ -11,25 +12,43 @@ import {
11
12
  tweetLinkClass,
12
13
  tweetMentionClass,
13
14
  } from "#/lib/ui";
15
+ import { safeHttpUrl } from "#/lib/url-safety";
14
16
  import { ProfilePreview } from "./ProfilePreview";
15
17
 
18
+ function rangeKey(range: { start: number; end: number }) {
19
+ return `${range.start}:${range.end}`;
20
+ }
21
+
16
22
  export function TweetRichText({
17
23
  text,
18
24
  entities,
19
25
  className = "body-copy",
20
26
  hiddenUrlRanges = [],
27
+ urlLabel = "display",
28
+ as = "p",
21
29
  }: {
22
30
  text: string;
23
31
  entities: TweetEntities;
24
32
  className?: string;
25
33
  hiddenUrlRanges?: Array<{ start: number; end: number }>;
34
+ urlLabel?: "display" | "expanded";
35
+ as?: "p" | "span";
26
36
  }) {
27
37
  const richEntities = enrichFallbackUrlEntities(text, entities);
28
- const segments = collectTweetSegments(richEntities);
38
+ const segments = collectTweetSegmentsForText(text, richEntities);
39
+ const hiddenRawRangeKeys = new Set(hiddenUrlRanges.map(rangeKey));
40
+ const hiddenRangeKeys = new Set(hiddenRawRangeKeys);
41
+ for (const entry of richEntities.urls ?? []) {
42
+ if (!hiddenRawRangeKeys.has(rangeKey(entry))) continue;
43
+ hiddenRangeKeys.add(
44
+ rangeKey(normalizeTweetUrlEntityRangeForText(text, entry)),
45
+ );
46
+ }
47
+ const Wrapper = as;
29
48
  let cursor = 0;
30
49
 
31
50
  return (
32
- <p className={className === "body-copy" ? bodyCopyClass : className}>
51
+ <Wrapper className={className === "body-copy" ? bodyCopyClass : className}>
33
52
  {segments.map((segment, index) => {
34
53
  if (
35
54
  segment.start < cursor ||
@@ -47,13 +66,7 @@ export function TweetRichText({
47
66
  {text.slice(segment.start, segment.end)}
48
67
  </Fragment>
49
68
  );
50
- if (
51
- segment.kind === "url" &&
52
- hiddenUrlRanges.some(
53
- (range) =>
54
- range.start === segment.start && range.end === segment.end,
55
- )
56
- ) {
69
+ if (segment.kind === "url" && hiddenRangeKeys.has(rangeKey(segment))) {
57
70
  node = null;
58
71
  } else if (segment.kind === "mention" && segment.profile) {
59
72
  node = (
@@ -64,18 +77,33 @@ export function TweetRichText({
64
77
  <span className={tweetMentionClass}>@{segment.username}</span>
65
78
  </ProfilePreview>
66
79
  );
67
- } else if (segment.kind === "url") {
80
+ } else if (segment.kind === "mention") {
68
81
  node = (
69
82
  <a
70
83
  key={`segment-${String(index)}`}
71
- className={tweetLinkClass}
72
- href={segment.expandedUrl}
73
- rel="noreferrer"
74
- target="_blank"
84
+ className={tweetMentionClass}
85
+ href={`/profiles/${encodeURIComponent(segment.username)}`}
75
86
  >
76
- {segment.displayUrl}
87
+ @{segment.username}
77
88
  </a>
78
89
  );
90
+ } else if (segment.kind === "url") {
91
+ const href = safeHttpUrl(segment.expandedUrl);
92
+ if (href) {
93
+ node = (
94
+ <a
95
+ key={`segment-${String(index)}`}
96
+ className={tweetLinkClass}
97
+ href={href}
98
+ rel="noreferrer"
99
+ target="_blank"
100
+ >
101
+ {urlLabel === "expanded"
102
+ ? segment.expandedUrl
103
+ : segment.displayUrl}
104
+ </a>
105
+ );
106
+ }
79
107
  } else if (segment.kind === "hashtag") {
80
108
  node = (
81
109
  <span
@@ -95,6 +123,6 @@ export function TweetRichText({
95
123
  );
96
124
  })}
97
125
  {text.slice(cursor)}
98
- </p>
126
+ </Wrapper>
99
127
  );
100
128
  }
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
2
  import type {
3
3
  QueryEnvelope,
4
4
  ReplyFilter,
@@ -10,6 +10,9 @@ import {
10
10
  fetchQueryResponse,
11
11
  postAction,
12
12
  } from "#/lib/api-client";
13
+ import { useSelectedAccountId } from "./account-selection";
14
+
15
+ const PAGE_SIZE = 50;
13
16
 
14
17
  interface UseTimelineRouteDataOptions {
15
18
  resource: Exclude<ResourceKind, "dms">;
@@ -32,7 +35,16 @@ export function useTimelineRouteData({
32
35
  const [items, setItems] = useState<TimelineItem[]>([]);
33
36
  const [loading, setLoading] = useState(true);
34
37
  const [error, setError] = useState<string | null>(null);
38
+ const [replyError, setReplyError] = useState<string | null>(null);
35
39
  const [refreshTick, setRefreshTick] = useState(0);
40
+ const [hasMore, setHasMore] = useState(false);
41
+ const [loadingMore, setLoadingMore] = useState(false);
42
+ const selectedAccountId = useSelectedAccountId(meta?.accounts);
43
+ // Bumped whenever the active filters change. A `loadMore` request that
44
+ // resolves against a stale generation is discarded so its older page is
45
+ // never appended to a freshly loaded feed.
46
+ const generationRef = useRef(0);
47
+ const loadMoreControllerRef = useRef<AbortController | null>(null);
36
48
 
37
49
  async function loadStatus() {
38
50
  setMeta(await fetchQueryEnvelope());
@@ -42,10 +54,17 @@ export function useTimelineRouteData({
42
54
  void loadStatus();
43
55
  }, []);
44
56
 
45
- useEffect(() => {
57
+ // Build the /api/query URL for the current filters. Passing the last item's
58
+ // (createdAt, id) requests the next (older) page via the server's keyset
59
+ // cursor, which is deterministic across duplicate timestamps.
60
+ function buildQueryUrl(until?: string, untilId?: string) {
46
61
  const url = new URL("/api/query", window.location.origin);
47
62
  url.searchParams.set("resource", resource);
48
63
  url.searchParams.set("refresh", String(refreshTick));
64
+ url.searchParams.set("limit", String(PAGE_SIZE));
65
+ if (selectedAccountId) {
66
+ url.searchParams.set("account", selectedAccountId);
67
+ }
49
68
  if (replyFilter) {
50
69
  url.searchParams.set("replyFilter", replyFilter);
51
70
  }
@@ -58,16 +77,29 @@ export function useTimelineRouteData({
58
77
  if (search.trim()) {
59
78
  url.searchParams.set("search", search.trim());
60
79
  }
80
+ if (until) {
81
+ url.searchParams.set("until", until);
82
+ }
83
+ if (untilId) {
84
+ url.searchParams.set("untilId", untilId);
85
+ }
86
+ return url;
87
+ }
61
88
 
89
+ useEffect(() => {
90
+ generationRef.current += 1;
91
+ loadMoreControllerRef.current?.abort();
62
92
  const controller = new AbortController();
63
93
  let active = true;
64
94
  setError(null);
65
95
  setLoading(true);
66
- fetchQueryResponse(url, { signal: controller.signal })
96
+ setLoadingMore(false);
97
+ fetchQueryResponse(buildQueryUrl(), { signal: controller.signal })
67
98
  .then((data) => {
68
- if (active) {
69
- setItems(data.items as TimelineItem[]);
70
- }
99
+ if (!active) return;
100
+ const next = data.items as TimelineItem[];
101
+ setItems(next);
102
+ setHasMore(next.length >= PAGE_SIZE);
71
103
  })
72
104
  .catch((fetchError: unknown) => {
73
105
  if (
@@ -81,6 +113,7 @@ export function useTimelineRouteData({
81
113
  fetchError instanceof Error ? fetchError.message : errorFallback,
82
114
  );
83
115
  setItems([]);
116
+ setHasMore(false);
84
117
  })
85
118
  .finally(() => {
86
119
  if (active) {
@@ -100,8 +133,47 @@ export function useTimelineRouteData({
100
133
  replyFilter,
101
134
  resource,
102
135
  search,
136
+ selectedAccountId,
103
137
  ]);
104
138
 
139
+ async function loadMore() {
140
+ if (loading || loadingMore || !hasMore || items.length === 0) return;
141
+ const lastItem = items[items.length - 1];
142
+ const until = lastItem?.createdAt;
143
+ const untilId = lastItem?.id;
144
+ if (!until || !untilId) return;
145
+ const generation = generationRef.current;
146
+ const controller = new AbortController();
147
+ loadMoreControllerRef.current = controller;
148
+ setLoadingMore(true);
149
+ try {
150
+ const data = await fetchQueryResponse(buildQueryUrl(until, untilId), {
151
+ signal: controller.signal,
152
+ });
153
+ // Discard if the filters changed (new generation) while in flight.
154
+ if (generation !== generationRef.current) return;
155
+ const page = data.items as TimelineItem[];
156
+ setItems((prev) => {
157
+ const seen = new Set(prev.map((item) => item.id));
158
+ return [...prev, ...page.filter((item) => !seen.has(item.id))];
159
+ });
160
+ setHasMore(page.length >= PAGE_SIZE);
161
+ } catch (loadError) {
162
+ if (
163
+ loadError instanceof DOMException &&
164
+ loadError.name === "AbortError"
165
+ ) {
166
+ return;
167
+ }
168
+ if (generation !== generationRef.current) return;
169
+ setError(loadError instanceof Error ? loadError.message : errorFallback);
170
+ } finally {
171
+ if (generation === generationRef.current) {
172
+ setLoadingMore(false);
173
+ }
174
+ }
175
+ }
176
+
105
177
  function retry() {
106
178
  setRefreshTick((value) => value + 1);
107
179
  }
@@ -115,14 +187,21 @@ export function useTimelineRouteData({
115
187
  const text = window.prompt("Reply text");
116
188
  if (!text?.trim()) return;
117
189
 
118
- await postAction({
119
- kind: "replyTweet",
120
- accountId: "acct_primary",
121
- tweetId,
122
- text,
123
- });
190
+ setReplyError(null);
191
+ try {
192
+ await postAction({
193
+ kind: "replyTweet",
194
+ accountId: selectedAccountId ?? "acct_primary",
195
+ tweetId,
196
+ text,
197
+ });
124
198
 
125
- retry();
199
+ retry();
200
+ } catch (replyError) {
201
+ setReplyError(
202
+ replyError instanceof Error ? replyError.message : "Reply failed",
203
+ );
204
+ }
126
205
  }
127
206
 
128
207
  return {
@@ -130,8 +209,13 @@ export function useTimelineRouteData({
130
209
  items,
131
210
  loading,
132
211
  error,
212
+ replyError,
133
213
  retry,
134
214
  refreshLocalView,
135
215
  replyToTweet,
216
+ selectedAccountId,
217
+ hasMore,
218
+ loadingMore,
219
+ loadMore,
136
220
  };
137
221
  }