birdclaw 0.4.1 → 0.5.1

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