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,831 @@
1
+ import type { EntryKind } from "convex-feedback";
2
+ import { useMemo, useState } from "react";
3
+ import {
4
+ ActivityIndicator,
5
+ Alert,
6
+ Modal,
7
+ Platform,
8
+ Pressable,
9
+ ScrollView,
10
+ Text,
11
+ View,
12
+ } from "react-native";
13
+
14
+ import type {
15
+ FeedbackScreenBodyProps,
16
+ FeedbackScreenCommentBranchProps,
17
+ FeedbackScreenEntryCardProps,
18
+ FeedbackScreenEntryDetailProps,
19
+ FeedbackScreenEntryModalProps,
20
+ FeedbackScreenReplyListProps,
21
+ FeedbackScreenRootProps,
22
+ } from "../shared/types";
23
+ import { FeedbackProvider, useFeedbackUi } from "./context.js";
24
+ import {
25
+ Comment,
26
+ FeedbackBoard,
27
+ FeedbackEntry,
28
+ FeedbackForm,
29
+ } from "./primitives.js";
30
+
31
+ const kinds: readonly EntryKind[] = [
32
+ "feedback",
33
+ "feature_request",
34
+ "bug_report",
35
+ ];
36
+
37
+ const isIos26OrNewer =
38
+ Platform.OS === "ios" &&
39
+ Number.parseInt(String(Platform.Version).split(".")[0] ?? "0", 10) >= 26;
40
+
41
+ export function FeedbackScreen({
42
+ hooks,
43
+ messages,
44
+ theme,
45
+ unstyled,
46
+ entrySort = "top",
47
+ commentSort = "top",
48
+ enabledKinds = kinds,
49
+ maxCommentDepth = 5,
50
+ transformComments,
51
+ renderActor,
52
+ ...colors
53
+ }: FeedbackScreenRootProps) {
54
+ return (
55
+ <FeedbackProvider messages={messages} theme={theme} unstyled={unstyled}>
56
+ <ScreenBody
57
+ hooks={hooks}
58
+ entrySort={entrySort}
59
+ commentSort={commentSort}
60
+ enabledKinds={enabledKinds}
61
+ maxCommentDepth={maxCommentDepth}
62
+ transformComments={transformComments}
63
+ renderActor={renderActor}
64
+ {...colors}
65
+ />
66
+ </FeedbackProvider>
67
+ );
68
+ }
69
+
70
+ function ScreenBody({
71
+ hooks,
72
+ entrySort,
73
+ commentSort,
74
+ enabledKinds,
75
+ maxCommentDepth,
76
+ transformComments,
77
+ renderActor,
78
+ ...colors
79
+ }: FeedbackScreenBodyProps) {
80
+ const { messages, theme } = useFeedbackUi();
81
+ const [query, setQuery] = useState("");
82
+ const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
83
+ const [showForm, setShowForm] = useState(false);
84
+ const list = hooks.useEntries({ sort: entrySort, kinds: enabledKinds });
85
+ const search = hooks.useSearchEntries({
86
+ searchQuery: query,
87
+ kinds: enabledKinds,
88
+ });
89
+
90
+ if (selectedEntryId !== null) {
91
+ return (
92
+ <FeedbackBoard.Root {...colors}>
93
+ <EntryDetail
94
+ hooks={hooks}
95
+ entryId={selectedEntryId}
96
+ onBack={() => setSelectedEntryId(null)}
97
+ commentSort={commentSort}
98
+ maxCommentDepth={maxCommentDepth}
99
+ transformComments={transformComments}
100
+ renderActor={renderActor}
101
+ />
102
+ </FeedbackBoard.Root>
103
+ );
104
+ }
105
+
106
+ const searching = query.trim().length > 0;
107
+ const entries = searching ? search : list.results;
108
+
109
+ return (
110
+ <>
111
+ <FeedbackBoard.Root {...colors}>
112
+ <FeedbackBoard.Header>
113
+ <FeedbackBoard.Title />
114
+ <Text style={{ color: theme.colors.mutedText }}>
115
+ {messages.board.subtitle}
116
+ </Text>
117
+ <Pressable
118
+ onPress={() => setShowForm(true)}
119
+ style={{
120
+ alignSelf: "flex-start",
121
+ paddingVertical: 9,
122
+ paddingHorizontal: 12,
123
+ borderRadius: 9,
124
+ backgroundColor: theme.colors.primary,
125
+ }}
126
+ >
127
+ <Text style={{ color: "#ffffff", fontWeight: "700" }}>
128
+ {messages.board.createEntry}
129
+ </Text>
130
+ </Pressable>
131
+ </FeedbackBoard.Header>
132
+ <FeedbackBoard.Search value={query} onValueChange={setQuery} />
133
+ <FeedbackBoard.List>
134
+ {(entries ?? []).map((entry) => (
135
+ <EntryCard
136
+ key={entry.id}
137
+ entry={entry}
138
+ hooks={hooks}
139
+ onOpen={() => setSelectedEntryId(entry.id)}
140
+ />
141
+ ))}
142
+ </FeedbackBoard.List>
143
+ {!searching && list.status === "CanLoadMore" ? (
144
+ <NativeButton
145
+ label={messages.board.loadMore}
146
+ onPress={() => list.loadMore(hooks.pageSizes.entries)}
147
+ />
148
+ ) : null}
149
+ </FeedbackBoard.Root>
150
+ {showForm ? (
151
+ <CreateEntryModal
152
+ hooks={hooks}
153
+ enabledKinds={enabledKinds}
154
+ onRequestClose={() => setShowForm(false)}
155
+ onCreated={(id) => {
156
+ setShowForm(false);
157
+ setSelectedEntryId(id);
158
+ }}
159
+ />
160
+ ) : null}
161
+ </>
162
+ );
163
+ }
164
+
165
+ function NativeButton({
166
+ label,
167
+ onPress,
168
+ }: {
169
+ label: string;
170
+ onPress: () => void;
171
+ }) {
172
+ const { theme } = useFeedbackUi();
173
+ return (
174
+ <Pressable
175
+ onPress={onPress}
176
+ style={{
177
+ alignSelf: "flex-start",
178
+ paddingVertical: 8,
179
+ paddingHorizontal: 11,
180
+ borderRadius: 8,
181
+ borderWidth: 1,
182
+ borderColor: theme.colors.border,
183
+ }}
184
+ >
185
+ <Text style={{ color: theme.colors.text, fontWeight: "600" }}>
186
+ {label}
187
+ </Text>
188
+ </Pressable>
189
+ );
190
+ }
191
+
192
+ function EntryCard({ entry, hooks, onOpen }: FeedbackScreenEntryCardProps) {
193
+ const setUpvote = hooks.useSetEntryUpvote();
194
+ return (
195
+ <FeedbackEntry.Root entry={entry}>
196
+ <FeedbackEntry.Upvote
197
+ onToggle={(active) =>
198
+ void setUpvote({ entryId: entry.id, desiredState: active })
199
+ }
200
+ />
201
+ <Pressable onPress={onOpen} style={{ flex: 1, gap: 4 }}>
202
+ <View
203
+ style={{
204
+ flexDirection: "row",
205
+ alignItems: "center",
206
+ justifyContent: "space-between",
207
+ gap: 8,
208
+ }}
209
+ >
210
+ <FeedbackEntry.Kind />
211
+ <FeedbackEntry.Status />
212
+ </View>
213
+ <FeedbackEntry.Title />
214
+ <FeedbackEntry.Body numberOfLines={3} />
215
+ <FeedbackEntry.CommentCount />
216
+ </Pressable>
217
+ </FeedbackEntry.Root>
218
+ );
219
+ }
220
+
221
+ function CreateEntryModal({
222
+ hooks,
223
+ enabledKinds,
224
+ onRequestClose,
225
+ onCreated,
226
+ }: FeedbackScreenEntryModalProps & {
227
+ onRequestClose: () => void;
228
+ }) {
229
+ const { messages, theme } = useFeedbackUi();
230
+ const isIos = Platform.OS === "ios";
231
+
232
+ const [previewEntryId, setPreviewEntryId] = useState<string | null>(null);
233
+
234
+ const [kind, setKind] = useState<EntryKind>(enabledKinds[0] ?? "feedback");
235
+ const [title, setTitle] = useState("");
236
+ const [body, setBody] = useState("");
237
+
238
+ return (
239
+ <Modal
240
+ visible
241
+ animationType="slide"
242
+ presentationStyle={isIos ? "formSheet" : "overFullScreen"}
243
+ transparent={!isIos}
244
+ allowSwipeDismissal={isIos}
245
+ onRequestClose={onRequestClose}
246
+ {...(isIos
247
+ ? {
248
+ backdropColor: isIos26OrNewer
249
+ ? "transparent"
250
+ : theme.colors.background,
251
+ }
252
+ : {})}
253
+ >
254
+ <View
255
+ style={
256
+ isIos
257
+ ? {
258
+ flex: 1,
259
+ backgroundColor: isIos26OrNewer
260
+ ? "transparent"
261
+ : theme.colors.background,
262
+ }
263
+ : {
264
+ flex: 1,
265
+ justifyContent: "flex-end",
266
+ backgroundColor: "rgba(0, 0, 0, 0.35)",
267
+ }
268
+ }
269
+ >
270
+ {!isIos ? (
271
+ <Pressable
272
+ accessibilityRole="button"
273
+ accessibilityLabel={messages.form.cancel}
274
+ onPress={onRequestClose}
275
+ style={{
276
+ position: "absolute",
277
+ top: 0,
278
+ right: 0,
279
+ bottom: 0,
280
+ left: 0,
281
+ }}
282
+ />
283
+ ) : null}
284
+
285
+ <View
286
+ style={
287
+ isIos
288
+ ? {
289
+ flex: 1,
290
+ backgroundColor: "transparent",
291
+ }
292
+ : {
293
+ maxHeight: "92%",
294
+ minHeight: 360,
295
+ overflow: "hidden",
296
+ borderTopLeftRadius: theme.radius * 2,
297
+ borderTopRightRadius: theme.radius * 2,
298
+ backgroundColor: theme.colors.background,
299
+ }
300
+ }
301
+ >
302
+ <View
303
+ style={{
304
+ flexDirection: "row",
305
+ alignItems: "center",
306
+ justifyContent: "space-between",
307
+ paddingHorizontal: 16,
308
+ paddingVertical: 14,
309
+ borderBottomWidth: 1,
310
+ borderBottomColor: theme.colors.border,
311
+ }}
312
+ >
313
+ <Text
314
+ style={{
315
+ color: theme.colors.text,
316
+ fontSize: 18,
317
+ fontWeight: "700",
318
+ }}
319
+ >
320
+ {messages.board.createEntry}
321
+ </Text>
322
+
323
+ <Pressable
324
+ accessibilityRole="button"
325
+ onPress={onRequestClose}
326
+ hitSlop={8}
327
+ >
328
+ <Text
329
+ style={{
330
+ color: theme.colors.primary,
331
+ fontWeight: "600",
332
+ }}
333
+ >
334
+ {messages.form.cancel}
335
+ </Text>
336
+ </Pressable>
337
+ </View>
338
+
339
+ <ScrollView
340
+ keyboardShouldPersistTaps="handled"
341
+ keyboardDismissMode={isIos ? "interactive" : "on-drag"}
342
+ contentInsetAdjustmentBehavior="automatic"
343
+ contentContainerStyle={{
344
+ padding: 16,
345
+ paddingBottom: 32,
346
+ }}
347
+ >
348
+ {previewEntryId !== null ? (
349
+ <EntryDetail
350
+ hooks={hooks}
351
+ entryId={previewEntryId}
352
+ onBack={() => setPreviewEntryId(null)}
353
+ commentSort="top"
354
+ maxCommentDepth={5}
355
+ />
356
+ ) : (
357
+ <CreateForm
358
+ hooks={hooks}
359
+ enabledKinds={enabledKinds}
360
+ kind={kind}
361
+ onKindChange={setKind}
362
+ title={title}
363
+ onTitleChange={setTitle}
364
+ body={body}
365
+ onBodyChange={setBody}
366
+ onCreated={onCreated}
367
+ onOpenSuggestion={setPreviewEntryId}
368
+ />
369
+ )}
370
+ </ScrollView>
371
+ </View>
372
+ </View>
373
+ </Modal>
374
+ );
375
+ }
376
+
377
+ function CreateForm({
378
+ hooks,
379
+ enabledKinds,
380
+ kind,
381
+ onKindChange,
382
+ title,
383
+ onTitleChange,
384
+ body,
385
+ onBodyChange,
386
+ onCreated,
387
+ onOpenSuggestion,
388
+ }: FeedbackScreenEntryModalProps & {
389
+ kind: EntryKind;
390
+ onKindChange: (kind: EntryKind) => void;
391
+ title: string;
392
+ onTitleChange: (title: string) => void;
393
+ body: string;
394
+ onBodyChange: (body: string) => void;
395
+ onOpenSuggestion: (id: string) => void;
396
+ }) {
397
+ const { messages, theme } = useFeedbackUi();
398
+ const [submitting, setSubmitting] = useState(false);
399
+ const create = hooks.useCreateEntry();
400
+ const similar = hooks.useSimilarEntries({ title, body, kind, limit: 3 });
401
+
402
+ const suggestions = useMemo(() => {
403
+ if (similar === undefined) return [];
404
+ return [...similar.exact, ...similar.similar];
405
+ }, [similar]);
406
+
407
+ const submit = async () => {
408
+ if (submitting || title.trim().length === 0 || body.trim().length === 0) {
409
+ return;
410
+ }
411
+
412
+ setSubmitting(true);
413
+
414
+ try {
415
+ onCreated(await create({ kind, title, body }));
416
+ } finally {
417
+ setSubmitting(false);
418
+ }
419
+ };
420
+
421
+ const requestSubmit = () => {
422
+ if (
423
+ submitting ||
424
+ similar === undefined ||
425
+ title.trim().length === 0 ||
426
+ body.trim().length === 0
427
+ ) {
428
+ return;
429
+ }
430
+
431
+ if (suggestions.length === 0) {
432
+ void submit();
433
+ return;
434
+ }
435
+
436
+ Alert.alert(
437
+ similar.exact.length > 0
438
+ ? messages.form.exactDuplicate
439
+ : messages.form.possibleDuplicates,
440
+ messages.form.duplicateWarning,
441
+ [
442
+ {
443
+ text: messages.form.cancel,
444
+ style: "cancel",
445
+ },
446
+ {
447
+ text: messages.form.submitAnyway,
448
+ onPress: () => {
449
+ void submit();
450
+ },
451
+ },
452
+ ],
453
+ );
454
+ };
455
+
456
+ return (
457
+ <FeedbackForm.Root>
458
+ <Text style={{ color: theme.colors.text, fontWeight: "600" }}>
459
+ {messages.form.kind}
460
+ </Text>
461
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 7 }}>
462
+ {enabledKinds.map((value) => (
463
+ <Pressable
464
+ key={value}
465
+ onPress={() => onKindChange(value)}
466
+ style={{
467
+ paddingVertical: 6,
468
+ paddingHorizontal: 9,
469
+ borderRadius: 999,
470
+ borderWidth: 1,
471
+ borderColor:
472
+ value === kind ? theme.colors.primary : theme.colors.border,
473
+ }}
474
+ >
475
+ <Text
476
+ style={{
477
+ color:
478
+ value === kind
479
+ ? theme.colors.primary
480
+ : theme.colors.mutedText,
481
+ }}
482
+ >
483
+ {messages.kinds[value]}
484
+ </Text>
485
+ </Pressable>
486
+ ))}
487
+ </View>
488
+ <FeedbackForm.Input
489
+ value={title}
490
+ onChangeText={onTitleChange}
491
+ placeholder={messages.form.titlePlaceholder}
492
+ />
493
+ <FeedbackForm.Textarea
494
+ value={body}
495
+ onChangeText={onBodyChange}
496
+ placeholder={messages.form.bodyPlaceholder}
497
+ />
498
+ {suggestions.length > 0 ? (
499
+ <View
500
+ style={{
501
+ gap: 8,
502
+ padding: 10,
503
+ borderWidth: 1,
504
+ borderColor: theme.colors.border,
505
+ borderRadius: theme.radius,
506
+ backgroundColor: theme.colors.surfaceMuted,
507
+ }}
508
+ >
509
+ <Text
510
+ style={{
511
+ color: theme.colors.text,
512
+ fontWeight: "600",
513
+ }}
514
+ >
515
+ {similar?.exact.length
516
+ ? messages.form.exactDuplicate
517
+ : messages.form.possibleDuplicates}
518
+ </Text>
519
+
520
+ {suggestions.map((entry) => (
521
+ <Pressable
522
+ key={entry.id}
523
+ onPress={() => onOpenSuggestion(entry.id)}
524
+ style={{
525
+ gap: 4,
526
+ padding: 10,
527
+ borderWidth: 1,
528
+ borderColor: theme.colors.border,
529
+ borderRadius: Math.max(8, theme.radius - 2),
530
+ backgroundColor: theme.colors.surface,
531
+ }}
532
+ >
533
+ <View
534
+ style={{
535
+ flexDirection: "row",
536
+ alignItems: "center",
537
+ justifyContent: "space-between",
538
+ gap: 8,
539
+ }}
540
+ >
541
+ <Text
542
+ style={{
543
+ flexShrink: 1,
544
+ color: theme.colors.mutedText,
545
+ fontSize: 12,
546
+ fontWeight: "600",
547
+ }}
548
+ >
549
+ {messages.kinds[entry.kind]}
550
+ </Text>
551
+
552
+ <Text
553
+ style={{
554
+ color: theme.colors.mutedText,
555
+ fontSize: 12,
556
+ }}
557
+ >
558
+ {messages.statuses[entry.status]}
559
+ </Text>
560
+ </View>
561
+
562
+ <Text
563
+ numberOfLines={2}
564
+ style={{
565
+ color: theme.colors.text,
566
+ fontWeight: "700",
567
+ }}
568
+ >
569
+ {entry.title}
570
+ </Text>
571
+
572
+ <Text
573
+ numberOfLines={2}
574
+ style={{
575
+ color: theme.colors.mutedText,
576
+ lineHeight: 18,
577
+ }}
578
+ >
579
+ {entry.body}
580
+ </Text>
581
+
582
+ <Text
583
+ style={{
584
+ color: theme.colors.mutedText,
585
+ fontSize: 12,
586
+ }}
587
+ >
588
+ ▲ {entry.upvoteCount} ·{" "}
589
+ {messages.entry.comments(entry.commentCount)}
590
+ </Text>
591
+ </Pressable>
592
+ ))}
593
+ </View>
594
+ ) : null}
595
+ <FeedbackForm.Submit submitting={submitting} onPress={requestSubmit} />
596
+ </FeedbackForm.Root>
597
+ );
598
+ }
599
+
600
+ function EntryDetail({
601
+ hooks,
602
+ entryId,
603
+ onBack,
604
+ commentSort,
605
+ maxCommentDepth,
606
+ transformComments,
607
+ renderActor,
608
+ }: FeedbackScreenEntryDetailProps) {
609
+ const { messages, theme } = useFeedbackUi();
610
+ const entry = hooks.useEntry(entryId);
611
+ const comments = hooks.useComments({ entryId, sort: commentSort });
612
+ const setUpvote = hooks.useSetEntryUpvote();
613
+ const createComment = hooks.useCreateComment();
614
+ const [body, setBody] = useState("");
615
+ const visible = useMemo(
616
+ () => transformComments?.(comments.results) ?? comments.results,
617
+ [comments.results, transformComments],
618
+ );
619
+
620
+ if (entry === undefined)
621
+ return (
622
+ <View
623
+ style={{
624
+ flex: 1,
625
+ gap: 8,
626
+ alignItems: "center",
627
+ justifyContent: "center",
628
+ }}
629
+ >
630
+ <ActivityIndicator size="large" />
631
+ <Text style={{ color: theme.colors.mutedText }}>
632
+ {messages.board.loading}
633
+ </Text>
634
+ </View>
635
+ );
636
+ if (entry === null)
637
+ return <NativeButton label={messages.entry.back} onPress={onBack} />;
638
+
639
+ return (
640
+ <View style={{ gap: 12 }}>
641
+ <NativeButton label={messages.entry.back} onPress={onBack} />
642
+ <FeedbackEntry.Root entry={entry}>
643
+ <FeedbackEntry.Upvote
644
+ onToggle={(active) =>
645
+ void setUpvote({ entryId, desiredState: active })
646
+ }
647
+ />
648
+ <FeedbackEntry.Content style={{ gap: 5 }}>
649
+ <FeedbackEntry.Status />
650
+ <FeedbackEntry.Title />
651
+ <FeedbackEntry.Body />
652
+ <FeedbackEntry.CommentCount />
653
+ </FeedbackEntry.Content>
654
+ </FeedbackEntry.Root>
655
+ <Text
656
+ style={{ color: theme.colors.text, fontSize: 18, fontWeight: "700" }}
657
+ >
658
+ {messages.comments.title}
659
+ </Text>
660
+ <FeedbackForm.Root>
661
+ <FeedbackForm.Textarea
662
+ value={body}
663
+ onChangeText={setBody}
664
+ placeholder={messages.comments.placeholder}
665
+ />
666
+ <FeedbackForm.Submit
667
+ onPress={async () => {
668
+ if (body.trim().length === 0) return;
669
+ await createComment({ entryId, body });
670
+ setBody("");
671
+ }}
672
+ >
673
+ {messages.comments.submit}
674
+ </FeedbackForm.Submit>
675
+ </FeedbackForm.Root>
676
+ <View style={{ gap: 8 }}>
677
+ {visible.map((comment) => (
678
+ <CommentBranch
679
+ key={comment.id}
680
+ comment={comment}
681
+ entryId={entryId}
682
+ hooks={hooks}
683
+ commentSort={commentSort}
684
+ maxCommentDepth={maxCommentDepth}
685
+ transformComments={transformComments}
686
+ renderActor={renderActor}
687
+ />
688
+ ))}
689
+ </View>
690
+ {comments.status === "CanLoadMore" ? (
691
+ <NativeButton
692
+ label={messages.comments.loadMore}
693
+ onPress={() => comments.loadMore(hooks.pageSizes.comments)}
694
+ />
695
+ ) : null}
696
+ </View>
697
+ );
698
+ }
699
+
700
+ function CommentBranch({
701
+ comment,
702
+ entryId,
703
+ hooks,
704
+ commentSort,
705
+ maxCommentDepth,
706
+ transformComments,
707
+ renderActor,
708
+ }: FeedbackScreenCommentBranchProps) {
709
+ const { messages, theme } = useFeedbackUi();
710
+ const [expanded, setExpanded] = useState(false);
711
+ const [replying, setReplying] = useState(false);
712
+ const [replyBody, setReplyBody] = useState("");
713
+ const setLike = hooks.useSetCommentLike();
714
+ const createComment = hooks.useCreateComment();
715
+ return (
716
+ <Comment.Root comment={comment}>
717
+ {renderActor === undefined ? null : (
718
+ <View>{renderActor(comment.actorId)}</View>
719
+ )}
720
+ <Comment.Body />
721
+ <View
722
+ style={{
723
+ flexDirection: "row",
724
+ flexWrap: "wrap",
725
+ alignItems: "center",
726
+ gap: 4,
727
+ }}
728
+ >
729
+ <Comment.Like
730
+ onToggle={(active) =>
731
+ void setLike({ commentId: comment.id, desiredState: active })
732
+ }
733
+ />
734
+ {comment.body !== null && comment.depth < maxCommentDepth ? (
735
+ <Comment.Reply onActivate={() => setReplying((value) => !value)} />
736
+ ) : null}
737
+ <Comment.RepliesButton
738
+ expanded={expanded}
739
+ onExpandedChange={setExpanded}
740
+ />
741
+ </View>
742
+ {replying ? (
743
+ <FeedbackForm.Root>
744
+ <FeedbackForm.Textarea
745
+ value={replyBody}
746
+ onChangeText={setReplyBody}
747
+ placeholder={messages.comments.placeholder}
748
+ />
749
+ <FeedbackForm.Submit
750
+ onPress={async () => {
751
+ if (replyBody.trim().length === 0) return;
752
+ await createComment({
753
+ entryId,
754
+ parentCommentId: comment.id,
755
+ body: replyBody,
756
+ });
757
+ setReplyBody("");
758
+ setReplying(false);
759
+ setExpanded(true);
760
+ }}
761
+ >
762
+ {messages.comments.reply}
763
+ </FeedbackForm.Submit>
764
+ <NativeButton
765
+ label={messages.comments.cancelReply}
766
+ onPress={() => setReplying(false)}
767
+ />
768
+ </FeedbackForm.Root>
769
+ ) : null}
770
+ {expanded ? (
771
+ <ReplyList
772
+ hooks={hooks}
773
+ entryId={entryId}
774
+ parentCommentId={comment.id}
775
+ commentSort={commentSort}
776
+ maxCommentDepth={maxCommentDepth}
777
+ transformComments={transformComments}
778
+ renderActor={renderActor}
779
+ />
780
+ ) : null}
781
+ {comment.deletedAt !== undefined ? (
782
+ <Text style={{ color: theme.colors.mutedText, fontSize: 11 }}>
783
+ {messages.comments.deleted}
784
+ </Text>
785
+ ) : null}
786
+ </Comment.Root>
787
+ );
788
+ }
789
+
790
+ function ReplyList({
791
+ hooks,
792
+ entryId,
793
+ parentCommentId,
794
+ commentSort,
795
+ maxCommentDepth,
796
+ transformComments,
797
+ renderActor,
798
+ }: FeedbackScreenReplyListProps) {
799
+ const { messages } = useFeedbackUi();
800
+ const replies = hooks.useComments({
801
+ entryId,
802
+ parentCommentId,
803
+ sort: commentSort,
804
+ });
805
+ const visible = useMemo(
806
+ () => transformComments?.(replies.results) ?? replies.results,
807
+ [replies.results, transformComments],
808
+ );
809
+ return (
810
+ <Comment.Children>
811
+ {visible.map((reply) => (
812
+ <CommentBranch
813
+ key={reply.id}
814
+ comment={reply}
815
+ entryId={entryId}
816
+ hooks={hooks}
817
+ commentSort={commentSort}
818
+ maxCommentDepth={maxCommentDepth}
819
+ transformComments={transformComments}
820
+ renderActor={renderActor}
821
+ />
822
+ ))}
823
+ {replies.status === "CanLoadMore" ? (
824
+ <NativeButton
825
+ label={messages.comments.loadMore}
826
+ onPress={() => replies.loadMore(hooks.pageSizes.replies)}
827
+ />
828
+ ) : null}
829
+ </Comment.Children>
830
+ );
831
+ }