birdclaw 0.1.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 (74) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +389 -0
  4. package/bin/birdclaw.mjs +28 -0
  5. package/package.json +100 -0
  6. package/playwright.config.ts +31 -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 +25 -0
  11. package/public/robots.txt +3 -0
  12. package/src/cli-moderation.ts +210 -0
  13. package/src/cli.ts +450 -0
  14. package/src/components/AppNav.tsx +49 -0
  15. package/src/components/AvatarChip.tsx +47 -0
  16. package/src/components/DmWorkspace.tsx +246 -0
  17. package/src/components/EmbeddedTweetCard.tsx +44 -0
  18. package/src/components/InboxCard.tsx +136 -0
  19. package/src/components/ProfilePreview.tsx +55 -0
  20. package/src/components/ThemeSlider.tsx +124 -0
  21. package/src/components/TimelineCard.tsx +118 -0
  22. package/src/components/TweetMediaGrid.tsx +39 -0
  23. package/src/components/TweetRichText.tsx +85 -0
  24. package/src/lib/actions-transport.ts +173 -0
  25. package/src/lib/archive-finder.ts +128 -0
  26. package/src/lib/archive-import.ts +736 -0
  27. package/src/lib/avatar-cache.ts +184 -0
  28. package/src/lib/bird-actions.ts +200 -0
  29. package/src/lib/bird.ts +183 -0
  30. package/src/lib/blocklist.ts +119 -0
  31. package/src/lib/blocks-write.ts +118 -0
  32. package/src/lib/blocks.ts +326 -0
  33. package/src/lib/config.ts +152 -0
  34. package/src/lib/db.ts +326 -0
  35. package/src/lib/dms-live.ts +279 -0
  36. package/src/lib/inbox.ts +210 -0
  37. package/src/lib/mentions-export.ts +147 -0
  38. package/src/lib/mentions-live.ts +475 -0
  39. package/src/lib/moderation-target.ts +171 -0
  40. package/src/lib/moderation-write.ts +72 -0
  41. package/src/lib/mutes-write.ts +118 -0
  42. package/src/lib/mutes.ts +77 -0
  43. package/src/lib/openai.ts +86 -0
  44. package/src/lib/present.ts +20 -0
  45. package/src/lib/profile-hydration.ts +144 -0
  46. package/src/lib/profile-replies.ts +60 -0
  47. package/src/lib/queries.ts +725 -0
  48. package/src/lib/seed.ts +486 -0
  49. package/src/lib/sync-cache.ts +65 -0
  50. package/src/lib/theme-transition.ts +117 -0
  51. package/src/lib/theme.tsx +157 -0
  52. package/src/lib/tweet-render.ts +115 -0
  53. package/src/lib/types.ts +316 -0
  54. package/src/lib/ui.ts +270 -0
  55. package/src/lib/x-profile.ts +203 -0
  56. package/src/lib/x-web.ts +168 -0
  57. package/src/lib/xurl.ts +492 -0
  58. package/src/routeTree.gen.ts +282 -0
  59. package/src/router.tsx +20 -0
  60. package/src/routes/__root.tsx +64 -0
  61. package/src/routes/api/action.tsx +88 -0
  62. package/src/routes/api/avatar.tsx +41 -0
  63. package/src/routes/api/blocks.tsx +32 -0
  64. package/src/routes/api/inbox.tsx +35 -0
  65. package/src/routes/api/query.tsx +72 -0
  66. package/src/routes/api/status.tsx +15 -0
  67. package/src/routes/blocks.tsx +352 -0
  68. package/src/routes/dms.tsx +262 -0
  69. package/src/routes/inbox.tsx +201 -0
  70. package/src/routes/index.tsx +125 -0
  71. package/src/routes/mentions.tsx +125 -0
  72. package/src/styles.css +109 -0
  73. package/tsconfig.json +30 -0
  74. package/vite.config.ts +23 -0
@@ -0,0 +1,352 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { AvatarChip } from "#/components/AvatarChip";
4
+ import { formatCompactNumber } from "#/lib/present";
5
+ import type {
6
+ BlockItem,
7
+ BlockListResponse,
8
+ BlockSearchItem,
9
+ QueryEnvelope,
10
+ } from "#/lib/types";
11
+ import {
12
+ actionButtonClass,
13
+ cardHeaderClass,
14
+ contentCardClass,
15
+ cx,
16
+ errorCopyClass,
17
+ eyebrowClass,
18
+ heroControlsBlocksClass,
19
+ heroControlsClass,
20
+ heroCopyClass,
21
+ heroShellClass,
22
+ heroTitleClass,
23
+ identityBlockClass,
24
+ metaRowClass,
25
+ mutedDotClass,
26
+ pageWrapClass,
27
+ stackGridClass,
28
+ textFieldClass,
29
+ textFieldShortClass,
30
+ textFieldWideClass,
31
+ timestampClass,
32
+ } from "#/lib/ui";
33
+
34
+ export const Route = createFileRoute("/blocks")({
35
+ component: BlocksRoute,
36
+ });
37
+
38
+ function BlocksRoute() {
39
+ const [meta, setMeta] = useState<QueryEnvelope | null>(null);
40
+ const [accountId, setAccountId] = useState<string>("acct_primary");
41
+ const [search, setSearch] = useState("");
42
+ const [items, setItems] = useState<BlockItem[]>([]);
43
+ const [matches, setMatches] = useState<BlockSearchItem[]>([]);
44
+ const [refreshTick, setRefreshTick] = useState(0);
45
+ const [isSubmitting, setIsSubmitting] = useState(false);
46
+ const [isSyncing, setIsSyncing] = useState(false);
47
+ const [message, setMessage] = useState("");
48
+ const [error, setError] = useState("");
49
+ const hasAccountId = accountId.trim().length > 0;
50
+ const isReady = Boolean(meta);
51
+
52
+ useEffect(() => {
53
+ const controller = new AbortController();
54
+
55
+ fetch("/api/status", { signal: controller.signal })
56
+ .then((response) => response.json())
57
+ .then((data: QueryEnvelope) => {
58
+ setMeta(data);
59
+ setAccountId(data.accounts[0]?.id ?? "acct_primary");
60
+ setError("");
61
+ })
62
+ .catch((error: unknown) => {
63
+ if (error instanceof DOMException && error.name === "AbortError") {
64
+ return;
65
+ }
66
+ setError(
67
+ error instanceof Error
68
+ ? error.message
69
+ : "Unable to load blocklist status",
70
+ );
71
+ });
72
+
73
+ return () => {
74
+ controller.abort();
75
+ };
76
+ }, []);
77
+
78
+ useEffect(() => {
79
+ const controller = new AbortController();
80
+ const params = new URLSearchParams({
81
+ account: accountId,
82
+ limit: "12",
83
+ refresh: String(refreshTick),
84
+ });
85
+ if (search.trim()) {
86
+ params.set("search", search.trim());
87
+ }
88
+
89
+ fetch(`/api/blocks?${params.toString()}`, { signal: controller.signal })
90
+ .then((response) => response.json())
91
+ .then((data: BlockListResponse) => {
92
+ setItems(data.items);
93
+ setMatches(data.matches);
94
+ setError("");
95
+ })
96
+ .catch((error: unknown) => {
97
+ if (error instanceof DOMException && error.name === "AbortError") {
98
+ return;
99
+ }
100
+ setError(
101
+ error instanceof Error ? error.message : "Unable to load blocklist",
102
+ );
103
+ });
104
+
105
+ return () => {
106
+ controller.abort();
107
+ };
108
+ }, [accountId, refreshTick, search]);
109
+
110
+ useEffect(() => {
111
+ if (!hasAccountId) {
112
+ return;
113
+ }
114
+
115
+ const controller = new AbortController();
116
+ setIsSyncing(true);
117
+
118
+ fetch("/api/action", {
119
+ method: "POST",
120
+ headers: { "content-type": "application/json" },
121
+ body: JSON.stringify({
122
+ kind: "syncBlocks",
123
+ accountId,
124
+ }),
125
+ signal: controller.signal,
126
+ })
127
+ .then((response) => response.json())
128
+ .then(
129
+ (data: {
130
+ ok?: boolean;
131
+ synced?: boolean;
132
+ syncedCount?: number;
133
+ transport?: { ok?: boolean; output?: string };
134
+ }) => {
135
+ if (data.ok === false) {
136
+ setError(data.transport?.output ?? "Block sync failed");
137
+ return;
138
+ }
139
+ setRefreshTick((value) => value + 1);
140
+ if (data.transport?.output?.includes("disabled")) {
141
+ return;
142
+ }
143
+ setMessage(
144
+ data.transport?.output ??
145
+ `Synced ${data.syncedCount ?? 0} remote blocks`,
146
+ );
147
+ },
148
+ )
149
+ .catch((error: unknown) => {
150
+ if (error instanceof DOMException && error.name === "AbortError") {
151
+ return;
152
+ }
153
+ setError(error instanceof Error ? error.message : "Block sync failed");
154
+ })
155
+ .finally(() => setIsSyncing(false));
156
+
157
+ return () => {
158
+ controller.abort();
159
+ };
160
+ }, [accountId, hasAccountId]);
161
+
162
+ const subtitle = useMemo(() => {
163
+ if (!meta) {
164
+ return items.length > 0
165
+ ? `${items.length} blocked profiles in view · loading transport status...`
166
+ : "Loading local blocklist...";
167
+ }
168
+ if (isSyncing)
169
+ return `Syncing remote blocklist · ${meta.transport.statusText}`;
170
+ return `${items.length} blocked profiles in view · ${meta.transport.statusText}`;
171
+ }, [isSyncing, items.length, meta]);
172
+
173
+ async function submit(
174
+ kind: "blockProfile" | "unblockProfile",
175
+ query: string,
176
+ ) {
177
+ const normalized = query.trim();
178
+ if (!normalized) return;
179
+
180
+ setIsSubmitting(true);
181
+ setError("");
182
+ setMessage("");
183
+
184
+ try {
185
+ const response = await fetch("/api/action", {
186
+ method: "POST",
187
+ headers: { "content-type": "application/json" },
188
+ body: JSON.stringify({
189
+ kind,
190
+ accountId,
191
+ query: normalized,
192
+ }),
193
+ });
194
+ const data = (await response.json()) as {
195
+ profile?: { handle?: string };
196
+ transport?: { output?: string };
197
+ };
198
+
199
+ setMessage(
200
+ `${kind === "blockProfile" ? "Blocked" : "Unblocked"} @${
201
+ data.profile?.handle ?? normalized.replace(/^@/, "")
202
+ } · ${data.transport?.output ?? "local"}`,
203
+ );
204
+ setRefreshTick((value) => value + 1);
205
+ } catch (submitError) {
206
+ setError(
207
+ submitError instanceof Error
208
+ ? submitError.message
209
+ : "Blocklist action failed",
210
+ );
211
+ } finally {
212
+ setIsSubmitting(false);
213
+ }
214
+ }
215
+
216
+ 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>
225
+ </div>
226
+ <div className={cx(heroControlsClass, heroControlsBlocksClass)}>
227
+ <select
228
+ className={cx(textFieldClass, textFieldShortClass)}
229
+ disabled={!isReady}
230
+ onChange={(event) => setAccountId(event.target.value)}
231
+ value={accountId}
232
+ >
233
+ {meta?.accounts.map((account) => (
234
+ <option key={account.id} value={account.id}>
235
+ {account.handle}
236
+ </option>
237
+ ))}
238
+ </select>
239
+ <input
240
+ className={cx(textFieldClass, textFieldWideClass)}
241
+ disabled={!hasAccountId}
242
+ onChange={(event) => setSearch(event.target.value)}
243
+ placeholder="Handle, name, bio, or X URL"
244
+ value={search}
245
+ />
246
+ <button
247
+ className={actionButtonClass}
248
+ disabled={!hasAccountId || isSubmitting || !search.trim()}
249
+ onClick={() => void submit("blockProfile", search)}
250
+ type="button"
251
+ >
252
+ {isSubmitting ? "Working..." : "Block"}
253
+ </button>
254
+ </div>
255
+ </section>
256
+
257
+ {message ? <p className={timestampClass}>{message}</p> : null}
258
+ {error ? <p className={errorCopyClass}>{error}</p> : null}
259
+
260
+ {matches.length > 0 ? (
261
+ <section className={stackGridClass}>
262
+ {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}>
278
+ <span>@{match.profile.handle}</span>
279
+ <span className={mutedDotClass} />
280
+ <span>
281
+ {formatCompactNumber(match.profile.followersCount)}{" "}
282
+ followers
283
+ </span>
284
+ </div>
285
+ </div>
286
+ </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>
299
+ </div>
300
+ <p className="mt-2.5">{match.profile.bio}</p>
301
+ </article>
302
+ ))}
303
+ </section>
304
+ ) : null}
305
+
306
+ <section className={stackGridClass}>
307
+ {items.map((item) => (
308
+ <article
309
+ className={cx(contentCardClass, "block-card")}
310
+ key={item.accountId + item.profile.id}
311
+ >
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}>
323
+ <span>@{item.profile.handle}</span>
324
+ <span className={mutedDotClass} />
325
+ <span>{item.accountHandle}</span>
326
+ <span className={mutedDotClass} />
327
+ <span>
328
+ {formatCompactNumber(item.profile.followersCount)}{" "}
329
+ followers
330
+ </span>
331
+ </div>
332
+ </div>
333
+ </div>
334
+ <button
335
+ className={actionButtonClass}
336
+ onClick={() => void submit("unblockProfile", item.profile.id)}
337
+ type="button"
338
+ >
339
+ Unblock
340
+ </button>
341
+ </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
+ </article>
348
+ ))}
349
+ </section>
350
+ </main>
351
+ );
352
+ }
@@ -0,0 +1,262 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { DmWorkspace } from "#/components/DmWorkspace";
4
+ import type {
5
+ DmConversationItem,
6
+ DmMessageItem,
7
+ QueryEnvelope,
8
+ QueryResponse,
9
+ ReplyFilter,
10
+ } from "#/lib/types";
11
+ import {
12
+ cx,
13
+ dmPageClass,
14
+ eyebrowClass,
15
+ heroControlsClass,
16
+ heroControlsDmClass,
17
+ heroCopyClass,
18
+ heroShellClass,
19
+ heroShellDmClass,
20
+ heroTitleClass,
21
+ pageWrapClass,
22
+ segmentActiveClass,
23
+ segmentClass,
24
+ segmentedClass,
25
+ textFieldClass,
26
+ textFieldShortClass,
27
+ textFieldWideClass,
28
+ } from "#/lib/ui";
29
+
30
+ export const Route = createFileRoute("/dms")({
31
+ component: DmsRoute,
32
+ });
33
+
34
+ function DmsRoute() {
35
+ const [meta, setMeta] = useState<QueryEnvelope | null>(null);
36
+ const [items, setItems] = useState<DmConversationItem[]>([]);
37
+ const [messages, setMessages] = useState<DmMessageItem[]>([]);
38
+ const [selectedConversationId, setSelectedConversationId] = useState<
39
+ string | undefined
40
+ >();
41
+ const [replyFilter, setReplyFilter] = useState<ReplyFilter>("unreplied");
42
+ const [minFollowers, setMinFollowers] = useState("0");
43
+ const [minInfluenceScore, setMinInfluenceScore] = useState("0");
44
+ const [sort, setSort] = useState<"recent" | "influence">("recent");
45
+ const [search, setSearch] = useState("");
46
+ const [replyDraft, setReplyDraft] = useState("");
47
+ const [refreshTick, setRefreshTick] = useState(0);
48
+
49
+ useEffect(() => {
50
+ fetch("/api/status")
51
+ .then((response) => response.json())
52
+ .then((data: QueryEnvelope) => setMeta(data));
53
+ }, []);
54
+
55
+ useEffect(() => {
56
+ const controller = new AbortController();
57
+ const url = new URL("/api/query", window.location.origin);
58
+ url.searchParams.set("resource", "dms");
59
+ url.searchParams.set("replyFilter", replyFilter);
60
+ url.searchParams.set("minFollowers", minFollowers);
61
+ url.searchParams.set("minInfluenceScore", minInfluenceScore);
62
+ url.searchParams.set("refresh", String(refreshTick));
63
+ url.searchParams.set("sort", sort);
64
+ if (selectedConversationId) {
65
+ url.searchParams.set("conversationId", selectedConversationId);
66
+ }
67
+ if (search.trim()) {
68
+ url.searchParams.set("search", search.trim());
69
+ }
70
+
71
+ fetch(url, { signal: controller.signal })
72
+ .then((response) => response.json())
73
+ .then((data: QueryResponse) => {
74
+ const conversations = data.items as DmConversationItem[];
75
+ const nextSelected =
76
+ data.selectedConversation?.conversation.id ?? conversations[0]?.id;
77
+ setItems(conversations);
78
+ setSelectedConversationId((current) => {
79
+ if (!current) return nextSelected;
80
+ return conversations.some(
81
+ (conversation) => conversation.id === current,
82
+ )
83
+ ? current
84
+ : nextSelected;
85
+ });
86
+ setMessages(data.selectedConversation?.messages ?? []);
87
+ })
88
+ .catch((error: unknown) => {
89
+ if (error instanceof DOMException && error.name === "AbortError") {
90
+ return;
91
+ }
92
+ throw error;
93
+ });
94
+
95
+ return () => {
96
+ controller.abort();
97
+ };
98
+ }, [
99
+ minFollowers,
100
+ minInfluenceScore,
101
+ refreshTick,
102
+ replyFilter,
103
+ search,
104
+ selectedConversationId,
105
+ sort,
106
+ ]);
107
+
108
+ const selectedConversation =
109
+ items.find((item) => item.id === selectedConversationId) ?? null;
110
+
111
+ const subtitle = useMemo(() => {
112
+ if (!meta) return "Loading direct messages...";
113
+ return `${meta.stats.dms} conversations cached locally · filter by follower load or derived influence score`;
114
+ }, [meta]);
115
+
116
+ async function replyToConversation(conversationId: string) {
117
+ const text = replyDraft.trim();
118
+ if (!text || !selectedConversation) return;
119
+
120
+ const now = new Date().toISOString();
121
+ const accountRecord = meta?.accounts.find(
122
+ (account) => account.id === selectedConversation.accountId,
123
+ );
124
+ const senderHandle = (
125
+ accountRecord?.handle ?? selectedConversation.accountHandle
126
+ ).replace(/^@/, "");
127
+ const optimisticMessage: DmMessageItem = {
128
+ id: `optimistic-${now}`,
129
+ conversationId,
130
+ text,
131
+ createdAt: now,
132
+ direction: "outbound",
133
+ isReplied: true,
134
+ mediaCount: 0,
135
+ sender: {
136
+ id: `local-${selectedConversation.accountId}`,
137
+ handle: senderHandle,
138
+ displayName: accountRecord?.name ?? senderHandle,
139
+ bio: "",
140
+ followersCount: 0,
141
+ avatarHue: 18,
142
+ createdAt: now,
143
+ },
144
+ };
145
+ const previousMessages = messages;
146
+ const previousItems = items;
147
+
148
+ setReplyDraft("");
149
+ setMessages((current) => [...current, optimisticMessage]);
150
+ setItems((current) =>
151
+ current.map((item) =>
152
+ item.id === conversationId
153
+ ? {
154
+ ...item,
155
+ lastMessageAt: now,
156
+ lastMessagePreview: text,
157
+ needsReply: false,
158
+ unreadCount: 0,
159
+ }
160
+ : item,
161
+ ),
162
+ );
163
+
164
+ 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
+ }),
173
+ });
174
+
175
+ setSelectedConversationId(conversationId);
176
+ setRefreshTick((value) => value + 1);
177
+ } catch (error) {
178
+ setReplyDraft(text);
179
+ setMessages(previousMessages);
180
+ setItems(previousItems);
181
+ throw error;
182
+ }
183
+ }
184
+
185
+ 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>
195
+ </div>
196
+ <div className={cx(heroControlsClass, heroControlsDmClass)}>
197
+ <input
198
+ className={cx(textFieldClass, textFieldWideClass)}
199
+ onChange={(event) => setSearch(event.target.value)}
200
+ placeholder="Search DMs"
201
+ value={search}
202
+ />
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>
247
+ </div>
248
+ </section>
249
+
250
+ <DmWorkspace
251
+ conversations={items}
252
+ onReplyDraftChange={setReplyDraft}
253
+ onReplySend={replyToConversation}
254
+ onSelectConversation={setSelectedConversationId}
255
+ replyDraft={replyDraft}
256
+ selectedConversation={selectedConversation}
257
+ selectedMessages={messages}
258
+ />
259
+ </div>
260
+ </main>
261
+ );
262
+ }