birdclaw 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -1
- package/README.md +50 -5
- package/package.json +3 -2
- package/scripts/browser-perf.mjs +1 -0
- package/scripts/start-test-server.mjs +16 -3
- package/src/cli.ts +376 -13
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +27 -7
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/DmWorkspace.tsx +18 -8
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +452 -0
- package/src/components/SyncNowButton.tsx +57 -25
- package/src/components/ThemeSlider.tsx +55 -50
- package/src/components/TimelineCard.tsx +225 -93
- package/src/components/TimelineRouteFrame.tsx +22 -8
- package/src/components/TweetMediaGrid.tsx +87 -38
- package/src/components/TweetRichText.tsx +15 -11
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +23 -7
- package/src/lib/account-sync-job.ts +654 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +128 -53
- package/src/lib/archive-finder.ts +78 -63
- package/src/lib/archive-import.ts +1364 -1300
- package/src/lib/authored-live.ts +261 -204
- package/src/lib/avatar-cache.ts +159 -44
- package/src/lib/backup.ts +1532 -951
- package/src/lib/bird-actions.ts +139 -57
- package/src/lib/bird-command.ts +101 -28
- package/src/lib/bird.ts +549 -194
- package/src/lib/blocklist.ts +40 -23
- package/src/lib/blocks-write.ts +129 -80
- package/src/lib/blocks.ts +165 -97
- package/src/lib/bookmark-sync-job.ts +250 -160
- package/src/lib/conversation-surface.ts +79 -48
- package/src/lib/db.ts +33 -3
- package/src/lib/dms-live.ts +720 -66
- package/src/lib/effect-runtime.ts +45 -0
- package/src/lib/follow-graph.ts +224 -180
- package/src/lib/http-effect.ts +222 -0
- package/src/lib/inbox.ts +74 -43
- package/src/lib/link-index.ts +88 -76
- package/src/lib/link-insights.ts +24 -0
- package/src/lib/link-preview-metadata.ts +472 -52
- package/src/lib/media-fetch.ts +286 -213
- package/src/lib/mention-threads-live.ts +352 -288
- package/src/lib/mentions-live.ts +390 -342
- package/src/lib/moderation-target.ts +102 -65
- package/src/lib/moderation-write.ts +77 -18
- package/src/lib/mutes-write.ts +129 -80
- package/src/lib/mutes.ts +8 -1
- package/src/lib/openai.ts +84 -53
- package/src/lib/period-digest.ts +953 -0
- package/src/lib/profile-affiliation-hydration.ts +93 -54
- package/src/lib/profile-hydration.ts +124 -72
- package/src/lib/profile-replies.ts +60 -43
- package/src/lib/profile-resolver.ts +402 -294
- package/src/lib/queries.ts +969 -199
- package/src/lib/research.ts +165 -120
- package/src/lib/sqlite.ts +1 -0
- package/src/lib/timeline-collections-live.ts +204 -167
- package/src/lib/timeline-live.ts +60 -39
- package/src/lib/tweet-lookup.ts +30 -19
- package/src/lib/types.ts +38 -1
- package/src/lib/ui.ts +30 -7
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +216 -148
- package/src/lib/whois.ts +166 -147
- package/src/lib/x-web.ts +102 -71
- package/src/lib/xurl.ts +681 -411
- package/src/routeTree.gen.ts +42 -0
- package/src/routes/__root.tsx +25 -5
- package/src/routes/api/action.tsx +127 -78
- package/src/routes/api/avatar.tsx +39 -30
- package/src/routes/api/blocks.tsx +26 -23
- package/src/routes/api/conversation.tsx +25 -14
- package/src/routes/api/inbox.tsx +27 -21
- package/src/routes/api/link-insights.tsx +31 -25
- package/src/routes/api/link-preview.tsx +25 -21
- package/src/routes/api/period-digest.tsx +123 -0
- package/src/routes/api/profile-hydrate.tsx +31 -28
- package/src/routes/api/query.tsx +79 -55
- package/src/routes/api/status.tsx +18 -10
- package/src/routes/api/sync.tsx +75 -29
- package/src/routes/dms.tsx +95 -28
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/today.tsx +441 -0
package/src/routes/api/sync.tsx
CHANGED
|
@@ -1,28 +1,64 @@
|
|
|
1
1
|
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import {
|
|
4
|
+
jsonResponse,
|
|
5
|
+
requestJsonEffect,
|
|
6
|
+
runRouteEffect,
|
|
7
|
+
sensitiveRequestErrorResponse,
|
|
8
|
+
} from "#/lib/http-effect";
|
|
9
|
+
import {
|
|
10
|
+
getWebSyncJob,
|
|
11
|
+
parseWebSyncKind,
|
|
12
|
+
startWebSync,
|
|
13
|
+
type WebSyncDmInbox,
|
|
14
|
+
type WebSyncOptions,
|
|
15
|
+
} from "#/lib/web-sync";
|
|
13
16
|
|
|
14
17
|
function parseAccountId(value: unknown) {
|
|
15
18
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
function parseDmInbox(value: unknown): WebSyncDmInbox | undefined {
|
|
22
|
+
return value === "all" || value === "accepted" || value === "requests"
|
|
23
|
+
? value
|
|
24
|
+
: undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parsePositiveInteger(value: unknown, max: number) {
|
|
28
|
+
const parsed =
|
|
29
|
+
typeof value === "number"
|
|
30
|
+
? value
|
|
31
|
+
: typeof value === "string"
|
|
32
|
+
? Number(value)
|
|
33
|
+
: Number.NaN;
|
|
34
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return undefined;
|
|
35
|
+
return Math.min(parsed, max);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseSyncOptions(kind: string, body: Record<string, unknown>) {
|
|
39
|
+
if (kind !== "dms") return {};
|
|
40
|
+
const options: WebSyncOptions = {};
|
|
41
|
+
const inbox = parseDmInbox(body.inbox);
|
|
42
|
+
const limit = parsePositiveInteger(body.limit, 1000);
|
|
43
|
+
const maxPages = parsePositiveInteger(body.maxPages, 250);
|
|
44
|
+
if (inbox) options.inbox = inbox;
|
|
45
|
+
if (limit) options.limit = limit;
|
|
46
|
+
if (maxPages) options.maxPages = maxPages;
|
|
47
|
+
if (typeof body.allPages === "boolean") options.allPages = body.allPages;
|
|
48
|
+
return options;
|
|
49
|
+
}
|
|
50
|
+
|
|
18
51
|
export const Route = createFileRoute("/api/sync")({
|
|
19
52
|
server: {
|
|
20
53
|
handlers: {
|
|
21
54
|
GET: ({ request }) => {
|
|
55
|
+
const denied = sensitiveRequestErrorResponse(request);
|
|
56
|
+
if (denied) return denied;
|
|
57
|
+
|
|
22
58
|
const url = new URL(request.url);
|
|
23
59
|
const id = url.searchParams.get("id");
|
|
24
60
|
if (!id) {
|
|
25
|
-
return
|
|
61
|
+
return jsonResponse(
|
|
26
62
|
{ ok: false, message: "Missing sync job id" },
|
|
27
63
|
{ status: 400 },
|
|
28
64
|
);
|
|
@@ -30,30 +66,40 @@ export const Route = createFileRoute("/api/sync")({
|
|
|
30
66
|
|
|
31
67
|
const job = getWebSyncJob(id);
|
|
32
68
|
if (!job) {
|
|
33
|
-
return
|
|
69
|
+
return jsonResponse(
|
|
34
70
|
{ ok: false, message: "Sync job not found" },
|
|
35
71
|
{ status: 404 },
|
|
36
72
|
);
|
|
37
73
|
}
|
|
38
74
|
|
|
39
|
-
return
|
|
75
|
+
return jsonResponse(job);
|
|
40
76
|
},
|
|
41
|
-
POST:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
}
|
|
77
|
+
POST: ({ request }) =>
|
|
78
|
+
runRouteEffect(
|
|
79
|
+
Effect.gen(function* () {
|
|
80
|
+
const denied = sensitiveRequestErrorResponse(request);
|
|
81
|
+
if (denied) return denied;
|
|
53
82
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
83
|
+
const body = yield* requestJsonEffect<Record<string, unknown>>(
|
|
84
|
+
request,
|
|
85
|
+
{},
|
|
86
|
+
);
|
|
87
|
+
const kind = parseWebSyncKind(body.kind);
|
|
88
|
+
if (!kind) {
|
|
89
|
+
return jsonResponse(
|
|
90
|
+
{ ok: false, message: "Unknown sync kind" },
|
|
91
|
+
{ status: 400 },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const job = startWebSync(
|
|
96
|
+
kind,
|
|
97
|
+
parseAccountId(body.accountId),
|
|
98
|
+
parseSyncOptions(kind, body),
|
|
99
|
+
);
|
|
100
|
+
return jsonResponse(job, { status: job.inProgress ? 202 : 200 });
|
|
101
|
+
}),
|
|
102
|
+
),
|
|
57
103
|
},
|
|
58
104
|
},
|
|
59
105
|
});
|
package/src/routes/dms.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
|
|
4
4
|
import { DmWorkspace } from "#/components/DmWorkspace";
|
|
5
5
|
import { FeedEmpty, FeedError, FeedLoading } from "#/components/FeedState";
|
|
6
6
|
import { SyncNowButton } from "#/components/SyncNowButton";
|
|
7
|
+
import { useSelectedAccountId } from "#/components/account-selection";
|
|
7
8
|
import {
|
|
8
9
|
fetchQueryEnvelope,
|
|
9
10
|
fetchQueryResponse,
|
|
@@ -31,8 +32,7 @@ import {
|
|
|
31
32
|
tabButtonClass,
|
|
32
33
|
tabButtonIndicatorClass,
|
|
33
34
|
tabStripClass,
|
|
34
|
-
|
|
35
|
-
textFieldShortClass,
|
|
35
|
+
timestampClass,
|
|
36
36
|
} from "#/lib/ui";
|
|
37
37
|
|
|
38
38
|
export const Route = createFileRoute("/dms")({
|
|
@@ -45,6 +45,22 @@ const TABS: Array<{ value: ReplyFilter; label: string }> = [
|
|
|
45
45
|
{ value: "replied", label: "Replied" },
|
|
46
46
|
];
|
|
47
47
|
|
|
48
|
+
const SORTS: Array<{ value: "recent" | "followers"; label: string }> = [
|
|
49
|
+
{ value: "recent", label: "Newest" },
|
|
50
|
+
{ value: "followers", label: "Followers" },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
type DmInboxFilter = "all" | "accepted" | "requests";
|
|
54
|
+
|
|
55
|
+
const INBOX_FILTERS: Array<{ value: DmInboxFilter; label: string }> = [
|
|
56
|
+
{ value: "all", label: "All" },
|
|
57
|
+
{ value: "accepted", label: "Accepted" },
|
|
58
|
+
{ value: "requests", label: "Requests" },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const filterNumberFieldClass =
|
|
62
|
+
"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)]";
|
|
63
|
+
|
|
48
64
|
function DmsRoute() {
|
|
49
65
|
const [meta, setMeta] = useState<QueryEnvelope | null>(null);
|
|
50
66
|
const [items, setItems] = useState<DmConversationItem[]>([]);
|
|
@@ -55,15 +71,18 @@ function DmsRoute() {
|
|
|
55
71
|
const [selectedConversationId, setSelectedConversationId] = useState<
|
|
56
72
|
string | undefined
|
|
57
73
|
>();
|
|
74
|
+
const [inboxFilter, setInboxFilter] = useState<DmInboxFilter>("all");
|
|
58
75
|
const [replyFilter, setReplyFilter] = useState<ReplyFilter>("unreplied");
|
|
59
|
-
const [minFollowers, setMinFollowers] = useState("
|
|
60
|
-
const [minInfluenceScore, setMinInfluenceScore] = useState("
|
|
61
|
-
const [sort, setSort] = useState<"recent" | "
|
|
76
|
+
const [minFollowers, setMinFollowers] = useState("");
|
|
77
|
+
const [minInfluenceScore, setMinInfluenceScore] = useState("");
|
|
78
|
+
const [sort, setSort] = useState<"recent" | "followers">("recent");
|
|
62
79
|
const [search, setSearch] = useState("");
|
|
63
80
|
const [replyDraft, setReplyDraft] = useState("");
|
|
64
81
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
65
82
|
const [loading, setLoading] = useState(true);
|
|
66
83
|
const [error, setError] = useState<string | null>(null);
|
|
84
|
+
const [replyError, setReplyError] = useState<string | null>(null);
|
|
85
|
+
const selectedAccountId = useSelectedAccountId(meta?.accounts);
|
|
67
86
|
|
|
68
87
|
async function loadStatus() {
|
|
69
88
|
setMeta(await fetchQueryEnvelope());
|
|
@@ -78,11 +97,19 @@ function DmsRoute() {
|
|
|
78
97
|
let active = true;
|
|
79
98
|
const url = new URL("/api/query", window.location.origin);
|
|
80
99
|
url.searchParams.set("resource", "dms");
|
|
100
|
+
url.searchParams.set("inbox", inboxFilter);
|
|
81
101
|
url.searchParams.set("replyFilter", replyFilter);
|
|
82
|
-
url.searchParams.set("minFollowers", minFollowers);
|
|
83
|
-
url.searchParams.set("minInfluenceScore", minInfluenceScore);
|
|
84
102
|
url.searchParams.set("refresh", String(refreshTick));
|
|
85
103
|
url.searchParams.set("sort", sort);
|
|
104
|
+
if (minFollowers.trim()) {
|
|
105
|
+
url.searchParams.set("minFollowers", minFollowers.trim());
|
|
106
|
+
}
|
|
107
|
+
if (minInfluenceScore.trim()) {
|
|
108
|
+
url.searchParams.set("minInfluenceScore", minInfluenceScore.trim());
|
|
109
|
+
}
|
|
110
|
+
if (selectedAccountId && inboxFilter !== "requests") {
|
|
111
|
+
url.searchParams.set("account", selectedAccountId);
|
|
112
|
+
}
|
|
86
113
|
if (selectedConversationId) {
|
|
87
114
|
url.searchParams.set("conversationId", selectedConversationId);
|
|
88
115
|
}
|
|
@@ -140,10 +167,12 @@ function DmsRoute() {
|
|
|
140
167
|
}, [
|
|
141
168
|
minFollowers,
|
|
142
169
|
minInfluenceScore,
|
|
170
|
+
inboxFilter,
|
|
143
171
|
refreshTick,
|
|
144
172
|
replyFilter,
|
|
145
173
|
search,
|
|
146
174
|
selectedConversationId,
|
|
175
|
+
selectedAccountId,
|
|
147
176
|
sort,
|
|
148
177
|
]);
|
|
149
178
|
|
|
@@ -194,6 +223,7 @@ function DmsRoute() {
|
|
|
194
223
|
const previousMessages = messages;
|
|
195
224
|
const previousItems = items;
|
|
196
225
|
|
|
226
|
+
setReplyError(null);
|
|
197
227
|
setReplyDraft("");
|
|
198
228
|
setMessages((current) => [...current, optimisticMessage]);
|
|
199
229
|
setItems((current) =>
|
|
@@ -223,7 +253,7 @@ function DmsRoute() {
|
|
|
223
253
|
setReplyDraft(text);
|
|
224
254
|
setMessages(previousMessages);
|
|
225
255
|
setItems(previousItems);
|
|
226
|
-
|
|
256
|
+
setReplyError(error instanceof Error ? error.message : "Reply failed");
|
|
227
257
|
}
|
|
228
258
|
}
|
|
229
259
|
|
|
@@ -241,12 +271,34 @@ function DmsRoute() {
|
|
|
241
271
|
<p className={pageSubtitleClass}>{subtitle}</p>
|
|
242
272
|
</div>
|
|
243
273
|
<SyncNowButton
|
|
274
|
+
accounts={meta?.accounts}
|
|
244
275
|
kind="dms"
|
|
245
276
|
label="Sync DMs"
|
|
246
277
|
onSynced={refreshLocalView}
|
|
278
|
+
syncOptions={{
|
|
279
|
+
inbox: inboxFilter,
|
|
280
|
+
limit: inboxFilter === "requests" ? 200 : 50,
|
|
281
|
+
maxPages: inboxFilter === "requests" ? 3 : 1,
|
|
282
|
+
}}
|
|
247
283
|
/>
|
|
248
284
|
</div>
|
|
249
285
|
<div className="flex flex-wrap items-center gap-2 px-4 pb-3">
|
|
286
|
+
<div className={segmentedClass} aria-label="DM inbox">
|
|
287
|
+
{INBOX_FILTERS.map((filter) => (
|
|
288
|
+
<button
|
|
289
|
+
key={filter.value}
|
|
290
|
+
aria-pressed={inboxFilter === filter.value}
|
|
291
|
+
className={cx(
|
|
292
|
+
segmentClass,
|
|
293
|
+
inboxFilter === filter.value && segmentActiveClass,
|
|
294
|
+
)}
|
|
295
|
+
onClick={() => setInboxFilter(filter.value)}
|
|
296
|
+
type="button"
|
|
297
|
+
>
|
|
298
|
+
{filter.label}
|
|
299
|
+
</button>
|
|
300
|
+
))}
|
|
301
|
+
</div>
|
|
250
302
|
<label className={cx(searchFieldShellClass, "flex-1 min-w-[200px]")}>
|
|
251
303
|
<Search className={searchFieldIconClass} strokeWidth={2} />
|
|
252
304
|
<input
|
|
@@ -256,37 +308,47 @@ function DmsRoute() {
|
|
|
256
308
|
value={search}
|
|
257
309
|
/>
|
|
258
310
|
</label>
|
|
259
|
-
<
|
|
260
|
-
className=
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
311
|
+
<label className={cx(filterNumberFieldClass, "w-[156px]")}>
|
|
312
|
+
<span className="shrink-0 text-[12px] font-semibold text-[var(--ink-soft)]">
|
|
313
|
+
Followers
|
|
314
|
+
</span>
|
|
315
|
+
<input
|
|
316
|
+
className="min-w-0 flex-1 border-0 bg-transparent text-right text-[14px] text-[var(--ink)] outline-none placeholder:text-[var(--ink-soft)]"
|
|
317
|
+
inputMode="numeric"
|
|
318
|
+
onChange={(event) => setMinFollowers(event.target.value)}
|
|
319
|
+
placeholder="Any"
|
|
320
|
+
value={minFollowers}
|
|
321
|
+
/>
|
|
322
|
+
</label>
|
|
323
|
+
<label className={cx(filterNumberFieldClass, "w-[132px]")}>
|
|
324
|
+
<span className="shrink-0 text-[12px] font-semibold text-[var(--ink-soft)]">
|
|
325
|
+
Score
|
|
326
|
+
</span>
|
|
327
|
+
<input
|
|
328
|
+
className="min-w-0 flex-1 border-0 bg-transparent text-right text-[14px] text-[var(--ink)] outline-none placeholder:text-[var(--ink-soft)]"
|
|
329
|
+
inputMode="numeric"
|
|
330
|
+
onChange={(event) => setMinInfluenceScore(event.target.value)}
|
|
331
|
+
placeholder="Any"
|
|
332
|
+
value={minInfluenceScore}
|
|
333
|
+
/>
|
|
334
|
+
</label>
|
|
273
335
|
<div className={segmentedClass}>
|
|
274
|
-
{
|
|
336
|
+
{SORTS.map((option) => (
|
|
275
337
|
<button
|
|
276
|
-
key={value}
|
|
338
|
+
key={option.value}
|
|
277
339
|
className={cx(
|
|
278
340
|
segmentClass,
|
|
279
|
-
value === sort && segmentActiveClass,
|
|
341
|
+
option.value === sort && segmentActiveClass,
|
|
280
342
|
)}
|
|
281
|
-
onClick={() => setSort(value)}
|
|
343
|
+
onClick={() => setSort(option.value)}
|
|
282
344
|
type="button"
|
|
283
345
|
>
|
|
284
|
-
{
|
|
346
|
+
{option.label}
|
|
285
347
|
</button>
|
|
286
348
|
))}
|
|
287
349
|
</div>
|
|
288
350
|
</div>
|
|
289
|
-
<div className={tabStripClass}>
|
|
351
|
+
<div className={tabStripClass} aria-label="DM reply filter">
|
|
290
352
|
{TABS.map((tab) => {
|
|
291
353
|
const active = replyFilter === tab.value;
|
|
292
354
|
return (
|
|
@@ -306,6 +368,11 @@ function DmsRoute() {
|
|
|
306
368
|
})}
|
|
307
369
|
</div>
|
|
308
370
|
</header>
|
|
371
|
+
{replyError ? (
|
|
372
|
+
<p className={cx(timestampClass, "px-4 py-2 text-red-500")}>
|
|
373
|
+
{replyError}
|
|
374
|
+
</p>
|
|
375
|
+
) : null}
|
|
309
376
|
|
|
310
377
|
{loading && (items.length === 0 || switchingConversation) ? (
|
|
311
378
|
<FeedLoading
|
package/src/routes/inbox.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createFileRoute } from "@tanstack/react-router";
|
|
2
2
|
import { Sparkles } from "lucide-react";
|
|
3
3
|
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { useSelectedAccountId } from "#/components/account-selection";
|
|
4
5
|
import { InboxCard } from "#/components/InboxCard";
|
|
6
|
+
import { postAction } from "#/lib/api-client";
|
|
5
7
|
import type {
|
|
6
8
|
InboxItem,
|
|
7
9
|
InboxKind,
|
|
@@ -48,7 +50,9 @@ function InboxRoute() {
|
|
|
48
50
|
const [activeReplyId, setActiveReplyId] = useState<string | null>(null);
|
|
49
51
|
const [replyDraft, setReplyDraft] = useState("");
|
|
50
52
|
const [isSendingReply, setIsSendingReply] = useState(false);
|
|
53
|
+
const [replyError, setReplyError] = useState<string | null>(null);
|
|
51
54
|
const [stats, setStats] = useState<InboxResponse["stats"] | null>(null);
|
|
55
|
+
const selectedAccountId = useSelectedAccountId(meta?.accounts);
|
|
52
56
|
|
|
53
57
|
useEffect(() => {
|
|
54
58
|
fetch("/api/status")
|
|
@@ -61,6 +65,9 @@ function InboxRoute() {
|
|
|
61
65
|
url.searchParams.set("kind", kind);
|
|
62
66
|
url.searchParams.set("minScore", minScore);
|
|
63
67
|
url.searchParams.set("refresh", String(refreshTick));
|
|
68
|
+
if (selectedAccountId) {
|
|
69
|
+
url.searchParams.set("account", selectedAccountId);
|
|
70
|
+
}
|
|
64
71
|
if (hideLowSignal) {
|
|
65
72
|
url.searchParams.set("hideLowSignal", "1");
|
|
66
73
|
}
|
|
@@ -71,7 +78,7 @@ function InboxRoute() {
|
|
|
71
78
|
setItems(data.items);
|
|
72
79
|
setStats(data.stats);
|
|
73
80
|
});
|
|
74
|
-
}, [hideLowSignal, kind, minScore, refreshTick]);
|
|
81
|
+
}, [hideLowSignal, kind, minScore, refreshTick, selectedAccountId]);
|
|
75
82
|
|
|
76
83
|
const subtitle = useMemo(() => {
|
|
77
84
|
if (!meta || !stats) return "Ranking unreplied mentions and DMs...";
|
|
@@ -87,6 +94,7 @@ function InboxRoute() {
|
|
|
87
94
|
body: JSON.stringify({
|
|
88
95
|
kind: "scoreInbox",
|
|
89
96
|
scoreKind: kind,
|
|
97
|
+
account: selectedAccountId,
|
|
90
98
|
limit: 8,
|
|
91
99
|
}),
|
|
92
100
|
});
|
|
@@ -99,28 +107,27 @@ function InboxRoute() {
|
|
|
99
107
|
async function sendReply(item: InboxItem) {
|
|
100
108
|
if (!replyDraft.trim()) return;
|
|
101
109
|
setIsSendingReply(true);
|
|
110
|
+
setReplyError(null);
|
|
102
111
|
try {
|
|
103
|
-
await
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
text: replyDraft,
|
|
118
|
-
},
|
|
119
|
-
),
|
|
120
|
-
});
|
|
112
|
+
await postAction(
|
|
113
|
+
item.entityKind === "dm"
|
|
114
|
+
? {
|
|
115
|
+
kind: "replyDm",
|
|
116
|
+
conversationId: item.entityId,
|
|
117
|
+
text: replyDraft,
|
|
118
|
+
}
|
|
119
|
+
: {
|
|
120
|
+
kind: "replyTweet",
|
|
121
|
+
accountId: item.accountId,
|
|
122
|
+
tweetId: item.entityId,
|
|
123
|
+
text: replyDraft,
|
|
124
|
+
},
|
|
125
|
+
);
|
|
121
126
|
setReplyDraft("");
|
|
122
127
|
setActiveReplyId(null);
|
|
123
128
|
setRefreshTick((value) => value + 1);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
setReplyError(error instanceof Error ? error.message : "Reply failed");
|
|
124
131
|
} finally {
|
|
125
132
|
setIsSendingReply(false);
|
|
126
133
|
}
|
|
@@ -181,6 +188,11 @@ function InboxRoute() {
|
|
|
181
188
|
})}
|
|
182
189
|
</div>
|
|
183
190
|
</header>
|
|
191
|
+
{replyError ? (
|
|
192
|
+
<p className={cx(timestampClass, "px-4 py-2 text-red-500")}>
|
|
193
|
+
{replyError}
|
|
194
|
+
</p>
|
|
195
|
+
) : null}
|
|
184
196
|
<section className={feedClass}>
|
|
185
197
|
{items.length === 0 ? (
|
|
186
198
|
<div className={emptyStateClass}>Inbox clear.</div>
|
|
@@ -193,6 +205,7 @@ function InboxRoute() {
|
|
|
193
205
|
onReplyChange={setReplyDraft}
|
|
194
206
|
onReplySend={() => void sendReply(item)}
|
|
195
207
|
onReplyToggle={() => {
|
|
208
|
+
setReplyError(null);
|
|
196
209
|
if (activeReplyId === item.id) {
|
|
197
210
|
setActiveReplyId(null);
|
|
198
211
|
setReplyDraft("");
|