diff --git a/e2e-realtime/realtime.spec.ts b/e2e-realtime/realtime.spec.ts index 94656c5..b251d43 100644 --- a/e2e-realtime/realtime.spec.ts +++ b/e2e-realtime/realtime.spec.ts @@ -66,6 +66,13 @@ test('GM hosts a session; a player on another device sees live combat', async ({ await player.getByRole('button', { name: 'Send' }).click(); await expect(gm.getByText('Hello GM')).toBeVisible(); + // Targeted share: the GM sends a private note to the player; only they see it. + await gm.getByRole('button', { name: /Share with/ }).click(); + await gm.getByLabel('Share title').fill('Secret passage'); + await gm.getByRole('button', { name: /Send to/ }).click(); + await expect(player.getByText('📨 Just for you')).toBeVisible(); + await expect(player.getByText('Secret passage')).toBeVisible(); + await gmCtx.close(); await playerCtx.close(); }); diff --git a/src/features/play/SessionSidebar.tsx b/src/features/play/SessionSidebar.tsx index 6519812..e70563a 100644 --- a/src/features/play/SessionSidebar.tsx +++ b/src/features/play/SessionSidebar.tsx @@ -3,11 +3,14 @@ import { useLiveQuery } from 'dexie-react-hooks'; import { useSessionStore } from '@/stores/sessionStore'; import { usePlayerSessionStore } from '@/stores/playerSessionStore'; import { useUiStore } from '@/stores/uiStore'; -import { sendChat } from '@/lib/sync/wsSync'; +import { sendChat, sendPrivateHandout } from '@/lib/sync/wsSync'; import { sessionLogRepo } from '@/lib/db/repositories'; import type { SessionLogEntry } from '@/lib/schemas'; -import { Input } from '@/components/ui/Input'; +import type { RosterEntry } from '@/lib/sync/messages'; +import { fileToDataUrl, resizeToMax } from '@/lib/img/resize'; +import { Input, Textarea } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; +import { Modal } from '@/components/ui/Modal'; import { cn } from '@/lib/cn'; /** @@ -48,6 +51,19 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () = setDraft(''); }; + // GM: targeted private share (handout/note/image) to one player + const [shareTarget, setShareTarget] = useState(null); + const [shareTitle, setShareTitle] = useState(''); + const [shareBody, setShareBody] = useState(''); + const [shareImage, setShareImage] = useState(undefined); + const openShare = (p: RosterEntry) => { setShareTarget(p); setShareTitle(''); setShareBody(''); setShareImage(undefined); }; + const onShareImage = async (file: File) => setShareImage(await resizeToMax(await fileToDataUrl(file), 1280)); + const sendShare = () => { + if (!shareTarget) return; + sendPrivateHandout(shareTarget.playerId, { title: shareTitle.trim() || 'A note', body: shareBody.trim(), ...(shareImage ? { image: shareImage } : {}) }); + setShareTarget(null); + }; + if (!open) return null; return ( @@ -81,6 +97,9 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
  • {p.name} + {isGm && p.playerId !== 'gm' && ( + + )}
  • ))} @@ -157,6 +176,25 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () = )} + + {shareTarget && ( + setShareTarget(null)} title={`Share privately with ${shareTarget.name}`} + footer={<> + + + }> +
    + setShareTitle(e.target.value)} placeholder="Title (e.g. A scrawled map)" aria-label="Share title" /> +