P14c: targeted private share (handout/note/image) from the roster

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 17:19:36 +02:00
parent e562a270d1
commit 4e13a5bdf4
3 changed files with 58 additions and 2 deletions
+7
View File
@@ -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();
});
+40 -2
View File
@@ -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<RosterEntry | null>(null);
const [shareTitle, setShareTitle] = useState('');
const [shareBody, setShareBody] = useState('');
const [shareImage, setShareImage] = useState<string | undefined>(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: () =
<li key={p.playerId} className={cn('flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs', p.playerId === 'gm' ? 'border-accent/50 text-accent' : 'border-line text-ink')}>
<span className="h-1.5 w-1.5 rounded-full bg-success" aria-hidden />
{p.name}
{isGm && p.playerId !== 'gm' && (
<button onClick={() => openShare(p)} title={`Share privately with ${p.name}`} aria-label={`Share with ${p.name}`} className="ml-0.5 text-muted hover:text-info">📨</button>
)}
</li>
))}
</ul>
@@ -157,6 +176,25 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
</div>
)}
</aside>
{shareTarget && (
<Modal open onClose={() => setShareTarget(null)} title={`Share privately with ${shareTarget.name}`}
footer={<>
<Button variant="ghost" onClick={() => setShareTarget(null)}>Cancel</Button>
<Button variant="primary" onClick={sendShare}>Send to {shareTarget.name}</Button>
</>}>
<div className="space-y-3">
<Input value={shareTitle} onChange={(e) => setShareTitle(e.target.value)} placeholder="Title (e.g. A scrawled map)" aria-label="Share title" />
<Textarea value={shareBody} onChange={(e) => setShareBody(e.target.value)} placeholder="Text only this player sees…" rows={4} aria-label="Share text" />
<div className="flex items-center gap-2">
{shareImage && <img src={shareImage} alt="" className="h-16 w-16 rounded border border-line object-cover" />}
<label className="cursor-pointer rounded-md border border-line px-2 py-1 text-xs text-ink hover:border-accent">Image<input type="file" accept="image/*" className="hidden" aria-label="Share image" onChange={(e) => { const f = e.target.files?.[0]; if (f) void onShareImage(f); e.target.value = ''; }} /></label>
{shareImage && <Button size="sm" variant="ghost" onClick={() => setShareImage(undefined)}>Remove</Button>}
</div>
<p className="text-xs text-muted">Only <span className="text-ink">{shareTarget.name}</span> receives this a secret map, note, or image (e.g. for a scout who went ahead).</p>
</div>
</Modal>
)}
</>
);
}
+11
View File
@@ -1,5 +1,6 @@
import type { Snapshot } from '@/lib/sync/messages';
import type { PlayerMap } from '@/lib/map';
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
import { EmptyState } from '@/components/ui/Page';
import { cn } from '@/lib/cn';
import { PlayerMapView } from './PlayerMapView';
@@ -8,8 +9,18 @@ import { PlayerMapView } from './PlayerMapView';
export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot; image?: string; images?: Record<string, string> }) {
const { party, encounter, map, handout } = snapshot;
const handoutImage = handout?.imageId ? images?.[handout.imageId] : undefined;
const privateHandout = usePlayerSessionStore((s) => s.privateHandout);
return (
<>
{privateHandout && (
<section className="mb-6 rounded-xl border border-info/60 bg-info/5 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-info">📨 Just for you</div>
<h2 className="font-display text-2xl font-bold text-ink">{privateHandout.title || 'A private note'}</h2>
{privateHandout.body && <p className="mt-1 whitespace-pre-wrap text-ink">{privateHandout.body}</p>}
{privateHandout.image && <img src={privateHandout.image} alt="" className="mt-3 max-h-[60vh] w-auto rounded-lg border border-line" />}
</section>
)}
{handout && (
<section className="mb-6 rounded-xl border border-accent/50 bg-accent/5 p-4">
<h2 className="font-display text-2xl font-bold text-accent">{handout.title || 'Handout'}</h2>