@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
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { memo, useMemo, useRef } from "react";
|
|
1
|
+
import { ChalkWhiteboardViewBounds, type SpaceSnapshot } from "@q9labsai/chalk-client";
|
|
2
|
+
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { Pressable, StyleSheet, View } from "react-native";
|
|
4
4
|
import { EmbeddedWhiteboard } from "../components/EmbeddedWhiteboard";
|
|
5
5
|
import { RtcVideoView } from "../components/RtcVideoView";
|
|
6
6
|
import { useSpaceClient } from "../context/space-client-context";
|
|
7
|
+
import { useDeviceLayout } from "../frame/device-layout";
|
|
8
|
+
import { LandscapeControlsContext } from "../frame/call-controls";
|
|
7
9
|
import { useDevScenario } from "../frame/dev-scenario";
|
|
8
10
|
import { useMeasuredRef } from "../frame/measurement";
|
|
9
|
-
import { Icon } from "../icons/Icon";
|
|
10
11
|
import { createNativeMediaStream } from "../media/native-webrtc";
|
|
11
12
|
import type { Frame } from "../motion/MovingFrame";
|
|
12
|
-
import {
|
|
13
|
+
import { useToasts } from "../primitives/toast/ToastStack";
|
|
13
14
|
import { NameTag } from "../primitives/NameTag";
|
|
14
15
|
import { useTheme } from "../theme/theme";
|
|
15
16
|
import { radii } from "../theme/tokens";
|
|
17
|
+
import { useStageView } from "./stage-view";
|
|
16
18
|
import { useSpaceSelector } from "./space-selector";
|
|
17
19
|
|
|
18
20
|
export type SharedContent = { readonly kind: "screen"; readonly sharerId: string } | { readonly kind: "board" };
|
|
@@ -60,34 +62,79 @@ function ScreenShare({ sharerId, inner, mini, onShowInSpotlight }: { readonly sh
|
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
const selectBoard = (snapshot: SpaceSnapshot) => snapshot.whiteboard.engine.status;
|
|
65
|
+
const selectRoster = (snapshot: SpaceSnapshot) => snapshot.participants.roster;
|
|
66
|
+
const selectSelfId = (snapshot: SpaceSnapshot) => snapshot.self.participantId;
|
|
67
|
+
const selectConnection = (snapshot: SpaceSnapshot) => snapshot.connection.status;
|
|
68
|
+
const selectAccess = (snapshot: SpaceSnapshot) => snapshot.whiteboard.access;
|
|
69
|
+
const selectCanManage = (snapshot: SpaceSnapshot) => snapshot.self.can("manageWhiteboard");
|
|
70
|
+
const selectSceneId = (snapshot: SpaceSnapshot) => snapshot.whiteboard.engine.sceneId;
|
|
63
71
|
|
|
64
|
-
/** The shared board
|
|
72
|
+
/** The shared board uses the same renderer, tools and view protocol as web. */
|
|
65
73
|
function Board({ mini, onShowInSpotlight }: { readonly mini: boolean; readonly onShowInSpotlight: () => void }): React.JSX.Element {
|
|
66
74
|
const client = useSpaceClient();
|
|
67
75
|
const { colors, scheme } = useTheme();
|
|
76
|
+
const view = useStageView();
|
|
77
|
+
const { width, height } = useDeviceLayout();
|
|
78
|
+
const landscapeControlsVisible = useContext(LandscapeControlsContext);
|
|
79
|
+
const phone = Math.min(width, height) < 600;
|
|
68
80
|
const status = useSpaceSelector(selectBoard);
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
|
|
81
|
+
const roster = useSpaceSelector(selectRoster);
|
|
82
|
+
const selfId = useSpaceSelector(selectSelfId);
|
|
83
|
+
const connectionStatus = useSpaceSelector(selectConnection);
|
|
84
|
+
const sceneId = useSpaceSelector(selectSceneId);
|
|
85
|
+
const access = useSpaceSelector(selectAccess);
|
|
86
|
+
const canManage = useSpaceSelector(selectCanManage);
|
|
87
|
+
const toasts = useToasts();
|
|
72
88
|
const transport = useMemo(() => client.whiteboard.transport(), [client, status]);
|
|
89
|
+
const [capabilities, setCapabilities] = useState({ canDraw: false, canClear: false, viewsAvailable: false });
|
|
90
|
+
useEffect(() => transport?.subscribeSummary?.((summary) => setCapabilities({ canDraw: summary.canDraw, canClear: summary.canClear, viewsAvailable: transport.supportsViews === true })), [transport]);
|
|
91
|
+
const board = useMemo(
|
|
92
|
+
() => ({
|
|
93
|
+
primary: !mini,
|
|
94
|
+
phone,
|
|
95
|
+
landscapeControlsVisible,
|
|
96
|
+
following: view.following,
|
|
97
|
+
ownParticipantId: selfId,
|
|
98
|
+
people: roster.map((person) => ({ id: person.participantId, displayName: person.displayName })),
|
|
99
|
+
viewBounds: ChalkWhiteboardViewBounds,
|
|
100
|
+
sceneId,
|
|
101
|
+
sceneStatus: status,
|
|
102
|
+
connectionStatus,
|
|
103
|
+
viewsAvailable: capabilities.viewsAvailable,
|
|
104
|
+
...(canManage && access.policy !== null && transport?.setDrawPolicy
|
|
105
|
+
? {
|
|
106
|
+
whoCanDraw: {
|
|
107
|
+
policy: access.policy,
|
|
108
|
+
people: roster
|
|
109
|
+
.filter((person) => person.participantId !== selfId && !person.capabilities.includes("manageWhiteboard"))
|
|
110
|
+
.map((person) => ({ id: person.participantId, name: person.displayName, picked: access.permissions.some((permission) => permission.participantId === person.participantId && permission.canDraw) })),
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
: {}),
|
|
114
|
+
colors,
|
|
115
|
+
}),
|
|
116
|
+
[mini, phone, landscapeControlsVisible, view.following, selfId, roster, sceneId, status, connectionStatus, capabilities.viewsAvailable, canManage, access, transport, colors],
|
|
117
|
+
);
|
|
118
|
+
const onClose = useCallback(() => {
|
|
119
|
+
void (async () => {
|
|
120
|
+
if (canManage) await transport?.setPresentation?.(false);
|
|
121
|
+
view.setBoardOpen(false);
|
|
122
|
+
view.setFollowing(null);
|
|
123
|
+
})().catch(() => toasts.show({ kind: "error", dismissible: true, message: "Couldn’t close the board. Try again." }));
|
|
124
|
+
}, [canManage, transport, toasts, view.setBoardOpen, view.setFollowing]);
|
|
73
125
|
const journeyId = useRef(`board-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`).current;
|
|
74
126
|
|
|
75
127
|
return (
|
|
76
128
|
<>
|
|
77
|
-
{transport ?
|
|
129
|
+
{transport ? (
|
|
130
|
+
<EmbeddedWhiteboard board={board} canClear={capabilities.canClear} canDraw={!mini && capabilities.canDraw} journeyId={journeyId} onCloseBoard={onClose} onFollow={view.setFollowing} style={StyleSheet.absoluteFill} testID="tile.content.board" theme={scheme} transport={transport} />
|
|
131
|
+
) : null}
|
|
78
132
|
{mini ? (
|
|
79
133
|
<>
|
|
80
134
|
<Pressable accessibilityLabel="Show the board in spotlight" accessibilityRole="button" onPress={onShowInSpotlight} style={StyleSheet.absoluteFill} testID="tile.content.open" />
|
|
81
135
|
<NameTag icon="pen" name="Board" testID="tile.content.name" />
|
|
82
136
|
</>
|
|
83
|
-
) :
|
|
84
|
-
<View accessibilityLiveRegion="polite" accessibilityRole="text" accessible ref={chipRef} style={chipStyle} testID="tile.content.view-only">
|
|
85
|
-
<Icon color={colors.textMuted} name="lock" size={16} />
|
|
86
|
-
<ChalkText tone="textSoft" variant="section">
|
|
87
|
-
View only
|
|
88
|
-
</ChalkText>
|
|
89
|
-
</View>
|
|
90
|
-
)}
|
|
137
|
+
) : null}
|
|
91
138
|
</>
|
|
92
139
|
);
|
|
93
140
|
}
|
|
@@ -95,6 +142,4 @@ function Board({ mini, onShowInSpotlight }: { readonly mini: boolean; readonly o
|
|
|
95
142
|
const styles = StyleSheet.create({
|
|
96
143
|
tile: { flex: 1, overflow: "hidden", borderRadius: radii.tilePhone },
|
|
97
144
|
box: { position: "absolute", overflow: "hidden" },
|
|
98
|
-
// `.vchip`: a 30 pt pill 12 pt in from the board's top-left corner, with a one-pixel ring.
|
|
99
|
-
viewOnly: { position: "absolute", left: 12, top: 12, height: 30, paddingHorizontal: 12, borderRadius: radii.round, borderWidth: 1, flexDirection: "row", alignItems: "center", gap: 6, zIndex: 3 },
|
|
100
145
|
});
|
|
@@ -24,6 +24,8 @@ export type ParticipantTileProps = {
|
|
|
24
24
|
readonly height: number;
|
|
25
25
|
/** The small tile floating over the other person when two are in the call. */
|
|
26
26
|
readonly float: boolean;
|
|
27
|
+
readonly following?: boolean;
|
|
28
|
+
readonly onOptions?: (id: string) => void;
|
|
27
29
|
readonly pinned: boolean;
|
|
28
30
|
/** Your own front-camera video is mirrored while this is on. */
|
|
29
31
|
readonly mirror: boolean;
|
|
@@ -66,7 +68,7 @@ function selectTile(id: string) {
|
|
|
66
68
|
* and the speaking ring. It subscribes to that person alone, so nobody else's change re-renders
|
|
67
69
|
* it, and the stage keeps it mounted across every move so the video view keeps its surface.
|
|
68
70
|
*/
|
|
69
|
-
export const ParticipantTile = memo(function ParticipantTile({ id, width, height, float, pinned, mirror, onOpen }: ParticipantTileProps): React.JSX.Element {
|
|
71
|
+
export const ParticipantTile = memo(function ParticipantTile({ id, width, height, float, pinned, following = false, mirror, onOpen, onOptions }: ParticipantTileProps): React.JSX.Element {
|
|
70
72
|
const select = useMemo(() => selectTile(id), [id]);
|
|
71
73
|
const tile = useSpaceSelector(select, shallowEqual);
|
|
72
74
|
const slot = useDevScenario().videoSlots?.[id];
|
|
@@ -85,14 +87,14 @@ export const ParticipantTile = memo(function ParticipantTile({ id, width, height
|
|
|
85
87
|
const label = `Options for ${tile.name}${tile.isSelf ? " (you)" : ""}${pinned ? ", pinned" : ""}${state}`;
|
|
86
88
|
|
|
87
89
|
return (
|
|
88
|
-
<TileShell cameraOn={video !== null} level={level} speaking={tile.speaking} testID={`tile.${id}`}>
|
|
90
|
+
<TileShell selected={following} cameraOn={video !== null} level={level} speaking={tile.speaking} testID={`tile.${id}`}>
|
|
89
91
|
{video ?? (
|
|
90
92
|
<View style={styles.center}>
|
|
91
93
|
<FaceAvatar level={level} lookAround name={tile.name} size={tileAvatarSize(width, height - nameTagZone)} style="bold" />
|
|
92
94
|
</View>
|
|
93
95
|
)}
|
|
94
|
-
<Pressable accessibilityHint="Opens a sheet" accessibilityLabel={label} accessibilityRole="button" onPress={() => onOpen(id)} ref={hitRef} style={StyleSheet.absoluteFill} testID={`tile.${id}.open`} />
|
|
95
|
-
<NameTag handRaised={tile.handRaised} muted={tile.muted} name={name} pinned={pinned} testID={`tile.${id}.name`} you={you} />
|
|
96
|
+
<Pressable accessibilityHint="Opens a sheet" accessibilityLabel={label} accessibilityRole="button" onPress={() => onOpen(id)} onLongPress={() => onOptions?.(id)} ref={hitRef} style={StyleSheet.absoluteFill} testID={`tile.${id}.open`} />
|
|
97
|
+
<NameTag following={following} handRaised={tile.handRaised} muted={tile.muted} name={name} pinned={pinned} testID={`tile.${id}.name`} you={you} />
|
|
96
98
|
{tile.isSelf && video !== null ? <FlipCameraButton back={tile.back} float={float} testID={`tile.${id}.flip`} /> : null}
|
|
97
99
|
</TileShell>
|
|
98
100
|
);
|
package/src/stage/Stage.tsx
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { SpaceSnapshot } from "@q9labsai/chalk-client";
|
|
2
|
-
import { memo, useCallback, useMemo, useState } from "react";
|
|
2
|
+
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { Pressable, StyleSheet, View } from "react-native";
|
|
4
4
|
import Animated from "react-native-reanimated";
|
|
5
|
+
import { useSpaceClient } from "../context/space-client-context";
|
|
6
|
+
import { useToasts } from "../primitives/toast/ToastStack";
|
|
7
|
+
import { ownNativeBoardSession } from "../whiteboard/native-board-session";
|
|
5
8
|
import { useDevScenario } from "../frame/dev-scenario";
|
|
6
9
|
import { useDeviceLayout } from "../frame/device-layout";
|
|
7
10
|
import { useMeasuredRef } from "../frame/measurement";
|
|
@@ -44,7 +47,7 @@ function selectStructure(snapshot: SpaceSnapshot): Structure {
|
|
|
44
47
|
present: (hasSelf && selfId !== null ? [selfId, ...others] : others).join("\n"),
|
|
45
48
|
speaker: roster.find((person) => person.presence.activeSpeaker)?.participantId ?? null,
|
|
46
49
|
sharer: roster.find((person) => person.participantId !== selfId && person.media.screenShare === "active")?.participantId ?? null,
|
|
47
|
-
board: snapshot.whiteboard.
|
|
50
|
+
board: snapshot.whiteboard.engine.presenting,
|
|
48
51
|
};
|
|
49
52
|
}
|
|
50
53
|
|
|
@@ -54,18 +57,33 @@ function selectStructure(snapshot: SpaceSnapshot): Structure {
|
|
|
54
57
|
* remounted by a move, a layout switch or a sheet; only tiles on the stage subscribe to video.
|
|
55
58
|
*/
|
|
56
59
|
export const Stage = memo(function Stage({ frame, onOpenPeople }: StageProps): React.JSX.Element {
|
|
60
|
+
const client = useSpaceClient();
|
|
61
|
+
const toasts = useToasts();
|
|
62
|
+
useEffect(() => ownNativeBoardSession(client, () => toasts.show({ kind: "error", dismissible: true, message: "Couldn’t open the board. Try again from More." })), [client, toasts]);
|
|
57
63
|
const structure = useSpaceSelector(selectStructure, shallowEqual);
|
|
64
|
+
const selectViewsAvailable = useCallback(() => client.whiteboard.transport()?.supportsViews === true, [client]);
|
|
65
|
+
const viewsAvailable = useSpaceSelector(selectViewsAvailable);
|
|
58
66
|
const view = useStageView();
|
|
59
67
|
const { arrangement } = useDeviceLayout();
|
|
60
68
|
const { shareAspect } = useDevScenario();
|
|
61
69
|
const [sheet, setSheet] = useState<{ readonly id: string | null; readonly open: boolean }>({ id: null, open: false });
|
|
62
70
|
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (view.following && !structure.present.split("\n").includes(view.following)) view.setFollowing(null);
|
|
73
|
+
}, [structure.present, view.following, view.setFollowing]);
|
|
74
|
+
|
|
63
75
|
const present = useMemo(() => (structure.present ? structure.present.split("\n") : []), [structure.present]);
|
|
64
76
|
const [order, setOrder] = useState<StageOrder>({ order: [], spot: null });
|
|
65
77
|
const next = nextStageOrder(order, present, structure.selfId, structure.speaker);
|
|
66
78
|
if (next !== order) setOrder(next);
|
|
67
79
|
|
|
68
|
-
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
view.setBoardOpen(null);
|
|
82
|
+
view.setFollowing(null);
|
|
83
|
+
}, [structure.board, view.setBoardOpen, view.setFollowing]);
|
|
84
|
+
|
|
85
|
+
const boardVisible = view.boardOpen ?? structure.board;
|
|
86
|
+
const content = useMemo<SharedContent | null>(() => (boardVisible ? { kind: "board" } : structure.sharer !== null ? { kind: "screen", sharerId: structure.sharer } : null), [boardVisible, structure.sharer]);
|
|
69
87
|
const pinned = view.pinned !== null && present.includes(view.pinned) ? view.pinned : null;
|
|
70
88
|
const items = useMemo(
|
|
71
89
|
() =>
|
|
@@ -82,6 +100,17 @@ export const Stage = memo(function Stage({ frame, onOpenPeople }: StageProps): R
|
|
|
82
100
|
[arrangement, content, frame, next, pinned, shareAspect, view.layout],
|
|
83
101
|
);
|
|
84
102
|
|
|
103
|
+
const followTile = useCallback(
|
|
104
|
+
(id: string) => {
|
|
105
|
+
if (!boardVisible || !viewsAvailable || id === structure.selfId) {
|
|
106
|
+
setSheet({ id, open: true });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
view.showContent();
|
|
110
|
+
view.setFollowing(view.following === id ? null : id);
|
|
111
|
+
},
|
|
112
|
+
[boardVisible, viewsAvailable, structure.selfId, view],
|
|
113
|
+
);
|
|
85
114
|
const openTile = useCallback((id: string) => setSheet({ id, open: true }), []);
|
|
86
115
|
const closeTile = useCallback(() => setSheet((current) => ({ ...current, open: false })), []);
|
|
87
116
|
// Like the mockup's `.stage`, the stage is a full-screen layer: tiles are placed in screen points
|
|
@@ -91,7 +120,7 @@ export const Stage = memo(function Stage({ frame, onOpenPeople }: StageProps): R
|
|
|
91
120
|
return (
|
|
92
121
|
<View pointerEvents="box-none" ref={stageRef} style={styles.layer} testID="frame.stage">
|
|
93
122
|
{items.map((item) => (
|
|
94
|
-
<StageTile content={content} item={item} key={itemKey(item)} mirror={view.mirror} onOpenPeople={onOpenPeople} onOpenTile={openTile} onShowContent={view.showContent} peopleCount={present.length} pinned={pinned} />
|
|
123
|
+
<StageTile content={content} item={item} key={itemKey(item)} mirror={view.mirror} onOpenPeople={onOpenPeople} onOpenTile={followTile} onTileOptions={openTile} following={view.following} onShowContent={view.showContent} peopleCount={present.length} pinned={pinned} />
|
|
95
124
|
))}
|
|
96
125
|
<TileSheet onClose={closeTile} open={sheet.open} participantId={sheet.id} />
|
|
97
126
|
</View>
|
|
@@ -108,19 +137,21 @@ type StageTileProps = {
|
|
|
108
137
|
readonly pinned: string | null;
|
|
109
138
|
readonly mirror: boolean;
|
|
110
139
|
readonly peopleCount: number;
|
|
140
|
+
readonly following: string | null;
|
|
141
|
+
readonly onTileOptions: (participantId: string) => void;
|
|
111
142
|
readonly onOpenTile: (participantId: string) => void;
|
|
112
143
|
readonly onOpenPeople: () => void;
|
|
113
144
|
readonly onShowContent: () => void;
|
|
114
145
|
};
|
|
115
146
|
|
|
116
|
-
function StageTile({ item, content, pinned, mirror, peopleCount, onOpenTile, onOpenPeople, onShowContent }: StageTileProps): React.JSX.Element | null {
|
|
147
|
+
function StageTile({ item, content, pinned, mirror, peopleCount, onOpenTile, onOpenPeople, onShowContent, following, onTileOptions }: StageTileProps): React.JSX.Element | null {
|
|
117
148
|
const { scheme } = useTheme();
|
|
118
149
|
const float = item.kind === "person" && item.float;
|
|
119
150
|
return (
|
|
120
151
|
<MovingFrame frame={item.frame} style={float ? [styles.float, { boxShadow: elevations[scheme].floatTile }] : null}>
|
|
121
152
|
<Animated.View entering={tileEntering} exiting={tileLeaving} style={styles.fill}>
|
|
122
153
|
{item.kind === "person" ? (
|
|
123
|
-
<ParticipantTile float={float} height={item.frame.height} id={item.id} mirror={mirror} onOpen={onOpenTile} pinned={pinned === item.id} width={item.frame.width} />
|
|
154
|
+
<ParticipantTile float={float} height={item.frame.height} id={item.id} mirror={mirror} onOpen={onOpenTile} onOptions={onTileOptions} following={following === item.id} pinned={pinned === item.id} width={item.frame.width} />
|
|
124
155
|
) : item.kind === "content" && content ? (
|
|
125
156
|
<ContentTile content={content} inner={item.inner} mini={item.mini} onShowInSpotlight={onShowContent} />
|
|
126
157
|
) : item.kind === "more" ? (
|
package/src/stage/TileSheet.tsx
CHANGED
|
@@ -21,6 +21,7 @@ export type TileSheetProps = {
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
type SheetModel = {
|
|
24
|
+
readonly boardOpen: boolean;
|
|
24
25
|
readonly name: string;
|
|
25
26
|
readonly isSelf: boolean;
|
|
26
27
|
readonly cameraOn: boolean;
|
|
@@ -31,11 +32,11 @@ type SheetModel = {
|
|
|
31
32
|
readonly canRemove: boolean;
|
|
32
33
|
};
|
|
33
34
|
|
|
34
|
-
const closed: SheetModel = { name: "", isSelf: false, cameraOn: false, back: false, otherCamera: null, muted: false, canMute: false, canRemove: false };
|
|
35
|
+
const closed: SheetModel = { boardOpen: false, name: "", isSelf: false, cameraOn: false, back: false, otherCamera: null, muted: false, canMute: false, canRemove: false };
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
38
|
* The phone's tile sheet (`#tsheet` with `tileSheetHTML`): pin, your camera and mirror, and the
|
|
38
|
-
* host's mute and remove for someone else.
|
|
39
|
+
* host's mute and remove for someone else. Follow is available while the board is open.
|
|
39
40
|
*/
|
|
40
41
|
export function TileSheet({ participantId, open, onClose }: TileSheetProps): React.JSX.Element {
|
|
41
42
|
const client = useSpaceClient();
|
|
@@ -48,6 +49,7 @@ export function TileSheet({ participantId, open, onClose }: TileSheetProps): Rea
|
|
|
48
49
|
const person = snapshot.participants.roster.find((participant) => participant.participantId === participantId);
|
|
49
50
|
const isSelf = snapshot.self.participantId === participantId;
|
|
50
51
|
return {
|
|
52
|
+
boardOpen: snapshot.whiteboard.engine.presenting,
|
|
51
53
|
name: person?.displayName ?? snapshot.self.displayName ?? "",
|
|
52
54
|
isSelf,
|
|
53
55
|
cameraOn: isSelf && snapshot.media.local.camera.state === "enabled",
|
|
@@ -61,6 +63,8 @@ export function TileSheet({ participantId, open, onClose }: TileSheetProps): Rea
|
|
|
61
63
|
[participantId],
|
|
62
64
|
);
|
|
63
65
|
const person = useSpaceSelector(select, shallowEqual);
|
|
66
|
+
const selectViewsAvailable = useCallback(() => client.whiteboard.transport()?.supportsViews === true, [client]);
|
|
67
|
+
const viewsAvailable = useSpaceSelector(selectViewsAvailable);
|
|
64
68
|
const id = participantId ?? "";
|
|
65
69
|
const failed = useCallback((message: string) => () => toasts.show({ kind: "error", dismissible: true, message }), [toasts]);
|
|
66
70
|
const run = (action: () => void) => () => {
|
|
@@ -77,6 +81,17 @@ export function TileSheet({ participantId, open, onClose }: TileSheetProps): Rea
|
|
|
77
81
|
</ChalkText>
|
|
78
82
|
</View>
|
|
79
83
|
<SheetGroup>
|
|
84
|
+
{(view.boardOpen ?? person.boardOpen) && viewsAvailable && !person.isSelf ? (
|
|
85
|
+
<SheetRow
|
|
86
|
+
icon="pin"
|
|
87
|
+
label={view.following === participantId ? "Stop following" : "Follow"}
|
|
88
|
+
onPress={run(() => {
|
|
89
|
+
view.showContent();
|
|
90
|
+
view.setFollowing(view.following === participantId ? null : id);
|
|
91
|
+
})}
|
|
92
|
+
testID="tile-sheet.follow"
|
|
93
|
+
/>
|
|
94
|
+
) : null}
|
|
80
95
|
<SheetRow icon="pin" label={view.pinned === participantId ? "Unpin" : "Pin"} onPress={run(() => view.togglePin(id))} testID="tile-sheet.pin" />
|
|
81
96
|
{person.cameraOn && person.otherCamera !== null ? (
|
|
82
97
|
<SheetRow
|
package/src/stage/stage-view.tsx
CHANGED
|
@@ -6,6 +6,10 @@ export type StageLayoutChoice = "spotlight" | "grid";
|
|
|
6
6
|
export type StageView = {
|
|
7
7
|
readonly layout: StageLayoutChoice;
|
|
8
8
|
readonly pinned: string | null;
|
|
9
|
+
readonly boardOpen: boolean | null;
|
|
10
|
+
readonly setBoardOpen: (open: boolean | null) => void;
|
|
11
|
+
readonly following: string | null;
|
|
12
|
+
readonly setFollowing: (participantId: string | null) => void;
|
|
9
13
|
/** Whether your own front-camera video is mirrored, as in a mirror. On by default. */
|
|
10
14
|
readonly mirror: boolean;
|
|
11
15
|
readonly setLayout: (layout: StageLayoutChoice) => void;
|
|
@@ -25,6 +29,8 @@ const StageViewContext = createContext<StageView | null>(null);
|
|
|
25
29
|
export function StageViewProvider({ children }: { readonly children: ReactNode }): React.JSX.Element {
|
|
26
30
|
const [layout, setLayout] = useState<StageLayoutChoice>("spotlight");
|
|
27
31
|
const [pinned, setPinned] = useState<string | null>(null);
|
|
32
|
+
const [boardOpen, setBoardOpen] = useState<boolean | null>(null);
|
|
33
|
+
const [following, setFollowing] = useState<string | null>(null);
|
|
28
34
|
const [mirror, setMirror] = useState(true);
|
|
29
35
|
|
|
30
36
|
const dev = useDevScenario();
|
|
@@ -37,6 +43,10 @@ export function StageViewProvider({ children }: { readonly children: ReactNode }
|
|
|
37
43
|
() => ({
|
|
38
44
|
layout,
|
|
39
45
|
pinned,
|
|
46
|
+
boardOpen,
|
|
47
|
+
setBoardOpen,
|
|
48
|
+
following,
|
|
49
|
+
setFollowing,
|
|
40
50
|
mirror,
|
|
41
51
|
setLayout: (next) => {
|
|
42
52
|
setLayout(next);
|
|
@@ -56,7 +66,7 @@ export function StageViewProvider({ children }: { readonly children: ReactNode }
|
|
|
56
66
|
},
|
|
57
67
|
setMirror,
|
|
58
68
|
}),
|
|
59
|
-
[layout, mirror, pinned],
|
|
69
|
+
[layout, mirror, pinned, following, boardOpen],
|
|
60
70
|
);
|
|
61
71
|
return <StageViewContext.Provider value={value}>{children}</StageViewContext.Provider>;
|
|
62
72
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { setImageAsync } from "expo-clipboard";
|
|
2
|
+
import { File, Paths } from "expo-file-system";
|
|
3
|
+
import { isAvailableAsync, shareAsync } from "expo-sharing";
|
|
4
|
+
|
|
5
|
+
/** Native exports stay on the device and use the system’s save/share destinations. */
|
|
6
|
+
export async function exportBoardImage(value: { readonly format: "png" | "svg"; readonly dataURL: string; readonly action?: "download" | "copy" }): Promise<void> {
|
|
7
|
+
if (value.format !== "png" || !value.dataURL.startsWith("data:image/png;base64,")) throw new Error("Board export is not a PNG image");
|
|
8
|
+
const base64 = value.dataURL.slice("data:image/png;base64,".length);
|
|
9
|
+
if (value.action === "copy") {
|
|
10
|
+
await setImageAsync(base64);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (!(await isAvailableAsync())) throw new Error("Image sharing is unavailable on this device");
|
|
14
|
+
const file = new File(Paths.cache, `chalk-board-${Date.now()}.png`);
|
|
15
|
+
try {
|
|
16
|
+
file.write(base64, { encoding: "base64" });
|
|
17
|
+
await shareAsync(file.uri, { mimeType: "image/png", UTI: "public.png", dialogTitle: "Save or share the board", anchor: { x: 16, y: 16, width: 1, height: 1 } });
|
|
18
|
+
} finally {
|
|
19
|
+
if (file.exists) file.delete();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ChalkWhiteboardV1Transport } from "@q9labsai/chalk-client";
|
|
2
|
+
|
|
3
|
+
type SessionClient = {
|
|
4
|
+
readonly whiteboard: { readonly transport: () => Pick<ChalkWhiteboardV1Transport, "startSceneSubscription" | "stopSceneSubscription"> | null };
|
|
5
|
+
readonly getSnapshot: () => { readonly connection: { readonly status: string } };
|
|
6
|
+
readonly subscribe: (listener: () => void) => () => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Subscribe before the tile exists, so a web presentation can open the native board. */
|
|
10
|
+
export function ownNativeBoardSession(client: SessionClient, onError: () => void): () => void {
|
|
11
|
+
const transport = client.whiteboard.transport();
|
|
12
|
+
if (!transport) return () => {};
|
|
13
|
+
let started = false;
|
|
14
|
+
let active = true;
|
|
15
|
+
const observe = () => {
|
|
16
|
+
if (started || client.getSnapshot().connection.status !== "live") return;
|
|
17
|
+
started = true;
|
|
18
|
+
void transport.startSceneSubscription().catch(() => {
|
|
19
|
+
if (active) onError();
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
const unsubscribe = client.subscribe(observe);
|
|
23
|
+
observe();
|
|
24
|
+
return () => {
|
|
25
|
+
active = false;
|
|
26
|
+
unsubscribe();
|
|
27
|
+
if (started) void transport.stopSceneSubscription();
|
|
28
|
+
};
|
|
29
|
+
}
|