convex-feedback-ui 0.1.2 → 0.1.3

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 (166) hide show
  1. package/README.md +115 -18
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/native/expo/ExpoFeedbackBody.d.ts +4 -0
  4. package/dist/native/expo/ExpoFeedbackBody.d.ts.map +1 -0
  5. package/dist/native/expo/ExpoFeedbackBody.js +26 -0
  6. package/dist/native/expo/ExpoFeedbackBody.js.map +1 -0
  7. package/dist/native/expo/FeedbackScreen.d.ts +3 -0
  8. package/dist/native/expo/FeedbackScreen.d.ts.map +1 -0
  9. package/dist/native/expo/FeedbackScreen.js +9 -0
  10. package/dist/native/expo/FeedbackScreen.js.map +1 -0
  11. package/dist/native/expo/FeedbackStack.d.ts +3 -0
  12. package/dist/native/expo/FeedbackStack.d.ts.map +1 -0
  13. package/dist/native/expo/FeedbackStack.js +41 -0
  14. package/dist/native/expo/FeedbackStack.js.map +1 -0
  15. package/dist/native/expo/index.d.ts +9 -0
  16. package/dist/native/expo/index.d.ts.map +1 -0
  17. package/dist/native/expo/index.js +7 -0
  18. package/dist/native/expo/index.js.map +1 -0
  19. package/dist/native/expo/types.d.ts +20 -0
  20. package/dist/native/expo/types.d.ts.map +1 -0
  21. package/dist/native/expo/types.js +2 -0
  22. package/dist/native/expo/types.js.map +1 -0
  23. package/dist/native/native/FeedbackScreen.d.ts +3 -0
  24. package/dist/native/native/FeedbackScreen.d.ts.map +1 -0
  25. package/dist/native/native/FeedbackScreen.js +9 -0
  26. package/dist/native/native/FeedbackScreen.js.map +1 -0
  27. package/dist/native/native/index.d.ts +8 -0
  28. package/dist/native/native/index.d.ts.map +1 -0
  29. package/dist/native/native/index.js +7 -0
  30. package/dist/native/native/index.js.map +1 -0
  31. package/dist/native/shared/helpers.d.ts +3 -0
  32. package/dist/native/shared/helpers.d.ts.map +1 -0
  33. package/dist/native/shared/helpers.js +4 -0
  34. package/dist/native/shared/helpers.js.map +1 -0
  35. package/dist/native/shared/ui/BodyContent.d.ts +3 -0
  36. package/dist/native/shared/ui/BodyContent.d.ts.map +1 -0
  37. package/dist/native/shared/ui/BodyContent.js +18 -0
  38. package/dist/native/shared/ui/BodyContent.js.map +1 -0
  39. package/dist/native/shared/ui/Button.d.ts +5 -0
  40. package/dist/native/shared/ui/Button.d.ts.map +1 -0
  41. package/dist/native/shared/ui/Button.js +15 -0
  42. package/dist/native/shared/ui/Button.js.map +1 -0
  43. package/dist/native/shared/ui/CommentBranch.d.ts +3 -0
  44. package/dist/native/shared/ui/CommentBranch.d.ts.map +1 -0
  45. package/dist/native/shared/ui/CommentBranch.js +35 -0
  46. package/dist/native/shared/ui/CommentBranch.js.map +1 -0
  47. package/dist/native/shared/ui/EntryCard.d.ts +3 -0
  48. package/dist/native/shared/ui/EntryCard.d.ts.map +1 -0
  49. package/dist/native/shared/ui/EntryCard.js +13 -0
  50. package/dist/native/shared/ui/EntryCard.js.map +1 -0
  51. package/dist/native/shared/ui/EntryDetail.d.ts +3 -0
  52. package/dist/native/shared/ui/EntryDetail.d.ts.map +1 -0
  53. package/dist/native/shared/ui/EntryDetail.js +37 -0
  54. package/dist/native/shared/ui/EntryDetail.js.map +1 -0
  55. package/dist/native/shared/ui/FeedbackScreenHeader.d.ts +2 -0
  56. package/dist/native/shared/ui/FeedbackScreenHeader.d.ts.map +1 -0
  57. package/dist/native/shared/ui/FeedbackScreenHeader.js +20 -0
  58. package/dist/native/shared/ui/FeedbackScreenHeader.js.map +1 -0
  59. package/dist/native/shared/ui/FeedbackScreenList.d.ts +3 -0
  60. package/dist/native/shared/ui/FeedbackScreenList.d.ts.map +1 -0
  61. package/dist/native/shared/ui/FeedbackScreenList.js +19 -0
  62. package/dist/native/shared/ui/FeedbackScreenList.js.map +1 -0
  63. package/dist/native/shared/ui/NewEntry.d.ts +5 -0
  64. package/dist/native/shared/ui/NewEntry.d.ts.map +1 -0
  65. package/dist/native/shared/ui/NewEntry.js +158 -0
  66. package/dist/native/shared/ui/NewEntry.js.map +1 -0
  67. package/dist/native/shared/ui/ReplyList.d.ts +3 -0
  68. package/dist/native/shared/ui/ReplyList.d.ts.map +1 -0
  69. package/dist/native/shared/ui/ReplyList.js +19 -0
  70. package/dist/native/shared/ui/ReplyList.js.map +1 -0
  71. package/dist/native/{primitives.d.ts → shared/ui/primitives.d.ts} +10 -12
  72. package/dist/native/shared/ui/primitives.d.ts.map +1 -0
  73. package/dist/native/{primitives.js → shared/ui/primitives.js} +19 -25
  74. package/dist/native/shared/ui/primitives.js.map +1 -0
  75. package/dist/react/{context.d.ts → css.d.ts} +2 -4
  76. package/dist/react/css.d.ts.map +1 -0
  77. package/dist/react/css.js +18 -0
  78. package/dist/react/css.js.map +1 -0
  79. package/dist/react/index.d.ts +1 -1
  80. package/dist/react/index.d.ts.map +1 -1
  81. package/dist/react/index.js +1 -1
  82. package/dist/react/index.js.map +1 -1
  83. package/dist/react/primitives.d.ts.map +1 -1
  84. package/dist/react/primitives.js +2 -1
  85. package/dist/react/primitives.js.map +1 -1
  86. package/dist/react/screen.d.ts +3 -3
  87. package/dist/react/screen.d.ts.map +1 -1
  88. package/dist/react/screen.js +25 -22
  89. package/dist/react/screen.js.map +1 -1
  90. package/dist/shared/context/FeedbackBodyProvider.d.ts +5 -0
  91. package/dist/shared/context/FeedbackBodyProvider.d.ts.map +1 -0
  92. package/dist/shared/context/FeedbackBodyProvider.js +43 -0
  93. package/dist/shared/context/FeedbackBodyProvider.js.map +1 -0
  94. package/dist/{native/context.d.ts → shared/context/FeedbackProvider.d.ts} +2 -2
  95. package/dist/shared/context/FeedbackProvider.d.ts.map +1 -0
  96. package/dist/{native/context.js → shared/context/FeedbackProvider.js} +3 -3
  97. package/dist/shared/context/FeedbackProvider.js.map +1 -0
  98. package/dist/shared/helpers.d.ts +7 -0
  99. package/dist/shared/helpers.d.ts.map +1 -0
  100. package/dist/shared/helpers.js +24 -0
  101. package/dist/shared/helpers.js.map +1 -0
  102. package/dist/shared/messages.d.ts.map +1 -1
  103. package/dist/shared/messages.js +23 -14
  104. package/dist/shared/messages.js.map +1 -1
  105. package/dist/shared/theme.d.ts.map +1 -1
  106. package/dist/shared/theme.js +1 -0
  107. package/dist/shared/theme.js.map +1 -1
  108. package/dist/shared/types/context.d.ts +4 -0
  109. package/dist/shared/types/context.d.ts.map +1 -1
  110. package/dist/shared/types/messages.d.ts +12 -1
  111. package/dist/shared/types/messages.d.ts.map +1 -1
  112. package/dist/shared/types/screen.d.ts +55 -18
  113. package/dist/shared/types/screen.d.ts.map +1 -1
  114. package/dist/shared/types/theme.d.ts +4 -0
  115. package/dist/shared/types/theme.d.ts.map +1 -1
  116. package/package.json +27 -12
  117. package/src/native/expo/ExpoFeedbackBody.tsx +48 -0
  118. package/src/native/expo/FeedbackScreen.tsx +37 -0
  119. package/src/native/expo/FeedbackStack.tsx +99 -0
  120. package/src/native/expo/README.md +17 -0
  121. package/src/native/expo/index.ts +8 -0
  122. package/src/native/expo/types.ts +23 -0
  123. package/src/native/native/FeedbackScreen.tsx +37 -0
  124. package/src/native/native/index.ts +7 -0
  125. package/src/native/shared/helpers.ts +9 -0
  126. package/src/native/shared/ui/BodyContent.tsx +43 -0
  127. package/src/native/shared/ui/Button.tsx +29 -0
  128. package/src/native/shared/ui/CommentBranch.tsx +88 -0
  129. package/src/native/shared/ui/EntryCard.tsx +37 -0
  130. package/src/native/shared/ui/EntryDetail.tsx +103 -0
  131. package/src/native/shared/ui/FeedbackScreenHeader.tsx +40 -0
  132. package/src/native/shared/ui/FeedbackScreenList.tsx +61 -0
  133. package/src/native/shared/ui/NewEntry.tsx +375 -0
  134. package/src/native/shared/ui/ReplyList.tsx +41 -0
  135. package/src/native/{primitives.tsx → shared/ui/primitives.tsx} +84 -92
  136. package/src/react/{context.tsx → css.tsx} +2 -44
  137. package/src/react/index.ts +1 -1
  138. package/src/react/primitives.tsx +2 -1
  139. package/src/react/screen.tsx +67 -123
  140. package/src/shared/context/FeedbackBodyProvider.tsx +82 -0
  141. package/src/{native/context.tsx → shared/context/FeedbackProvider.tsx} +3 -3
  142. package/src/shared/helpers.ts +33 -0
  143. package/src/shared/messages.ts +23 -22
  144. package/src/shared/theme.ts +1 -0
  145. package/src/shared/types/context.ts +5 -0
  146. package/src/shared/types/messages.ts +16 -1
  147. package/src/shared/types/screen.ts +69 -23
  148. package/src/shared/types/theme.ts +5 -0
  149. package/dist/native/context.d.ts.map +0 -1
  150. package/dist/native/context.js.map +0 -1
  151. package/dist/native/index.d.ts +0 -7
  152. package/dist/native/index.d.ts.map +0 -1
  153. package/dist/native/index.js +0 -6
  154. package/dist/native/index.js.map +0 -1
  155. package/dist/native/primitives.d.ts.map +0 -1
  156. package/dist/native/primitives.js.map +0 -1
  157. package/dist/native/screen.d.ts +0 -3
  158. package/dist/native/screen.d.ts.map +0 -1
  159. package/dist/native/screen.js +0 -284
  160. package/dist/native/screen.js.map +0 -1
  161. package/dist/react/context.d.ts.map +0 -1
  162. package/dist/react/context.js +0 -41
  163. package/dist/react/context.js.map +0 -1
  164. package/src/native/index.ts +0 -6
  165. package/src/native/screen.tsx +0 -831
  166. /package/src/native/{README.md → native/README.md} +0 -0
@@ -0,0 +1,88 @@
1
+ import { useState } from "react";
2
+ import { Text, View } from "react-native";
3
+
4
+ import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider.js";
5
+ import { useFeedbackUi } from "../../../shared/context/FeedbackProvider.js";
6
+ import type { FeedbackScreenCommentBranchProps } from "../../../shared/types";
7
+ import { Button } from "./Button";
8
+ import { ReplyList } from "./ReplyList";
9
+ import { Comment, FeedbackForm } from "./primitives.js";
10
+
11
+ export function CommentBranch({
12
+ comment,
13
+ entryId,
14
+ }: FeedbackScreenCommentBranchProps) {
15
+ const { hooks, maxCommentDepth, renderActor } = useFeedbackBody();
16
+ const { messages, theme } = useFeedbackUi();
17
+ const [expanded, setExpanded] = useState(false);
18
+ const [replying, setReplying] = useState(false);
19
+ const [replyBody, setReplyBody] = useState("");
20
+ const setLike = hooks.useSetCommentLike();
21
+ const createComment = hooks.useCreateComment();
22
+
23
+ return (
24
+ <Comment.Root comment={comment}>
25
+ {renderActor === undefined ? null : (
26
+ <View>{renderActor(comment.actorId)}</View>
27
+ )}
28
+ <Comment.Body />
29
+ <View
30
+ style={{
31
+ flexDirection: "row",
32
+ flexWrap: "wrap",
33
+ alignItems: "center",
34
+ gap: 4,
35
+ }}
36
+ >
37
+ <Comment.Like
38
+ onToggle={(active) =>
39
+ void setLike({ commentId: comment.id, desiredState: active })
40
+ }
41
+ />
42
+ {comment.body !== null && comment.depth < maxCommentDepth ? (
43
+ <Comment.Reply onActivate={() => setReplying((value) => !value)} />
44
+ ) : null}
45
+ <Comment.RepliesButton
46
+ expanded={expanded}
47
+ onExpandedChange={setExpanded}
48
+ />
49
+ </View>
50
+ {replying ? (
51
+ <FeedbackForm.Root>
52
+ <FeedbackForm.Textarea
53
+ value={replyBody}
54
+ onChangeText={setReplyBody}
55
+ placeholder={messages.comments.placeholder}
56
+ />
57
+ <FeedbackForm.Submit
58
+ onPress={async () => {
59
+ if (replyBody.trim().length === 0) return;
60
+ await createComment({
61
+ entryId,
62
+ parentCommentId: comment.id,
63
+ body: replyBody,
64
+ });
65
+ setReplyBody("");
66
+ setReplying(false);
67
+ setExpanded(true);
68
+ }}
69
+ >
70
+ {messages.comments.reply}
71
+ </FeedbackForm.Submit>
72
+ <Button
73
+ label={messages.comments.cancelReply}
74
+ onPress={() => setReplying(false)}
75
+ />
76
+ </FeedbackForm.Root>
77
+ ) : null}
78
+ {expanded ? (
79
+ <ReplyList entryId={entryId} parentCommentId={comment.id} />
80
+ ) : null}
81
+ {comment.deletedAt !== undefined ? (
82
+ <Text style={{ color: theme.colors.mutedText, fontSize: 11 }}>
83
+ {messages.comments.deleted}
84
+ </Text>
85
+ ) : null}
86
+ </Comment.Root>
87
+ );
88
+ }
@@ -0,0 +1,37 @@
1
+ import { Pressable, View } from "react-native";
2
+
3
+ import type { FeedbackScreenEntryCardProps } from "../../../shared/types";
4
+ import { FeedbackEntry } from "./primitives";
5
+
6
+ export function EntryCard({
7
+ entry,
8
+ hooks,
9
+ onOpen,
10
+ }: FeedbackScreenEntryCardProps) {
11
+ const setUpvote = hooks.useSetEntryUpvote();
12
+ return (
13
+ <FeedbackEntry.Root entry={entry}>
14
+ <FeedbackEntry.Upvote
15
+ onToggle={(active) =>
16
+ void setUpvote({ entryId: entry.id, desiredState: active })
17
+ }
18
+ />
19
+ <Pressable onPress={onOpen} style={{ flex: 1, gap: 4 }}>
20
+ <View
21
+ style={{
22
+ flexDirection: "row",
23
+ alignItems: "center",
24
+ justifyContent: "space-between",
25
+ gap: 8,
26
+ }}
27
+ >
28
+ <FeedbackEntry.Kind />
29
+ <FeedbackEntry.Status />
30
+ </View>
31
+ <FeedbackEntry.Title />
32
+ <FeedbackEntry.Body numberOfLines={3} />
33
+ <FeedbackEntry.CommentCount />
34
+ </Pressable>
35
+ </FeedbackEntry.Root>
36
+ );
37
+ }
@@ -0,0 +1,103 @@
1
+ import { useMemo, useState } from "react";
2
+ import { ActivityIndicator, Text, View } from "react-native";
3
+
4
+ import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider";
5
+ import { useFeedbackUi } from "../../../shared/context/FeedbackProvider";
6
+ import type { FeedbackScreenEntryDetailProps } from "../../../shared/types";
7
+ import { Button } from "./Button";
8
+ import { CommentBranch } from "./CommentBranch";
9
+ import { FeedbackEntry, FeedbackForm } from "./primitives";
10
+
11
+ export function EntryDetail({
12
+ entryId,
13
+ onBack,
14
+ hideBackButton,
15
+ }: FeedbackScreenEntryDetailProps) {
16
+ const { hooks, commentSort, transformComments } = useFeedbackBody();
17
+
18
+ const { messages, theme } = useFeedbackUi();
19
+ const entry = hooks.useEntry(entryId);
20
+ const comments = hooks.useComments({ entryId, sort: commentSort });
21
+ const setUpvote = hooks.useSetEntryUpvote();
22
+ const createComment = hooks.useCreateComment();
23
+ const [body, setBody] = useState("");
24
+ const visible = useMemo(
25
+ () => transformComments?.(comments.results) ?? comments.results,
26
+ [comments.results, transformComments],
27
+ );
28
+
29
+ if (entry === undefined)
30
+ return (
31
+ <View
32
+ style={{
33
+ flex: 1,
34
+ gap: 8,
35
+ alignItems: "center",
36
+ justifyContent: "center",
37
+ }}
38
+ >
39
+ <ActivityIndicator size="large" />
40
+ <Text style={{ color: theme.colors.mutedText }}>
41
+ {messages.board.loading}
42
+ </Text>
43
+ </View>
44
+ );
45
+
46
+ if (entry === null) {
47
+ if (hideBackButton) return;
48
+ return <Button label={messages.entry.back} onPress={onBack} />;
49
+ }
50
+
51
+ return (
52
+ <View style={{ gap: 12 }}>
53
+ {!hideBackButton && (
54
+ <Button label={messages.entry.back} onPress={onBack} />
55
+ )}
56
+ <FeedbackEntry.Root entry={entry}>
57
+ <FeedbackEntry.Upvote
58
+ onToggle={(active) =>
59
+ void setUpvote({ entryId, desiredState: active })
60
+ }
61
+ />
62
+ <FeedbackEntry.Content style={{ gap: 5 }}>
63
+ <FeedbackEntry.Status />
64
+ <FeedbackEntry.Title />
65
+ <FeedbackEntry.Body />
66
+ <FeedbackEntry.CommentCount />
67
+ </FeedbackEntry.Content>
68
+ </FeedbackEntry.Root>
69
+ <Text
70
+ style={{ color: theme.colors.text, fontSize: 18, fontWeight: "700" }}
71
+ >
72
+ {messages.comments.title}
73
+ </Text>
74
+ <FeedbackForm.Root>
75
+ <FeedbackForm.Textarea
76
+ value={body}
77
+ onChangeText={setBody}
78
+ placeholder={messages.comments.placeholder}
79
+ />
80
+ <FeedbackForm.Submit
81
+ onPress={async () => {
82
+ if (body.trim().length === 0) return;
83
+ await createComment({ entryId, body });
84
+ setBody("");
85
+ }}
86
+ >
87
+ {messages.comments.submit}
88
+ </FeedbackForm.Submit>
89
+ </FeedbackForm.Root>
90
+ <View style={{ gap: 8 }}>
91
+ {visible.map((comment) => (
92
+ <CommentBranch key={comment.id} comment={comment} entryId={entryId} />
93
+ ))}
94
+ </View>
95
+ {comments.status === "CanLoadMore" && (
96
+ <Button
97
+ label={messages.comments.loadMore}
98
+ onPress={() => comments.loadMore(hooks.pageSizes.comments)}
99
+ />
100
+ )}
101
+ </View>
102
+ );
103
+ }
@@ -0,0 +1,40 @@
1
+ import { Pressable, Text } from "react-native";
2
+ import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider";
3
+ import { useFeedbackUi } from "../../../shared/context/FeedbackProvider";
4
+ import { FeedbackBoard } from "./primitives";
5
+
6
+ export function FeedbackScreenHeader() {
7
+ const { query, setShowForm, setQuery, enabledKinds } = useFeedbackBody();
8
+ const { messages, theme } = useFeedbackUi();
9
+
10
+ const pageTitle =
11
+ enabledKinds.length === 1
12
+ ? messages.kindsPlural[enabledKinds.at(0)!]
13
+ : messages.board.title;
14
+
15
+ return (
16
+ <>
17
+ <FeedbackBoard.Header>
18
+ <FeedbackBoard.Title>{pageTitle}</FeedbackBoard.Title>
19
+ <Text style={{ color: theme.colors.mutedText }}>
20
+ {messages.board.subtitle}
21
+ </Text>
22
+ <Pressable
23
+ onPress={() => setShowForm(true)}
24
+ style={{
25
+ alignSelf: "flex-start",
26
+ paddingVertical: 9,
27
+ paddingHorizontal: 12,
28
+ borderRadius: 9,
29
+ backgroundColor: theme.colors.primary,
30
+ }}
31
+ >
32
+ <Text style={{ color: theme.colors.text, fontWeight: "700" }}>
33
+ {messages.board.createEntry}
34
+ </Text>
35
+ </Pressable>
36
+ </FeedbackBoard.Header>
37
+ <FeedbackBoard.Search value={query} onValueChange={(q) => setQuery(q)} />
38
+ </>
39
+ );
40
+ }
@@ -0,0 +1,61 @@
1
+ import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider.js";
2
+ import { useFeedbackUi } from "../../../shared/context/FeedbackProvider.js";
3
+ import type { FeedbackScreenListProps } from "../../../shared/types/index.js";
4
+ import { Button } from "./Button.js";
5
+ import { EntryCard } from "./EntryCard.js";
6
+ import { EntryDetail } from "./EntryDetail.js";
7
+ import { FeedbackBoard } from "./primitives.js";
8
+
9
+ export function FeedbackScreenList({
10
+ hideBackButton = false,
11
+ onEntryOpen,
12
+ }: FeedbackScreenListProps) {
13
+ const {
14
+ hooks,
15
+ entrySort,
16
+ isSearching,
17
+ enabledKinds,
18
+ selectedEntryId,
19
+ debouncedQuery,
20
+ setSelectedEntryId,
21
+ } = useFeedbackBody();
22
+ const { messages, theme } = useFeedbackUi();
23
+
24
+ const list = hooks.useEntries({ sort: entrySort, kinds: enabledKinds });
25
+ const search = hooks.useSearchEntries({
26
+ searchQuery: debouncedQuery,
27
+ kinds: enabledKinds,
28
+ });
29
+
30
+ const entries = isSearching ? search : list.results;
31
+
32
+ return (
33
+ <>
34
+ <FeedbackBoard.List style={{ paddingHorizontal: theme.spacing }}>
35
+ {selectedEntryId ? (
36
+ <EntryDetail
37
+ entryId={selectedEntryId}
38
+ hideBackButton={hideBackButton}
39
+ onBack={() => setSelectedEntryId(null)}
40
+ />
41
+ ) : (
42
+ (entries ?? []).map((entry) => (
43
+ <EntryCard
44
+ key={entry.id}
45
+ entry={entry}
46
+ hooks={hooks}
47
+ onOpen={() => onEntryOpen(entry.id)}
48
+ />
49
+ ))
50
+ )}
51
+ </FeedbackBoard.List>
52
+
53
+ {!isSearching && list.status === "CanLoadMore" && (
54
+ <Button
55
+ label={messages.board.loadMore}
56
+ onPress={() => list.loadMore(hooks.pageSizes.entries)}
57
+ />
58
+ )}
59
+ </>
60
+ );
61
+ }
@@ -0,0 +1,375 @@
1
+ import type { EntryKind } from "convex-feedback";
2
+ import { useMemo, useState } from "react";
3
+ import {
4
+ Alert,
5
+ Modal,
6
+ Platform,
7
+ Pressable,
8
+ ScrollView,
9
+ Text,
10
+ View,
11
+ } from "react-native";
12
+
13
+ import { useFeedbackUi } from "../../../shared/context/FeedbackProvider";
14
+ import type { FeedbackScreenEntryModalProps } from "../../../shared/types";
15
+ import { useFeedbackBody } from "../../native";
16
+ import { EntryDetail } from "./EntryDetail";
17
+ import { FeedbackForm } from "./primitives";
18
+
19
+ export function CreateEntryModal({
20
+ onRequestClose,
21
+ onCreated,
22
+ }: FeedbackScreenEntryModalProps & {
23
+ onRequestClose: () => void;
24
+ }) {
25
+ const { enabledKinds } = useFeedbackBody();
26
+ const { messages, theme } = useFeedbackUi();
27
+ const isIos = Platform.OS === "ios";
28
+
29
+ const [previewEntryId, setPreviewEntryId] = useState<string | null>(null);
30
+
31
+ const [kind, setKind] = useState<EntryKind>(enabledKinds[0] ?? "feedback");
32
+ const [title, setTitle] = useState("");
33
+ const [body, setBody] = useState("");
34
+
35
+ const modalTile =
36
+ enabledKinds.length === 1
37
+ ? messages.newFeedback[enabledKinds.at(0)!]
38
+ : messages.board.createEntry;
39
+
40
+ return (
41
+ <Modal
42
+ visible
43
+ animationType="slide"
44
+ presentationStyle={"pageSheet"}
45
+ allowSwipeDismissal={isIos}
46
+ onRequestClose={onRequestClose}
47
+ role="dialog"
48
+ >
49
+ <View
50
+ style={{
51
+ flex: 1,
52
+ justifyContent: "flex-end",
53
+ backgroundColor: isIos ? theme.colors.background : "transparent",
54
+ }}
55
+ >
56
+ {isIos && (
57
+ <Pressable
58
+ accessibilityRole="button"
59
+ accessibilityLabel={messages.form.cancel}
60
+ onPress={onRequestClose}
61
+ style={{
62
+ position: "absolute",
63
+ top: 0,
64
+ right: 0,
65
+ bottom: 0,
66
+ left: 0,
67
+ }}
68
+ />
69
+ )}
70
+
71
+ <View
72
+ style={{
73
+ flex: 1,
74
+ minHeight: 360,
75
+ overflow: "hidden",
76
+ borderTopLeftRadius: theme.radius * 2,
77
+ borderTopRightRadius: theme.radius * 2,
78
+ backgroundColor: theme.colors.background,
79
+ }}
80
+ >
81
+ <View
82
+ style={{
83
+ flexDirection: "row",
84
+ alignItems: "center",
85
+ justifyContent: "space-between",
86
+ paddingHorizontal: 20,
87
+ paddingVertical: 18,
88
+ borderBottomWidth: 1,
89
+ borderBottomColor: theme.colors.border,
90
+ }}
91
+ >
92
+ <Text
93
+ style={{
94
+ color: theme.colors.text,
95
+ fontSize: 18,
96
+ fontWeight: "700",
97
+ }}
98
+ >
99
+ {modalTile}
100
+ </Text>
101
+
102
+ <Pressable
103
+ accessibilityRole="button"
104
+ onPress={onRequestClose}
105
+ hitSlop={8}
106
+ >
107
+ <Text
108
+ style={{
109
+ color: theme.colors.primary,
110
+ fontWeight: "600",
111
+ }}
112
+ >
113
+ {messages.form.cancel}
114
+ </Text>
115
+ </Pressable>
116
+ </View>
117
+
118
+ <ScrollView
119
+ keyboardShouldPersistTaps="handled"
120
+ keyboardDismissMode={isIos ? "interactive" : "on-drag"}
121
+ contentInsetAdjustmentBehavior="automatic"
122
+ contentContainerStyle={{
123
+ padding: 16,
124
+ paddingBottom: 32,
125
+ }}
126
+ >
127
+ {previewEntryId !== null ? (
128
+ <EntryDetail
129
+ entryId={previewEntryId}
130
+ onBack={() => setPreviewEntryId(null)}
131
+ />
132
+ ) : (
133
+ <CreateForm
134
+ kind={kind}
135
+ onKindChange={setKind}
136
+ title={title}
137
+ onTitleChange={setTitle}
138
+ body={body}
139
+ onBodyChange={setBody}
140
+ onCreated={onCreated}
141
+ onOpenSuggestion={setPreviewEntryId}
142
+ />
143
+ )}
144
+ </ScrollView>
145
+ </View>
146
+ </View>
147
+ </Modal>
148
+ );
149
+ }
150
+
151
+ function CreateForm({
152
+ kind,
153
+ onKindChange,
154
+ title,
155
+ onTitleChange,
156
+ body,
157
+ onBodyChange,
158
+ onCreated,
159
+ onOpenSuggestion,
160
+ }: FeedbackScreenEntryModalProps & {
161
+ kind: EntryKind;
162
+ onKindChange: (kind: EntryKind) => void;
163
+ title: string;
164
+ onTitleChange: (title: string) => void;
165
+ body: string;
166
+ onBodyChange: (body: string) => void;
167
+ onOpenSuggestion: (id: string) => void;
168
+ }) {
169
+ const { hooks, enabledKinds } = useFeedbackBody();
170
+ const { messages, theme } = useFeedbackUi();
171
+ const [submitting, setSubmitting] = useState(false);
172
+ const create = hooks.useCreateEntry();
173
+ const similar = hooks.useSimilarEntries({ title, body, kind, limit: 3 });
174
+
175
+ const suggestions = useMemo(() => {
176
+ if (similar === undefined) return [];
177
+ return [...similar.exact, ...similar.similar];
178
+ }, [similar]);
179
+
180
+ const submit = async () => {
181
+ if (submitting || title.trim().length === 0 || body.trim().length === 0) {
182
+ return;
183
+ }
184
+
185
+ setSubmitting(true);
186
+
187
+ try {
188
+ onCreated(await create({ kind, title, body }));
189
+ } finally {
190
+ setSubmitting(false);
191
+ }
192
+ };
193
+
194
+ const requestSubmit = () => {
195
+ if (
196
+ submitting ||
197
+ similar === undefined ||
198
+ title.trim().length === 0 ||
199
+ body.trim().length === 0
200
+ ) {
201
+ return;
202
+ }
203
+
204
+ if (suggestions.length === 0) {
205
+ void submit();
206
+ return;
207
+ }
208
+
209
+ Alert.alert(
210
+ similar.exact.length > 0
211
+ ? messages.form.exactDuplicate
212
+ : messages.form.possibleDuplicates,
213
+ messages.form.duplicateWarning,
214
+ [
215
+ {
216
+ text: messages.form.cancel,
217
+ style: "cancel",
218
+ },
219
+ {
220
+ text: messages.form.submitAnyway,
221
+ onPress: () => {
222
+ void submit();
223
+ },
224
+ },
225
+ ],
226
+ );
227
+ };
228
+
229
+ return (
230
+ <FeedbackForm.Root>
231
+ {enabledKinds.length > 1 && (
232
+ <>
233
+ <Text style={{ color: theme.colors.text, fontWeight: "600" }}>
234
+ {messages.form.kind}
235
+ </Text>
236
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 7 }}>
237
+ {enabledKinds.map((value) => (
238
+ <Pressable
239
+ key={value}
240
+ onPress={() => onKindChange(value)}
241
+ style={{
242
+ paddingVertical: 6,
243
+ paddingHorizontal: 9,
244
+ borderRadius: 999,
245
+ borderWidth: 1,
246
+ borderColor:
247
+ value === kind ? theme.colors.primary : theme.colors.border,
248
+ }}
249
+ >
250
+ <Text
251
+ style={{
252
+ color:
253
+ value === kind
254
+ ? theme.colors.primary
255
+ : theme.colors.mutedText,
256
+ }}
257
+ >
258
+ {messages.kinds[value]}
259
+ </Text>
260
+ </Pressable>
261
+ ))}
262
+ </View>
263
+ </>
264
+ )}
265
+ <FeedbackForm.Input
266
+ value={title}
267
+ onChangeText={onTitleChange}
268
+ placeholder={messages.form.titlePlaceholder}
269
+ />
270
+ <FeedbackForm.Textarea
271
+ value={body}
272
+ onChangeText={onBodyChange}
273
+ placeholder={messages.form.bodyPlaceholder}
274
+ />
275
+ {suggestions.length > 0 ? (
276
+ <View
277
+ style={{
278
+ gap: 8,
279
+ padding: 10,
280
+ borderWidth: 1,
281
+ borderColor: theme.colors.border,
282
+ borderRadius: theme.radius,
283
+ backgroundColor: theme.colors.surfaceMuted,
284
+ }}
285
+ >
286
+ <Text
287
+ style={{
288
+ color: theme.colors.text,
289
+ fontWeight: "600",
290
+ }}
291
+ >
292
+ {similar?.exact.length
293
+ ? messages.form.exactDuplicate
294
+ : messages.form.possibleDuplicates}
295
+ </Text>
296
+
297
+ {suggestions.map((entry) => (
298
+ <Pressable
299
+ key={entry.id}
300
+ onPress={() => onOpenSuggestion(entry.id)}
301
+ style={{
302
+ gap: 4,
303
+ padding: 10,
304
+ borderWidth: 1,
305
+ borderColor: theme.colors.border,
306
+ borderRadius: Math.max(8, theme.radius - 2),
307
+ backgroundColor: theme.colors.surface,
308
+ }}
309
+ >
310
+ <View
311
+ style={{
312
+ flexDirection: "row",
313
+ alignItems: "center",
314
+ justifyContent: "space-between",
315
+ gap: 8,
316
+ }}
317
+ >
318
+ <Text
319
+ style={{
320
+ flexShrink: 1,
321
+ color: theme.colors.mutedText,
322
+ fontSize: 12,
323
+ fontWeight: "600",
324
+ }}
325
+ >
326
+ {messages.kinds[entry.kind]}
327
+ </Text>
328
+
329
+ <Text
330
+ style={{
331
+ color: theme.colors.mutedText,
332
+ fontSize: 12,
333
+ }}
334
+ >
335
+ {messages.statuses[entry.status]}
336
+ </Text>
337
+ </View>
338
+
339
+ <Text
340
+ numberOfLines={2}
341
+ style={{
342
+ color: theme.colors.text,
343
+ fontWeight: "700",
344
+ }}
345
+ >
346
+ {entry.title}
347
+ </Text>
348
+
349
+ <Text
350
+ numberOfLines={2}
351
+ style={{
352
+ color: theme.colors.mutedText,
353
+ lineHeight: 18,
354
+ }}
355
+ >
356
+ {entry.body}
357
+ </Text>
358
+
359
+ <Text
360
+ style={{
361
+ color: theme.colors.mutedText,
362
+ fontSize: 12,
363
+ }}
364
+ >
365
+ ▲ {entry.upvoteCount} ·{" "}
366
+ {messages.entry.comments(entry.commentCount)}
367
+ </Text>
368
+ </Pressable>
369
+ ))}
370
+ </View>
371
+ ) : null}
372
+ <FeedbackForm.Submit submitting={submitting} onPress={requestSubmit} />
373
+ </FeedbackForm.Root>
374
+ );
375
+ }