feat(P5B.T6): composer Spielen button + bubble dispatch + game-picker
Wires mini-game entry points: createGamePayload helper in conversationFeatures.ts (GamePayload import added), game state/handler/picker-dialog/modal-mount/open-event-listener in ConversationPage.tsx (DM-only guard uses conversation.members[].userId camelCase as confirmed), and parsed.kind==='game' bubble branch in MessageBubble.tsx dispatching chatapp:open-game CustomEvent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -442,6 +442,32 @@ export function MessageBubble({
|
|||||||
defaultValue: 'Nachricht nicht lesbar',
|
defaultValue: 'Nachricht nicht lesbar',
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
) : parsed.kind === 'game' ? (
|
||||||
|
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
||||||
|
<rect x="3" y="6" width="18" height="12" rx="3" />
|
||||||
|
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-semibold text-fg">{parsed.gameType === 'c4' ? 'Vier-Gewinnt' : 'Tic-Tac-Toe'}</div>
|
||||||
|
<div className="text-xs text-fg-muted">Gemeinsam spielen</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('chatapp:open-game', { detail: { id: parsed.gameId } }),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={!parsed.gameId}
|
||||||
|
className="cursor-pointer rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Spielen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : parsed.kind === 'watch_together' ? (
|
) : parsed.kind === 'watch_together' ? (
|
||||||
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
|
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type PollOption,
|
type PollOption,
|
||||||
type WhiteboardPayload,
|
type WhiteboardPayload,
|
||||||
type WatchTogetherPayload,
|
type WatchTogetherPayload,
|
||||||
|
type GamePayload,
|
||||||
} from '@chat-app/shared/chat';
|
} from '@chat-app/shared/chat';
|
||||||
|
|
||||||
export type AttachmentBucket = 'media' | 'audio' | 'files';
|
export type AttachmentBucket = 'media' | 'audio' | 'files';
|
||||||
@@ -117,6 +118,16 @@ export function createWatchTogetherPayload(sessionId: string): string {
|
|||||||
return serializeMessagePayload(payload);
|
return serializeMessagePayload(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createGamePayload(gameId: string, gameType: 'ttt' | 'c4'): string {
|
||||||
|
const payload: GamePayload = {
|
||||||
|
v: 1,
|
||||||
|
type: 'game',
|
||||||
|
game_id: gameId,
|
||||||
|
game_type: gameType,
|
||||||
|
};
|
||||||
|
return serializeMessagePayload(payload);
|
||||||
|
}
|
||||||
|
|
||||||
export function summarizePollVotes(
|
export function summarizePollVotes(
|
||||||
options: PollOption[],
|
options: PollOption[],
|
||||||
reactions: ReactionSummaryInput[],
|
reactions: ReactionSummaryInput[],
|
||||||
|
|||||||
@@ -47,10 +47,12 @@ import {
|
|||||||
createPollPayload,
|
createPollPayload,
|
||||||
createWhiteboardPayload,
|
createWhiteboardPayload,
|
||||||
createWatchTogetherPayload,
|
createWatchTogetherPayload,
|
||||||
|
createGamePayload,
|
||||||
} from '../lib/conversationFeatures';
|
} from '../lib/conversationFeatures';
|
||||||
import { WhiteboardModal } from '../components/WhiteboardModal';
|
import { WhiteboardModal } from '../components/WhiteboardModal';
|
||||||
import { WatchTogetherModal } from '../components/WatchTogetherModal';
|
import { WatchTogetherModal } from '../components/WatchTogetherModal';
|
||||||
import { createWhiteboard, createWatchSession, parseYouTubeUrl } from '@chat-app/shared/chat';
|
import { GameModal } from '../components/GameModal';
|
||||||
|
import { createWhiteboard, createWatchSession, parseYouTubeUrl, createGame, type GameType } from '@chat-app/shared/chat';
|
||||||
import { compressImages } from '../lib/imageCompress';
|
import { compressImages } from '../lib/imageCompress';
|
||||||
import { ensureInstallId } from '../lib/installId';
|
import { ensureInstallId } from '../lib/installId';
|
||||||
import { searchCachedMessages } from '../lib/messageCache';
|
import { searchCachedMessages } from '../lib/messageCache';
|
||||||
@@ -176,6 +178,10 @@ export function ConversationPage() {
|
|||||||
const [watchUrl, setWatchUrl] = useState('');
|
const [watchUrl, setWatchUrl] = useState('');
|
||||||
const [watchError, setWatchError] = useState<string | null>(null);
|
const [watchError, setWatchError] = useState<string | null>(null);
|
||||||
const [watchCreating, setWatchCreating] = useState(false);
|
const [watchCreating, setWatchCreating] = useState(false);
|
||||||
|
const [openGameId, setOpenGameId] = useState<string | null>(null);
|
||||||
|
const [gameDialogOpen, setGameDialogOpen] = useState(false);
|
||||||
|
const [gameError, setGameError] = useState<string | null>(null);
|
||||||
|
const [gameCreating, setGameCreating] = useState(false);
|
||||||
const [pollError, setPollError] = useState<string | null>(null);
|
const [pollError, setPollError] = useState<string | null>(null);
|
||||||
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
|
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
|
||||||
const [forwardTarget, setForwardTarget] = useState<DecryptedMessage | null>(null);
|
const [forwardTarget, setForwardTarget] = useState<DecryptedMessage | null>(null);
|
||||||
@@ -488,6 +494,15 @@ export function ConversationPage() {
|
|||||||
return () => window.removeEventListener('chatapp:open-watch-together', onOpen);
|
return () => window.removeEventListener('chatapp:open-watch-together', onOpen);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onOpen = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent<{ id?: string }>).detail;
|
||||||
|
if (detail?.id) setOpenGameId(detail.id);
|
||||||
|
};
|
||||||
|
window.addEventListener('chatapp:open-game', onOpen);
|
||||||
|
return () => window.removeEventListener('chatapp:open-game', onOpen);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id && messages.length > 0) markRead(id);
|
if (id && messages.length > 0) markRead(id);
|
||||||
}, [id, messages.length, markRead]);
|
}, [id, messages.length, markRead]);
|
||||||
@@ -663,6 +678,38 @@ export function ConversationPage() {
|
|||||||
}
|
}
|
||||||
}, [id, watchUrl, send, replyTo?.id]);
|
}, [id, watchUrl, send, replyTo?.id]);
|
||||||
|
|
||||||
|
const handleStartGame = useCallback(async (gameType: GameType) => {
|
||||||
|
if (!id) return;
|
||||||
|
if (!conversation || conversation.members.length !== 2) {
|
||||||
|
setGameError('Spiele aktuell nur in 1:1-Chats.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const opponent = conversation.members.find((m) => m.userId !== myId);
|
||||||
|
if (!opponent) {
|
||||||
|
setGameError('Kein Gegner gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGameCreating(true);
|
||||||
|
setGameError(null);
|
||||||
|
try {
|
||||||
|
const game = await createGame(supabase, {
|
||||||
|
conversationId: id,
|
||||||
|
gameType,
|
||||||
|
opponentUserId: opponent.userId,
|
||||||
|
});
|
||||||
|
const payload = createGamePayload(game.id, gameType);
|
||||||
|
await send(payload, [], replyTo?.id ?? null);
|
||||||
|
setReplyTo(null);
|
||||||
|
setStickToBottom(true);
|
||||||
|
setGameDialogOpen(false);
|
||||||
|
setOpenGameId(game.id);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten');
|
||||||
|
} finally {
|
||||||
|
setGameCreating(false);
|
||||||
|
}
|
||||||
|
}, [id, conversation, myId, send, replyTo?.id]);
|
||||||
|
|
||||||
async function ingestFiles(files: File[]) {
|
async function ingestFiles(files: File[]) {
|
||||||
const compressed = await compressImages(files);
|
const compressed = await compressImages(files);
|
||||||
const next: File[] = [];
|
const next: File[] = [];
|
||||||
@@ -1075,6 +1122,15 @@ export function ConversationPage() {
|
|||||||
>
|
>
|
||||||
<PlayBoxIcon className="h-4 w-4" />
|
<PlayBoxIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGameDialogOpen(true)}
|
||||||
|
title={t('app:composer.game', { defaultValue: 'Spielen' })}
|
||||||
|
aria-label={t('app:composer.game', { defaultValue: 'Spielen' })}
|
||||||
|
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-3 hover:text-fg"
|
||||||
|
>
|
||||||
|
<GameIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1289,6 +1345,62 @@ export function ConversationPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{openGameId && (
|
||||||
|
<GameModal
|
||||||
|
gameId={openGameId}
|
||||||
|
onClose={() => setOpenGameId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gameDialogOpen && (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
className="fixed inset-0 z-[70] flex items-center justify-center bg-ink-950/90 p-6"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setGameDialogOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-sm rounded-2xl border border-line bg-surface-2 p-5 shadow-xl">
|
||||||
|
<h2 className="mb-3 font-display text-lg font-semibold text-fg">
|
||||||
|
{t('app:game.pick_title', { defaultValue: 'Spiel auswählen' })}
|
||||||
|
</h2>
|
||||||
|
{gameError && (
|
||||||
|
<p className="mb-2 text-xs text-rose-400">{gameError}</p>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleStartGame('ttt')}
|
||||||
|
disabled={gameCreating}
|
||||||
|
className="flex flex-col items-center gap-2 rounded-xl border border-line bg-surface-3 p-4 text-fg transition hover:border-accent/40 hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span className="text-3xl">×○</span>
|
||||||
|
<span className="text-xs font-semibold">Tic-Tac-Toe</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleStartGame('c4')}
|
||||||
|
disabled={gameCreating}
|
||||||
|
className="flex flex-col items-center gap-2 rounded-xl border border-line bg-surface-3 p-4 text-fg transition hover:border-accent/40 hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span className="text-3xl">🔴🟡</span>
|
||||||
|
<span className="text-xs font-semibold">Vier-Gewinnt</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setGameDialogOpen(false); setGameError(null); }}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface"
|
||||||
|
>
|
||||||
|
{t('app:game.cancel', { defaultValue: 'Abbrechen' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{watchDialogOpen && (
|
{watchDialogOpen && (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -1633,3 +1745,13 @@ function PlayBoxIcon(props: React.SVGProps<SVGSVGElement>) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GameIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||||
|
<rect x="3" y="6" width="18" height="12" rx="3" />
|
||||||
|
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user