birdclaw 0.8.1 → 0.8.3

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 (103) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/package.json +2 -4
  3. package/scripts/browser-perf.mjs +27 -0
  4. package/src/cli/command-context.ts +17 -0
  5. package/src/cli/register-analysis.ts +500 -0
  6. package/src/cli/register-compose.ts +40 -0
  7. package/src/cli/register-graph.ts +132 -0
  8. package/src/cli/register-inbox.ts +41 -0
  9. package/src/cli/register-storage.ts +106 -0
  10. package/src/cli.ts +30 -750
  11. package/src/components/AccountSwitcher.tsx +7 -15
  12. package/src/components/AvatarChip.tsx +1 -1
  13. package/src/components/AvatarPreload.ts +149 -0
  14. package/src/components/ConversationThread.tsx +4 -0
  15. package/src/components/EmbeddedTweetCard.tsx +4 -0
  16. package/src/components/FloatingPreview.tsx +382 -0
  17. package/src/components/MarkdownCitations.tsx +680 -0
  18. package/src/components/MarkdownViewer.tsx +7 -715
  19. package/src/components/ProfileAnalysisClient.ts +191 -0
  20. package/src/components/ProfileAnalysisStream.tsx +16 -185
  21. package/src/components/ProfilePreview.tsx +41 -81
  22. package/src/components/TimelineCard.tsx +18 -2
  23. package/src/components/TweetArticleCard.tsx +66 -0
  24. package/src/components/TweetRichText.tsx +33 -2
  25. package/src/components/links-controller.ts +162 -0
  26. package/src/components/links-model.ts +198 -0
  27. package/src/components/network-map-controller.ts +84 -0
  28. package/src/components/network-map-model.ts +255 -0
  29. package/src/components/useDebouncedValue.ts +12 -0
  30. package/src/components/useTimelineRouteData.ts +142 -170
  31. package/src/lib/analysis-runtime.ts +238 -0
  32. package/src/lib/api-client.ts +16 -100
  33. package/src/lib/api-contracts.ts +328 -0
  34. package/src/lib/archive-finder.ts +38 -0
  35. package/src/lib/archive-import-plan.ts +102 -0
  36. package/src/lib/archive-import.ts +170 -239
  37. package/src/lib/authored-live.ts +77 -182
  38. package/src/lib/backup.ts +335 -424
  39. package/src/lib/bird.ts +32 -1
  40. package/src/lib/blocks-write.ts +30 -26
  41. package/src/lib/blocks.ts +18 -20
  42. package/src/lib/database-metrics.ts +88 -0
  43. package/src/lib/database-migrations.ts +34 -0
  44. package/src/lib/database-schema.ts +312 -0
  45. package/src/lib/database-writer.ts +69 -0
  46. package/src/lib/db.ts +141 -334
  47. package/src/lib/dm-read-model.ts +533 -0
  48. package/src/lib/dms-live.ts +34 -97
  49. package/src/lib/follow-graph.ts +17 -27
  50. package/src/lib/import-repository.ts +138 -0
  51. package/src/lib/inbox.ts +2 -1
  52. package/src/lib/live-sync-engine.ts +209 -0
  53. package/src/lib/live-transport-gateway.ts +128 -0
  54. package/src/lib/mention-threads-live.ts +90 -176
  55. package/src/lib/mentions-export.ts +1 -1
  56. package/src/lib/mentions-live.ts +57 -225
  57. package/src/lib/moderation-target.ts +15 -4
  58. package/src/lib/moderation-write.ts +1 -1
  59. package/src/lib/mutes-write.ts +30 -26
  60. package/src/lib/openai-response-runtime.ts +251 -0
  61. package/src/lib/paginated-sync.ts +93 -0
  62. package/src/lib/period-digest.ts +116 -304
  63. package/src/lib/profile-analysis.ts +37 -111
  64. package/src/lib/queries.ts +6 -2380
  65. package/src/lib/query-actions.ts +437 -0
  66. package/src/lib/query-client.tsx +47 -0
  67. package/src/lib/query-read-model-shared.ts +52 -0
  68. package/src/lib/query-read-models.ts +5 -0
  69. package/src/lib/query-resource.ts +41 -0
  70. package/src/lib/query-status.ts +164 -0
  71. package/src/lib/research.ts +1 -1
  72. package/src/lib/runtime-services.ts +20 -0
  73. package/src/lib/search-discussion.ts +75 -279
  74. package/src/lib/server-runtime-services.ts +30 -0
  75. package/src/lib/sqlite.ts +53 -14
  76. package/src/lib/streaming-ingestion.ts +240 -0
  77. package/src/lib/sync-cache.ts +6 -1
  78. package/src/lib/sync-plan.ts +175 -0
  79. package/src/lib/timeline-collections-live.ts +83 -256
  80. package/src/lib/timeline-live.ts +86 -235
  81. package/src/lib/timeline-read-model.ts +1191 -0
  82. package/src/lib/tweet-render.ts +78 -0
  83. package/src/lib/tweet-repository.ts +156 -0
  84. package/src/lib/tweet-search-live.ts +63 -166
  85. package/src/lib/types.ts +8 -0
  86. package/src/lib/ui.ts +1 -1
  87. package/src/lib/web-sync.ts +67 -50
  88. package/src/lib/whois.ts +2 -1
  89. package/src/router.tsx +1 -1
  90. package/src/routes/__root.tsx +11 -21
  91. package/src/routes/api/action.tsx +1 -1
  92. package/src/routes/api/conversation.tsx +1 -1
  93. package/src/routes/api/query.tsx +32 -26
  94. package/src/routes/api/status.tsx +6 -2
  95. package/src/routes/api/sync.tsx +5 -2
  96. package/src/routes/blocks.tsx +97 -131
  97. package/src/routes/data-sources.tsx +17 -25
  98. package/src/routes/dms.tsx +168 -167
  99. package/src/routes/inbox.tsx +63 -57
  100. package/src/routes/links.tsx +31 -329
  101. package/src/routes/network-map.tsx +41 -344
  102. package/src/routes/rate-limits.tsx +17 -21
  103. package/vite.config.ts +0 -2
@@ -0,0 +1,255 @@
1
+ import type * as GeoJSON from "geojson";
2
+ import Supercluster from "supercluster";
3
+ import type { NetworkMapKind, NetworkMapResponse } from "#/lib/network-map";
4
+
5
+ export type ReactMapboxModule = typeof import("react-map-gl/mapbox");
6
+ export type MapFeature = NetworkMapResponse["features"][number];
7
+ export type MapBounds = [number, number, number, number];
8
+
9
+ export interface ClusterPointProperties {
10
+ featureIndex: number;
11
+ handle: string;
12
+ name: string;
13
+ avatarUrl: string | null;
14
+ relationship: MapFeature["properties"]["relationship"];
15
+ followersCount: number;
16
+ }
17
+
18
+ export interface ClusterAggregateProperties {
19
+ followers: number;
20
+ following: number;
21
+ mutual: number;
22
+ }
23
+
24
+ export interface ClusterFeatureProperties extends ClusterAggregateProperties {
25
+ cluster: true;
26
+ cluster_id: number;
27
+ point_count: number;
28
+ point_count_abbreviated: string | number;
29
+ }
30
+
31
+ export type ClusterPointFeature = GeoJSON.Feature<
32
+ GeoJSON.Point,
33
+ ClusterPointProperties
34
+ >;
35
+ export type ClusterFeature = GeoJSON.Feature<
36
+ GeoJSON.Point,
37
+ ClusterFeatureProperties
38
+ >;
39
+ export type ClusterResult = ClusterPointFeature | ClusterFeature;
40
+ export type MapViewport = { bounds: MapBounds; zoom: number };
41
+ export type MapTarget = {
42
+ getBounds: () => {
43
+ getWest: () => number;
44
+ getSouth: () => number;
45
+ getEast: () => number;
46
+ getNorth: () => number;
47
+ };
48
+ getZoom: () => number;
49
+ };
50
+
51
+ export type SelectedOverlay =
52
+ | { kind: "profile"; feature: MapFeature }
53
+ | {
54
+ kind: "cluster";
55
+ coordinates: [number, number];
56
+ count: number;
57
+ features: MapFeature[];
58
+ stats: ClusterAggregateProperties;
59
+ };
60
+
61
+ export const CLUSTER_LEAF_SAMPLE_SIZE = 48;
62
+
63
+ export const MAP_TYPES: Array<{ value: NetworkMapKind; label: string }> = [
64
+ { value: "all", label: "All" },
65
+ { value: "followers", label: "Followers" },
66
+ { value: "following", label: "Following" },
67
+ { value: "mutual", label: "Mutual" },
68
+ ];
69
+
70
+ export const WORLD_BOUNDS: MapBounds = [-180, -85, 180, 85];
71
+ export const WORLD_VIEWPORT: MapViewport = { bounds: WORLD_BOUNDS, zoom: 1.15 };
72
+
73
+ export async function fetchMap(
74
+ type: NetworkMapKind,
75
+ refresh: boolean,
76
+ accountId?: string,
77
+ signal?: AbortSignal,
78
+ ) {
79
+ const url = new URL("/api/network-map", window.location.origin);
80
+ url.searchParams.set("type", type);
81
+ url.searchParams.set("limit", "50000");
82
+ url.searchParams.set("geocodeLimit", refresh ? "80" : "12");
83
+ if (accountId) url.searchParams.set("account", accountId);
84
+ if (refresh) url.searchParams.set("refresh", "true");
85
+ const response = await fetch(url, { signal });
86
+ if (!response.ok) {
87
+ throw new Error(`Map request failed (${String(response.status)})`);
88
+ }
89
+ return (await response.json()) as NetworkMapResponse;
90
+ }
91
+
92
+ export function formatNumber(value: number) {
93
+ return new Intl.NumberFormat().format(value);
94
+ }
95
+
96
+ export function formatRelationship(
97
+ value: MapFeature["properties"]["relationship"],
98
+ ) {
99
+ if (value === "mutual") return "mutual";
100
+ if (value === "following") return "following";
101
+ return "follower";
102
+ }
103
+
104
+ export function relationshipColor(
105
+ relationship: MapFeature["properties"]["relationship"],
106
+ ) {
107
+ if (relationship === "mutual") return "#22c55e";
108
+ if (relationship === "following") return "#f59e0b";
109
+ return "#1d9bf0";
110
+ }
111
+
112
+ export function avatarInitial(feature: MapFeature) {
113
+ return (feature.properties.name || feature.properties.handle || "?")
114
+ .slice(0, 1)
115
+ .toUpperCase();
116
+ }
117
+
118
+ export function avatarPath(feature: MapFeature) {
119
+ if (!feature.properties.avatarUrl) return null;
120
+ const query = new URLSearchParams({
121
+ profileId: feature.properties.profileId,
122
+ v: feature.properties.avatarUrl,
123
+ });
124
+ return `/api/avatar?${query.toString()}`;
125
+ }
126
+
127
+ export function clusterGradient(stats: ClusterAggregateProperties) {
128
+ const total = Math.max(1, stats.followers + stats.following + stats.mutual);
129
+ const mutual = (stats.mutual / total) * 100;
130
+ const following = mutual + (stats.following / total) * 100;
131
+ return `conic-gradient(#22c55e 0 ${mutual}%, #f59e0b ${mutual}% ${following}%, #1d9bf0 ${following}% 100%)`;
132
+ }
133
+
134
+ export function buildClusterIndex(features: MapFeature[]) {
135
+ const points: ClusterPointFeature[] = features.map(
136
+ (feature, featureIndex) => ({
137
+ type: "Feature",
138
+ geometry: feature.geometry,
139
+ properties: {
140
+ featureIndex,
141
+ handle: feature.properties.handle,
142
+ name: feature.properties.name,
143
+ avatarUrl: feature.properties.avatarUrl,
144
+ relationship: feature.properties.relationship,
145
+ followersCount: feature.properties.followersCount,
146
+ },
147
+ }),
148
+ );
149
+ return new Supercluster<ClusterPointProperties, ClusterAggregateProperties>({
150
+ maxZoom: 18,
151
+ minPoints: 2,
152
+ radius: 64,
153
+ map: (props) => ({
154
+ followers: props.relationship === "followers" ? 1 : 0,
155
+ following: props.relationship === "following" ? 1 : 0,
156
+ mutual: props.relationship === "mutual" ? 1 : 0,
157
+ }),
158
+ reduce: (accumulated, props) => {
159
+ accumulated.followers += props.followers;
160
+ accumulated.following += props.following;
161
+ accumulated.mutual += props.mutual;
162
+ },
163
+ }).load(points);
164
+ }
165
+
166
+ export function isCluster(item: ClusterResult): item is ClusterFeature {
167
+ return "cluster" in item.properties && item.properties.cluster === true;
168
+ }
169
+
170
+ export function compareClusterFeatures(a: MapFeature, b: MapFeature) {
171
+ return (
172
+ b.properties.followersCount - a.properties.followersCount ||
173
+ a.properties.handle.localeCompare(b.properties.handle)
174
+ );
175
+ }
176
+
177
+ export function getClusterDisplayAnchor(
178
+ features: MapFeature[],
179
+ fallback: [number, number],
180
+ ): [number, number] {
181
+ const buckets = new Map<
182
+ string,
183
+ { coordinates: [number, number]; count: number; followers: number }
184
+ >();
185
+ for (const feature of features) {
186
+ const [lng, lat] = feature.geometry.coordinates;
187
+ const key = `${lng.toFixed(4)},${lat.toFixed(4)}`;
188
+ const existing = buckets.get(key);
189
+ if (existing) {
190
+ existing.count += 1;
191
+ existing.followers += feature.properties.followersCount;
192
+ } else {
193
+ buckets.set(key, {
194
+ coordinates: [lng, lat],
195
+ count: 1,
196
+ followers: feature.properties.followersCount,
197
+ });
198
+ }
199
+ }
200
+ const best = [...buckets.values()].sort(
201
+ (a, b) => b.count - a.count || b.followers - a.followers,
202
+ )[0];
203
+ return best?.coordinates ?? fallback;
204
+ }
205
+
206
+ export function readViewport(target: unknown): MapViewport | null {
207
+ if (!target || typeof target !== "object") return null;
208
+ const map = target as Partial<MapTarget>;
209
+ if (
210
+ typeof map.getBounds !== "function" ||
211
+ typeof map.getZoom !== "function"
212
+ ) {
213
+ return null;
214
+ }
215
+ const bounds = map.getBounds();
216
+ return {
217
+ bounds: [
218
+ bounds.getWest(),
219
+ bounds.getSouth(),
220
+ bounds.getEast(),
221
+ bounds.getNorth(),
222
+ ],
223
+ zoom: map.getZoom(),
224
+ };
225
+ }
226
+
227
+ export function boundsContainFeature(bounds: MapBounds, feature: MapFeature) {
228
+ const [west, south, east, north] = bounds;
229
+ const [lng, lat] = feature.geometry.coordinates;
230
+ const normalizedWest = ((west + 540) % 360) - 180;
231
+ const normalizedEast = ((east + 540) % 360) - 180;
232
+ const inLatitude = lat >= Math.max(-85, south) && lat <= Math.min(85, north);
233
+ const inLongitude =
234
+ east - west >= 360
235
+ ? true
236
+ : normalizedWest <= normalizedEast
237
+ ? lng >= normalizedWest && lng <= normalizedEast
238
+ : lng >= normalizedWest || lng <= normalizedEast;
239
+ return inLatitude && inLongitude;
240
+ }
241
+
242
+ export function featureMatchesSearch(feature: MapFeature, search: string) {
243
+ const needle = search.trim().toLowerCase();
244
+ if (!needle) return true;
245
+ return [
246
+ feature.properties.name,
247
+ feature.properties.handle,
248
+ feature.properties.location,
249
+ feature.properties.resolvedLocation ?? "",
250
+ formatRelationship(feature.properties.relationship),
251
+ ]
252
+ .join(" ")
253
+ .toLowerCase()
254
+ .includes(needle);
255
+ }
@@ -0,0 +1,12 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export function useDebouncedValue<T>(value: T, delayMs: number) {
4
+ const [debouncedValue, setDebouncedValue] = useState(value);
5
+
6
+ useEffect(() => {
7
+ const timeout = window.setTimeout(() => setDebouncedValue(value), delayMs);
8
+ return () => window.clearTimeout(timeout);
9
+ }, [delayMs, value]);
10
+
11
+ return debouncedValue;
12
+ }
@@ -1,18 +1,22 @@
1
- import { useEffect, useRef, useState } from "react";
2
- import type {
3
- QueryEnvelope,
4
- ReplyFilter,
5
- ResourceKind,
6
- TimelineItem,
7
- } from "#/lib/types";
1
+ import {
2
+ useInfiniteQuery,
3
+ useMutation,
4
+ useQuery,
5
+ useQueryClient,
6
+ } from "@tanstack/react-query";
7
+ import { useMemo } from "react";
8
8
  import {
9
9
  fetchQueryEnvelope,
10
10
  fetchQueryResponse,
11
11
  postAction,
12
12
  } from "#/lib/api-client";
13
+ import { queryKeys } from "#/lib/query-client";
14
+ import type { ReplyFilter, ResourceKind, TimelineItem } from "#/lib/types";
13
15
  import { useSelectedAccountId } from "./account-selection";
16
+ import { useDebouncedValue } from "./useDebouncedValue";
14
17
 
15
18
  const PAGE_SIZE = 50;
19
+ const TIMELINE_STALE_TIME_MS = 5 * 60_000;
16
20
 
17
21
  interface UseTimelineRouteDataOptions {
18
22
  resource: Exclude<ResourceKind, "dms">;
@@ -23,6 +27,49 @@ interface UseTimelineRouteDataOptions {
23
27
  bookmarkedOnly?: boolean;
24
28
  }
25
29
 
30
+ interface TimelinePageParam {
31
+ until: string;
32
+ untilId: string;
33
+ }
34
+
35
+ function buildTimelineQueryUrl({
36
+ resource,
37
+ search,
38
+ replyFilter,
39
+ likedOnly,
40
+ bookmarkedOnly,
41
+ selectedAccountId,
42
+ pageParam,
43
+ }: {
44
+ resource: Exclude<ResourceKind, "dms">;
45
+ search: string;
46
+ replyFilter?: ReplyFilter;
47
+ likedOnly: boolean;
48
+ bookmarkedOnly: boolean;
49
+ selectedAccountId?: string;
50
+ pageParam?: TimelinePageParam;
51
+ }) {
52
+ const params = new URLSearchParams({
53
+ resource,
54
+ limit: String(PAGE_SIZE),
55
+ });
56
+ if (selectedAccountId) params.set("account", selectedAccountId);
57
+ if (replyFilter) params.set("replyFilter", replyFilter);
58
+ if (likedOnly) params.set("liked", "true");
59
+ if (bookmarkedOnly) params.set("bookmarked", "true");
60
+ if (search.trim()) params.set("search", search.trim());
61
+ if (pageParam) {
62
+ params.set("until", pageParam.until);
63
+ params.set("untilId", pageParam.untilId);
64
+ }
65
+ params.sort();
66
+ const base =
67
+ typeof window === "undefined"
68
+ ? "http://birdclaw.local"
69
+ : window.location.origin;
70
+ return new URL(`/api/query?${params.toString()}`, base).toString();
71
+ }
72
+
26
73
  export function useTimelineRouteData({
27
74
  resource,
28
75
  search,
@@ -31,191 +78,116 @@ export function useTimelineRouteData({
31
78
  likedOnly = false,
32
79
  bookmarkedOnly = false,
33
80
  }: UseTimelineRouteDataOptions) {
34
- const [meta, setMeta] = useState<QueryEnvelope | null>(null);
35
- const [items, setItems] = useState<TimelineItem[]>([]);
36
- const [loading, setLoading] = useState(true);
37
- const [error, setError] = useState<string | null>(null);
38
- const [replyError, setReplyError] = useState<string | null>(null);
39
- const [refreshTick, setRefreshTick] = useState(0);
40
- const [hasMore, setHasMore] = useState(false);
41
- const [loadingMore, setLoadingMore] = useState(false);
81
+ const queryClient = useQueryClient();
82
+ const statusQuery = useQuery({
83
+ queryKey: queryKeys.status,
84
+ queryFn: ({ signal }) => fetchQueryEnvelope({ signal }),
85
+ });
86
+ const meta = statusQuery.data ?? null;
42
87
  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);
48
-
49
- async function loadStatus() {
50
- setMeta(await fetchQueryEnvelope());
51
- }
52
-
53
- useEffect(() => {
54
- void loadStatus();
55
- }, []);
56
-
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) {
61
- const url = new URL("/api/query", window.location.origin);
62
- url.searchParams.set("resource", resource);
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
- }
68
- if (replyFilter) {
69
- url.searchParams.set("replyFilter", replyFilter);
70
- }
71
- if (likedOnly) {
72
- url.searchParams.set("liked", "true");
73
- }
74
- if (bookmarkedOnly) {
75
- url.searchParams.set("bookmarked", "true");
76
- }
77
- if (search.trim()) {
78
- url.searchParams.set("search", search.trim());
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
- }
88
-
89
- useEffect(() => {
90
- generationRef.current += 1;
91
- loadMoreControllerRef.current?.abort();
92
- const controller = new AbortController();
93
- let active = true;
94
- setError(null);
95
- setLoading(true);
96
- setLoadingMore(false);
97
- fetchQueryResponse(buildQueryUrl(), { signal: controller.signal })
98
- .then((data) => {
99
- if (!active) return;
100
- const next = data.items as TimelineItem[];
101
- setItems(next);
102
- setHasMore(next.length >= PAGE_SIZE);
103
- })
104
- .catch((fetchError: unknown) => {
105
- if (
106
- fetchError instanceof DOMException &&
107
- fetchError.name === "AbortError"
108
- ) {
109
- return;
110
- }
111
- if (!active) return;
112
- setError(
113
- fetchError instanceof Error ? fetchError.message : errorFallback,
114
- );
115
- setItems([]);
116
- setHasMore(false);
117
- })
118
- .finally(() => {
119
- if (active) {
120
- setLoading(false);
121
- }
122
- });
123
-
124
- return () => {
125
- active = false;
126
- controller.abort();
127
- };
128
- }, [
129
- bookmarkedOnly,
130
- errorFallback,
131
- likedOnly,
132
- refreshTick,
133
- replyFilter,
134
- resource,
135
- search,
136
- selectedAccountId,
137
- ]);
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);
88
+ const debouncedSearch = useDebouncedValue(search, 180);
89
+ const timelineQueryKey = [
90
+ ...queryKeys.timelines,
91
+ {
92
+ resource,
93
+ search: debouncedSearch,
94
+ replyFilter: replyFilter ?? "all",
95
+ likedOnly,
96
+ bookmarkedOnly,
97
+ selectedAccountId: selectedAccountId ?? null,
98
+ },
99
+ ] as const;
100
+ const timelineQuery = useInfiniteQuery({
101
+ queryKey: timelineQueryKey,
102
+ initialPageParam: undefined as TimelinePageParam | undefined,
103
+ queryFn: ({ pageParam, signal }) =>
104
+ fetchQueryResponse(
105
+ buildTimelineQueryUrl({
106
+ resource,
107
+ search: debouncedSearch,
108
+ replyFilter,
109
+ likedOnly,
110
+ bookmarkedOnly,
111
+ selectedAccountId,
112
+ pageParam,
113
+ }),
114
+ { signal },
115
+ ),
116
+ getNextPageParam: (lastPage) => {
117
+ const items = lastPage.items as TimelineItem[];
118
+ const lastItem = items.at(-1);
119
+ return items.length >= PAGE_SIZE && lastItem
120
+ ? { until: lastItem.createdAt, untilId: lastItem.id }
121
+ : undefined;
122
+ },
123
+ staleTime: TIMELINE_STALE_TIME_MS,
124
+ });
125
+ const items = useMemo(() => {
126
+ const seen = new Set<string>();
127
+ const merged: TimelineItem[] = [];
128
+ for (const page of timelineQuery.data?.pages ?? []) {
129
+ for (const item of page.items as TimelineItem[]) {
130
+ if (seen.has(item.id)) continue;
131
+ seen.add(item.id);
132
+ merged.push(item);
173
133
  }
174
134
  }
175
- }
135
+ return merged;
136
+ }, [timelineQuery.data]);
137
+ const replyMutation = useMutation({
138
+ mutationFn: ({ tweetId, text }: { tweetId: string; text: string }) =>
139
+ postAction({
140
+ kind: "replyTweet",
141
+ accountId: selectedAccountId ?? "acct_primary",
142
+ tweetId,
143
+ text,
144
+ }),
145
+ onSuccess: () =>
146
+ queryClient.invalidateQueries({ queryKey: timelineQueryKey }),
147
+ });
176
148
 
177
149
  function retry() {
178
- setRefreshTick((value) => value + 1);
150
+ void timelineQuery.refetch();
179
151
  }
180
152
 
181
153
  function refreshLocalView() {
182
- setRefreshTick((value) => value + 1);
183
- void loadStatus();
154
+ void Promise.all([
155
+ queryClient.invalidateQueries({ queryKey: queryKeys.timelines }),
156
+ queryClient.invalidateQueries({ queryKey: queryKeys.status }),
157
+ ]);
184
158
  }
185
159
 
186
160
  async function replyToTweet(tweetId: string) {
187
161
  const text = window.prompt("Reply text");
188
162
  if (!text?.trim()) return;
189
-
190
- setReplyError(null);
191
- try {
192
- await postAction({
193
- kind: "replyTweet",
194
- accountId: selectedAccountId ?? "acct_primary",
195
- tweetId,
196
- text,
163
+ await replyMutation
164
+ .mutateAsync({ tweetId, text: text.trim() })
165
+ .catch(() => {
166
+ // The mutation error is exposed below for the route frame.
197
167
  });
198
-
199
- retry();
200
- } catch (replyError) {
201
- setReplyError(
202
- replyError instanceof Error ? replyError.message : "Reply failed",
203
- );
204
- }
205
168
  }
206
169
 
170
+ const queryError = timelineQuery.error;
207
171
  return {
208
172
  meta,
209
173
  items,
210
- loading,
211
- error,
212
- replyError,
174
+ loading: timelineQuery.isPending,
175
+ error: queryError
176
+ ? queryError instanceof Error
177
+ ? queryError.message
178
+ : errorFallback
179
+ : null,
180
+ replyError: replyMutation.error
181
+ ? replyMutation.error instanceof Error
182
+ ? replyMutation.error.message
183
+ : "Reply failed"
184
+ : null,
213
185
  retry,
214
186
  refreshLocalView,
215
187
  replyToTweet,
216
188
  selectedAccountId,
217
- hasMore,
218
- loadingMore,
219
- loadMore,
189
+ hasMore: timelineQuery.hasNextPage,
190
+ loadingMore: timelineQuery.isFetchingNextPage,
191
+ loadMore: () => timelineQuery.fetchNextPage().then(() => undefined),
220
192
  };
221
193
  }