convex-feedback-ui 0.1.4 → 0.1.6

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 (141) hide show
  1. package/README.md +176 -3
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/native/expo/ExpoFeedbackBody.d.ts +1 -1
  4. package/dist/native/expo/ExpoFeedbackBody.d.ts.map +1 -1
  5. package/dist/native/expo/ExpoFeedbackBody.js +2 -2
  6. package/dist/native/expo/ExpoFeedbackBody.js.map +1 -1
  7. package/dist/native/expo/FeedbackCreateStackLayout.d.ts +3 -0
  8. package/dist/native/expo/FeedbackCreateStackLayout.d.ts.map +1 -0
  9. package/dist/native/expo/FeedbackCreateStackLayout.js +35 -0
  10. package/dist/native/expo/FeedbackCreateStackLayout.js.map +1 -0
  11. package/dist/native/expo/FeedbackRoutes.d.ts +4 -0
  12. package/dist/native/expo/FeedbackRoutes.d.ts.map +1 -0
  13. package/dist/native/expo/FeedbackRoutes.js +76 -0
  14. package/dist/native/expo/FeedbackRoutes.js.map +1 -0
  15. package/dist/native/expo/FeedbackScreen.d.ts +1 -1
  16. package/dist/native/expo/FeedbackScreen.d.ts.map +1 -1
  17. package/dist/native/expo/FeedbackScreen.js +3 -2
  18. package/dist/native/expo/FeedbackScreen.js.map +1 -1
  19. package/dist/native/expo/FeedbackStack.d.ts +1 -1
  20. package/dist/native/expo/FeedbackStack.d.ts.map +1 -1
  21. package/dist/native/expo/FeedbackStack.js +4 -2
  22. package/dist/native/expo/FeedbackStack.js.map +1 -1
  23. package/dist/native/expo/FeedbackStackLayout.d.ts +3 -0
  24. package/dist/native/expo/FeedbackStackLayout.d.ts.map +1 -0
  25. package/dist/native/expo/FeedbackStackLayout.js +54 -0
  26. package/dist/native/expo/FeedbackStackLayout.js.map +1 -0
  27. package/dist/native/expo/RoutedFeedbackContext.d.ts +4 -0
  28. package/dist/native/expo/RoutedFeedbackContext.d.ts.map +1 -0
  29. package/dist/native/expo/RoutedFeedbackContext.js +11 -0
  30. package/dist/native/expo/RoutedFeedbackContext.js.map +1 -0
  31. package/dist/native/expo/RoutedFeedbackModalContext.d.ts +7 -0
  32. package/dist/native/expo/RoutedFeedbackModalContext.d.ts.map +1 -0
  33. package/dist/native/expo/RoutedFeedbackModalContext.js +7 -0
  34. package/dist/native/expo/RoutedFeedbackModalContext.js.map +1 -0
  35. package/dist/native/expo/index.d.ts +6 -0
  36. package/dist/native/expo/index.d.ts.map +1 -1
  37. package/dist/native/expo/index.js +6 -0
  38. package/dist/native/expo/index.js.map +1 -1
  39. package/dist/native/expo/metadata.d.ts +4 -0
  40. package/dist/native/expo/metadata.d.ts.map +1 -0
  41. package/dist/native/expo/metadata.js +44 -0
  42. package/dist/native/expo/metadata.js.map +1 -0
  43. package/dist/native/expo/routes.d.ts +16 -0
  44. package/dist/native/expo/routes.d.ts.map +1 -0
  45. package/dist/native/expo/routes.js +23 -0
  46. package/dist/native/expo/routes.js.map +1 -0
  47. package/dist/native/expo/types.d.ts +51 -1
  48. package/dist/native/expo/types.d.ts.map +1 -1
  49. package/dist/native/native/FeedbackScreen.d.ts +1 -1
  50. package/dist/native/native/FeedbackScreen.d.ts.map +1 -1
  51. package/dist/native/native/FeedbackScreen.js +3 -2
  52. package/dist/native/native/FeedbackScreen.js.map +1 -1
  53. package/dist/native/native/index.d.ts +2 -0
  54. package/dist/native/native/index.d.ts.map +1 -1
  55. package/dist/native/native/index.js +2 -0
  56. package/dist/native/native/index.js.map +1 -1
  57. package/dist/native/shared/metadata.d.ts +5 -0
  58. package/dist/native/shared/metadata.d.ts.map +1 -0
  59. package/dist/native/shared/metadata.js +31 -0
  60. package/dist/native/shared/metadata.js.map +1 -0
  61. package/dist/native/shared/ui/EntryDetail.d.ts.map +1 -1
  62. package/dist/native/shared/ui/EntryDetail.js +3 -1
  63. package/dist/native/shared/ui/EntryDetail.js.map +1 -1
  64. package/dist/native/shared/ui/FeedbackScreenList.d.ts +1 -1
  65. package/dist/native/shared/ui/FeedbackScreenList.d.ts.map +1 -1
  66. package/dist/native/shared/ui/FeedbackScreenList.js +2 -2
  67. package/dist/native/shared/ui/FeedbackScreenList.js.map +1 -1
  68. package/dist/native/shared/ui/MetadataModal.d.ts +6 -0
  69. package/dist/native/shared/ui/MetadataModal.d.ts.map +1 -0
  70. package/dist/native/shared/ui/MetadataModal.js +39 -0
  71. package/dist/native/shared/ui/MetadataModal.js.map +1 -0
  72. package/dist/native/shared/ui/NewEntry.d.ts +10 -0
  73. package/dist/native/shared/ui/NewEntry.d.ts.map +1 -1
  74. package/dist/native/shared/ui/NewEntry.js +11 -4
  75. package/dist/native/shared/ui/NewEntry.js.map +1 -1
  76. package/dist/react/index.d.ts +2 -0
  77. package/dist/react/index.d.ts.map +1 -1
  78. package/dist/react/index.js +2 -0
  79. package/dist/react/index.js.map +1 -1
  80. package/dist/react/metadata.d.ts +4 -0
  81. package/dist/react/metadata.d.ts.map +1 -0
  82. package/dist/react/metadata.js +40 -0
  83. package/dist/react/metadata.js.map +1 -0
  84. package/dist/react/screen.d.ts +1 -1
  85. package/dist/react/screen.d.ts.map +1 -1
  86. package/dist/react/screen.js +18 -4
  87. package/dist/react/screen.js.map +1 -1
  88. package/dist/react/styles.css +35 -0
  89. package/dist/shared/context/FeedbackBodyProvider.d.ts +1 -1
  90. package/dist/shared/context/FeedbackBodyProvider.d.ts.map +1 -1
  91. package/dist/shared/context/FeedbackBodyProvider.js +5 -1
  92. package/dist/shared/context/FeedbackBodyProvider.js.map +1 -1
  93. package/dist/shared/messages.d.ts.map +1 -1
  94. package/dist/shared/messages.js +9 -0
  95. package/dist/shared/messages.js.map +1 -1
  96. package/dist/shared/metadata.d.ts +6 -0
  97. package/dist/shared/metadata.d.ts.map +1 -0
  98. package/dist/shared/metadata.js +54 -0
  99. package/dist/shared/metadata.js.map +1 -0
  100. package/dist/shared/types/index.d.ts +1 -0
  101. package/dist/shared/types/index.d.ts.map +1 -1
  102. package/dist/shared/types/messages.d.ts +17 -0
  103. package/dist/shared/types/messages.d.ts.map +1 -1
  104. package/dist/shared/types/metadata.d.ts +22 -0
  105. package/dist/shared/types/metadata.d.ts.map +1 -0
  106. package/dist/shared/types/metadata.js +2 -0
  107. package/dist/shared/types/metadata.js.map +1 -0
  108. package/dist/shared/types/screen.d.ts +14 -1
  109. package/dist/shared/types/screen.d.ts.map +1 -1
  110. package/package.json +12 -8
  111. package/src/native/expo/ExpoFeedbackBody.tsx +6 -1
  112. package/src/native/expo/FeedbackCreateStackLayout.tsx +62 -0
  113. package/src/native/expo/FeedbackRoutes.tsx +204 -0
  114. package/src/native/expo/FeedbackScreen.tsx +4 -0
  115. package/src/native/expo/FeedbackStack.tsx +16 -4
  116. package/src/native/expo/FeedbackStackLayout.tsx +116 -0
  117. package/src/native/expo/README.md +24 -0
  118. package/src/native/expo/RoutedFeedbackContext.tsx +20 -0
  119. package/src/native/expo/RoutedFeedbackModalContext.tsx +14 -0
  120. package/src/native/expo/index.ts +6 -0
  121. package/src/native/expo/metadata.ts +62 -0
  122. package/src/native/expo/routes.ts +41 -0
  123. package/src/native/expo/types.ts +63 -1
  124. package/src/native/native/FeedbackScreen.tsx +4 -0
  125. package/src/native/native/index.ts +2 -0
  126. package/src/native/shared/metadata.ts +39 -0
  127. package/src/native/shared/ui/EntryDetail.tsx +14 -0
  128. package/src/native/shared/ui/FeedbackScreenList.tsx +2 -1
  129. package/src/native/shared/ui/MetadataModal.tsx +112 -0
  130. package/src/native/shared/ui/NewEntry.tsx +18 -4
  131. package/src/react/index.ts +2 -0
  132. package/src/react/metadata.ts +51 -0
  133. package/src/react/screen.tsx +89 -2
  134. package/src/react/styles.css +35 -0
  135. package/src/shared/context/FeedbackBodyProvider.tsx +8 -0
  136. package/src/shared/messages.ts +9 -0
  137. package/src/shared/metadata.ts +84 -0
  138. package/src/shared/types/index.ts +1 -0
  139. package/src/shared/types/messages.ts +24 -0
  140. package/src/shared/types/metadata.ts +55 -0
  141. package/src/shared/types/screen.ts +17 -2
@@ -1,14 +1,21 @@
1
1
  import type { StackScreenProps } from "expo-router";
2
+ import type { ImageSourcePropType } from "react-native";
2
3
  import type { SearchBarCommands } from "react-native-screens";
3
- import type { FeedbackScreenRootProps } from "../../shared/types";
4
+ import type {
5
+ FeedbackColorProps,
6
+ FeedbackScreenRootProps,
7
+ } from "../../shared/types";
4
8
 
5
9
  interface FeedbackScreenWithStack {
6
10
  useStack?: true;
7
11
  StackOptions?: StackScreenProps["options"];
12
+ /** Android image sources for toolbar actions. iOS continues to use SF Symbols. */
13
+ androidToolbarIcons?: FeedbackAndroidToolbarIcons;
8
14
  }
9
15
  interface FeedbackScreenWithoutStack {
10
16
  useStack?: false;
11
17
  StackOptions?: never;
18
+ androidToolbarIcons?: never;
12
19
  }
13
20
  export type FeedbackScreenStackProps =
14
21
  FeedbackScreenWithStack | FeedbackScreenWithoutStack;
@@ -19,5 +26,60 @@ export type ExpoFeedbackScreenProps = FeedbackScreenStackProps &
19
26
  export interface FeedbackStackProps {
20
27
  searchRef: React.RefObject<SearchBarCommands | null>;
21
28
  stackOptions?: StackScreenProps["options"];
29
+ androidToolbarIcons?: FeedbackAndroidToolbarIcons;
22
30
  children: React.ReactNode;
23
31
  }
32
+
33
+ /** File names used by the routed feedback stack. */
34
+ export interface FeedbackRouteNames {
35
+ /** Board route. @default "index" */
36
+ board: string;
37
+ /** Entry route. Must contain `[entryId]`. @default "[entryId]" */
38
+ entry: string;
39
+ /** Create-entry modal route. @default "new" */
40
+ create: string;
41
+ }
42
+
43
+ export type FeedbackStackScreenOptions = Exclude<
44
+ NonNullable<StackScreenProps["options"]>,
45
+ (...args: never[]) => unknown
46
+ >;
47
+
48
+ export interface FeedbackAndroidToolbarIcons {
49
+ /** Android image source for the create-entry action. */
50
+ create?: ImageSourcePropType;
51
+ /** Android image source for the back action. */
52
+ back?: ImageSourcePropType;
53
+ /** Android image source for modal close actions. */
54
+ close?: ImageSourcePropType;
55
+ }
56
+
57
+ export interface FeedbackStackLayoutProps extends FeedbackScreenRootProps {
58
+ /** Partial overrides for the prescribed route file names. */
59
+ routes?: Partial<FeedbackRouteNames>;
60
+ /** Options shared by every feedback screen. */
61
+ screenOptions?: FeedbackStackScreenOptions;
62
+ /** Board screen option overrides. */
63
+ boardOptions?: FeedbackStackScreenOptions;
64
+ /** Entry-detail screen option overrides. */
65
+ entryOptions?: FeedbackStackScreenOptions;
66
+ /** Create-entry modal option overrides. */
67
+ createOptions?: FeedbackStackScreenOptions;
68
+ /** Android image sources for toolbar actions. iOS continues to use SF Symbols. */
69
+ androidToolbarIcons?: FeedbackAndroidToolbarIcons;
70
+ }
71
+
72
+ export interface FeedbackCreateStackLayoutProps {
73
+ /** Options shared by the create form and suggested-entry screens. */
74
+ screenOptions?: FeedbackStackScreenOptions;
75
+ /** Create-form screen option overrides. */
76
+ createOptions?: FeedbackStackScreenOptions;
77
+ /** Suggested-entry screen option overrides. */
78
+ entryOptions?: FeedbackStackScreenOptions;
79
+ }
80
+
81
+ export interface RoutedFeedbackContextValue {
82
+ routes: FeedbackRouteNames;
83
+ colors: FeedbackColorProps;
84
+ androidToolbarIcons: FeedbackAndroidToolbarIcons;
85
+ }
@@ -3,6 +3,7 @@ import { FeedbackProvider } from "../../shared/context/FeedbackProvider";
3
3
  import { kinds } from "../../shared/helpers";
4
4
  import type { FeedbackScreenRootProps } from "../../shared/types";
5
5
  import { FeedbackScreenContent } from "../shared/ui/BodyContent";
6
+ import { collectNativeMetadata } from "../shared/metadata";
6
7
 
7
8
  export function FeedbackScreen({
8
9
  hooks,
@@ -14,6 +15,7 @@ export function FeedbackScreen({
14
15
  enabledKinds = kinds,
15
16
  maxCommentDepth = 5,
16
17
  debounceDuration = 300,
18
+ collectMetadata,
17
19
  transformComments,
18
20
  renderActor,
19
21
  ...props
@@ -27,6 +29,8 @@ export function FeedbackScreen({
27
29
  enabledKinds={enabledKinds}
28
30
  maxCommentDepth={maxCommentDepth}
29
31
  debounceDuration={debounceDuration}
32
+ collectMetadata={collectMetadata}
33
+ collectStandardMetadata={collectNativeMetadata}
30
34
  transformComments={transformComments}
31
35
  renderActor={renderActor}
32
36
  >
@@ -1,7 +1,9 @@
1
1
  export * from "../../shared/context/FeedbackBodyProvider.js";
2
2
  export * from "../../shared/context/FeedbackProvider.js";
3
3
  export * from "../../shared/messages.js";
4
+ export * from "../../shared/metadata.js";
4
5
  export * from "../../shared/theme.js";
5
6
  export type * from "../../shared/types/index.js";
6
7
  export * from "../shared/ui/primitives.js";
8
+ export * from "../shared/metadata.js";
7
9
  export * from "./FeedbackScreen.js";
@@ -0,0 +1,39 @@
1
+ import type { FeedbackMetadataValue } from "convex-feedback";
2
+ import { Dimensions, PixelRatio, Platform } from "react-native";
3
+
4
+ import type {
5
+ StandardMetadataKey,
6
+ EnabledStandardMetadataSelection,
7
+ } from "../../shared/types/metadata.js";
8
+
9
+ export const defaultNativeMetadataKeys: readonly StandardMetadataKey[] = [
10
+ "platform",
11
+ "osVersion",
12
+ "deviceModel",
13
+ "screenWidth",
14
+ "screenHeight",
15
+ "pixelRatio",
16
+ "fontScale",
17
+ ];
18
+
19
+ export function collectNativeMetadata(
20
+ selection: EnabledStandardMetadataSelection,
21
+ ): Partial<Record<StandardMetadataKey, FeedbackMetadataValue>> {
22
+ const keys = new Set(
23
+ selection === true ? defaultNativeMetadataKeys : selection,
24
+ );
25
+ const metadata: Partial<Record<StandardMetadataKey, FeedbackMetadataValue>> =
26
+ {};
27
+ const screen = Dimensions.get("screen");
28
+
29
+ if (keys.has("platform")) metadata.platform = Platform.OS;
30
+ if (keys.has("osVersion")) metadata.osVersion = Platform.Version;
31
+ if (keys.has("deviceModel") && Platform.OS === "android")
32
+ metadata.deviceModel = Platform.constants.Model;
33
+ if (keys.has("screenWidth")) metadata.screenWidth = screen.width;
34
+ if (keys.has("screenHeight")) metadata.screenHeight = screen.height;
35
+ if (keys.has("pixelRatio")) metadata.pixelRatio = PixelRatio.get();
36
+ if (keys.has("fontScale")) metadata.fontScale = PixelRatio.getFontScale();
37
+
38
+ return metadata;
39
+ }
@@ -7,6 +7,7 @@ import type { FeedbackScreenEntryDetailProps } from "../../../shared/types";
7
7
  import { Button } from "./Button";
8
8
  import { CommentBranch } from "./CommentBranch";
9
9
  import { FeedbackEntry, FeedbackForm } from "./primitives";
10
+ import { MetadataModal } from "./MetadataModal";
10
11
 
11
12
  export function EntryDetail({
12
13
  entryId,
@@ -21,6 +22,7 @@ export function EntryDetail({
21
22
  const setUpvote = hooks.useSetEntryUpvote();
22
23
  const createComment = hooks.useCreateComment();
23
24
  const [body, setBody] = useState("");
25
+ const [showMetadata, setShowMetadata] = useState(false);
24
26
  const visible = useMemo(
25
27
  () => transformComments?.(comments.results) ?? comments.results,
26
28
  [comments.results, transformComments],
@@ -66,6 +68,18 @@ export function EntryDetail({
66
68
  <FeedbackEntry.CommentCount />
67
69
  </FeedbackEntry.Content>
68
70
  </FeedbackEntry.Root>
71
+ {entry.metadata !== undefined && (
72
+ <Button
73
+ label={messages.metadata.view}
74
+ onPress={() => setShowMetadata(true)}
75
+ />
76
+ )}
77
+ {showMetadata && entry.metadata !== undefined && (
78
+ <MetadataModal
79
+ metadata={entry.metadata}
80
+ onRequestClose={() => setShowMetadata(false)}
81
+ />
82
+ )}
69
83
  <Text
70
84
  style={{ color: theme.colors.text, fontSize: 18, fontWeight: "700" }}
71
85
  >
@@ -8,6 +8,7 @@ import { FeedbackBoard } from "./primitives.js";
8
8
 
9
9
  export function FeedbackScreenList({
10
10
  hideBackButton = false,
11
+ showSelectedEntry = true,
11
12
  onEntryOpen,
12
13
  }: FeedbackScreenListProps) {
13
14
  const {
@@ -32,7 +33,7 @@ export function FeedbackScreenList({
32
33
  return (
33
34
  <>
34
35
  <FeedbackBoard.List style={{ padding: theme.spacing }}>
35
- {selectedEntryId ? (
36
+ {showSelectedEntry && selectedEntryId ? (
36
37
  <EntryDetail
37
38
  entryId={selectedEntryId}
38
39
  hideBackButton={hideBackButton}
@@ -0,0 +1,112 @@
1
+ import type { FeedbackMetadata } from "convex-feedback";
2
+ import { Modal, Pressable, ScrollView, Text, View } from "react-native";
3
+
4
+ import { useFeedbackUi } from "../../../shared/context/FeedbackProvider";
5
+ import { formatMetadataKey } from "../../../shared/metadata";
6
+ import { Button } from "./Button";
7
+
8
+ export function MetadataModal({
9
+ metadata,
10
+ onRequestClose,
11
+ }: {
12
+ metadata: FeedbackMetadata;
13
+ onRequestClose: () => void;
14
+ }) {
15
+ const { messages, theme } = useFeedbackUi();
16
+ const sections = [
17
+ [messages.metadata.standard, metadata.standard],
18
+ [messages.metadata.additional, metadata.additional],
19
+ ] as const;
20
+ const hasValues = sections.some(
21
+ ([, values]) => values !== undefined && Object.keys(values).length > 0,
22
+ );
23
+
24
+ return (
25
+ <Modal
26
+ visible
27
+ transparent
28
+ animationType="fade"
29
+ onRequestClose={onRequestClose}
30
+ role="dialog"
31
+ >
32
+ <Pressable
33
+ accessible={false}
34
+ focusable={false}
35
+ onPress={onRequestClose}
36
+ style={{
37
+ flex: 1,
38
+ justifyContent: "center",
39
+ padding: 20,
40
+ backgroundColor: "rgba(0, 0, 0, 0.4)",
41
+ }}
42
+ >
43
+ <Pressable
44
+ accessible={false}
45
+ focusable={false}
46
+ onPress={(event) => event.stopPropagation()}
47
+ style={{
48
+ maxHeight: "80%",
49
+ gap: 14,
50
+ padding: 18,
51
+ borderRadius: theme.radius,
52
+ backgroundColor: theme.colors.surface,
53
+ }}
54
+ >
55
+ <Text
56
+ style={{
57
+ color: theme.colors.text,
58
+ fontSize: 18,
59
+ fontWeight: "700",
60
+ }}
61
+ >
62
+ {messages.metadata.title}
63
+ </Text>
64
+ <ScrollView contentContainerStyle={{ gap: 16 }}>
65
+ {hasValues ? (
66
+ sections.map(([label, values]) =>
67
+ values === undefined ||
68
+ Object.keys(values).length === 0 ? null : (
69
+ <View key={label} style={{ gap: 8 }}>
70
+ <Text
71
+ style={{ color: theme.colors.text, fontWeight: "700" }}
72
+ >
73
+ {label}
74
+ </Text>
75
+ {Object.entries(values).map(([key, value]) => (
76
+ <View
77
+ key={key}
78
+ style={{
79
+ gap: 3,
80
+ paddingBottom: 8,
81
+ borderBottomWidth: 1,
82
+ borderBottomColor: theme.colors.border,
83
+ }}
84
+ >
85
+ <Text
86
+ style={{
87
+ color: theme.colors.mutedText,
88
+ fontWeight: "600",
89
+ }}
90
+ >
91
+ {formatMetadataKey(key)}
92
+ </Text>
93
+ <Text selectable style={{ color: theme.colors.text }}>
94
+ {String(value)}
95
+ </Text>
96
+ </View>
97
+ ))}
98
+ </View>
99
+ ),
100
+ )
101
+ ) : (
102
+ <Text style={{ color: theme.colors.mutedText }}>
103
+ {messages.metadata.empty}
104
+ </Text>
105
+ )}
106
+ </ScrollView>
107
+ <Button label={messages.metadata.close} onPress={onRequestClose} />
108
+ </Pressable>
109
+ </Pressable>
110
+ </Modal>
111
+ );
112
+ }
@@ -13,6 +13,7 @@ import {
13
13
  import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider";
14
14
  import { useFeedbackUi } from "../../../shared/context/FeedbackProvider";
15
15
  import type { FeedbackScreenEntryModalProps } from "../../../shared/types";
16
+ import { collectEntryMetadata } from "../../../shared/metadata";
16
17
  import { EntryDetail } from "./EntryDetail";
17
18
  import { FeedbackForm } from "./primitives";
18
19
 
@@ -130,7 +131,7 @@ export function CreateEntryModal({
130
131
  onBack={() => setPreviewEntryId(null)}
131
132
  />
132
133
  ) : (
133
- <CreateForm
134
+ <CreateEntryForm
134
135
  kind={kind}
135
136
  onKindChange={setKind}
136
137
  title={title}
@@ -148,7 +149,7 @@ export function CreateEntryModal({
148
149
  );
149
150
  }
150
151
 
151
- function CreateForm({
152
+ export function CreateEntryForm({
152
153
  kind,
153
154
  onKindChange,
154
155
  title,
@@ -166,7 +167,8 @@ function CreateForm({
166
167
  onBodyChange: (body: string) => void;
167
168
  onOpenSuggestion: (id: string) => void;
168
169
  }) {
169
- const { hooks, enabledKinds } = useFeedbackBody();
170
+ const { hooks, enabledKinds, collectMetadata, collectStandardMetadata } =
171
+ useFeedbackBody();
170
172
  const { messages, theme } = useFeedbackUi();
171
173
  const [submitting, setSubmitting] = useState(false);
172
174
  const create = hooks.useCreateEntry();
@@ -185,7 +187,19 @@ function CreateForm({
185
187
  setSubmitting(true);
186
188
 
187
189
  try {
188
- onCreated(await create({ kind, title, body }));
190
+ const metadata = await collectEntryMetadata(
191
+ collectMetadata,
192
+ kind,
193
+ collectStandardMetadata,
194
+ );
195
+ onCreated(
196
+ await create({
197
+ kind,
198
+ title,
199
+ body,
200
+ ...(metadata === undefined ? {} : { metadata }),
201
+ }),
202
+ );
189
203
  } finally {
190
204
  setSubmitting(false);
191
205
  }
@@ -1,6 +1,8 @@
1
1
  export * from "../shared/messages.js";
2
+ export * from "../shared/metadata.js";
2
3
  export * from "../shared/theme.js";
3
4
  export type * from "../shared/types";
4
5
  export * from "./css.js";
6
+ export * from "./metadata.js";
5
7
  export * from "./primitives.js";
6
8
  export * from "./screen.js";
@@ -0,0 +1,51 @@
1
+ import type { FeedbackMetadataValue } from "convex-feedback";
2
+
3
+ import type {
4
+ StandardMetadataKey,
5
+ EnabledStandardMetadataSelection,
6
+ } from "../shared/types/metadata.js";
7
+
8
+ const defaultWebMetadataKeys: readonly StandardMetadataKey[] = [
9
+ "platform",
10
+ "userAgent",
11
+ "language",
12
+ "timezone",
13
+ "screenWidth",
14
+ "screenHeight",
15
+ "viewportWidth",
16
+ "viewportHeight",
17
+ "devicePixelRatio",
18
+ ];
19
+
20
+ export function collectWebMetadata(
21
+ selection: EnabledStandardMetadataSelection,
22
+ ): Partial<Record<StandardMetadataKey, FeedbackMetadataValue>> {
23
+ const keys = new Set(selection === true ? defaultWebMetadataKeys : selection);
24
+ const metadata: Partial<Record<StandardMetadataKey, FeedbackMetadataValue>> =
25
+ {};
26
+ const browserNavigator =
27
+ typeof navigator === "undefined" ? undefined : navigator;
28
+ const browserWindow = typeof window === "undefined" ? undefined : window;
29
+
30
+ if (keys.has("platform")) metadata.platform = "web";
31
+ if (keys.has("userAgent") && browserNavigator?.userAgent)
32
+ metadata.userAgent = browserNavigator.userAgent;
33
+ if (keys.has("language") && browserNavigator?.language)
34
+ metadata.language = browserNavigator.language;
35
+ if (keys.has("timezone")) {
36
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
37
+ if (timezone) metadata.timezone = timezone;
38
+ }
39
+ if (keys.has("screenWidth") && browserWindow?.screen)
40
+ metadata.screenWidth = browserWindow.screen.width;
41
+ if (keys.has("screenHeight") && browserWindow?.screen)
42
+ metadata.screenHeight = browserWindow.screen.height;
43
+ if (keys.has("viewportWidth") && browserWindow)
44
+ metadata.viewportWidth = browserWindow.innerWidth;
45
+ if (keys.has("viewportHeight") && browserWindow)
46
+ metadata.viewportHeight = browserWindow.innerHeight;
47
+ if (keys.has("devicePixelRatio") && browserWindow)
48
+ metadata.devicePixelRatio = browserWindow.devicePixelRatio;
49
+
50
+ return metadata;
51
+ }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import type { EntryKind } from "convex-feedback";
3
+ import type { EntryKind, FeedbackMetadata } from "convex-feedback";
4
4
  import { useMemo, useState, type SyntheticEvent } from "react";
5
5
 
6
6
  import {
@@ -26,6 +26,8 @@ import {
26
26
  FeedbackEntry,
27
27
  FeedbackForm,
28
28
  } from "./primitives.js";
29
+ import { collectWebMetadata } from "./metadata.js";
30
+ import { collectEntryMetadata, formatMetadataKey } from "../shared/metadata.js";
29
31
 
30
32
  const entryKinds: readonly EntryKind[] = [
31
33
  "feedback",
@@ -49,6 +51,7 @@ export function FeedbackScreen({
49
51
  transformComments,
50
52
  renderActor,
51
53
  debounceDuration = 300,
54
+ collectMetadata,
52
55
  ...props
53
56
  }: FeedbackScreenProps) {
54
57
  return (
@@ -60,6 +63,8 @@ export function FeedbackScreen({
60
63
  enabledKinds={enabledKinds}
61
64
  maxCommentDepth={maxCommentDepth}
62
65
  debounceDuration={debounceDuration}
66
+ collectMetadata={collectMetadata}
67
+ collectStandardMetadata={collectWebMetadata}
63
68
  transformComments={transformComments}
64
69
  renderActor={renderActor}
65
70
  >
@@ -207,7 +212,8 @@ function EntryCard({ entry, hooks, onOpen }: FeedbackScreenEntryCardProps) {
207
212
  }
208
213
 
209
214
  function CreateEntryForm({ onCreated }: FeedbackScreenEntryModalProps) {
210
- const { hooks, enabledKinds } = useFeedbackBody();
215
+ const { hooks, enabledKinds, collectMetadata, collectStandardMetadata } =
216
+ useFeedbackBody();
211
217
  const { messages } = useFeedbackUi();
212
218
  const [kind, setKind] = useState<EntryKind>(enabledKinds[0] ?? "feedback");
213
219
  const [title, setTitle] = useState("");
@@ -242,10 +248,16 @@ function CreateEntryForm({ onCreated }: FeedbackScreenEntryModalProps) {
242
248
  setSubmitting(true);
243
249
 
244
250
  try {
251
+ const metadata = await collectEntryMetadata(
252
+ collectMetadata,
253
+ kind,
254
+ collectStandardMetadata,
255
+ );
245
256
  const entryId = await createEntry({
246
257
  kind,
247
258
  title,
248
259
  body,
260
+ ...(metadata === undefined ? {} : { metadata }),
249
261
  });
250
262
 
251
263
  onCreated(entryId);
@@ -382,6 +394,7 @@ function EntryDetail({ entryId, onBack }: FeedbackScreenEntryDetailProps) {
382
394
  const comments = hooks.useComments({ entryId, sort: commentSort });
383
395
  const createComment = hooks.useCreateComment();
384
396
  const [body, setBody] = useState("");
397
+ const [showMetadata, setShowMetadata] = useState(false);
385
398
 
386
399
  const visibleComments = useMemo(
387
400
  () => transformComments?.(comments.results) ?? comments.results,
@@ -422,6 +435,25 @@ function EntryDetail({ entryId, onBack }: FeedbackScreenEntryDetailProps) {
422
435
  </FeedbackEntry.Content>
423
436
  </FeedbackEntry.Root>
424
437
 
438
+ {entry.metadata !== undefined && (
439
+ <div>
440
+ <button
441
+ type="button"
442
+ className="cf-button"
443
+ onClick={() => setShowMetadata(true)}
444
+ >
445
+ {messages.metadata.view}
446
+ </button>
447
+ </div>
448
+ )}
449
+
450
+ {showMetadata && entry.metadata !== undefined && (
451
+ <MetadataDialog
452
+ metadata={entry.metadata}
453
+ onClose={() => setShowMetadata(false)}
454
+ />
455
+ )}
456
+
425
457
  <section className="cf-discussion">
426
458
  <h3>{messages.comments.title}</h3>
427
459
  <FeedbackForm.Root
@@ -473,6 +505,61 @@ function EntryDetail({ entryId, onBack }: FeedbackScreenEntryDetailProps) {
473
505
  );
474
506
  }
475
507
 
508
+ function MetadataDialog({
509
+ metadata,
510
+ onClose,
511
+ }: {
512
+ metadata: FeedbackMetadata;
513
+ onClose: () => void;
514
+ }) {
515
+ const { messages } = useFeedbackUi();
516
+ const sections = [
517
+ [messages.metadata.standard, metadata.standard],
518
+ [messages.metadata.additional, metadata.additional],
519
+ ] as const;
520
+ const hasValues = sections.some(
521
+ ([, values]) => values !== undefined && Object.keys(values).length > 0,
522
+ );
523
+
524
+ return (
525
+ <div className="cf-confirm-backdrop" onMouseDown={onClose}>
526
+ <div
527
+ className="cf-confirm cf-metadata-dialog"
528
+ role="dialog"
529
+ aria-modal="true"
530
+ aria-labelledby="cf-metadata-title"
531
+ onMouseDown={(event) => event.stopPropagation()}
532
+ >
533
+ <h3 id="cf-metadata-title">{messages.metadata.title}</h3>
534
+ {hasValues ? (
535
+ sections.map(([label, values]) =>
536
+ values === undefined || Object.keys(values).length === 0 ? null : (
537
+ <section key={label} className="cf-metadata-section">
538
+ <h4>{label}</h4>
539
+ <dl>
540
+ {Object.entries(values).map(([key, value]) => (
541
+ <div key={key} className="cf-metadata-row">
542
+ <dt>{formatMetadataKey(key)}</dt>
543
+ <dd>{String(value)}</dd>
544
+ </div>
545
+ ))}
546
+ </dl>
547
+ </section>
548
+ ),
549
+ )
550
+ ) : (
551
+ <p>{messages.metadata.empty}</p>
552
+ )}
553
+ <div className="cf-inline-actions">
554
+ <button type="button" className="cf-button" onClick={onClose}>
555
+ {messages.metadata.close}
556
+ </button>
557
+ </div>
558
+ </div>
559
+ </div>
560
+ );
561
+ }
562
+
476
563
  function CommentBranch({ comment, entryId }: FeedbackScreenCommentBranchProps) {
477
564
  const { hooks, maxCommentDepth, renderActor } = useFeedbackBody();
478
565
  const { messages } = useFeedbackUi();
@@ -297,3 +297,38 @@
297
297
  color: var(--cf-muted);
298
298
  line-height: 1.5;
299
299
  }
300
+
301
+ :where(.cf-board) :where(.cf-metadata-dialog) {
302
+ width: min(100%, 560px);
303
+ max-height: min(80vh, 720px);
304
+ overflow: auto;
305
+ }
306
+
307
+ :where(.cf-board) :where(.cf-metadata-dialog h3),
308
+ :where(.cf-board) :where(.cf-metadata-dialog h4),
309
+ :where(.cf-board) :where(.cf-metadata-dialog dl) {
310
+ margin: 0;
311
+ }
312
+
313
+ :where(.cf-board) :where(.cf-metadata-section) {
314
+ display: grid;
315
+ gap: 8px;
316
+ }
317
+
318
+ :where(.cf-board) :where(.cf-metadata-row) {
319
+ display: grid;
320
+ grid-template-columns: minmax(120px, 0.8fr) minmax(0, 1.2fr);
321
+ gap: 12px;
322
+ padding: 8px 0;
323
+ border-bottom: 1px solid var(--cf-border);
324
+ }
325
+
326
+ :where(.cf-board) :where(.cf-metadata-row dt) {
327
+ color: var(--cf-muted);
328
+ font-weight: 600;
329
+ }
330
+
331
+ :where(.cf-board) :where(.cf-metadata-row dd) {
332
+ min-width: 0;
333
+ overflow-wrap: anywhere;
334
+ }
@@ -22,6 +22,10 @@ export function FeedbackBodyProvider({
22
22
  enabledKinds,
23
23
  maxCommentDepth,
24
24
  debounceDuration,
25
+ collectMetadata,
26
+ collectStandardMetadata,
27
+ transformComments,
28
+ renderActor,
25
29
  children,
26
30
  }: PropsWithChildren<FeedbackScreenProviderProps>) {
27
31
  const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
@@ -60,6 +64,10 @@ export function FeedbackBodyProvider({
60
64
  enabledKinds,
61
65
  maxCommentDepth,
62
66
  debounceDuration,
67
+ collectMetadata,
68
+ collectStandardMetadata,
69
+ transformComments,
70
+ renderActor,
63
71
  } satisfies FeedbackScreenBodyContextValue;
64
72
 
65
73
  return (
@@ -41,6 +41,14 @@ export const englishFeedbackMessages: FeedbackMessages = {
41
41
  open: "Open",
42
42
  back: "Go back",
43
43
  },
44
+ metadata: {
45
+ view: "View metadata",
46
+ title: "Diagnostic metadata",
47
+ standard: "Standard",
48
+ additional: "Additional",
49
+ empty: "No metadata values were collected.",
50
+ close: "Close",
51
+ },
44
52
  form: {
45
53
  kind: "Type",
46
54
  title: "Title",
@@ -94,6 +102,7 @@ export function mergeFeedbackMessages(
94
102
  ...overrides.statuses,
95
103
  },
96
104
  entry: { ...englishFeedbackMessages.entry, ...overrides.entry },
105
+ metadata: { ...englishFeedbackMessages.metadata, ...overrides.metadata },
97
106
  form: { ...englishFeedbackMessages.form, ...overrides.form },
98
107
  comments: { ...englishFeedbackMessages.comments, ...overrides.comments },
99
108
  };