birdclaw 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +50 -5
  3. package/package.json +3 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +376 -13
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +27 -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 +452 -0
  13. package/src/components/SyncNowButton.tsx +57 -25
  14. package/src/components/ThemeSlider.tsx +55 -50
  15. package/src/components/TimelineCard.tsx +225 -93
  16. package/src/components/TimelineRouteFrame.tsx +22 -8
  17. package/src/components/TweetMediaGrid.tsx +87 -38
  18. package/src/components/TweetRichText.tsx +15 -11
  19. package/src/components/account-selection.ts +64 -0
  20. package/src/components/useTimelineRouteData.ts +23 -7
  21. package/src/lib/account-sync-job.ts +654 -0
  22. package/src/lib/actions-transport.ts +216 -146
  23. package/src/lib/api-client.ts +128 -53
  24. package/src/lib/archive-finder.ts +78 -63
  25. package/src/lib/archive-import.ts +1364 -1300
  26. package/src/lib/authored-live.ts +261 -204
  27. package/src/lib/avatar-cache.ts +159 -44
  28. package/src/lib/backup.ts +1532 -951
  29. package/src/lib/bird-actions.ts +139 -57
  30. package/src/lib/bird-command.ts +101 -28
  31. package/src/lib/bird.ts +549 -194
  32. package/src/lib/blocklist.ts +40 -23
  33. package/src/lib/blocks-write.ts +129 -80
  34. package/src/lib/blocks.ts +165 -97
  35. package/src/lib/bookmark-sync-job.ts +250 -160
  36. package/src/lib/conversation-surface.ts +79 -48
  37. package/src/lib/db.ts +33 -3
  38. package/src/lib/dms-live.ts +720 -66
  39. package/src/lib/effect-runtime.ts +45 -0
  40. package/src/lib/follow-graph.ts +224 -180
  41. package/src/lib/http-effect.ts +222 -0
  42. package/src/lib/inbox.ts +74 -43
  43. package/src/lib/link-index.ts +88 -76
  44. package/src/lib/link-insights.ts +24 -0
  45. package/src/lib/link-preview-metadata.ts +472 -52
  46. package/src/lib/media-fetch.ts +286 -213
  47. package/src/lib/mention-threads-live.ts +352 -288
  48. package/src/lib/mentions-live.ts +390 -342
  49. package/src/lib/moderation-target.ts +102 -65
  50. package/src/lib/moderation-write.ts +77 -18
  51. package/src/lib/mutes-write.ts +129 -80
  52. package/src/lib/mutes.ts +8 -1
  53. package/src/lib/openai.ts +84 -53
  54. package/src/lib/period-digest.ts +953 -0
  55. package/src/lib/profile-affiliation-hydration.ts +93 -54
  56. package/src/lib/profile-hydration.ts +124 -72
  57. package/src/lib/profile-replies.ts +60 -43
  58. package/src/lib/profile-resolver.ts +402 -294
  59. package/src/lib/queries.ts +969 -199
  60. package/src/lib/research.ts +165 -120
  61. package/src/lib/sqlite.ts +1 -0
  62. package/src/lib/timeline-collections-live.ts +204 -167
  63. package/src/lib/timeline-live.ts +60 -39
  64. package/src/lib/tweet-lookup.ts +30 -19
  65. package/src/lib/types.ts +38 -1
  66. package/src/lib/ui.ts +30 -7
  67. package/src/lib/url-expansion.ts +226 -55
  68. package/src/lib/url-safety.ts +220 -0
  69. package/src/lib/web-sync.ts +216 -148
  70. package/src/lib/whois.ts +166 -147
  71. package/src/lib/x-web.ts +102 -71
  72. package/src/lib/xurl.ts +681 -411
  73. package/src/routeTree.gen.ts +42 -0
  74. package/src/routes/__root.tsx +25 -5
  75. package/src/routes/api/action.tsx +127 -78
  76. package/src/routes/api/avatar.tsx +39 -30
  77. package/src/routes/api/blocks.tsx +26 -23
  78. package/src/routes/api/conversation.tsx +25 -14
  79. package/src/routes/api/inbox.tsx +27 -21
  80. package/src/routes/api/link-insights.tsx +31 -25
  81. package/src/routes/api/link-preview.tsx +25 -21
  82. package/src/routes/api/period-digest.tsx +123 -0
  83. package/src/routes/api/profile-hydrate.tsx +31 -28
  84. package/src/routes/api/query.tsx +79 -55
  85. package/src/routes/api/status.tsx +18 -10
  86. package/src/routes/api/sync.tsx +75 -29
  87. package/src/routes/dms.tsx +95 -28
  88. package/src/routes/inbox.tsx +32 -19
  89. package/src/routes/today.tsx +441 -0
@@ -1,28 +1,64 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
- import { getWebSyncJob, parseWebSyncKind, startWebSync } from "#/lib/web-sync";
3
-
4
- function json(data: unknown, init?: ResponseInit) {
5
- return new Response(JSON.stringify(data), {
6
- ...init,
7
- headers: {
8
- "content-type": "application/json",
9
- ...init?.headers,
10
- },
11
- });
12
- }
2
+ import { Effect } from "effect";
3
+ import {
4
+ jsonResponse,
5
+ requestJsonEffect,
6
+ runRouteEffect,
7
+ sensitiveRequestErrorResponse,
8
+ } from "#/lib/http-effect";
9
+ import {
10
+ getWebSyncJob,
11
+ parseWebSyncKind,
12
+ startWebSync,
13
+ type WebSyncDmInbox,
14
+ type WebSyncOptions,
15
+ } from "#/lib/web-sync";
13
16
 
14
17
  function parseAccountId(value: unknown) {
15
18
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
16
19
  }
17
20
 
21
+ function parseDmInbox(value: unknown): WebSyncDmInbox | undefined {
22
+ return value === "all" || value === "accepted" || value === "requests"
23
+ ? value
24
+ : undefined;
25
+ }
26
+
27
+ function parsePositiveInteger(value: unknown, max: number) {
28
+ const parsed =
29
+ typeof value === "number"
30
+ ? value
31
+ : typeof value === "string"
32
+ ? Number(value)
33
+ : Number.NaN;
34
+ if (!Number.isInteger(parsed) || parsed <= 0) return undefined;
35
+ return Math.min(parsed, max);
36
+ }
37
+
38
+ function parseSyncOptions(kind: string, body: Record<string, unknown>) {
39
+ if (kind !== "dms") return {};
40
+ const options: WebSyncOptions = {};
41
+ const inbox = parseDmInbox(body.inbox);
42
+ const limit = parsePositiveInteger(body.limit, 1000);
43
+ const maxPages = parsePositiveInteger(body.maxPages, 250);
44
+ if (inbox) options.inbox = inbox;
45
+ if (limit) options.limit = limit;
46
+ if (maxPages) options.maxPages = maxPages;
47
+ if (typeof body.allPages === "boolean") options.allPages = body.allPages;
48
+ return options;
49
+ }
50
+
18
51
  export const Route = createFileRoute("/api/sync")({
19
52
  server: {
20
53
  handlers: {
21
54
  GET: ({ request }) => {
55
+ const denied = sensitiveRequestErrorResponse(request);
56
+ if (denied) return denied;
57
+
22
58
  const url = new URL(request.url);
23
59
  const id = url.searchParams.get("id");
24
60
  if (!id) {
25
- return json(
61
+ return jsonResponse(
26
62
  { ok: false, message: "Missing sync job id" },
27
63
  { status: 400 },
28
64
  );
@@ -30,30 +66,40 @@ export const Route = createFileRoute("/api/sync")({
30
66
 
31
67
  const job = getWebSyncJob(id);
32
68
  if (!job) {
33
- return json(
69
+ return jsonResponse(
34
70
  { ok: false, message: "Sync job not found" },
35
71
  { status: 404 },
36
72
  );
37
73
  }
38
74
 
39
- return json(job);
75
+ return jsonResponse(job);
40
76
  },
41
- POST: async ({ request }) => {
42
- const body = (await request.json().catch(() => ({}))) as Record<
43
- string,
44
- unknown
45
- >;
46
- const kind = parseWebSyncKind(body.kind);
47
- if (!kind) {
48
- return json(
49
- { ok: false, message: "Unknown sync kind" },
50
- { status: 400 },
51
- );
52
- }
77
+ POST: ({ request }) =>
78
+ runRouteEffect(
79
+ Effect.gen(function* () {
80
+ const denied = sensitiveRequestErrorResponse(request);
81
+ if (denied) return denied;
53
82
 
54
- const job = startWebSync(kind, parseAccountId(body.accountId));
55
- return json(job, { status: job.inProgress ? 202 : 200 });
56
- },
83
+ const body = yield* requestJsonEffect<Record<string, unknown>>(
84
+ request,
85
+ {},
86
+ );
87
+ const kind = parseWebSyncKind(body.kind);
88
+ if (!kind) {
89
+ return jsonResponse(
90
+ { ok: false, message: "Unknown sync kind" },
91
+ { status: 400 },
92
+ );
93
+ }
94
+
95
+ const job = startWebSync(
96
+ kind,
97
+ parseAccountId(body.accountId),
98
+ parseSyncOptions(kind, body),
99
+ );
100
+ return jsonResponse(job, { status: job.inProgress ? 202 : 200 });
101
+ }),
102
+ ),
57
103
  },
58
104
  },
59
105
  });
@@ -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
@@ -1,7 +1,9 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
2
  import { Sparkles } from "lucide-react";
3
3
  import { useEffect, useMemo, useState } from "react";
4
+ import { useSelectedAccountId } from "#/components/account-selection";
4
5
  import { InboxCard } from "#/components/InboxCard";
6
+ import { postAction } from "#/lib/api-client";
5
7
  import type {
6
8
  InboxItem,
7
9
  InboxKind,
@@ -48,7 +50,9 @@ function InboxRoute() {
48
50
  const [activeReplyId, setActiveReplyId] = useState<string | null>(null);
49
51
  const [replyDraft, setReplyDraft] = useState("");
50
52
  const [isSendingReply, setIsSendingReply] = useState(false);
53
+ const [replyError, setReplyError] = useState<string | null>(null);
51
54
  const [stats, setStats] = useState<InboxResponse["stats"] | null>(null);
55
+ const selectedAccountId = useSelectedAccountId(meta?.accounts);
52
56
 
53
57
  useEffect(() => {
54
58
  fetch("/api/status")
@@ -61,6 +65,9 @@ function InboxRoute() {
61
65
  url.searchParams.set("kind", kind);
62
66
  url.searchParams.set("minScore", minScore);
63
67
  url.searchParams.set("refresh", String(refreshTick));
68
+ if (selectedAccountId) {
69
+ url.searchParams.set("account", selectedAccountId);
70
+ }
64
71
  if (hideLowSignal) {
65
72
  url.searchParams.set("hideLowSignal", "1");
66
73
  }
@@ -71,7 +78,7 @@ function InboxRoute() {
71
78
  setItems(data.items);
72
79
  setStats(data.stats);
73
80
  });
74
- }, [hideLowSignal, kind, minScore, refreshTick]);
81
+ }, [hideLowSignal, kind, minScore, refreshTick, selectedAccountId]);
75
82
 
76
83
  const subtitle = useMemo(() => {
77
84
  if (!meta || !stats) return "Ranking unreplied mentions and DMs...";
@@ -87,6 +94,7 @@ function InboxRoute() {
87
94
  body: JSON.stringify({
88
95
  kind: "scoreInbox",
89
96
  scoreKind: kind,
97
+ account: selectedAccountId,
90
98
  limit: 8,
91
99
  }),
92
100
  });
@@ -99,28 +107,27 @@ function InboxRoute() {
99
107
  async function sendReply(item: InboxItem) {
100
108
  if (!replyDraft.trim()) return;
101
109
  setIsSendingReply(true);
110
+ setReplyError(null);
102
111
  try {
103
- await fetch("/api/action", {
104
- method: "POST",
105
- headers: { "content-type": "application/json" },
106
- body: JSON.stringify(
107
- item.entityKind === "dm"
108
- ? {
109
- kind: "replyDm",
110
- conversationId: item.entityId,
111
- text: replyDraft,
112
- }
113
- : {
114
- kind: "replyTweet",
115
- accountId: item.accountId,
116
- tweetId: item.entityId,
117
- text: replyDraft,
118
- },
119
- ),
120
- });
112
+ await postAction(
113
+ item.entityKind === "dm"
114
+ ? {
115
+ kind: "replyDm",
116
+ conversationId: item.entityId,
117
+ text: replyDraft,
118
+ }
119
+ : {
120
+ kind: "replyTweet",
121
+ accountId: item.accountId,
122
+ tweetId: item.entityId,
123
+ text: replyDraft,
124
+ },
125
+ );
121
126
  setReplyDraft("");
122
127
  setActiveReplyId(null);
123
128
  setRefreshTick((value) => value + 1);
129
+ } catch (error) {
130
+ setReplyError(error instanceof Error ? error.message : "Reply failed");
124
131
  } finally {
125
132
  setIsSendingReply(false);
126
133
  }
@@ -181,6 +188,11 @@ function InboxRoute() {
181
188
  })}
182
189
  </div>
183
190
  </header>
191
+ {replyError ? (
192
+ <p className={cx(timestampClass, "px-4 py-2 text-red-500")}>
193
+ {replyError}
194
+ </p>
195
+ ) : null}
184
196
  <section className={feedClass}>
185
197
  {items.length === 0 ? (
186
198
  <div className={emptyStateClass}>Inbox clear.</div>
@@ -193,6 +205,7 @@ function InboxRoute() {
193
205
  onReplyChange={setReplyDraft}
194
206
  onReplySend={() => void sendReply(item)}
195
207
  onReplyToggle={() => {
208
+ setReplyError(null);
196
209
  if (activeReplyId === item.id) {
197
210
  setActiveReplyId(null);
198
211
  setReplyDraft("");