birdclaw 0.5.1 → 0.7.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 +92 -1
- package/README.md +75 -5
- package/package.json +8 -2
- package/scripts/browser-perf.mjs +1 -0
- package/scripts/start-test-server.mjs +16 -3
- package/src/cli.ts +812 -37
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +37 -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 +818 -0
- package/src/components/ProfileAnalysisStream.tsx +428 -0
- package/src/components/ProfilePreview.tsx +120 -9
- package/src/components/SavedTimelineView.tsx +30 -8
- package/src/components/SyncNowButton.tsx +60 -25
- package/src/components/ThemeSlider.tsx +55 -50
- package/src/components/TimelineCard.tsx +240 -92
- package/src/components/TimelineRouteFrame.tsx +38 -8
- package/src/components/TweetMediaGrid.tsx +87 -38
- package/src/components/TweetRichText.tsx +45 -17
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +97 -13
- package/src/lib/account-sync-job.ts +666 -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 +1593 -1291
- package/src/lib/authored-live.ts +262 -204
- package/src/lib/avatar-cache.ts +208 -43
- package/src/lib/backup.ts +1536 -954
- package/src/lib/bird-actions.ts +139 -57
- package/src/lib/bird-command.ts +101 -28
- package/src/lib/bird.ts +582 -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/config.ts +35 -2
- package/src/lib/conversation-surface.ts +79 -48
- package/src/lib/data-sources.ts +219 -0
- package/src/lib/db.ts +95 -4
- 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/geocoding.ts +296 -0
- 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/location.ts +137 -0
- package/src/lib/media-fetch.ts +286 -213
- package/src/lib/mention-threads-live.ts +445 -288
- package/src/lib/mentions-live.ts +549 -354
- 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/network-map.ts +382 -0
- package/src/lib/openai.ts +84 -53
- package/src/lib/period-digest.ts +1317 -0
- package/src/lib/profile-affiliation-hydration.ts +93 -54
- package/src/lib/profile-analysis.ts +1272 -0
- package/src/lib/profile-bio-entities.ts +1 -1
- 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 +983 -203
- package/src/lib/research.ts +165 -120
- package/src/lib/search-discussion.ts +1016 -0
- package/src/lib/sqlite.ts +1 -0
- package/src/lib/timeline-collections-live.ts +204 -167
- package/src/lib/timeline-live.ts +325 -51
- package/src/lib/tweet-account-edges.ts +2 -0
- package/src/lib/tweet-lookup.ts +30 -19
- package/src/lib/tweet-render.ts +141 -1
- package/src/lib/tweet-search-live.ts +565 -0
- package/src/lib/types.ts +75 -3
- package/src/lib/ui.ts +31 -8
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +222 -149
- package/src/lib/whois.ts +166 -147
- package/src/lib/x-web.ts +102 -71
- package/src/lib/xurl-rate-limits.ts +267 -0
- package/src/lib/xurl.ts +1185 -405
- package/src/routeTree.gen.ts +273 -0
- package/src/routes/__root.tsx +24 -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/data-sources.tsx +24 -0
- 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/network-map.tsx +55 -0
- package/src/routes/api/period-digest.tsx +133 -0
- package/src/routes/api/profile-analysis.tsx +152 -0
- package/src/routes/api/profile-hydrate.tsx +31 -28
- package/src/routes/api/query.tsx +80 -55
- package/src/routes/api/search-discussion.tsx +169 -0
- package/src/routes/api/status.tsx +18 -10
- package/src/routes/api/sync.tsx +75 -29
- package/src/routes/api/xurl-rate-limits.tsx +24 -0
- package/src/routes/data-sources.tsx +255 -0
- package/src/routes/discuss.tsx +419 -0
- package/src/routes/dms.tsx +95 -28
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/network-map.tsx +1035 -0
- package/src/routes/profile-analyze.tsx +112 -0
- package/src/routes/profiles.$handle.tsx +228 -0
- package/src/routes/rate-limits.tsx +309 -0
- package/src/routes/today.tsx +455 -0
- package/src/styles.css +22 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import {
|
|
3
|
+
ChevronDown,
|
|
4
|
+
CheckCircle2,
|
|
5
|
+
Loader2,
|
|
6
|
+
RefreshCw,
|
|
7
|
+
Search,
|
|
8
|
+
Sparkles,
|
|
9
|
+
} from "lucide-react";
|
|
10
|
+
import {
|
|
11
|
+
type FormEvent,
|
|
12
|
+
useCallback,
|
|
13
|
+
useEffect,
|
|
14
|
+
useMemo,
|
|
15
|
+
useRef,
|
|
16
|
+
useState,
|
|
17
|
+
} from "react";
|
|
18
|
+
import { MarkdownViewer } from "#/components/MarkdownViewer";
|
|
19
|
+
import type {
|
|
20
|
+
SearchDiscussionContext,
|
|
21
|
+
SearchDiscussionRunResult,
|
|
22
|
+
SearchDiscussionSource,
|
|
23
|
+
SearchDiscussionStreamEvent,
|
|
24
|
+
} from "#/lib/search-discussion";
|
|
25
|
+
import type { TweetSearchMode } from "#/lib/tweet-search-live";
|
|
26
|
+
import {
|
|
27
|
+
cx,
|
|
28
|
+
errorCopyClass,
|
|
29
|
+
pageHeaderActionsClass,
|
|
30
|
+
pageHeaderClass,
|
|
31
|
+
pageHeaderRowClass,
|
|
32
|
+
pageSubtitleClass,
|
|
33
|
+
pageTitleClass,
|
|
34
|
+
primaryButtonClass,
|
|
35
|
+
searchFieldIconClass,
|
|
36
|
+
searchFieldInputClass,
|
|
37
|
+
searchFieldShellClass,
|
|
38
|
+
secondaryButtonClass,
|
|
39
|
+
selectFieldClass,
|
|
40
|
+
textFieldClass,
|
|
41
|
+
} from "#/lib/ui";
|
|
42
|
+
|
|
43
|
+
export const Route = createFileRoute("/discuss")({
|
|
44
|
+
component: DiscussRoute,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const sources: Array<{ value: SearchDiscussionSource; label: string }> = [
|
|
48
|
+
{ value: "search", label: "Live search" },
|
|
49
|
+
{ value: "all", label: "All local" },
|
|
50
|
+
{ value: "home", label: "Home" },
|
|
51
|
+
{ value: "mentions", label: "Mentions" },
|
|
52
|
+
{ value: "authored", label: "Authored" },
|
|
53
|
+
{ value: "likes", label: "Likes" },
|
|
54
|
+
{ value: "bookmarks", label: "Bookmarks" },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const modes: Array<{ value: TweetSearchMode; label: string }> = [
|
|
58
|
+
{ value: "auto", label: "Auto" },
|
|
59
|
+
{ value: "bird", label: "Bird" },
|
|
60
|
+
{ value: "xurl", label: "xurl" },
|
|
61
|
+
{ value: "local", label: "Local" },
|
|
62
|
+
];
|
|
63
|
+
const DISCUSS_SEARCH_LIMIT = 20_000;
|
|
64
|
+
const DISCUSS_MAX_PAGES = 200;
|
|
65
|
+
|
|
66
|
+
function discussionUrl(
|
|
67
|
+
query: string,
|
|
68
|
+
options: {
|
|
69
|
+
source: SearchDiscussionSource;
|
|
70
|
+
mode: TweetSearchMode;
|
|
71
|
+
includeDms: boolean;
|
|
72
|
+
question: string;
|
|
73
|
+
refresh: boolean;
|
|
74
|
+
},
|
|
75
|
+
) {
|
|
76
|
+
const url = new URL("/api/search-discussion", window.location.origin);
|
|
77
|
+
url.searchParams.set("query", query);
|
|
78
|
+
url.searchParams.set("source", options.source);
|
|
79
|
+
url.searchParams.set("mode", options.mode);
|
|
80
|
+
url.searchParams.set("includeDms", String(options.includeDms));
|
|
81
|
+
url.searchParams.set("limit", String(DISCUSS_SEARCH_LIMIT));
|
|
82
|
+
url.searchParams.set("maxPages", String(DISCUSS_MAX_PAGES));
|
|
83
|
+
if (options.question.trim()) {
|
|
84
|
+
url.searchParams.set("question", options.question.trim());
|
|
85
|
+
}
|
|
86
|
+
if (options.refresh) {
|
|
87
|
+
url.searchParams.set("refresh", "true");
|
|
88
|
+
}
|
|
89
|
+
return url;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function DropdownField<T extends string>({
|
|
93
|
+
label,
|
|
94
|
+
value,
|
|
95
|
+
options,
|
|
96
|
+
onChange,
|
|
97
|
+
}: {
|
|
98
|
+
label: string;
|
|
99
|
+
value: T;
|
|
100
|
+
options: Array<{ value: T; label: string }>;
|
|
101
|
+
onChange: (value: T) => void;
|
|
102
|
+
}) {
|
|
103
|
+
return (
|
|
104
|
+
<label className="relative min-w-0">
|
|
105
|
+
<span className="pointer-events-none absolute left-3 top-1.5 z-10 text-[11px] font-semibold uppercase tracking-[0.08em] text-[var(--ink-soft)]">
|
|
106
|
+
{label}
|
|
107
|
+
</span>
|
|
108
|
+
<select
|
|
109
|
+
aria-label={label}
|
|
110
|
+
className={cx(
|
|
111
|
+
selectFieldClass,
|
|
112
|
+
"h-[54px] rounded-2xl bg-[var(--bg)] pb-1 pl-3 pr-9 pt-5 font-semibold text-[var(--ink)]",
|
|
113
|
+
)}
|
|
114
|
+
value={value}
|
|
115
|
+
onChange={(event) => onChange(event.currentTarget.value as T)}
|
|
116
|
+
>
|
|
117
|
+
{options.map((item) => (
|
|
118
|
+
<option key={item.value} value={item.value}>
|
|
119
|
+
{item.label}
|
|
120
|
+
</option>
|
|
121
|
+
))}
|
|
122
|
+
</select>
|
|
123
|
+
<ChevronDown
|
|
124
|
+
aria-hidden="true"
|
|
125
|
+
className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[var(--ink-soft)]"
|
|
126
|
+
strokeWidth={2}
|
|
127
|
+
/>
|
|
128
|
+
</label>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function discussionRequestError(response: Response) {
|
|
133
|
+
const status = `${String(response.status)}${response.statusText ? ` ${response.statusText}` : ""}`;
|
|
134
|
+
let detail = "";
|
|
135
|
+
try {
|
|
136
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
137
|
+
if (contentType.includes("application/json")) {
|
|
138
|
+
const payload = (await response.json()) as {
|
|
139
|
+
error?: unknown;
|
|
140
|
+
message?: unknown;
|
|
141
|
+
};
|
|
142
|
+
if (typeof payload.message === "string") detail = payload.message;
|
|
143
|
+
else if (typeof payload.error === "string") detail = payload.error;
|
|
144
|
+
} else {
|
|
145
|
+
detail = (await response.text()).trim();
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
detail = "";
|
|
149
|
+
}
|
|
150
|
+
return new Error(
|
|
151
|
+
detail
|
|
152
|
+
? `Discussion request failed (${status}): ${detail}`
|
|
153
|
+
: `Discussion request failed (${status})`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function formatCounts(context: SearchDiscussionContext | null) {
|
|
158
|
+
if (!context) return "Live keyword search with local memory.";
|
|
159
|
+
const counts = context.counts;
|
|
160
|
+
const live = context.liveSearch
|
|
161
|
+
? context.liveSearch.ok
|
|
162
|
+
? `${context.liveSearch.source} ${String(context.liveSearch.count)} fetched`
|
|
163
|
+
: `${context.liveSearch.source} failed`
|
|
164
|
+
: "local";
|
|
165
|
+
return [
|
|
166
|
+
live,
|
|
167
|
+
`${String(counts.search)} search`,
|
|
168
|
+
`${String(counts.home + counts.mentions + counts.authored)} timeline`,
|
|
169
|
+
`${String(counts.likes + counts.bookmarks)} saved`,
|
|
170
|
+
context.includeDms ? `${String(counts.dms)} DMs` : null,
|
|
171
|
+
]
|
|
172
|
+
.filter(Boolean)
|
|
173
|
+
.join(" · ");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function useDiscussionStream(
|
|
177
|
+
query: string,
|
|
178
|
+
source: SearchDiscussionSource,
|
|
179
|
+
mode: TweetSearchMode,
|
|
180
|
+
includeDms: boolean,
|
|
181
|
+
question: string,
|
|
182
|
+
) {
|
|
183
|
+
const [markdown, setMarkdown] = useState("");
|
|
184
|
+
const [context, setContext] = useState<SearchDiscussionContext | null>(null);
|
|
185
|
+
const [result, setResult] = useState<SearchDiscussionRunResult | null>(null);
|
|
186
|
+
const [error, setError] = useState<string | null>(null);
|
|
187
|
+
const [loading, setLoading] = useState(false);
|
|
188
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
189
|
+
const requestIdRef = useRef(0);
|
|
190
|
+
|
|
191
|
+
const run = useCallback(
|
|
192
|
+
(refresh = false) => {
|
|
193
|
+
const trimmed = query.trim();
|
|
194
|
+
if (!trimmed) return;
|
|
195
|
+
abortRef.current?.abort();
|
|
196
|
+
const controller = new AbortController();
|
|
197
|
+
const requestId = requestIdRef.current + 1;
|
|
198
|
+
requestIdRef.current = requestId;
|
|
199
|
+
abortRef.current = controller;
|
|
200
|
+
const isActiveRequest = () =>
|
|
201
|
+
abortRef.current === controller &&
|
|
202
|
+
requestIdRef.current === requestId &&
|
|
203
|
+
!controller.signal.aborted;
|
|
204
|
+
setMarkdown("");
|
|
205
|
+
setContext(null);
|
|
206
|
+
setResult(null);
|
|
207
|
+
setError(null);
|
|
208
|
+
setLoading(true);
|
|
209
|
+
|
|
210
|
+
fetch(
|
|
211
|
+
discussionUrl(trimmed, {
|
|
212
|
+
source,
|
|
213
|
+
mode,
|
|
214
|
+
includeDms,
|
|
215
|
+
question,
|
|
216
|
+
refresh,
|
|
217
|
+
}),
|
|
218
|
+
{ signal: controller.signal },
|
|
219
|
+
)
|
|
220
|
+
.then(async (response) => {
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
throw await discussionRequestError(response);
|
|
223
|
+
}
|
|
224
|
+
if (!response.body) {
|
|
225
|
+
throw new Error("Discussion request failed: empty response body");
|
|
226
|
+
}
|
|
227
|
+
const reader = response.body.getReader();
|
|
228
|
+
const decoder = new TextDecoder();
|
|
229
|
+
let buffer = "";
|
|
230
|
+
const pump = (): Promise<void> =>
|
|
231
|
+
reader.read().then(({ done, value }) => {
|
|
232
|
+
if (!isActiveRequest()) return;
|
|
233
|
+
if (done) return;
|
|
234
|
+
buffer += decoder.decode(value, { stream: true });
|
|
235
|
+
let newline = buffer.indexOf("\n");
|
|
236
|
+
while (newline >= 0) {
|
|
237
|
+
const line = buffer.slice(0, newline).trim();
|
|
238
|
+
buffer = buffer.slice(newline + 1);
|
|
239
|
+
if (line) {
|
|
240
|
+
const event = JSON.parse(line) as SearchDiscussionStreamEvent;
|
|
241
|
+
if (!isActiveRequest()) return;
|
|
242
|
+
if (event.type === "start") {
|
|
243
|
+
setContext(event.context);
|
|
244
|
+
} else if (event.type === "delta") {
|
|
245
|
+
setMarkdown((current) => current + event.delta);
|
|
246
|
+
} else if (event.type === "done") {
|
|
247
|
+
setResult(event.result);
|
|
248
|
+
setContext(event.result.context);
|
|
249
|
+
setMarkdown(event.result.markdown);
|
|
250
|
+
} else if (event.type === "error") {
|
|
251
|
+
setError(event.error);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
newline = buffer.indexOf("\n");
|
|
255
|
+
}
|
|
256
|
+
return pump();
|
|
257
|
+
});
|
|
258
|
+
return pump();
|
|
259
|
+
})
|
|
260
|
+
.catch((cause: unknown) => {
|
|
261
|
+
if (!isActiveRequest()) return;
|
|
262
|
+
setError(
|
|
263
|
+
cause instanceof Error ? cause.message : "Discussion failed",
|
|
264
|
+
);
|
|
265
|
+
})
|
|
266
|
+
.finally(() => {
|
|
267
|
+
if (isActiveRequest()) {
|
|
268
|
+
setLoading(false);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
[includeDms, mode, query, question, source],
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
useEffect(() => () => abortRef.current?.abort(), []);
|
|
276
|
+
|
|
277
|
+
return { context, error, loading, markdown, result, run };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function DiscussRoute() {
|
|
281
|
+
const [query, setQuery] = useState("");
|
|
282
|
+
const [submittedQuery, setSubmittedQuery] = useState("");
|
|
283
|
+
const [question, setQuestion] = useState("");
|
|
284
|
+
const [source, setSource] = useState<SearchDiscussionSource>("search");
|
|
285
|
+
const [mode, setMode] = useState<TweetSearchMode>("xurl");
|
|
286
|
+
const [includeDms, setIncludeDms] = useState(false);
|
|
287
|
+
const pendingSubmitRef = useRef(false);
|
|
288
|
+
const { context, error, loading, markdown, result, run } =
|
|
289
|
+
useDiscussionStream(submittedQuery, source, mode, includeDms, question);
|
|
290
|
+
const sourceLabel = useMemo(
|
|
291
|
+
() => formatCounts(result?.context ?? context),
|
|
292
|
+
[context, result],
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
function submit(event: FormEvent) {
|
|
296
|
+
event.preventDefault();
|
|
297
|
+
const trimmed = query.trim();
|
|
298
|
+
if (!trimmed) return;
|
|
299
|
+
pendingSubmitRef.current = true;
|
|
300
|
+
setSubmittedQuery(trimmed);
|
|
301
|
+
if (trimmed === submittedQuery) {
|
|
302
|
+
pendingSubmitRef.current = false;
|
|
303
|
+
run(false);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
if (!submittedQuery || !pendingSubmitRef.current) return;
|
|
309
|
+
pendingSubmitRef.current = false;
|
|
310
|
+
run(false);
|
|
311
|
+
}, [run, submittedQuery]);
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<div className="flex min-h-screen flex-col">
|
|
315
|
+
<header className={pageHeaderClass}>
|
|
316
|
+
<div className={pageHeaderRowClass}>
|
|
317
|
+
<div className="min-w-0">
|
|
318
|
+
<h1 className={pageTitleClass}>Discuss</h1>
|
|
319
|
+
<p className={pageSubtitleClass}>{sourceLabel}</p>
|
|
320
|
+
</div>
|
|
321
|
+
<div className={pageHeaderActionsClass}>
|
|
322
|
+
<button
|
|
323
|
+
type="button"
|
|
324
|
+
className={secondaryButtonClass}
|
|
325
|
+
onClick={() => run(true)}
|
|
326
|
+
disabled={loading || !submittedQuery}
|
|
327
|
+
>
|
|
328
|
+
<RefreshCw
|
|
329
|
+
className={cx("size-4", loading && "animate-spin")}
|
|
330
|
+
aria-hidden="true"
|
|
331
|
+
/>
|
|
332
|
+
Refresh
|
|
333
|
+
</button>
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
<form
|
|
337
|
+
className="grid gap-2 px-4 pb-3 md:grid-cols-[minmax(220px,1fr)_minmax(180px,0.8fr)_auto]"
|
|
338
|
+
onSubmit={submit}
|
|
339
|
+
>
|
|
340
|
+
<label className={searchFieldShellClass}>
|
|
341
|
+
<Search className={searchFieldIconClass} strokeWidth={2} />
|
|
342
|
+
<input
|
|
343
|
+
className={searchFieldInputClass}
|
|
344
|
+
placeholder="Keywords"
|
|
345
|
+
value={query}
|
|
346
|
+
onChange={(event) => setQuery(event.currentTarget.value)}
|
|
347
|
+
/>
|
|
348
|
+
</label>
|
|
349
|
+
<input
|
|
350
|
+
className={textFieldClass}
|
|
351
|
+
placeholder="Optional question"
|
|
352
|
+
value={question}
|
|
353
|
+
onChange={(event) => setQuestion(event.currentTarget.value)}
|
|
354
|
+
/>
|
|
355
|
+
<button
|
|
356
|
+
type="submit"
|
|
357
|
+
className={primaryButtonClass}
|
|
358
|
+
disabled={loading || !query.trim()}
|
|
359
|
+
>
|
|
360
|
+
<Sparkles className="size-4" aria-hidden="true" />
|
|
361
|
+
Discuss
|
|
362
|
+
</button>
|
|
363
|
+
<div className="grid gap-2 md:col-span-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
|
|
364
|
+
<DropdownField
|
|
365
|
+
label="Source"
|
|
366
|
+
options={sources}
|
|
367
|
+
value={source}
|
|
368
|
+
onChange={setSource}
|
|
369
|
+
/>
|
|
370
|
+
<DropdownField
|
|
371
|
+
label="Mode"
|
|
372
|
+
options={modes}
|
|
373
|
+
value={mode}
|
|
374
|
+
onChange={setMode}
|
|
375
|
+
/>
|
|
376
|
+
<label className="inline-flex h-[54px] items-center gap-2 rounded-2xl border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-medium text-[var(--ink-soft)]">
|
|
377
|
+
<input
|
|
378
|
+
type="checkbox"
|
|
379
|
+
checked={includeDms}
|
|
380
|
+
onChange={(event) => setIncludeDms(event.currentTarget.checked)}
|
|
381
|
+
/>
|
|
382
|
+
DMs
|
|
383
|
+
</label>
|
|
384
|
+
</div>
|
|
385
|
+
</form>
|
|
386
|
+
</header>
|
|
387
|
+
|
|
388
|
+
{error ? <div className={errorCopyClass}>{error}</div> : null}
|
|
389
|
+
|
|
390
|
+
<div className="border-b border-[var(--line)] px-4 py-2 text-[13px] text-[var(--ink-soft)]">
|
|
391
|
+
<span className="inline-flex items-center gap-1">
|
|
392
|
+
{loading ? (
|
|
393
|
+
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
|
394
|
+
) : markdown ? (
|
|
395
|
+
<CheckCircle2 className="size-4" aria-hidden="true" />
|
|
396
|
+
) : (
|
|
397
|
+
<Sparkles className="size-4" aria-hidden="true" />
|
|
398
|
+
)}
|
|
399
|
+
{loading
|
|
400
|
+
? "Searching and streaming"
|
|
401
|
+
: result
|
|
402
|
+
? `${result.cached ? "Cached" : "Ready"} · ${result.context.query}`
|
|
403
|
+
: "Ready"}
|
|
404
|
+
</span>
|
|
405
|
+
</div>
|
|
406
|
+
|
|
407
|
+
{markdown ? (
|
|
408
|
+
<MarkdownViewer
|
|
409
|
+
context={(result?.context ?? context) as never}
|
|
410
|
+
markdown={markdown}
|
|
411
|
+
/>
|
|
412
|
+
) : (
|
|
413
|
+
<div className="px-4 py-5 text-[14px] text-[var(--ink-soft)]">
|
|
414
|
+
{loading ? "Waiting for the first tokens..." : "Search to begin."}
|
|
415
|
+
</div>
|
|
416
|
+
)}
|
|
417
|
+
</div>
|
|
418
|
+
);
|
|
419
|
+
}
|
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
|