@q9labsai/chalk-react-native 5.2.0 → 5.2.1
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.
- package/dist/{chunk-4L5GXJ36.js → chunk-BXDD5BE4.js} +23 -1
- package/dist/{chunk-MBZRE4US.js → chunk-PPQSOU35.js} +5 -5
- package/dist/components/EmbeddedWhiteboard.d.ts +8 -4
- package/dist/frame/call-controls.d.ts +2 -0
- package/dist/frame/use-landscape-chrome.d.ts +8 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +358 -97
- package/dist/invites.d.ts +5 -0
- package/dist/invites.js +3 -1
- package/dist/primitives/IconButton.d.ts +1 -0
- package/dist/primitives/NameTag.d.ts +2 -1
- package/dist/primitives/TileShell.d.ts +2 -1
- package/dist/stage/ParticipantTile.d.ts +3 -1
- package/dist/stage/TileSheet.d.ts +1 -1
- package/dist/stage/stage-view.d.ts +4 -0
- package/dist/ui.js +1 -1
- package/dist/whiteboard/export-board-image.d.ts +6 -0
- package/dist/whiteboard/native-board-session.d.ts +15 -0
- package/embedded/chalk-whiteboard/Figtree-Regular.ttf +0 -0
- package/embedded/chalk-whiteboard/Figtree-SemiBold.ttf +0 -0
- package/embedded/chalk-whiteboard/asset-manifest.json +19 -4
- package/embedded/chalk-whiteboard/chalk.css +44 -0
- package/embedded/chalk-whiteboard/licenses/Figtree.txt +93 -0
- package/embedded/chalk-whiteboard/renderer.js +1669 -1418
- package/package.json +11 -6
- package/src/components/EmbeddedWhiteboard.tsx +90 -6
- package/src/dock/Dock.tsx +14 -3
- package/src/dock/MoreSheet.tsx +23 -0
- package/src/frame/CallFrame.tsx +20 -33
- package/src/frame/call-controls.ts +4 -0
- package/src/frame/use-landscape-chrome.ts +22 -0
- package/src/index.ts +2 -2
- package/src/invites.ts +32 -1
- package/src/primitives/IconButton.tsx +6 -5
- package/src/primitives/NameTag.tsx +7 -1
- package/src/primitives/TileShell.tsx +3 -1
- package/src/stage/ContentTile.tsx +64 -19
- package/src/stage/ParticipantTile.tsx +6 -4
- package/src/stage/Stage.tsx +37 -6
- package/src/stage/TileSheet.tsx +17 -2
- package/src/stage/stage-view.tsx +11 -1
- package/src/whiteboard/export-board-image.ts +21 -0
- package/src/whiteboard/native-board-session.ts +29 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@q9labsai/chalk-react-native",
|
|
3
|
-
"version": "5.2.
|
|
3
|
+
"version": "5.2.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -106,20 +106,24 @@
|
|
|
106
106
|
"registry": "https://registry.npmjs.org/"
|
|
107
107
|
},
|
|
108
108
|
"dependencies": {
|
|
109
|
-
"@q9labsai/chalk-client": "^5.2.
|
|
110
|
-
"@q9labsai/chalk-whiteboard": "^5.2.
|
|
111
|
-
"@q9labsai/facehash": "^5.2.
|
|
109
|
+
"@q9labsai/chalk-client": "^5.2.1",
|
|
110
|
+
"@q9labsai/chalk-whiteboard": "^5.2.1",
|
|
111
|
+
"@q9labsai/facehash": "^5.2.1",
|
|
112
112
|
"uqr": "^0.1.3"
|
|
113
113
|
},
|
|
114
114
|
"devDependencies": {
|
|
115
115
|
"@types/node": "^26.2.0",
|
|
116
116
|
"@types/react": "^19.2.18",
|
|
117
|
+
"@types/react-dom": "^19.2.3",
|
|
117
118
|
"expo-audio": "~55.0.18",
|
|
118
119
|
"expo-clipboard": "~55.0.9",
|
|
119
120
|
"expo-crypto": "~55.0.19",
|
|
120
121
|
"expo-document-picker": "~55.0.15",
|
|
121
122
|
"expo-file-system": "~55.0.24",
|
|
122
123
|
"expo-haptics": "~55.0.18",
|
|
124
|
+
"expo-sharing": "55.0.11",
|
|
125
|
+
"happy-dom": "^21.0.4",
|
|
126
|
+
"react-dom": "19.2.7",
|
|
123
127
|
"react-native-gesture-handler": "~2.30.1",
|
|
124
128
|
"react-native-reanimated": "4.2.1",
|
|
125
129
|
"react-native-safe-area-context": "~5.6.2",
|
|
@@ -136,6 +140,7 @@
|
|
|
136
140
|
"expo-document-picker": "~55.0.15",
|
|
137
141
|
"expo-file-system": "~55.0.24",
|
|
138
142
|
"expo-haptics": "~55.0.18",
|
|
143
|
+
"expo-sharing": "55.0.11",
|
|
139
144
|
"react": "^18.0.0 || ^19.0.0",
|
|
140
145
|
"react-native": ">=0.77",
|
|
141
146
|
"react-native-gesture-handler": "~2.30.1",
|
|
@@ -146,9 +151,9 @@
|
|
|
146
151
|
"react-native-worklets": "0.7.2"
|
|
147
152
|
},
|
|
148
153
|
"scripts": {
|
|
149
|
-
"build": "rm -rf ./dist && tsup ./src/index.ts ./src/client.ts ./src/runtime.ts ./src/clipboard.ts ./src/diagnostics.ts ./src/dev.ts ./src/invites.ts ./src/ui.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 expo-audio --external expo-clipboard --external expo-crypto --external expo-document-picker --external expo-file-system --external expo-haptics --external react-native-gesture-handler --external react-native-reanimated --external react-native-safe-area-context --external react-native-svg --loader .wav=copy && pnpm run build:types && pnpm run sync:whiteboard-assets",
|
|
154
|
+
"build": "rm -rf ./dist && tsup ./src/index.ts ./src/client.ts ./src/runtime.ts ./src/clipboard.ts ./src/diagnostics.ts ./src/dev.ts ./src/invites.ts ./src/ui.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 expo-audio --external expo-clipboard --external expo-crypto --external expo-document-picker --external expo-file-system --external expo-sharing --external expo-haptics --external react-native-gesture-handler --external react-native-reanimated --external react-native-safe-area-context --external react-native-svg --loader .wav=copy && pnpm run build:types && pnpm run sync:whiteboard-assets",
|
|
150
155
|
"build:types": "rm -f tsconfig.tsbuildinfo && tsc --build tsconfig.json",
|
|
151
|
-
"dev": "tsup ./src/index.ts ./src/client.ts ./src/runtime.ts ./src/clipboard.ts ./src/diagnostics.ts ./src/dev.ts ./src/invites.ts ./src/ui.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 expo-audio --external expo-clipboard --external expo-crypto --external expo-document-picker --external expo-file-system --external expo-haptics --external react-native-gesture-handler --external react-native-reanimated --external react-native-safe-area-context --external react-native-svg --loader .wav=copy --watch",
|
|
156
|
+
"dev": "tsup ./src/index.ts ./src/client.ts ./src/runtime.ts ./src/clipboard.ts ./src/diagnostics.ts ./src/dev.ts ./src/invites.ts ./src/ui.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 expo-audio --external expo-clipboard --external expo-crypto --external expo-document-picker --external expo-file-system --external expo-sharing --external expo-haptics --external react-native-gesture-handler --external react-native-reanimated --external react-native-safe-area-context --external react-native-svg --loader .wav=copy --watch",
|
|
152
157
|
"sync:whiteboard-assets": "node ./scripts/sync-whiteboard-assets.mjs",
|
|
153
158
|
"test": "pnpm exec vitest run",
|
|
154
159
|
"check-types": "tsc-rs --project tsconfig.check-types.json --noEmit",
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { ChalkWhiteboardController, type ChalkEmbeddedWhiteboardTransport, type ChalkEmbeddedWhiteboardViewport } from "@q9labsai/chalk-whiteboard/embedded";
|
|
1
|
+
import { ChalkWhiteboardController, type ChalkEmbeddedWhiteboardTransport, type ChalkEmbeddedBoardContext, type ChalkEmbeddedWhiteboardViewport } from "@q9labsai/chalk-whiteboard/embedded";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { ActivityIndicator, Pressable, Text, useWindowDimensions, View, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
|
|
4
|
+
import { exportBoardImage } from "../whiteboard/export-board-image";
|
|
5
|
+
import { useToasts } from "../primitives/toast/ToastStack";
|
|
4
6
|
import WebView, { type WebViewMessageEvent, type WebViewNavigation } from "react-native-webview";
|
|
5
7
|
|
|
6
8
|
import { isEmbeddedWhiteboardNavigationAllowed, rendererURLWithContext, resolveEmbeddedWhiteboardRendererURL } from "../whiteboard/embedded-whiteboard-assets";
|
|
7
9
|
import { createEmbeddedWhiteboardViewport } from "../whiteboard/embedded-whiteboard-viewport";
|
|
8
10
|
|
|
9
11
|
export interface EmbeddedWhiteboardProps {
|
|
12
|
+
readonly board: ChalkEmbeddedBoardContext;
|
|
13
|
+
readonly onFollow?: (participantId: string | null) => void;
|
|
14
|
+
readonly onCloseBoard?: () => void;
|
|
10
15
|
readonly transport: ChalkEmbeddedWhiteboardTransport;
|
|
11
16
|
readonly journeyId: string;
|
|
12
17
|
readonly traceparent?: string;
|
|
@@ -20,7 +25,7 @@ export interface EmbeddedWhiteboardProps {
|
|
|
20
25
|
readonly testID?: string;
|
|
21
26
|
readonly onMetric?: (metric: { readonly name: string; readonly value: number; readonly attributes?: Readonly<Record<string, string | number | boolean>> }) => void;
|
|
22
27
|
readonly onError?: (error: { readonly code: string; readonly message: string; readonly recoverable: boolean }) => void;
|
|
23
|
-
readonly onUserExport?: (value: { readonly requestId: string; readonly format: "png" | "svg"; readonly mimeType: string; readonly dataURL: string }) => void
|
|
28
|
+
readonly onUserExport?: (value: { readonly requestId: string; readonly format: "png" | "svg"; readonly mimeType: string; readonly dataURL: string; readonly action?: "download" | "copy" }) => void | Promise<void>;
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
export const embeddedWhiteboardWebViewSecurityPolicy = Object.freeze({
|
|
@@ -28,7 +33,7 @@ export const embeddedWhiteboardWebViewSecurityPolicy = Object.freeze({
|
|
|
28
33
|
allowFileAccessFromFileURLs: false,
|
|
29
34
|
allowUniversalAccessFromFileURLs: false,
|
|
30
35
|
cacheEnabled: false,
|
|
31
|
-
domStorageEnabled:
|
|
36
|
+
domStorageEnabled: true,
|
|
32
37
|
javaScriptCanOpenWindowsAutomatically: false,
|
|
33
38
|
javaScriptEnabled: true,
|
|
34
39
|
mixedContentMode: "never" as const,
|
|
@@ -37,11 +42,41 @@ export const embeddedWhiteboardWebViewSecurityPolicy = Object.freeze({
|
|
|
37
42
|
thirdPartyCookiesEnabled: false,
|
|
38
43
|
});
|
|
39
44
|
|
|
40
|
-
export function EmbeddedWhiteboard({
|
|
45
|
+
export function EmbeddedWhiteboard({
|
|
46
|
+
board,
|
|
47
|
+
onFollow,
|
|
48
|
+
onCloseBoard,
|
|
49
|
+
transport,
|
|
50
|
+
journeyId,
|
|
51
|
+
traceparent,
|
|
52
|
+
tracestate,
|
|
53
|
+
canDraw,
|
|
54
|
+
canClear,
|
|
55
|
+
theme: themeOverride,
|
|
56
|
+
localParticipantColor,
|
|
57
|
+
rendererURL: rendererURLOverride,
|
|
58
|
+
style,
|
|
59
|
+
testID,
|
|
60
|
+
onMetric,
|
|
61
|
+
onError,
|
|
62
|
+
onUserExport,
|
|
63
|
+
}: EmbeddedWhiteboardProps): React.JSX.Element {
|
|
64
|
+
const toasts = useToasts();
|
|
65
|
+
const noticesRef = useRef(new Map<string, number>());
|
|
66
|
+
const clearNotices = useCallback(() => {
|
|
67
|
+
noticesRef.current.forEach((id) => toasts.dismiss(id));
|
|
68
|
+
noticesRef.current.clear();
|
|
69
|
+
}, [toasts]);
|
|
41
70
|
const theme = themeOverride ?? "light";
|
|
42
71
|
const webViewRef = useRef<WebView>(null);
|
|
43
72
|
const controllerRef = useRef<ChalkWhiteboardController | null>(null);
|
|
44
73
|
const listenersRef = useRef(new Set<(message: string) => void>());
|
|
74
|
+
const boardRef = useRef(board);
|
|
75
|
+
boardRef.current = board;
|
|
76
|
+
const onFollowRef = useRef(onFollow);
|
|
77
|
+
onFollowRef.current = onFollow;
|
|
78
|
+
const onCloseBoardRef = useRef(onCloseBoard);
|
|
79
|
+
onCloseBoardRef.current = onCloseBoard;
|
|
45
80
|
const onMetricRef = useRef(onMetric);
|
|
46
81
|
const onErrorRef = useRef(onError);
|
|
47
82
|
const onUserExportRef = useRef(onUserExport);
|
|
@@ -71,7 +106,13 @@ export function EmbeddedWhiteboard({ transport, journeyId, traceparent, tracesta
|
|
|
71
106
|
);
|
|
72
107
|
const handleControllerError = useCallback((error: { readonly code: string; readonly message: string; readonly recoverable: boolean }) => onErrorRef.current?.(error), []);
|
|
73
108
|
const handleControllerMetric = useCallback((metric: { readonly name: string; readonly value: number; readonly attributes?: Readonly<Record<string, string | number | boolean>> }) => onMetricRef.current?.(metric), []);
|
|
74
|
-
const handleControllerUserExport = useCallback((value: { readonly requestId: string; readonly format: "png" | "svg"; readonly mimeType: string; readonly dataURL: string }) =>
|
|
109
|
+
const handleControllerUserExport = useCallback(async (value: { readonly requestId: string; readonly format: "png" | "svg"; readonly mimeType: string; readonly dataURL: string; readonly action?: "download" | "copy" }) => {
|
|
110
|
+
if (onUserExportRef.current) {
|
|
111
|
+
await onUserExportRef.current(value);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
await exportBoardImage(value);
|
|
115
|
+
}, []);
|
|
75
116
|
|
|
76
117
|
useEffect(() => {
|
|
77
118
|
let active = true;
|
|
@@ -99,6 +140,43 @@ export function EmbeddedWhiteboard({ transport, journeyId, traceparent, tracesta
|
|
|
99
140
|
journeyId,
|
|
100
141
|
...(traceparent ? { traceparent } : {}),
|
|
101
142
|
...(tracestate ? { tracestate } : {}),
|
|
143
|
+
board: boardRef.current,
|
|
144
|
+
manageSceneSubscription: false,
|
|
145
|
+
onNotice: (notice) => {
|
|
146
|
+
if (!notice) {
|
|
147
|
+
clearNotices();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const toastId = toasts.show({
|
|
151
|
+
kind: notice.kind,
|
|
152
|
+
message: notice.message,
|
|
153
|
+
dismissible: notice.kind === "error",
|
|
154
|
+
...(notice.actionLabel
|
|
155
|
+
? {
|
|
156
|
+
actions: [
|
|
157
|
+
{
|
|
158
|
+
label: notice.actionLabel,
|
|
159
|
+
onPress: () => {
|
|
160
|
+
controller.performNoticeAction(notice.id);
|
|
161
|
+
const id = noticesRef.current.get(notice.id);
|
|
162
|
+
if (id !== undefined) toasts.dismiss(id);
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
}
|
|
167
|
+
: {}),
|
|
168
|
+
});
|
|
169
|
+
noticesRef.current.set(notice.id, toastId);
|
|
170
|
+
if (noticesRef.current.size > 8) {
|
|
171
|
+
const oldest = noticesRef.current.entries().next().value;
|
|
172
|
+
if (oldest) {
|
|
173
|
+
toasts.dismiss(oldest[1]);
|
|
174
|
+
noticesRef.current.delete(oldest[0]);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
onFollow: (id) => onFollowRef.current?.(id),
|
|
179
|
+
onCloseBoard: () => onCloseBoardRef.current?.(),
|
|
102
180
|
canDraw,
|
|
103
181
|
canClear,
|
|
104
182
|
theme,
|
|
@@ -114,8 +192,13 @@ export function EmbeddedWhiteboard({ transport, journeyId, traceparent, tracesta
|
|
|
114
192
|
return () => {
|
|
115
193
|
if (controllerRef.current === controller) controllerRef.current = null;
|
|
116
194
|
controller.stop();
|
|
195
|
+
clearNotices();
|
|
117
196
|
};
|
|
118
|
-
}, [handleControllerError, handleControllerMetric, handleControllerUserExport, journeyId, localParticipantColor, rendererPort, theme, traceparent, tracestate, transport]);
|
|
197
|
+
}, [clearNotices, toasts, handleControllerError, handleControllerMetric, handleControllerUserExport, journeyId, localParticipantColor, rendererPort, theme, traceparent, tracestate, transport]);
|
|
198
|
+
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
controllerRef.current?.setBoard(board);
|
|
201
|
+
}, [board]);
|
|
119
202
|
|
|
120
203
|
useEffect(() => {
|
|
121
204
|
controllerRef.current?.setCapabilities({ canDraw, canClear });
|
|
@@ -136,6 +219,7 @@ export function EmbeddedWhiteboard({ transport, journeyId, traceparent, tracesta
|
|
|
136
219
|
};
|
|
137
220
|
const allowNavigation = (request: WebViewNavigation): boolean => Boolean(rendererURL && isEmbeddedWhiteboardNavigationAllowed(request.url, rendererURL));
|
|
138
221
|
const reloadRenderer = (): void => {
|
|
222
|
+
clearNotices();
|
|
139
223
|
controllerRef.current?.rendererReloaded();
|
|
140
224
|
setRendererGeneration(createRendererGeneration());
|
|
141
225
|
};
|
package/src/dock/Dock.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useConnection } from "../hooks/space-hooks";
|
|
1
|
+
import { useConnection, useMedia } from "../hooks/space-hooks";
|
|
2
2
|
import { memo, useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
|
3
3
|
import { StyleSheet, View } from "react-native";
|
|
4
4
|
import { useDevScenario } from "../frame/dev-scenario";
|
|
@@ -31,14 +31,25 @@ export type DockProps = {
|
|
|
31
31
|
*/
|
|
32
32
|
export const Dock = memo(function Dock({ arrangement, microphoneOn, cameraOn, onToggleMicrophone, onToggleCamera, onOpenMore, onLeave, moreOpen, leaveOpen, moreRef, leaveRef }: DockProps) {
|
|
33
33
|
const live = useConnection().status === "live";
|
|
34
|
+
const { local } = useMedia();
|
|
34
35
|
const size = arrangement === "pad" ? 44 : 52;
|
|
35
36
|
const dockStyle = [styles.dock, arrangement === "pad" ? styles.pad : arrangement === "landscape" ? styles.landscape : styles.portrait];
|
|
36
37
|
const dockRef = useMeasuredRef<View>("frame.dock", dockStyle);
|
|
37
38
|
const output = useAudioOutput();
|
|
38
39
|
return (
|
|
39
40
|
<View accessibilityLabel="Call controls" accessibilityRole="toolbar" pointerEvents="box-none" ref={dockRef} style={dockStyle} testID="frame.dock">
|
|
40
|
-
<IconButton
|
|
41
|
-
|
|
41
|
+
<IconButton
|
|
42
|
+
accessibilityLabel={microphoneOn ? "Mute microphone" : "Unmute microphone"}
|
|
43
|
+
busy={local.microphone.state === "requesting"}
|
|
44
|
+
icon={microphoneOn ? "mic" : "micOff"}
|
|
45
|
+
off={!microphoneOn}
|
|
46
|
+
disabled={!live && !microphoneOn}
|
|
47
|
+
onCanvas
|
|
48
|
+
onPress={onToggleMicrophone}
|
|
49
|
+
size={size}
|
|
50
|
+
testID="dock.microphone"
|
|
51
|
+
/>
|
|
52
|
+
<IconButton accessibilityLabel={cameraOn ? "Turn off camera" : "Turn on camera"} busy={local.camera.state === "requesting"} icon={cameraOn ? "cam" : "camOff"} off={!cameraOn} disabled={!live && !cameraOn} onCanvas onPress={onToggleCamera} size={size} testID="dock.camera" />
|
|
42
53
|
{output ? <AudioOutputButton captioned={arrangement === "portrait"} output={output} size={size} /> : null}
|
|
43
54
|
<IconButton accessibilityLabel="More options" expanded={moreOpen} icon="more" onCanvas onPress={onOpenMore} ref={moreRef} size={size} testID="dock.more" />
|
|
44
55
|
{arrangement === "pad" ? <View style={styles.gap} /> : null}
|
package/src/dock/MoreSheet.tsx
CHANGED
|
@@ -48,12 +48,14 @@ const REACTIONS: readonly (readonly [Reaction, string])[] = [
|
|
|
48
48
|
export function MoreSheet({ open, onClose, onOpenPeople, onOpenChat, inviteLink, returnFocusRef }: MoreSheetProps): React.JSX.Element {
|
|
49
49
|
const client = useSpaceClient();
|
|
50
50
|
const self = useSelf();
|
|
51
|
+
const stage = useStageView();
|
|
51
52
|
const people = useParticipants().roster.length;
|
|
52
53
|
const toasts = useToasts();
|
|
53
54
|
const { soundsEnabled } = usePreferences();
|
|
54
55
|
const canRaiseHand = useCan("raiseHand");
|
|
55
56
|
const live = useConnection().status === "live";
|
|
56
57
|
const canReact = useCan("sendReaction") && live;
|
|
58
|
+
const canManageBoard = useCan("manageWhiteboard");
|
|
57
59
|
|
|
58
60
|
const fail = useCallback((message: string) => () => toasts.show({ kind: "error", dismissible: true, message }), [toasts]);
|
|
59
61
|
const toggleHand = useCallback(() => {
|
|
@@ -86,6 +88,27 @@ export function MoreSheet({ open, onClose, onOpenPeople, onOpenChat, inviteLink,
|
|
|
86
88
|
<SheetGroup>
|
|
87
89
|
{inviteLink ? <CopyLinkRow link={inviteLink} /> : null}
|
|
88
90
|
{inviteLink ? <SheetRow icon="iosShare" label="Share link" onPress={() => shareLink(inviteLink)} testID="more.share" /> : null}
|
|
91
|
+
<SheetRow
|
|
92
|
+
icon="pen"
|
|
93
|
+
label="Board"
|
|
94
|
+
onPress={() => {
|
|
95
|
+
onClose();
|
|
96
|
+
const transport = client.whiteboard.transport();
|
|
97
|
+
if (!transport) {
|
|
98
|
+
fail("The board is unavailable in this call.")();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
void transport
|
|
102
|
+
.startSceneSubscription()
|
|
103
|
+
.then(async () => {
|
|
104
|
+
if (canManageBoard) await transport.setPresentation?.(true);
|
|
105
|
+
stage.setBoardOpen(true);
|
|
106
|
+
stage.showContent();
|
|
107
|
+
})
|
|
108
|
+
.catch(fail("Couldn’t open the board. Try again."));
|
|
109
|
+
}}
|
|
110
|
+
testID="more.board"
|
|
111
|
+
/>
|
|
89
112
|
<RecordingRow onDone={onClose} />
|
|
90
113
|
<ChatRow onPress={onOpenChat} />
|
|
91
114
|
<SheetRow end={<PeopleCount count={people} />} icon="users" label="People" onPress={onOpenPeople} testID="more.people" />
|
package/src/frame/CallFrame.tsx
CHANGED
|
@@ -9,18 +9,19 @@ import { MoreSheet } from "../dock/MoreSheet";
|
|
|
9
9
|
import { haptics } from "../haptics/haptics";
|
|
10
10
|
import { CallDetailsSheet } from "../header/CallDetailsSheet";
|
|
11
11
|
import { Header } from "../header/Header";
|
|
12
|
-
import { useCan, useConnection, useMedia } from "../hooks/space-hooks";
|
|
12
|
+
import { useCan, useConnection, useMedia, useWhiteboard } from "../hooks/space-hooks";
|
|
13
13
|
import { timings } from "../motion/motion";
|
|
14
14
|
import { ToastStack, useToasts } from "../primitives/toast/ToastStack";
|
|
15
15
|
import { ChatNotices } from "../sheets/people-chat/ChatNotices";
|
|
16
16
|
import { PeopleChatSheet } from "../sheets/people-chat/PeopleChatSheet";
|
|
17
17
|
import { playSound } from "../sounds/sounds";
|
|
18
18
|
import { Stage } from "../stage/Stage";
|
|
19
|
+
import { useStageView } from "../stage/stage-view";
|
|
20
|
+
import { useLandscapeChrome } from "./use-landscape-chrome";
|
|
19
21
|
import { CallOverlays } from "../status/CallOverlays";
|
|
20
22
|
import { ArrivalScreen } from "../status/ChalkArrival";
|
|
21
23
|
import { callScreen } from "../status/call-screen";
|
|
22
24
|
import { useTheme } from "../theme/theme";
|
|
23
|
-
import { durations } from "../theme/tokens";
|
|
24
25
|
import { useCallEventToasts, type Permission } from "../toasts/CallEventToasts";
|
|
25
26
|
import { useDevScenario } from "./dev-scenario";
|
|
26
27
|
import { useDeviceLayout } from "./device-layout";
|
|
@@ -28,6 +29,7 @@ import { PortalHost, usePortalCount } from "./portal";
|
|
|
28
29
|
import { useMeasuredRef } from "./measurement";
|
|
29
30
|
import { usePreferences } from "./preferences";
|
|
30
31
|
import { frameRegions } from "./regions";
|
|
32
|
+
import { LandscapeControlsContext } from "./call-controls";
|
|
31
33
|
|
|
32
34
|
export type CallFrameProps = {
|
|
33
35
|
readonly displayName?: string;
|
|
@@ -119,7 +121,17 @@ export function CallFrame({ displayName, callName = "Call", inviteLink, onLeave,
|
|
|
119
121
|
if (devScenario.menu === "leave") setLeaveMenuOpen(true);
|
|
120
122
|
}, [devScenario]);
|
|
121
123
|
|
|
122
|
-
const
|
|
124
|
+
const sharedBoard = useWhiteboard().engine.presenting;
|
|
125
|
+
const boardVisible = useStageView().boardOpen ?? sharedBoard;
|
|
126
|
+
const chrome = useLandscapeChrome({ fades: layout.arrangement === "landscape" && sheet === null && !layered, boardVisible });
|
|
127
|
+
const chromeOpacity = useSharedValue(1);
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
chromeOpacity.value = withTiming(chrome.hidden ? 0 : 1, timings.fade);
|
|
130
|
+
}, [chrome.hidden, chromeOpacity]);
|
|
131
|
+
const chromeStyle = useAnimatedStyle(() => {
|
|
132
|
+
"worklet";
|
|
133
|
+
return { opacity: chromeOpacity.value };
|
|
134
|
+
});
|
|
123
135
|
const forced = devScenario.callState;
|
|
124
136
|
const screen = forced === "hold" ? { kind: "call" as const } : forced === "left" || forced === "ended" || forced === "failed" || forced === "removed" || forced === "exited" ? { kind: "status" as const, outcome: forced } : callScreen(connection, leftByChoice);
|
|
125
137
|
|
|
@@ -127,12 +139,14 @@ export function CallFrame({ displayName, callName = "Call", inviteLink, onLeave,
|
|
|
127
139
|
<View ref={screenRef} style={screenStyle} testID="frame.screen">
|
|
128
140
|
<StatusBar barStyle={scheme === "dark" ? "light-content" : "dark-content"} />
|
|
129
141
|
<View accessibilityElementsHidden={layered} importantForAccessibility={layered ? "no-hide-descendants" : "auto"} style={StyleSheet.absoluteFill}>
|
|
130
|
-
<
|
|
142
|
+
<LandscapeControlsContext.Provider value={layout.arrangement === "landscape" && !chrome.hidden}>
|
|
143
|
+
<Stage frame={regions.stage} onOpenPeople={() => setSheet("people")} />
|
|
144
|
+
</LandscapeControlsContext.Provider>
|
|
131
145
|
{chrome.hidden ? <Pressable accessibilityLabel="Show call controls" onPress={chrome.show} style={StyleSheet.absoluteFill} testID="frame.show-controls" /> : null}
|
|
132
|
-
<Animated.View pointerEvents={chrome.hidden ? "none" : "box-none"} style={[styles.region, frameStyle(regions.header),
|
|
146
|
+
<Animated.View pointerEvents={chrome.hidden ? "none" : "box-none"} style={[styles.region, frameStyle(regions.header), chromeStyle]}>
|
|
133
147
|
<Header arrangement={layout.arrangement} callName={callName} detailsOpen={sheet === "details"} onOpenDetails={() => setSheet("details")} ref={nameRef} />
|
|
134
148
|
</Animated.View>
|
|
135
|
-
<Animated.View pointerEvents={chrome.hidden ? "none" : "box-none"} style={[styles.region, frameStyle(regions.dock),
|
|
149
|
+
<Animated.View pointerEvents={chrome.hidden ? "none" : "box-none"} style={[styles.region, frameStyle(regions.dock), chromeStyle]}>
|
|
136
150
|
<Dock
|
|
137
151
|
arrangement={layout.arrangement}
|
|
138
152
|
cameraOn={cameraOn}
|
|
@@ -172,33 +186,6 @@ export function CallFrame({ displayName, callName = "Call", inviteLink, onLeave,
|
|
|
172
186
|
);
|
|
173
187
|
}
|
|
174
188
|
|
|
175
|
-
/** Phone landscape (L1): the controls fade after 4 s and come back on a tap. */
|
|
176
|
-
function useLandscapeChrome(fades: boolean): { readonly hidden: boolean; readonly show: () => void; readonly style: ReturnType<typeof useAnimatedStyle> } {
|
|
177
|
-
const [hidden, setHidden] = useState(false);
|
|
178
|
-
const [shownAt, setShownAt] = useState(0);
|
|
179
|
-
const opacity = useSharedValue(1);
|
|
180
|
-
useEffect(() => {
|
|
181
|
-
opacity.value = withTiming(hidden ? 0 : 1, timings.fade);
|
|
182
|
-
}, [hidden, opacity]);
|
|
183
|
-
useEffect(() => {
|
|
184
|
-
if (!fades) {
|
|
185
|
-
setHidden(false);
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
const timer = setTimeout(() => setHidden(true), durations.landscapeHide);
|
|
189
|
-
return () => clearTimeout(timer);
|
|
190
|
-
}, [fades, shownAt]);
|
|
191
|
-
const show = useCallback(() => {
|
|
192
|
-
setHidden(false);
|
|
193
|
-
setShownAt(Date.now());
|
|
194
|
-
}, []);
|
|
195
|
-
const style = useAnimatedStyle(() => {
|
|
196
|
-
"worklet";
|
|
197
|
-
return { opacity: opacity.value };
|
|
198
|
-
});
|
|
199
|
-
return { hidden, show, style };
|
|
200
|
-
}
|
|
201
|
-
|
|
202
189
|
function frameStyle(frame: { readonly x: number; readonly y: number; readonly width: number; readonly height: number }) {
|
|
203
190
|
return { left: frame.x, top: frame.y, width: frame.width, height: frame.height };
|
|
204
191
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { durations } from "../theme/tokens";
|
|
3
|
+
|
|
4
|
+
/** A visible board stays interactive instead of acquiring the tap-to-reveal overlay. */
|
|
5
|
+
export function useLandscapeChrome({ fades, boardVisible }: { readonly fades: boolean; readonly boardVisible: boolean }) {
|
|
6
|
+
const canHide = fades && !boardVisible;
|
|
7
|
+
const [hidden, setHidden] = useState(false);
|
|
8
|
+
const [shownAt, setShownAt] = useState(0);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (!canHide) {
|
|
11
|
+
setHidden(false);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const timer = setTimeout(() => setHidden(true), durations.landscapeHide);
|
|
15
|
+
return () => clearTimeout(timer);
|
|
16
|
+
}, [canHide, shownAt]);
|
|
17
|
+
const show = useCallback(() => {
|
|
18
|
+
setHidden(false);
|
|
19
|
+
setShownAt(Date.now());
|
|
20
|
+
}, []);
|
|
21
|
+
return { hidden: canHide && hidden, show };
|
|
22
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,6 @@ export type { ColorScheme, Palette } from "./theme/tokens";
|
|
|
7
7
|
export { ChalkProvider, useSpaceClient } from "./context/space-client-context";
|
|
8
8
|
export type { ChalkProviderProps } from "./context/space-client-context";
|
|
9
9
|
export { useCan, useChat, useConnection, useMedia, useParticipants, useReactions, useRecording, useSelf, useWhiteboard } from "./hooks/space-hooks";
|
|
10
|
-
export { extractSpaceInviteFromLink, getClipboardInviteSuggestion, parseSpaceInviteLink } from "./invites";
|
|
11
|
-
export type { SpaceInviteLink } from "./invites";
|
|
10
|
+
export { extractSpaceInviteFromLink, getClipboardInviteSuggestion, parseChalkInviteLink, parseSpaceInviteLink } from "./invites";
|
|
11
|
+
export type { ChalkInviteLink, SpaceInviteLink } from "./invites";
|
|
12
12
|
export { buildChalkInviteLink } from "./utils/build-chalk-invite-link";
|
package/src/invites.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parseInviteCode } from "@q9labsai/chalk-client/invites";
|
|
2
|
+
|
|
1
3
|
const CHALK_INVITE_HOSTS: readonly string[] = ["chalkmeet.com", "chalk.q9labs.ai"];
|
|
2
4
|
const CHALK_CUSTOM_PROTOCOLS = new Set(["chalk:", "ai.q9labs.chalk.mobile:"]);
|
|
3
5
|
const SPACE_SLUG_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u;
|
|
@@ -8,6 +10,11 @@ export type SpaceInviteLink = {
|
|
|
8
10
|
readonly spaceInviteToken: string;
|
|
9
11
|
};
|
|
10
12
|
|
|
13
|
+
export type ChalkInviteLink = {
|
|
14
|
+
readonly slug?: string;
|
|
15
|
+
readonly spaceInviteToken: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
11
18
|
export function parseSpaceInviteLink(input: string): SpaceInviteLink | null {
|
|
12
19
|
const trimmed = input.trim();
|
|
13
20
|
if (!trimmed) {
|
|
@@ -36,6 +43,30 @@ export function parseSpaceInviteLink(input: string): SpaceInviteLink | null {
|
|
|
36
43
|
}
|
|
37
44
|
}
|
|
38
45
|
|
|
46
|
+
export function parseChalkInviteLink(input: string): ChalkInviteLink | null {
|
|
47
|
+
const trimmed = input.trim();
|
|
48
|
+
const code = parseInviteCode(trimmed);
|
|
49
|
+
if (code) return { spaceInviteToken: code };
|
|
50
|
+
|
|
51
|
+
const legacy = parseSpaceInviteLink(trimmed);
|
|
52
|
+
if (legacy) return legacy;
|
|
53
|
+
|
|
54
|
+
const normalized = trimmed.includes("://") ? trimmed : CHALK_INVITE_HOSTS.some((host) => trimmed.startsWith(`${host}/`)) ? `https://${trimmed}` : null;
|
|
55
|
+
if (!normalized) return null;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const parsed = new URL(normalized);
|
|
59
|
+
if (!CHALK_CUSTOM_PROTOCOLS.has(parsed.protocol) && !isAllowedWebInviteOrigin(parsed)) return null;
|
|
60
|
+
const pathSegments = parsed.pathname.split("/").filter(Boolean);
|
|
61
|
+
if (CHALK_CUSTOM_PROTOCOLS.has(parsed.protocol)) pathSegments.unshift(parsed.hostname);
|
|
62
|
+
if (pathSegments.length !== 1 || parsed.search || parsed.hash) return null;
|
|
63
|
+
const pathCode = parseInviteCode(decodeURIComponent(pathSegments[0] ?? ""));
|
|
64
|
+
return pathCode ? { spaceInviteToken: pathCode } : null;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
39
70
|
function isChalkInviteHost(hostname: string): boolean {
|
|
40
71
|
return CHALK_INVITE_HOSTS.some((host) => host === hostname);
|
|
41
72
|
}
|
|
@@ -63,5 +94,5 @@ export function getClipboardInviteSuggestion(clipboardText: string | null | unde
|
|
|
63
94
|
return null;
|
|
64
95
|
}
|
|
65
96
|
|
|
66
|
-
return
|
|
97
|
+
return parseChalkInviteLink(normalizedClipboardText) ? normalizedClipboardText : null;
|
|
67
98
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { forwardRef, memo } from "react";
|
|
2
|
-
import { Pressable, StyleSheet, View, type View as ViewType } from "react-native";
|
|
2
|
+
import { ActivityIndicator, Pressable, StyleSheet, View, type View as ViewType } from "react-native";
|
|
3
3
|
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
|
4
4
|
import { Icon, type IconName } from "../icons/Icon";
|
|
5
5
|
import { usePressAnimation } from "../motion/press";
|
|
@@ -27,12 +27,13 @@ export type IconButtonProps = {
|
|
|
27
27
|
/** Sits directly on the call canvas (dock, header); the light theme rings it with a hairline. */
|
|
28
28
|
readonly onCanvas?: boolean;
|
|
29
29
|
readonly disabled?: boolean;
|
|
30
|
+
readonly busy?: boolean;
|
|
30
31
|
readonly testID?: string;
|
|
31
32
|
};
|
|
32
33
|
|
|
33
34
|
/** The mockup's round button (`.rb`): press scale 0.94 over 150 ms, background switches at once. */
|
|
34
35
|
export const IconButton = memo(
|
|
35
|
-
forwardRef<ViewType, IconButtonProps>(function IconButton({ icon, accessibilityLabel, onPress, size = 44, tone = "raised", selected = false, off = false, caption, expanded, onCanvas = false, disabled = false, testID }, ref) {
|
|
36
|
+
forwardRef<ViewType, IconButtonProps>(function IconButton({ icon, accessibilityLabel, onPress, size = 44, tone = "raised", selected = false, off = false, caption, expanded, onCanvas = false, disabled = false, busy = false, testID }, ref) {
|
|
36
37
|
const { colors, elevation } = useTheme();
|
|
37
38
|
const press = usePressAnimation("round");
|
|
38
39
|
const base = selected ? colors.text : BASE[tone](colors);
|
|
@@ -51,8 +52,8 @@ export const IconButton = memo(
|
|
|
51
52
|
<Pressable
|
|
52
53
|
accessibilityLabel={accessibilityLabel}
|
|
53
54
|
accessibilityRole="button"
|
|
54
|
-
accessibilityState={{ selected, disabled, expanded }}
|
|
55
|
-
disabled={disabled}
|
|
55
|
+
accessibilityState={{ selected, disabled: disabled || busy, expanded, busy }}
|
|
56
|
+
disabled={disabled || busy}
|
|
56
57
|
hitSlop={size < 44 ? (44 - size) / 2 : undefined}
|
|
57
58
|
onPress={onPress}
|
|
58
59
|
onPressIn={press.onPressIn}
|
|
@@ -61,7 +62,7 @@ export const IconButton = memo(
|
|
|
61
62
|
testID={testID}
|
|
62
63
|
>
|
|
63
64
|
<Animated.View ref={roundRef} style={[roundStyle, background, press.scaleStyle]}>
|
|
64
|
-
<Icon color={iconColor} name={icon} size={tone === "ghost" ? 18 : 20} />
|
|
65
|
+
{busy ? <ActivityIndicator color={iconColor} size="small" /> : <Icon color={iconColor} name={icon} size={tone === "ghost" ? 18 : 20} />}
|
|
65
66
|
</Animated.View>
|
|
66
67
|
{caption ? (
|
|
67
68
|
<View pointerEvents="none" style={styles.captionBox}>
|
|
@@ -8,6 +8,7 @@ import { ChalkText } from "./ChalkText";
|
|
|
8
8
|
export type NameTagProps = {
|
|
9
9
|
readonly name: string;
|
|
10
10
|
readonly muted?: boolean;
|
|
11
|
+
readonly following?: boolean;
|
|
11
12
|
readonly pinned?: boolean;
|
|
12
13
|
/** A leading icon for a tile that isn't a person, such as `share` on a shared screen or `pen` on the board. */
|
|
13
14
|
readonly icon?: IconName;
|
|
@@ -20,7 +21,7 @@ export type NameTagProps = {
|
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
/** The mockup's name tag (`.tag`): bottom-left of a tile, white on the dark overlay in both themes. */
|
|
23
|
-
export const NameTag = memo(function NameTag({ name, muted = false, pinned = false, icon, you = false, handRaised = false, handOrder, testID }: NameTagProps): React.JSX.Element {
|
|
24
|
+
export const NameTag = memo(function NameTag({ name, muted = false, pinned = false, following = false, icon, you = false, handRaised = false, handOrder, testID }: NameTagProps): React.JSX.Element {
|
|
24
25
|
const tagRef = useMeasuredRef<View>(testID, styles.tag);
|
|
25
26
|
return (
|
|
26
27
|
<View pointerEvents="none" style={styles.slot}>
|
|
@@ -30,6 +31,11 @@ export const NameTag = memo(function NameTag({ name, muted = false, pinned = fal
|
|
|
30
31
|
<ChalkText color={fixed.overlayText} numberOfLines={1} style={styles.name} variant="label">
|
|
31
32
|
{name}
|
|
32
33
|
</ChalkText>
|
|
34
|
+
{following ? (
|
|
35
|
+
<ChalkText color={fixed.overlayText} variant="label">
|
|
36
|
+
following
|
|
37
|
+
</ChalkText>
|
|
38
|
+
) : null}
|
|
33
39
|
{you ? (
|
|
34
40
|
<ChalkText color={fixed.overlayText} style={styles.you} variant="label">
|
|
35
41
|
(You)
|
|
@@ -9,6 +9,7 @@ import { fixed, radii } from "../theme/tokens";
|
|
|
9
9
|
export type TileShellProps = {
|
|
10
10
|
readonly children: ReactNode;
|
|
11
11
|
/** The live ring shows only on a speaking person whose camera is on; a camera-off face reacts instead. */
|
|
12
|
+
readonly selected?: boolean;
|
|
12
13
|
readonly speaking?: boolean;
|
|
13
14
|
readonly cameraOn?: boolean;
|
|
14
15
|
/** Speaking level from 0 to 1; the ring's opacity follows it (0.3 + 0.7 × level). */
|
|
@@ -21,7 +22,7 @@ export type TileShellProps = {
|
|
|
21
22
|
* content, and the live ring drawn over the video. The stage fills it with video or an
|
|
22
23
|
* avatar and a `NameTag`.
|
|
23
24
|
*/
|
|
24
|
-
export const TileShell = memo(function TileShell({ children, speaking = false, cameraOn = false, level, testID }: TileShellProps): React.JSX.Element {
|
|
25
|
+
export const TileShell = memo(function TileShell({ children, selected = false, speaking = false, cameraOn = false, level, testID }: TileShellProps): React.JSX.Element {
|
|
25
26
|
const { colors } = useTheme();
|
|
26
27
|
const { phone } = useDeviceLayout();
|
|
27
28
|
const radius = phone ? radii.tilePhone : radii.tile;
|
|
@@ -38,6 +39,7 @@ export const TileShell = memo(function TileShell({ children, speaking = false, c
|
|
|
38
39
|
return (
|
|
39
40
|
<View style={[styles.tile, { borderRadius: radius, backgroundColor: colors.tile }]} testID={testID}>
|
|
40
41
|
{children}
|
|
42
|
+
{selected ? <View pointerEvents="none" style={[StyleSheet.absoluteFill, { borderRadius: radius, borderWidth: 2, borderColor: colors.text, zIndex: 6 }]} /> : null}
|
|
41
43
|
<Animated.View pointerEvents="none" style={[styles.ring, { borderRadius: radius }, ringStyle]} testID={testID ? `${testID}.ring` : undefined}>
|
|
42
44
|
<View style={[styles.ringShade, { borderRadius: radius - 3 }]} />
|
|
43
45
|
</Animated.View>
|