birdclaw 0.5.0 → 0.5.1
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.
- package/CHANGELOG.md +20 -0
- package/README.md +5 -0
- package/bin/birdclaw.mjs +50 -11
- package/package.json +7 -6
- package/public/birdclaw-mark.png +0 -0
- package/scripts/browser-perf.mjs +399 -0
- package/scripts/build-docs-site.mjs +940 -0
- package/scripts/docs-site-assets.mjs +311 -0
- package/scripts/run-vitest.mjs +21 -0
- package/scripts/sanitize-node-options.mjs +23 -0
- package/scripts/start-test-server.mjs +29 -0
- package/src/cli.ts +46 -1
- package/src/components/AppNav.tsx +3 -3
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +11 -10
- package/src/components/DmWorkspace.tsx +24 -9
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +14 -2
- package/src/components/SavedTimelineView.tsx +64 -56
- package/src/components/SyncNowButton.tsx +105 -0
- package/src/components/ThemeSlider.tsx +10 -59
- package/src/components/TimelineCard.tsx +157 -61
- package/src/components/TimelineRouteFrame.tsx +156 -0
- package/src/components/TweetMediaGrid.tsx +120 -23
- package/src/components/TweetRichText.tsx +13 -2
- package/src/components/useTimelineRouteData.ts +137 -0
- package/src/lib/api-client.ts +229 -0
- package/src/lib/archive-finder.ts +24 -20
- package/src/lib/archive-import.ts +18 -3
- package/src/lib/conversation-surface.ts +174 -0
- package/src/lib/db.ts +2 -0
- package/src/lib/queries.ts +93 -28
- package/src/lib/ui.ts +11 -3
- package/src/lib/web-sync.ts +443 -0
- package/src/routeTree.gen.ts +21 -0
- package/src/routes/api/sync.tsx +59 -0
- package/src/routes/dms.tsx +100 -27
- package/src/routes/index.tsx +21 -127
- package/src/routes/links.tsx +50 -5
- package/src/routes/mentions.tsx +21 -127
- package/src/styles.css +74 -11
- package/vite.config.ts +8 -0
package/src/routeTree.gen.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { Route as DmsRouteImport } from './routes/dms'
|
|
|
17
17
|
import { Route as BookmarksRouteImport } from './routes/bookmarks'
|
|
18
18
|
import { Route as BlocksRouteImport } from './routes/blocks'
|
|
19
19
|
import { Route as IndexRouteImport } from './routes/index'
|
|
20
|
+
import { Route as ApiSyncRouteImport } from './routes/api/sync'
|
|
20
21
|
import { Route as ApiStatusRouteImport } from './routes/api/status'
|
|
21
22
|
import { Route as ApiQueryRouteImport } from './routes/api/query'
|
|
22
23
|
import { Route as ApiProfileHydrateRouteImport } from './routes/api/profile-hydrate'
|
|
@@ -68,6 +69,11 @@ const IndexRoute = IndexRouteImport.update({
|
|
|
68
69
|
path: '/',
|
|
69
70
|
getParentRoute: () => rootRouteImport,
|
|
70
71
|
} as any)
|
|
72
|
+
const ApiSyncRoute = ApiSyncRouteImport.update({
|
|
73
|
+
id: '/api/sync',
|
|
74
|
+
path: '/api/sync',
|
|
75
|
+
getParentRoute: () => rootRouteImport,
|
|
76
|
+
} as any)
|
|
71
77
|
const ApiStatusRoute = ApiStatusRouteImport.update({
|
|
72
78
|
id: '/api/status',
|
|
73
79
|
path: '/api/status',
|
|
@@ -138,6 +144,7 @@ export interface FileRoutesByFullPath {
|
|
|
138
144
|
'/api/profile-hydrate': typeof ApiProfileHydrateRoute
|
|
139
145
|
'/api/query': typeof ApiQueryRoute
|
|
140
146
|
'/api/status': typeof ApiStatusRoute
|
|
147
|
+
'/api/sync': typeof ApiSyncRoute
|
|
141
148
|
}
|
|
142
149
|
export interface FileRoutesByTo {
|
|
143
150
|
'/': typeof IndexRoute
|
|
@@ -158,6 +165,7 @@ export interface FileRoutesByTo {
|
|
|
158
165
|
'/api/profile-hydrate': typeof ApiProfileHydrateRoute
|
|
159
166
|
'/api/query': typeof ApiQueryRoute
|
|
160
167
|
'/api/status': typeof ApiStatusRoute
|
|
168
|
+
'/api/sync': typeof ApiSyncRoute
|
|
161
169
|
}
|
|
162
170
|
export interface FileRoutesById {
|
|
163
171
|
__root__: typeof rootRouteImport
|
|
@@ -179,6 +187,7 @@ export interface FileRoutesById {
|
|
|
179
187
|
'/api/profile-hydrate': typeof ApiProfileHydrateRoute
|
|
180
188
|
'/api/query': typeof ApiQueryRoute
|
|
181
189
|
'/api/status': typeof ApiStatusRoute
|
|
190
|
+
'/api/sync': typeof ApiSyncRoute
|
|
182
191
|
}
|
|
183
192
|
export interface FileRouteTypes {
|
|
184
193
|
fileRoutesByFullPath: FileRoutesByFullPath
|
|
@@ -201,6 +210,7 @@ export interface FileRouteTypes {
|
|
|
201
210
|
| '/api/profile-hydrate'
|
|
202
211
|
| '/api/query'
|
|
203
212
|
| '/api/status'
|
|
213
|
+
| '/api/sync'
|
|
204
214
|
fileRoutesByTo: FileRoutesByTo
|
|
205
215
|
to:
|
|
206
216
|
| '/'
|
|
@@ -221,6 +231,7 @@ export interface FileRouteTypes {
|
|
|
221
231
|
| '/api/profile-hydrate'
|
|
222
232
|
| '/api/query'
|
|
223
233
|
| '/api/status'
|
|
234
|
+
| '/api/sync'
|
|
224
235
|
id:
|
|
225
236
|
| '__root__'
|
|
226
237
|
| '/'
|
|
@@ -241,6 +252,7 @@ export interface FileRouteTypes {
|
|
|
241
252
|
| '/api/profile-hydrate'
|
|
242
253
|
| '/api/query'
|
|
243
254
|
| '/api/status'
|
|
255
|
+
| '/api/sync'
|
|
244
256
|
fileRoutesById: FileRoutesById
|
|
245
257
|
}
|
|
246
258
|
export interface RootRouteChildren {
|
|
@@ -262,6 +274,7 @@ export interface RootRouteChildren {
|
|
|
262
274
|
ApiProfileHydrateRoute: typeof ApiProfileHydrateRoute
|
|
263
275
|
ApiQueryRoute: typeof ApiQueryRoute
|
|
264
276
|
ApiStatusRoute: typeof ApiStatusRoute
|
|
277
|
+
ApiSyncRoute: typeof ApiSyncRoute
|
|
265
278
|
}
|
|
266
279
|
|
|
267
280
|
declare module '@tanstack/react-router' {
|
|
@@ -322,6 +335,13 @@ declare module '@tanstack/react-router' {
|
|
|
322
335
|
preLoaderRoute: typeof IndexRouteImport
|
|
323
336
|
parentRoute: typeof rootRouteImport
|
|
324
337
|
}
|
|
338
|
+
'/api/sync': {
|
|
339
|
+
id: '/api/sync'
|
|
340
|
+
path: '/api/sync'
|
|
341
|
+
fullPath: '/api/sync'
|
|
342
|
+
preLoaderRoute: typeof ApiSyncRouteImport
|
|
343
|
+
parentRoute: typeof rootRouteImport
|
|
344
|
+
}
|
|
325
345
|
'/api/status': {
|
|
326
346
|
id: '/api/status'
|
|
327
347
|
path: '/api/status'
|
|
@@ -414,6 +434,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|
|
414
434
|
ApiProfileHydrateRoute: ApiProfileHydrateRoute,
|
|
415
435
|
ApiQueryRoute: ApiQueryRoute,
|
|
416
436
|
ApiStatusRoute: ApiStatusRoute,
|
|
437
|
+
ApiSyncRoute: ApiSyncRoute,
|
|
417
438
|
}
|
|
418
439
|
export const routeTree = rootRouteImport
|
|
419
440
|
._addFileChildren(rootRouteChildren)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { getWebSyncJob, parseWebSyncKind, startWebSync } from "#/lib/web-sync";
|
|
3
|
+
|
|
4
|
+
function json(data: unknown, init?: ResponseInit) {
|
|
5
|
+
return new Response(JSON.stringify(data), {
|
|
6
|
+
...init,
|
|
7
|
+
headers: {
|
|
8
|
+
"content-type": "application/json",
|
|
9
|
+
...init?.headers,
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseAccountId(value: unknown) {
|
|
15
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const Route = createFileRoute("/api/sync")({
|
|
19
|
+
server: {
|
|
20
|
+
handlers: {
|
|
21
|
+
GET: ({ request }) => {
|
|
22
|
+
const url = new URL(request.url);
|
|
23
|
+
const id = url.searchParams.get("id");
|
|
24
|
+
if (!id) {
|
|
25
|
+
return json(
|
|
26
|
+
{ ok: false, message: "Missing sync job id" },
|
|
27
|
+
{ status: 400 },
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const job = getWebSyncJob(id);
|
|
32
|
+
if (!job) {
|
|
33
|
+
return json(
|
|
34
|
+
{ ok: false, message: "Sync job not found" },
|
|
35
|
+
{ status: 404 },
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return json(job);
|
|
40
|
+
},
|
|
41
|
+
POST: async ({ request }) => {
|
|
42
|
+
const body = (await request.json().catch(() => ({}))) as Record<
|
|
43
|
+
string,
|
|
44
|
+
unknown
|
|
45
|
+
>;
|
|
46
|
+
const kind = parseWebSyncKind(body.kind);
|
|
47
|
+
if (!kind) {
|
|
48
|
+
return json(
|
|
49
|
+
{ ok: false, message: "Unknown sync kind" },
|
|
50
|
+
{ status: 400 },
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const job = startWebSync(kind, parseAccountId(body.accountId));
|
|
55
|
+
return json(job, { status: job.inProgress ? 202 : 200 });
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
package/src/routes/dms.tsx
CHANGED
|
@@ -2,11 +2,17 @@ import { createFileRoute } from "@tanstack/react-router";
|
|
|
2
2
|
import { Search } from "lucide-react";
|
|
3
3
|
import { useEffect, useMemo, useState } from "react";
|
|
4
4
|
import { DmWorkspace } from "#/components/DmWorkspace";
|
|
5
|
+
import { FeedEmpty, FeedError, FeedLoading } from "#/components/FeedState";
|
|
6
|
+
import { SyncNowButton } from "#/components/SyncNowButton";
|
|
7
|
+
import {
|
|
8
|
+
fetchQueryEnvelope,
|
|
9
|
+
fetchQueryResponse,
|
|
10
|
+
postAction,
|
|
11
|
+
} from "#/lib/api-client";
|
|
5
12
|
import type {
|
|
6
13
|
DmConversationItem,
|
|
7
14
|
DmMessageItem,
|
|
8
15
|
QueryEnvelope,
|
|
9
|
-
QueryResponse,
|
|
10
16
|
ReplyFilter,
|
|
11
17
|
} from "#/lib/types";
|
|
12
18
|
import {
|
|
@@ -43,6 +49,9 @@ function DmsRoute() {
|
|
|
43
49
|
const [meta, setMeta] = useState<QueryEnvelope | null>(null);
|
|
44
50
|
const [items, setItems] = useState<DmConversationItem[]>([]);
|
|
45
51
|
const [messages, setMessages] = useState<DmMessageItem[]>([]);
|
|
52
|
+
const [loadedConversationId, setLoadedConversationId] = useState<
|
|
53
|
+
string | undefined
|
|
54
|
+
>();
|
|
46
55
|
const [selectedConversationId, setSelectedConversationId] = useState<
|
|
47
56
|
string | undefined
|
|
48
57
|
>();
|
|
@@ -53,15 +62,20 @@ function DmsRoute() {
|
|
|
53
62
|
const [search, setSearch] = useState("");
|
|
54
63
|
const [replyDraft, setReplyDraft] = useState("");
|
|
55
64
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
65
|
+
const [loading, setLoading] = useState(true);
|
|
66
|
+
const [error, setError] = useState<string | null>(null);
|
|
67
|
+
|
|
68
|
+
async function loadStatus() {
|
|
69
|
+
setMeta(await fetchQueryEnvelope());
|
|
70
|
+
}
|
|
56
71
|
|
|
57
72
|
useEffect(() => {
|
|
58
|
-
|
|
59
|
-
.then((response) => response.json())
|
|
60
|
-
.then((data: QueryEnvelope) => setMeta(data));
|
|
73
|
+
void loadStatus();
|
|
61
74
|
}, []);
|
|
62
75
|
|
|
63
76
|
useEffect(() => {
|
|
64
77
|
const controller = new AbortController();
|
|
78
|
+
let active = true;
|
|
65
79
|
const url = new URL("/api/query", window.location.origin);
|
|
66
80
|
url.searchParams.set("resource", "dms");
|
|
67
81
|
url.searchParams.set("replyFilter", replyFilter);
|
|
@@ -76,12 +90,15 @@ function DmsRoute() {
|
|
|
76
90
|
url.searchParams.set("search", search.trim());
|
|
77
91
|
}
|
|
78
92
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
93
|
+
setError(null);
|
|
94
|
+
setLoading(true);
|
|
95
|
+
fetchQueryResponse(url, { signal: controller.signal })
|
|
96
|
+
.then((data) => {
|
|
97
|
+
if (!active) return;
|
|
82
98
|
const conversations = data.items as DmConversationItem[];
|
|
83
99
|
const nextSelected =
|
|
84
100
|
data.selectedConversation?.conversation.id ?? conversations[0]?.id;
|
|
101
|
+
setLoadedConversationId(data.selectedConversation?.conversation.id);
|
|
85
102
|
setItems(conversations);
|
|
86
103
|
setSelectedConversationId((current) => {
|
|
87
104
|
if (!current) return nextSelected;
|
|
@@ -93,14 +110,31 @@ function DmsRoute() {
|
|
|
93
110
|
});
|
|
94
111
|
setMessages(data.selectedConversation?.messages ?? []);
|
|
95
112
|
})
|
|
96
|
-
.catch((
|
|
97
|
-
if (
|
|
113
|
+
.catch((fetchError: unknown) => {
|
|
114
|
+
if (
|
|
115
|
+
fetchError instanceof DOMException &&
|
|
116
|
+
fetchError.name === "AbortError"
|
|
117
|
+
) {
|
|
98
118
|
return;
|
|
99
119
|
}
|
|
100
|
-
|
|
120
|
+
if (!active) return;
|
|
121
|
+
setError(
|
|
122
|
+
fetchError instanceof Error
|
|
123
|
+
? fetchError.message
|
|
124
|
+
: "Messages unavailable",
|
|
125
|
+
);
|
|
126
|
+
setItems([]);
|
|
127
|
+
setMessages([]);
|
|
128
|
+
setLoadedConversationId(undefined);
|
|
129
|
+
})
|
|
130
|
+
.finally(() => {
|
|
131
|
+
if (active) {
|
|
132
|
+
setLoading(false);
|
|
133
|
+
}
|
|
101
134
|
});
|
|
102
135
|
|
|
103
136
|
return () => {
|
|
137
|
+
active = false;
|
|
104
138
|
controller.abort();
|
|
105
139
|
};
|
|
106
140
|
}, [
|
|
@@ -115,6 +149,13 @@ function DmsRoute() {
|
|
|
115
149
|
|
|
116
150
|
const selectedConversation =
|
|
117
151
|
items.find((item) => item.id === selectedConversationId) ?? null;
|
|
152
|
+
const switchingConversation =
|
|
153
|
+
loading &&
|
|
154
|
+
Boolean(
|
|
155
|
+
selectedConversationId &&
|
|
156
|
+
loadedConversationId &&
|
|
157
|
+
selectedConversationId !== loadedConversationId,
|
|
158
|
+
);
|
|
118
159
|
|
|
119
160
|
const subtitle = useMemo(() => {
|
|
120
161
|
if (!meta) return "Loading direct messages...";
|
|
@@ -170,14 +211,10 @@ function DmsRoute() {
|
|
|
170
211
|
);
|
|
171
212
|
|
|
172
213
|
try {
|
|
173
|
-
await
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
kind: "replyDm",
|
|
178
|
-
conversationId,
|
|
179
|
-
text,
|
|
180
|
-
}),
|
|
214
|
+
await postAction({
|
|
215
|
+
kind: "replyDm",
|
|
216
|
+
conversationId,
|
|
217
|
+
text,
|
|
181
218
|
});
|
|
182
219
|
|
|
183
220
|
setSelectedConversationId(conversationId);
|
|
@@ -190,6 +227,11 @@ function DmsRoute() {
|
|
|
190
227
|
}
|
|
191
228
|
}
|
|
192
229
|
|
|
230
|
+
function refreshLocalView() {
|
|
231
|
+
setRefreshTick((value) => value + 1);
|
|
232
|
+
void loadStatus();
|
|
233
|
+
}
|
|
234
|
+
|
|
193
235
|
return (
|
|
194
236
|
<>
|
|
195
237
|
<header className={pageHeaderClass}>
|
|
@@ -198,6 +240,11 @@ function DmsRoute() {
|
|
|
198
240
|
<h1 className={pageTitleClass}>Messages</h1>
|
|
199
241
|
<p className={pageSubtitleClass}>{subtitle}</p>
|
|
200
242
|
</div>
|
|
243
|
+
<SyncNowButton
|
|
244
|
+
kind="dms"
|
|
245
|
+
label="Sync DMs"
|
|
246
|
+
onSynced={refreshLocalView}
|
|
247
|
+
/>
|
|
201
248
|
</div>
|
|
202
249
|
<div className="flex flex-wrap items-center gap-2 px-4 pb-3">
|
|
203
250
|
<label className={cx(searchFieldShellClass, "flex-1 min-w-[200px]")}>
|
|
@@ -260,15 +307,41 @@ function DmsRoute() {
|
|
|
260
307
|
</div>
|
|
261
308
|
</header>
|
|
262
309
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
310
|
+
{loading && (items.length === 0 || switchingConversation) ? (
|
|
311
|
+
<FeedLoading
|
|
312
|
+
detail="Reading local conversations and reply state"
|
|
313
|
+
label="Loading messages"
|
|
314
|
+
/>
|
|
315
|
+
) : error ? (
|
|
316
|
+
<FeedError
|
|
317
|
+
action={
|
|
318
|
+
<button
|
|
319
|
+
className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
|
|
320
|
+
onClick={() => setRefreshTick((value) => value + 1)}
|
|
321
|
+
type="button"
|
|
322
|
+
>
|
|
323
|
+
Retry
|
|
324
|
+
</button>
|
|
325
|
+
}
|
|
326
|
+
message={error}
|
|
327
|
+
title="Could not load messages"
|
|
328
|
+
/>
|
|
329
|
+
) : items.length === 0 ? (
|
|
330
|
+
<FeedEmpty
|
|
331
|
+
detail="Sync DMs or broaden the filters to find a conversation."
|
|
332
|
+
label="No conversations in this view"
|
|
333
|
+
/>
|
|
334
|
+
) : (
|
|
335
|
+
<DmWorkspace
|
|
336
|
+
conversations={items}
|
|
337
|
+
onReplyDraftChange={setReplyDraft}
|
|
338
|
+
onReplySend={replyToConversation}
|
|
339
|
+
onSelectConversation={setSelectedConversationId}
|
|
340
|
+
replyDraft={replyDraft}
|
|
341
|
+
selectedConversation={selectedConversation}
|
|
342
|
+
selectedMessages={messages}
|
|
343
|
+
/>
|
|
344
|
+
)}
|
|
272
345
|
</>
|
|
273
346
|
);
|
|
274
347
|
}
|
package/src/routes/index.tsx
CHANGED
|
@@ -1,138 +1,32 @@
|
|
|
1
1
|
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { TimelineCard } from "#/components/TimelineCard";
|
|
5
|
-
import type {
|
|
6
|
-
QueryEnvelope,
|
|
7
|
-
QueryResponse,
|
|
8
|
-
ReplyFilter,
|
|
9
|
-
TimelineItem,
|
|
10
|
-
} from "#/lib/types";
|
|
11
|
-
import {
|
|
12
|
-
cx,
|
|
13
|
-
emptyStateClass,
|
|
14
|
-
feedClass,
|
|
15
|
-
pageHeaderClass,
|
|
16
|
-
pageHeaderRowClass,
|
|
17
|
-
pageSubtitleClass,
|
|
18
|
-
pageTitleClass,
|
|
19
|
-
searchFieldIconClass,
|
|
20
|
-
searchFieldInputClass,
|
|
21
|
-
searchFieldShellClass,
|
|
22
|
-
tabButtonActiveClass,
|
|
23
|
-
tabButtonClass,
|
|
24
|
-
tabButtonIndicatorClass,
|
|
25
|
-
tabStripClass,
|
|
26
|
-
} from "#/lib/ui";
|
|
2
|
+
import { TimelineRouteFrame } from "#/components/TimelineRouteFrame";
|
|
3
|
+
import type { QueryEnvelope } from "#/lib/types";
|
|
27
4
|
|
|
28
5
|
export const Route = createFileRoute("/")({
|
|
29
6
|
component: HomeRoute,
|
|
30
7
|
});
|
|
31
8
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
{
|
|
35
|
-
|
|
36
|
-
];
|
|
9
|
+
function homeSubtitle(meta: QueryEnvelope | null) {
|
|
10
|
+
if (!meta) return "Loading local context...";
|
|
11
|
+
return `${String(meta.stats.home)} items · ${String(meta.stats.needsReply)} waiting · ${meta.transport.statusText}`;
|
|
12
|
+
}
|
|
37
13
|
|
|
38
14
|
function HomeRoute() {
|
|
39
|
-
const [meta, setMeta] = useState<QueryEnvelope | null>(null);
|
|
40
|
-
const [items, setItems] = useState<TimelineItem[]>([]);
|
|
41
|
-
const [replyFilter, setReplyFilter] = useState<ReplyFilter>("all");
|
|
42
|
-
const [search, setSearch] = useState("");
|
|
43
|
-
const [refreshTick, setRefreshTick] = useState(0);
|
|
44
|
-
|
|
45
|
-
useEffect(() => {
|
|
46
|
-
fetch("/api/status")
|
|
47
|
-
.then((response) => response.json())
|
|
48
|
-
.then((data: QueryEnvelope) => setMeta(data));
|
|
49
|
-
}, []);
|
|
50
|
-
|
|
51
|
-
useEffect(() => {
|
|
52
|
-
const url = new URL("/api/query", window.location.origin);
|
|
53
|
-
url.searchParams.set("resource", "home");
|
|
54
|
-
url.searchParams.set("replyFilter", replyFilter);
|
|
55
|
-
url.searchParams.set("refresh", String(refreshTick));
|
|
56
|
-
if (search.trim()) {
|
|
57
|
-
url.searchParams.set("search", search.trim());
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
fetch(url)
|
|
61
|
-
.then((response) => response.json())
|
|
62
|
-
.then((data: QueryResponse) => setItems(data.items as TimelineItem[]));
|
|
63
|
-
}, [refreshTick, replyFilter, search]);
|
|
64
|
-
|
|
65
|
-
const subtitle = useMemo(() => {
|
|
66
|
-
if (!meta) return "Loading local context...";
|
|
67
|
-
return `${String(meta.stats.home)} items · ${String(meta.stats.needsReply)} waiting · ${meta.transport.statusText}`;
|
|
68
|
-
}, [meta]);
|
|
69
|
-
|
|
70
|
-
async function replyToTweet(tweetId: string) {
|
|
71
|
-
const text = window.prompt("Reply text");
|
|
72
|
-
if (!text?.trim()) return;
|
|
73
|
-
|
|
74
|
-
await fetch("/api/action", {
|
|
75
|
-
method: "POST",
|
|
76
|
-
headers: { "content-type": "application/json" },
|
|
77
|
-
body: JSON.stringify({
|
|
78
|
-
kind: "replyTweet",
|
|
79
|
-
accountId: "acct_primary",
|
|
80
|
-
tweetId,
|
|
81
|
-
text,
|
|
82
|
-
}),
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
setRefreshTick((value) => value + 1);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
15
|
return (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
value={search}
|
|
105
|
-
/>
|
|
106
|
-
</label>
|
|
107
|
-
</div>
|
|
108
|
-
<div className={tabStripClass}>
|
|
109
|
-
{TABS.map((tab) => {
|
|
110
|
-
const active = replyFilter === tab.value;
|
|
111
|
-
return (
|
|
112
|
-
<button
|
|
113
|
-
key={tab.value}
|
|
114
|
-
type="button"
|
|
115
|
-
aria-pressed={active}
|
|
116
|
-
className={cx(tabButtonClass, active && tabButtonActiveClass)}
|
|
117
|
-
onClick={() => setReplyFilter(tab.value)}
|
|
118
|
-
>
|
|
119
|
-
<span className="relative inline-flex flex-col items-center justify-center py-1">
|
|
120
|
-
{tab.value}
|
|
121
|
-
{active ? <span className={tabButtonIndicatorClass} /> : null}
|
|
122
|
-
</span>
|
|
123
|
-
</button>
|
|
124
|
-
);
|
|
125
|
-
})}
|
|
126
|
-
</div>
|
|
127
|
-
</header>
|
|
128
|
-
<section className={feedClass}>
|
|
129
|
-
{items.length === 0 ? (
|
|
130
|
-
<div className={emptyStateClass}>No posts to show.</div>
|
|
131
|
-
) : null}
|
|
132
|
-
{items.map((item) => (
|
|
133
|
-
<TimelineCard key={item.id} item={item} onReply={replyToTweet} />
|
|
134
|
-
))}
|
|
135
|
-
</section>
|
|
136
|
-
</>
|
|
16
|
+
<TimelineRouteFrame
|
|
17
|
+
emptyDetail="Try a different filter or sync the timeline again."
|
|
18
|
+
emptyLabel="No posts in this view"
|
|
19
|
+
errorFallback="Timeline unavailable"
|
|
20
|
+
errorTitle="Could not load posts"
|
|
21
|
+
initialReplyFilter="all"
|
|
22
|
+
loadingDetail="Reading the local timeline store"
|
|
23
|
+
loadingLabel="Loading posts"
|
|
24
|
+
resource="home"
|
|
25
|
+
searchPlaceholder="Search local timeline"
|
|
26
|
+
subtitle={homeSubtitle}
|
|
27
|
+
syncKind="timeline"
|
|
28
|
+
syncLabel="Sync timeline"
|
|
29
|
+
title="Home"
|
|
30
|
+
/>
|
|
137
31
|
);
|
|
138
32
|
}
|
package/src/routes/links.tsx
CHANGED
|
@@ -11,6 +11,12 @@ import {
|
|
|
11
11
|
} from "lucide-react";
|
|
12
12
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
13
13
|
import { AvatarChip } from "#/components/AvatarChip";
|
|
14
|
+
import {
|
|
15
|
+
FeedEmpty,
|
|
16
|
+
FeedError,
|
|
17
|
+
FeedLoading,
|
|
18
|
+
LinkSkeletonRows,
|
|
19
|
+
} from "#/components/FeedState";
|
|
14
20
|
import { ProfilePreview } from "#/components/ProfilePreview";
|
|
15
21
|
import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
|
|
16
22
|
import type {
|
|
@@ -26,7 +32,6 @@ import type {
|
|
|
26
32
|
} from "#/lib/types";
|
|
27
33
|
import {
|
|
28
34
|
cx,
|
|
29
|
-
emptyStateClass,
|
|
30
35
|
pageHeaderClass,
|
|
31
36
|
pageHeaderRowClass,
|
|
32
37
|
pageSubtitleClass,
|
|
@@ -649,6 +654,7 @@ function LinksRoute() {
|
|
|
649
654
|
const cacheRef = useRef(new Map<string, LinkInsightResponse>());
|
|
650
655
|
const inFlightRef = useRef(new Set<string>());
|
|
651
656
|
const mountedRef = useRef(true);
|
|
657
|
+
const [errorByKey, setErrorByKey] = useState<Record<string, string>>({});
|
|
652
658
|
const [, bumpCacheVersion] = useState(0);
|
|
653
659
|
const currentCacheKey = insightCacheKey(
|
|
654
660
|
kind,
|
|
@@ -658,6 +664,8 @@ function LinksRoute() {
|
|
|
658
664
|
refreshTick,
|
|
659
665
|
);
|
|
660
666
|
const data = cacheRef.current.get(currentCacheKey) ?? null;
|
|
667
|
+
const error = errorByKey[currentCacheKey] ?? null;
|
|
668
|
+
const loading = !data && !error;
|
|
661
669
|
|
|
662
670
|
useEffect(() => {
|
|
663
671
|
return () => {
|
|
@@ -672,6 +680,11 @@ function LinksRoute() {
|
|
|
672
680
|
return;
|
|
673
681
|
}
|
|
674
682
|
inFlightRef.current.add(key);
|
|
683
|
+
setErrorByKey((current) => {
|
|
684
|
+
const rest = { ...current };
|
|
685
|
+
delete rest[key];
|
|
686
|
+
return rest;
|
|
687
|
+
});
|
|
675
688
|
fetch(linkInsightsUrl(fetchKind, range, sort, source, refreshTick))
|
|
676
689
|
.then((response) => response.json())
|
|
677
690
|
.then((response: LinkInsightResponse) => {
|
|
@@ -684,7 +697,17 @@ function LinksRoute() {
|
|
|
684
697
|
if (error instanceof DOMException && error.name === "AbortError") {
|
|
685
698
|
return;
|
|
686
699
|
}
|
|
700
|
+
if (!mountedRef.current) {
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
687
703
|
console.warn("Link insights failed", error);
|
|
704
|
+
setErrorByKey((current) => ({
|
|
705
|
+
...current,
|
|
706
|
+
[key]:
|
|
707
|
+
error instanceof Error
|
|
708
|
+
? error.message
|
|
709
|
+
: "Link insights unavailable",
|
|
710
|
+
}));
|
|
688
711
|
})
|
|
689
712
|
.finally(() => {
|
|
690
713
|
inFlightRef.current.delete(key);
|
|
@@ -865,10 +888,32 @@ function LinksRoute() {
|
|
|
865
888
|
</header>
|
|
866
889
|
|
|
867
890
|
<section className="flex flex-col">
|
|
868
|
-
{
|
|
869
|
-
<
|
|
870
|
-
|
|
871
|
-
|
|
891
|
+
{loading ? (
|
|
892
|
+
<FeedLoading
|
|
893
|
+
detail="Ranking URLs and collecting local discussion"
|
|
894
|
+
label="Loading links"
|
|
895
|
+
>
|
|
896
|
+
<LinkSkeletonRows />
|
|
897
|
+
</FeedLoading>
|
|
898
|
+
) : error ? (
|
|
899
|
+
<FeedError
|
|
900
|
+
action={
|
|
901
|
+
<button
|
|
902
|
+
className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
|
|
903
|
+
onClick={() => setRefreshTick((value) => value + 1)}
|
|
904
|
+
type="button"
|
|
905
|
+
>
|
|
906
|
+
Retry
|
|
907
|
+
</button>
|
|
908
|
+
}
|
|
909
|
+
message={error}
|
|
910
|
+
title="Could not load links"
|
|
911
|
+
/>
|
|
912
|
+
) : items.length === 0 ? (
|
|
913
|
+
<FeedEmpty
|
|
914
|
+
detail="Try a different range, source, or search term."
|
|
915
|
+
label="No links in this window"
|
|
916
|
+
/>
|
|
872
917
|
) : null}
|
|
873
918
|
{items.map((item, index) => (
|
|
874
919
|
<LinkInsightRow
|