From 4e13a5bdf4656b1425b5a3394c729824cc52d6b9 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 17:19:36 +0200 Subject: [PATCH] P14c: targeted private share (handout/note/image) from the roster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GM clicks a player's 📨 in the session roster → composer (title/body/image via resizeToMax) → sendPrivateHandout to that player only (server-routed; non- recipients never receive it). Covers "share a secret map/note to the scout". - The recipient sees a prominent "📨 Just for you" card on their player view (PlayerBoards renders playerSessionStore.privateHandout, image inline). - Realtime e2e: GM shares a private note; only the targeted player sees it. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e-realtime/realtime.spec.ts | 7 +++++ src/features/play/SessionSidebar.tsx | 42 ++++++++++++++++++++++++++-- src/features/player/PlayerBoards.tsx | 11 ++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) 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" /> +