birdclaw 0.8.2 → 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 (89) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +2 -1
  3. package/src/cli/command-context.ts +17 -0
  4. package/src/cli/register-analysis.ts +500 -0
  5. package/src/cli/register-compose.ts +40 -0
  6. package/src/cli/register-graph.ts +132 -0
  7. package/src/cli/register-inbox.ts +41 -0
  8. package/src/cli/register-storage.ts +106 -0
  9. package/src/cli.ts +30 -750
  10. package/src/components/AccountSwitcher.tsx +7 -15
  11. package/src/components/AvatarChip.tsx +1 -1
  12. package/src/components/AvatarPreload.ts +149 -0
  13. package/src/components/MarkdownCitations.tsx +680 -0
  14. package/src/components/MarkdownViewer.tsx +8 -674
  15. package/src/components/ProfileAnalysisClient.ts +191 -0
  16. package/src/components/ProfileAnalysisStream.tsx +16 -185
  17. package/src/components/ProfilePreview.tsx +2 -0
  18. package/src/components/links-controller.ts +162 -0
  19. package/src/components/links-model.ts +198 -0
  20. package/src/components/network-map-controller.ts +84 -0
  21. package/src/components/network-map-model.ts +255 -0
  22. package/src/components/useTimelineRouteData.ts +105 -235
  23. package/src/lib/analysis-runtime.ts +238 -0
  24. package/src/lib/api-client.ts +16 -215
  25. package/src/lib/api-contracts.ts +328 -0
  26. package/src/lib/archive-import-plan.ts +102 -0
  27. package/src/lib/archive-import.ts +170 -239
  28. package/src/lib/authored-live.ts +75 -120
  29. package/src/lib/backup.ts +335 -424
  30. package/src/lib/blocks-write.ts +30 -26
  31. package/src/lib/blocks.ts +18 -20
  32. package/src/lib/database-metrics.ts +88 -0
  33. package/src/lib/database-migrations.ts +34 -0
  34. package/src/lib/database-schema.ts +312 -0
  35. package/src/lib/database-writer.ts +69 -0
  36. package/src/lib/db.ts +84 -330
  37. package/src/lib/dm-read-model.ts +533 -0
  38. package/src/lib/dms-live.ts +34 -97
  39. package/src/lib/follow-graph.ts +17 -27
  40. package/src/lib/import-repository.ts +138 -0
  41. package/src/lib/inbox.ts +2 -1
  42. package/src/lib/live-sync-engine.ts +209 -0
  43. package/src/lib/live-transport-gateway.ts +128 -0
  44. package/src/lib/mention-threads-live.ts +90 -177
  45. package/src/lib/mentions-export.ts +1 -1
  46. package/src/lib/mentions-live.ts +57 -181
  47. package/src/lib/moderation-target.ts +15 -4
  48. package/src/lib/moderation-write.ts +1 -1
  49. package/src/lib/mutes-write.ts +30 -26
  50. package/src/lib/openai-response-runtime.ts +251 -0
  51. package/src/lib/paginated-sync.ts +93 -0
  52. package/src/lib/period-digest.ts +116 -304
  53. package/src/lib/profile-analysis.ts +36 -110
  54. package/src/lib/queries.ts +6 -2381
  55. package/src/lib/query-actions.ts +437 -0
  56. package/src/lib/query-client.tsx +47 -0
  57. package/src/lib/query-read-model-shared.ts +52 -0
  58. package/src/lib/query-read-models.ts +5 -0
  59. package/src/lib/query-resource.ts +41 -0
  60. package/src/lib/query-status.ts +164 -0
  61. package/src/lib/research.ts +1 -1
  62. package/src/lib/runtime-services.ts +20 -0
  63. package/src/lib/search-discussion.ts +75 -279
  64. package/src/lib/server-runtime-services.ts +30 -0
  65. package/src/lib/sqlite.ts +48 -12
  66. package/src/lib/streaming-ingestion.ts +240 -0
  67. package/src/lib/sync-cache.ts +6 -1
  68. package/src/lib/sync-plan.ts +175 -0
  69. package/src/lib/timeline-collections-live.ts +83 -257
  70. package/src/lib/timeline-live.ts +86 -236
  71. package/src/lib/timeline-read-model.ts +1191 -0
  72. package/src/lib/tweet-repository.ts +156 -0
  73. package/src/lib/tweet-search-live.ts +63 -167
  74. package/src/lib/web-sync.ts +67 -50
  75. package/src/lib/whois.ts +2 -1
  76. package/src/routes/__root.tsx +11 -8
  77. package/src/routes/api/action.tsx +1 -1
  78. package/src/routes/api/conversation.tsx +1 -1
  79. package/src/routes/api/query.tsx +32 -26
  80. package/src/routes/api/status.tsx +6 -4
  81. package/src/routes/api/sync.tsx +5 -2
  82. package/src/routes/blocks.tsx +97 -131
  83. package/src/routes/data-sources.tsx +17 -25
  84. package/src/routes/dms.tsx +167 -184
  85. package/src/routes/inbox.tsx +63 -57
  86. package/src/routes/links.tsx +31 -394
  87. package/src/routes/network-map.tsx +41 -344
  88. package/src/routes/rate-limits.tsx +17 -21
  89. package/src/lib/client-cache.ts +0 -109
@@ -1,4 +1,10 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
+ import {
3
+ keepPreviousData,
4
+ useMutation,
5
+ useQuery,
6
+ useQueryClient,
7
+ } from "@tanstack/react-query";
2
8
  import { Search } from "lucide-react";
3
9
  import { useEffect, useMemo, useState } from "react";
4
10
  import { DmWorkspace } from "#/components/DmWorkspace";
@@ -6,16 +12,15 @@ import { FeedEmpty, FeedError, FeedLoading } from "#/components/FeedState";
6
12
  import { SyncNowButton } from "#/components/SyncNowButton";
7
13
  import { useSelectedAccountId } from "#/components/account-selection";
8
14
  import {
9
- fetchCachedQueryResponse,
10
15
  fetchQueryEnvelope,
11
- invalidateCachedQueryResponses,
16
+ fetchQueryResponse,
12
17
  postAction,
13
- readCachedQueryResponse,
14
18
  } from "#/lib/api-client";
19
+ import { queryKeys } from "#/lib/query-client";
15
20
  import type {
16
21
  DmConversationItem,
17
22
  DmMessageItem,
18
- QueryEnvelope,
23
+ QueryResponse,
19
24
  ReplyFilter,
20
25
  } from "#/lib/types";
21
26
  import { useDebouncedValue } from "#/components/useDebouncedValue";
@@ -65,12 +70,7 @@ const filterNumberFieldClass =
65
70
  "flex h-[46px] shrink-0 items-center gap-2 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 py-0 text-[14px] text-[var(--ink)] outline-none transition-colors duration-150 focus-within:border-[var(--accent)] focus-within:shadow-[0_0_0_1px_var(--accent)]";
66
71
 
67
72
  function DmsRoute() {
68
- const [meta, setMeta] = useState<QueryEnvelope | null>(null);
69
- const [items, setItems] = useState<DmConversationItem[]>([]);
70
- const [messages, setMessages] = useState<DmMessageItem[]>([]);
71
- const [loadedConversationId, setLoadedConversationId] = useState<
72
- string | undefined
73
- >();
73
+ const queryClient = useQueryClient();
74
74
  const [selectedConversationId, setSelectedConversationId] = useState<
75
75
  string | undefined
76
76
  >();
@@ -81,204 +81,187 @@ function DmsRoute() {
81
81
  const [sort, setSort] = useState<"recent" | "followers">("recent");
82
82
  const [search, setSearch] = useState("");
83
83
  const [replyDraft, setReplyDraft] = useState("");
84
- const [refreshTick, setRefreshTick] = useState(0);
85
- const [loading, setLoading] = useState(true);
86
- const [error, setError] = useState<string | null>(null);
87
- const [replyError, setReplyError] = useState<string | null>(null);
84
+ const statusQuery = useQuery({
85
+ queryKey: queryKeys.status,
86
+ queryFn: ({ signal }) => fetchQueryEnvelope({ signal }),
87
+ });
88
+ const meta = statusQuery.data ?? null;
88
89
  const selectedAccountId = useSelectedAccountId(meta?.accounts);
89
90
  const debouncedSearch = useDebouncedValue(search, 180);
90
-
91
- async function loadStatus(force = false) {
92
- setMeta(await fetchQueryEnvelope(undefined, { force }));
93
- }
91
+ const dmsQueryKey = [
92
+ ...queryKeys.dms,
93
+ {
94
+ inboxFilter,
95
+ replyFilter,
96
+ minFollowers,
97
+ minInfluenceScore,
98
+ sort,
99
+ search: debouncedSearch,
100
+ selectedAccountId: selectedAccountId ?? null,
101
+ selectedConversationId: selectedConversationId ?? null,
102
+ },
103
+ ] as const;
104
+ const dmsQuery = useQuery({
105
+ queryKey: dmsQueryKey,
106
+ queryFn: ({ signal }) => {
107
+ const url = new URL("/api/query", window.location.origin);
108
+ url.searchParams.set("resource", "dms");
109
+ url.searchParams.set("inbox", inboxFilter);
110
+ url.searchParams.set("replyFilter", replyFilter);
111
+ url.searchParams.set("sort", sort);
112
+ if (minFollowers.trim()) {
113
+ url.searchParams.set("minFollowers", minFollowers.trim());
114
+ }
115
+ if (minInfluenceScore.trim()) {
116
+ url.searchParams.set("minInfluenceScore", minInfluenceScore.trim());
117
+ }
118
+ if (selectedAccountId && inboxFilter !== "requests") {
119
+ url.searchParams.set("account", selectedAccountId);
120
+ }
121
+ if (selectedConversationId) {
122
+ url.searchParams.set("conversationId", selectedConversationId);
123
+ }
124
+ if (debouncedSearch.trim()) {
125
+ url.searchParams.set("search", debouncedSearch.trim());
126
+ }
127
+ return fetchQueryResponse(url, { signal });
128
+ },
129
+ placeholderData: keepPreviousData,
130
+ staleTime: 5 * 60_000,
131
+ });
132
+ const items = (dmsQuery.data?.items ?? []) as DmConversationItem[];
133
+ const messages = dmsQuery.data?.selectedConversation?.messages ?? [];
134
+ const loadedConversationId =
135
+ dmsQuery.data?.selectedConversation?.conversation.id;
94
136
 
95
137
  useEffect(() => {
96
- void loadStatus();
97
- }, []);
98
-
99
- useEffect(() => {
100
- const controller = new AbortController();
101
- let active = true;
102
- const url = new URL("/api/query", window.location.origin);
103
- url.searchParams.set("resource", "dms");
104
- url.searchParams.set("inbox", inboxFilter);
105
- url.searchParams.set("replyFilter", replyFilter);
106
- url.searchParams.set("refresh", String(refreshTick));
107
- url.searchParams.set("sort", sort);
108
- if (minFollowers.trim()) {
109
- url.searchParams.set("minFollowers", minFollowers.trim());
110
- }
111
- if (minInfluenceScore.trim()) {
112
- url.searchParams.set("minInfluenceScore", minInfluenceScore.trim());
113
- }
114
- if (selectedAccountId && inboxFilter !== "requests") {
115
- url.searchParams.set("account", selectedAccountId);
116
- }
117
- if (selectedConversationId) {
118
- url.searchParams.set("conversationId", selectedConversationId);
119
- }
120
- if (debouncedSearch.trim()) {
121
- url.searchParams.set("search", debouncedSearch.trim());
122
- }
123
-
124
- const applyResponse = (
125
- data: Awaited<ReturnType<typeof fetchCachedQueryResponse>>,
126
- ) => {
127
- const conversations = data.items as DmConversationItem[];
128
- const nextSelected =
129
- data.selectedConversation?.conversation.id ?? conversations[0]?.id;
130
- setLoadedConversationId(data.selectedConversation?.conversation.id);
131
- setItems(conversations);
132
- setSelectedConversationId((current) => {
133
- if (!current) return nextSelected;
134
- return conversations.some((conversation) => conversation.id === current)
135
- ? current
136
- : nextSelected;
137
- });
138
- setMessages(data.selectedConversation?.messages ?? []);
139
- };
140
- setError(null);
141
- const cached = readCachedQueryResponse(url);
142
- if (cached) {
143
- applyResponse(cached);
144
- setLoading(false);
145
- return () => {
146
- active = false;
147
- controller.abort();
148
- };
149
- }
150
- setLoading(true);
151
- fetchCachedQueryResponse(url, { signal: controller.signal })
152
- .then((data) => {
153
- if (!active) return;
154
- applyResponse(data);
155
- })
156
- .catch((fetchError: unknown) => {
157
- if (
158
- fetchError instanceof DOMException &&
159
- fetchError.name === "AbortError"
160
- ) {
161
- return;
162
- }
163
- if (!active) return;
164
- setError(
165
- fetchError instanceof Error
166
- ? fetchError.message
167
- : "Messages unavailable",
168
- );
169
- setItems([]);
170
- setMessages([]);
171
- setLoadedConversationId(undefined);
172
- })
173
- .finally(() => {
174
- if (active) {
175
- setLoading(false);
176
- }
177
- });
178
-
179
- return () => {
180
- active = false;
181
- controller.abort();
182
- };
183
- }, [
184
- minFollowers,
185
- minInfluenceScore,
186
- inboxFilter,
187
- refreshTick,
188
- replyFilter,
189
- debouncedSearch,
190
- selectedConversationId,
191
- selectedAccountId,
192
- sort,
193
- ]);
138
+ if (!dmsQuery.data) return;
139
+ const nextSelected = loadedConversationId ?? items[0]?.id;
140
+ setSelectedConversationId((current) => {
141
+ if (!current) return nextSelected;
142
+ return items.some((conversation) => conversation.id === current)
143
+ ? current
144
+ : nextSelected;
145
+ });
146
+ }, [dmsQuery.data, items, loadedConversationId]);
194
147
 
195
148
  const selectedConversation =
196
149
  items.find((item) => item.id === selectedConversationId) ?? null;
197
- const switchingConversation =
198
- loading &&
199
- Boolean(
200
- selectedConversationId &&
201
- loadedConversationId &&
202
- selectedConversationId !== loadedConversationId,
203
- );
150
+ const switchingConversation = Boolean(
151
+ !dmsQuery.isError &&
152
+ selectedConversationId &&
153
+ loadedConversationId &&
154
+ selectedConversationId !== loadedConversationId,
155
+ );
204
156
 
205
157
  const subtitle = useMemo(() => {
206
158
  if (!meta) return "Loading direct messages...";
207
159
  return `${String(meta.stats.dms)} conversations cached locally`;
208
160
  }, [meta]);
209
-
210
- async function replyToConversation(conversationId: string) {
211
- const text = replyDraft.trim();
212
- if (!text || !selectedConversation) return;
213
-
214
- const now = new Date().toISOString();
215
- const accountRecord = meta?.accounts.find(
216
- (account) => account.id === selectedConversation.accountId,
217
- );
218
- const senderHandle = (
219
- accountRecord?.handle ?? selectedConversation.accountHandle
220
- ).replace(/^@/, "");
221
- const optimisticMessage: DmMessageItem = {
222
- id: `optimistic-${now}`,
161
+ const replyMutation = useMutation({
162
+ mutationFn: ({
223
163
  conversationId,
224
164
  text,
225
- createdAt: now,
226
- direction: "outbound",
227
- isReplied: true,
228
- mediaCount: 0,
229
- sender: {
230
- id: `local-${selectedConversation.accountId}`,
231
- handle: senderHandle,
232
- displayName: accountRecord?.name ?? senderHandle,
233
- bio: "",
234
- followersCount: 0,
235
- avatarHue: 18,
165
+ }: {
166
+ conversationId: string;
167
+ text: string;
168
+ }) => postAction({ kind: "replyDm", conversationId, text }),
169
+ onMutate: async ({ conversationId, text }) => {
170
+ await queryClient.cancelQueries({ queryKey: dmsQueryKey });
171
+ const previous = queryClient.getQueryData<QueryResponse>(dmsQueryKey);
172
+ if (!previous || !selectedConversation) return { previous };
173
+ const now = new Date().toISOString();
174
+ const accountRecord = meta?.accounts.find(
175
+ (account) => account.id === selectedConversation.accountId,
176
+ );
177
+ const senderHandle = (
178
+ accountRecord?.handle ?? selectedConversation.accountHandle
179
+ ).replace(/^@/, "");
180
+ const optimisticMessage: DmMessageItem = {
181
+ id: `optimistic-${now}`,
182
+ conversationId,
183
+ text,
236
184
  createdAt: now,
237
- },
238
- };
239
- const previousMessages = messages;
240
- const previousItems = items;
241
-
242
- setReplyError(null);
243
- setReplyDraft("");
244
- setMessages((current) => [...current, optimisticMessage]);
245
- setItems((current) =>
246
- current.map((item) =>
247
- item.id === conversationId
185
+ direction: "outbound",
186
+ isReplied: true,
187
+ mediaCount: 0,
188
+ sender: {
189
+ id: `local-${selectedConversation.accountId}`,
190
+ handle: senderHandle,
191
+ displayName: accountRecord?.name ?? senderHandle,
192
+ bio: "",
193
+ followersCount: 0,
194
+ avatarHue: 18,
195
+ createdAt: now,
196
+ },
197
+ };
198
+ const previousItems = previous.items as DmConversationItem[];
199
+ queryClient.setQueryData<QueryResponse>(dmsQueryKey, {
200
+ ...previous,
201
+ items: previousItems.map((item) =>
202
+ item.id === conversationId
203
+ ? {
204
+ ...item,
205
+ lastMessageAt: now,
206
+ lastMessagePreview: text,
207
+ needsReply: false,
208
+ unreadCount: 0,
209
+ }
210
+ : item,
211
+ ),
212
+ selectedConversation: previous.selectedConversation
248
213
  ? {
249
- ...item,
250
- lastMessageAt: now,
251
- lastMessagePreview: text,
252
- needsReply: false,
253
- unreadCount: 0,
214
+ ...previous.selectedConversation,
215
+ messages: [
216
+ ...previous.selectedConversation.messages,
217
+ optimisticMessage,
218
+ ],
254
219
  }
255
- : item,
256
- ),
257
- );
258
-
259
- try {
260
- await postAction({
261
- kind: "replyDm",
262
- conversationId,
263
- text,
220
+ : previous.selectedConversation,
264
221
  });
222
+ return { previous };
223
+ },
224
+ onError: (_error, _variables, context) => {
225
+ if (context?.previous) {
226
+ queryClient.setQueryData(dmsQueryKey, context.previous);
227
+ }
228
+ },
229
+ onSettled: () =>
230
+ Promise.all([
231
+ queryClient.invalidateQueries({ queryKey: queryKeys.dms }),
232
+ queryClient.invalidateQueries({ queryKey: queryKeys.status }),
233
+ ]),
234
+ });
265
235
 
266
- invalidateCachedQueryResponses();
236
+ async function replyToConversation(conversationId: string) {
237
+ const text = replyDraft.trim();
238
+ if (!text || !selectedConversation) return;
239
+ setReplyDraft("");
240
+ try {
241
+ await replyMutation.mutateAsync({ conversationId, text });
267
242
  setSelectedConversationId(conversationId);
268
- setRefreshTick((value) => value + 1);
269
- } catch (error) {
243
+ } catch {
270
244
  setReplyDraft(text);
271
- setMessages(previousMessages);
272
- setItems(previousItems);
273
- setReplyError(error instanceof Error ? error.message : "Reply failed");
274
245
  }
275
246
  }
276
247
 
277
248
  function refreshLocalView() {
278
- invalidateCachedQueryResponses();
279
- setRefreshTick((value) => value + 1);
280
- void loadStatus(true);
249
+ void Promise.all([
250
+ queryClient.invalidateQueries({ queryKey: queryKeys.dms }),
251
+ queryClient.invalidateQueries({ queryKey: queryKeys.status }),
252
+ ]);
281
253
  }
254
+ const loading = dmsQuery.isPending;
255
+ const error = dmsQuery.error
256
+ ? dmsQuery.error instanceof Error
257
+ ? dmsQuery.error.message
258
+ : "Messages unavailable"
259
+ : null;
260
+ const replyError = replyMutation.error
261
+ ? replyMutation.error instanceof Error
262
+ ? replyMutation.error.message
263
+ : "Reply failed"
264
+ : null;
282
265
 
283
266
  return (
284
267
  <>
@@ -392,7 +375,7 @@ function DmsRoute() {
392
375
  </p>
393
376
  ) : null}
394
377
 
395
- {loading && (items.length === 0 || switchingConversation) ? (
378
+ {(loading && items.length === 0) || switchingConversation ? (
396
379
  <FeedLoading
397
380
  detail="Reading local conversations and reply state"
398
381
  label="Loading messages"
@@ -402,7 +385,7 @@ function DmsRoute() {
402
385
  action={
403
386
  <button
404
387
  className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
405
- onClick={() => setRefreshTick((value) => value + 1)}
388
+ onClick={() => void dmsQuery.refetch()}
406
389
  type="button"
407
390
  >
408
391
  Retry
@@ -1,15 +1,17 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
+ import {
3
+ keepPreviousData,
4
+ useMutation,
5
+ useQuery,
6
+ useQueryClient,
7
+ } from "@tanstack/react-query";
2
8
  import { Sparkles } from "lucide-react";
3
- import { useEffect, useMemo, useState } from "react";
9
+ import { useMemo, useState } from "react";
4
10
  import { useSelectedAccountId } from "#/components/account-selection";
5
11
  import { InboxCard } from "#/components/InboxCard";
6
- import { postAction } from "#/lib/api-client";
7
- import type {
8
- InboxItem,
9
- InboxKind,
10
- InboxResponse,
11
- QueryEnvelope,
12
- } from "#/lib/types";
12
+ import { fetchQueryEnvelope, postAction } from "#/lib/api-client";
13
+ import { queryKeys } from "#/lib/query-client";
14
+ import type { InboxItem, InboxKind, InboxResponse } from "#/lib/types";
13
15
  import {
14
16
  cx,
15
17
  emptyStateClass,
@@ -40,45 +42,64 @@ const TABS: Array<{ value: InboxKind; label: string }> = [
40
42
  ];
41
43
 
42
44
  function InboxRoute() {
43
- const [meta, setMeta] = useState<QueryEnvelope | null>(null);
44
- const [items, setItems] = useState<InboxItem[]>([]);
45
+ const queryClient = useQueryClient();
45
46
  const [kind, setKind] = useState<InboxKind>("mixed");
46
47
  const [minScore, setMinScore] = useState("40");
47
48
  const [hideLowSignal, setHideLowSignal] = useState(true);
48
- const [refreshTick, setRefreshTick] = useState(0);
49
- const [isScoring, setIsScoring] = useState(false);
50
49
  const [activeReplyId, setActiveReplyId] = useState<string | null>(null);
51
50
  const [replyDraft, setReplyDraft] = useState("");
52
51
  const [isSendingReply, setIsSendingReply] = useState(false);
53
52
  const [replyError, setReplyError] = useState<string | null>(null);
54
- const [stats, setStats] = useState<InboxResponse["stats"] | null>(null);
53
+ const statusQuery = useQuery({
54
+ queryKey: queryKeys.status,
55
+ queryFn: ({ signal }) => fetchQueryEnvelope({ signal }),
56
+ });
57
+ const meta = statusQuery.data ?? null;
55
58
  const selectedAccountId = useSelectedAccountId(meta?.accounts);
59
+ const inboxQueryKey = [
60
+ ...queryKeys.inbox,
61
+ {
62
+ hideLowSignal,
63
+ kind,
64
+ minScore,
65
+ selectedAccountId: selectedAccountId ?? null,
66
+ },
67
+ ] as const;
68
+ const inboxQuery = useQuery({
69
+ queryKey: inboxQueryKey,
70
+ queryFn: async ({ signal }) => {
71
+ const url = new URL("/api/inbox", window.location.origin);
72
+ url.searchParams.set("kind", kind);
73
+ url.searchParams.set("minScore", minScore);
74
+ if (selectedAccountId) {
75
+ url.searchParams.set("account", selectedAccountId);
76
+ }
77
+ if (hideLowSignal) {
78
+ url.searchParams.set("hideLowSignal", "1");
79
+ }
56
80
 
57
- useEffect(() => {
58
- fetch("/api/status")
59
- .then((response) => response.json())
60
- .then((data: QueryEnvelope) => setMeta(data));
61
- }, []);
62
-
63
- useEffect(() => {
64
- const url = new URL("/api/inbox", window.location.origin);
65
- url.searchParams.set("kind", kind);
66
- url.searchParams.set("minScore", minScore);
67
- url.searchParams.set("refresh", String(refreshTick));
68
- if (selectedAccountId) {
69
- url.searchParams.set("account", selectedAccountId);
70
- }
71
- if (hideLowSignal) {
72
- url.searchParams.set("hideLowSignal", "1");
73
- }
74
-
75
- fetch(url)
76
- .then((response) => response.json())
77
- .then((data: InboxResponse) => {
78
- setItems(data.items);
79
- setStats(data.stats);
80
- });
81
- }, [hideLowSignal, kind, minScore, refreshTick, selectedAccountId]);
81
+ const response = await fetch(url, { signal });
82
+ if (!response.ok) {
83
+ throw new Error(`Inbox request failed (${String(response.status)})`);
84
+ }
85
+ return (await response.json()) as InboxResponse;
86
+ },
87
+ placeholderData: keepPreviousData,
88
+ staleTime: 5 * 60_000,
89
+ });
90
+ const items = inboxQuery.data?.items ?? [];
91
+ const stats = inboxQuery.data?.stats ?? null;
92
+ const scoreMutation = useMutation({
93
+ mutationFn: () =>
94
+ postAction({
95
+ kind: "scoreInbox",
96
+ scoreKind: kind,
97
+ account: selectedAccountId,
98
+ limit: 8,
99
+ }),
100
+ onSuccess: () =>
101
+ queryClient.invalidateQueries({ queryKey: queryKeys.inbox }),
102
+ });
82
103
 
83
104
  const subtitle = useMemo(() => {
84
105
  if (!meta || !stats) return "Ranking unreplied mentions and DMs...";
@@ -86,22 +107,7 @@ function InboxRoute() {
86
107
  }, [meta, stats]);
87
108
 
88
109
  async function scoreNow() {
89
- setIsScoring(true);
90
- try {
91
- await fetch("/api/action", {
92
- method: "POST",
93
- headers: { "content-type": "application/json" },
94
- body: JSON.stringify({
95
- kind: "scoreInbox",
96
- scoreKind: kind,
97
- account: selectedAccountId,
98
- limit: 8,
99
- }),
100
- });
101
- setRefreshTick((value) => value + 1);
102
- } finally {
103
- setIsScoring(false);
104
- }
110
+ await scoreMutation.mutateAsync();
105
111
  }
106
112
 
107
113
  async function sendReply(item: InboxItem) {
@@ -125,7 +131,7 @@ function InboxRoute() {
125
131
  );
126
132
  setReplyDraft("");
127
133
  setActiveReplyId(null);
128
- setRefreshTick((value) => value + 1);
134
+ await queryClient.invalidateQueries({ queryKey: queryKeys.inbox });
129
135
  } catch (error) {
130
136
  setReplyError(error instanceof Error ? error.message : "Reply failed");
131
137
  } finally {
@@ -143,12 +149,12 @@ function InboxRoute() {
143
149
  </div>
144
150
  <button
145
151
  className={primaryButtonClass}
146
- disabled={isScoring}
152
+ disabled={scoreMutation.isPending}
147
153
  onClick={() => void scoreNow()}
148
154
  type="button"
149
155
  >
150
156
  <Sparkles className="size-4" strokeWidth={2.2} />
151
- {isScoring ? "Scoring..." : "Score with OpenAI"}
157
+ {scoreMutation.isPending ? "Scoring..." : "Score with OpenAI"}
152
158
  </button>
153
159
  </div>
154
160
  <div className="flex flex-wrap items-center gap-2 px-4 pb-3">