convex-feedback-ui 0.2.0-beta.2 → 0.2.0-beta.4

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 (86) hide show
  1. package/README.md +40 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/native/expo/FeedbackRoutes.d.ts.map +1 -1
  4. package/dist/native/expo/FeedbackRoutes.js +15 -2
  5. package/dist/native/expo/FeedbackRoutes.js.map +1 -1
  6. package/dist/native/expo/RoadmapRoutes.d.ts +3 -0
  7. package/dist/native/expo/RoadmapRoutes.d.ts.map +1 -0
  8. package/dist/native/expo/RoadmapRoutes.js +61 -0
  9. package/dist/native/expo/RoadmapRoutes.js.map +1 -0
  10. package/dist/native/expo/RoadmapScreen.d.ts +24 -0
  11. package/dist/native/expo/RoadmapScreen.d.ts.map +1 -0
  12. package/dist/native/expo/RoadmapScreen.js +43 -0
  13. package/dist/native/expo/RoadmapScreen.js.map +1 -0
  14. package/dist/native/expo/RoadmapStackLayout.d.ts +8 -0
  15. package/dist/native/expo/RoadmapStackLayout.d.ts.map +1 -0
  16. package/dist/native/expo/RoadmapStackLayout.js +55 -0
  17. package/dist/native/expo/RoadmapStackLayout.js.map +1 -0
  18. package/dist/native/expo/RoutedRoadmapContext.d.ts +4 -0
  19. package/dist/native/expo/RoutedRoadmapContext.d.ts.map +1 -0
  20. package/dist/native/expo/RoutedRoadmapContext.js +11 -0
  21. package/dist/native/expo/RoutedRoadmapContext.js.map +1 -0
  22. package/dist/native/expo/index.d.ts +5 -1
  23. package/dist/native/expo/index.d.ts.map +1 -1
  24. package/dist/native/expo/index.js +5 -1
  25. package/dist/native/expo/index.js.map +1 -1
  26. package/dist/native/expo/routes.d.ts +17 -1
  27. package/dist/native/expo/routes.d.ts.map +1 -1
  28. package/dist/native/expo/routes.js +46 -0
  29. package/dist/native/expo/routes.js.map +1 -1
  30. package/dist/native/expo/types.d.ts +35 -1
  31. package/dist/native/expo/types.d.ts.map +1 -1
  32. package/dist/native/native/index.d.ts +1 -0
  33. package/dist/native/native/index.d.ts.map +1 -1
  34. package/dist/native/native/index.js +1 -0
  35. package/dist/native/native/index.js.map +1 -1
  36. package/dist/native/shared/ui/EntryCard.d.ts +1 -1
  37. package/dist/native/shared/ui/EntryCard.d.ts.map +1 -1
  38. package/dist/native/shared/ui/EntryCard.js +2 -2
  39. package/dist/native/shared/ui/EntryCard.js.map +1 -1
  40. package/dist/native/shared/ui/RoadmapBoard.d.ts +55 -0
  41. package/dist/native/shared/ui/RoadmapBoard.d.ts.map +1 -0
  42. package/dist/native/shared/ui/RoadmapBoard.js +89 -0
  43. package/dist/native/shared/ui/RoadmapBoard.js.map +1 -0
  44. package/dist/native/shared/ui/RoadmapScreen.d.ts +36 -1
  45. package/dist/native/shared/ui/RoadmapScreen.d.ts.map +1 -1
  46. package/dist/native/shared/ui/RoadmapScreen.js +143 -51
  47. package/dist/native/shared/ui/RoadmapScreen.js.map +1 -1
  48. package/dist/native/shared/ui/primitives.d.ts +1 -1
  49. package/dist/native/shared/ui/primitives.d.ts.map +1 -1
  50. package/dist/native/shared/ui/primitives.js +3 -1
  51. package/dist/native/shared/ui/primitives.js.map +1 -1
  52. package/dist/react/roadmap.d.ts.map +1 -1
  53. package/dist/react/roadmap.js +25 -13
  54. package/dist/react/roadmap.js.map +1 -1
  55. package/dist/react/styles.css +129 -0
  56. package/dist/shared/messages.d.ts.map +1 -1
  57. package/dist/shared/messages.js +24 -0
  58. package/dist/shared/messages.js.map +1 -1
  59. package/dist/shared/types/messages.d.ts +28 -1
  60. package/dist/shared/types/messages.d.ts.map +1 -1
  61. package/dist/shared/types/roadmap.d.ts +10 -1
  62. package/dist/shared/types/roadmap.d.ts.map +1 -1
  63. package/dist/shared/types/screen.d.ts +2 -0
  64. package/dist/shared/types/screen.d.ts.map +1 -1
  65. package/package.json +2 -2
  66. package/src/native/expo/FeedbackRoutes.tsx +30 -7
  67. package/src/native/expo/README.md +10 -2
  68. package/src/native/expo/RoadmapRoutes.tsx +171 -0
  69. package/src/native/expo/RoadmapScreen.tsx +177 -0
  70. package/src/native/expo/RoadmapStackLayout.tsx +110 -0
  71. package/src/native/expo/RoutedRoadmapContext.tsx +21 -0
  72. package/src/native/expo/index.ts +10 -1
  73. package/src/native/expo/routes.ts +69 -1
  74. package/src/native/expo/types.ts +43 -0
  75. package/src/native/native/README.md +9 -3
  76. package/src/native/native/index.ts +1 -0
  77. package/src/native/shared/ui/EntryCard.tsx +2 -1
  78. package/src/native/shared/ui/RoadmapBoard.tsx +225 -0
  79. package/src/native/shared/ui/RoadmapScreen.tsx +415 -242
  80. package/src/native/shared/ui/primitives.tsx +3 -0
  81. package/src/react/roadmap.tsx +140 -85
  82. package/src/react/styles.css +129 -0
  83. package/src/shared/messages.ts +25 -0
  84. package/src/shared/types/messages.ts +41 -1
  85. package/src/shared/types/roadmap.ts +18 -1
  86. package/src/shared/types/screen.ts +2 -0
@@ -1,271 +1,368 @@
1
- import type {
2
- FeedbackEntry as FeedbackEntryData,
3
- RoadmapItem,
4
- } from "convex-feedback";
5
- import { useState } from "react";
6
- import { Pressable, ScrollView, Text, View } from "react-native";
1
+ import type { RoadmapItem, RoadmapStatus } from "convex-feedback";
2
+ import { useState, type PropsWithChildren } from "react";
3
+ import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
4
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
7
5
 
6
+ import {
7
+ FeedbackBodyProvider,
8
+ useFeedbackBody,
9
+ } from "../../../shared/context/FeedbackBodyProvider.js";
8
10
  import {
9
11
  FeedbackProvider,
10
12
  useFeedbackUi,
11
13
  } from "../../../shared/context/FeedbackProvider.js";
12
- import { allowAuthenticatedAction } from "../../../shared/helpers.js";
14
+ import { kinds } from "../../../shared/helpers.js";
13
15
  import type { RoadmapScreenProps } from "../../../shared/types/index.js";
14
- import { useNativeAction } from "../helpers.js";
16
+ import { collectNativeMetadata } from "../metadata.js";
15
17
  import { Button } from "./Button.js";
16
- import { FeedbackBoard, FeedbackEntry } from "./primitives.js";
18
+ import { EntryCard } from "./EntryCard.js";
19
+ import { EntryDetail } from "./EntryDetail.js";
20
+ import { RoadmapBoard, RoadmapBoardCard } from "./RoadmapBoard.js";
21
+ import { FeedbackBoard } from "./primitives.js";
17
22
 
18
- export function RoadmapScreen({
23
+ const stages: readonly { value: RoadmapStatus }[] = [
24
+ { value: "planned" },
25
+ { value: "in_progress" },
26
+ { value: "shipped" },
27
+ ];
28
+
29
+ /**
30
+ * Keeps the roadmap and its attached entry details on the same UI context.
31
+ * Routed Expo layouts reuse this provider so every route sees the same hooks,
32
+ * theme, messages, and comment configuration.
33
+ */
34
+ export function RoadmapProvider({
19
35
  hooks,
20
36
  messages,
21
37
  theme,
22
38
  unstyled,
23
- onEntryOpen,
39
+ commentSort = "top",
40
+ maxCommentDepth = 5,
41
+ transformComments,
42
+ renderActor,
24
43
  onUnauthenticated,
25
- pageSize,
26
- entryPageSize,
27
- ...colors
28
- }: RoadmapScreenProps) {
44
+ children,
45
+ }: PropsWithChildren<
46
+ Pick<
47
+ RoadmapScreenProps,
48
+ | "hooks"
49
+ | "messages"
50
+ | "theme"
51
+ | "unstyled"
52
+ | "commentSort"
53
+ | "maxCommentDepth"
54
+ | "transformComments"
55
+ | "renderActor"
56
+ | "onUnauthenticated"
57
+ >
58
+ >) {
29
59
  return (
30
60
  <FeedbackProvider messages={messages} theme={theme} unstyled={unstyled}>
31
- <RoadmapScreenInner
61
+ <FeedbackBodyProvider
32
62
  hooks={hooks}
33
- onEntryOpen={onEntryOpen}
63
+ entrySort="top"
64
+ commentSort={commentSort}
65
+ enabledKinds={kinds}
66
+ maxCommentDepth={maxCommentDepth}
67
+ debounceDuration={300}
68
+ collectStandardMetadata={collectNativeMetadata}
69
+ transformComments={transformComments}
70
+ renderActor={renderActor}
34
71
  onUnauthenticated={onUnauthenticated}
35
- pageSize={pageSize ?? hooks.pageSizes.roadmap}
36
- entryPageSize={entryPageSize ?? hooks.pageSizes.entries}
37
- {...colors}
38
- />
72
+ >
73
+ {children}
74
+ </FeedbackBodyProvider>
39
75
  </FeedbackProvider>
40
76
  );
41
77
  }
42
78
 
43
- function RoadmapScreenInner({
79
+ /** Self-contained public roadmap screen for native and non-routed Expo apps. */
80
+ export function RoadmapScreen({
44
81
  hooks,
82
+ messages,
83
+ theme,
84
+ unstyled,
45
85
  onEntryOpen,
46
86
  onUnauthenticated,
87
+ commentSort,
88
+ maxCommentDepth,
89
+ transformComments,
90
+ renderActor,
91
+ pageSize = hooks.pageSizes.roadmap,
92
+ entryPageSize = hooks.pageSizes.entries,
93
+ ...colors
94
+ }: RoadmapScreenProps) {
95
+ return (
96
+ <RoadmapProvider
97
+ hooks={hooks}
98
+ messages={messages}
99
+ theme={theme}
100
+ unstyled={unstyled}
101
+ commentSort={commentSort}
102
+ maxCommentDepth={maxCommentDepth}
103
+ transformComments={transformComments}
104
+ renderActor={renderActor}
105
+ onUnauthenticated={onUnauthenticated}
106
+ >
107
+ <RoadmapScreenContent
108
+ pageSize={pageSize}
109
+ entryPageSize={entryPageSize}
110
+ onEntryOpen={onEntryOpen}
111
+ {...colors}
112
+ />
113
+ </RoadmapProvider>
114
+ );
115
+ }
116
+
117
+ export interface RoadmapScreenContentProps extends Pick<
118
+ RoadmapScreenProps,
119
+ | "onEntryOpen"
120
+ | "onUnauthenticated"
121
+ | "primaryColor"
122
+ | "primaryForeground"
123
+ | "backgroundColor"
124
+ | "surfaceColor"
125
+ | "textColor"
126
+ | "mutedColor"
127
+ | "borderColor"
128
+ | "dangerColor"
129
+ > {
130
+ pageSize: number;
131
+ entryPageSize: number;
132
+ /** Controlled search value used by Expo Router's native Stack.SearchBar. */
133
+ query?: string;
134
+ /** Controlled search setter used by Expo Router's native Stack.SearchBar. */
135
+ onQueryChange?: (query: string) => void;
136
+ /** Renders the inline title/search header for non-Stack usage. @default true */
137
+ showBoardHeader?: boolean;
138
+ }
139
+
140
+ /** Shared screen content used by direct native and Expo Stack integrations. */
141
+ export function RoadmapScreenContent({
47
142
  pageSize,
48
143
  entryPageSize,
144
+ onEntryOpen,
145
+ query,
146
+ onQueryChange,
147
+ showBoardHeader = true,
49
148
  ...colors
50
- }: RoadmapScreenProps & {
149
+ }: RoadmapScreenContentProps) {
150
+ const [selected, setSelected] = useState<RoadmapItem | null>(null);
151
+
152
+ return (
153
+ <FeedbackBoard.Root {...colors}>
154
+ {selected === null ? (
155
+ <RoadmapBoardContent
156
+ pageSize={pageSize}
157
+ onItemOpen={setSelected}
158
+ query={query}
159
+ onQueryChange={onQueryChange}
160
+ showHeader={showBoardHeader}
161
+ />
162
+ ) : (
163
+ <RoadmapDetailPage
164
+ item={selected}
165
+ entryPageSize={entryPageSize}
166
+ onEntryOpen={onEntryOpen}
167
+ onBack={() => setSelected(null)}
168
+ />
169
+ )}
170
+ </FeedbackBoard.Root>
171
+ );
172
+ }
173
+
174
+ /** Board content used by both the self-contained and routed Expo screens. */
175
+ export function RoadmapBoardContent({
176
+ pageSize,
177
+ onItemOpen,
178
+ query: controlledQuery,
179
+ onQueryChange,
180
+ showHeader = true,
181
+ }: {
51
182
  pageSize: number;
52
- entryPageSize: number;
183
+ onItemOpen: (item: RoadmapItem) => void;
184
+ query?: string;
185
+ onQueryChange?: (query: string) => void;
186
+ showHeader?: boolean;
53
187
  }) {
188
+ const { hooks } = useFeedbackBody();
54
189
  const { messages, theme } = useFeedbackUi();
55
- const [query, setQuery] = useState("");
56
- const [selectedId, setSelectedId] = useState<string | null>(null);
190
+ const insets = useSafeAreaInsets();
191
+ const [internalQuery, setInternalQuery] = useState("");
192
+ const query = controlledQuery ?? internalQuery;
193
+ const setQuery = onQueryChange ?? setInternalQuery;
57
194
  const roadmap = hooks.useRoadmap();
58
195
  const search = hooks.useSearchRoadmap(query);
59
196
  const searching = query.trim().length > 0;
60
197
  const items = searching ? (search ?? []) : roadmap.results;
61
- const selected = items.find((item) => item.id === selectedId) ?? null;
62
-
63
- if (selected !== null) {
64
- return (
65
- <FeedbackBoard.Root {...colors}>
66
- <ScrollView
67
- contentContainerStyle={{ gap: theme.spacing, padding: theme.spacing }}
68
- >
69
- <RoadmapDetail
70
- item={selected}
71
- hooks={hooks}
72
- entryPageSize={entryPageSize}
73
- onEntryOpen={onEntryOpen}
74
- onUnauthenticated={onUnauthenticated}
75
- onBack={() => setSelectedId(null)}
76
- />
77
- </ScrollView>
78
- </FeedbackBoard.Root>
79
- );
80
- }
81
-
82
198
  const loading = searching
83
199
  ? search === undefined
84
200
  : roadmap.status === "LoadingFirstPage";
85
201
 
86
202
  return (
87
- <FeedbackBoard.Root {...colors}>
88
- <FeedbackBoard.Header
89
- style={{ gap: theme.spacing, padding: theme.spacing }}
90
- >
91
- <FeedbackBoard.Title>Roadmap</FeedbackBoard.Title>
92
- <Text style={{ color: theme.colors.mutedText }}>
93
- See what is planned, in progress, and shipped.
94
- </Text>
95
- <FeedbackBoard.Search
96
- value={query}
97
- onValueChange={setQuery}
98
- placeholder="Search roadmap"
99
- />
100
- </FeedbackBoard.Header>
101
- {loading ? (
102
- <FeedbackBoard.State>
103
- <Text style={{ color: theme.colors.mutedText }}>
104
- {messages.board.loading}
203
+ <View
204
+ style={[styles.boardScreen, { backgroundColor: theme.colors.background }]}
205
+ >
206
+ {showHeader && (
207
+ <FeedbackBoard.Header
208
+ style={[
209
+ styles.boardHeader,
210
+ { paddingTop: Math.max(theme.spacing, insets.top + 12) },
211
+ ]}
212
+ >
213
+ <FeedbackBoard.Title>{messages.roadmap.title}</FeedbackBoard.Title>
214
+ <Text style={[styles.subtitle, { color: theme.colors.mutedText }]}>
215
+ {messages.roadmap.subtitle}
105
216
  </Text>
106
- </FeedbackBoard.State>
107
- ) : items.length === 0 ? (
217
+ <FeedbackBoard.Search
218
+ value={query}
219
+ onValueChange={setQuery}
220
+ placeholder={messages.roadmap.searchPlaceholder}
221
+ accessibilityLabel={messages.roadmap.searchPlaceholder}
222
+ />
223
+ </FeedbackBoard.Header>
224
+ )}
225
+
226
+ {!loading && items.length === 0 ? (
108
227
  <FeedbackBoard.State>
109
- <Text style={{ color: theme.colors.mutedText }}>
110
- {searching ? "No roadmap items found." : "No roadmap items yet."}
228
+ <Text style={[styles.stateText, { color: theme.colors.mutedText }]}>
229
+ {searching
230
+ ? messages.roadmap.noSearchResults
231
+ : messages.roadmap.noItems}
111
232
  </Text>
112
233
  </FeedbackBoard.State>
113
234
  ) : (
114
- <FeedbackBoard.List contentContainerStyle={{ padding: theme.spacing }}>
115
- {items.map((item) => (
116
- <RoadmapCard
117
- key={item.id}
118
- item={item}
119
- onOpen={() => setSelectedId(item.id)}
120
- />
121
- ))}
122
- </FeedbackBoard.List>
235
+ <RoadmapBoard
236
+ items={items}
237
+ stages={stages.map((stage) => ({
238
+ ...stage,
239
+ label: messages.roadmap.statuses[stage.value],
240
+ }))}
241
+ colors={{
242
+ background: theme.colors.background,
243
+ surface: theme.colors.surface,
244
+ text: theme.colors.text,
245
+ muted: theme.colors.mutedText,
246
+ border: theme.colors.border,
247
+ primary: theme.colors.primary,
248
+ }}
249
+ emptyLabel={messages.roadmap.emptyStage}
250
+ loading={loading}
251
+ onItemOpen={onItemOpen}
252
+ renderItem={({ item, onOpen }) => (
253
+ <RoadmapCard item={item} onOpen={onOpen} />
254
+ )}
255
+ />
123
256
  )}
257
+
124
258
  {!searching &&
125
259
  (roadmap.status === "CanLoadMore" ||
126
260
  roadmap.status === "LoadingMore") && (
127
- <Button
128
- label={messages.board.loadMore}
129
- disabled={roadmap.status === "LoadingMore"}
130
- onPress={() => roadmap.loadMore(pageSize)}
131
- />
261
+ <View
262
+ style={{
263
+ alignItems: "center",
264
+ paddingBottom: insets.bottom + theme.spacing,
265
+ }}
266
+ >
267
+ <Button
268
+ label={messages.board.loadMore}
269
+ disabled={roadmap.status === "LoadingMore"}
270
+ onPress={() => roadmap.loadMore(pageSize)}
271
+ />
272
+ </View>
132
273
  )}
133
- </FeedbackBoard.Root>
134
- );
135
- }
136
-
137
- function RoadmapCard({
138
- item,
139
- onOpen,
140
- }: {
141
- item: RoadmapItem;
142
- onOpen: () => void;
143
- }) {
144
- const { theme } = useFeedbackUi();
145
- return (
146
- <Pressable onPress={onOpen} style={{ gap: 5, padding: 12 }}>
147
- <View
148
- style={{
149
- flexDirection: "row",
150
- justifyContent: "space-between",
151
- gap: 8,
152
- }}
153
- >
154
- <Text
155
- style={{
156
- color: theme.colors.mutedText,
157
- fontSize: 12,
158
- fontWeight: "600",
159
- }}
160
- >
161
- {item.status.replaceAll("_", " ")}
162
- </Text>
163
- <Text style={{ color: theme.colors.mutedText, fontSize: 12 }}>
164
- {item.feedbackCount} linked
165
- </Text>
166
- </View>
167
- <Text
168
- style={{ color: theme.colors.text, fontSize: 16, fontWeight: "700" }}
169
- >
170
- {item.title}
171
- </Text>
172
- {item.description && (
173
- <Text style={{ color: theme.colors.mutedText, lineHeight: 20 }}>
174
- {item.description}
175
- </Text>
176
- )}
177
- </Pressable>
274
+ </View>
178
275
  );
179
276
  }
180
277
 
181
- function RoadmapDetail({
278
+ /** Detail content used by routed roadmap item pages and in-screen navigation. */
279
+ export function RoadmapItemContent({
182
280
  item,
183
- hooks,
184
281
  entryPageSize,
185
282
  onEntryOpen,
186
- onUnauthenticated,
187
- onBack,
188
283
  }: {
189
284
  item: RoadmapItem;
190
- hooks: RoadmapScreenProps["hooks"];
191
285
  entryPageSize: number;
192
- onEntryOpen: RoadmapScreenProps["onEntryOpen"];
193
- onUnauthenticated: RoadmapScreenProps["onUnauthenticated"];
194
- onBack: () => void;
286
+ onEntryOpen?: (entryId: string) => void;
195
287
  }) {
288
+ const { hooks } = useFeedbackBody();
196
289
  const { messages, theme } = useFeedbackUi();
197
290
  const feedback = hooks.useRoadmapFeedback(item.id);
291
+ const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
292
+
293
+ if (selectedEntryId !== null) {
294
+ return (
295
+ <View style={styles.entryDetail}>
296
+ <Button
297
+ label={messages.entry.back}
298
+ onPress={() => setSelectedEntryId(null)}
299
+ />
300
+ <EntryDetail
301
+ entryId={selectedEntryId}
302
+ hideBackButton
303
+ onBack={() => setSelectedEntryId(null)}
304
+ />
305
+ </View>
306
+ );
307
+ }
308
+
309
+ const openEntry = (entryId: string) => {
310
+ if (onEntryOpen === undefined) {
311
+ setSelectedEntryId(entryId);
312
+ } else {
313
+ onEntryOpen(entryId);
314
+ }
315
+ };
316
+
198
317
  return (
199
- <View style={{ gap: theme.spacing }}>
200
- <Button label={messages.entry.back} onPress={onBack} />
201
- <View style={{ gap: 6 }}>
202
- <Text
203
- style={{
204
- color: theme.colors.mutedText,
205
- fontSize: 12,
206
- fontWeight: "600",
207
- }}
208
- >
209
- {item.status.replaceAll("_", " ")} · Position {item.position}
210
- </Text>
211
- <Text
212
- style={{ color: theme.colors.text, fontSize: 23, fontWeight: "700" }}
213
- >
318
+ <View style={styles.detailContent}>
319
+ <View style={styles.detailIntro}>
320
+ <RoadmapStatusBadge status={item.status} />
321
+ <Text style={[styles.detailTitle, { color: theme.colors.text }]}>
214
322
  {item.title}
215
323
  </Text>
216
- <Text style={{ color: theme.colors.mutedText, lineHeight: 21 }}>
217
- {item.description ?? "No description."}
218
- </Text>
219
- </View>
220
- <View style={{ gap: 4 }}>
221
- <RoadmapField label="ID" value={item.id} />
222
- <RoadmapField
223
- label="Linked feedback"
224
- value={String(item.feedbackCount)}
225
- />
226
- <RoadmapField
227
- label="Created"
228
- value={new Date(item.createdAt).toLocaleString()}
229
- />
230
- <RoadmapField
231
- label="Updated"
232
- value={new Date(item.updatedAt).toLocaleString()}
233
- />
234
- <RoadmapField
235
- label="Document created"
236
- value={new Date(item.creationTime).toLocaleString()}
237
- />
324
+ {item.description ? (
325
+ <Text
326
+ style={[
327
+ styles.detailDescription,
328
+ { color: theme.colors.mutedText },
329
+ ]}
330
+ >
331
+ {item.description}
332
+ </Text>
333
+ ) : null}
238
334
  </View>
239
- <Text
240
- style={{ color: theme.colors.text, fontSize: 18, fontWeight: "700" }}
241
- >
242
- Attached feedback
335
+
336
+ <Text style={[styles.sectionTitle, { color: theme.colors.text }]}>
337
+ {messages.roadmap.attachedFeedback}
243
338
  </Text>
339
+
244
340
  {feedback.status === "LoadingFirstPage" ? (
245
- <Text style={{ color: theme.colors.mutedText }}>
246
- {messages.board.loading}
247
- </Text>
341
+ <View style={styles.loadingInline}>
342
+ <ActivityIndicator color={theme.colors.primary} />
343
+ </View>
248
344
  ) : feedback.results.length === 0 ? (
249
- <Text style={{ color: theme.colors.mutedText }}>
250
- No feedback attached.
345
+ <Text style={[styles.emptyText, { color: theme.colors.mutedText }]}>
346
+ {messages.roadmap.noAttachedFeedback}
251
347
  </Text>
252
348
  ) : (
253
- <View style={{ gap: 8 }}>
349
+ <View style={styles.entryList}>
254
350
  {feedback.results.map((entry) => (
255
- <RoadmapEntry
351
+ <EntryCard
256
352
  key={entry.id}
257
353
  entry={entry}
258
354
  hooks={hooks}
259
- onOpen={onEntryOpen}
260
- onUnauthenticated={onUnauthenticated}
355
+ titleNumberOfLines={1}
356
+ onOpen={() => openEntry(entry.id)}
261
357
  />
262
358
  ))}
263
359
  </View>
264
360
  )}
361
+
265
362
  {(feedback.status === "CanLoadMore" ||
266
363
  feedback.status === "LoadingMore") && (
267
364
  <Button
268
- label={messages.comments.loadMore}
365
+ label={messages.board.loadMore}
269
366
  disabled={feedback.status === "LoadingMore"}
270
367
  onPress={() => feedback.loadMore(entryPageSize)}
271
368
  />
@@ -274,72 +371,148 @@ function RoadmapDetail({
274
371
  );
275
372
  }
276
373
 
277
- function RoadmapField({ label, value }: { label: string; value: string }) {
278
- const { theme } = useFeedbackUi();
374
+ function RoadmapDetailPage({
375
+ item,
376
+ entryPageSize,
377
+ onEntryOpen,
378
+ onBack,
379
+ }: {
380
+ item: RoadmapItem;
381
+ entryPageSize: number;
382
+ onEntryOpen?: (entryId: string) => void;
383
+ onBack: () => void;
384
+ }) {
385
+ const { theme, messages } = useFeedbackUi();
386
+ const insets = useSafeAreaInsets();
387
+
279
388
  return (
280
- <View style={{ flexDirection: "row", gap: 8 }}>
281
- <Text style={{ color: theme.colors.mutedText, fontWeight: "600" }}>
282
- {label}:
283
- </Text>
284
- <Text style={{ flex: 1, color: theme.colors.text }}>{value}</Text>
389
+ <View
390
+ style={[
391
+ styles.detailScreen,
392
+ { backgroundColor: theme.colors.background },
393
+ ]}
394
+ >
395
+ <View
396
+ style={[
397
+ styles.detailToolbar,
398
+ { paddingTop: Math.max(theme.spacing, insets.top + 8) },
399
+ ]}
400
+ >
401
+ <Button label={messages.entry.back} onPress={onBack} />
402
+ </View>
403
+ <FeedbackBoard.List
404
+ style={{
405
+ paddingHorizontal: theme.spacing,
406
+ paddingBottom: insets.bottom + theme.spacing * 2,
407
+ }}
408
+ >
409
+ <RoadmapItemContent
410
+ item={item}
411
+ entryPageSize={entryPageSize}
412
+ onEntryOpen={onEntryOpen}
413
+ />
414
+ </FeedbackBoard.List>
285
415
  </View>
286
416
  );
287
417
  }
288
418
 
289
- function RoadmapEntry({
290
- entry,
291
- hooks,
419
+ function RoadmapCard({
420
+ item,
292
421
  onOpen,
293
- onUnauthenticated,
294
422
  }: {
295
- entry: FeedbackEntryData;
296
- hooks: RoadmapScreenProps["hooks"];
297
- onOpen: RoadmapScreenProps["onEntryOpen"];
298
- onUnauthenticated: RoadmapScreenProps["onUnauthenticated"];
423
+ item: RoadmapItem;
424
+ onOpen: () => void;
299
425
  }) {
300
- const { isAuthenticated } = useFeedbackAuth(hooks);
301
- const setUpvote = hooks.useSetEntryUpvote();
302
- const action = useNativeAction();
303
- const toggle = (desiredState: boolean) => {
304
- if (
305
- !allowAuthenticatedAction(isAuthenticated, onUnauthenticated) ||
306
- action.pending
307
- ) {
308
- return;
309
- }
310
- void action.run(
311
- () => setUpvote({ entryId: entry.id, desiredState }),
312
- "Could not update vote",
313
- );
314
- };
426
+ const { messages, theme } = useFeedbackUi();
315
427
 
316
428
  return (
317
- <FeedbackEntry.Root entry={entry}>
318
- <FeedbackEntry.Upvote
319
- disabled={isAuthenticated === undefined || action.pending}
320
- onToggle={toggle}
321
- />
322
- <Pressable
323
- onPress={onOpen === undefined ? undefined : () => onOpen(entry.id)}
324
- accessibilityRole={onOpen === undefined ? undefined : "button"}
325
- style={{ flex: 1, minWidth: 0 }}
326
- >
327
- <FeedbackEntry.Content>
328
- <View style={{ gap: 5 }}>
329
- <View style={{ flexDirection: "row", gap: 8 }}>
330
- <FeedbackEntry.Kind />
331
- <FeedbackEntry.Status />
332
- </View>
333
- <FeedbackEntry.Title />
334
- <FeedbackEntry.Body />
335
- <FeedbackEntry.CommentCount />
336
- </View>
337
- </FeedbackEntry.Content>
338
- </Pressable>
339
- </FeedbackEntry.Root>
429
+ <RoadmapBoardCard
430
+ colors={{ border: theme.colors.border, surface: theme.colors.surface }}
431
+ accessibilityLabel={item.title}
432
+ onPress={onOpen}
433
+ >
434
+ <View style={styles.cardMeta}>
435
+ <RoadmapStatusBadge status={item.status} />
436
+ </View>
437
+ <Text style={[styles.cardTitle, { color: theme.colors.text }]}>
438
+ {item.title}
439
+ </Text>
440
+ {item.description ? (
441
+ <Text
442
+ style={[styles.cardDescription, { color: theme.colors.mutedText }]}
443
+ numberOfLines={3}
444
+ >
445
+ {item.description}
446
+ </Text>
447
+ ) : null}
448
+ <Text style={[styles.cardLink, { color: theme.colors.primary }]}>
449
+ {messages.entry.open} ›
450
+ </Text>
451
+ </RoadmapBoardCard>
340
452
  );
341
453
  }
342
454
 
343
- function useFeedbackAuth(hooks: RoadmapScreenProps["hooks"]) {
344
- return { isAuthenticated: hooks.useIsAuthenticated() };
455
+ function RoadmapStatusBadge({ status }: { status: RoadmapStatus }) {
456
+ const { messages, theme } = useFeedbackUi();
457
+
458
+ return (
459
+ <View
460
+ style={[
461
+ styles.statusBadge,
462
+ {
463
+ borderColor: theme.colors.border,
464
+ backgroundColor: theme.colors.surfaceMuted,
465
+ },
466
+ ]}
467
+ >
468
+ <View
469
+ style={[styles.statusDot, { backgroundColor: theme.colors.primary }]}
470
+ />
471
+ <Text style={[styles.statusText, { color: theme.colors.mutedText }]}>
472
+ {messages.roadmap.statuses[status]}
473
+ </Text>
474
+ </View>
475
+ );
345
476
  }
477
+
478
+ const styles = StyleSheet.create({
479
+ boardScreen: { flex: 1, minHeight: 0 },
480
+ boardHeader: {
481
+ gap: 10,
482
+ paddingHorizontal: 18,
483
+ paddingBottom: 16,
484
+ },
485
+ subtitle: { fontSize: 14, lineHeight: 20 },
486
+ stateText: { textAlign: "center" },
487
+ cardMeta: {
488
+ flexDirection: "row",
489
+ alignItems: "center",
490
+ gap: 8,
491
+ },
492
+ cardTitle: { fontSize: 16, fontWeight: "700", lineHeight: 21 },
493
+ cardDescription: { fontSize: 13, lineHeight: 19 },
494
+ cardLink: { fontSize: 13, fontWeight: "700" },
495
+ statusBadge: {
496
+ alignSelf: "flex-start",
497
+ flexDirection: "row",
498
+ alignItems: "center",
499
+ gap: 5,
500
+ borderWidth: StyleSheet.hairlineWidth,
501
+ borderRadius: 999,
502
+ paddingHorizontal: 7,
503
+ paddingVertical: 4,
504
+ },
505
+ statusDot: { width: 6, height: 6, borderRadius: 3 },
506
+ statusText: { fontSize: 11, fontWeight: "700" },
507
+ detailScreen: { flex: 1, minHeight: 0 },
508
+ detailToolbar: { paddingHorizontal: 18, paddingBottom: 10 },
509
+ detailContent: { gap: 18 },
510
+ detailIntro: { gap: 10 },
511
+ detailTitle: { fontSize: 26, fontWeight: "800", lineHeight: 31 },
512
+ detailDescription: { fontSize: 15, lineHeight: 23 },
513
+ sectionTitle: { fontSize: 18, fontWeight: "800" },
514
+ loadingInline: { paddingVertical: 22 },
515
+ emptyText: { lineHeight: 20 },
516
+ entryList: { gap: 10 },
517
+ entryDetail: { gap: 16 },
518
+ });