convex-feedback-ui 0.1.8 → 0.2.0-beta.0

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 (137) hide show
  1. package/README.md +30 -1
  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/FeedbackRoutes.d.ts.map +1 -1
  8. package/dist/native/expo/FeedbackRoutes.js +17 -9
  9. package/dist/native/expo/FeedbackRoutes.js.map +1 -1
  10. package/dist/native/expo/FeedbackScreen.d.ts +1 -1
  11. package/dist/native/expo/FeedbackScreen.d.ts.map +1 -1
  12. package/dist/native/expo/FeedbackScreen.js +2 -2
  13. package/dist/native/expo/FeedbackScreen.js.map +1 -1
  14. package/dist/native/expo/FeedbackStack.d.ts +1 -1
  15. package/dist/native/expo/FeedbackStack.d.ts.map +1 -1
  16. package/dist/native/expo/FeedbackStack.js +15 -8
  17. package/dist/native/expo/FeedbackStack.js.map +1 -1
  18. package/dist/native/expo/FeedbackStackLayout.d.ts +1 -1
  19. package/dist/native/expo/FeedbackStackLayout.d.ts.map +1 -1
  20. package/dist/native/expo/FeedbackStackLayout.js +7 -2
  21. package/dist/native/expo/FeedbackStackLayout.js.map +1 -1
  22. package/dist/native/expo/index.d.ts +1 -0
  23. package/dist/native/expo/index.d.ts.map +1 -1
  24. package/dist/native/expo/index.js +1 -0
  25. package/dist/native/expo/index.js.map +1 -1
  26. package/dist/native/expo/types.d.ts +13 -0
  27. package/dist/native/expo/types.d.ts.map +1 -1
  28. package/dist/native/native/FeedbackScreen.d.ts +1 -1
  29. package/dist/native/native/FeedbackScreen.d.ts.map +1 -1
  30. package/dist/native/native/FeedbackScreen.js +2 -2
  31. package/dist/native/native/FeedbackScreen.js.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/helpers.d.ts +6 -1
  37. package/dist/native/shared/helpers.d.ts.map +1 -1
  38. package/dist/native/shared/helpers.js +33 -0
  39. package/dist/native/shared/helpers.js.map +1 -1
  40. package/dist/native/shared/ui/BodyContent.d.ts.map +1 -1
  41. package/dist/native/shared/ui/BodyContent.js +8 -2
  42. package/dist/native/shared/ui/BodyContent.js.map +1 -1
  43. package/dist/native/shared/ui/Button.d.ts +2 -1
  44. package/dist/native/shared/ui/Button.d.ts.map +1 -1
  45. package/dist/native/shared/ui/Button.js +3 -2
  46. package/dist/native/shared/ui/Button.js.map +1 -1
  47. package/dist/native/shared/ui/CommentBranch.d.ts.map +1 -1
  48. package/dist/native/shared/ui/CommentBranch.js +33 -11
  49. package/dist/native/shared/ui/CommentBranch.js.map +1 -1
  50. package/dist/native/shared/ui/EntryCard.d.ts.map +1 -1
  51. package/dist/native/shared/ui/EntryCard.js +13 -1
  52. package/dist/native/shared/ui/EntryCard.js.map +1 -1
  53. package/dist/native/shared/ui/EntryDetail.d.ts.map +1 -1
  54. package/dist/native/shared/ui/EntryDetail.js +23 -5
  55. package/dist/native/shared/ui/EntryDetail.js.map +1 -1
  56. package/dist/native/shared/ui/FeedbackScreenHeader.d.ts.map +1 -1
  57. package/dist/native/shared/ui/FeedbackScreenHeader.js +7 -2
  58. package/dist/native/shared/ui/FeedbackScreenHeader.js.map +1 -1
  59. package/dist/native/shared/ui/FeedbackScreenList.d.ts.map +1 -1
  60. package/dist/native/shared/ui/FeedbackScreenList.js +16 -8
  61. package/dist/native/shared/ui/FeedbackScreenList.js.map +1 -1
  62. package/dist/native/shared/ui/NewEntry.d.ts.map +1 -1
  63. package/dist/native/shared/ui/NewEntry.js +13 -11
  64. package/dist/native/shared/ui/NewEntry.js.map +1 -1
  65. package/dist/native/shared/ui/RoadmapScreen.d.ts +3 -0
  66. package/dist/native/shared/ui/RoadmapScreen.d.ts.map +1 -0
  67. package/dist/native/shared/ui/RoadmapScreen.js +73 -0
  68. package/dist/native/shared/ui/RoadmapScreen.js.map +1 -0
  69. package/dist/react/action.d.ts +14 -0
  70. package/dist/react/action.d.ts.map +1 -0
  71. package/dist/react/action.js +38 -0
  72. package/dist/react/action.js.map +1 -0
  73. package/dist/react/index.d.ts +1 -0
  74. package/dist/react/index.d.ts.map +1 -1
  75. package/dist/react/index.js +1 -0
  76. package/dist/react/index.js.map +1 -1
  77. package/dist/react/roadmap.d.ts +3 -0
  78. package/dist/react/roadmap.d.ts.map +1 -0
  79. package/dist/react/roadmap.js +59 -0
  80. package/dist/react/roadmap.js.map +1 -0
  81. package/dist/react/screen.d.ts +1 -1
  82. package/dist/react/screen.d.ts.map +1 -1
  83. package/dist/react/screen.js +99 -42
  84. package/dist/react/screen.js.map +1 -1
  85. package/dist/react/styles.css +42 -0
  86. package/dist/shared/context/FeedbackBodyProvider.d.ts +1 -1
  87. package/dist/shared/context/FeedbackBodyProvider.d.ts.map +1 -1
  88. package/dist/shared/context/FeedbackBodyProvider.js +8 -2
  89. package/dist/shared/context/FeedbackBodyProvider.js.map +1 -1
  90. package/dist/shared/helpers.d.ts +6 -0
  91. package/dist/shared/helpers.d.ts.map +1 -1
  92. package/dist/shared/helpers.js +12 -0
  93. package/dist/shared/helpers.js.map +1 -1
  94. package/dist/shared/types/index.d.ts +1 -0
  95. package/dist/shared/types/index.d.ts.map +1 -1
  96. package/dist/shared/types/messages.d.ts +1 -1
  97. package/dist/shared/types/messages.d.ts.map +1 -1
  98. package/dist/shared/types/roadmap.d.ts +17 -0
  99. package/dist/shared/types/roadmap.d.ts.map +1 -0
  100. package/dist/shared/types/roadmap.js +2 -0
  101. package/dist/shared/types/roadmap.js.map +1 -0
  102. package/dist/shared/types/screen.d.ts +10 -1
  103. package/dist/shared/types/screen.d.ts.map +1 -1
  104. package/package.json +2 -2
  105. package/src/native/expo/ExpoFeedbackBody.tsx +2 -0
  106. package/src/native/expo/FeedbackRoutes.tsx +54 -30
  107. package/src/native/expo/FeedbackScreen.tsx +2 -0
  108. package/src/native/expo/FeedbackStack.tsx +46 -27
  109. package/src/native/expo/FeedbackStackLayout.tsx +11 -1
  110. package/src/native/expo/README.md +15 -0
  111. package/src/native/expo/index.ts +1 -0
  112. package/src/native/expo/types.ts +12 -0
  113. package/src/native/native/FeedbackScreen.tsx +2 -0
  114. package/src/native/native/README.md +4 -0
  115. package/src/native/native/index.ts +1 -0
  116. package/src/native/shared/helpers.ts +38 -1
  117. package/src/native/shared/ui/BodyContent.tsx +15 -2
  118. package/src/native/shared/ui/Button.tsx +4 -0
  119. package/src/native/shared/ui/CommentBranch.tsx +58 -15
  120. package/src/native/shared/ui/EntryCard.tsx +21 -3
  121. package/src/native/shared/ui/EntryDetail.tsx +42 -8
  122. package/src/native/shared/ui/FeedbackScreenHeader.tsx +9 -1
  123. package/src/native/shared/ui/FeedbackScreenList.tsx +28 -13
  124. package/src/native/shared/ui/NewEntry.tsx +25 -12
  125. package/src/native/shared/ui/RoadmapScreen.tsx +345 -0
  126. package/src/react/README.md +1 -1
  127. package/src/react/action.tsx +56 -0
  128. package/src/react/index.ts +1 -0
  129. package/src/react/roadmap.tsx +303 -0
  130. package/src/react/screen.tsx +199 -75
  131. package/src/react/styles.css +42 -0
  132. package/src/shared/context/FeedbackBodyProvider.tsx +9 -1
  133. package/src/shared/helpers.ts +16 -0
  134. package/src/shared/types/index.ts +1 -0
  135. package/src/shared/types/messages.ts +1 -1
  136. package/src/shared/types/roadmap.ts +25 -0
  137. package/src/shared/types/screen.ts +12 -1
@@ -1,13 +1,19 @@
1
1
  import { Stack, useRouter } from "expo-router";
2
+ import { Fragment } from "react";
2
3
  import { useFeedbackBody } from "../../shared/context/FeedbackBodyProvider";
3
4
  import { useFeedbackUi } from "../../shared/context/FeedbackProvider";
4
- import { createEntryLabel, entryStatusChoices } from "../../shared/helpers";
5
+ import {
6
+ allowAuthenticatedAction,
7
+ createEntryLabel,
8
+ entryStatusChoices,
9
+ } from "../../shared/helpers";
5
10
  import type { FeedbackStackProps } from "./types";
6
11
 
7
12
  export function FeedbackStack({
8
13
  searchRef,
9
14
  stackOptions,
10
15
  androidToolbarIcons = {},
16
+ BottomToolbarWrapper,
11
17
  children,
12
18
  }: FeedbackStackProps) {
13
19
  const {
@@ -20,9 +26,12 @@ export function FeedbackStack({
20
26
  setSelectedEntryId,
21
27
  statusFilter,
22
28
  setStatusFilter,
29
+ isAuthenticated,
30
+ onUnauthenticated,
23
31
  } = useFeedbackBody();
24
32
  const { messages, theme } = useFeedbackUi();
25
33
  const router = useRouter();
34
+ const ToolbarWrapper = BottomToolbarWrapper ?? Fragment;
26
35
 
27
36
  const canGoBack = !!selectedEntryId || router.canGoBack();
28
37
 
@@ -64,7 +73,12 @@ export function FeedbackStack({
64
73
  }
65
74
  variant="prominent"
66
75
  accessibilityLabel={createEntryLabel(enabledKinds, messages)}
67
- onPress={() => setShowForm(true)}
76
+ disabled={isAuthenticated === undefined}
77
+ onPress={() => {
78
+ if (allowAuthenticatedAction(isAuthenticated, onUnauthenticated)) {
79
+ setShowForm(true);
80
+ }
81
+ }}
68
82
  tintColor={theme.colors.primary}
69
83
  >
70
84
  {createEntryLabel(enabledKinds, messages)}
@@ -91,7 +105,10 @@ export function FeedbackStack({
91
105
  <Stack.SearchBar
92
106
  onChangeText={(q) => {
93
107
  if (selectedEntryId) setSelectedEntryId(null);
94
- setQuery(q.nativeEvent.text);
108
+ setQuery(
109
+ (q as unknown as { nativeEvent: { text: string } }).nativeEvent
110
+ .text,
111
+ );
95
112
  }}
96
113
  onFocus={() => {
97
114
  if (selectedEntryId) setSelectedEntryId(null);
@@ -104,30 +121,32 @@ export function FeedbackStack({
104
121
  ref={searchRef}
105
122
  textColor={theme.colors.text}
106
123
  />
107
- <Stack.Toolbar placement="bottom">
108
- <Stack.Toolbar.SearchBarSlot />
109
- <Stack.Toolbar.Spacer />
110
- <Stack.Toolbar.Menu
111
- icon={
112
- process.env.EXPO_OS === "ios"
113
- ? "line.3.horizontal.decrease"
114
- : androidToolbarIcons.filter
115
- }
116
- title={messages.board.statusFilter}
117
- accessibilityLabel={messages.board.statusFilter}
118
- tintColor={theme.colors.text}
119
- >
120
- {entryStatusChoices(messages).map(({ value, label }) => (
121
- <Stack.Toolbar.MenuAction
122
- key={value}
123
- isOn={statusFilter === value}
124
- onPress={() => setStatusFilter(value)}
125
- >
126
- {label}
127
- </Stack.Toolbar.MenuAction>
128
- ))}
129
- </Stack.Toolbar.Menu>
130
- </Stack.Toolbar>
124
+ <ToolbarWrapper>
125
+ <Stack.Toolbar placement="bottom">
126
+ <Stack.Toolbar.SearchBarSlot />
127
+ <Stack.Toolbar.Spacer />
128
+ <Stack.Toolbar.Menu
129
+ icon={
130
+ process.env.EXPO_OS === "ios"
131
+ ? "line.3.horizontal.decrease"
132
+ : androidToolbarIcons.filter
133
+ }
134
+ title={messages.board.statusFilter}
135
+ accessibilityLabel={messages.board.statusFilter}
136
+ tintColor={theme.colors.text}
137
+ >
138
+ {entryStatusChoices(messages).map(({ value, label }) => (
139
+ <Stack.Toolbar.MenuAction
140
+ key={value}
141
+ isOn={statusFilter === value}
142
+ onPress={() => setStatusFilter(value)}
143
+ >
144
+ {label}
145
+ </Stack.Toolbar.MenuAction>
146
+ ))}
147
+ </Stack.Toolbar.Menu>
148
+ </Stack.Toolbar>
149
+ </ToolbarWrapper>
131
150
  </>
132
151
  );
133
152
  }
@@ -25,12 +25,14 @@ export function FeedbackStackLayout({
25
25
  loading,
26
26
  transformComments,
27
27
  renderActor,
28
+ onUnauthenticated,
28
29
  routes: routeOverrides,
29
30
  screenOptions,
30
31
  boardOptions,
31
32
  entryOptions,
32
33
  createOptions,
33
34
  androidToolbarIcons = {},
35
+ BottomToolbarWrapper,
34
36
  primaryColor,
35
37
  primaryForeground,
36
38
  backgroundColor,
@@ -80,8 +82,16 @@ export function FeedbackStackLayout({
80
82
  collectStandardMetadata={collectExpoMetadata}
81
83
  transformComments={transformComments}
82
84
  renderActor={renderActor}
85
+ onUnauthenticated={onUnauthenticated}
83
86
  >
84
- <RoutedFeedbackProvider value={{ routes, colors, androidToolbarIcons }}>
87
+ <RoutedFeedbackProvider
88
+ value={{
89
+ routes,
90
+ colors,
91
+ androidToolbarIcons,
92
+ bottomToolbarWrapper: BottomToolbarWrapper,
93
+ }}
94
+ >
85
95
  <Stack>
86
96
  <Stack.Screen
87
97
  name={routes.board}
@@ -37,5 +37,20 @@ Toolbar actions retain their package-provided SF Symbols on iOS. Pass
37
37
  `ImageSourcePropType` values for the create, back, close, and filter actions.
38
38
  Missing Android images fall back to text labels.
39
39
 
40
+ > [!WARNING]
41
+ > If the filter toolbar button is intermittently broken, especially on iOS 26+, pass `Host` from `@expo/ui/swift-ui` to `BottomToolbarWrapper`. This wraps every bottom toolbar:
42
+ >
43
+ > ```tsx
44
+ > import { Host } from "@expo/ui/swift-ui";
45
+ >
46
+ > <FeedbackScreen hooks={feedbackHooks} BottomToolbarWrapper={Host} />;
47
+ > ```
48
+
49
+ Use the same prop on `FeedbackStackLayout` for routed navigation.
50
+
40
51
  The existing `FeedbackScreen` remains the self-contained API for conditional
41
52
  in-screen navigation.
53
+
54
+ `RoadmapScreen` is also exported from this entry point for public roadmap
55
+ browsing. Pass `onEntryOpen` for host navigation and `onUnauthenticated` for
56
+ the host's sign-in UI.
@@ -5,6 +5,7 @@ export * from "../../shared/metadata.js";
5
5
  export * from "../../shared/theme.js";
6
6
  export type * from "../../shared/types/index.js";
7
7
  export * from "../shared/ui/primitives.js";
8
+ export * from "../shared/ui/RoadmapScreen.js";
8
9
  export * from "./metadata.js";
9
10
  export * from "./FeedbackScreen.js";
10
11
  export * from "./FeedbackCreateStackLayout.js";
@@ -1,4 +1,5 @@
1
1
  import type { StackScreenProps } from "expo-router";
2
+ import type { ComponentType, ReactNode } from "react";
2
3
  import type { ImageSourcePropType } from "react-native";
3
4
  import type { SearchBarCommands } from "react-native-screens";
4
5
  import type {
@@ -6,16 +7,22 @@ import type {
6
7
  FeedbackScreenRootProps,
7
8
  } from "../../shared/types";
8
9
 
10
+ /** Optional wrapper used around Expo Router bottom toolbars. */
11
+ export type BottomToolbarWrapper = ComponentType<{ children: ReactNode }>;
12
+
9
13
  interface FeedbackScreenWithStack {
10
14
  useStack?: true;
11
15
  StackOptions?: StackScreenProps["options"];
12
16
  /** Android image sources for toolbar actions. iOS continues to use SF Symbols. */
13
17
  androidToolbarIcons?: FeedbackAndroidToolbarIcons;
18
+ /** Optional component wrapping the bottom search/filter toolbar. */
19
+ BottomToolbarWrapper?: BottomToolbarWrapper;
14
20
  }
15
21
  interface FeedbackScreenWithoutStack {
16
22
  useStack?: false;
17
23
  StackOptions?: never;
18
24
  androidToolbarIcons?: never;
25
+ BottomToolbarWrapper?: never;
19
26
  }
20
27
  export type FeedbackScreenStackProps =
21
28
  FeedbackScreenWithStack | FeedbackScreenWithoutStack;
@@ -27,6 +34,8 @@ export interface FeedbackStackProps {
27
34
  searchRef: React.RefObject<SearchBarCommands | null>;
28
35
  stackOptions?: StackScreenProps["options"];
29
36
  androidToolbarIcons?: FeedbackAndroidToolbarIcons;
37
+ /** Optional component wrapping the bottom search/filter toolbar. */
38
+ BottomToolbarWrapper?: BottomToolbarWrapper;
30
39
  children: React.ReactNode;
31
40
  }
32
41
 
@@ -69,6 +78,8 @@ export interface FeedbackStackLayoutProps extends FeedbackScreenRootProps {
69
78
  createOptions?: FeedbackStackScreenOptions;
70
79
  /** Android image sources for toolbar actions. iOS continues to use SF Symbols. */
71
80
  androidToolbarIcons?: FeedbackAndroidToolbarIcons;
81
+ /** Optional component wrapping the bottom search/filter toolbar. */
82
+ BottomToolbarWrapper?: BottomToolbarWrapper;
72
83
  }
73
84
 
74
85
  export interface FeedbackCreateStackLayoutProps {
@@ -84,4 +95,5 @@ export interface RoutedFeedbackContextValue {
84
95
  routes: FeedbackRouteNames;
85
96
  colors: FeedbackColorProps;
86
97
  androidToolbarIcons: FeedbackAndroidToolbarIcons;
98
+ bottomToolbarWrapper?: BottomToolbarWrapper;
87
99
  }
@@ -20,6 +20,7 @@ export function FeedbackScreen({
20
20
  loading,
21
21
  transformComments,
22
22
  renderActor,
23
+ onUnauthenticated,
23
24
  ...props
24
25
  }: FeedbackScreenRootProps) {
25
26
  return (
@@ -37,6 +38,7 @@ export function FeedbackScreen({
37
38
  collectStandardMetadata={collectNativeMetadata}
38
39
  transformComments={transformComments}
39
40
  renderActor={renderActor}
41
+ onUnauthenticated={onUnauthenticated}
40
42
  >
41
43
  <FeedbackScreenContent {...props} />
42
44
  </FeedbackBodyProvider>
@@ -15,3 +15,7 @@ To replace a control with Expo UI, use render-function children on an interactiv
15
15
  ```
16
16
 
17
17
  Containers such as `FeedbackEntry.Content`, `FeedbackForm.Root`, and `Comment.Children` accept arbitrary children, so custom Expo UI fields/buttons can also be placed directly inside the compound layout.
18
+
19
+ `RoadmapScreen` provides the public roadmap and renders attached entries with
20
+ the same `FeedbackEntry` primitives. Pass `onUnauthenticated` to handle auth
21
+ when an anonymous visitor votes.
@@ -7,3 +7,4 @@ export type * from "../../shared/types/index.js";
7
7
  export * from "../shared/ui/primitives.js";
8
8
  export * from "../shared/metadata.js";
9
9
  export * from "./FeedbackScreen.js";
10
+ export * from "../shared/ui/RoadmapScreen.js";
@@ -1,4 +1,5 @@
1
- import type { StyleProp } from "react-native";
1
+ import { useState } from "react";
2
+ import { Alert, type StyleProp } from "react-native";
2
3
 
3
4
  export function combineStyle<T>(
4
5
  enabled: boolean,
@@ -7,3 +8,39 @@ export function combineStyle<T>(
7
8
  ): StyleProp<T> {
8
9
  return enabled ? [fallback, style] : style;
9
10
  }
11
+
12
+ function errorMessage(error: unknown): string {
13
+ return error instanceof Error ? error.message : "Something went wrong.";
14
+ }
15
+
16
+ /** Runs a native UI mutation and offers a single retry action on failure. */
17
+ export function useNativeAction() {
18
+ const [pending, setPending] = useState(false);
19
+
20
+ const run = async <T>(
21
+ action: () => Promise<T>,
22
+ title: string,
23
+ retryAction: () => Promise<unknown> = action,
24
+ ): Promise<T | undefined> => {
25
+ if (pending) return undefined;
26
+ setPending(true);
27
+ try {
28
+ return await action();
29
+ } catch (error) {
30
+ Alert.alert(title, errorMessage(error), [
31
+ { text: "Cancel", style: "cancel" },
32
+ {
33
+ text: "Retry",
34
+ onPress: () => {
35
+ void run(retryAction, title, retryAction);
36
+ },
37
+ },
38
+ ]);
39
+ return undefined;
40
+ } finally {
41
+ setPending(false);
42
+ }
43
+ };
44
+
45
+ return { pending, run };
46
+ }
@@ -1,5 +1,6 @@
1
1
  import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider.js";
2
2
  import type { FeedbackScreenContentProps } from "../../../shared/types";
3
+ import { allowAuthenticatedAction } from "../../../shared/helpers.js";
3
4
  import { FeedbackScreenHeader } from "./FeedbackScreenHeader";
4
5
  import { FeedbackScreenList } from "./FeedbackScreenList";
5
6
  import { CreateEntryModal } from "./NewEntry";
@@ -12,13 +13,25 @@ export function FeedbackScreenContent({
12
13
  onCreateEntry,
13
14
  ...colors
14
15
  }: FeedbackScreenContentProps) {
15
- const { showForm, setShowForm, setSelectedEntryId } = useFeedbackBody();
16
+ const {
17
+ showForm,
18
+ setShowForm,
19
+ setSelectedEntryId,
20
+ isAuthenticated,
21
+ onUnauthenticated,
22
+ } = useFeedbackBody();
16
23
 
17
24
  const handleEntryOpen = (entryId: string) => {
18
25
  setSelectedEntryId(entryId);
19
26
  onEntryOpen?.(entryId);
20
27
  };
21
28
 
29
+ const handleCreateEntry = () => {
30
+ if (allowAuthenticatedAction(isAuthenticated, onUnauthenticated)) {
31
+ (onCreateEntry ?? (() => setShowForm(true)))();
32
+ }
33
+ };
34
+
22
35
  return (
23
36
  <>
24
37
  <FeedbackBoard.Root {...colors}>
@@ -27,7 +40,7 @@ export function FeedbackScreenContent({
27
40
  <FeedbackScreenList
28
41
  hideBackButton={hideBackButton}
29
42
  onEntryOpen={handleEntryOpen}
30
- onCreateEntry={onCreateEntry}
43
+ onCreateEntry={handleCreateEntry}
31
44
  />
32
45
  </FeedbackBoard.Root>
33
46
 
@@ -5,16 +5,19 @@ export function Button({
5
5
  label,
6
6
  onPress,
7
7
  variant = "default",
8
+ disabled = false,
8
9
  }: {
9
10
  label: string;
10
11
  onPress: () => void;
11
12
  variant?: "default" | "primary";
13
+ disabled?: boolean;
12
14
  }) {
13
15
  const { theme } = useFeedbackUi();
14
16
  const primary = variant === "primary";
15
17
  return (
16
18
  <Pressable
17
19
  onPress={onPress}
20
+ disabled={disabled}
18
21
  style={{
19
22
  alignSelf: primary ? "center" : "flex-start",
20
23
  paddingVertical: 8,
@@ -23,6 +26,7 @@ export function Button({
23
26
  borderWidth: 1,
24
27
  borderColor: primary ? theme.colors.primary : theme.colors.border,
25
28
  backgroundColor: primary ? theme.colors.primary : undefined,
29
+ opacity: disabled ? 0.55 : 1,
26
30
  }}
27
31
  >
28
32
  <Text
@@ -3,9 +3,11 @@ import { Text, View } from "react-native";
3
3
 
4
4
  import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider.js";
5
5
  import { useFeedbackUi } from "../../../shared/context/FeedbackProvider.js";
6
+ import { allowAuthenticatedAction } from "../../../shared/helpers.js";
6
7
  import type { FeedbackScreenCommentBranchProps } from "../../../shared/types";
7
8
  import { Button } from "./Button";
8
9
  import { Comment, FeedbackForm } from "./primitives.js";
10
+ import { useNativeAction } from "../helpers.js";
9
11
 
10
12
  import type { FeedbackScreenReplyListProps } from "../../../shared/types";
11
13
 
@@ -32,9 +34,11 @@ export function ReplyList({
32
34
  {visible.map((reply) => (
33
35
  <CommentBranch key={reply.id} comment={reply} entryId={entryId} />
34
36
  ))}
35
- {replies.status === "CanLoadMore" && (
37
+ {(replies.status === "CanLoadMore" ||
38
+ replies.status === "LoadingMore") && (
36
39
  <Button
37
40
  label={messages.comments.loadMore}
41
+ disabled={replies.status === "LoadingMore"}
38
42
  onPress={() => replies.loadMore(hooks.pageSizes.replies)}
39
43
  />
40
44
  )}
@@ -46,13 +50,34 @@ export function CommentBranch({
46
50
  comment,
47
51
  entryId,
48
52
  }: FeedbackScreenCommentBranchProps) {
49
- const { hooks, maxCommentDepth, renderActor } = useFeedbackBody();
53
+ const {
54
+ hooks,
55
+ maxCommentDepth,
56
+ renderActor,
57
+ isAuthenticated,
58
+ onUnauthenticated,
59
+ } = useFeedbackBody();
50
60
  const { messages, theme } = useFeedbackUi();
51
61
  const [expanded, setExpanded] = useState(false);
52
62
  const [replying, setReplying] = useState(false);
53
63
  const [replyBody, setReplyBody] = useState("");
54
64
  const setLike = hooks.useSetCommentLike();
55
65
  const createComment = hooks.useCreateComment();
66
+ const likeAction = useNativeAction();
67
+ const replyAction = useNativeAction();
68
+
69
+ const toggleLike = (desiredState: boolean) => {
70
+ if (
71
+ !allowAuthenticatedAction(isAuthenticated, onUnauthenticated) ||
72
+ likeAction.pending
73
+ ) {
74
+ return;
75
+ }
76
+ void likeAction.run(
77
+ () => setLike({ commentId: comment.id, desiredState }),
78
+ "Could not update like",
79
+ );
80
+ };
56
81
 
57
82
  return (
58
83
  <Comment.Root comment={comment}>
@@ -69,12 +94,20 @@ export function CommentBranch({
69
94
  }}
70
95
  >
71
96
  <Comment.Like
72
- onToggle={(active) =>
73
- void setLike({ commentId: comment.id, desiredState: active })
74
- }
97
+ disabled={isAuthenticated === undefined || likeAction.pending}
98
+ onToggle={toggleLike}
75
99
  />
76
100
  {comment.body !== null && comment.depth < maxCommentDepth ? (
77
- <Comment.Reply onActivate={() => setReplying((value) => !value)} />
101
+ <Comment.Reply
102
+ disabled={isAuthenticated === undefined || replyAction.pending}
103
+ onActivate={() => {
104
+ if (
105
+ allowAuthenticatedAction(isAuthenticated, onUnauthenticated)
106
+ ) {
107
+ setReplying((value) => !value);
108
+ }
109
+ }}
110
+ />
78
111
  ) : null}
79
112
  <Comment.RepliesButton
80
113
  expanded={expanded}
@@ -89,16 +122,26 @@ export function CommentBranch({
89
122
  placeholder={messages.comments.placeholder}
90
123
  />
91
124
  <FeedbackForm.Submit
92
- onPress={async () => {
125
+ disabled={isAuthenticated === undefined || replyAction.pending}
126
+ submitting={replyAction.pending}
127
+ onPress={() => {
93
128
  if (replyBody.trim().length === 0) return;
94
- await createComment({
95
- entryId,
96
- parentCommentId: comment.id,
97
- body: replyBody,
98
- });
99
- setReplyBody("");
100
- setReplying(false);
101
- setExpanded(true);
129
+ if (
130
+ !allowAuthenticatedAction(isAuthenticated, onUnauthenticated) ||
131
+ replyAction.pending
132
+ ) {
133
+ return;
134
+ }
135
+ void replyAction.run(async () => {
136
+ await createComment({
137
+ entryId,
138
+ parentCommentId: comment.id,
139
+ body: replyBody,
140
+ });
141
+ setReplyBody("");
142
+ setReplying(false);
143
+ setExpanded(true);
144
+ }, "Could not add reply");
102
145
  }}
103
146
  >
104
147
  {messages.comments.reply}
@@ -1,6 +1,9 @@
1
1
  import { Pressable, View } from "react-native";
2
2
 
3
3
  import type { FeedbackScreenEntryCardProps } from "../../../shared/types";
4
+ import { allowAuthenticatedAction } from "../../../shared/helpers.js";
5
+ import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider.js";
6
+ import { useNativeAction } from "../helpers.js";
4
7
  import { FeedbackEntry } from "./primitives";
5
8
 
6
9
  export function EntryCard({
@@ -8,13 +11,28 @@ export function EntryCard({
8
11
  hooks,
9
12
  onOpen,
10
13
  }: FeedbackScreenEntryCardProps) {
14
+ const { isAuthenticated, onUnauthenticated } = useFeedbackBody();
11
15
  const setUpvote = hooks.useSetEntryUpvote();
16
+ const action = useNativeAction();
17
+
18
+ const toggleUpvote = (desiredState: boolean) => {
19
+ if (
20
+ !allowAuthenticatedAction(isAuthenticated, onUnauthenticated) ||
21
+ action.pending
22
+ ) {
23
+ return;
24
+ }
25
+ void action.run(
26
+ () => setUpvote({ entryId: entry.id, desiredState }),
27
+ "Could not update vote",
28
+ );
29
+ };
30
+
12
31
  return (
13
32
  <FeedbackEntry.Root entry={entry}>
14
33
  <FeedbackEntry.Upvote
15
- onToggle={(active) =>
16
- void setUpvote({ entryId: entry.id, desiredState: active })
17
- }
34
+ disabled={isAuthenticated === undefined || action.pending}
35
+ onToggle={toggleUpvote}
18
36
  />
19
37
  <Pressable onPress={onOpen} style={{ flex: 1, gap: 4 }}>
20
38
  <View
@@ -3,18 +3,26 @@ import { ActivityIndicator, Text, View } from "react-native";
3
3
 
4
4
  import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider";
5
5
  import { useFeedbackUi } from "../../../shared/context/FeedbackProvider";
6
+ import { allowAuthenticatedAction } from "../../../shared/helpers.js";
6
7
  import type { FeedbackScreenEntryDetailProps } from "../../../shared/types";
7
8
  import { Button } from "./Button";
8
9
  import { CommentBranch } from "./CommentBranch";
9
10
  import { FeedbackEntry, FeedbackForm } from "./primitives";
10
11
  import { MetadataModal } from "./MetadataModal";
12
+ import { useNativeAction } from "../helpers.js";
11
13
 
12
14
  export function EntryDetail({
13
15
  entryId,
14
16
  onBack,
15
17
  hideBackButton,
16
18
  }: FeedbackScreenEntryDetailProps) {
17
- const { hooks, commentSort, transformComments } = useFeedbackBody();
19
+ const {
20
+ hooks,
21
+ commentSort,
22
+ transformComments,
23
+ isAuthenticated,
24
+ onUnauthenticated,
25
+ } = useFeedbackBody();
18
26
 
19
27
  const { messages, theme } = useFeedbackUi();
20
28
  const entry = hooks.useEntry(entryId);
@@ -23,6 +31,21 @@ export function EntryDetail({
23
31
  const createComment = hooks.useCreateComment();
24
32
  const [body, setBody] = useState("");
25
33
  const [showMetadata, setShowMetadata] = useState(false);
34
+ const upvoteAction = useNativeAction();
35
+ const commentAction = useNativeAction();
36
+
37
+ const toggleUpvote = (desiredState: boolean) => {
38
+ if (
39
+ !allowAuthenticatedAction(isAuthenticated, onUnauthenticated) ||
40
+ upvoteAction.pending
41
+ ) {
42
+ return;
43
+ }
44
+ void upvoteAction.run(
45
+ () => setUpvote({ entryId, desiredState }),
46
+ "Could not update vote",
47
+ );
48
+ };
26
49
  const visible = useMemo(
27
50
  () => transformComments?.(comments.results) ?? comments.results,
28
51
  [comments.results, transformComments],
@@ -57,9 +80,8 @@ export function EntryDetail({
57
80
  )}
58
81
  <FeedbackEntry.Root entry={entry}>
59
82
  <FeedbackEntry.Upvote
60
- onToggle={(active) =>
61
- void setUpvote({ entryId, desiredState: active })
62
- }
83
+ disabled={isAuthenticated === undefined || upvoteAction.pending}
84
+ onToggle={toggleUpvote}
63
85
  />
64
86
  <FeedbackEntry.Content style={{ gap: 5 }}>
65
87
  <FeedbackEntry.Status />
@@ -92,10 +114,20 @@ export function EntryDetail({
92
114
  placeholder={messages.comments.placeholder}
93
115
  />
94
116
  <FeedbackForm.Submit
95
- onPress={async () => {
117
+ disabled={isAuthenticated === undefined || commentAction.pending}
118
+ submitting={commentAction.pending}
119
+ onPress={() => {
96
120
  if (body.trim().length === 0) return;
97
- await createComment({ entryId, body });
98
- setBody("");
121
+ if (
122
+ !allowAuthenticatedAction(isAuthenticated, onUnauthenticated) ||
123
+ commentAction.pending
124
+ ) {
125
+ return;
126
+ }
127
+ void commentAction.run(async () => {
128
+ await createComment({ entryId, body });
129
+ setBody("");
130
+ }, "Could not add comment");
99
131
  }}
100
132
  >
101
133
  {messages.comments.submit}
@@ -106,9 +138,11 @@ export function EntryDetail({
106
138
  <CommentBranch key={comment.id} comment={comment} entryId={entryId} />
107
139
  ))}
108
140
  </View>
109
- {comments.status === "CanLoadMore" && (
141
+ {(comments.status === "CanLoadMore" ||
142
+ comments.status === "LoadingMore") && (
110
143
  <Button
111
144
  label={messages.comments.loadMore}
145
+ disabled={comments.status === "LoadingMore"}
112
146
  onPress={() => comments.loadMore(hooks.pageSizes.comments)}
113
147
  />
114
148
  )}
@@ -3,6 +3,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
3
3
  import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider";
4
4
  import { useFeedbackUi } from "../../../shared/context/FeedbackProvider";
5
5
  import { createEntryLabel, entryStatusChoices } from "../../../shared/helpers";
6
+ import { allowAuthenticatedAction } from "../../../shared/helpers";
6
7
  import { ChoiceChips, FeedbackBoard } from "./primitives";
7
8
 
8
9
  export function FeedbackScreenHeader() {
@@ -14,6 +15,8 @@ export function FeedbackScreenHeader() {
14
15
  enabledKinds,
15
16
  statusFilter,
16
17
  setStatusFilter,
18
+ isAuthenticated,
19
+ onUnauthenticated,
17
20
  } = useFeedbackBody();
18
21
  const { messages, theme } = useFeedbackUi();
19
22
  const { top } = useSafeAreaInsets();
@@ -36,7 +39,12 @@ export function FeedbackScreenHeader() {
36
39
  onValueChange={setStatusFilter}
37
40
  />
38
41
  <Pressable
39
- onPress={() => setShowForm(true)}
42
+ disabled={isAuthenticated === undefined}
43
+ onPress={() => {
44
+ if (allowAuthenticatedAction(isAuthenticated, onUnauthenticated)) {
45
+ setShowForm(true);
46
+ }
47
+ }}
40
48
  style={{
41
49
  alignSelf: "flex-start",
42
50
  paddingVertical: 9,