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:
@@ -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 player.getByRole('button', { name: 'Send' }).click();
|
||||||
await expect(gm.getByText('Hello GM')).toBeVisible();
|
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 gmCtx.close();
|
||||||
await playerCtx.close();
|
await playerCtx.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import { useLiveQuery } from 'dexie-react-hooks';
|
|||||||
import { useSessionStore } from '@/stores/sessionStore';
|
import { useSessionStore } from '@/stores/sessionStore';
|
||||||
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
||||||
import { useUiStore } from '@/stores/uiStore';
|
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 { sessionLogRepo } from '@/lib/db/repositories';
|
||||||
import type { SessionLogEntry } from '@/lib/schemas';
|
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 { Button } from '@/components/ui/Button';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +51,19 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
|
|||||||
setDraft('');
|
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;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
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')}>
|
<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 />
|
<span className="h-1.5 w-1.5 rounded-full bg-success" aria-hidden />
|
||||||
{p.name}
|
{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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -157,6 +176,25 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Snapshot } from '@/lib/sync/messages';
|
import type { Snapshot } from '@/lib/sync/messages';
|
||||||
import type { PlayerMap } from '@/lib/map';
|
import type { PlayerMap } from '@/lib/map';
|
||||||
|
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
||||||
import { EmptyState } from '@/components/ui/Page';
|
import { EmptyState } from '@/components/ui/Page';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import { PlayerMapView } from './PlayerMapView';
|
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> }) {
|
export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot; image?: string; images?: Record<string, string> }) {
|
||||||
const { party, encounter, map, handout } = snapshot;
|
const { party, encounter, map, handout } = snapshot;
|
||||||
const handoutImage = handout?.imageId ? images?.[handout.imageId] : undefined;
|
const handoutImage = handout?.imageId ? images?.[handout.imageId] : undefined;
|
||||||
|
const privateHandout = usePlayerSessionStore((s) => s.privateHandout);
|
||||||
return (
|
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 && (
|
{handout && (
|
||||||
<section className="mb-6 rounded-xl border border-accent/50 bg-accent/5 p-4">
|
<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>
|
<h2 className="font-display text-2xl font-bold text-accent">{handout.title || 'Handout'}</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user