convex-feedback-ui 0.1.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 (118) hide show
  1. package/README.md +187 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/native/context.d.ts +10 -0
  4. package/dist/native/context.d.ts.map +1 -0
  5. package/dist/native/context.js +33 -0
  6. package/dist/native/context.js.map +1 -0
  7. package/dist/native/index.d.ts +7 -0
  8. package/dist/native/index.d.ts.map +1 -0
  9. package/dist/native/index.js +6 -0
  10. package/dist/native/index.js.map +1 -0
  11. package/dist/native/primitives.d.ts +94 -0
  12. package/dist/native/primitives.d.ts.map +1 -0
  13. package/dist/native/primitives.js +303 -0
  14. package/dist/native/primitives.js.map +1 -0
  15. package/dist/native/screen.d.ts +3 -0
  16. package/dist/native/screen.d.ts.map +1 -0
  17. package/dist/native/screen.js +284 -0
  18. package/dist/native/screen.js.map +1 -0
  19. package/dist/react/context.d.ts +22 -0
  20. package/dist/react/context.d.ts.map +1 -0
  21. package/dist/react/context.js +41 -0
  22. package/dist/react/context.js.map +1 -0
  23. package/dist/react/index.d.ts +7 -0
  24. package/dist/react/index.d.ts.map +1 -0
  25. package/dist/react/index.js +6 -0
  26. package/dist/react/index.js.map +1 -0
  27. package/dist/react/primitives.d.ts +105 -0
  28. package/dist/react/primitives.d.ts.map +1 -0
  29. package/dist/react/primitives.js +217 -0
  30. package/dist/react/primitives.js.map +1 -0
  31. package/dist/react/screen.d.ts +6 -0
  32. package/dist/react/screen.d.ts.map +1 -0
  33. package/dist/react/screen.js +152 -0
  34. package/dist/react/screen.js.map +1 -0
  35. package/dist/react/styles.css +299 -0
  36. package/dist/shared/messages.d.ts +4 -0
  37. package/dist/shared/messages.d.ts.map +1 -0
  38. package/dist/shared/messages.js +85 -0
  39. package/dist/shared/messages.js.map +1 -0
  40. package/dist/shared/render.d.ts +4 -0
  41. package/dist/shared/render.d.ts.map +1 -0
  42. package/dist/shared/render.js +6 -0
  43. package/dist/shared/render.js.map +1 -0
  44. package/dist/shared/theme.d.ts +4 -0
  45. package/dist/shared/theme.d.ts.map +1 -0
  46. package/dist/shared/theme.js +23 -0
  47. package/dist/shared/theme.js.map +1 -0
  48. package/dist/shared/types/board.d.ts +43 -0
  49. package/dist/shared/types/board.d.ts.map +1 -0
  50. package/dist/shared/types/board.js +2 -0
  51. package/dist/shared/types/board.js.map +1 -0
  52. package/dist/shared/types/comment.d.ts +85 -0
  53. package/dist/shared/types/comment.d.ts.map +1 -0
  54. package/dist/shared/types/comment.js +2 -0
  55. package/dist/shared/types/comment.js.map +1 -0
  56. package/dist/shared/types/context.d.ts +68 -0
  57. package/dist/shared/types/context.d.ts.map +1 -0
  58. package/dist/shared/types/context.js +2 -0
  59. package/dist/shared/types/context.js.map +1 -0
  60. package/dist/shared/types/entry.d.ts +41 -0
  61. package/dist/shared/types/entry.d.ts.map +1 -0
  62. package/dist/shared/types/entry.js +2 -0
  63. package/dist/shared/types/entry.js.map +1 -0
  64. package/dist/shared/types/form.d.ts +25 -0
  65. package/dist/shared/types/form.d.ts.map +1 -0
  66. package/dist/shared/types/form.js +2 -0
  67. package/dist/shared/types/form.js.map +1 -0
  68. package/dist/shared/types/helpers.d.ts +2 -0
  69. package/dist/shared/types/helpers.d.ts.map +1 -0
  70. package/dist/shared/types/helpers.js +2 -0
  71. package/dist/shared/types/helpers.js.map +1 -0
  72. package/dist/shared/types/index.d.ts +10 -0
  73. package/dist/shared/types/index.d.ts.map +1 -0
  74. package/dist/shared/types/index.js +2 -0
  75. package/dist/shared/types/index.js.map +1 -0
  76. package/dist/shared/types/messages.d.ts +117 -0
  77. package/dist/shared/types/messages.d.ts.map +1 -0
  78. package/dist/shared/types/messages.js +2 -0
  79. package/dist/shared/types/messages.js.map +1 -0
  80. package/dist/shared/types/render.d.ts +12 -0
  81. package/dist/shared/types/render.d.ts.map +1 -0
  82. package/dist/shared/types/render.js +2 -0
  83. package/dist/shared/types/render.js.map +1 -0
  84. package/dist/shared/types/screen.d.ts +95 -0
  85. package/dist/shared/types/screen.d.ts.map +1 -0
  86. package/dist/shared/types/screen.js +2 -0
  87. package/dist/shared/types/screen.js.map +1 -0
  88. package/dist/shared/types/theme.d.ts +48 -0
  89. package/dist/shared/types/theme.d.ts.map +1 -0
  90. package/dist/shared/types/theme.js +2 -0
  91. package/dist/shared/types/theme.js.map +1 -0
  92. package/package.json +58 -0
  93. package/src/native/README.md +17 -0
  94. package/src/native/context.tsx +74 -0
  95. package/src/native/index.ts +39 -0
  96. package/src/native/primitives.tsx +730 -0
  97. package/src/native/screen.tsx +831 -0
  98. package/src/react/README.md +7 -0
  99. package/src/react/context.tsx +83 -0
  100. package/src/react/index.ts +42 -0
  101. package/src/react/primitives.tsx +589 -0
  102. package/src/react/screen.tsx +633 -0
  103. package/src/react/styles.css +299 -0
  104. package/src/shared/README.md +9 -0
  105. package/src/shared/messages.ts +99 -0
  106. package/src/shared/render.ts +12 -0
  107. package/src/shared/theme.ts +27 -0
  108. package/src/shared/types/board.ts +48 -0
  109. package/src/shared/types/comment.ts +101 -0
  110. package/src/shared/types/context.ts +81 -0
  111. package/src/shared/types/entry.ts +48 -0
  112. package/src/shared/types/form.ts +27 -0
  113. package/src/shared/types/helpers.ts +1 -0
  114. package/src/shared/types/index.ts +45 -0
  115. package/src/shared/types/messages.ts +160 -0
  116. package/src/shared/types/render.ts +12 -0
  117. package/src/shared/types/screen.ts +123 -0
  118. package/src/shared/types/theme.ts +61 -0
@@ -0,0 +1,633 @@
1
+ "use client";
2
+
3
+ import type { CommentSort, EntryKind, EntrySort } from "convex-feedback";
4
+ import type { FeedbackHooks } from "convex-feedback/react";
5
+ import { useMemo, useState, type SyntheticEvent } from "react";
6
+
7
+ import type {
8
+ FeedbackColorProps,
9
+ FeedbackScreenCommentBranchProps,
10
+ FeedbackScreenEntryCardProps,
11
+ FeedbackScreenEntryDetailProps,
12
+ FeedbackScreenEntryModalProps,
13
+ FeedbackScreenReplyListProps,
14
+ FeedbackScreenRootProps,
15
+ } from "../shared/types/";
16
+ import { FeedbackProvider, useFeedbackUi } from "./context.js";
17
+ import {
18
+ Comment,
19
+ FeedbackBoard,
20
+ FeedbackEntry,
21
+ FeedbackForm,
22
+ } from "./primitives.js";
23
+
24
+ const entryKinds: readonly EntryKind[] = [
25
+ "feedback",
26
+ "feature_request",
27
+ "bug_report",
28
+ ];
29
+
30
+ export interface FeedbackScreenProps
31
+ extends FeedbackColorProps, FeedbackScreenRootProps {
32
+ className?: string | undefined;
33
+ }
34
+
35
+ export function FeedbackScreen({
36
+ hooks,
37
+ messages,
38
+ theme,
39
+ unstyled,
40
+ entrySort = "top",
41
+ commentSort = "top",
42
+ enabledKinds = entryKinds,
43
+ maxCommentDepth = 5,
44
+ transformComments,
45
+ renderActor,
46
+ className,
47
+ ...colors
48
+ }: FeedbackScreenProps) {
49
+ return (
50
+ <FeedbackProvider messages={messages} theme={theme} unstyled={unstyled}>
51
+ <FeedbackScreenInner
52
+ hooks={hooks}
53
+ entrySort={entrySort}
54
+ commentSort={commentSort}
55
+ enabledKinds={enabledKinds}
56
+ maxCommentDepth={maxCommentDepth}
57
+ transformComments={transformComments}
58
+ renderActor={renderActor}
59
+ className={className}
60
+ {...colors}
61
+ />
62
+ </FeedbackProvider>
63
+ );
64
+ }
65
+
66
+ interface FeedbackScreenInnerProps extends FeedbackColorProps {
67
+ hooks: FeedbackHooks;
68
+ entrySort: EntrySort;
69
+ commentSort: CommentSort;
70
+ enabledKinds: readonly EntryKind[];
71
+ maxCommentDepth: number;
72
+ transformComments?: FeedbackScreenProps["transformComments"];
73
+ renderActor?: FeedbackScreenProps["renderActor"];
74
+ className?: string | undefined;
75
+ }
76
+
77
+ function FeedbackScreenInner({
78
+ hooks,
79
+ entrySort,
80
+ commentSort,
81
+ enabledKinds,
82
+ maxCommentDepth,
83
+ transformComments,
84
+ renderActor,
85
+ className,
86
+ ...colors
87
+ }: FeedbackScreenInnerProps) {
88
+ const { messages } = useFeedbackUi();
89
+ const [searchQuery, setSearchQuery] = useState("");
90
+ const [showForm, setShowForm] = useState(false);
91
+ const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
92
+ const list = hooks.useEntries({ sort: entrySort, kinds: enabledKinds });
93
+ const searchResults = hooks.useSearchEntries({
94
+ searchQuery,
95
+ kinds: enabledKinds,
96
+ });
97
+
98
+ if (selectedEntryId !== null) {
99
+ return (
100
+ <FeedbackBoard.Root className={className} {...colors}>
101
+ <EntryDetail
102
+ hooks={hooks}
103
+ entryId={selectedEntryId}
104
+ onBack={() => setSelectedEntryId(null)}
105
+ commentSort={commentSort}
106
+ maxCommentDepth={maxCommentDepth}
107
+ transformComments={transformComments}
108
+ renderActor={renderActor}
109
+ />
110
+ </FeedbackBoard.Root>
111
+ );
112
+ }
113
+
114
+ const searching = searchQuery.trim().length > 0;
115
+ const entries = searching ? searchResults : list.results;
116
+ const loading = searching
117
+ ? searchResults === undefined
118
+ : list.status === "LoadingFirstPage";
119
+
120
+ return (
121
+ <FeedbackBoard.Root className={className} {...colors}>
122
+ <FeedbackBoard.Header>
123
+ <div>
124
+ <FeedbackBoard.Title />
125
+ <p className="cf-board__subtitle">{messages.board.subtitle}</p>
126
+ </div>
127
+ <button
128
+ type="button"
129
+ className="cf-button cf-button--primary"
130
+ onClick={() => setShowForm((current) => !current)}
131
+ >
132
+ {showForm ? messages.form.cancel : messages.board.createEntry}
133
+ </button>
134
+ </FeedbackBoard.Header>
135
+
136
+ {showForm ? (
137
+ <CreateEntryForm
138
+ hooks={hooks}
139
+ enabledKinds={enabledKinds}
140
+ onCreated={(entryId) => {
141
+ setShowForm(false);
142
+ setSelectedEntryId(entryId);
143
+ }}
144
+ />
145
+ ) : null}
146
+
147
+ <FeedbackBoard.Search
148
+ value={searchQuery}
149
+ onValueChange={setSearchQuery}
150
+ />
151
+
152
+ {loading ? <p className="cf-state">{messages.board.loading}</p> : null}
153
+ {!loading && entries !== undefined && entries.length === 0 ? (
154
+ <p className="cf-state">
155
+ {searching
156
+ ? messages.board.noSearchResults
157
+ : messages.board.noEntries}
158
+ </p>
159
+ ) : null}
160
+
161
+ <FeedbackBoard.List>
162
+ {(entries ?? []).map((entry) => (
163
+ <EntryCard
164
+ key={entry.id}
165
+ entry={entry}
166
+ hooks={hooks}
167
+ onOpen={() => setSelectedEntryId(entry.id)}
168
+ />
169
+ ))}
170
+ </FeedbackBoard.List>
171
+
172
+ {!searching && list.status === "CanLoadMore" ? (
173
+ <button
174
+ type="button"
175
+ className="cf-button"
176
+ onClick={() => list.loadMore(hooks.pageSizes.entries)}
177
+ >
178
+ {messages.board.loadMore}
179
+ </button>
180
+ ) : null}
181
+ </FeedbackBoard.Root>
182
+ );
183
+ }
184
+
185
+ function EntryCard({ entry, hooks, onOpen }: FeedbackScreenEntryCardProps) {
186
+ const setUpvote = hooks.useSetEntryUpvote();
187
+ return (
188
+ <FeedbackEntry.Root entry={entry}>
189
+ <FeedbackEntry.Upvote
190
+ onToggle={(active) =>
191
+ void setUpvote({ entryId: entry.id, desiredState: active })
192
+ }
193
+ />
194
+ <FeedbackEntry.Content
195
+ onClick={onOpen}
196
+ onKeyDown={(event) => {
197
+ if (event.key === "Enter" || event.key === " ") {
198
+ event.preventDefault();
199
+ onOpen();
200
+ }
201
+ }}
202
+ role="button"
203
+ tabIndex={0}
204
+ >
205
+ <div className="cf-entry__meta">
206
+ <FeedbackEntry.Kind />
207
+ <FeedbackEntry.Status />
208
+ <FeedbackEntry.CommentCount />
209
+ </div>
210
+ <FeedbackEntry.Title />
211
+ <FeedbackEntry.Body />
212
+ </FeedbackEntry.Content>
213
+ </FeedbackEntry.Root>
214
+ );
215
+ }
216
+
217
+ function CreateEntryForm({
218
+ hooks,
219
+ enabledKinds,
220
+ onCreated,
221
+ }: FeedbackScreenEntryModalProps) {
222
+ const { messages } = useFeedbackUi();
223
+ const [kind, setKind] = useState<EntryKind>(enabledKinds[0] ?? "feedback");
224
+ const [title, setTitle] = useState("");
225
+ const [body, setBody] = useState("");
226
+ const [submitting, setSubmitting] = useState(false);
227
+ const createEntry = hooks.useCreateEntry();
228
+ const similar = hooks.useSimilarEntries({ title, body, kind });
229
+ const [confirmingDuplicate, setConfirmingDuplicate] = useState(false);
230
+
231
+ const submit = (event: SyntheticEvent<HTMLFormElement>) => {
232
+ event.preventDefault();
233
+
234
+ if (
235
+ similar === undefined ||
236
+ title.trim().length === 0 ||
237
+ body.trim().length === 0
238
+ ) {
239
+ return;
240
+ }
241
+
242
+ if (suggestions.length > 0) {
243
+ setConfirmingDuplicate(true);
244
+ return;
245
+ }
246
+
247
+ void create();
248
+ };
249
+
250
+ const create = async () => {
251
+ if (submitting) return;
252
+
253
+ setSubmitting(true);
254
+
255
+ try {
256
+ const entryId = await createEntry({
257
+ kind,
258
+ title,
259
+ body,
260
+ });
261
+
262
+ onCreated(entryId);
263
+ } finally {
264
+ setSubmitting(false);
265
+ }
266
+ };
267
+
268
+ const suggestions = useMemo(() => {
269
+ if (similar === undefined) return [];
270
+ return [...similar.exact, ...similar.similar];
271
+ }, [similar]);
272
+
273
+ return (
274
+ <FeedbackForm.Root onSubmit={submit}>
275
+ <label className="cf-field">
276
+ <span>{messages.form.kind}</span>
277
+ <FeedbackForm.Select
278
+ value={kind}
279
+ onChange={(event) => setKind(event.currentTarget.value as EntryKind)}
280
+ >
281
+ {enabledKinds.map((value) => (
282
+ <option key={value} value={value}>
283
+ {messages.kinds[value]}
284
+ </option>
285
+ ))}
286
+ </FeedbackForm.Select>
287
+ </label>
288
+ <label className="cf-field">
289
+ <span>{messages.form.title}</span>
290
+ <FeedbackForm.Input
291
+ value={title}
292
+ onChange={(event) => setTitle(event.currentTarget.value)}
293
+ placeholder={messages.form.titlePlaceholder}
294
+ required
295
+ />
296
+ </label>
297
+ <label className="cf-field">
298
+ <span>{messages.form.body}</span>
299
+ <FeedbackForm.Textarea
300
+ value={body}
301
+ onChange={(event) => setBody(event.currentTarget.value)}
302
+ placeholder={messages.form.bodyPlaceholder}
303
+ rows={5}
304
+ required
305
+ />
306
+ </label>
307
+ {suggestions.length > 0 && (
308
+ <aside className="cf-duplicates">
309
+ <strong>
310
+ {similar?.exact.length
311
+ ? messages.form.exactDuplicate
312
+ : messages.form.possibleDuplicates}
313
+ </strong>
314
+
315
+ <div className="cf-duplicates__list">
316
+ {suggestions.map((entry) => (
317
+ <div key={entry.id} className="cf-duplicate">
318
+ <div className="cf-entry__meta">
319
+ <span className="cf-entry__kind">
320
+ {messages.kinds[entry.kind]}
321
+ </span>
322
+
323
+ <span className="cf-status">
324
+ {messages.statuses[entry.status]}
325
+ </span>
326
+ </div>
327
+
328
+ <strong>{entry.title}</strong>
329
+
330
+ <span className="cf-duplicate__body">{entry.body}</span>
331
+
332
+ <span className="cf-duplicate__meta">
333
+ ▲ {entry.upvoteCount} ·{" "}
334
+ {messages.entry.comments(entry.commentCount)}
335
+ </span>
336
+ </div>
337
+ ))}
338
+ </div>
339
+ </aside>
340
+ )}
341
+ <FeedbackForm.Submit
342
+ submitting={submitting}
343
+ disabled={confirmingDuplicate || submitting}
344
+ />
345
+ {confirmingDuplicate && (
346
+ <div className="cf-confirm-backdrop">
347
+ <div
348
+ className="cf-confirm"
349
+ role="alertdialog"
350
+ aria-modal="true"
351
+ aria-labelledby="cf-duplicate-confirm-title"
352
+ >
353
+ <strong id="cf-duplicate-confirm-title">
354
+ {similar?.exact.length
355
+ ? messages.form.exactDuplicate
356
+ : messages.form.possibleDuplicates}
357
+ </strong>
358
+
359
+ <p>{messages.form.duplicateWarning}</p>
360
+
361
+ <div className="cf-inline-actions">
362
+ <button
363
+ type="button"
364
+ className="cf-button"
365
+ onClick={() => setConfirmingDuplicate(false)}
366
+ >
367
+ {messages.form.cancel}
368
+ </button>
369
+
370
+ <button
371
+ type="button"
372
+ className="cf-button cf-button--primary"
373
+ onClick={() => {
374
+ setConfirmingDuplicate(false);
375
+ void create();
376
+ }}
377
+ >
378
+ {messages.form.submitAnyway}
379
+ </button>
380
+ </div>
381
+ </div>
382
+ </div>
383
+ )}
384
+ </FeedbackForm.Root>
385
+ );
386
+ }
387
+
388
+ function EntryDetail({
389
+ hooks,
390
+ entryId,
391
+ onBack,
392
+ commentSort,
393
+ maxCommentDepth,
394
+ transformComments,
395
+ renderActor,
396
+ }: FeedbackScreenEntryDetailProps) {
397
+ const { messages } = useFeedbackUi();
398
+ const entry = hooks.useEntry(entryId);
399
+ const setUpvote = hooks.useSetEntryUpvote();
400
+ const comments = hooks.useComments({ entryId, sort: commentSort });
401
+ const createComment = hooks.useCreateComment();
402
+ const [body, setBody] = useState("");
403
+
404
+ const visibleComments = useMemo(
405
+ () => transformComments?.(comments.results) ?? comments.results,
406
+ [comments.results, transformComments],
407
+ );
408
+
409
+ if (entry === undefined)
410
+ return <p className="cf-state">{messages.board.loading}</p>;
411
+ if (entry === null)
412
+ return (
413
+ <button type="button" className="cf-button" onClick={onBack}>
414
+ {messages.entry.back}
415
+ </button>
416
+ );
417
+
418
+ return (
419
+ <div className="cf-detail">
420
+ <button
421
+ type="button"
422
+ className="cf-button cf-button--link"
423
+ onClick={onBack}
424
+ >
425
+ {messages.entry.back}
426
+ </button>
427
+ <FeedbackEntry.Root entry={entry}>
428
+ <FeedbackEntry.Upvote
429
+ onToggle={(active) =>
430
+ void setUpvote({ entryId, desiredState: active })
431
+ }
432
+ />
433
+ <FeedbackEntry.Content>
434
+ <div className="cf-entry__meta">
435
+ <FeedbackEntry.Status />
436
+ <FeedbackEntry.CommentCount />
437
+ </div>
438
+ <FeedbackEntry.Title />
439
+ <FeedbackEntry.Body />
440
+ </FeedbackEntry.Content>
441
+ </FeedbackEntry.Root>
442
+
443
+ <section className="cf-discussion">
444
+ <h3>{messages.comments.title}</h3>
445
+ <FeedbackForm.Root
446
+ onSubmit={async (event) => {
447
+ event.preventDefault();
448
+ if (body.trim().length === 0) return;
449
+ await createComment({ entryId, body });
450
+ setBody("");
451
+ }}
452
+ >
453
+ <FeedbackForm.Textarea
454
+ value={body}
455
+ onChange={(event) => setBody(event.currentTarget.value)}
456
+ placeholder={messages.comments.placeholder}
457
+ rows={3}
458
+ />
459
+ <button type="submit" className="cf-button cf-button--primary">
460
+ {messages.comments.submit}
461
+ </button>
462
+ </FeedbackForm.Root>
463
+
464
+ {comments.status === "LoadingFirstPage" ? (
465
+ <p className="cf-state">{messages.board.loading}</p>
466
+ ) : null}
467
+ {comments.status !== "LoadingFirstPage" &&
468
+ visibleComments.length === 0 ? (
469
+ <p className="cf-state">{messages.comments.noComments}</p>
470
+ ) : null}
471
+ <div className="cf-comments">
472
+ {visibleComments.map((comment) => (
473
+ <CommentBranch
474
+ key={comment.id}
475
+ comment={comment}
476
+ entryId={entryId}
477
+ hooks={hooks}
478
+ commentSort={commentSort}
479
+ maxCommentDepth={maxCommentDepth}
480
+ transformComments={transformComments}
481
+ renderActor={renderActor}
482
+ />
483
+ ))}
484
+ </div>
485
+ {comments.status === "CanLoadMore" ? (
486
+ <button
487
+ type="button"
488
+ className="cf-button"
489
+ onClick={() => comments.loadMore(hooks.pageSizes.comments)}
490
+ >
491
+ {messages.comments.loadMore}
492
+ </button>
493
+ ) : null}
494
+ </section>
495
+ </div>
496
+ );
497
+ }
498
+
499
+ function CommentBranch({
500
+ comment,
501
+ entryId,
502
+ hooks,
503
+ commentSort,
504
+ maxCommentDepth,
505
+ transformComments,
506
+ renderActor,
507
+ }: FeedbackScreenCommentBranchProps) {
508
+ const { messages } = useFeedbackUi();
509
+ const [expanded, setExpanded] = useState(false);
510
+ const [replying, setReplying] = useState(false);
511
+ const [replyBody, setReplyBody] = useState("");
512
+ const setLike = hooks.useSetCommentLike();
513
+ const createComment = hooks.useCreateComment();
514
+
515
+ return (
516
+ <Comment.Root comment={comment}>
517
+ {renderActor === undefined ? null : (
518
+ <div className="cf-comment__author">{renderActor(comment.actorId)}</div>
519
+ )}
520
+ <Comment.Body />
521
+ <div className="cf-comment__toolbar">
522
+ <Comment.Like
523
+ onToggle={(active) =>
524
+ void setLike({ commentId: comment.id, desiredState: active })
525
+ }
526
+ />
527
+ {comment.body !== null && comment.depth < maxCommentDepth ? (
528
+ <Comment.Reply onActivate={() => setReplying((value) => !value)} />
529
+ ) : null}
530
+ <Comment.RepliesButton
531
+ expanded={expanded}
532
+ onExpandedChange={setExpanded}
533
+ />
534
+ </div>
535
+ {replying ? (
536
+ <FeedbackForm.Root
537
+ onSubmit={async (event) => {
538
+ event.preventDefault();
539
+ if (replyBody.trim().length === 0) return;
540
+ await createComment({
541
+ entryId,
542
+ parentCommentId: comment.id,
543
+ body: replyBody,
544
+ });
545
+ setReplyBody("");
546
+ setReplying(false);
547
+ setExpanded(true);
548
+ }}
549
+ >
550
+ <FeedbackForm.Textarea
551
+ value={replyBody}
552
+ onChange={(event) => setReplyBody(event.currentTarget.value)}
553
+ placeholder={messages.comments.placeholder}
554
+ rows={2}
555
+ />
556
+ <div className="cf-inline-actions">
557
+ <button type="submit" className="cf-button cf-button--primary">
558
+ {messages.comments.reply}
559
+ </button>
560
+ <button
561
+ type="button"
562
+ className="cf-button"
563
+ onClick={() => setReplying(false)}
564
+ >
565
+ {messages.comments.cancelReply}
566
+ </button>
567
+ </div>
568
+ </FeedbackForm.Root>
569
+ ) : null}
570
+ {expanded ? (
571
+ <ReplyList
572
+ hooks={hooks}
573
+ entryId={entryId}
574
+ parentCommentId={comment.id}
575
+ commentSort={commentSort}
576
+ maxCommentDepth={maxCommentDepth}
577
+ transformComments={transformComments}
578
+ renderActor={renderActor}
579
+ />
580
+ ) : null}
581
+ </Comment.Root>
582
+ );
583
+ }
584
+
585
+ function ReplyList({
586
+ hooks,
587
+ entryId,
588
+ parentCommentId,
589
+ commentSort,
590
+ maxCommentDepth,
591
+ transformComments,
592
+ renderActor,
593
+ }: FeedbackScreenReplyListProps) {
594
+ const { messages } = useFeedbackUi();
595
+ const replies = hooks.useComments({
596
+ entryId,
597
+ parentCommentId,
598
+ sort: commentSort,
599
+ });
600
+ const visibleReplies = useMemo(
601
+ () => transformComments?.(replies.results) ?? replies.results,
602
+ [replies.results, transformComments],
603
+ );
604
+
605
+ return (
606
+ <Comment.Children>
607
+ {replies.status === "LoadingFirstPage" ? (
608
+ <p className="cf-state">{messages.board.loading}</p>
609
+ ) : null}
610
+ {visibleReplies.map((reply) => (
611
+ <CommentBranch
612
+ key={reply.id}
613
+ comment={reply}
614
+ entryId={entryId}
615
+ hooks={hooks}
616
+ commentSort={commentSort}
617
+ maxCommentDepth={maxCommentDepth}
618
+ transformComments={transformComments}
619
+ renderActor={renderActor}
620
+ />
621
+ ))}
622
+ {replies.status === "CanLoadMore" ? (
623
+ <button
624
+ type="button"
625
+ className="cf-button"
626
+ onClick={() => replies.loadMore(hooks.pageSizes.replies)}
627
+ >
628
+ {messages.comments.loadMore}
629
+ </button>
630
+ ) : null}
631
+ </Comment.Children>
632
+ );
633
+ }