birdclaw 0.4.1 → 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 +52 -0
- package/README.md +113 -7
- package/bin/birdclaw.mjs +50 -11
- package/package.json +30 -28
- package/playwright.config.ts +1 -0
- package/public/birdclaw-mark.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +2 -2
- 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 +496 -19
- package/src/components/AppNav.tsx +66 -29
- package/src/components/AvatarChip.tsx +10 -5
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +126 -0
- package/src/components/DmWorkspace.tsx +118 -105
- package/src/components/EmbeddedTweetCard.tsx +20 -14
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +104 -90
- package/src/components/LinkPreviewCard.tsx +270 -0
- package/src/components/ProfilePreview.tsx +8 -3
- package/src/components/SavedTimelineView.tsx +89 -71
- package/src/components/SyncNowButton.tsx +105 -0
- package/src/components/ThemeSlider.tsx +10 -59
- package/src/components/TimelineCard.tsx +326 -86
- package/src/components/TimelineRouteFrame.tsx +156 -0
- package/src/components/TweetMediaGrid.tsx +120 -23
- package/src/components/TweetRichText.tsx +19 -4
- 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 +1582 -67
- package/src/lib/authored-live.ts +1074 -0
- package/src/lib/backup.ts +316 -14
- package/src/lib/bird-actions.ts +1 -10
- package/src/lib/bird-command.ts +57 -0
- package/src/lib/bird.ts +89 -5
- package/src/lib/config.ts +1 -1
- package/src/lib/conversation-surface.ts +174 -0
- package/src/lib/db.ts +193 -4
- package/src/lib/follow-graph.ts +1053 -0
- package/src/lib/link-index.ts +11 -98
- package/src/lib/link-insights.ts +834 -0
- package/src/lib/link-preview-metadata.ts +334 -0
- package/src/lib/media-fetch.ts +787 -0
- package/src/lib/media-includes.ts +165 -0
- package/src/lib/mention-threads-live.ts +535 -43
- package/src/lib/mentions-live.ts +623 -19
- package/src/lib/moderation-target.ts +6 -0
- package/src/lib/profile-hydration.ts +1 -1
- package/src/lib/profile-resolver.ts +115 -1
- package/src/lib/queries.ts +326 -35
- package/src/lib/seed.ts +127 -8
- package/src/lib/timeline-collections-live.ts +145 -22
- package/src/lib/timeline-live.ts +10 -15
- package/src/lib/tweet-account-edges.ts +9 -2
- package/src/lib/tweet-render.ts +97 -0
- package/src/lib/types.ts +185 -2
- package/src/lib/ui.ts +383 -147
- package/src/lib/url-expansion-store.ts +110 -0
- package/src/lib/url-expansion.ts +20 -3
- package/src/lib/web-sync.ts +443 -0
- package/src/lib/x-profile.ts +7 -2
- package/src/lib/xurl.ts +296 -12
- package/src/routeTree.gen.ts +126 -0
- package/src/routes/__root.tsx +7 -3
- package/src/routes/api/conversation.tsx +34 -0
- package/src/routes/api/link-insights.tsx +79 -0
- package/src/routes/api/link-preview.tsx +43 -0
- package/src/routes/api/profile-hydrate.tsx +51 -0
- package/src/routes/api/sync.tsx +59 -0
- package/src/routes/blocks.tsx +111 -86
- package/src/routes/dms.tsx +172 -87
- package/src/routes/inbox.tsx +98 -86
- package/src/routes/index.tsx +22 -115
- package/src/routes/links.tsx +928 -0
- package/src/routes/mentions.tsx +22 -115
- package/src/styles.css +169 -43
- package/vite.config.ts +8 -0
package/src/routes/blocks.tsx
CHANGED
|
@@ -9,22 +9,21 @@ import type {
|
|
|
9
9
|
QueryEnvelope,
|
|
10
10
|
} from "#/lib/types";
|
|
11
11
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
contentCardClass,
|
|
12
|
+
blockRowBodyClass,
|
|
13
|
+
blockRowClass,
|
|
15
14
|
cx,
|
|
15
|
+
dangerButtonClass,
|
|
16
|
+
emptyStateClass,
|
|
16
17
|
errorCopyClass,
|
|
17
|
-
eyebrowClass,
|
|
18
|
-
heroControlsBlocksClass,
|
|
19
|
-
heroControlsClass,
|
|
20
|
-
heroCopyClass,
|
|
21
|
-
heroShellClass,
|
|
22
|
-
heroTitleClass,
|
|
23
|
-
identityBlockClass,
|
|
24
|
-
metaRowClass,
|
|
25
18
|
mutedDotClass,
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
pageHeaderClass,
|
|
20
|
+
pageHeaderRowClass,
|
|
21
|
+
pageSubtitleClass,
|
|
22
|
+
pageTitleClass,
|
|
23
|
+
primaryButtonClass,
|
|
24
|
+
secondaryButtonClass,
|
|
25
|
+
selectFieldClass,
|
|
26
|
+
statusCopyClass,
|
|
28
27
|
textFieldClass,
|
|
29
28
|
textFieldShortClass,
|
|
30
29
|
textFieldWideClass,
|
|
@@ -142,7 +141,7 @@ function BlocksRoute() {
|
|
|
142
141
|
}
|
|
143
142
|
setMessage(
|
|
144
143
|
data.transport?.output ??
|
|
145
|
-
`Synced ${data.syncedCount ?? 0} remote blocks`,
|
|
144
|
+
`Synced ${String(data.syncedCount ?? 0)} remote blocks`,
|
|
146
145
|
);
|
|
147
146
|
},
|
|
148
147
|
)
|
|
@@ -162,12 +161,12 @@ function BlocksRoute() {
|
|
|
162
161
|
const subtitle = useMemo(() => {
|
|
163
162
|
if (!meta) {
|
|
164
163
|
return items.length > 0
|
|
165
|
-
? `${items.length} blocked profiles
|
|
164
|
+
? `${String(items.length)} blocked profiles · loading transport...`
|
|
166
165
|
: "Loading local blocklist...";
|
|
167
166
|
}
|
|
168
167
|
if (isSyncing)
|
|
169
168
|
return `Syncing remote blocklist · ${meta.transport.statusText}`;
|
|
170
|
-
return `${items.length} blocked profiles
|
|
169
|
+
return `${String(items.length)} blocked profiles · ${meta.transport.statusText}`;
|
|
171
170
|
}, [isSyncing, items.length, meta]);
|
|
172
171
|
|
|
173
172
|
async function submit(
|
|
@@ -192,9 +191,14 @@ function BlocksRoute() {
|
|
|
192
191
|
}),
|
|
193
192
|
});
|
|
194
193
|
const data = (await response.json()) as {
|
|
194
|
+
ok?: boolean;
|
|
195
195
|
profile?: { handle?: string };
|
|
196
|
-
transport?: { output?: string };
|
|
196
|
+
transport?: { ok?: boolean; output?: string };
|
|
197
197
|
};
|
|
198
|
+
if (data.ok === false || data.transport?.ok === false) {
|
|
199
|
+
setError(data.transport?.output ?? "Blocklist action failed");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
198
202
|
|
|
199
203
|
setMessage(
|
|
200
204
|
`${kind === "blockProfile" ? "Blocked" : "Unblocked"} @${
|
|
@@ -214,18 +218,20 @@ function BlocksRoute() {
|
|
|
214
218
|
}
|
|
215
219
|
|
|
216
220
|
return (
|
|
217
|
-
|
|
218
|
-
<
|
|
219
|
-
<div>
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
221
|
+
<>
|
|
222
|
+
<header className={pageHeaderClass}>
|
|
223
|
+
<div className={pageHeaderRowClass}>
|
|
224
|
+
<div className="flex min-w-0 flex-col">
|
|
225
|
+
<h1 className={pageTitleClass}>Blocks</h1>
|
|
226
|
+
<h2 className={cx(pageSubtitleClass, "text-[14px]")}>
|
|
227
|
+
Maintain a clean blocklist locally.
|
|
228
|
+
</h2>
|
|
229
|
+
<p className={pageSubtitleClass}>{subtitle}</p>
|
|
230
|
+
</div>
|
|
225
231
|
</div>
|
|
226
|
-
<div className=
|
|
232
|
+
<div className="flex flex-wrap items-center gap-2 px-4 pb-3">
|
|
227
233
|
<select
|
|
228
|
-
className={cx(
|
|
234
|
+
className={cx(selectFieldClass, textFieldShortClass)}
|
|
229
235
|
disabled={!isReady}
|
|
230
236
|
onChange={(event) => setAccountId(event.target.value)}
|
|
231
237
|
value={accountId}
|
|
@@ -237,14 +243,18 @@ function BlocksRoute() {
|
|
|
237
243
|
))}
|
|
238
244
|
</select>
|
|
239
245
|
<input
|
|
240
|
-
className={cx(
|
|
246
|
+
className={cx(
|
|
247
|
+
textFieldClass,
|
|
248
|
+
textFieldWideClass,
|
|
249
|
+
"flex-1 min-w-[200px]",
|
|
250
|
+
)}
|
|
241
251
|
disabled={!hasAccountId}
|
|
242
252
|
onChange={(event) => setSearch(event.target.value)}
|
|
243
253
|
placeholder="Handle, name, bio, or Twitter URL"
|
|
244
254
|
value={search}
|
|
245
255
|
/>
|
|
246
256
|
<button
|
|
247
|
-
className={
|
|
257
|
+
className={primaryButtonClass}
|
|
248
258
|
disabled={!hasAccountId || isSubmitting || !search.trim()}
|
|
249
259
|
onClick={() => void submit("blockProfile", search)}
|
|
250
260
|
type="button"
|
|
@@ -252,29 +262,31 @@ function BlocksRoute() {
|
|
|
252
262
|
{isSubmitting ? "Working..." : "Block"}
|
|
253
263
|
</button>
|
|
254
264
|
</div>
|
|
255
|
-
</
|
|
265
|
+
</header>
|
|
256
266
|
|
|
257
|
-
{message ? <p className={
|
|
267
|
+
{message ? <p className={statusCopyClass}>{message}</p> : null}
|
|
258
268
|
{error ? <p className={errorCopyClass}>{error}</p> : null}
|
|
259
269
|
|
|
260
270
|
{matches.length > 0 ? (
|
|
261
|
-
<section className=
|
|
271
|
+
<section className="flex flex-col">
|
|
272
|
+
<h2 className="px-4 pt-3 pb-1 text-[13px] font-semibold uppercase tracking-wide text-[var(--ink-soft)]">
|
|
273
|
+
Search matches
|
|
274
|
+
</h2>
|
|
262
275
|
{matches.map((match) => (
|
|
263
|
-
<article
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
<
|
|
277
|
-
<div className={metaRowClass}>
|
|
276
|
+
<article className={blockRowClass} key={match.profile.id}>
|
|
277
|
+
<AvatarChip
|
|
278
|
+
avatarUrl={match.profile.avatarUrl}
|
|
279
|
+
hue={match.profile.avatarHue}
|
|
280
|
+
name={match.profile.displayName}
|
|
281
|
+
profileId={match.profile.id}
|
|
282
|
+
/>
|
|
283
|
+
<div className={blockRowBodyClass}>
|
|
284
|
+
<div className="flex items-center justify-between gap-2">
|
|
285
|
+
<div className="flex min-w-0 flex-col">
|
|
286
|
+
<strong className="truncate text-[15px] text-[var(--ink)]">
|
|
287
|
+
{match.profile.displayName}
|
|
288
|
+
</strong>
|
|
289
|
+
<div className="flex flex-wrap items-center gap-1.5 text-[13px] text-[var(--ink-soft)]">
|
|
278
290
|
<span>@{match.profile.handle}</span>
|
|
279
291
|
<span className={mutedDotClass} />
|
|
280
292
|
<span>
|
|
@@ -283,43 +295,52 @@ function BlocksRoute() {
|
|
|
283
295
|
</span>
|
|
284
296
|
</div>
|
|
285
297
|
</div>
|
|
298
|
+
<button
|
|
299
|
+
className={
|
|
300
|
+
match.isBlocked ? secondaryButtonClass : dangerButtonClass
|
|
301
|
+
}
|
|
302
|
+
onClick={() =>
|
|
303
|
+
void submit(
|
|
304
|
+
match.isBlocked ? "unblockProfile" : "blockProfile",
|
|
305
|
+
match.profile.id,
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
type="button"
|
|
309
|
+
>
|
|
310
|
+
{match.isBlocked ? "Unblock" : "Block"}
|
|
311
|
+
</button>
|
|
286
312
|
</div>
|
|
287
|
-
<
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
void submit(
|
|
291
|
-
match.isBlocked ? "unblockProfile" : "blockProfile",
|
|
292
|
-
match.profile.id,
|
|
293
|
-
)
|
|
294
|
-
}
|
|
295
|
-
type="button"
|
|
296
|
-
>
|
|
297
|
-
{match.isBlocked ? "Unblock" : "Block"}
|
|
298
|
-
</button>
|
|
313
|
+
<p className="text-[14px] leading-[1.4] text-[var(--ink)]">
|
|
314
|
+
{match.profile.bio}
|
|
315
|
+
</p>
|
|
299
316
|
</div>
|
|
300
|
-
<p className="mt-2.5">{match.profile.bio}</p>
|
|
301
317
|
</article>
|
|
302
318
|
))}
|
|
303
319
|
</section>
|
|
304
320
|
) : null}
|
|
305
321
|
|
|
306
|
-
<section className=
|
|
322
|
+
<section className="flex flex-col">
|
|
323
|
+
{items.length === 0 && matches.length === 0 ? (
|
|
324
|
+
<div className={emptyStateClass}>No blocks in this account.</div>
|
|
325
|
+
) : null}
|
|
307
326
|
{items.map((item) => (
|
|
308
327
|
<article
|
|
309
|
-
className={
|
|
328
|
+
className={blockRowClass}
|
|
310
329
|
key={item.accountId + item.profile.id}
|
|
311
330
|
>
|
|
312
|
-
<
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
<div>
|
|
321
|
-
<strong>
|
|
322
|
-
|
|
331
|
+
<AvatarChip
|
|
332
|
+
avatarUrl={item.profile.avatarUrl}
|
|
333
|
+
hue={item.profile.avatarHue}
|
|
334
|
+
name={item.profile.displayName}
|
|
335
|
+
profileId={item.profile.id}
|
|
336
|
+
/>
|
|
337
|
+
<div className={blockRowBodyClass}>
|
|
338
|
+
<div className="flex items-center justify-between gap-2">
|
|
339
|
+
<div className="flex min-w-0 flex-col">
|
|
340
|
+
<strong className="truncate text-[15px] text-[var(--ink)]">
|
|
341
|
+
{item.profile.displayName}
|
|
342
|
+
</strong>
|
|
343
|
+
<div className="flex flex-wrap items-center gap-1.5 text-[13px] text-[var(--ink-soft)]">
|
|
323
344
|
<span>@{item.profile.handle}</span>
|
|
324
345
|
<span className={mutedDotClass} />
|
|
325
346
|
<span>{item.accountHandle}</span>
|
|
@@ -330,23 +351,27 @@ function BlocksRoute() {
|
|
|
330
351
|
</span>
|
|
331
352
|
</div>
|
|
332
353
|
</div>
|
|
354
|
+
<button
|
|
355
|
+
className={secondaryButtonClass}
|
|
356
|
+
onClick={() => void submit("unblockProfile", item.profile.id)}
|
|
357
|
+
type="button"
|
|
358
|
+
>
|
|
359
|
+
Unblock
|
|
360
|
+
</button>
|
|
333
361
|
</div>
|
|
334
|
-
|
|
335
|
-
className=
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
362
|
+
{item.profile.bio ? (
|
|
363
|
+
<p className="text-[14px] leading-[1.4] text-[var(--ink)]">
|
|
364
|
+
{item.profile.bio}
|
|
365
|
+
</p>
|
|
366
|
+
) : null}
|
|
367
|
+
<p className={timestampClass}>
|
|
368
|
+
Blocked {new Date(item.blockedAt).toLocaleString()} ·{" "}
|
|
369
|
+
{item.source}
|
|
370
|
+
</p>
|
|
341
371
|
</div>
|
|
342
|
-
<p className="mt-2.5">{item.profile.bio}</p>
|
|
343
|
-
<p className={timestampClass}>
|
|
344
|
-
Blocked {new Date(item.blockedAt).toLocaleString()} ·{" "}
|
|
345
|
-
{item.source}
|
|
346
|
-
</p>
|
|
347
372
|
</article>
|
|
348
373
|
))}
|
|
349
374
|
</section>
|
|
350
|
-
|
|
375
|
+
</>
|
|
351
376
|
);
|
|
352
377
|
}
|
package/src/routes/dms.tsx
CHANGED
|
@@ -1,40 +1,57 @@
|
|
|
1
1
|
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { Search } from "lucide-react";
|
|
2
3
|
import { useEffect, useMemo, useState } from "react";
|
|
3
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";
|
|
4
12
|
import type {
|
|
5
13
|
DmConversationItem,
|
|
6
14
|
DmMessageItem,
|
|
7
15
|
QueryEnvelope,
|
|
8
|
-
QueryResponse,
|
|
9
16
|
ReplyFilter,
|
|
10
17
|
} from "#/lib/types";
|
|
11
18
|
import {
|
|
12
19
|
cx,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
heroTitleClass,
|
|
21
|
-
pageWrapClass,
|
|
20
|
+
pageHeaderClass,
|
|
21
|
+
pageHeaderRowClass,
|
|
22
|
+
pageSubtitleClass,
|
|
23
|
+
pageTitleClass,
|
|
24
|
+
searchFieldIconClass,
|
|
25
|
+
searchFieldInputClass,
|
|
26
|
+
searchFieldShellClass,
|
|
22
27
|
segmentActiveClass,
|
|
23
28
|
segmentClass,
|
|
24
29
|
segmentedClass,
|
|
30
|
+
tabButtonActiveClass,
|
|
31
|
+
tabButtonClass,
|
|
32
|
+
tabButtonIndicatorClass,
|
|
33
|
+
tabStripClass,
|
|
25
34
|
textFieldClass,
|
|
26
35
|
textFieldShortClass,
|
|
27
|
-
textFieldWideClass,
|
|
28
36
|
} from "#/lib/ui";
|
|
29
37
|
|
|
30
38
|
export const Route = createFileRoute("/dms")({
|
|
31
39
|
component: DmsRoute,
|
|
32
40
|
});
|
|
33
41
|
|
|
42
|
+
const TABS: Array<{ value: ReplyFilter; label: string }> = [
|
|
43
|
+
{ value: "all", label: "All" },
|
|
44
|
+
{ value: "unreplied", label: "Unreplied" },
|
|
45
|
+
{ value: "replied", label: "Replied" },
|
|
46
|
+
];
|
|
47
|
+
|
|
34
48
|
function DmsRoute() {
|
|
35
49
|
const [meta, setMeta] = useState<QueryEnvelope | null>(null);
|
|
36
50
|
const [items, setItems] = useState<DmConversationItem[]>([]);
|
|
37
51
|
const [messages, setMessages] = useState<DmMessageItem[]>([]);
|
|
52
|
+
const [loadedConversationId, setLoadedConversationId] = useState<
|
|
53
|
+
string | undefined
|
|
54
|
+
>();
|
|
38
55
|
const [selectedConversationId, setSelectedConversationId] = useState<
|
|
39
56
|
string | undefined
|
|
40
57
|
>();
|
|
@@ -45,15 +62,20 @@ function DmsRoute() {
|
|
|
45
62
|
const [search, setSearch] = useState("");
|
|
46
63
|
const [replyDraft, setReplyDraft] = useState("");
|
|
47
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
|
+
}
|
|
48
71
|
|
|
49
72
|
useEffect(() => {
|
|
50
|
-
|
|
51
|
-
.then((response) => response.json())
|
|
52
|
-
.then((data: QueryEnvelope) => setMeta(data));
|
|
73
|
+
void loadStatus();
|
|
53
74
|
}, []);
|
|
54
75
|
|
|
55
76
|
useEffect(() => {
|
|
56
77
|
const controller = new AbortController();
|
|
78
|
+
let active = true;
|
|
57
79
|
const url = new URL("/api/query", window.location.origin);
|
|
58
80
|
url.searchParams.set("resource", "dms");
|
|
59
81
|
url.searchParams.set("replyFilter", replyFilter);
|
|
@@ -68,12 +90,15 @@ function DmsRoute() {
|
|
|
68
90
|
url.searchParams.set("search", search.trim());
|
|
69
91
|
}
|
|
70
92
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
93
|
+
setError(null);
|
|
94
|
+
setLoading(true);
|
|
95
|
+
fetchQueryResponse(url, { signal: controller.signal })
|
|
96
|
+
.then((data) => {
|
|
97
|
+
if (!active) return;
|
|
74
98
|
const conversations = data.items as DmConversationItem[];
|
|
75
99
|
const nextSelected =
|
|
76
100
|
data.selectedConversation?.conversation.id ?? conversations[0]?.id;
|
|
101
|
+
setLoadedConversationId(data.selectedConversation?.conversation.id);
|
|
77
102
|
setItems(conversations);
|
|
78
103
|
setSelectedConversationId((current) => {
|
|
79
104
|
if (!current) return nextSelected;
|
|
@@ -85,14 +110,31 @@ function DmsRoute() {
|
|
|
85
110
|
});
|
|
86
111
|
setMessages(data.selectedConversation?.messages ?? []);
|
|
87
112
|
})
|
|
88
|
-
.catch((
|
|
89
|
-
if (
|
|
113
|
+
.catch((fetchError: unknown) => {
|
|
114
|
+
if (
|
|
115
|
+
fetchError instanceof DOMException &&
|
|
116
|
+
fetchError.name === "AbortError"
|
|
117
|
+
) {
|
|
90
118
|
return;
|
|
91
119
|
}
|
|
92
|
-
|
|
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
|
+
}
|
|
93
134
|
});
|
|
94
135
|
|
|
95
136
|
return () => {
|
|
137
|
+
active = false;
|
|
96
138
|
controller.abort();
|
|
97
139
|
};
|
|
98
140
|
}, [
|
|
@@ -107,10 +149,17 @@ function DmsRoute() {
|
|
|
107
149
|
|
|
108
150
|
const selectedConversation =
|
|
109
151
|
items.find((item) => item.id === selectedConversationId) ?? null;
|
|
152
|
+
const switchingConversation =
|
|
153
|
+
loading &&
|
|
154
|
+
Boolean(
|
|
155
|
+
selectedConversationId &&
|
|
156
|
+
loadedConversationId &&
|
|
157
|
+
selectedConversationId !== loadedConversationId,
|
|
158
|
+
);
|
|
110
159
|
|
|
111
160
|
const subtitle = useMemo(() => {
|
|
112
161
|
if (!meta) return "Loading direct messages...";
|
|
113
|
-
return `${meta.stats.dms} conversations cached locally
|
|
162
|
+
return `${String(meta.stats.dms)} conversations cached locally`;
|
|
114
163
|
}, [meta]);
|
|
115
164
|
|
|
116
165
|
async function replyToConversation(conversationId: string) {
|
|
@@ -162,14 +211,10 @@ function DmsRoute() {
|
|
|
162
211
|
);
|
|
163
212
|
|
|
164
213
|
try {
|
|
165
|
-
await
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
kind: "replyDm",
|
|
170
|
-
conversationId,
|
|
171
|
-
text,
|
|
172
|
-
}),
|
|
214
|
+
await postAction({
|
|
215
|
+
kind: "replyDm",
|
|
216
|
+
conversationId,
|
|
217
|
+
text,
|
|
173
218
|
});
|
|
174
219
|
|
|
175
220
|
setSelectedConversationId(conversationId);
|
|
@@ -182,71 +227,111 @@ function DmsRoute() {
|
|
|
182
227
|
}
|
|
183
228
|
}
|
|
184
229
|
|
|
230
|
+
function refreshLocalView() {
|
|
231
|
+
setRefreshTick((value) => value + 1);
|
|
232
|
+
void loadStatus();
|
|
233
|
+
}
|
|
234
|
+
|
|
185
235
|
return (
|
|
186
|
-
|
|
187
|
-
<
|
|
188
|
-
<
|
|
189
|
-
<div>
|
|
190
|
-
<
|
|
191
|
-
<
|
|
192
|
-
Influence, bio, and reply state. No hunting.
|
|
193
|
-
</h2>
|
|
194
|
-
<p className={heroCopyClass}>{subtitle}</p>
|
|
236
|
+
<>
|
|
237
|
+
<header className={pageHeaderClass}>
|
|
238
|
+
<div className={pageHeaderRowClass}>
|
|
239
|
+
<div className="flex min-w-0 flex-col">
|
|
240
|
+
<h1 className={pageTitleClass}>Messages</h1>
|
|
241
|
+
<p className={pageSubtitleClass}>{subtitle}</p>
|
|
195
242
|
</div>
|
|
196
|
-
<
|
|
243
|
+
<SyncNowButton
|
|
244
|
+
kind="dms"
|
|
245
|
+
label="Sync DMs"
|
|
246
|
+
onSynced={refreshLocalView}
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
<div className="flex flex-wrap items-center gap-2 px-4 pb-3">
|
|
250
|
+
<label className={cx(searchFieldShellClass, "flex-1 min-w-[200px]")}>
|
|
251
|
+
<Search className={searchFieldIconClass} strokeWidth={2} />
|
|
197
252
|
<input
|
|
198
|
-
className={
|
|
253
|
+
className={searchFieldInputClass}
|
|
199
254
|
onChange={(event) => setSearch(event.target.value)}
|
|
200
255
|
placeholder="Search DMs"
|
|
201
256
|
value={search}
|
|
202
257
|
/>
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
<div className={segmentedClass}>
|
|
233
|
-
{(["all", "replied", "unreplied"] as const).map((value) => (
|
|
234
|
-
<button
|
|
235
|
-
key={value}
|
|
236
|
-
className={cx(
|
|
237
|
-
segmentClass,
|
|
238
|
-
value === replyFilter && segmentActiveClass,
|
|
239
|
-
)}
|
|
240
|
-
onClick={() => setReplyFilter(value)}
|
|
241
|
-
type="button"
|
|
242
|
-
>
|
|
243
|
-
{value}
|
|
244
|
-
</button>
|
|
245
|
-
))}
|
|
246
|
-
</div>
|
|
258
|
+
</label>
|
|
259
|
+
<input
|
|
260
|
+
className={cx(textFieldClass, textFieldShortClass)}
|
|
261
|
+
inputMode="numeric"
|
|
262
|
+
onChange={(event) => setMinFollowers(event.target.value)}
|
|
263
|
+
placeholder="Min followers"
|
|
264
|
+
value={minFollowers}
|
|
265
|
+
/>
|
|
266
|
+
<input
|
|
267
|
+
className={cx(textFieldClass, textFieldShortClass)}
|
|
268
|
+
inputMode="numeric"
|
|
269
|
+
onChange={(event) => setMinInfluenceScore(event.target.value)}
|
|
270
|
+
placeholder="Min score"
|
|
271
|
+
value={minInfluenceScore}
|
|
272
|
+
/>
|
|
273
|
+
<div className={segmentedClass}>
|
|
274
|
+
{(["recent", "influence"] as const).map((value) => (
|
|
275
|
+
<button
|
|
276
|
+
key={value}
|
|
277
|
+
className={cx(
|
|
278
|
+
segmentClass,
|
|
279
|
+
value === sort && segmentActiveClass,
|
|
280
|
+
)}
|
|
281
|
+
onClick={() => setSort(value)}
|
|
282
|
+
type="button"
|
|
283
|
+
>
|
|
284
|
+
{value}
|
|
285
|
+
</button>
|
|
286
|
+
))}
|
|
247
287
|
</div>
|
|
248
|
-
</
|
|
288
|
+
</div>
|
|
289
|
+
<div className={tabStripClass}>
|
|
290
|
+
{TABS.map((tab) => {
|
|
291
|
+
const active = replyFilter === tab.value;
|
|
292
|
+
return (
|
|
293
|
+
<button
|
|
294
|
+
key={tab.value}
|
|
295
|
+
type="button"
|
|
296
|
+
aria-pressed={active}
|
|
297
|
+
className={cx(tabButtonClass, active && tabButtonActiveClass)}
|
|
298
|
+
onClick={() => setReplyFilter(tab.value)}
|
|
299
|
+
>
|
|
300
|
+
<span className="relative inline-flex flex-col items-center justify-center py-1">
|
|
301
|
+
{tab.value}
|
|
302
|
+
{active ? <span className={tabButtonIndicatorClass} /> : null}
|
|
303
|
+
</span>
|
|
304
|
+
</button>
|
|
305
|
+
);
|
|
306
|
+
})}
|
|
307
|
+
</div>
|
|
308
|
+
</header>
|
|
249
309
|
|
|
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
|
+
) : (
|
|
250
335
|
<DmWorkspace
|
|
251
336
|
conversations={items}
|
|
252
337
|
onReplyDraftChange={setReplyDraft}
|
|
@@ -256,7 +341,7 @@ function DmsRoute() {
|
|
|
256
341
|
selectedConversation={selectedConversation}
|
|
257
342
|
selectedMessages={messages}
|
|
258
343
|
/>
|
|
259
|
-
|
|
260
|
-
|
|
344
|
+
)}
|
|
345
|
+
</>
|
|
261
346
|
);
|
|
262
347
|
}
|