@q9labsai/chalk-react-native 4.1.15 → 4.1.17

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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -0
  3. package/dist/android.js +5 -2
  4. package/dist/{chunk-MK6OO4V7.js → chunk-2OS7SSXE.js} +6 -1
  5. package/dist/{chunk-4NLA35HE.js → chunk-ITLUHTVS.js} +1126 -644
  6. package/dist/client.js +1 -1
  7. package/dist/components/Chalk.d.ts +2 -1
  8. package/dist/components/Chalk.d.ts.map +1 -1
  9. package/dist/components/EmbeddedWhiteboard.d.ts.map +1 -1
  10. package/dist/components/SpaceView.d.ts +2 -1
  11. package/dist/components/SpaceView.d.ts.map +1 -1
  12. package/dist/components/SpaceView.shared.d.ts.map +1 -1
  13. package/dist/components/native-space-view/FeedbackSheet.d.ts +12 -0
  14. package/dist/components/native-space-view/FeedbackSheet.d.ts.map +1 -0
  15. package/dist/components/native-space-view/SpaceActionMenu.d.ts +2 -1
  16. package/dist/components/native-space-view/SpaceActionMenu.d.ts.map +1 -1
  17. package/dist/components/native-space-view/SpaceChatAttachment.d.ts +8 -0
  18. package/dist/components/native-space-view/SpaceChatAttachment.d.ts.map +1 -0
  19. package/dist/components/native-space-view/SpaceChatSheet.d.ts.map +1 -1
  20. package/dist/components/native-space-view/SpaceMoreSheet.d.ts +2 -1
  21. package/dist/components/native-space-view/SpaceMoreSheet.d.ts.map +1 -1
  22. package/dist/components/native-space-view/SpaceTopBar.android.d.ts +3 -1
  23. package/dist/components/native-space-view/SpaceTopBar.android.d.ts.map +1 -1
  24. package/dist/components/native-space-view/screen-share-availability.d.ts +4 -1
  25. package/dist/components/native-space-view/screen-share-availability.d.ts.map +1 -1
  26. package/dist/components/native-space-view/space-chat-attachments.d.ts.map +1 -1
  27. package/dist/components/native-space-view/useSpaceViewController.d.ts +8 -2
  28. package/dist/components/native-space-view/useSpaceViewController.d.ts.map +1 -1
  29. package/dist/feedback/native-capture.d.ts +9 -0
  30. package/dist/feedback/native-capture.d.ts.map +1 -0
  31. package/dist/feedback/native-evidence.d.ts +3 -0
  32. package/dist/feedback/native-evidence.d.ts.map +1 -0
  33. package/dist/hooks/space-hooks.d.ts +1 -0
  34. package/dist/hooks/space-hooks.d.ts.map +1 -1
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +5 -2
  38. package/dist/ios-pad.js +5 -2
  39. package/dist/ios-phone.js +5 -2
  40. package/dist/ios.js +5 -2
  41. package/dist/macos.js +5 -2
  42. package/dist/space-client/create-native-space-client.d.ts +2 -0
  43. package/dist/space-client/create-native-space-client.d.ts.map +1 -1
  44. package/dist/whiteboard/embedded-whiteboard-viewport.d.ts +3 -0
  45. package/dist/whiteboard/embedded-whiteboard-viewport.d.ts.map +1 -0
  46. package/embedded/chalk-whiteboard/asset-manifest.json +2 -2
  47. package/embedded/chalk-whiteboard/renderer.js +581 -581
  48. package/package.json +8 -6
  49. package/src/components/Chalk.tsx +6 -2
  50. package/src/components/EmbeddedWhiteboard.tsx +14 -5
  51. package/src/components/SpaceView.shared.tsx +58 -47
  52. package/src/components/SpaceView.tsx +2 -1
  53. package/src/components/native-space-view/FeedbackSheet.tsx +296 -0
  54. package/src/components/native-space-view/SpaceActionMenu.tsx +23 -3
  55. package/src/components/native-space-view/SpaceChatAttachment.tsx +105 -0
  56. package/src/components/native-space-view/SpaceChatSheet.tsx +14 -9
  57. package/src/components/native-space-view/SpaceMoreSheet.tsx +35 -6
  58. package/src/components/native-space-view/SpaceTopBar.android.tsx +16 -1
  59. package/src/components/native-space-view/screen-share-availability.test.ts +16 -0
  60. package/src/components/native-space-view/screen-share-availability.ts +4 -1
  61. package/src/components/native-space-view/space-chat-attachments.test.ts +26 -0
  62. package/src/components/native-space-view/space-chat-attachments.ts +2 -4
  63. package/src/components/native-space-view/useSpaceViewController.ts +28 -8
  64. package/src/feedback/native-capture.ts +62 -0
  65. package/src/feedback/native-evidence.ts +37 -0
  66. package/src/hooks/space-hooks.ts +4 -0
  67. package/src/index.ts +1 -1
  68. package/src/space-client/create-native-space-client.ts +7 -0
  69. package/src/whiteboard/embedded-whiteboard-viewport.test.ts +13 -0
  70. package/src/whiteboard/embedded-whiteboard-viewport.ts +6 -0
@@ -0,0 +1,105 @@
1
+ import type { ChatAttachment } from "@q9labsai/chalk-client";
2
+ import { useEffect, useState } from "react";
3
+ import { Image, Pressable, StyleSheet, Text, View } from "react-native";
4
+
5
+ import { Theme } from "../../ui/theme";
6
+
7
+ export function SpaceChatAttachment({ attachment, isLocal, onOpen, resolveUrl }: { readonly attachment: ChatAttachment; readonly isLocal: boolean; readonly onOpen: (attachmentId: string) => void; readonly resolveUrl: (attachmentId: string) => Promise<string | null> }): React.JSX.Element {
8
+ const isImage = attachment.mimeType.startsWith("image/");
9
+ const [previewUrl, setPreviewUrl] = useState<string | null>(null);
10
+ const [previewFailed, setPreviewFailed] = useState(false);
11
+ const [retryGeneration, setRetryGeneration] = useState(0);
12
+
13
+ useEffect(() => {
14
+ if (!isImage || previewUrl || previewFailed) return;
15
+ let cancelled = false;
16
+ void resolveUrl(attachment.attachmentId).then(
17
+ (url) => {
18
+ if (cancelled) return;
19
+ if (!url) {
20
+ reportAttachmentPreviewFailure(attachment.attachmentId, "resolve");
21
+ setPreviewFailed(true);
22
+ return;
23
+ }
24
+ setPreviewUrl(url);
25
+ setPreviewFailed(false);
26
+ },
27
+ () => {
28
+ if (cancelled) return;
29
+ reportAttachmentPreviewFailure(attachment.attachmentId, "resolve");
30
+ setPreviewFailed(true);
31
+ },
32
+ );
33
+ return () => {
34
+ cancelled = true;
35
+ };
36
+ }, [attachment.attachmentId, isImage, previewFailed, previewUrl, resolveUrl, retryGeneration]);
37
+
38
+ if (!isImage) {
39
+ return (
40
+ <Pressable accessibilityRole="link" onPress={() => onOpen(attachment.attachmentId)} style={({ pressed }) => [styles.attachmentLink, pressed && styles.pressed]}>
41
+ <Text numberOfLines={1} style={[styles.attachment, isLocal && styles.localText]}>
42
+ {attachment.fileName}
43
+ </Text>
44
+ </Pressable>
45
+ );
46
+ }
47
+
48
+ if (previewFailed) {
49
+ return (
50
+ <View style={styles.previewError}>
51
+ <Text accessibilityLiveRegion="polite" style={[styles.previewErrorText, isLocal && styles.localText]}>
52
+ Image preview unavailable
53
+ </Text>
54
+ <Pressable
55
+ accessibilityLabel={`Retry preview for ${attachment.fileName}`}
56
+ accessibilityRole="button"
57
+ style={styles.retryButton}
58
+ onPress={() => {
59
+ setPreviewFailed(false);
60
+ setPreviewUrl(null);
61
+ setRetryGeneration((current) => current + 1);
62
+ }}
63
+ >
64
+ <Text style={[styles.retry, isLocal && styles.localText]}>Retry preview</Text>
65
+ </Pressable>
66
+ </View>
67
+ );
68
+ }
69
+
70
+ if (!previewUrl) return <Text style={[styles.previewLoading, isLocal && styles.localText]}>Loading {attachment.fileName}…</Text>;
71
+
72
+ return (
73
+ <Pressable accessibilityLabel={`Open ${attachment.fileName}`} accessibilityRole="imagebutton" onPress={() => onOpen(attachment.attachmentId)} style={({ pressed }) => [styles.imagePreviewButton, pressed && styles.pressed]}>
74
+ <Image
75
+ accessibilityLabel={attachment.fileName}
76
+ onError={() => {
77
+ reportAttachmentPreviewFailure(attachment.attachmentId, "render");
78
+ setPreviewFailed(true);
79
+ setPreviewUrl(null);
80
+ }}
81
+ resizeMode="cover"
82
+ source={{ uri: previewUrl }}
83
+ style={styles.imagePreview}
84
+ />
85
+ </Pressable>
86
+ );
87
+ }
88
+
89
+ function reportAttachmentPreviewFailure(attachmentId: string, operation: "render" | "resolve"): void {
90
+ console.warn("Chat attachment preview unavailable.", { attachmentId, operation });
91
+ }
92
+
93
+ const styles = StyleSheet.create({
94
+ attachmentLink: { justifyContent: "center", marginTop: 4, maxWidth: "100%", minHeight: 32 },
95
+ attachment: { color: Theme.colors.information, fontSize: 14 },
96
+ imagePreviewButton: { borderRadius: Theme.radius.sm, marginTop: Theme.spacing.sm, maxWidth: "100%", overflow: "hidden" },
97
+ imagePreview: { height: 160, width: 240 },
98
+ previewLoading: { color: Theme.colors.ink2, fontSize: 13, marginTop: Theme.spacing.sm },
99
+ previewError: { borderColor: Theme.colors.line, borderRadius: Theme.radius.sm, borderWidth: 1, marginTop: Theme.spacing.sm, width: 240, maxWidth: "100%", minHeight: 160, justifyContent: "center", alignItems: "center", padding: Theme.spacing.md },
100
+ previewErrorText: { color: Theme.colors.ink2, fontSize: 13 },
101
+ retryButton: { minHeight: 44, justifyContent: "center", paddingHorizontal: Theme.spacing.sm },
102
+ retry: { color: Theme.colors.information, fontSize: 13, fontWeight: "700", marginTop: 6 },
103
+ localText: { color: Theme.colors.surface },
104
+ pressed: { opacity: 0.72, transform: [{ scale: 0.98 }] },
105
+ });
@@ -6,6 +6,7 @@ import { FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text,
6
6
 
7
7
  import { Theme } from "../../ui/theme";
8
8
  import { formatChatTimestamp } from "./space-progressive-surface-helpers";
9
+ import { SpaceChatAttachment } from "./SpaceChatAttachment";
9
10
  import { InitialsAvatar } from "./SpaceSurfacePrimitives";
10
11
  import type { SpaceController } from "./space-progressive-surface-types";
11
12
 
@@ -31,7 +32,7 @@ export function SpaceChatSheet({ controller }: { readonly controller: SpaceContr
31
32
  const sequence = viewableItems.at(-1)?.item.sequence;
32
33
  if (sequence) controller.markChatMessageVisible(sequence);
33
34
  }}
34
- renderItem={({ item }) => <ChatMessage isLocal={item.senderId === localParticipantId} message={item} onOpenAttachment={controller.openChatAttachment} />}
35
+ renderItem={({ item }) => <ChatMessage isLocal={item.senderId === localParticipantId} message={item} onOpenAttachment={controller.openChatAttachment} resolveAttachmentUrl={controller.resolveChatAttachmentUrl} />}
35
36
  showsVerticalScrollIndicator={false}
36
37
  />
37
38
  {controller.chat.pendingMessages.map((message) => (
@@ -97,7 +98,17 @@ export function SpaceChatSheet({ controller }: { readonly controller: SpaceContr
97
98
  );
98
99
  }
99
100
 
100
- function ChatMessage({ isLocal, message, onOpenAttachment }: { readonly isLocal: boolean; readonly message: SpaceController["chat"]["messages"][number]; readonly onOpenAttachment: (attachmentId: string) => void }): React.JSX.Element {
101
+ function ChatMessage({
102
+ isLocal,
103
+ message,
104
+ onOpenAttachment,
105
+ resolveAttachmentUrl,
106
+ }: {
107
+ readonly isLocal: boolean;
108
+ readonly message: SpaceController["chat"]["messages"][number];
109
+ readonly onOpenAttachment: (attachmentId: string) => void;
110
+ readonly resolveAttachmentUrl: (attachmentId: string) => Promise<string | null>;
111
+ }): React.JSX.Element {
101
112
  const timestamp = formatChatTimestamp(message.timestamp);
102
113
  return (
103
114
  <View style={[styles.messageRow, isLocal && styles.messageRowLocal]}>
@@ -107,11 +118,7 @@ function ChatMessage({ isLocal, message, onOpenAttachment }: { readonly isLocal:
107
118
  <View style={[styles.bubble, isLocal ? styles.localBubble : styles.remoteBubble]}>
108
119
  {message.text ? <Text style={[styles.messageText, isLocal && styles.localMessageText]}>{message.text}</Text> : null}
109
120
  {message.attachments.map((attachment) => (
110
- <Pressable accessibilityRole="link" key={attachment.attachmentId} onPress={() => onOpenAttachment(attachment.attachmentId)} style={({ pressed }) => [styles.attachmentLink, pressed && styles.pressed]}>
111
- <Text numberOfLines={1} style={[styles.attachment, isLocal && styles.localMessageText]}>
112
- {attachment.fileName}
113
- </Text>
114
- </Pressable>
121
+ <SpaceChatAttachment attachment={attachment} isLocal={isLocal} key={attachment.attachmentId} onOpen={onOpenAttachment} resolveUrl={resolveAttachmentUrl} />
115
122
  ))}
116
123
  </View>
117
124
  <Text style={[styles.timestamp, isLocal && styles.timestampLocal]}>{timestamp}</Text>
@@ -150,8 +157,6 @@ const styles = StyleSheet.create({
150
157
  localBubble: { backgroundColor: Theme.colors.ink, borderColor: Theme.colors.ink, borderTopRightRadius: 5 },
151
158
  messageText: { color: Theme.colors.ink, fontSize: 17, lineHeight: 24 },
152
159
  localMessageText: { color: Theme.colors.surface },
153
- attachmentLink: { justifyContent: "center", marginTop: 4, maxWidth: "100%", minHeight: 32 },
154
- attachment: { color: Theme.colors.information, fontSize: 14 },
155
160
  timestamp: { color: Theme.colors.ink3, fontSize: 12, marginTop: 5, paddingHorizontal: 4 },
156
161
  timestampLocal: { textAlign: "right" },
157
162
  readBy: { color: Theme.colors.ink3, fontSize: 11, marginTop: 3, paddingHorizontal: 4 },
@@ -19,10 +19,11 @@ interface MoreAction {
19
19
  readonly icon?: SpaceIcon;
20
20
  readonly symbol?: string;
21
21
  readonly wash?: string;
22
+ readonly unavailableReason?: string;
22
23
  readonly onPress: () => void;
23
24
  }
24
25
 
25
- export function SpaceMoreSheet({ controller, onOpenSettings }: { readonly controller: SpaceController; readonly onOpenSettings: () => void }): React.JSX.Element {
26
+ export function SpaceMoreSheet({ controller, onOpenSettings, onOpenFeedback }: { readonly controller: SpaceController; readonly onOpenSettings: () => void; readonly onOpenFeedback?: () => void }): React.JSX.Element {
26
27
  const close = () => controller.setActionsOpen(false);
27
28
  const actions: MoreAction[] = [
28
29
  {
@@ -77,12 +78,13 @@ export function SpaceMoreSheet({ controller, onOpenSettings }: { readonly contro
77
78
  : []),
78
79
  ...(controller.canParticipants ? [{ id: "people", label: "People", icon: UserGroupIcon, onPress: () => controller.openPanel("participants") }] : []),
79
80
  ...(controller.canChat ? [{ id: "chat", label: "Chat", icon: Chat01Icon, onPress: () => controller.openPanel("chat") }] : []),
80
- ...(controller.canScreenShare
81
+ ...(controller.canScreenShare || controller.screenShare.isLocalSharing || controller.screenShareUnavailableReason
81
82
  ? [
82
83
  {
83
84
  id: "screen-share",
84
85
  label: controller.screenShare.isLocalSharing ? "Stop sharing" : "Present screen",
85
86
  icon: ComputerScreenShareIcon,
87
+ unavailableReason: controller.screenShare.isLocalSharing ? undefined : (controller.screenShareUnavailableReason ?? undefined),
86
88
  onPress: () => {
87
89
  close();
88
90
  controller.toggleScreenShare();
@@ -99,6 +101,19 @@ export function SpaceMoreSheet({ controller, onOpenSettings }: { readonly contro
99
101
  onOpenSettings();
100
102
  },
101
103
  },
104
+ ...(onOpenFeedback
105
+ ? [
106
+ {
107
+ id: "feedback",
108
+ label: "Feedback",
109
+ symbol: "✉",
110
+ onPress: () => {
111
+ close();
112
+ onOpenFeedback();
113
+ },
114
+ },
115
+ ]
116
+ : []),
102
117
  ];
103
118
 
104
119
  return (
@@ -110,11 +125,23 @@ export function SpaceMoreSheet({ controller, onOpenSettings }: { readonly contro
110
125
  <Text style={styles.title}>More</Text>
111
126
  <ScrollView contentContainerStyle={styles.actionGrid} showsVerticalScrollIndicator={false}>
112
127
  {actions.map((action) => (
113
- <Pressable accessibilityLabel={action.label} accessibilityRole="button" key={action.id} onPress={action.onPress} style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}>
128
+ <Pressable
129
+ accessibilityLabel={action.label}
130
+ accessibilityHint={action.unavailableReason}
131
+ accessibilityRole="button"
132
+ accessibilityState={{ disabled: Boolean(action.unavailableReason) }}
133
+ disabled={Boolean(action.unavailableReason)}
134
+ key={action.id}
135
+ onPress={action.onPress}
136
+ style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}
137
+ >
114
138
  <IconTile icon={action.icon} symbol={action.symbol} wash={action.wash ?? Theme.colors.surfaceMuted} />
115
- <Text numberOfLines={2} style={styles.actionLabel}>
116
- {action.label}
117
- </Text>
139
+ <View style={styles.actionCopy}>
140
+ <Text numberOfLines={2} style={styles.actionLabel}>
141
+ {action.label}
142
+ </Text>
143
+ {action.unavailableReason ? <Text style={styles.actionDetail}>{action.unavailableReason}</Text> : null}
144
+ </View>
118
145
  </Pressable>
119
146
  ))}
120
147
  </ScrollView>
@@ -155,6 +182,8 @@ const styles = StyleSheet.create({
155
182
  actionGrid: { flexDirection: "row", flexWrap: "wrap", gap: Theme.spacing.md, padding: Theme.spacing.lg, paddingBottom: Theme.spacing.md },
156
183
  action: { alignItems: "center", backgroundColor: Theme.colors.surfaceMuted, borderColor: Theme.colors.line, borderRadius: Theme.radius.md, borderWidth: 1, flexDirection: "row", gap: Theme.spacing.md, minHeight: 76, paddingHorizontal: Theme.spacing.md, width: "47.8%" },
157
184
  actionLabel: { color: Theme.colors.ink, flex: 1, fontSize: 15, fontWeight: "600", lineHeight: 20 },
185
+ actionCopy: { flex: 1, gap: Theme.spacing.xs },
186
+ actionDetail: { color: Theme.colors.ink2, fontSize: 12, lineHeight: 17 },
158
187
  actionPressed: { opacity: 0.72, transform: [{ scale: 0.985 }] },
159
188
  leaveButton: { alignItems: "center", borderColor: Theme.colors.dangerBackground, borderRadius: Theme.radius.md, borderWidth: 1, flexDirection: "row", gap: Theme.spacing.sm, justifyContent: "center", marginHorizontal: Theme.spacing.lg, minHeight: 52, paddingHorizontal: Theme.spacing.lg },
160
189
  leaveText: { color: Theme.colors.error, fontSize: 16, fontWeight: "700" },
@@ -1,3 +1,4 @@
1
+ import type { Recording } from "@q9labsai/chalk-client";
1
2
  import UserGroupIcon from "@hugeicons/core-free-icons/dist/esm/UserGroupIcon";
2
3
  import { HugeiconsIcon } from "@hugeicons/react-native";
3
4
  import { Image, Platform, StyleSheet, Text, View } from "react-native";
@@ -10,9 +11,10 @@ export interface SpaceTopBarProps {
10
11
  participantCount: number;
11
12
  formattedDuration: string;
12
13
  logoUrl?: string;
14
+ recording?: Recording | null;
13
15
  }
14
16
 
15
- export function SpaceTopBarAndroid({ spaceName, participantCount, formattedDuration, logoUrl }: SpaceTopBarProps): React.JSX.Element {
17
+ export function SpaceTopBarAndroid({ spaceName, participantCount, formattedDuration, logoUrl, recording }: SpaceTopBarProps): React.JSX.Element {
16
18
  const theme = useNativeTheme();
17
19
  return (
18
20
  <View style={[styles.topBar, { backgroundColor: theme.colors.darkCanvas, borderColor: theme.colors.border }]}>
@@ -26,6 +28,11 @@ export function SpaceTopBarAndroid({ spaceName, participantCount, formattedDurat
26
28
  <Text style={[styles.timerText, { color: theme.colors.mutedForeground }]}>{formattedDuration}</Text>
27
29
  </View>
28
30
  </View>
31
+ {recording && recording.status !== "stopped" ? (
32
+ <Text accessibilityLiveRegion="polite" style={[styles.timerText, { color: theme.colors.error }]}>
33
+ {recordingLabels[recording.status]}
34
+ </Text>
35
+ ) : null}
29
36
  <View style={[styles.topBarRight, { backgroundColor: theme.colors.surface }]}>
30
37
  <HugeiconsIcon icon={UserGroupIcon} size={14} color={theme.colors.foreground} />
31
38
  <Text style={[styles.topBarCount, { color: theme.colors.foreground }]}>{participantCount}</Text>
@@ -34,6 +41,14 @@ export function SpaceTopBarAndroid({ spaceName, participantCount, formattedDurat
34
41
  );
35
42
  }
36
43
 
44
+ const recordingLabels = {
45
+ starting: "Starting recording…",
46
+ recording: "Recording",
47
+ stopping: "Stopping recording…",
48
+ stopped: "Recording stopped",
49
+ failed: "Recording failed",
50
+ };
51
+
37
52
  export { SpaceTopBarAndroid as SpaceTopBar };
38
53
 
39
54
  const styles = StyleSheet.create({
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveNativeScreenShareAvailability } from "./screen-share-availability";
3
+
4
+ describe("native screen sharing availability", () => {
5
+ it.each(["ios", "android"])("allows capture on a supported physical %s runtime", (platform) => {
6
+ expect(resolveNativeScreenShareAvailability({ featureEnabled: true, platform, simulator: false, captureAvailable: true }).enabled).toBe(true);
7
+ });
8
+ it.each([
9
+ { featureEnabled: false, platform: "ios", simulator: false, captureAvailable: true },
10
+ { featureEnabled: true, platform: "ios", simulator: true, captureAvailable: true },
11
+ { featureEnabled: true, platform: "android", simulator: false, captureAvailable: false },
12
+ { featureEnabled: true, platform: "windows", simulator: false, captureAvailable: true },
13
+ ])("explains an unavailable runtime: $platform", (input) => {
14
+ expect(resolveNativeScreenShareAvailability(input)).toMatchObject({ enabled: false, detail: expect.any(String), reason: expect.any(String) });
15
+ });
16
+ });
@@ -4,7 +4,7 @@ export interface NativeScreenShareAvailability {
4
4
  detail: string | null;
5
5
  }
6
6
 
7
- export function resolveNativeScreenShareAvailability({ featureEnabled }: { featureEnabled: boolean }): NativeScreenShareAvailability {
7
+ export function resolveNativeScreenShareAvailability({ featureEnabled, platform, simulator, captureAvailable }: { featureEnabled: boolean; platform: string; simulator: boolean; captureAvailable: boolean }): NativeScreenShareAvailability {
8
8
  if (!featureEnabled) {
9
9
  return {
10
10
  enabled: false,
@@ -13,6 +13,9 @@ export function resolveNativeScreenShareAvailability({ featureEnabled }: { featu
13
13
  };
14
14
  }
15
15
 
16
+ if (simulator) return { enabled: false, reason: "simulator", detail: "Screen sharing requires a physical device." };
17
+ if ((platform !== "ios" && platform !== "android") || !captureAvailable) return { enabled: false, reason: "runtime-unsupported", detail: "Screen sharing is unavailable in this app runtime. Use a supported native build or share from the web app." };
18
+
16
19
  return {
17
20
  enabled: true,
18
21
  reason: null,
@@ -0,0 +1,26 @@
1
+ import type { ChatUploadFile } from "@q9labsai/chalk-client";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { describeChatUploadFile } from "./space-chat-attachments";
5
+
6
+ describe("describeChatUploadFile", () => {
7
+ it("describes a lazy native picker file that also exposes bytes", () => {
8
+ const file: ChatUploadFile = new LazyNativeFileWithBytes();
9
+
10
+ expect(describeChatUploadFile(file)).toMatchObject({ fileName: "mobile-photo.png", mimeType: "image/png", byteLength: 7 });
11
+ });
12
+ });
13
+
14
+ class LazyNativeFileWithBytes {
15
+ readonly name = "mobile-photo.png";
16
+ readonly type = "image/png";
17
+ readonly size = 7;
18
+
19
+ arrayBuffer(): Promise<ArrayBuffer> {
20
+ return Promise.resolve(new ArrayBuffer(this.size));
21
+ }
22
+
23
+ bytes(): Promise<Uint8Array> {
24
+ return Promise.resolve(new Uint8Array(this.size));
25
+ }
26
+ }
@@ -11,10 +11,8 @@ export interface NativeChatFileDraft {
11
11
  }
12
12
 
13
13
  export function describeChatUploadFile(file: ChatUploadFile): NativeChatFileDraft {
14
- if ("bytes" in file) {
15
- return { file, fileName: file.fileName, mimeType: file.mimeType, byteLength: file.bytes.byteLength };
16
- }
17
- return { file, fileName: file.name, mimeType: file.type, byteLength: file.size };
14
+ if ("arrayBuffer" in file) return { file, fileName: file.name, mimeType: file.type, byteLength: file.size };
15
+ return { file, fileName: file.fileName, mimeType: file.mimeType, byteLength: file.bytes.byteLength };
18
16
  }
19
17
 
20
18
  export function validateChatFileDraft(draft: NativeChatFileDraft): string | null {
@@ -1,9 +1,10 @@
1
1
  import type { ChatSendInput, Reaction } from "@q9labsai/chalk-client";
2
2
  import { useCallback, useEffect, useMemo, useState } from "react";
3
- import { Alert, Linking } from "react-native";
3
+ import { Alert, Linking, Platform } from "react-native";
4
+ import { mediaDevices } from "@cloudflare/react-native-webrtc";
4
5
 
5
6
  import { useSpaceClient } from "../../context/space-client-context";
6
- import { useChat as useSpaceChat, useMedia as useSpaceMedia, useParticipants as useSpaceParticipants, useReactions as useSpaceReactions, useSelf, useWhiteboard } from "../../hooks/space-hooks";
7
+ import { useChat as useSpaceChat, useMedia as useSpaceMedia, useParticipants as useSpaceParticipants, useReactions as useSpaceReactions, useSelf, useWhiteboard, useRecording, useConnection } from "../../hooks/space-hooks";
7
8
  import { getNativeJourneyContext } from "../../telemetry";
8
9
  import type { NativeChatMessage, NativeParticipant, NativeReaction } from "../../ui/native-types";
9
10
  import { isIosSimulator } from "../../utils/ios-simulator";
@@ -29,6 +30,9 @@ export function useSpaceViewController({ spaceName, inviteLink, layout: controll
29
30
  const chatSlice = useSpaceChat();
30
31
  const reactionSlice = useSpaceReactions();
31
32
  const whiteboardSlice = useWhiteboard();
33
+ const recording = useRecording().current;
34
+ const connection = useConnection();
35
+
32
36
  const journeyContext = getNativeJourneyContext(client);
33
37
  const [actionsOpen, setActionsOpen] = useState(false);
34
38
  const [reactionPickerOpen, setReactionPickerOpen] = useState(false);
@@ -55,7 +59,7 @@ export function useSpaceViewController({ spaceName, inviteLink, layout: controll
55
59
  return {
56
60
  ...participant,
57
61
  id: participant.participantId,
58
- audioEnabled: local ? media.local.microphone.state === "enabled" || media.local.microphone.state === "requesting" : participant.media.microphone === "active",
62
+ audioEnabled: local ? media.local.microphone.state === "enabled" : participant.media.microphone === "active",
59
63
  videoEnabled: local ? media.local.camera.state === "enabled" || media.local.camera.state === "requesting" : participant.media.camera === "active",
60
64
  audioTrack: local ? media.local.microphone.track : (remoteMedia.find((publication) => publication.source === "microphone")?.track ?? null),
61
65
  videoTrack: local ? media.local.camera.track : (remoteMedia.find((publication) => publication.source === "camera")?.track ?? null),
@@ -175,7 +179,7 @@ export function useSpaceViewController({ spaceName, inviteLink, layout: controll
175
179
  const canClear = canWhiteboard && self.can("manageWhiteboard");
176
180
  const whiteboardTransport = useMemo(() => client.whiteboard.transport(), [client.whiteboard, whiteboardSlice.engine.status]);
177
181
  const canEndEpisode = self.can("endEpisode");
178
- const screenShareAvailability = useMemo(() => resolveNativeScreenShareAvailability({ featureEnabled: features?.screenShare !== false }), [features?.screenShare]);
182
+ const screenShareAvailability = useMemo(() => resolveNativeScreenShareAvailability({ featureEnabled: features?.screenShare !== false, platform: Platform.OS, simulator: isIosSimulator(), captureAvailable: typeof mediaDevices.getDisplayMedia === "function" }), [features?.screenShare]);
179
183
  const capabilities = useMemo(
180
184
  () => ({
181
185
  canEndEpisode,
@@ -297,12 +301,24 @@ export function useSpaceViewController({ spaceName, inviteLink, layout: controll
297
301
  });
298
302
  }, [chat, chatAttachments, chatDraft, client.chat.files, run]);
299
303
 
304
+ const resolveChatAttachmentUrl = useCallback(
305
+ async (attachmentId: string) => {
306
+ const attachment = chatSlice.messages.flatMap((message) => message.attachments).find((candidate) => candidate.attachmentId === attachmentId);
307
+ return attachment ? client.chat.files.resolveUrl(attachment) : null;
308
+ },
309
+ [chatSlice.messages, client.chat.files],
310
+ );
311
+
300
312
  return {
313
+ recording,
314
+ canManageRecording: self.can("manageRecording"),
315
+ canToggleRecording: connection.status === "live" && recording?.status !== "starting" && recording?.status !== "stopping",
316
+ toggleRecording: () => void run(() => (recording?.status === "recording" ? client.recording.stop() : client.recording.start())),
301
317
  simulatorMediaDisabled: isIosSimulator(),
302
318
  spaceName: spaceName || "Space",
303
319
  panel: spacePanels.panel,
304
320
  selfName: self.displayName || "Participant",
305
- isMuted: !(media.local.microphone.state === "enabled" || media.local.microphone.state === "requesting"),
321
+ isMuted: media.local.microphone.state !== "enabled",
306
322
  isCameraOff: !(media.local.camera.state === "enabled" || media.local.camera.state === "requesting"),
307
323
  handRaised: interactionState.isHandRaised,
308
324
  raisedHandCount: interactionState.raisedHandCount,
@@ -321,6 +337,7 @@ export function useSpaceViewController({ spaceName, inviteLink, layout: controll
321
337
  canChat: capabilities.canChat,
322
338
  canParticipants: capabilities.canParticipants,
323
339
  canScreenShare: capabilities.canScreenShare,
340
+ screenShareUnavailableReason: features?.screenShare !== false && self.can("publishScreen") ? screenShareAvailability.detail : null,
324
341
  canReactions: capabilities.canReactions,
325
342
  canHandRaise: capabilities.canHandRaise,
326
343
  canInvite: capabilities.canInvite,
@@ -337,7 +354,7 @@ export function useSpaceViewController({ spaceName, inviteLink, layout: controll
337
354
  participants: spaceParticipants,
338
355
  settings: {
339
356
  displayName: self.displayName ?? "",
340
- microphoneEnabled: media.local.microphone.state === "enabled" || media.local.microphone.state === "requesting",
357
+ microphoneEnabled: media.local.microphone.state === "enabled",
341
358
  cameraEnabled: media.local.camera.state === "enabled" || media.local.camera.state === "requesting",
342
359
  devices: media.devices,
343
360
  selection: media.selection,
@@ -373,9 +390,12 @@ export function useSpaceViewController({ spaceName, inviteLink, layout: controll
373
390
  void run(() => interactionState.sendReaction(reaction));
374
391
  },
375
392
  sendChatMessage,
393
+ resolveChatAttachmentUrl,
376
394
  openChatAttachment: (attachmentId: string) => {
377
- const attachment = chat.messages.flatMap((message) => message.attachments).find((candidate) => candidate.attachmentId === attachmentId);
378
- if (attachment) void run(() => Linking.openURL(client.chat.files.url(attachment)));
395
+ void run(async () => {
396
+ const url = await resolveChatAttachmentUrl(attachmentId);
397
+ if (url) await Linking.openURL(url);
398
+ });
379
399
  },
380
400
  markChatMessageVisible: (sequence: string) => void run(() => chat.markAsRead(sequence)),
381
401
  admitParticipant: (requestId: string) => void run(() => client.participants.admit(requestId)),
@@ -0,0 +1,62 @@
1
+ import type { FeedbackScreenshotCapture, FeedbackScreenshotUnavailable } from "@q9labsai/chalk-client";
2
+ import { Platform, type View } from "react-native";
3
+ import { captureRef } from "react-native-view-shot";
4
+
5
+ export type FeedbackCaptureTarget = React.RefObject<View | null>;
6
+
7
+ export type FeedbackCaptureSize = Readonly<{
8
+ width: number;
9
+ height: number;
10
+ }>;
11
+
12
+ const MAX_SCREENSHOT_BYTES = 450 * 1024;
13
+ const MAX_SCREENSHOT_WIDTH = 1_920;
14
+ const MAX_SCREENSHOT_HEIGHT = 1_080;
15
+
16
+ export async function captureNativeFeedbackView(target: FeedbackCaptureTarget, size: FeedbackCaptureSize): Promise<FeedbackScreenshotCapture | FeedbackScreenshotUnavailable> {
17
+ if (Platform.OS === "macos") return { state: "unavailable", failure_code: "unsupported" };
18
+ if (!target.current) return { state: "unavailable", failure_code: "capture_failed" };
19
+
20
+ const outputSize = boundedOutputSize(size);
21
+ if (!outputSize) return { state: "unavailable", failure_code: "capture_failed" };
22
+ try {
23
+ const captured = await captureRef(target, {
24
+ format: "jpg",
25
+ quality: 0.72,
26
+ result: "base64",
27
+ ...outputSize,
28
+ });
29
+ const dataBase64 = normalizeBase64(captured);
30
+ if (!dataBase64) return { state: "unavailable", failure_code: "capture_failed" };
31
+ if (base64ByteLength(dataBase64) > MAX_SCREENSHOT_BYTES) return { state: "unavailable", failure_code: "too_large" };
32
+
33
+ return {
34
+ state: "captured",
35
+ mime_type: "image/jpeg",
36
+ width: outputSize.width,
37
+ height: outputSize.height,
38
+ captured_at: new Date().toISOString(),
39
+ data_base64: dataBase64,
40
+ };
41
+ } catch {
42
+ return { state: "unavailable", failure_code: "capture_failed" };
43
+ }
44
+ }
45
+
46
+ function boundedOutputSize(size: FeedbackCaptureSize): Readonly<{ width: number; height: number }> | undefined {
47
+ if (!Number.isFinite(size.width) || !Number.isFinite(size.height) || size.width <= 0 || size.height <= 0) return undefined;
48
+ const scale = Math.min(1, MAX_SCREENSHOT_WIDTH / size.width, MAX_SCREENSHOT_HEIGHT / size.height);
49
+ return {
50
+ width: Math.max(1, Math.floor(size.width * scale)),
51
+ height: Math.max(1, Math.floor(size.height * scale)),
52
+ };
53
+ }
54
+
55
+ function normalizeBase64(value: string): string {
56
+ return value.replace(/^data:[^;]+;base64,/u, "").replaceAll(/\s/gu, "");
57
+ }
58
+
59
+ function base64ByteLength(value: string): number {
60
+ const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
61
+ return Math.floor((value.length * 3) / 4) - padding;
62
+ }
@@ -0,0 +1,37 @@
1
+ import type { FeedbackEvidenceInput, FeedbackPlatformKind } from "@q9labsai/chalk-client";
2
+ import { Platform } from "react-native";
3
+
4
+ import { getDeviceInfo } from "../runtime";
5
+
6
+ export function createNativeFeedbackEvidence(): FeedbackEvidenceInput {
7
+ const device = getDeviceInfo({ scriptUrl: null });
8
+ const kind = feedbackPlatformKind(Platform.OS);
9
+ const osName = boundedString(device.systemName ?? device.platform ?? kind);
10
+ const osVersion = boundedString(device.osVersion);
11
+ const deviceModel = boundedString(device.model);
12
+ const reactNativeVersion = boundedString(device.reactNativeVersion);
13
+ const deviceClass = kind === "macos" ? "desktop" : device.interfaceIdiom === "pad" ? "tablet" : "phone";
14
+
15
+ return {
16
+ sdk: { client: "@q9labsai/chalk-client", ...(reactNativeVersion ? { react_native: reactNativeVersion } : {}) },
17
+ platform: {
18
+ kind,
19
+ ...(osName ? { os_name: osName } : {}),
20
+ ...(osVersion ? { os_version: osVersion } : {}),
21
+ device_class: deviceClass,
22
+ ...(deviceModel ? { device_model: deviceModel } : {}),
23
+ },
24
+ };
25
+ }
26
+
27
+ function feedbackPlatformKind(platform: string): FeedbackPlatformKind {
28
+ if (platform === "ios") return "ios";
29
+ if (platform === "macos") return "macos";
30
+ return "android";
31
+ }
32
+
33
+ function boundedString(value: string | null): string | undefined {
34
+ const normalized = value?.trim();
35
+ if (!normalized) return undefined;
36
+ return normalized.slice(0, 64);
37
+ }
@@ -34,6 +34,10 @@ export function useReactions(): SpaceSnapshot["reactions"] {
34
34
  return useSpaceSlice("reactions");
35
35
  }
36
36
 
37
+ export function useRecording(): SpaceSnapshot["recording"] {
38
+ return useSpaceSlice("recording");
39
+ }
40
+
37
41
  export function useWhiteboard(): SpaceSnapshot["whiteboard"] {
38
42
  return useSpaceSlice("whiteboard");
39
43
  }
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@ export { Logo } from "./components/logo/Logo";
6
6
  export type { LogoMotion, LogoProps, LogoVariant } from "./components/logo/Logo";
7
7
  export { ChalkProvider, useSpaceClient } from "./context/space-client-context";
8
8
  export type { ChalkProviderProps } from "./context/space-client-context";
9
- export { useCan, useChat, useConnection, useMedia, useParticipants, useReactions, useSelf, useWhiteboard } from "./hooks/space-hooks";
9
+ export { useCan, useChat, useConnection, useMedia, useParticipants, useReactions, useRecording, useSelf, useWhiteboard } from "./hooks/space-hooks";
10
10
  export { Theme, createNativeTheme } from "./ui/theme";
11
11
  export { extractSpaceInviteFromLink, getClipboardInviteSuggestion, parseSpaceInviteLink } from "./invites";
12
12
  export type { SpaceInviteLink } from "./invites";
@@ -16,6 +16,7 @@ import {
16
16
  type ReactNativeAsyncStorage,
17
17
  } from "@q9labsai/chalk-client";
18
18
  import { createSpaceClientForPlatform, type SpaceClientPlatform } from "@q9labsai/chalk-client/effect";
19
+ import type { FeedbackSource } from "@q9labsai/chalk-client";
19
20
  import type { SpaceClient } from "@q9labsai/chalk-client";
20
21
  import { RTCPeerConnection, mediaDevices } from "@cloudflare/react-native-webrtc";
21
22
  import { AppState } from "react-native";
@@ -41,6 +42,7 @@ export type NativeSpaceClientOptions = {
41
42
  readonly syncStartupTimeoutMs?: number;
42
43
  readonly storage?: ReactNativeAsyncStorage;
43
44
  readonly telemetry?: TelemetryJourney;
45
+ readonly feedbackSource?: FeedbackSource;
44
46
  };
45
47
 
46
48
  export function createNativeSpaceClient(options: NativeSpaceClientOptions): SpaceClient {
@@ -86,6 +88,8 @@ export function createNativeSpaceClient(options: NativeSpaceClientOptions): Spac
86
88
  initialMicrophoneEnabled: options.microphone,
87
89
  initialCameraEnabled: options.camera,
88
90
  onConnectionDiagnostic: (event) => journey?.recordDiagnostic(connectionDiagnostic(event)),
91
+ ...(journey ? { recordReconnect: (observation) => journey.recordDiagnostic(observation) } : {}),
92
+ feedbackSource: options.feedbackSource ?? "embedded",
89
93
  },
90
94
  );
91
95
  bindNativeAuthenticatedTelemetry(client, journey);
@@ -150,6 +154,7 @@ function createMediaClient(apiBaseURL: string, input: MediaFactoryInput, fetch:
150
154
  participantId: subject.participantId,
151
155
  transport: createCloudflareSFUHTTPTransport({
152
156
  apiBaseURL,
157
+ telemetry: input.telemetry,
153
158
  credential: () => authenticatedNativeCredential(input.credential, journey),
154
159
  tenantId: subject.tenantId,
155
160
  spaceId: subject.spaceId,
@@ -169,6 +174,7 @@ function createMediaClient(apiBaseURL: string, input: MediaFactoryInput, fetch:
169
174
  observePeerConnection?.(connection as unknown as RtcPeerConnection);
170
175
  return connection as unknown as globalThis.RTCPeerConnection;
171
176
  },
177
+ recordReconnect: input.recordReconnect,
172
178
  onError: input.onFailure,
173
179
  onScreenEnded: input.onScreenEnded,
174
180
  });
@@ -224,6 +230,7 @@ function createSyncClient(syncURL: string, input: SyncFactoryInput, lifecycle: R
224
230
  lifecycle,
225
231
  webSocket,
226
232
  telemetry: input.telemetry,
233
+ recordReconnect: input.recordReconnect,
227
234
  ...(storage ? { pendingStore: new AsyncStorageV1PendingTargetStore({ scope, storage }) } : {}),
228
235
  });
229
236
  }
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { createEmbeddedWhiteboardViewport } from "./embedded-whiteboard-viewport";
4
+
5
+ describe("native embedded whiteboard viewport", () => {
6
+ it("uses the measured Board rectangle rather than the device window", () => {
7
+ expect(createEmbeddedWhiteboardViewport(640, 420, 2)).toEqual({ width: 640, height: 420, scale: 2 });
8
+ });
9
+
10
+ it("waits for a usable layout", () => {
11
+ expect(createEmbeddedWhiteboardViewport(0, 420, 2)).toBeNull();
12
+ });
13
+ });