birdclaw 0.5.0 → 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 +63 -0
- package/README.md +55 -5
- package/bin/birdclaw.mjs +50 -11
- package/package.json +9 -7
- package/public/birdclaw-mark.png +0 -0
- package/scripts/browser-perf.mjs +400 -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 +42 -0
- package/src/cli.ts +422 -14
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +29 -9
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +11 -10
- package/src/components/DmWorkspace.tsx +39 -14
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +14 -2
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +452 -0
- package/src/components/SavedTimelineView.tsx +64 -56
- package/src/components/SyncNowButton.tsx +137 -0
- package/src/components/ThemeSlider.tsx +49 -93
- package/src/components/TimelineCard.tsx +364 -136
- package/src/components/TimelineRouteFrame.tsx +170 -0
- package/src/components/TweetMediaGrid.tsx +170 -24
- package/src/components/TweetRichText.tsx +28 -13
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +153 -0
- package/src/lib/account-sync-job.ts +654 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +304 -0
- package/src/lib/archive-finder.ts +72 -53
- package/src/lib/archive-import.ts +1377 -1298
- 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 +205 -0
- package/src/lib/db.ts +35 -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 +1024 -189
- 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 +41 -10
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +511 -0
- 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 +63 -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 +105 -0
- package/src/routes/dms.tsx +195 -55
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/index.tsx +21 -127
- package/src/routes/links.tsx +50 -5
- package/src/routes/mentions.tsx +21 -127
- package/src/routes/today.tsx +441 -0
- package/src/styles.css +74 -11
- package/vite.config.ts +8 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
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";
|
|
16
|
+
|
|
17
|
+
function parseAccountId(value: unknown) {
|
|
18
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
19
|
+
}
|
|
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
|
+
|
|
51
|
+
export const Route = createFileRoute("/api/sync")({
|
|
52
|
+
server: {
|
|
53
|
+
handlers: {
|
|
54
|
+
GET: ({ request }) => {
|
|
55
|
+
const denied = sensitiveRequestErrorResponse(request);
|
|
56
|
+
if (denied) return denied;
|
|
57
|
+
|
|
58
|
+
const url = new URL(request.url);
|
|
59
|
+
const id = url.searchParams.get("id");
|
|
60
|
+
if (!id) {
|
|
61
|
+
return jsonResponse(
|
|
62
|
+
{ ok: false, message: "Missing sync job id" },
|
|
63
|
+
{ status: 400 },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const job = getWebSyncJob(id);
|
|
68
|
+
if (!job) {
|
|
69
|
+
return jsonResponse(
|
|
70
|
+
{ ok: false, message: "Sync job not found" },
|
|
71
|
+
{ status: 404 },
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return jsonResponse(job);
|
|
76
|
+
},
|
|
77
|
+
POST: ({ request }) =>
|
|
78
|
+
runRouteEffect(
|
|
79
|
+
Effect.gen(function* () {
|
|
80
|
+
const denied = sensitiveRequestErrorResponse(request);
|
|
81
|
+
if (denied) return denied;
|
|
82
|
+
|
|
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
|
+
),
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
});
|
package/src/routes/dms.tsx
CHANGED
|
@@ -2,11 +2,18 @@ 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 { useSelectedAccountId } from "#/components/account-selection";
|
|
8
|
+
import {
|
|
9
|
+
fetchQueryEnvelope,
|
|
10
|
+
fetchQueryResponse,
|
|
11
|
+
postAction,
|
|
12
|
+
} from "#/lib/api-client";
|
|
5
13
|
import type {
|
|
6
14
|
DmConversationItem,
|
|
7
15
|
DmMessageItem,
|
|
8
16
|
QueryEnvelope,
|
|
9
|
-
QueryResponse,
|
|
10
17
|
ReplyFilter,
|
|
11
18
|
} from "#/lib/types";
|
|
12
19
|
import {
|
|
@@ -25,8 +32,7 @@ import {
|
|
|
25
32
|
tabButtonClass,
|
|
26
33
|
tabButtonIndicatorClass,
|
|
27
34
|
tabStripClass,
|
|
28
|
-
|
|
29
|
-
textFieldShortClass,
|
|
35
|
+
timestampClass,
|
|
30
36
|
} from "#/lib/ui";
|
|
31
37
|
|
|
32
38
|
export const Route = createFileRoute("/dms")({
|
|
@@ -39,36 +45,71 @@ const TABS: Array<{ value: ReplyFilter; label: string }> = [
|
|
|
39
45
|
{ value: "replied", label: "Replied" },
|
|
40
46
|
];
|
|
41
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
|
+
|
|
42
64
|
function DmsRoute() {
|
|
43
65
|
const [meta, setMeta] = useState<QueryEnvelope | null>(null);
|
|
44
66
|
const [items, setItems] = useState<DmConversationItem[]>([]);
|
|
45
67
|
const [messages, setMessages] = useState<DmMessageItem[]>([]);
|
|
68
|
+
const [loadedConversationId, setLoadedConversationId] = useState<
|
|
69
|
+
string | undefined
|
|
70
|
+
>();
|
|
46
71
|
const [selectedConversationId, setSelectedConversationId] = useState<
|
|
47
72
|
string | undefined
|
|
48
73
|
>();
|
|
74
|
+
const [inboxFilter, setInboxFilter] = useState<DmInboxFilter>("all");
|
|
49
75
|
const [replyFilter, setReplyFilter] = useState<ReplyFilter>("unreplied");
|
|
50
|
-
const [minFollowers, setMinFollowers] = useState("
|
|
51
|
-
const [minInfluenceScore, setMinInfluenceScore] = useState("
|
|
52
|
-
const [sort, setSort] = useState<"recent" | "
|
|
76
|
+
const [minFollowers, setMinFollowers] = useState("");
|
|
77
|
+
const [minInfluenceScore, setMinInfluenceScore] = useState("");
|
|
78
|
+
const [sort, setSort] = useState<"recent" | "followers">("recent");
|
|
53
79
|
const [search, setSearch] = useState("");
|
|
54
80
|
const [replyDraft, setReplyDraft] = useState("");
|
|
55
81
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
82
|
+
const [loading, setLoading] = useState(true);
|
|
83
|
+
const [error, setError] = useState<string | null>(null);
|
|
84
|
+
const [replyError, setReplyError] = useState<string | null>(null);
|
|
85
|
+
const selectedAccountId = useSelectedAccountId(meta?.accounts);
|
|
86
|
+
|
|
87
|
+
async function loadStatus() {
|
|
88
|
+
setMeta(await fetchQueryEnvelope());
|
|
89
|
+
}
|
|
56
90
|
|
|
57
91
|
useEffect(() => {
|
|
58
|
-
|
|
59
|
-
.then((response) => response.json())
|
|
60
|
-
.then((data: QueryEnvelope) => setMeta(data));
|
|
92
|
+
void loadStatus();
|
|
61
93
|
}, []);
|
|
62
94
|
|
|
63
95
|
useEffect(() => {
|
|
64
96
|
const controller = new AbortController();
|
|
97
|
+
let active = true;
|
|
65
98
|
const url = new URL("/api/query", window.location.origin);
|
|
66
99
|
url.searchParams.set("resource", "dms");
|
|
100
|
+
url.searchParams.set("inbox", inboxFilter);
|
|
67
101
|
url.searchParams.set("replyFilter", replyFilter);
|
|
68
|
-
url.searchParams.set("minFollowers", minFollowers);
|
|
69
|
-
url.searchParams.set("minInfluenceScore", minInfluenceScore);
|
|
70
102
|
url.searchParams.set("refresh", String(refreshTick));
|
|
71
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
|
+
}
|
|
72
113
|
if (selectedConversationId) {
|
|
73
114
|
url.searchParams.set("conversationId", selectedConversationId);
|
|
74
115
|
}
|
|
@@ -76,12 +117,15 @@ function DmsRoute() {
|
|
|
76
117
|
url.searchParams.set("search", search.trim());
|
|
77
118
|
}
|
|
78
119
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
120
|
+
setError(null);
|
|
121
|
+
setLoading(true);
|
|
122
|
+
fetchQueryResponse(url, { signal: controller.signal })
|
|
123
|
+
.then((data) => {
|
|
124
|
+
if (!active) return;
|
|
82
125
|
const conversations = data.items as DmConversationItem[];
|
|
83
126
|
const nextSelected =
|
|
84
127
|
data.selectedConversation?.conversation.id ?? conversations[0]?.id;
|
|
128
|
+
setLoadedConversationId(data.selectedConversation?.conversation.id);
|
|
85
129
|
setItems(conversations);
|
|
86
130
|
setSelectedConversationId((current) => {
|
|
87
131
|
if (!current) return nextSelected;
|
|
@@ -93,28 +137,54 @@ function DmsRoute() {
|
|
|
93
137
|
});
|
|
94
138
|
setMessages(data.selectedConversation?.messages ?? []);
|
|
95
139
|
})
|
|
96
|
-
.catch((
|
|
97
|
-
if (
|
|
140
|
+
.catch((fetchError: unknown) => {
|
|
141
|
+
if (
|
|
142
|
+
fetchError instanceof DOMException &&
|
|
143
|
+
fetchError.name === "AbortError"
|
|
144
|
+
) {
|
|
98
145
|
return;
|
|
99
146
|
}
|
|
100
|
-
|
|
147
|
+
if (!active) return;
|
|
148
|
+
setError(
|
|
149
|
+
fetchError instanceof Error
|
|
150
|
+
? fetchError.message
|
|
151
|
+
: "Messages unavailable",
|
|
152
|
+
);
|
|
153
|
+
setItems([]);
|
|
154
|
+
setMessages([]);
|
|
155
|
+
setLoadedConversationId(undefined);
|
|
156
|
+
})
|
|
157
|
+
.finally(() => {
|
|
158
|
+
if (active) {
|
|
159
|
+
setLoading(false);
|
|
160
|
+
}
|
|
101
161
|
});
|
|
102
162
|
|
|
103
163
|
return () => {
|
|
164
|
+
active = false;
|
|
104
165
|
controller.abort();
|
|
105
166
|
};
|
|
106
167
|
}, [
|
|
107
168
|
minFollowers,
|
|
108
169
|
minInfluenceScore,
|
|
170
|
+
inboxFilter,
|
|
109
171
|
refreshTick,
|
|
110
172
|
replyFilter,
|
|
111
173
|
search,
|
|
112
174
|
selectedConversationId,
|
|
175
|
+
selectedAccountId,
|
|
113
176
|
sort,
|
|
114
177
|
]);
|
|
115
178
|
|
|
116
179
|
const selectedConversation =
|
|
117
180
|
items.find((item) => item.id === selectedConversationId) ?? null;
|
|
181
|
+
const switchingConversation =
|
|
182
|
+
loading &&
|
|
183
|
+
Boolean(
|
|
184
|
+
selectedConversationId &&
|
|
185
|
+
loadedConversationId &&
|
|
186
|
+
selectedConversationId !== loadedConversationId,
|
|
187
|
+
);
|
|
118
188
|
|
|
119
189
|
const subtitle = useMemo(() => {
|
|
120
190
|
if (!meta) return "Loading direct messages...";
|
|
@@ -153,6 +223,7 @@ function DmsRoute() {
|
|
|
153
223
|
const previousMessages = messages;
|
|
154
224
|
const previousItems = items;
|
|
155
225
|
|
|
226
|
+
setReplyError(null);
|
|
156
227
|
setReplyDraft("");
|
|
157
228
|
setMessages((current) => [...current, optimisticMessage]);
|
|
158
229
|
setItems((current) =>
|
|
@@ -170,14 +241,10 @@ function DmsRoute() {
|
|
|
170
241
|
);
|
|
171
242
|
|
|
172
243
|
try {
|
|
173
|
-
await
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
kind: "replyDm",
|
|
178
|
-
conversationId,
|
|
179
|
-
text,
|
|
180
|
-
}),
|
|
244
|
+
await postAction({
|
|
245
|
+
kind: "replyDm",
|
|
246
|
+
conversationId,
|
|
247
|
+
text,
|
|
181
248
|
});
|
|
182
249
|
|
|
183
250
|
setSelectedConversationId(conversationId);
|
|
@@ -186,10 +253,15 @@ function DmsRoute() {
|
|
|
186
253
|
setReplyDraft(text);
|
|
187
254
|
setMessages(previousMessages);
|
|
188
255
|
setItems(previousItems);
|
|
189
|
-
|
|
256
|
+
setReplyError(error instanceof Error ? error.message : "Reply failed");
|
|
190
257
|
}
|
|
191
258
|
}
|
|
192
259
|
|
|
260
|
+
function refreshLocalView() {
|
|
261
|
+
setRefreshTick((value) => value + 1);
|
|
262
|
+
void loadStatus();
|
|
263
|
+
}
|
|
264
|
+
|
|
193
265
|
return (
|
|
194
266
|
<>
|
|
195
267
|
<header className={pageHeaderClass}>
|
|
@@ -198,8 +270,35 @@ function DmsRoute() {
|
|
|
198
270
|
<h1 className={pageTitleClass}>Messages</h1>
|
|
199
271
|
<p className={pageSubtitleClass}>{subtitle}</p>
|
|
200
272
|
</div>
|
|
273
|
+
<SyncNowButton
|
|
274
|
+
accounts={meta?.accounts}
|
|
275
|
+
kind="dms"
|
|
276
|
+
label="Sync DMs"
|
|
277
|
+
onSynced={refreshLocalView}
|
|
278
|
+
syncOptions={{
|
|
279
|
+
inbox: inboxFilter,
|
|
280
|
+
limit: inboxFilter === "requests" ? 200 : 50,
|
|
281
|
+
maxPages: inboxFilter === "requests" ? 3 : 1,
|
|
282
|
+
}}
|
|
283
|
+
/>
|
|
201
284
|
</div>
|
|
202
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>
|
|
203
302
|
<label className={cx(searchFieldShellClass, "flex-1 min-w-[200px]")}>
|
|
204
303
|
<Search className={searchFieldIconClass} strokeWidth={2} />
|
|
205
304
|
<input
|
|
@@ -209,37 +308,47 @@ function DmsRoute() {
|
|
|
209
308
|
value={search}
|
|
210
309
|
/>
|
|
211
310
|
</label>
|
|
212
|
-
<
|
|
213
|
-
className=
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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>
|
|
226
335
|
<div className={segmentedClass}>
|
|
227
|
-
{
|
|
336
|
+
{SORTS.map((option) => (
|
|
228
337
|
<button
|
|
229
|
-
key={value}
|
|
338
|
+
key={option.value}
|
|
230
339
|
className={cx(
|
|
231
340
|
segmentClass,
|
|
232
|
-
value === sort && segmentActiveClass,
|
|
341
|
+
option.value === sort && segmentActiveClass,
|
|
233
342
|
)}
|
|
234
|
-
onClick={() => setSort(value)}
|
|
343
|
+
onClick={() => setSort(option.value)}
|
|
235
344
|
type="button"
|
|
236
345
|
>
|
|
237
|
-
{
|
|
346
|
+
{option.label}
|
|
238
347
|
</button>
|
|
239
348
|
))}
|
|
240
349
|
</div>
|
|
241
350
|
</div>
|
|
242
|
-
<div className={tabStripClass}>
|
|
351
|
+
<div className={tabStripClass} aria-label="DM reply filter">
|
|
243
352
|
{TABS.map((tab) => {
|
|
244
353
|
const active = replyFilter === tab.value;
|
|
245
354
|
return (
|
|
@@ -259,16 +368,47 @@ function DmsRoute() {
|
|
|
259
368
|
})}
|
|
260
369
|
</div>
|
|
261
370
|
</header>
|
|
371
|
+
{replyError ? (
|
|
372
|
+
<p className={cx(timestampClass, "px-4 py-2 text-red-500")}>
|
|
373
|
+
{replyError}
|
|
374
|
+
</p>
|
|
375
|
+
) : null}
|
|
262
376
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
377
|
+
{loading && (items.length === 0 || switchingConversation) ? (
|
|
378
|
+
<FeedLoading
|
|
379
|
+
detail="Reading local conversations and reply state"
|
|
380
|
+
label="Loading messages"
|
|
381
|
+
/>
|
|
382
|
+
) : error ? (
|
|
383
|
+
<FeedError
|
|
384
|
+
action={
|
|
385
|
+
<button
|
|
386
|
+
className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
|
|
387
|
+
onClick={() => setRefreshTick((value) => value + 1)}
|
|
388
|
+
type="button"
|
|
389
|
+
>
|
|
390
|
+
Retry
|
|
391
|
+
</button>
|
|
392
|
+
}
|
|
393
|
+
message={error}
|
|
394
|
+
title="Could not load messages"
|
|
395
|
+
/>
|
|
396
|
+
) : items.length === 0 ? (
|
|
397
|
+
<FeedEmpty
|
|
398
|
+
detail="Sync DMs or broaden the filters to find a conversation."
|
|
399
|
+
label="No conversations in this view"
|
|
400
|
+
/>
|
|
401
|
+
) : (
|
|
402
|
+
<DmWorkspace
|
|
403
|
+
conversations={items}
|
|
404
|
+
onReplyDraftChange={setReplyDraft}
|
|
405
|
+
onReplySend={replyToConversation}
|
|
406
|
+
onSelectConversation={setSelectedConversationId}
|
|
407
|
+
replyDraft={replyDraft}
|
|
408
|
+
selectedConversation={selectedConversation}
|
|
409
|
+
selectedMessages={messages}
|
|
410
|
+
/>
|
|
411
|
+
)}
|
|
272
412
|
</>
|
|
273
413
|
);
|
|
274
414
|
}
|
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("");
|