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.
Files changed (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. 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
+ }
@@ -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
- textFieldClass,
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("0");
60
- const [minInfluenceScore, setMinInfluenceScore] = useState("0");
61
- const [sort, setSort] = useState<"recent" | "influence">("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
- throw error;
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
- <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
- />
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
- {(["recent", "influence"] as const).map((value) => (
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
- {value}
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