@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.
Files changed (43) hide show
  1. package/dist/{chunk-4L5GXJ36.js → chunk-BXDD5BE4.js} +23 -1
  2. package/dist/{chunk-MBZRE4US.js → chunk-PPQSOU35.js} +5 -5
  3. package/dist/components/EmbeddedWhiteboard.d.ts +8 -4
  4. package/dist/frame/call-controls.d.ts +2 -0
  5. package/dist/frame/use-landscape-chrome.d.ts +8 -0
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +358 -97
  8. package/dist/invites.d.ts +5 -0
  9. package/dist/invites.js +3 -1
  10. package/dist/primitives/IconButton.d.ts +1 -0
  11. package/dist/primitives/NameTag.d.ts +2 -1
  12. package/dist/primitives/TileShell.d.ts +2 -1
  13. package/dist/stage/ParticipantTile.d.ts +3 -1
  14. package/dist/stage/TileSheet.d.ts +1 -1
  15. package/dist/stage/stage-view.d.ts +4 -0
  16. package/dist/ui.js +1 -1
  17. package/dist/whiteboard/export-board-image.d.ts +6 -0
  18. package/dist/whiteboard/native-board-session.d.ts +15 -0
  19. package/embedded/chalk-whiteboard/Figtree-Regular.ttf +0 -0
  20. package/embedded/chalk-whiteboard/Figtree-SemiBold.ttf +0 -0
  21. package/embedded/chalk-whiteboard/asset-manifest.json +19 -4
  22. package/embedded/chalk-whiteboard/chalk.css +44 -0
  23. package/embedded/chalk-whiteboard/licenses/Figtree.txt +93 -0
  24. package/embedded/chalk-whiteboard/renderer.js +1669 -1418
  25. package/package.json +11 -6
  26. package/src/components/EmbeddedWhiteboard.tsx +90 -6
  27. package/src/dock/Dock.tsx +14 -3
  28. package/src/dock/MoreSheet.tsx +23 -0
  29. package/src/frame/CallFrame.tsx +20 -33
  30. package/src/frame/call-controls.ts +4 -0
  31. package/src/frame/use-landscape-chrome.ts +22 -0
  32. package/src/index.ts +2 -2
  33. package/src/invites.ts +32 -1
  34. package/src/primitives/IconButton.tsx +6 -5
  35. package/src/primitives/NameTag.tsx +7 -1
  36. package/src/primitives/TileShell.tsx +3 -1
  37. package/src/stage/ContentTile.tsx +64 -19
  38. package/src/stage/ParticipantTile.tsx +6 -4
  39. package/src/stage/Stage.tsx +37 -6
  40. package/src/stage/TileSheet.tsx +17 -2
  41. package/src/stage/stage-view.tsx +11 -1
  42. package/src/whiteboard/export-board-image.ts +21 -0
  43. package/src/whiteboard/native-board-session.ts +29 -0
@@ -1,18 +1,20 @@
1
- import type { SpaceSnapshot } from "@q9labsai/chalk-client";
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 { ChalkText } from "../primitives/ChalkText";
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, view-only today, through the embedded whiteboard. It loads only while a board is shared. */
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 chipStyle = [styles.viewOnly, { backgroundColor: colors.surface, borderColor: colors.lineStrong }];
70
- const chipRef = useMeasuredRef<View>("tile.content.view-only", chipStyle);
71
- // The transport changes when the engine resubscribes.
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 ? <EmbeddedWhiteboard canClear={false} canDraw={false} journeyId={journeyId} style={StyleSheet.absoluteFill} testID="tile.content.board" theme={scheme} transport={transport} /> : null}
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
  );
@@ -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.open,
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
- const content = useMemo<SharedContent | null>(() => (structure.board ? { kind: "board" } : structure.sharer !== null ? { kind: "screen", sharerId: structure.sharer } : null), [structure.board, structure.sharer]);
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" ? (
@@ -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. Board following waits for board editing.
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
@@ -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
+ }