@q9labsai/chalk-react-native 4.1.14 → 4.1.16

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 (69) hide show
  1. package/README.md +11 -0
  2. package/dist/android.js +5 -2
  3. package/dist/{chunk-MK6OO4V7.js → chunk-2OS7SSXE.js} +6 -1
  4. package/dist/{chunk-4NLA35HE.js → chunk-ITLUHTVS.js} +1126 -644
  5. package/dist/client.js +1 -1
  6. package/dist/components/Chalk.d.ts +2 -1
  7. package/dist/components/Chalk.d.ts.map +1 -1
  8. package/dist/components/EmbeddedWhiteboard.d.ts.map +1 -1
  9. package/dist/components/SpaceView.d.ts +2 -1
  10. package/dist/components/SpaceView.d.ts.map +1 -1
  11. package/dist/components/SpaceView.shared.d.ts.map +1 -1
  12. package/dist/components/native-space-view/FeedbackSheet.d.ts +12 -0
  13. package/dist/components/native-space-view/FeedbackSheet.d.ts.map +1 -0
  14. package/dist/components/native-space-view/SpaceActionMenu.d.ts +2 -1
  15. package/dist/components/native-space-view/SpaceActionMenu.d.ts.map +1 -1
  16. package/dist/components/native-space-view/SpaceChatAttachment.d.ts +8 -0
  17. package/dist/components/native-space-view/SpaceChatAttachment.d.ts.map +1 -0
  18. package/dist/components/native-space-view/SpaceChatSheet.d.ts.map +1 -1
  19. package/dist/components/native-space-view/SpaceMoreSheet.d.ts +2 -1
  20. package/dist/components/native-space-view/SpaceMoreSheet.d.ts.map +1 -1
  21. package/dist/components/native-space-view/SpaceTopBar.android.d.ts +3 -1
  22. package/dist/components/native-space-view/SpaceTopBar.android.d.ts.map +1 -1
  23. package/dist/components/native-space-view/screen-share-availability.d.ts +4 -1
  24. package/dist/components/native-space-view/screen-share-availability.d.ts.map +1 -1
  25. package/dist/components/native-space-view/space-chat-attachments.d.ts.map +1 -1
  26. package/dist/components/native-space-view/useSpaceViewController.d.ts +8 -2
  27. package/dist/components/native-space-view/useSpaceViewController.d.ts.map +1 -1
  28. package/dist/feedback/native-capture.d.ts +9 -0
  29. package/dist/feedback/native-capture.d.ts.map +1 -0
  30. package/dist/feedback/native-evidence.d.ts +3 -0
  31. package/dist/feedback/native-evidence.d.ts.map +1 -0
  32. package/dist/hooks/space-hooks.d.ts +1 -0
  33. package/dist/hooks/space-hooks.d.ts.map +1 -1
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +5 -2
  37. package/dist/ios-pad.js +5 -2
  38. package/dist/ios-phone.js +5 -2
  39. package/dist/ios.js +5 -2
  40. package/dist/macos.js +5 -2
  41. package/dist/space-client/create-native-space-client.d.ts +2 -0
  42. package/dist/space-client/create-native-space-client.d.ts.map +1 -1
  43. package/dist/whiteboard/embedded-whiteboard-viewport.d.ts +3 -0
  44. package/dist/whiteboard/embedded-whiteboard-viewport.d.ts.map +1 -0
  45. package/embedded/chalk-whiteboard/asset-manifest.json +2 -2
  46. package/embedded/chalk-whiteboard/renderer.js +581 -581
  47. package/package.json +8 -6
  48. package/src/components/Chalk.tsx +6 -2
  49. package/src/components/EmbeddedWhiteboard.tsx +14 -5
  50. package/src/components/SpaceView.shared.tsx +58 -47
  51. package/src/components/SpaceView.tsx +2 -1
  52. package/src/components/native-space-view/FeedbackSheet.tsx +296 -0
  53. package/src/components/native-space-view/SpaceActionMenu.tsx +23 -3
  54. package/src/components/native-space-view/SpaceChatAttachment.tsx +105 -0
  55. package/src/components/native-space-view/SpaceChatSheet.tsx +14 -9
  56. package/src/components/native-space-view/SpaceMoreSheet.tsx +35 -6
  57. package/src/components/native-space-view/SpaceTopBar.android.tsx +16 -1
  58. package/src/components/native-space-view/screen-share-availability.test.ts +16 -0
  59. package/src/components/native-space-view/screen-share-availability.ts +4 -1
  60. package/src/components/native-space-view/space-chat-attachments.test.ts +26 -0
  61. package/src/components/native-space-view/space-chat-attachments.ts +2 -4
  62. package/src/components/native-space-view/useSpaceViewController.ts +28 -8
  63. package/src/feedback/native-capture.ts +62 -0
  64. package/src/feedback/native-evidence.ts +37 -0
  65. package/src/hooks/space-hooks.ts +4 -0
  66. package/src/index.ts +1 -1
  67. package/src/space-client/create-native-space-client.ts +7 -0
  68. package/src/whiteboard/embedded-whiteboard-viewport.test.ts +13 -0
  69. package/src/whiteboard/embedded-whiteboard-viewport.ts +6 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@q9labsai/chalk-react-native",
3
- "version": "4.1.14",
3
+ "version": "4.1.16",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -150,13 +150,14 @@
150
150
  "registry": "https://registry.npmjs.org/"
151
151
  },
152
152
  "dependencies": {
153
- "@q9labsai/chalk-client": "^4.1.14",
154
- "@q9labsai/chalk-whiteboard": "^4.1.14",
155
- "@q9labsai/facehash": "^4.1.14"
153
+ "@q9labsai/chalk-client": "^4.1.16",
154
+ "@q9labsai/chalk-whiteboard": "^4.1.16",
155
+ "@q9labsai/facehash": "^4.1.16"
156
156
  },
157
157
  "devDependencies": {
158
158
  "@types/node": "^26.2.0",
159
159
  "@types/react": "^19.2.18",
160
+ "react-native-view-shot": "4.0.3",
160
161
  "react-native-webview": "13.16.1",
161
162
  "typescript": "^6.0.3"
162
163
  },
@@ -168,12 +169,13 @@
168
169
  "react": "^18.0.0 || ^19.0.0",
169
170
  "react-native": ">=0.77",
170
171
  "react-native-svg": "^15.15.3",
172
+ "react-native-view-shot": "^4.0.3",
171
173
  "react-native-webview": "^13.16.1"
172
174
  },
173
175
  "scripts": {
174
- "build": "rm -rf ./dist && tsup ./src/index.ts ./src/client.ts ./src/android.ts ./src/ios.ts ./src/ios-phone.ts ./src/ios-pad.ts ./src/macos.ts ./src/runtime.ts ./src/clipboard.ts ./src/diagnostics.ts ./src/invites.ts ./src/ui/theme.ts ./src/ui/appearance.ts --format esm --out-dir ./dist --platform browser --external react --external react-native --external react-native-webview --external @q9labsai/chalk-whiteboard --external @cloudflare/react-native-webrtc --external @cloudflare/realtimekit-react-native && pnpm run build:types && pnpm run sync:whiteboard-assets",
176
+ "build": "rm -rf ./dist && tsup ./src/index.ts ./src/client.ts ./src/android.ts ./src/ios.ts ./src/ios-phone.ts ./src/ios-pad.ts ./src/macos.ts ./src/runtime.ts ./src/clipboard.ts ./src/diagnostics.ts ./src/invites.ts ./src/ui/theme.ts ./src/ui/appearance.ts --format esm --out-dir ./dist --platform browser --external react --external react-native --external react-native-webview --external react-native-view-shot --external @q9labsai/chalk-whiteboard --external @cloudflare/react-native-webrtc --external @cloudflare/realtimekit-react-native && pnpm run build:types && pnpm run sync:whiteboard-assets",
175
177
  "build:types": "rm -f tsconfig.tsbuildinfo && tsc --build tsconfig.json",
176
- "dev": "tsup ./src/index.ts ./src/client.ts ./src/android.ts ./src/ios.ts ./src/ios-phone.ts ./src/ios-pad.ts ./src/macos.ts ./src/runtime.ts ./src/clipboard.ts ./src/diagnostics.ts ./src/invites.ts ./src/ui/theme.ts ./src/ui/appearance.ts --format esm --out-dir ./dist --platform browser --external react --external react-native --external react-native-webview --external @q9labsai/chalk-whiteboard --external @cloudflare/react-native-webrtc --external @cloudflare/realtimekit-react-native --watch",
178
+ "dev": "tsup ./src/index.ts ./src/client.ts ./src/android.ts ./src/ios.ts ./src/ios-phone.ts ./src/ios-pad.ts ./src/macos.ts ./src/runtime.ts ./src/clipboard.ts ./src/diagnostics.ts ./src/invites.ts ./src/ui/theme.ts ./src/ui/appearance.ts --format esm --out-dir ./dist --platform browser --external react --external react-native --external react-native-webview --external react-native-view-shot --external @q9labsai/chalk-whiteboard --external @cloudflare/react-native-webrtc --external @cloudflare/realtimekit-react-native --watch",
177
179
  "sync:whiteboard-assets": "node ./scripts/sync-whiteboard-assets.mjs",
178
180
  "test": "pnpm exec vitest run",
179
181
  "check-types": "tsc --project tsconfig.check-types.json --noEmit",
@@ -1,4 +1,4 @@
1
- import type { ChatUploadFile, ClientEventMap, GetAccess, SpaceClient } from "@q9labsai/chalk-client";
1
+ import type { ChatUploadFile, ClientEventMap, FeedbackEvidenceInput, GetAccess, SpaceClient } from "@q9labsai/chalk-client";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
3
3
  import { Pressable, StyleSheet, Text, View } from "react-native";
4
4
 
@@ -65,6 +65,7 @@ type ChalkCommonProps = ChalkCallbacks & {
65
65
  readonly layout?: SpaceLayout;
66
66
  readonly onLayoutChange?: (layout: SpaceLayout) => void;
67
67
  readonly onOpenDiagnostics?: () => void;
68
+ readonly feedbackEvidence?: Partial<FeedbackEvidenceInput>;
68
69
  };
69
70
 
70
71
  export type ChalkProps = ChalkCommonProps &
@@ -273,6 +274,7 @@ function SpaceExperience(props: ChalkProps): React.JSX.Element {
273
274
  logoUrl={props.logoUrl}
274
275
  onLayoutChange={props.onLayoutChange}
275
276
  onOpenDiagnostics={props.onOpenDiagnostics}
277
+ feedbackEvidence={props.feedbackEvidence}
276
278
  pickChatFiles={props.pickChatFiles}
277
279
  reconnecting={connection.status === "reconnecting"}
278
280
  spaceName={spaceName}
@@ -296,12 +298,13 @@ type NativeSpaceViewProps = {
296
298
  readonly logoUrl?: string;
297
299
  readonly onLayoutChange?: (layout: SpaceLayout) => void;
298
300
  readonly onOpenDiagnostics?: () => void;
301
+ readonly feedbackEvidence?: Partial<FeedbackEvidenceInput>;
299
302
  readonly pickChatFiles?: () => Promise<readonly ChatUploadFile[]>;
300
303
  readonly reconnecting: boolean;
301
304
  readonly spaceName: string;
302
305
  };
303
306
 
304
- function NativeSpaceView({ features, inviteLink, layout, logoUrl, onLayoutChange, onOpenDiagnostics, pickChatFiles, reconnecting, spaceName }: NativeSpaceViewProps): React.JSX.Element {
307
+ function NativeSpaceView({ features, inviteLink, layout, logoUrl, onLayoutChange, onOpenDiagnostics, feedbackEvidence, pickChatFiles, reconnecting, spaceName }: NativeSpaceViewProps): React.JSX.Element {
305
308
  const client = useSpaceClient();
306
309
  const theme = useNativeTheme();
307
310
 
@@ -316,6 +319,7 @@ function NativeSpaceView({ features, inviteLink, layout, logoUrl, onLayoutChange
316
319
  onLayoutChange={onLayoutChange}
317
320
  onLeave={() => client.leave()}
318
321
  onOpenDiagnostics={onOpenDiagnostics}
322
+ feedbackEvidence={feedbackEvidence}
319
323
  pickChatFiles={pickChatFiles}
320
324
  reconnecting={reconnecting}
321
325
  spaceName={spaceName}
@@ -1,10 +1,11 @@
1
1
  import { ChalkWhiteboardController, type ChalkEmbeddedWhiteboardTransport, type ChalkEmbeddedWhiteboardViewport } from "@q9labsai/chalk-whiteboard/embedded";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { ActivityIndicator, Pressable, StyleSheet, Text, useWindowDimensions, View, type StyleProp, type ViewStyle } from "react-native";
3
+ import { ActivityIndicator, Pressable, StyleSheet, Text, useWindowDimensions, View, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
4
4
  import WebView, { type WebViewMessageEvent, type WebViewNavigation } from "react-native-webview";
5
5
 
6
6
  import { useNativeTheme } from "../ui/native-theme";
7
7
  import { isEmbeddedWhiteboardNavigationAllowed, rendererURLWithContext, resolveEmbeddedWhiteboardRendererURL } from "../whiteboard/embedded-whiteboard-assets";
8
+ import { createEmbeddedWhiteboardViewport } from "../whiteboard/embedded-whiteboard-viewport";
8
9
 
9
10
  export interface EmbeddedWhiteboardProps {
10
11
  readonly transport: ChalkEmbeddedWhiteboardTransport;
@@ -51,7 +52,11 @@ export function EmbeddedWhiteboard({ transport, journeyId, traceparent, tracesta
51
52
  const [loadAttempt, setLoadAttempt] = useState(0);
52
53
  const [loadError, setLoadError] = useState<string | null>(null);
53
54
  const [compatibilityNotice, setCompatibilityNotice] = useState<string | null>(null);
54
- const { width, height, scale } = useWindowDimensions();
55
+ const [layout, setLayout] = useState({ width: 0, height: 0 });
56
+ const { scale } = useWindowDimensions();
57
+ const viewport = useMemo<ChalkEmbeddedWhiteboardViewport | null>(() => createEmbeddedWhiteboardViewport(layout.width, layout.height, scale), [layout.height, layout.width, scale]);
58
+ const latestViewportRef = useRef(viewport);
59
+ latestViewportRef.current = viewport;
55
60
  onMetricRef.current = onMetric;
56
61
  onErrorRef.current = onError;
57
62
  onUserExportRef.current = onUserExport;
@@ -106,6 +111,7 @@ export function EmbeddedWhiteboard({ transport, journeyId, traceparent, tracesta
106
111
  onUserExport: handleControllerUserExport,
107
112
  });
108
113
  controller.start();
114
+ if (latestViewportRef.current) controller.setViewport(latestViewportRef.current);
109
115
  controllerRef.current = controller;
110
116
  return () => {
111
117
  if (controllerRef.current === controller) controllerRef.current = null;
@@ -117,10 +123,13 @@ export function EmbeddedWhiteboard({ transport, journeyId, traceparent, tracesta
117
123
  controllerRef.current?.setCapabilities({ canDraw, canClear });
118
124
  }, [canClear, canDraw]);
119
125
 
120
- const viewport = useMemo<ChalkEmbeddedWhiteboardViewport>(() => ({ width, height, scale }), [height, scale, width]);
121
126
  useEffect(() => {
122
- controllerRef.current?.setViewport(viewport);
127
+ if (viewport) controllerRef.current?.setViewport(viewport);
123
128
  }, [viewport]);
129
+ const handleLayout = useCallback((event: LayoutChangeEvent): void => {
130
+ const next = { width: event.nativeEvent.layout.width, height: event.nativeEvent.layout.height };
131
+ setLayout((current) => (current.width === next.width && current.height === next.height ? current : next));
132
+ }, []);
124
133
 
125
134
  const sourceURL = rendererURL ? rendererURLWithContext(rendererURL, { journeyId, rendererGeneration }) : null;
126
135
  const handleMessage = (event: WebViewMessageEvent): void => {
@@ -168,7 +177,7 @@ export function EmbeddedWhiteboard({ transport, journeyId, traceparent, tracesta
168
177
  }
169
178
 
170
179
  return (
171
- <View style={[styles.container, style]} testID={testID}>
180
+ <View onLayout={handleLayout} style={[styles.container, style]} testID={testID}>
172
181
  <WebView
173
182
  key={rendererGeneration}
174
183
  ref={webViewRef}
@@ -1,5 +1,7 @@
1
+ import { useRef, useState } from "react";
1
2
  import { Alert, StyleSheet, Text, View } from "react-native";
2
3
 
4
+ import { useSpaceClient } from "../context/space-client-context";
3
5
  import { Theme } from "../ui/theme";
4
6
  import { useNativeTheme } from "../ui/native-theme";
5
7
  import { getIosSimulatorMediaMessage } from "../utils/ios-simulator";
@@ -8,6 +10,8 @@ import type { SpaceViewProps } from "./SpaceView";
8
10
  import { SpaceGridAndroid } from "./native-space-view/SpaceGrid.android";
9
11
  import { SpaceBottomDockAndroid } from "./native-space-view/SpaceBottomDock.android";
10
12
  import { SpaceActionMenu, selectSpaceReaction } from "./native-space-view/SpaceActionMenu";
13
+ import { FeedbackSheet } from "./native-space-view/FeedbackSheet";
14
+ import type { FeedbackCaptureSize } from "../feedback/native-capture";
11
15
  import { SpaceStageAndroid } from "./native-space-view/SpaceStage.android";
12
16
  import { SpaceTopBarAndroid } from "./native-space-view/SpaceTopBar.android";
13
17
  import { SpaceWhiteboardSurface } from "./native-space-view/SpaceWhiteboardSurface";
@@ -17,7 +21,11 @@ import { useSpaceViewController } from "./native-space-view/useSpaceViewControll
17
21
 
18
22
  export function SpaceViewShared(props: SpaceViewProps): React.JSX.Element {
19
23
  const controller = useSpaceViewController(props);
24
+ const client = useSpaceClient();
20
25
  const theme = useNativeTheme();
26
+ const captureTarget = useRef<View>(null);
27
+ const [captureSize, setCaptureSize] = useState<FeedbackCaptureSize>({ width: 0, height: 0 });
28
+ const [feedbackRequested, setFeedbackRequested] = useState(false);
21
29
  const toggleMedia = (action: () => void) => {
22
30
  if (controller.simulatorMediaDisabled) {
23
31
  Alert.alert("Media unavailable", getIosSimulatorMediaMessage());
@@ -27,55 +35,58 @@ export function SpaceViewShared(props: SpaceViewProps): React.JSX.Element {
27
35
  };
28
36
 
29
37
  return (
30
- <View style={[styles.space, { backgroundColor: theme.colors.darkCanvas }]}>
31
- <SpaceTopBarAndroid formattedDuration={controller.formattedDuration} logoUrl={props.logoUrl} participantCount={controller.participantCount} spaceName={controller.spaceName} />
32
- {props.reconnecting ? (
33
- <View accessibilityLiveRegion="polite" style={[styles.recovery, { backgroundColor: theme.colors.primary }]}>
34
- <Text style={[styles.recoveryText, { color: theme.colors.primaryForeground }]}>Reconnecting… controls will resume shortly.</Text>
38
+ <>
39
+ <View collapsable={false} onLayout={(event) => setCaptureSize({ width: event.nativeEvent.layout.width, height: event.nativeEvent.layout.height })} ref={captureTarget} style={[styles.space, { backgroundColor: theme.colors.darkCanvas }]}>
40
+ <SpaceTopBarAndroid recording={controller.recording} formattedDuration={controller.formattedDuration} logoUrl={props.logoUrl} participantCount={controller.participantCount} spaceName={controller.spaceName} />
41
+ {props.reconnecting ? (
42
+ <View accessibilityLiveRegion="polite" style={[styles.recovery, { backgroundColor: theme.colors.primary }]}>
43
+ <Text style={[styles.recoveryText, { color: theme.colors.primaryForeground }]}>Reconnecting… controls will resume shortly.</Text>
44
+ </View>
45
+ ) : null}
46
+ <View style={styles.stage}>
47
+ {controller.whiteboard.isOpen ? (
48
+ <SpaceWhiteboardSurface whiteboard={controller.whiteboard} />
49
+ ) : controller.derived.isStageMode || controller.layout.layout !== "grid" ? (
50
+ <SpaceStageAndroid
51
+ activeReactions={controller.activeReactions}
52
+ handRaised={controller.handRaised}
53
+ isCompactViewport={controller.derived.isCompactViewport}
54
+ isMuted={controller.isMuted}
55
+ layoutMode={controller.layout.layout}
56
+ primaryContent={controller.derived.primaryContent}
57
+ raisedHandCount={controller.raisedHandCount}
58
+ screenShareTrack={controller.derived.screenShareTrack}
59
+ screenSharer={controller.derived.screenSharer}
60
+ selfName={controller.selfName}
61
+ stripParticipants={controller.derived.allParticipants}
62
+ whiteboard={{
63
+ isOpen: controller.whiteboard.isOpen,
64
+ canDraw: controller.whiteboard.canDraw,
65
+ elementCount: controller.whiteboard.elements.length,
66
+ participantCount: controller.whiteboard.openParticipants.length,
67
+ }}
68
+ />
69
+ ) : (
70
+ <SpaceGridAndroid gridPages={controller.derived.gridPages} participants={controller.derived.allParticipants} />
71
+ )}
35
72
  </View>
36
- ) : null}
37
- <View style={styles.stage}>
38
- {controller.whiteboard.isOpen ? (
39
- <SpaceWhiteboardSurface whiteboard={controller.whiteboard} />
40
- ) : controller.derived.isStageMode || controller.layout.layout !== "grid" ? (
41
- <SpaceStageAndroid
42
- activeReactions={controller.activeReactions}
43
- handRaised={controller.handRaised}
44
- isCompactViewport={controller.derived.isCompactViewport}
45
- isMuted={controller.isMuted}
46
- layoutMode={controller.layout.layout}
47
- primaryContent={controller.derived.primaryContent}
48
- raisedHandCount={controller.raisedHandCount}
49
- screenShareTrack={controller.derived.screenShareTrack}
50
- screenSharer={controller.derived.screenSharer}
51
- selfName={controller.selfName}
52
- stripParticipants={controller.derived.allParticipants}
53
- whiteboard={{
54
- isOpen: controller.whiteboard.isOpen,
55
- canDraw: controller.whiteboard.canDraw,
56
- elementCount: controller.whiteboard.elements.length,
57
- participantCount: controller.whiteboard.openParticipants.length,
58
- }}
59
- />
60
- ) : (
61
- <SpaceGridAndroid gridPages={controller.derived.gridPages} participants={controller.derived.allParticipants} />
62
- )}
73
+ <SpaceBottomDockAndroid
74
+ isCameraOff={controller.isCameraOff}
75
+ isMuted={controller.isMuted}
76
+ onLeave={controller.handleLeave}
77
+ onOpenChat={controller.canChat ? () => controller.openPanel("chat") : undefined}
78
+ onOpenMore={() => controller.setActionsOpen(true)}
79
+ onToggleAudio={() => toggleMedia(controller.toggleAudio)}
80
+ onToggleVideo={() => toggleMedia(controller.toggleVideo)}
81
+ simulatorMediaDisabled={controller.simulatorMediaDisabled}
82
+ unreadChatCount={controller.chat.unreadCount}
83
+ />
84
+ <SpaceActionMenu controller={controller} onOpenDiagnostics={props.onOpenDiagnostics} onOpenFeedback={() => setFeedbackRequested(true)} />
85
+ {controller.panel === "settings" ? <SettingsSheet controller={controller} isOpen onClose={controller.closePanel} /> : <SpacePanelSheet controller={controller} />}
86
+ <ReactionPicker isOpen={controller.reactionPickerOpen} onClose={() => controller.setReactionPickerOpen(false)} onSelect={(reaction) => selectSpaceReaction(controller, reaction)} />
63
87
  </View>
64
- <SpaceBottomDockAndroid
65
- isCameraOff={controller.isCameraOff}
66
- isMuted={controller.isMuted}
67
- onLeave={controller.handleLeave}
68
- onOpenChat={controller.canChat ? () => controller.openPanel("chat") : undefined}
69
- onOpenMore={() => controller.setActionsOpen(true)}
70
- onToggleAudio={() => toggleMedia(controller.toggleAudio)}
71
- onToggleVideo={() => toggleMedia(controller.toggleVideo)}
72
- simulatorMediaDisabled={controller.simulatorMediaDisabled}
73
- unreadChatCount={controller.chat.unreadCount}
74
- />
75
- <SpaceActionMenu controller={controller} onOpenDiagnostics={props.onOpenDiagnostics} />
76
- {controller.panel === "settings" ? <SettingsSheet controller={controller} isOpen onClose={controller.closePanel} /> : <SpacePanelSheet controller={controller} />}
77
- <ReactionPicker isOpen={controller.reactionPickerOpen} onClose={() => controller.setReactionPickerOpen(false)} onSelect={(reaction) => selectSpaceReaction(controller, reaction)} />
78
- </View>
88
+ <FeedbackSheet client={client} captureSize={captureSize} captureTarget={captureTarget} feedbackEvidence={props.feedbackEvidence} onClose={() => setFeedbackRequested(false)} requested={feedbackRequested} />
89
+ </>
79
90
  );
80
91
  }
81
92
 
@@ -1,4 +1,4 @@
1
- import type { ChatUploadFile } from "@q9labsai/chalk-client";
1
+ import type { ChatUploadFile, FeedbackEvidenceInput } from "@q9labsai/chalk-client";
2
2
  import { resolvePlatformVariant } from "../platform/platform";
3
3
  import type { Layout } from "../ui/native-types";
4
4
  import { SpaceViewAndroid } from "./SpaceView.android";
@@ -30,6 +30,7 @@ export interface SpaceViewProps {
30
30
  onLeave: () => void | Promise<void>;
31
31
  onEndEpisode?: () => void | Promise<void>;
32
32
  onOpenDiagnostics?: () => void;
33
+ feedbackEvidence?: Partial<FeedbackEvidenceInput>;
33
34
  onDiagnosticsChange?: (snapshot: SpaceViewDiagnosticsSnapshot) => void;
34
35
  /** Selects files; the SDK owns bounds, upload, and send sequencing. */
35
36
  pickChatFiles?: () => Promise<readonly ChatUploadFile[]>;
@@ -0,0 +1,296 @@
1
+ import type { FeedbackCategory, FeedbackEvidenceInput, FeedbackPrepared, FeedbackScreenshotCapture, FeedbackScreenshotUnavailable, SpaceClient } from "@q9labsai/chalk-client";
2
+ import { useCallback, useEffect, useMemo, useState } from "react";
3
+ import { Image, KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";
4
+
5
+ import { useNativeTheme } from "../../ui/native-theme";
6
+ import { Theme } from "../../ui/theme";
7
+ import { createNativeFeedbackEvidence } from "../../feedback/native-evidence";
8
+ import { captureNativeFeedbackView, type FeedbackCaptureSize, type FeedbackCaptureTarget } from "../../feedback/native-capture";
9
+
10
+ type FeedbackScreenshot = FeedbackScreenshotCapture | FeedbackScreenshotUnavailable;
11
+
12
+ export interface FeedbackSheetProps {
13
+ readonly client: SpaceClient;
14
+ readonly requested: boolean;
15
+ readonly captureTarget: FeedbackCaptureTarget;
16
+ readonly captureSize: FeedbackCaptureSize;
17
+ readonly feedbackEvidence?: Partial<FeedbackEvidenceInput>;
18
+ readonly onClose: () => void;
19
+ }
20
+
21
+ const categories: readonly FeedbackCategory[] = ["bug", "feature_request", "other"];
22
+
23
+ export function FeedbackSheet({ client, requested, captureTarget, captureSize, feedbackEvidence, onClose }: FeedbackSheetProps): React.JSX.Element {
24
+ const theme = useNativeTheme();
25
+ const [visible, setVisible] = useState(false);
26
+ const [preparing, setPreparing] = useState(false);
27
+ const [sending, setSending] = useState(false);
28
+ const [category, setCategory] = useState<FeedbackCategory>("bug");
29
+ const [message, setMessage] = useState("");
30
+ const [screenshot, setScreenshot] = useState<FeedbackScreenshot>({ state: "unavailable", failure_code: "capture_failed" });
31
+ const [prepared, setPrepared] = useState<FeedbackPrepared | undefined>();
32
+ const [error, setError] = useState<string | undefined>();
33
+ const [receiptId, setReceiptId] = useState<string | undefined>();
34
+ const evidence = useMemo(() => ({ ...createNativeFeedbackEvidence(), ...feedbackEvidence }), [feedbackEvidence]);
35
+ const prepareScreenshot = useCallback((nextScreenshot: FeedbackScreenshot) => client.feedback.prepare({ evidence, screenshot_provider: () => nextScreenshot }), [client.feedback, evidence]);
36
+
37
+ useEffect(() => {
38
+ if (!requested) {
39
+ setVisible(false);
40
+ return;
41
+ }
42
+
43
+ let active = true;
44
+ setPreparing(true);
45
+ setSending(false);
46
+ setCategory("bug");
47
+ setMessage("");
48
+ setError(undefined);
49
+ setReceiptId(undefined);
50
+ setPrepared(undefined);
51
+
52
+ const prepare = async (): Promise<void> => {
53
+ let captured: FeedbackScreenshot;
54
+ try {
55
+ captured = await captureNativeFeedbackView(captureTarget, captureSize);
56
+ } catch {
57
+ captured = { state: "unavailable", failure_code: "capture_failed" };
58
+ }
59
+ if (!active) return;
60
+ setScreenshot(captured);
61
+ try {
62
+ const nextPrepared = await prepareScreenshot(captured);
63
+ if (!active) return;
64
+ setPrepared(nextPrepared);
65
+ } catch (cause) {
66
+ if (!active) return;
67
+ setError(errorMessage(cause));
68
+ } finally {
69
+ if (active) {
70
+ setPreparing(false);
71
+ setVisible(true);
72
+ }
73
+ }
74
+ };
75
+
76
+ void prepare();
77
+ return () => {
78
+ active = false;
79
+ };
80
+ }, [captureSize, captureTarget, prepareScreenshot, requested]);
81
+
82
+ const close = useCallback(() => {
83
+ setVisible(false);
84
+ onClose();
85
+ }, [onClose]);
86
+
87
+ const refresh = useCallback(async () => {
88
+ if (preparing || sending) return;
89
+ setPreparing(true);
90
+ setPrepared(undefined);
91
+ setError(undefined);
92
+ let captured: FeedbackScreenshot;
93
+ try {
94
+ captured = await captureNativeFeedbackView(captureTarget, captureSize);
95
+ } catch {
96
+ captured = { state: "unavailable", failure_code: "capture_failed" };
97
+ }
98
+ setScreenshot(captured);
99
+ try {
100
+ setPrepared(await prepareScreenshot(captured));
101
+ } catch (cause) {
102
+ setError(errorMessage(cause));
103
+ } finally {
104
+ setPreparing(false);
105
+ }
106
+ }, [captureSize, captureTarget, prepareScreenshot, preparing, sending]);
107
+
108
+ const removeScreenshot = useCallback(async () => {
109
+ if (preparing || sending) return;
110
+ const removed: FeedbackScreenshot = { state: "removed" };
111
+ setPreparing(true);
112
+ setPrepared(undefined);
113
+ setScreenshot(removed);
114
+ setError(undefined);
115
+ try {
116
+ setPrepared(await prepareScreenshot(removed));
117
+ } catch (cause) {
118
+ setError(errorMessage(cause));
119
+ } finally {
120
+ setPreparing(false);
121
+ }
122
+ }, [prepareScreenshot, preparing, sending]);
123
+
124
+ const submit = useCallback(async () => {
125
+ if (sending || !message.trim()) return;
126
+ setSending(true);
127
+ setError(undefined);
128
+ try {
129
+ const request = prepared ?? (await prepareScreenshot(screenshot));
130
+ setPrepared(request);
131
+ const receipt = await request.send({ category, message });
132
+ setReceiptId(receipt.id);
133
+ } catch (cause) {
134
+ setError(errorMessage(cause));
135
+ } finally {
136
+ setSending(false);
137
+ }
138
+ }, [category, message, prepareScreenshot, prepared, screenshot, sending]);
139
+
140
+ const selectCategory = useCallback((nextCategory: FeedbackCategory) => {
141
+ setCategory(nextCategory);
142
+ setPrepared(undefined);
143
+ }, []);
144
+
145
+ const updateMessage = useCallback((nextMessage: string) => {
146
+ setMessage(nextMessage);
147
+ setPrepared(undefined);
148
+ }, []);
149
+
150
+ const screenshotUri = screenshotDataUri(screenshot);
151
+ const canSubmit = message.trim().length > 0 && !sending && !preparing && !receiptId;
152
+
153
+ return (
154
+ <Modal animationType="slide" onRequestClose={close} transparent visible={visible}>
155
+ <KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : "height"} style={styles.overlay}>
156
+ <Pressable accessibilityLabel="Close Feedback" onPress={close} style={styles.backdrop} />
157
+ <View style={[styles.sheet, { backgroundColor: theme.colors.background }]}>
158
+ <View style={styles.header}>
159
+ <View style={styles.headerCopy}>
160
+ <Text accessibilityRole="header" style={[styles.title, { color: theme.colors.foreground }]}>
161
+ Feedback
162
+ </Text>
163
+ <Text style={[styles.subtitle, { color: theme.colors.mutedForeground }]}>Tell Chalk what happened. We receive this report with safe diagnostic context.</Text>
164
+ </View>
165
+ <Pressable accessibilityLabel="Close Feedback" accessibilityRole="button" disabled={sending} onPress={close} style={styles.closeButton}>
166
+ <Text style={[styles.closeText, { color: theme.colors.foreground }]}>×</Text>
167
+ </Pressable>
168
+ </View>
169
+
170
+ {receiptId ? (
171
+ <View style={styles.success}>
172
+ <Text style={[styles.successTitle, { color: theme.colors.foreground }]}>Feedback sent to Chalk</Text>
173
+ <Text style={[styles.subtitle, { color: theme.colors.mutedForeground }]}>Thanks. Chalk received your report.</Text>
174
+ <Pressable accessibilityRole="button" onPress={close} style={[styles.primaryButton, { backgroundColor: theme.colors.primary }]}>
175
+ <Text style={[styles.primaryButtonText, { color: theme.colors.primaryForeground }]}>Done</Text>
176
+ </Pressable>
177
+ </View>
178
+ ) : (
179
+ <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
180
+ <Text style={[styles.label, { color: theme.colors.foreground }]}>Category</Text>
181
+ <View style={styles.categoryRow}>
182
+ {categories.map((option) => (
183
+ <Pressable
184
+ accessibilityRole="button"
185
+ accessibilityState={{ selected: category === option }}
186
+ key={option}
187
+ onPress={() => selectCategory(option)}
188
+ style={[styles.categoryButton, { borderColor: theme.colors.border }, category === option && { backgroundColor: theme.colors.primary, borderColor: theme.colors.primary }]}
189
+ >
190
+ <Text style={[styles.categoryText, { color: category === option ? theme.colors.primaryForeground : theme.colors.foreground }]}>{categoryLabel(option)}</Text>
191
+ </Pressable>
192
+ ))}
193
+ </View>
194
+
195
+ <Text style={[styles.label, { color: theme.colors.foreground }]}>Message</Text>
196
+ <TextInput
197
+ accessibilityLabel="Feedback message"
198
+ editable={!sending}
199
+ maxLength={8_000}
200
+ multiline
201
+ onChangeText={updateMessage}
202
+ placeholder={category === "bug" ? "What went wrong?" : category === "feature_request" ? "What would help?" : "What should Chalk know?"}
203
+ placeholderTextColor={theme.colors.placeholder}
204
+ style={[styles.messageInput, { borderColor: theme.colors.border, color: theme.colors.foreground }]}
205
+ textAlignVertical="top"
206
+ value={message}
207
+ />
208
+
209
+ <Text style={[styles.label, { color: theme.colors.foreground }]}>Screenshot</Text>
210
+ {screenshotUri ? (
211
+ <View style={styles.thumbnailFrame}>
212
+ <Image accessibilityLabel="Feedback screenshot preview" source={{ uri: screenshotUri }} style={styles.thumbnail} />
213
+ <View style={styles.thumbnailActions}>
214
+ <Pressable accessibilityRole="button" disabled={preparing || sending} onPress={() => void refresh()} style={styles.smallButton}>
215
+ <Text style={[styles.smallButtonText, { color: theme.colors.primary }]}>Refresh</Text>
216
+ </Pressable>
217
+ <Pressable accessibilityRole="button" disabled={preparing || sending} onPress={() => void removeScreenshot()} style={styles.smallButton}>
218
+ <Text style={[styles.smallButtonText, { color: theme.colors.error }]}>Remove</Text>
219
+ </Pressable>
220
+ </View>
221
+ </View>
222
+ ) : (
223
+ <View style={[styles.unavailable, { borderColor: theme.colors.border }]}>
224
+ <Text style={[styles.unavailableText, { color: theme.colors.mutedForeground }]}>Screenshot unavailable. You can still send Feedback.</Text>
225
+ <Pressable accessibilityRole="button" disabled={preparing || sending} onPress={() => void refresh()} style={styles.smallButton}>
226
+ <Text style={[styles.smallButtonText, { color: theme.colors.primary }]}>Refresh</Text>
227
+ </Pressable>
228
+ </View>
229
+ )}
230
+ {screenshot.state === "partial" ? <Text style={[styles.notice, { color: theme.colors.mutedForeground }]}>Some protected content may be missing from this screenshot.</Text> : null}
231
+ {preparing ? <Text style={[styles.notice, { color: theme.colors.mutedForeground }]}>Preparing Feedback…</Text> : null}
232
+ {error ? (
233
+ <Text accessibilityLiveRegion="polite" style={[styles.error, { color: theme.colors.error }]}>
234
+ {error}
235
+ </Text>
236
+ ) : null}
237
+
238
+ <Pressable accessibilityRole="button" disabled={!canSubmit} onPress={() => void submit()} style={[styles.primaryButton, { backgroundColor: theme.colors.primary }, !canSubmit && styles.disabledButton]}>
239
+ <Text style={[styles.primaryButtonText, { color: theme.colors.primaryForeground }]}>{sending ? "Sending…" : "Send Feedback"}</Text>
240
+ </Pressable>
241
+ <Text style={[styles.footer, { color: theme.colors.mutedForeground }]}>Chalk includes safe Space state, journey, and diagnostic details. It never includes credentials, names, or message content outside this report. There is no reply channel.</Text>
242
+ </ScrollView>
243
+ )}
244
+ </View>
245
+ </KeyboardAvoidingView>
246
+ </Modal>
247
+ );
248
+ }
249
+
250
+ function screenshotDataUri(screenshot: FeedbackScreenshot): string | undefined {
251
+ if (screenshot.state !== "captured" && screenshot.state !== "partial") return undefined;
252
+ return `data:${screenshot.mime_type};base64,${screenshot.data_base64}`;
253
+ }
254
+
255
+ function categoryLabel(category: FeedbackCategory): string {
256
+ if (category === "feature_request") return "Feature request";
257
+ if (category === "bug") return "Bug";
258
+ return "Other";
259
+ }
260
+
261
+ function errorMessage(cause: unknown): string {
262
+ return cause instanceof Error ? cause.message : "Chalk could not send Feedback.";
263
+ }
264
+
265
+ const styles = StyleSheet.create({
266
+ overlay: { flex: 1, justifyContent: "flex-end", backgroundColor: Theme.colors.darkOverlay55 },
267
+ backdrop: { ...StyleSheet.absoluteFillObject },
268
+ sheet: { maxHeight: "92%", borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 36 },
269
+ header: { flexDirection: "row", alignItems: "flex-start", justifyContent: "space-between", gap: 16, marginBottom: 20 },
270
+ headerCopy: { flex: 1, gap: 4 },
271
+ title: { ...Theme.typography.heading },
272
+ subtitle: { ...Theme.typography.body },
273
+ closeButton: { alignItems: "center", borderColor: Theme.colors.border, borderRadius: Theme.radius.full, borderWidth: 1, height: 36, justifyContent: "center", width: 36 },
274
+ closeText: { fontSize: 24, lineHeight: 26 },
275
+ content: { gap: 12, paddingBottom: 8 },
276
+ label: { ...Theme.typography.label, marginTop: 4 },
277
+ categoryRow: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
278
+ categoryButton: { borderRadius: 14, borderWidth: 1, paddingHorizontal: 13, paddingVertical: 10 },
279
+ categoryText: { fontSize: 14, fontWeight: "700" },
280
+ messageInput: { minHeight: 116, borderRadius: 14, borderWidth: 1, padding: 14, fontSize: 15, lineHeight: 21 },
281
+ thumbnailFrame: { borderRadius: 16, overflow: "hidden", borderWidth: 1, borderColor: Theme.colors.border },
282
+ thumbnail: { width: "100%", height: 170, backgroundColor: Theme.colors.surfaceMuted },
283
+ thumbnailActions: { flexDirection: "row", gap: 16, paddingHorizontal: 12, paddingVertical: 8 },
284
+ unavailable: { borderRadius: 14, borderWidth: 1, padding: 14, gap: 4 },
285
+ unavailableText: { ...Theme.typography.body },
286
+ smallButton: { alignSelf: "flex-start", paddingVertical: 6, paddingHorizontal: 2 },
287
+ smallButtonText: { fontSize: 14, fontWeight: "700" },
288
+ notice: { ...Theme.typography.meta },
289
+ error: { ...Theme.typography.meta, fontWeight: "700" },
290
+ primaryButton: { alignItems: "center", borderRadius: 14, justifyContent: "center", minHeight: 50, marginTop: 8, paddingHorizontal: 16 },
291
+ primaryButtonText: { fontSize: 16, fontWeight: "800" },
292
+ disabledButton: { opacity: 0.45 },
293
+ footer: { ...Theme.typography.meta, marginTop: 4 },
294
+ success: { gap: 12, paddingVertical: 28 },
295
+ successTitle: { ...Theme.typography.subheading },
296
+ });
@@ -7,9 +7,9 @@ import type { useSpaceViewController } from "./useSpaceViewController";
7
7
 
8
8
  type Controller = ReturnType<typeof useSpaceViewController>;
9
9
 
10
- export function SpaceActionMenu({ controller, onOpenDiagnostics }: { readonly controller: Controller; readonly onOpenDiagnostics?: () => void }): React.JSX.Element {
10
+ export function SpaceActionMenu({ controller, onOpenDiagnostics, onOpenFeedback }: { readonly controller: Controller; readonly onOpenDiagnostics?: () => void; readonly onOpenFeedback?: () => void }): React.JSX.Element {
11
11
  const theme = useNativeTheme();
12
- const actions = [
12
+ const actions: { label: string; onPress: () => void; disabled?: boolean }[] = [
13
13
  ...(controller.canInvite ? [{ label: "Invite participants", onPress: controller.handleInviteParticipants }] : []),
14
14
  ...(controller.canParticipants ? [{ label: "Participants", onPress: () => controller.openPanel("participants") }] : []),
15
15
  ...(controller.canChat ? [{ label: "Chat", onPress: () => controller.openPanel("chat") }] : []),
@@ -27,6 +27,15 @@ export function SpaceActionMenu({ controller, onOpenDiagnostics }: { readonly co
27
27
  },
28
28
  ]
29
29
  : []),
30
+ ...(controller.canManageRecording
31
+ ? [
32
+ {
33
+ label: controller.recording?.status === "recording" ? "Stop recording" : "Start recording",
34
+ disabled: !controller.canToggleRecording,
35
+ onPress: controller.toggleRecording,
36
+ },
37
+ ]
38
+ : []),
30
39
  ...(controller.canSettings ? [{ label: "Settings", onPress: () => controller.openPanel("settings") }] : []),
31
40
  ...(onOpenDiagnostics
32
41
  ? [
@@ -39,6 +48,17 @@ export function SpaceActionMenu({ controller, onOpenDiagnostics }: { readonly co
39
48
  },
40
49
  ]
41
50
  : []),
51
+ ...(onOpenFeedback
52
+ ? [
53
+ {
54
+ label: "Feedback",
55
+ onPress: () => {
56
+ controller.setActionsOpen(false);
57
+ onOpenFeedback();
58
+ },
59
+ },
60
+ ]
61
+ : []),
42
62
  ...(["grid", "focus", "presentation"] as const).map((layout) => ({
43
63
  label: `${layout === "grid" ? "Grid" : layout === "focus" ? "Focus" : "Presentation"} layout${controller.layout.layout === layout ? " (selected)" : ""}`,
44
64
  onPress: () => {
@@ -53,7 +73,7 @@ export function SpaceActionMenu({ controller, onOpenDiagnostics }: { readonly co
53
73
  <View style={[styles.sheet, { backgroundColor: theme.colors.background }]}>
54
74
  <Text style={[styles.title, { color: theme.colors.foreground }]}>Space actions</Text>
55
75
  {actions.map((action) => (
56
- <Pressable key={action.label} onPress={action.onPress} style={({ pressed }) => [styles.action, { backgroundColor: theme.colors.card }, pressed && styles.pressed]}>
76
+ <Pressable accessibilityRole="button" accessibilityState={{ disabled: action.disabled ?? false }} disabled={action.disabled} key={action.label} onPress={action.onPress} style={({ pressed }) => [styles.action, { backgroundColor: theme.colors.card }, pressed && styles.pressed]}>
57
77
  <Text style={[styles.actionText, { color: theme.colors.foreground }]}>{action.label}</Text>
58
78
  </Pressable>
59
79
  ))}