Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 888ed1b217 | |||
| a974e5b8ab | |||
| 7ebc5f6c9d | |||
| b8b451ef4f | |||
| d7c0c3d0a2 | |||
| 70ce824120 | |||
| d10840e0b2 | |||
| cd59ee30d6 | |||
| e56e22631b | |||
| dbf90504b4 | |||
| 256a613134 | |||
| e1423dba32 | |||
| 7730828403 | |||
| ecbd11e369 | |||
| 7d5f3b37cd | |||
| 7ce6b1c1d4 | |||
| 4399d39f08 | |||
| ad239ec549 | |||
| d1193142ae |
@@ -25,6 +25,7 @@
|
|||||||
"@livekit/track-processors": "^0.7.2",
|
"@livekit/track-processors": "^0.7.2",
|
||||||
"@supabase/supabase-js": "^2.46.0",
|
"@supabase/supabase-js": "^2.46.0",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^11.3.0",
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
"electron-updater": "^6.3.0",
|
"electron-updater": "^6.3.0",
|
||||||
"i18next": "^23.16.4",
|
"i18next": "^23.16.4",
|
||||||
"libsodium-wrappers-sumo": "0.7.15",
|
"libsodium-wrappers-sumo": "0.7.15",
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.0",
|
"@types/better-sqlite3": "^7.6.0",
|
||||||
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/libsodium-wrappers": "^0.7.14",
|
"@types/libsodium-wrappers": "^0.7.14",
|
||||||
"@types/libsodium-wrappers-sumo": "^0.8.2",
|
"@types/libsodium-wrappers-sumo": "^0.8.2",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
C4_COLS,
|
||||||
|
C4_ROWS,
|
||||||
|
c4DropRow,
|
||||||
|
c4WinningCells,
|
||||||
|
type Cell,
|
||||||
|
} from '@chat-app/shared/chat';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
board: Cell[];
|
||||||
|
myPlayerIdx: 0 | 1 | null;
|
||||||
|
disabled: boolean;
|
||||||
|
onMove: (column: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectFourBoard({ board, myPlayerIdx, disabled, onMove }: Props) {
|
||||||
|
const winCells = c4WinningCells(board);
|
||||||
|
const winSet = new Set<number>(winCells ?? []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mx-auto w-full max-w-2xl rounded-2xl bg-sky-900/40 p-3"
|
||||||
|
role="grid"
|
||||||
|
aria-label="Vier-Gewinnt"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="grid gap-1.5"
|
||||||
|
style={{ gridTemplateColumns: 'repeat(' + C4_COLS + ', minmax(0, 1fr))' }}
|
||||||
|
>
|
||||||
|
{Array.from({ length: C4_ROWS * C4_COLS }, (_, idx) => {
|
||||||
|
const cell = board[idx];
|
||||||
|
const col = idx % C4_COLS;
|
||||||
|
const dropTo = c4DropRow(board, col);
|
||||||
|
const canClickColumn = !disabled && dropTo >= 0 && myPlayerIdx !== null;
|
||||||
|
const inWin = winSet.has(idx);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
onClick={() => canClickColumn && onMove(col)}
|
||||||
|
disabled={!canClickColumn}
|
||||||
|
aria-label={'Spalte ' + (col + 1) + (cell !== null ? ' belegt' : '')}
|
||||||
|
className={
|
||||||
|
'flex aspect-square items-center justify-center rounded-full border-2 transition ' +
|
||||||
|
(inWin
|
||||||
|
? 'border-emerald-300 bg-emerald-400 shadow-[0_0_12px_rgba(110,231,183,0.7)]'
|
||||||
|
: cell === 0
|
||||||
|
? 'border-rose-300 bg-rose-500'
|
||||||
|
: cell === 1
|
||||||
|
? 'border-amber-300 bg-amber-400'
|
||||||
|
: canClickColumn
|
||||||
|
? 'cursor-pointer border-sky-700 bg-sky-950 hover:bg-sky-900'
|
||||||
|
: 'cursor-not-allowed border-sky-800 bg-sky-950 opacity-80')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
muteDurationToIso,
|
muteDurationToIso,
|
||||||
setConversationArchived,
|
setConversationArchived,
|
||||||
|
setConversationMentionsOnly,
|
||||||
setConversationMutedUntil,
|
setConversationMutedUntil,
|
||||||
} from '@chat-app/shared/chat';
|
} from '@chat-app/shared/chat';
|
||||||
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
|
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
@@ -8,12 +9,13 @@ import { createPortal } from 'react-dom';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { ArchiveIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
|
import { ArchiveIcon, AtIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
mutedUntil: string | null;
|
mutedUntil: string | null;
|
||||||
|
mentionsOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MuteOption {
|
interface MuteOption {
|
||||||
@@ -50,7 +52,7 @@ interface MenuPos {
|
|||||||
// escape the sidebar's `overflow-y-auto` clipping context. Position is
|
// escape the sidebar's `overflow-y-auto` clipping context. Position is
|
||||||
// computed from the trigger's bounding rect — menu anchors right-aligned
|
// computed from the trigger's bounding rect — menu anchors right-aligned
|
||||||
// under the trigger so it doesn't push off-screen on narrow windows.
|
// under the trigger so it doesn't push off-screen on narrow windows.
|
||||||
export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Props) {
|
export function ConversationRowMenu({ conversationId, archived, mutedUntil, mentionsOnly }: Props) {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
|
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
|
||||||
@@ -145,6 +147,21 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
|
|||||||
[conversationId],
|
[conversationId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleMentionsOnly = useCallback(
|
||||||
|
async (next: boolean) => {
|
||||||
|
setOpen(false);
|
||||||
|
try {
|
||||||
|
await setConversationMentionsOnly(supabase, {
|
||||||
|
conversationId,
|
||||||
|
mentionsOnly: next,
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('mentions-only toggle failed', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[conversationId],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -196,6 +213,16 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
|
|||||||
}}
|
}}
|
||||||
hasSubmenu={!isMuted}
|
hasSubmenu={!isMuted}
|
||||||
/>
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={<AtIcon className="h-4 w-4" />}
|
||||||
|
label={
|
||||||
|
(mentionsOnly ? '✓ ' : '') +
|
||||||
|
t('app:chats.mentions_only', {
|
||||||
|
defaultValue: 'Nur bei @Mentions benachrichtigen',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onClick={() => void handleMentionsOnly(!mentionsOnly)}
|
||||||
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import confetti from 'canvas-confetti';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useGame } from '../hooks/useGame';
|
||||||
|
import { ConnectFourBoard } from './ConnectFourBoard';
|
||||||
|
import { TicTacToeBoard } from './TicTacToeBoard';
|
||||||
|
import { XIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
gameId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GameModal({ gameId, onClose }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { game, loading, error, makeMove } = useGame(gameId);
|
||||||
|
const { session: auth } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const myUserId = auth?.user.id ?? null;
|
||||||
|
const myPlayerIdx: 0 | 1 | null =
|
||||||
|
!game || !myUserId
|
||||||
|
? null
|
||||||
|
: game.players[0] === myUserId
|
||||||
|
? 0
|
||||||
|
: game.players[1] === myUserId
|
||||||
|
? 1
|
||||||
|
: null;
|
||||||
|
const isMyTurn = !!game && game.currentTurnUserId === myUserId;
|
||||||
|
const finished = !!game?.finishedAt;
|
||||||
|
const winnerIdx: 0 | 1 | null =
|
||||||
|
!game?.winnerUserId
|
||||||
|
? null
|
||||||
|
: game.players[0] === game.winnerUserId
|
||||||
|
? 0
|
||||||
|
: game.players[1] === game.winnerUserId
|
||||||
|
? 1
|
||||||
|
: null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (finished && winnerIdx !== null && winnerIdx === myPlayerIdx) {
|
||||||
|
// Two bursts from the lower corners for a celebratory feel.
|
||||||
|
void confetti({
|
||||||
|
particleCount: 80,
|
||||||
|
spread: 60,
|
||||||
|
origin: { x: 0.2, y: 0.9 },
|
||||||
|
});
|
||||||
|
void confetti({
|
||||||
|
particleCount: 80,
|
||||||
|
spread: 60,
|
||||||
|
origin: { x: 0.8, y: 0.9 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [finished, winnerIdx, myPlayerIdx]);
|
||||||
|
const title =
|
||||||
|
game?.gameType === 'c4'
|
||||||
|
? t('app:game.c4', { defaultValue: 'Vier-Gewinnt' })
|
||||||
|
: t('app:game.ttt', { defaultValue: 'Tic-Tac-Toe' });
|
||||||
|
|
||||||
|
const statusLine = (() => {
|
||||||
|
if (loading) return t('app:game.loading', { defaultValue: 'Lädt…' });
|
||||||
|
if (error) return error;
|
||||||
|
if (!game) return t('app:game.missing', { defaultValue: 'Spiel nicht gefunden.' });
|
||||||
|
if (finished) {
|
||||||
|
if (winnerIdx === null) return t('app:game.draw', { defaultValue: 'Unentschieden!' });
|
||||||
|
if (winnerIdx === myPlayerIdx) return t('app:game.you_win', { defaultValue: 'Du hast gewonnen!' });
|
||||||
|
return t('app:game.you_lose', { defaultValue: 'Du hast verloren.' });
|
||||||
|
}
|
||||||
|
if (myPlayerIdx === null) return t('app:game.spectator', { defaultValue: 'Du schaust nur zu.' });
|
||||||
|
if (isMyTurn) return t('app:game.your_turn', { defaultValue: 'Du bist dran' });
|
||||||
|
return t('app:game.opponent_turn', { defaultValue: 'Gegner ist dran…' });
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
className="fixed inset-0 z-[80] flex flex-col bg-ink-950/95"
|
||||||
|
>
|
||||||
|
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
|
||||||
|
<h2 className="font-display text-sm font-semibold text-fg">{title}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('app:game.close', { defaultValue: 'Schließen' })}
|
||||||
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||||
|
>
|
||||||
|
<XIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-6">
|
||||||
|
{game?.gameType === 'ttt' ? (
|
||||||
|
<TicTacToeBoard
|
||||||
|
board={game.state.board}
|
||||||
|
myPlayerIdx={myPlayerIdx}
|
||||||
|
disabled={!isMyTurn || finished}
|
||||||
|
onMove={(cell) => void makeMove({ cell }).catch(() => {})}
|
||||||
|
/>
|
||||||
|
) : game?.gameType === 'c4' ? (
|
||||||
|
<ConnectFourBoard
|
||||||
|
board={game.state.board}
|
||||||
|
myPlayerIdx={myPlayerIdx}
|
||||||
|
disabled={!isMyTurn || finished}
|
||||||
|
onMove={(column) => void makeMove({ column }).catch(() => {})}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="flex shrink-0 items-center justify-center border-t border-line/40 bg-surface-2 px-4 py-3 text-sm font-medium text-fg">
|
||||||
|
{statusLine}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -442,6 +442,58 @@ 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' ? (
|
||||||
|
<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="4" width="18" height="14" rx="2" />
|
||||||
|
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-semibold text-fg">Watch Together</div>
|
||||||
|
<div className="text-xs text-fg-muted">YouTube synchronisiert ansehen</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('chatapp:open-watch-together', { detail: { id: parsed.sessionId } }),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={!parsed.sessionId}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Beitreten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : parsed.kind === 'whiteboard' ? (
|
) : parsed.kind === 'whiteboard' ? (
|
||||||
<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">
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { type Cell, tttWinningLine } from '@chat-app/shared/chat';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
board: Cell[];
|
||||||
|
myPlayerIdx: 0 | 1 | null;
|
||||||
|
disabled: boolean;
|
||||||
|
onMove: (cell: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARK = ['×', '○'] as const;
|
||||||
|
|
||||||
|
export function TicTacToeBoard({ board, myPlayerIdx, disabled, onMove }: Props) {
|
||||||
|
const winLine = tttWinningLine(board);
|
||||||
|
const winSet = new Set<number>(winLine ?? []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mx-auto grid w-full max-w-md grid-cols-3 gap-2 p-4"
|
||||||
|
style={{ aspectRatio: '1 / 1' }}
|
||||||
|
role="grid"
|
||||||
|
aria-label="Tic-Tac-Toe"
|
||||||
|
>
|
||||||
|
{board.map((cell, i) => {
|
||||||
|
const filled = cell !== null;
|
||||||
|
const inWin = winSet.has(i);
|
||||||
|
const canClick = !disabled && !filled && myPlayerIdx !== null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => canClick && onMove(i)}
|
||||||
|
disabled={!canClick}
|
||||||
|
aria-label={'Feld ' + (i + 1) + (filled ? ' belegt' : ' frei')}
|
||||||
|
className={
|
||||||
|
'flex aspect-square items-center justify-center rounded-xl border-2 text-5xl font-bold transition ' +
|
||||||
|
(inWin
|
||||||
|
? 'border-emerald-400 bg-emerald-400/20 text-emerald-200'
|
||||||
|
: filled
|
||||||
|
? cell === 0
|
||||||
|
? 'border-rose-500/60 bg-rose-500/10 text-rose-300'
|
||||||
|
: 'border-sky-500/60 bg-sky-500/10 text-sky-300'
|
||||||
|
: canClick
|
||||||
|
? 'cursor-pointer border-line bg-surface-2 text-fg-muted hover:bg-surface-3 hover:text-fg'
|
||||||
|
: 'cursor-not-allowed border-line bg-surface-2 text-fg-muted opacity-60')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{filled ? MARK[cell as 0 | 1] : ''}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useWatchSession } from '../hooks/useWatchSession';
|
||||||
|
import { XIcon } from './icons';
|
||||||
|
|
||||||
|
interface YTPlayer {
|
||||||
|
playVideo: () => void;
|
||||||
|
pauseVideo: () => void;
|
||||||
|
seekTo: (seconds: number, allowSeekAhead: boolean) => void;
|
||||||
|
getCurrentTime: () => number;
|
||||||
|
getPlayerState: () => number;
|
||||||
|
destroy: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YTPlayerOptions {
|
||||||
|
width: string | number;
|
||||||
|
height: string | number;
|
||||||
|
videoId: string;
|
||||||
|
playerVars?: { autoplay?: 0 | 1; controls?: 0 | 1; modestbranding?: 0 | 1 };
|
||||||
|
events?: {
|
||||||
|
onReady?: (ev: { target: YTPlayer }) => void;
|
||||||
|
onStateChange?: (ev: { data: number; target: YTPlayer }) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YTNamespace {
|
||||||
|
Player: new (elementId: string | HTMLElement, opts: YTPlayerOptions) => YTPlayer;
|
||||||
|
PlayerState: { UNSTARTED: -1; ENDED: 0; PLAYING: 1; PAUSED: 2; BUFFERING: 3; CUED: 5 };
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
YT?: YTNamespace;
|
||||||
|
onYouTubeIframeAPIReady?: () => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const IFRAME_API_URL = 'https://www.youtube.com/iframe_api';
|
||||||
|
let apiPromise: Promise<YTNamespace> | null = null;
|
||||||
|
|
||||||
|
function loadIframeApi(): Promise<YTNamespace> {
|
||||||
|
if (apiPromise) return apiPromise;
|
||||||
|
apiPromise = new Promise((resolve, reject) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
reject(new Error('no window'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.YT?.Player) {
|
||||||
|
resolve(window.YT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prev = window.onYouTubeIframeAPIReady;
|
||||||
|
window.onYouTubeIframeAPIReady = () => {
|
||||||
|
try {
|
||||||
|
prev?.();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
if (window.YT?.Player) resolve(window.YT);
|
||||||
|
else reject(new Error('YT namespace missing after ready'));
|
||||||
|
};
|
||||||
|
const existing = document.querySelector(
|
||||||
|
'script[src="' + IFRAME_API_URL + '"]',
|
||||||
|
);
|
||||||
|
if (existing) return;
|
||||||
|
const tag = document.createElement('script');
|
||||||
|
tag.src = IFRAME_API_URL;
|
||||||
|
tag.async = true;
|
||||||
|
document.head.appendChild(tag);
|
||||||
|
});
|
||||||
|
return apiPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sessionId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRIFT_THRESHOLD_SECONDS = 2;
|
||||||
|
|
||||||
|
export function WatchTogetherModal({ sessionId, onClose }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { session, pushState, endSession, error, loading } = useWatchSession(sessionId);
|
||||||
|
const { session: auth } = useAuth();
|
||||||
|
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const playerRef = useRef<YTPlayer | null>(null);
|
||||||
|
const [playerReady, setPlayerReady] = useState(false);
|
||||||
|
const ownerId = session?.ownerUserId ?? null;
|
||||||
|
const isOwner = !!auth?.user.id && auth.user.id === ownerId;
|
||||||
|
const ended = !!session?.endedAt;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session?.videoId || !mountRef.current) return;
|
||||||
|
if (playerRef.current) return;
|
||||||
|
let disposed = false;
|
||||||
|
void loadIframeApi().then((YT) => {
|
||||||
|
if (disposed || !mountRef.current) return;
|
||||||
|
playerRef.current = new YT.Player(mountRef.current, {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
videoId: session.videoId,
|
||||||
|
playerVars: { autoplay: 1, controls: isOwner ? 1 : 0, modestbranding: 1 },
|
||||||
|
events: {
|
||||||
|
onReady: () => setPlayerReady(true),
|
||||||
|
onStateChange: (ev) => {
|
||||||
|
if (!isOwner) return;
|
||||||
|
const playing = ev.data === YT.PlayerState.PLAYING;
|
||||||
|
const pos = ev.target.getCurrentTime();
|
||||||
|
pushState({ playing, positionSeconds: pos, updatedAtMs: Date.now() });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('YouTube IFrame API failed', err);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
try {
|
||||||
|
playerRef.current?.destroy();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
playerRef.current = null;
|
||||||
|
};
|
||||||
|
}, [session?.videoId, isOwner, pushState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOwner || !playerReady) return;
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
const p = playerRef.current;
|
||||||
|
if (!p) return;
|
||||||
|
try {
|
||||||
|
const state = p.getPlayerState();
|
||||||
|
const playing = state === window.YT?.PlayerState.PLAYING;
|
||||||
|
pushState({
|
||||||
|
playing,
|
||||||
|
positionSeconds: p.getCurrentTime(),
|
||||||
|
updatedAtMs: Date.now(),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [isOwner, playerReady, pushState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOwner || !playerReady || !session) return;
|
||||||
|
const p = playerRef.current;
|
||||||
|
if (!p) return;
|
||||||
|
const remoteAgeSec = Math.max(0, (Date.now() - session.currentState.updatedAtMs) / 1000);
|
||||||
|
const projectedRemote = session.currentState.playing
|
||||||
|
? session.currentState.positionSeconds + remoteAgeSec
|
||||||
|
: session.currentState.positionSeconds;
|
||||||
|
let local = 0;
|
||||||
|
try {
|
||||||
|
local = p.getCurrentTime();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Math.abs(local - projectedRemote) > DRIFT_THRESHOLD_SECONDS) {
|
||||||
|
try {
|
||||||
|
p.seekTo(projectedRemote, true);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const state = p.getPlayerState();
|
||||||
|
const localPlaying = state === window.YT?.PlayerState.PLAYING;
|
||||||
|
if (session.currentState.playing && !localPlaying) {
|
||||||
|
p.playVideo();
|
||||||
|
} else if (!session.currentState.playing && localPlaying) {
|
||||||
|
p.pauseVideo();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, [isOwner, playerReady, session]);
|
||||||
|
|
||||||
|
const handleClose = async () => {
|
||||||
|
if (isOwner && !ended) {
|
||||||
|
try {
|
||||||
|
await endSession();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('endSession failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('app:watch.title', { defaultValue: 'Watch Together' })}
|
||||||
|
className="fixed inset-0 z-[80] flex flex-col bg-black"
|
||||||
|
>
|
||||||
|
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
|
||||||
|
<h2 className="font-display text-sm font-semibold text-fg">
|
||||||
|
{t('app:watch.title', { defaultValue: 'Watch Together' })}
|
||||||
|
{ended && (
|
||||||
|
<span className="ml-2 rounded-md bg-rose-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-rose-600 dark:text-rose-300">
|
||||||
|
{t('app:watch.ended', { defaultValue: 'Beendet' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleClose()}
|
||||||
|
aria-label={t('app:watch.close', { defaultValue: 'Schließen' })}
|
||||||
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||||
|
>
|
||||||
|
<XIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black p-4">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-fg-muted">
|
||||||
|
{t('app:watch.loading', { defaultValue: 'Lädt…' })}
|
||||||
|
</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-sm text-rose-400">{error}</p>
|
||||||
|
) : !session ? (
|
||||||
|
<p className="text-sm text-fg-muted">
|
||||||
|
{t('app:watch.missing', { defaultValue: 'Session nicht gefunden.' })}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="aspect-video w-full max-w-5xl">
|
||||||
|
<div ref={mountRef} className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-line/40 bg-surface-2 px-4 py-2 text-xs text-fg-muted">
|
||||||
|
<span>
|
||||||
|
{isOwner
|
||||||
|
? t('app:watch.you_are_host', { defaultValue: 'Du steuerst die Wiedergabe.' })
|
||||||
|
: t('app:watch.you_are_guest', { defaultValue: 'Nur der Host kann steuern.' })}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{session?.currentState.playing
|
||||||
|
? t('app:watch.playing', { defaultValue: '▶ Läuft' })
|
||||||
|
: t('app:watch.paused', { defaultValue: '⏸ Pause' })}
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -225,7 +225,12 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
|||||||
(c) => c.id === row.conversation_id,
|
(c) => c.id === row.conversation_id,
|
||||||
);
|
);
|
||||||
const muted = isConversationMuted(convForMute?.mutedUntil ?? null);
|
const muted = isConversationMuted(convForMute?.mutedUntil ?? null);
|
||||||
if (presenceRef.current !== 'dnd' && !muted) {
|
// "Mentions only" silences non-mention messages here. Mentions
|
||||||
|
// still fire via the independent useMentionNotifications
|
||||||
|
// subscription on message_mentions, so this branch doesn't
|
||||||
|
// lose the @-alerts.
|
||||||
|
const mentionsOnly = convForMute?.mentionsOnly ?? false;
|
||||||
|
if (presenceRef.current !== 'dnd' && !muted && !mentionsOnly) {
|
||||||
playNotificationTone();
|
playNotificationTone();
|
||||||
const conv = conversationsRef.current.find(
|
const conv = conversationsRef.current.find(
|
||||||
(c) => c.id === row.conversation_id,
|
(c) => c.id === row.conversation_id,
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { getGame, makeGameMove, type GameRecord } from '@chat-app/shared/chat';
|
||||||
|
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
|
||||||
|
export function useGame(gameId: string | null): {
|
||||||
|
game: GameRecord | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
makeMove: (move: object) => Promise<void>;
|
||||||
|
} {
|
||||||
|
const [game, setGame] = useState<GameRecord | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!gameId) {
|
||||||
|
setGame(null);
|
||||||
|
setLoading(false);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const fresh = await getGame(supabase, gameId);
|
||||||
|
if (!cancelled) {
|
||||||
|
setGame(fresh);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
setError(err instanceof Error ? err.message : 'failed to load game');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const channel = supabase
|
||||||
|
.channel('game:' + gameId)
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{
|
||||||
|
event: 'UPDATE',
|
||||||
|
schema: 'public',
|
||||||
|
table: 'conversation_games',
|
||||||
|
filter: 'id=eq.' + gameId,
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
void getGame(supabase, gameId)
|
||||||
|
.then((fresh) => { if (fresh) setGame(fresh); })
|
||||||
|
.catch((err) => { console.warn('game realtime refetch failed', err); });
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
void supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
}, [gameId]);
|
||||||
|
|
||||||
|
const makeMove = useCallback(async (move: object) => {
|
||||||
|
if (!gameId) return;
|
||||||
|
try {
|
||||||
|
await makeGameMove(supabase, { gameId, move });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'move failed');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [gameId]);
|
||||||
|
|
||||||
|
return { game, loading, error, makeMove };
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
endWatchSession,
|
||||||
|
getWatchSession,
|
||||||
|
updateWatchSessionState,
|
||||||
|
type WatchSession,
|
||||||
|
type WatchSessionState,
|
||||||
|
} from '@chat-app/shared/chat';
|
||||||
|
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
|
||||||
|
const PUSH_THROTTLE_MS = 500;
|
||||||
|
|
||||||
|
export function useWatchSession(sessionId: string | null): {
|
||||||
|
session: WatchSession | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
pushState: (state: WatchSessionState) => void;
|
||||||
|
endSession: () => Promise<void>;
|
||||||
|
} {
|
||||||
|
const [session, setSession] = useState<WatchSession | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const pendingRef = useRef<WatchSessionState | null>(null);
|
||||||
|
const lastPushAtRef = useRef<number>(0);
|
||||||
|
const pushTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) {
|
||||||
|
setSession(null);
|
||||||
|
setLoading(false);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const fresh = await getWatchSession(supabase, sessionId);
|
||||||
|
if (!cancelled) {
|
||||||
|
setSession(fresh);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
setError(err instanceof Error ? err.message : 'failed to load session');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const channel = supabase
|
||||||
|
.channel('watch_session:' + sessionId)
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{
|
||||||
|
event: 'UPDATE',
|
||||||
|
schema: 'public',
|
||||||
|
table: 'conversation_watch_sessions',
|
||||||
|
filter: 'id=eq.' + sessionId,
|
||||||
|
},
|
||||||
|
(payload) => {
|
||||||
|
const row = payload.new as {
|
||||||
|
id?: string;
|
||||||
|
ended_at?: string | null;
|
||||||
|
current_state?: unknown;
|
||||||
|
} | null;
|
||||||
|
if (!row?.id) return;
|
||||||
|
const raw = (row.current_state ?? {}) as Partial<{
|
||||||
|
playing: boolean;
|
||||||
|
position_seconds: number;
|
||||||
|
updated_at_ms: number;
|
||||||
|
}>;
|
||||||
|
setSession((cur) => {
|
||||||
|
if (!cur) return cur;
|
||||||
|
return {
|
||||||
|
...cur,
|
||||||
|
endedAt: row.ended_at ?? null,
|
||||||
|
currentState: {
|
||||||
|
playing: typeof raw.playing === 'boolean' ? raw.playing : cur.currentState.playing,
|
||||||
|
positionSeconds:
|
||||||
|
typeof raw.position_seconds === 'number'
|
||||||
|
? raw.position_seconds
|
||||||
|
: cur.currentState.positionSeconds,
|
||||||
|
updatedAtMs:
|
||||||
|
typeof raw.updated_at_ms === 'number'
|
||||||
|
? raw.updated_at_ms
|
||||||
|
: cur.currentState.updatedAtMs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
void supabase.removeChannel(channel);
|
||||||
|
if (pushTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(pushTimerRef.current);
|
||||||
|
pushTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
// Throttled writer: keeps the latest state in pendingRef; fires at most
|
||||||
|
// once per PUSH_THROTTLE_MS. Trailing-edge push guarantees the final
|
||||||
|
// state is always sent even when a rapid burst stops before the leading-
|
||||||
|
// edge timeout expires.
|
||||||
|
const pushState = useCallback((state: WatchSessionState) => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
pendingRef.current = state;
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = now - lastPushAtRef.current;
|
||||||
|
if (elapsed >= PUSH_THROTTLE_MS) {
|
||||||
|
lastPushAtRef.current = now;
|
||||||
|
const toPush = pendingRef.current;
|
||||||
|
pendingRef.current = null;
|
||||||
|
void updateWatchSessionState(supabase, sessionId, toPush).catch((err) => {
|
||||||
|
console.warn('updateWatchSessionState failed', err);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pushTimerRef.current !== null) window.clearTimeout(pushTimerRef.current);
|
||||||
|
pushTimerRef.current = window.setTimeout(() => {
|
||||||
|
pushTimerRef.current = null;
|
||||||
|
const toPush = pendingRef.current;
|
||||||
|
if (!toPush) return;
|
||||||
|
pendingRef.current = null;
|
||||||
|
lastPushAtRef.current = Date.now();
|
||||||
|
void updateWatchSessionState(supabase, sessionId, toPush).catch((err) => {
|
||||||
|
console.warn('updateWatchSessionState trailing failed', err);
|
||||||
|
});
|
||||||
|
}, PUSH_THROTTLE_MS - elapsed);
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const endSession = useCallback(async () => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
await endWatchSession(supabase, sessionId);
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
return { session, loading, error, pushState, endSession };
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
type DecryptedMessage,
|
type DecryptedMessage,
|
||||||
type PollOption,
|
type PollOption,
|
||||||
type WhiteboardPayload,
|
type WhiteboardPayload,
|
||||||
|
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';
|
||||||
@@ -107,6 +109,25 @@ export function createWhiteboardPayload(whiteboardId: string): string {
|
|||||||
return serializeMessagePayload(payload);
|
return serializeMessagePayload(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createWatchTogetherPayload(sessionId: string): string {
|
||||||
|
const payload: WatchTogetherPayload = {
|
||||||
|
v: 1,
|
||||||
|
type: 'watch_together',
|
||||||
|
session_id: sessionId,
|
||||||
|
};
|
||||||
|
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[],
|
||||||
|
|||||||
@@ -224,6 +224,14 @@ function ConversationList({
|
|||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<p className="px-4 py-2 text-xs text-rose-500 dark:text-rose-300">{error}</p>
|
<p className="px-4 py-2 text-xs text-rose-500 dark:text-rose-300">{error}</p>
|
||||||
|
) : query.trim().length > 0 && items.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<SearchIcon className="h-8 w-8" />}
|
||||||
|
title={t('app:chats.search_empty_title', { defaultValue: 'Keine Treffer' })}
|
||||||
|
description={t('app:chats.search_empty_desc', {
|
||||||
|
defaultValue: 'Keine Chats passen zu "' + query.trim() + '".',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
showArchived ? (
|
showArchived ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -417,6 +425,7 @@ function ConversationRow({
|
|||||||
conversationId={item.id}
|
conversationId={item.id}
|
||||||
archived={item.archived}
|
archived={item.archived}
|
||||||
mutedUntil={item.mutedUntil}
|
mutedUntil={item.mutedUntil}
|
||||||
|
mentionsOnly={item.mentionsOnly}
|
||||||
/>
|
/>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,9 +46,13 @@ import {
|
|||||||
collectConversationAttachments,
|
collectConversationAttachments,
|
||||||
createPollPayload,
|
createPollPayload,
|
||||||
createWhiteboardPayload,
|
createWhiteboardPayload,
|
||||||
|
createWatchTogetherPayload,
|
||||||
|
createGamePayload,
|
||||||
} from '../lib/conversationFeatures';
|
} from '../lib/conversationFeatures';
|
||||||
import { WhiteboardModal } from '../components/WhiteboardModal';
|
import { WhiteboardModal } from '../components/WhiteboardModal';
|
||||||
import { createWhiteboard } from '@chat-app/shared/chat';
|
import { WatchTogetherModal } from '../components/WatchTogetherModal';
|
||||||
|
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';
|
||||||
@@ -169,6 +173,15 @@ export function ConversationPage() {
|
|||||||
const [pollSending, setPollSending] = useState(false);
|
const [pollSending, setPollSending] = useState(false);
|
||||||
const [openWhiteboardId, setOpenWhiteboardId] = useState<string | null>(null);
|
const [openWhiteboardId, setOpenWhiteboardId] = useState<string | null>(null);
|
||||||
const [creatingWhiteboard, setCreatingWhiteboard] = useState(false);
|
const [creatingWhiteboard, setCreatingWhiteboard] = useState(false);
|
||||||
|
const [openWatchSessionId, setOpenWatchSessionId] = useState<string | null>(null);
|
||||||
|
const [watchDialogOpen, setWatchDialogOpen] = useState(false);
|
||||||
|
const [watchUrl, setWatchUrl] = useState('');
|
||||||
|
const [watchError, setWatchError] = useState<string | null>(null);
|
||||||
|
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);
|
||||||
@@ -472,6 +485,24 @@ export function ConversationPage() {
|
|||||||
return () => window.removeEventListener('chatapp:open-whiteboard', onOpen);
|
return () => window.removeEventListener('chatapp:open-whiteboard', onOpen);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onOpen = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent<{ id?: string }>).detail;
|
||||||
|
if (detail?.id) setOpenWatchSessionId(detail.id);
|
||||||
|
};
|
||||||
|
window.addEventListener('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]);
|
||||||
@@ -622,6 +653,63 @@ export function ConversationPage() {
|
|||||||
}
|
}
|
||||||
}, [id, creatingWhiteboard, send, replyTo?.id]);
|
}, [id, creatingWhiteboard, send, replyTo?.id]);
|
||||||
|
|
||||||
|
const handleStartWatchTogether = useCallback(async () => {
|
||||||
|
if (!id) return;
|
||||||
|
const videoId = parseYouTubeUrl(watchUrl);
|
||||||
|
if (!videoId) {
|
||||||
|
setWatchError('Ungültige YouTube-URL.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWatchCreating(true);
|
||||||
|
setWatchError(null);
|
||||||
|
try {
|
||||||
|
const ws = await createWatchSession(supabase, { conversationId: id, videoId });
|
||||||
|
const payload = createWatchTogetherPayload(ws.id);
|
||||||
|
await send(payload, [], replyTo?.id ?? null);
|
||||||
|
setReplyTo(null);
|
||||||
|
setStickToBottom(true);
|
||||||
|
setWatchDialogOpen(false);
|
||||||
|
setWatchUrl('');
|
||||||
|
setOpenWatchSessionId(ws.id);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten');
|
||||||
|
} finally {
|
||||||
|
setWatchCreating(false);
|
||||||
|
}
|
||||||
|
}, [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[] = [];
|
||||||
@@ -1025,6 +1113,24 @@ export function ConversationPage() {
|
|||||||
>
|
>
|
||||||
<WhiteboardIcon className="h-4 w-4" />
|
<WhiteboardIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWatchDialogOpen(true)}
|
||||||
|
title={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
|
||||||
|
aria-label={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
|
||||||
|
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
|
||||||
|
>
|
||||||
|
<PlayBoxIcon className="h-4 w-4" />
|
||||||
|
</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"
|
||||||
@@ -1231,6 +1337,115 @@ export function ConversationPage() {
|
|||||||
onClose={() => setOpenWhiteboardId(null)}
|
onClose={() => setOpenWhiteboardId(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{openWatchSessionId && (
|
||||||
|
<WatchTogetherModal
|
||||||
|
sessionId={openWatchSessionId}
|
||||||
|
onClose={() => setOpenWatchSessionId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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 && (
|
||||||
|
<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) setWatchDialogOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md 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:watch.dialog_title', { defaultValue: 'Watch Together starten' })}
|
||||||
|
</h2>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
placeholder="https://youtu.be/..."
|
||||||
|
value={watchUrl}
|
||||||
|
onChange={(e) => { setWatchUrl(e.target.value); setWatchError(null); }}
|
||||||
|
className="mb-2 w-full rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||||
|
/>
|
||||||
|
{watchError && (
|
||||||
|
<p className="mb-2 text-xs text-rose-400">{watchError}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setWatchDialogOpen(false); setWatchUrl(''); setWatchError(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:watch.cancel', { defaultValue: 'Abbrechen' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleStartWatchTogether()}
|
||||||
|
disabled={watchCreating || !watchUrl.trim()}
|
||||||
|
className="cursor-pointer rounded-md bg-accent px-4 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{watchCreating
|
||||||
|
? t('app:watch.starting', { defaultValue: 'Startet…' })
|
||||||
|
: t('app:watch.start', { defaultValue: 'Starten' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1520,3 +1735,23 @@ function WhiteboardIcon(props: React.SVGProps<SVGSVGElement>) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlayBoxIcon(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="4" width="18" height="14" rx="2" />
|
||||||
|
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,361 @@
|
|||||||
|
# Phase 5C — Spec-Polish (4 leftover sub-items)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Close the 4 documented but never-built sub-items from the fifteen-features spec:
|
||||||
|
1. Empty-state for empty search results in chat list (Phase 1 spec line 52).
|
||||||
|
2. Per-conversation "Nur bei @Mentions benachrichtigen" toggle (Phase 2 spec line 72).
|
||||||
|
3. Mentions-on-edit recompute (Phase 2 spec line 141).
|
||||||
|
4. Confetti animation on game-win (Phase 5 spec line 133).
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- (1) Drops the existing `EmptyState` primitive into the `ChatsPage` search results when the filter produces zero rows.
|
||||||
|
- (2) Adds a `mentions_only boolean` column to `conversation_members` + a toggle in `ConversationRowMenu` next to the mute submenu. The notification gate suppresses non-mention notifications when set. `useMentionNotifications` is untouched — mentions fire regardless.
|
||||||
|
- (3) Extends `editEncryptedMessage()` in `packages/shared/src/chat/messages.ts` to re-run `parseMentionUsernames` + `insertMentions` after the text changes. Old mention rows are deleted first.
|
||||||
|
- (4) Adds `canvas-confetti` dep, fires it in `GameModal` when `winnerIdx === myPlayerIdx`.
|
||||||
|
|
||||||
|
**Tech Stack:** No new infra. Adds one runtime dep (`canvas-confetti` + types).
|
||||||
|
|
||||||
|
**Non-goals:**
|
||||||
|
- Cron-based items (7-day view-once purge, 12h watch-together auto-end, 24h game auto-draw) — server-side per spec, out of scope.
|
||||||
|
- New in-conversation message search (only chat-list search empty state).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-flight
|
||||||
|
|
||||||
|
- [ ] **Verify clean working tree on `main`**
|
||||||
|
|
||||||
|
Run: `cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status`
|
||||||
|
Expected: clean.
|
||||||
|
|
||||||
|
- [ ] **Confirm tooling is green**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/shared test -- --run`
|
||||||
|
Expected: all green; 71 shared tests pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Empty-state for empty search results
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/desktop/src/pages/ChatsPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Locate the search-filter render**
|
||||||
|
|
||||||
|
```
|
||||||
|
Read apps/desktop/src/pages/ChatsPage.tsx (offset 1, limit 80)
|
||||||
|
```
|
||||||
|
|
||||||
|
Find:
|
||||||
|
- Search input + `query` state (~lines 29-48).
|
||||||
|
- `queryFiltered` (or similarly named) memo.
|
||||||
|
- The render loop over the filtered list.
|
||||||
|
- The existing `<EmptyState>` import — add `import { EmptyState } from '../components/EmptyState';` if missing.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Render an empty-state when search yields zero results**
|
||||||
|
|
||||||
|
Wrap the rendered list with a length check:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{query.trim().length > 0 && filteredList.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('app:chats.search_empty_title', { defaultValue: 'Keine Treffer' })}
|
||||||
|
description={t('app:chats.search_empty_desc', {
|
||||||
|
defaultValue: 'Keine Chats passen zu "' + query.trim() + '".',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
/* existing list render */
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `EmptyState`'s prop shape differs (`icon`/`action` required), match the existing chat-list call site (P1.T6). Grep first: `Grep -n "EmptyState" apps/desktop/src/pages/ChatsPage.tsx apps/desktop/src/components/EmptyState.tsx`.
|
||||||
|
|
||||||
|
Substitute the real variable names from the file (`query` vs `searchText`, `filteredList` vs `queryFiltered`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Typecheck**
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm --filter @chat-app/desktop typecheck
|
||||||
|
```
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "D:\Programmieren\ChatApp-Electron\chat-app"
|
||||||
|
git add apps/desktop/src/pages/ChatsPage.tsx
|
||||||
|
git commit -m "feat(P5C.T1): empty-state for empty chat-list search results"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Per-conv "Nur bei @Mentions benachrichtigen" toggle
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `supabase/migrations/20260516000010_mentions_only.sql`
|
||||||
|
- Modify: `packages/db-types/src/index.ts`
|
||||||
|
- Modify: `packages/shared/src/chat/conversations.ts` (or wherever conversation_members helpers live)
|
||||||
|
- Modify: `apps/desktop/src/components/ConversationRowMenu.tsx`
|
||||||
|
- Modify: the notification gate (likely `apps/desktop/src/lib/osNotify.ts` callers, e.g. `useConversationMessages.ts`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: SQL migration + prod push**
|
||||||
|
|
||||||
|
Create `supabase/migrations/20260516000010_mentions_only.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Phase 5C: per-conversation "notify only on @mentions" toggle.
|
||||||
|
-- Lives alongside the existing muted_until column on conversation_members.
|
||||||
|
-- When true: the renderer's incoming-message notification gate suppresses
|
||||||
|
-- the alert unless the message contains an @-mention of the local user.
|
||||||
|
-- Mentions always fire regardless (override-by-design).
|
||||||
|
|
||||||
|
alter table public.conversation_members
|
||||||
|
add column if not exists mentions_only boolean not null default false;
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "D:\Programmieren\ChatApp-Electron\chat-app"
|
||||||
|
git add supabase/migrations/20260516000010_mentions_only.sql
|
||||||
|
git commit -m "feat(P5C.T2-sql): conversation_members.mentions_only column"
|
||||||
|
bash scripts/prod/push-migrations.sh mentions_only
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: db-types extension**
|
||||||
|
|
||||||
|
In `packages/db-types/src/index.ts`, find the `conversation_members` entry. Add `mentions_only: boolean` to `Row`, `mentions_only?: boolean` to `Insert`, and `mentions_only?: boolean` to `Update`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Shared wrapper for the toggle**
|
||||||
|
|
||||||
|
Find where `conversation_members` mutation helpers live (likely a `setConversationMuted` exists):
|
||||||
|
```
|
||||||
|
Grep -rn "conversation_members" packages/shared/src/
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to that same file (or the most-fitting chat helper):
|
||||||
|
```ts
|
||||||
|
export async function setConversationMentionsOnly(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
params: { conversationId: string; mentionsOnly: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
const { data: session } = await client.auth.getUser();
|
||||||
|
if (!session.user) throw new Error('not authenticated');
|
||||||
|
const { error } = await client
|
||||||
|
.from('conversation_members')
|
||||||
|
.update({ mentions_only: params.mentionsOnly })
|
||||||
|
.eq('conversation_id', params.conversationId)
|
||||||
|
.eq('user_id', session.user.id);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `AppSupabaseClient` import if missing.
|
||||||
|
|
||||||
|
Also: if the conversation-read wrapper (`listConversations` or similar) projects `muted_until` into a camelCase `mutedUntil`, add `mentionsOnly` alongside it in the same mapper.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Render the toggle in `ConversationRowMenu.tsx`**
|
||||||
|
|
||||||
|
```
|
||||||
|
Read apps/desktop/src/components/ConversationRowMenu.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Find the mute entry (~lines 31-42). Add a sibling menu item below it:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void setConversationMentionsOnly(supabase, {
|
||||||
|
conversationId: conv.id,
|
||||||
|
mentionsOnly: !conv.mentionsOnly,
|
||||||
|
}).catch((err) => console.warn('mentions-only toggle failed', err));
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="..." // copy from the existing mute-entry className
|
||||||
|
>
|
||||||
|
<span>{conv.mentionsOnly ? '✓ ' : ''}Nur bei @Mentions benachrichtigen</span>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
If the `conv` prop type doesn't yet expose `mentionsOnly`, extend the type in the source (the `Conversation` interface in shared) and the mapper in the read wrapper from Step 3.
|
||||||
|
|
||||||
|
Add imports:
|
||||||
|
```ts
|
||||||
|
import { setConversationMentionsOnly } from '@chat-app/shared/chat';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
```
|
||||||
|
(Adapt the `@chat-app/shared/chat` path if Step 3's helper lives in a different sub-path.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Suppress non-mention notifications when `mentions_only` is true**
|
||||||
|
|
||||||
|
```
|
||||||
|
Grep -rn "useMentionNotifications\|osNotify" apps/desktop/src/
|
||||||
|
```
|
||||||
|
|
||||||
|
Find the message-incoming notification path. It's the place that calls `osNotify(...)` on inbound non-self messages. The current shape likely:
|
||||||
|
```ts
|
||||||
|
if (!isAppFocused() && !isMuted(conv)) {
|
||||||
|
osNotify(...);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Extend it (the cleanest path — assumes `useMentionNotifications` independently fires for every mention, which the recon confirmed):
|
||||||
|
```ts
|
||||||
|
if (!isAppFocused() && !isMuted(conv)) {
|
||||||
|
if (conv.mentionsOnly) {
|
||||||
|
// Non-mention messages are silenced here. The mention case is handled
|
||||||
|
// by useMentionNotifications (which subscribes to message_mentions
|
||||||
|
// INSERT independently) so we don't lose the @-alert.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
osNotify(...);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `conv` isn't in scope at the gate (some hooks only have `conversationId`), look up the conv via the `ConversationsContext` cache. Pattern: `Grep -n "useConversations\|conversations.find\|conversationsById" apps/desktop/src/`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Typecheck**
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck
|
||||||
|
```
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/db-types/src/index.ts packages/shared/src/chat/ apps/desktop/src/
|
||||||
|
# verify with git status that only intended files are staged before committing
|
||||||
|
git commit -m "feat(P5C.T2): per-conv 'mentions only' toggle + notification gate"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Mentions-on-edit recompute
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/shared/src/chat/messages.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the existing edit + send paths**
|
||||||
|
|
||||||
|
```
|
||||||
|
Read packages/shared/src/chat/messages.ts (offset 140, limit 100)
|
||||||
|
```
|
||||||
|
|
||||||
|
Find:
|
||||||
|
- `insertMessage` (~line 145) — calls `parseMentionUsernames` + `insertMentions`.
|
||||||
|
- `editEncryptedMessage` (~lines 208-229) — no mention re-extraction.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Extend `editEncryptedMessage`**
|
||||||
|
|
||||||
|
After the existing `UPDATE` on `messages`, add:
|
||||||
|
```ts
|
||||||
|
// Recompute mentions: edit can add/remove @-tokens. Drop old, insert new.
|
||||||
|
await client.from('message_mentions').delete().eq('message_id', messageId);
|
||||||
|
const mentionUsernames = parseMentionUsernames(plaintext);
|
||||||
|
if (mentionUsernames.length > 0) {
|
||||||
|
await insertMentions(client, {
|
||||||
|
messageId,
|
||||||
|
conversationId,
|
||||||
|
usernames: mentionUsernames,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapt to the exact arg shapes used by `insertMessage` (precedent).
|
||||||
|
|
||||||
|
If `editEncryptedMessage`'s signature doesn't accept `plaintext` and `conversationId`, extend the signature and fix all callers. Grep first: `Grep -rn "editEncryptedMessage" apps/desktop/src/ packages/shared/src/`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Typecheck + tests**
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck
|
||||||
|
pnpm --filter @chat-app/shared test -- --run
|
||||||
|
```
|
||||||
|
Expected: all green.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/shared/src/chat/messages.ts apps/desktop/src/
|
||||||
|
git commit -m "feat(P5C.T3): recompute message_mentions on edit"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Confetti on game win
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/desktop/package.json` (deps)
|
||||||
|
- Modify: `apps/desktop/src/components/GameModal.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the dep**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd "D:\Programmieren\ChatApp-Electron\chat-app"
|
||||||
|
pnpm --filter @chat-app/desktop add canvas-confetti
|
||||||
|
pnpm --filter @chat-app/desktop add -D @types/canvas-confetti
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fire confetti when the local player wins**
|
||||||
|
|
||||||
|
In `apps/desktop/src/components/GameModal.tsx` (P5B.T5 commit `cd59ee3`), add an effect near the existing keyboard-Esc effect:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import confetti from 'canvas-confetti';
|
||||||
|
// ...
|
||||||
|
useEffect(() => {
|
||||||
|
if (finished && winnerIdx !== null && winnerIdx === myPlayerIdx) {
|
||||||
|
void confetti({
|
||||||
|
particleCount: 80,
|
||||||
|
spread: 60,
|
||||||
|
origin: { x: 0.2, y: 0.9 },
|
||||||
|
});
|
||||||
|
void confetti({
|
||||||
|
particleCount: 80,
|
||||||
|
spread: 60,
|
||||||
|
origin: { x: 0.8, y: 0.9 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [finished, winnerIdx, myPlayerIdx]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Typecheck**
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm --filter @chat-app/desktop typecheck
|
||||||
|
```
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/desktop/package.json apps/desktop/src/components/GameModal.tsx
|
||||||
|
# include pnpm-lock.yaml if changed at the repo root
|
||||||
|
git add pnpm-lock.yaml
|
||||||
|
git commit -m "feat(P5C.T4): confetti burst on game win"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final gate
|
||||||
|
|
||||||
|
- [ ] **Step 1: Typecheck both packages**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck`
|
||||||
|
Expected: both PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run shared tests**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/shared test -- --run`
|
||||||
|
Expected: PASS — 71 tests (same as baseline; no new tests added).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify no uncommitted changes**
|
||||||
|
|
||||||
|
Run: `git status`
|
||||||
|
Expected: clean.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Report**
|
||||||
|
|
||||||
|
Report: "Phase 5C (Polish) code-complete on `main`; mentions_only migration applied to prod. Fifteen-features spec is now 100 % implemented. Smoke: (1) search 'xyz' in chat list → 'Keine Treffer' card. (2) Conv menu → 'Nur bei @Mentions' → DM with normal text → silent; DM with '@<dein-name>' → notify. (3) Edit a sent message to add @someone → that someone gets a notification. (4) Win a TTT or C4 game → confetti."
|
||||||
@@ -57,6 +57,7 @@ export type Database = {
|
|||||||
accepted: boolean
|
accepted: boolean
|
||||||
conversation_id: string
|
conversation_id: string
|
||||||
joined_at: string
|
joined_at: string
|
||||||
|
mentions_only: boolean
|
||||||
role: Database["public"]["Enums"]["member_role"]
|
role: Database["public"]["Enums"]["member_role"]
|
||||||
user_id: string
|
user_id: string
|
||||||
}
|
}
|
||||||
@@ -64,6 +65,7 @@ export type Database = {
|
|||||||
accepted?: boolean
|
accepted?: boolean
|
||||||
conversation_id: string
|
conversation_id: string
|
||||||
joined_at?: string
|
joined_at?: string
|
||||||
|
mentions_only?: boolean
|
||||||
role?: Database["public"]["Enums"]["member_role"]
|
role?: Database["public"]["Enums"]["member_role"]
|
||||||
user_id: string
|
user_id: string
|
||||||
}
|
}
|
||||||
@@ -71,6 +73,7 @@ export type Database = {
|
|||||||
accepted?: boolean
|
accepted?: boolean
|
||||||
conversation_id?: string
|
conversation_id?: string
|
||||||
joined_at?: string
|
joined_at?: string
|
||||||
|
mentions_only?: boolean
|
||||||
role?: Database["public"]["Enums"]["member_role"]
|
role?: Database["public"]["Enums"]["member_role"]
|
||||||
user_id?: string
|
user_id?: string
|
||||||
}
|
}
|
||||||
@@ -582,6 +585,71 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Relationships: []
|
Relationships: []
|
||||||
}
|
}
|
||||||
|
conversation_watch_sessions: {
|
||||||
|
Row: {
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
owner_user_id: string;
|
||||||
|
video_id: string;
|
||||||
|
started_at: string;
|
||||||
|
ended_at: string | null;
|
||||||
|
current_state: { playing: boolean; position_seconds: number; updated_at_ms: number };
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
id?: string;
|
||||||
|
conversation_id: string;
|
||||||
|
owner_user_id: string;
|
||||||
|
video_id: string;
|
||||||
|
started_at?: string;
|
||||||
|
ended_at?: string | null;
|
||||||
|
current_state?: { playing: boolean; position_seconds: number; updated_at_ms: number };
|
||||||
|
};
|
||||||
|
Update: {
|
||||||
|
id?: string;
|
||||||
|
conversation_id?: string;
|
||||||
|
owner_user_id?: string;
|
||||||
|
video_id?: string;
|
||||||
|
started_at?: string;
|
||||||
|
ended_at?: string | null;
|
||||||
|
current_state?: { playing: boolean; position_seconds: number; updated_at_ms: number };
|
||||||
|
};
|
||||||
|
Relationships: [];
|
||||||
|
};
|
||||||
|
conversation_games: {
|
||||||
|
Row: {
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
game_type: 'ttt' | 'c4';
|
||||||
|
state: { kind: 'ttt' | 'c4'; board: Array<number | null> };
|
||||||
|
players: [string, string];
|
||||||
|
current_turn_user_id: string | null;
|
||||||
|
winner_user_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
finished_at: string | null;
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
id?: string;
|
||||||
|
conversation_id: string;
|
||||||
|
game_type: 'ttt' | 'c4';
|
||||||
|
state: { kind: 'ttt' | 'c4'; board: Array<number | null> };
|
||||||
|
players: [string, string];
|
||||||
|
current_turn_user_id: string | null;
|
||||||
|
winner_user_id?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
finished_at?: string | null;
|
||||||
|
};
|
||||||
|
Update: {
|
||||||
|
id?: string;
|
||||||
|
conversation_id?: string;
|
||||||
|
game_type?: 'ttt' | 'c4';
|
||||||
|
state?: { kind: 'ttt' | 'c4'; board: Array<number | null> };
|
||||||
|
players?: [string, string];
|
||||||
|
current_turn_user_id?: string | null;
|
||||||
|
winner_user_id?: string | null;
|
||||||
|
finished_at?: string | null;
|
||||||
|
};
|
||||||
|
Relationships: [];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
Views: {
|
Views: {
|
||||||
[_ in never]: never
|
[_ in never]: never
|
||||||
@@ -590,6 +658,10 @@ export type Database = {
|
|||||||
accept_dm: { Args: { conversation_id: string }; Returns: undefined }
|
accept_dm: { Args: { conversation_id: string }; Returns: undefined }
|
||||||
are_friends: { Args: { a: string; b: string }; Returns: boolean }
|
are_friends: { Args: { a: string; b: string }; Returns: boolean }
|
||||||
revoke_device: { Args: { p_device_id: string }; Returns: undefined }
|
revoke_device: { Args: { p_device_id: string }; Returns: undefined }
|
||||||
|
game_make_move: {
|
||||||
|
Args: { p_game_id: string; p_move: object };
|
||||||
|
Returns: { kind: 'ttt' | 'c4'; board: Array<number | null> };
|
||||||
|
}
|
||||||
attachment_object_conv_id: {
|
attachment_object_conv_id: {
|
||||||
Args: { object_name: string }
|
Args: { object_name: string }
|
||||||
Returns: string
|
Returns: string
|
||||||
|
|||||||
@@ -82,11 +82,26 @@ export interface WhiteboardPayload {
|
|||||||
whiteboard_id: string;
|
whiteboard_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WatchTogetherPayload {
|
||||||
|
v: 1;
|
||||||
|
type: 'watch_together';
|
||||||
|
session_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamePayload {
|
||||||
|
v: 1;
|
||||||
|
type: 'game';
|
||||||
|
game_id: string;
|
||||||
|
game_type: 'ttt' | 'c4';
|
||||||
|
}
|
||||||
|
|
||||||
export type MessagePayload =
|
export type MessagePayload =
|
||||||
| TextMessagePayload
|
| TextMessagePayload
|
||||||
| CallEventPayload
|
| CallEventPayload
|
||||||
| PollPayload
|
| PollPayload
|
||||||
| WhiteboardPayload;
|
| WhiteboardPayload
|
||||||
|
| WatchTogetherPayload
|
||||||
|
| GamePayload;
|
||||||
|
|
||||||
export type ParsedMessagePayload =
|
export type ParsedMessagePayload =
|
||||||
| {
|
| {
|
||||||
@@ -109,6 +124,15 @@ export type ParsedMessagePayload =
|
|||||||
| {
|
| {
|
||||||
kind: 'whiteboard';
|
kind: 'whiteboard';
|
||||||
whiteboardId: string;
|
whiteboardId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'watch_together';
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'game';
|
||||||
|
gameId: string;
|
||||||
|
gameType: 'ttt' | 'c4';
|
||||||
};
|
};
|
||||||
|
|
||||||
export function serializeMessagePayload(payload: MessagePayload): string {
|
export function serializeMessagePayload(payload: MessagePayload): string {
|
||||||
@@ -172,6 +196,19 @@ export function parseMessagePayload(raw: string | null): ParsedMessagePayload {
|
|||||||
: '';
|
: '';
|
||||||
return { kind: 'whiteboard', whiteboardId: id };
|
return { kind: 'whiteboard', whiteboardId: id };
|
||||||
}
|
}
|
||||||
|
if (obj.type === 'watch_together') {
|
||||||
|
const p = obj as Partial<WatchTogetherPayload>;
|
||||||
|
const id = typeof p.session_id === 'string' && p.session_id.length > 0
|
||||||
|
? p.session_id
|
||||||
|
: '';
|
||||||
|
return { kind: 'watch_together', sessionId: id };
|
||||||
|
}
|
||||||
|
if (obj.type === 'game') {
|
||||||
|
const p = obj as Partial<GamePayload>;
|
||||||
|
const id = typeof p.game_id === 'string' && p.game_id.length > 0 ? p.game_id : '';
|
||||||
|
const t = p.game_type === 'ttt' || p.game_type === 'c4' ? p.game_type : 'ttt';
|
||||||
|
return { kind: 'game', gameId: id, gameType: t };
|
||||||
|
}
|
||||||
const t = obj as TextMessagePayload;
|
const t = obj as TextMessagePayload;
|
||||||
return {
|
return {
|
||||||
kind: 'text',
|
kind: 'text',
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
|
|||||||
// cast the select to bypass typing.
|
// cast the select to bypass typing.
|
||||||
const { data: myMembers, error: mErr } = await client
|
const { data: myMembers, error: mErr } = await client
|
||||||
.from('conversation_members')
|
.from('conversation_members')
|
||||||
.select('conversation_id, role, accepted, archived, muted_until' as '*')
|
.select('conversation_id, role, accepted, archived, muted_until, mentions_only' as '*')
|
||||||
.eq('user_id', myId);
|
.eq('user_id', myId);
|
||||||
if (mErr) throw mErr;
|
if (mErr) throw mErr;
|
||||||
const myMembersList = (myMembers ?? []) as unknown as Array<{
|
const myMembersList = (myMembers ?? []) as unknown as Array<{
|
||||||
@@ -44,6 +44,7 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
|
|||||||
accepted: boolean;
|
accepted: boolean;
|
||||||
archived: boolean | null;
|
archived: boolean | null;
|
||||||
muted_until: string | null;
|
muted_until: string | null;
|
||||||
|
mentions_only: boolean | null;
|
||||||
}>;
|
}>;
|
||||||
if (myMembersList.length === 0) return [];
|
if (myMembersList.length === 0) return [];
|
||||||
|
|
||||||
@@ -114,7 +115,13 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
|
|||||||
? (members.find((m) => m.userId !== myId)?.profile ?? null)
|
? (members.find((m) => m.userId !== myId)?.profile ?? null)
|
||||||
: null;
|
: null;
|
||||||
const mineRow = mine as
|
const mineRow = mine as
|
||||||
| { accepted: boolean; role: string; archived?: boolean; muted_until?: string | null }
|
| {
|
||||||
|
accepted: boolean;
|
||||||
|
role: string;
|
||||||
|
archived?: boolean;
|
||||||
|
muted_until?: string | null;
|
||||||
|
mentions_only?: boolean | null;
|
||||||
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
return {
|
return {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
@@ -129,6 +136,7 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
|
|||||||
lastMessageAt: lastSeen.get(c.id) ?? null,
|
lastMessageAt: lastSeen.get(c.id) ?? null,
|
||||||
archived: mineRow?.archived ?? false,
|
archived: mineRow?.archived ?? false,
|
||||||
mutedUntil: mineRow?.muted_until ?? null,
|
mutedUntil: mineRow?.muted_until ?? null,
|
||||||
|
mentionsOnly: mineRow?.mentions_only ?? false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -164,6 +172,24 @@ export async function setConversationMutedUntil(
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle "mentions only" — when true the renderer's notification gate
|
||||||
|
// suppresses non-mention alerts for this conversation. Mentions still fire
|
||||||
|
// via the independent useMentionNotifications subscription on
|
||||||
|
// message_mentions, so the @-alerts are never lost.
|
||||||
|
export async function setConversationMentionsOnly(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
params: { conversationId: string; mentionsOnly: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
const { data: session } = await client.auth.getUser();
|
||||||
|
if (!session.user) throw new Error('not authenticated');
|
||||||
|
const { error } = await client
|
||||||
|
.from('conversation_members')
|
||||||
|
.update({ mentions_only: params.mentionsOnly } as never)
|
||||||
|
.eq('conversation_id', params.conversationId)
|
||||||
|
.eq('user_id', session.user.id);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
// Convenience: `null` unmutes, number means minutes from now. For "forever"
|
// Convenience: `null` unmutes, number means minutes from now. For "forever"
|
||||||
// pass a very large number (e.g. 100 years worth of minutes).
|
// pass a very large number (e.g. 100 years worth of minutes).
|
||||||
export function muteDurationToIso(minutes: number | null): string | null {
|
export function muteDurationToIso(minutes: number | null): string | null {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
c4DropRow,
|
||||||
|
c4WinningCells,
|
||||||
|
emptyC4Board,
|
||||||
|
emptyTttBoard,
|
||||||
|
isBoardFull,
|
||||||
|
tttWinningLine,
|
||||||
|
type Cell,
|
||||||
|
} from './games';
|
||||||
|
|
||||||
|
describe('tttWinningLine', () => {
|
||||||
|
it('detects a row win', () => {
|
||||||
|
const b: Cell[] = [0, 0, 0, null, null, null, null, null, null];
|
||||||
|
expect(tttWinningLine(b)).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects a diagonal win', () => {
|
||||||
|
const b: Cell[] = [1, null, null, null, 1, null, null, null, 1];
|
||||||
|
expect(tttWinningLine(b)).toEqual([0, 4, 8]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no winner', () => {
|
||||||
|
expect(tttWinningLine(emptyTttBoard())).toBeNull();
|
||||||
|
const mixed: Cell[] = [0, 1, 0, 1, 0, 1, 1, 0, 1];
|
||||||
|
expect(tttWinningLine(mixed)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('c4WinningCells', () => {
|
||||||
|
it('detects a horizontal win', () => {
|
||||||
|
const b = emptyC4Board();
|
||||||
|
b[35] = 1; b[36] = 1; b[37] = 1; b[38] = 1;
|
||||||
|
expect(c4WinningCells(b)).toEqual([35, 36, 37, 38]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects a vertical win', () => {
|
||||||
|
const b = emptyC4Board();
|
||||||
|
b[14] = 0; b[21] = 0; b[28] = 0; b[35] = 0;
|
||||||
|
expect(c4WinningCells(b)).toEqual([14, 21, 28, 35]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects a diagonal ↘ win', () => {
|
||||||
|
const b = emptyC4Board();
|
||||||
|
b[14] = 1; b[22] = 1; b[30] = 1; b[38] = 1;
|
||||||
|
expect(c4WinningCells(b)).toEqual([14, 22, 30, 38]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no winner', () => {
|
||||||
|
expect(c4WinningCells(emptyC4Board())).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('c4DropRow', () => {
|
||||||
|
it('returns the bottom row on an empty column', () => {
|
||||||
|
expect(c4DropRow(emptyC4Board(), 0)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stacks on top of an existing piece', () => {
|
||||||
|
const b = emptyC4Board();
|
||||||
|
b[35] = 0;
|
||||||
|
expect(c4DropRow(b, 0)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns -1 when the column is full', () => {
|
||||||
|
const b = emptyC4Board();
|
||||||
|
for (let r = 0; r < 6; r++) b[r * 7 + 3] = 0;
|
||||||
|
expect(c4DropRow(b, 3)).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isBoardFull', () => {
|
||||||
|
it('true for a fully filled board, false otherwise', () => {
|
||||||
|
expect(isBoardFull(emptyTttBoard())).toBe(false);
|
||||||
|
const filled: Cell[] = [0, 1, 0, 1, 0, 1, 1, 0, 1];
|
||||||
|
expect(isBoardFull(filled)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import type { AppSupabaseClient } from '../supabase/client';
|
||||||
|
|
||||||
|
export type GameType = 'ttt' | 'c4';
|
||||||
|
export type Cell = 0 | 1 | null;
|
||||||
|
|
||||||
|
export const TTT_CELLS = 9;
|
||||||
|
export const C4_ROWS = 6;
|
||||||
|
export const C4_COLS = 7;
|
||||||
|
export const C4_CELLS = C4_ROWS * C4_COLS;
|
||||||
|
|
||||||
|
export const TTT_LINES: ReadonlyArray<readonly [number, number, number]> = [
|
||||||
|
[0, 1, 2], [3, 4, 5], [6, 7, 8],
|
||||||
|
[0, 3, 6], [1, 4, 7], [2, 5, 8],
|
||||||
|
[0, 4, 8], [2, 4, 6],
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface GameRecord {
|
||||||
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
|
gameType: GameType;
|
||||||
|
state: { kind: GameType; board: Cell[] };
|
||||||
|
players: [string, string];
|
||||||
|
currentTurnUserId: string | null;
|
||||||
|
winnerUserId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyTttBoard(): Cell[] {
|
||||||
|
return new Array(TTT_CELLS).fill(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyC4Board(): Cell[] {
|
||||||
|
return new Array(C4_CELLS).fill(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tttWinningLine(board: Cell[]): readonly [number, number, number] | null {
|
||||||
|
for (const line of TTT_LINES) {
|
||||||
|
const a = board[line[0]];
|
||||||
|
const b = board[line[1]];
|
||||||
|
const c = board[line[2]];
|
||||||
|
if (a !== null && a === b && b === c) return line;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function c4WinningCells(board: Cell[]): readonly number[] | null {
|
||||||
|
const directions: Array<[number, number]> = [
|
||||||
|
[0, 1], [1, 0], [1, 1], [1, -1],
|
||||||
|
];
|
||||||
|
for (let r = 0; r < C4_ROWS; r++) {
|
||||||
|
for (let c = 0; c < C4_COLS; c++) {
|
||||||
|
const base = board[r * C4_COLS + c];
|
||||||
|
if (base === null) continue;
|
||||||
|
for (const [dr, dc] of directions) {
|
||||||
|
const rEnd = r + dr * 3;
|
||||||
|
const cEnd = c + dc * 3;
|
||||||
|
if (rEnd < 0 || rEnd >= C4_ROWS || cEnd < 0 || cEnd >= C4_COLS) continue;
|
||||||
|
let ok = true;
|
||||||
|
const cells: number[] = [r * C4_COLS + c];
|
||||||
|
for (let i = 1; i < 4; i++) {
|
||||||
|
const idx = (r + dr * i) * C4_COLS + (c + dc * i);
|
||||||
|
if (board[idx] !== base) { ok = false; break; }
|
||||||
|
cells.push(idx);
|
||||||
|
}
|
||||||
|
if (ok) return cells;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBoardFull(board: Cell[]): boolean {
|
||||||
|
return board.every((c) => c !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function c4DropRow(board: Cell[], column: number): number {
|
||||||
|
for (let r = C4_ROWS - 1; r >= 0; r--) {
|
||||||
|
if (board[r * C4_COLS + column] === null) return r;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGame(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
params: { conversationId: string; gameType: GameType; opponentUserId: string },
|
||||||
|
): Promise<GameRecord> {
|
||||||
|
const { data: session } = await client.auth.getUser();
|
||||||
|
if (!session.user) throw new Error('not authenticated');
|
||||||
|
const board = params.gameType === 'ttt' ? emptyTttBoard() : emptyC4Board();
|
||||||
|
const state = { kind: params.gameType, board };
|
||||||
|
const players: [string, string] = [session.user.id, params.opponentUserId];
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('conversation_games')
|
||||||
|
.insert({
|
||||||
|
conversation_id: params.conversationId,
|
||||||
|
game_type: params.gameType,
|
||||||
|
state,
|
||||||
|
players,
|
||||||
|
current_turn_user_id: session.user.id,
|
||||||
|
})
|
||||||
|
.select(
|
||||||
|
'id, conversation_id, game_type, state, players, current_turn_user_id, winner_user_id, created_at, finished_at',
|
||||||
|
)
|
||||||
|
.single();
|
||||||
|
if (error) throw error;
|
||||||
|
return mapRow(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGame(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
gameId: string,
|
||||||
|
): Promise<GameRecord | null> {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('conversation_games')
|
||||||
|
.select(
|
||||||
|
'id, conversation_id, game_type, state, players, current_turn_user_id, winner_user_id, created_at, finished_at',
|
||||||
|
)
|
||||||
|
.eq('id', gameId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) throw error;
|
||||||
|
return data ? mapRow(data) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function makeGameMove(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
params: { gameId: string; move: object },
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await client.rpc('game_make_move', {
|
||||||
|
p_game_id: params.gameId,
|
||||||
|
p_move: params.move,
|
||||||
|
});
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRow(row: {
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
game_type: string;
|
||||||
|
state: unknown;
|
||||||
|
players: unknown;
|
||||||
|
current_turn_user_id: string | null;
|
||||||
|
winner_user_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
finished_at: string | null;
|
||||||
|
}): GameRecord {
|
||||||
|
const rawState = (row.state ?? {}) as { kind?: string; board?: unknown };
|
||||||
|
const kind: GameType = rawState.kind === 'c4' ? 'c4' : 'ttt';
|
||||||
|
const rawBoard = Array.isArray(rawState.board) ? rawState.board : [];
|
||||||
|
const board: Cell[] = rawBoard.map((c) =>
|
||||||
|
typeof c === 'number' && (c === 0 || c === 1) ? (c as Cell) : null,
|
||||||
|
);
|
||||||
|
const players = Array.isArray(row.players) ? row.players : [];
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
conversationId: row.conversation_id,
|
||||||
|
gameType: row.game_type === 'c4' ? 'c4' : 'ttt',
|
||||||
|
state: { kind, board },
|
||||||
|
players: [String(players[0] ?? ''), String(players[1] ?? '')],
|
||||||
|
currentTurnUserId: row.current_turn_user_id,
|
||||||
|
winnerUserId: row.winner_user_id,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
finishedAt: row.finished_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ export * from './mentions';
|
|||||||
export * from './viewOnceAttachments';
|
export * from './viewOnceAttachments';
|
||||||
export * from './whiteboards';
|
export * from './whiteboards';
|
||||||
export * from './soundboards';
|
export * from './soundboards';
|
||||||
|
export * from './watchTogether';
|
||||||
|
export * from './games';
|
||||||
|
|
||||||
// ----- RPC wrappers ---------------------------------------------------------
|
// ----- RPC wrappers ---------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -226,6 +226,33 @@ export async function editEncryptedMessage(
|
|||||||
} as never)
|
} as never)
|
||||||
.eq('id', params.messageId);
|
.eq('id', params.messageId);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Recompute mentions: an edit can add or remove @-tokens. Drop old rows
|
||||||
|
// then re-insert from the new plaintext (best-effort, same as insertMessage).
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const { error: delErr } = await (params.client as any)
|
||||||
|
.from('message_mentions')
|
||||||
|
.delete()
|
||||||
|
.eq('message_id', params.messageId);
|
||||||
|
if (delErr) throw delErr;
|
||||||
|
|
||||||
|
const mentionUsernames = parseMentionUsernames(params.newPlaintext);
|
||||||
|
if (mentionUsernames.length > 0) {
|
||||||
|
const resolver = makeMentionResolver(params.client);
|
||||||
|
const resolved = await resolver.resolveUsernames(params.conversationId, mentionUsernames);
|
||||||
|
if (resolved.size > 0) {
|
||||||
|
await insertMentions(
|
||||||
|
params.client,
|
||||||
|
params.messageId,
|
||||||
|
params.conversationId,
|
||||||
|
[...resolved.values()],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('mention recompute on edit failed', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function softDeleteMessage(
|
export async function softDeleteMessage(
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export interface ConversationSummary {
|
|||||||
// ISO timestamp. null = not muted. Past timestamp = expired mute (treat as
|
// ISO timestamp. null = not muted. Past timestamp = expired mute (treat as
|
||||||
// not muted — the server row is kept for history until the next toggle).
|
// not muted — the server row is kept for history until the next toggle).
|
||||||
mutedUntil: string | null;
|
mutedUntil: string | null;
|
||||||
|
// When true the renderer suppresses non-mention notifications. Mentions
|
||||||
|
// still fire via the independent useMentionNotifications subscription on
|
||||||
|
// message_mentions, so this flag never silences @-alerts.
|
||||||
|
mentionsOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createWatchSession,
|
||||||
|
getWatchSession,
|
||||||
|
parseYouTubeUrl,
|
||||||
|
updateWatchSessionState,
|
||||||
|
} from './watchTogether';
|
||||||
|
|
||||||
|
describe('parseYouTubeUrl', () => {
|
||||||
|
it.each([
|
||||||
|
['https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
|
||||||
|
['https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42', 'dQw4w9WgXcQ'],
|
||||||
|
['https://youtu.be/dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
|
||||||
|
['https://youtu.be/dQw4w9WgXcQ?t=1', 'dQw4w9WgXcQ'],
|
||||||
|
['https://www.youtube.com/embed/dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
|
||||||
|
['https://www.youtube.com/shorts/dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
|
||||||
|
['https://m.youtube.com/watch?v=dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
|
||||||
|
['dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
|
||||||
|
])('extracts the id from %s', (url, expected) => {
|
||||||
|
expect(parseYouTubeUrl(url)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'',
|
||||||
|
' ',
|
||||||
|
'https://vimeo.com/123',
|
||||||
|
'not a url',
|
||||||
|
'short_id',
|
||||||
|
'https://www.youtube.com/playlist?list=PL123',
|
||||||
|
])('returns null for %s', (input) => {
|
||||||
|
expect(parseYouTubeUrl(input)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeClient(opts: {
|
||||||
|
user?: { id: string } | null;
|
||||||
|
insertReturn?: { data: unknown; error: unknown };
|
||||||
|
selectReturn?: { data: unknown; error: unknown };
|
||||||
|
updateReturn?: { error: unknown };
|
||||||
|
}): any {
|
||||||
|
const single = vi.fn().mockResolvedValue(opts.insertReturn ?? { data: {}, error: null });
|
||||||
|
const insertSelect = vi.fn().mockReturnValue({ single });
|
||||||
|
const insertChain = vi.fn().mockReturnValue({ select: insertSelect });
|
||||||
|
const maybeSingle = vi.fn().mockResolvedValue(opts.selectReturn ?? { data: null, error: null });
|
||||||
|
const eqSelect = vi.fn().mockReturnValue({ maybeSingle });
|
||||||
|
const selectChain = vi.fn().mockReturnValue({ eq: eqSelect });
|
||||||
|
const eqUpdate = vi.fn().mockResolvedValue(opts.updateReturn ?? { error: null });
|
||||||
|
const updateChain = vi.fn().mockReturnValue({ eq: eqUpdate });
|
||||||
|
const from = vi.fn().mockReturnValue({
|
||||||
|
insert: insertChain,
|
||||||
|
select: selectChain,
|
||||||
|
update: updateChain,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: opts.user ?? { id: 'u-1' } } }) },
|
||||||
|
from,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createWatchSession', () => {
|
||||||
|
it('inserts row + maps response to camelCase', async () => {
|
||||||
|
const client = makeClient({
|
||||||
|
insertReturn: {
|
||||||
|
data: {
|
||||||
|
id: 'w-1',
|
||||||
|
conversation_id: 'c-1',
|
||||||
|
owner_user_id: 'u-1',
|
||||||
|
video_id: 'dQw4w9WgXcQ',
|
||||||
|
started_at: '2026-05-16T00:00:00Z',
|
||||||
|
ended_at: null,
|
||||||
|
current_state: { playing: false, position_seconds: 0, updated_at_ms: 0 },
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const out = await createWatchSession(client, { conversationId: 'c-1', videoId: 'dQw4w9WgXcQ' });
|
||||||
|
expect(out.id).toBe('w-1');
|
||||||
|
expect(out.ownerUserId).toBe('u-1');
|
||||||
|
expect(out.videoId).toBe('dQw4w9WgXcQ');
|
||||||
|
expect(out.endedAt).toBeNull();
|
||||||
|
expect(out.currentState.playing).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getWatchSession', () => {
|
||||||
|
it('returns null when row not found', async () => {
|
||||||
|
const client = makeClient({ selectReturn: { data: null, error: null } });
|
||||||
|
const out = await getWatchSession(client, 'w-missing');
|
||||||
|
expect(out).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces missing current_state fields to safe defaults', async () => {
|
||||||
|
const client = makeClient({
|
||||||
|
selectReturn: {
|
||||||
|
data: {
|
||||||
|
id: 'w-1',
|
||||||
|
conversation_id: 'c-1',
|
||||||
|
owner_user_id: 'u-1',
|
||||||
|
video_id: 'dQw4w9WgXcQ',
|
||||||
|
started_at: '2026-05-16T00:00:00Z',
|
||||||
|
ended_at: null,
|
||||||
|
current_state: {},
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const out = await getWatchSession(client, 'w-1');
|
||||||
|
expect(out?.currentState).toEqual({ playing: false, positionSeconds: 0, updatedAtMs: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateWatchSessionState', () => {
|
||||||
|
it('does not throw on success', async () => {
|
||||||
|
const client = makeClient({ updateReturn: { error: null } });
|
||||||
|
await expect(
|
||||||
|
updateWatchSessionState(client, 'w-1', {
|
||||||
|
playing: true,
|
||||||
|
positionSeconds: 42.5,
|
||||||
|
updatedAtMs: Date.now(),
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { AppSupabaseClient } from '../supabase/client';
|
||||||
|
|
||||||
|
export interface WatchSessionState {
|
||||||
|
playing: boolean;
|
||||||
|
positionSeconds: number;
|
||||||
|
updatedAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchSession {
|
||||||
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
videoId: string;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string | null;
|
||||||
|
currentState: WatchSessionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseYouTubeUrl(input: string): string | null {
|
||||||
|
const s = input.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
if (/^[A-Za-z0-9_-]{11}$/.test(s)) return s;
|
||||||
|
const patterns = [
|
||||||
|
/[?&]v=([A-Za-z0-9_-]{11})/,
|
||||||
|
/youtu\.be\/([A-Za-z0-9_-]{11})/,
|
||||||
|
/youtube\.com\/embed\/([A-Za-z0-9_-]{11})/,
|
||||||
|
/youtube\.com\/shorts\/([A-Za-z0-9_-]{11})/,
|
||||||
|
];
|
||||||
|
for (const re of patterns) {
|
||||||
|
const m = re.exec(s);
|
||||||
|
if (m && m[1]) return m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWatchSession(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
params: { conversationId: string; videoId: string },
|
||||||
|
): Promise<WatchSession> {
|
||||||
|
const { data: session } = await client.auth.getUser();
|
||||||
|
if (!session.user) throw new Error('not authenticated');
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('conversation_watch_sessions')
|
||||||
|
.insert({
|
||||||
|
conversation_id: params.conversationId,
|
||||||
|
owner_user_id: session.user.id,
|
||||||
|
video_id: params.videoId,
|
||||||
|
})
|
||||||
|
.select('id, conversation_id, owner_user_id, video_id, started_at, ended_at, current_state')
|
||||||
|
.single();
|
||||||
|
if (error) throw error;
|
||||||
|
return mapRow(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWatchSession(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<WatchSession | null> {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('conversation_watch_sessions')
|
||||||
|
.select('id, conversation_id, owner_user_id, video_id, started_at, ended_at, current_state')
|
||||||
|
.eq('id', sessionId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) throw error;
|
||||||
|
return data ? mapRow(data) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWatchSessionState(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
sessionId: string,
|
||||||
|
state: WatchSessionState,
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await client
|
||||||
|
.from('conversation_watch_sessions')
|
||||||
|
.update({
|
||||||
|
current_state: {
|
||||||
|
playing: state.playing,
|
||||||
|
position_seconds: state.positionSeconds,
|
||||||
|
updated_at_ms: state.updatedAtMs,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.eq('id', sessionId);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function endWatchSession(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await client
|
||||||
|
.from('conversation_watch_sessions')
|
||||||
|
.update({ ended_at: new Date().toISOString() })
|
||||||
|
.eq('id', sessionId);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRow(row: {
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
owner_user_id: string;
|
||||||
|
video_id: string;
|
||||||
|
started_at: string;
|
||||||
|
ended_at: string | null;
|
||||||
|
current_state: unknown;
|
||||||
|
}): WatchSession {
|
||||||
|
const raw = (row.current_state ?? {}) as Partial<{
|
||||||
|
playing: boolean;
|
||||||
|
position_seconds: number;
|
||||||
|
updated_at_ms: number;
|
||||||
|
}>;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
conversationId: row.conversation_id,
|
||||||
|
ownerUserId: row.owner_user_id,
|
||||||
|
videoId: row.video_id,
|
||||||
|
startedAt: row.started_at,
|
||||||
|
endedAt: row.ended_at,
|
||||||
|
currentState: {
|
||||||
|
playing: typeof raw.playing === 'boolean' ? raw.playing : false,
|
||||||
|
positionSeconds: typeof raw.position_seconds === 'number' ? raw.position_seconds : 0,
|
||||||
|
updatedAtMs: typeof raw.updated_at_ms === 'number' ? raw.updated_at_ms : 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Generated
+16
@@ -68,6 +68,9 @@ importers:
|
|||||||
better-sqlite3:
|
better-sqlite3:
|
||||||
specifier: ^11.3.0
|
specifier: ^11.3.0
|
||||||
version: 11.10.0
|
version: 11.10.0
|
||||||
|
canvas-confetti:
|
||||||
|
specifier: ^1.9.4
|
||||||
|
version: 1.9.4
|
||||||
electron-updater:
|
electron-updater:
|
||||||
specifier: ^6.3.0
|
specifier: ^6.3.0
|
||||||
version: 6.8.3
|
version: 6.8.3
|
||||||
@@ -102,6 +105,9 @@ importers:
|
|||||||
'@types/better-sqlite3':
|
'@types/better-sqlite3':
|
||||||
specifier: ^7.6.0
|
specifier: ^7.6.0
|
||||||
version: 7.6.13
|
version: 7.6.13
|
||||||
|
'@types/canvas-confetti':
|
||||||
|
specifier: ^1.9.0
|
||||||
|
version: 1.9.0
|
||||||
'@types/libsodium-wrappers':
|
'@types/libsodium-wrappers':
|
||||||
specifier: ^0.7.14
|
specifier: ^0.7.14
|
||||||
version: 0.7.14
|
version: 0.7.14
|
||||||
@@ -1967,6 +1973,9 @@ packages:
|
|||||||
'@types/cacheable-request@6.0.3':
|
'@types/cacheable-request@6.0.3':
|
||||||
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
|
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
|
||||||
|
|
||||||
|
'@types/canvas-confetti@1.9.0':
|
||||||
|
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
|
||||||
|
|
||||||
'@types/debug@4.1.13':
|
'@types/debug@4.1.13':
|
||||||
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
||||||
|
|
||||||
@@ -2670,6 +2679,9 @@ packages:
|
|||||||
caniuse-lite@1.0.30001788:
|
caniuse-lite@1.0.30001788:
|
||||||
resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==}
|
resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==}
|
||||||
|
|
||||||
|
canvas-confetti@1.9.4:
|
||||||
|
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
|
||||||
|
|
||||||
chai@5.3.3:
|
chai@5.3.3:
|
||||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -8703,6 +8715,8 @@ snapshots:
|
|||||||
'@types/node': 22.19.17
|
'@types/node': 22.19.17
|
||||||
'@types/responselike': 1.0.3
|
'@types/responselike': 1.0.3
|
||||||
|
|
||||||
|
'@types/canvas-confetti@1.9.0': {}
|
||||||
|
|
||||||
'@types/debug@4.1.13':
|
'@types/debug@4.1.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/ms': 2.1.0
|
'@types/ms': 2.1.0
|
||||||
@@ -9634,6 +9648,8 @@ snapshots:
|
|||||||
|
|
||||||
caniuse-lite@1.0.30001788: {}
|
caniuse-lite@1.0.30001788: {}
|
||||||
|
|
||||||
|
canvas-confetti@1.9.4: {}
|
||||||
|
|
||||||
chai@5.3.3:
|
chai@5.3.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
assertion-error: 2.0.1
|
assertion-error: 2.0.1
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
-- Phase 5A: per-conversation synchronized YouTube playback.
|
||||||
|
--
|
||||||
|
-- conversation_watch_sessions: one row per Watch-Together session. The bubble
|
||||||
|
-- in the chat is a normal `messages` row whose plaintext payload is
|
||||||
|
-- `{v:1, type:'watch_together', session_id:<id>}`. The owner's player drives
|
||||||
|
-- current_state (jsonb {playing, position_seconds, updated_at_ms}); other
|
||||||
|
-- joiners reconcile via realtime postgres_changes UPDATE when local drift
|
||||||
|
-- exceeds 2 seconds.
|
||||||
|
|
||||||
|
create table if not exists public.conversation_watch_sessions (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
conversation_id uuid not null references public.conversations(id) on delete cascade,
|
||||||
|
owner_user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
|
video_id text not null,
|
||||||
|
started_at timestamptz not null default now(),
|
||||||
|
ended_at timestamptz null,
|
||||||
|
current_state jsonb not null default '{"playing":false,"position_seconds":0,"updated_at_ms":0}'::jsonb,
|
||||||
|
constraint conversation_watch_sessions_video_id_len check (length(video_id) between 1 and 64)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists conversation_watch_sessions_conv_idx
|
||||||
|
on public.conversation_watch_sessions(conversation_id, started_at desc);
|
||||||
|
|
||||||
|
alter table public.conversation_watch_sessions enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists conversation_watch_sessions_select on public.conversation_watch_sessions;
|
||||||
|
drop policy if exists conversation_watch_sessions_insert on public.conversation_watch_sessions;
|
||||||
|
drop policy if exists conversation_watch_sessions_update on public.conversation_watch_sessions;
|
||||||
|
|
||||||
|
create policy conversation_watch_sessions_select
|
||||||
|
on public.conversation_watch_sessions
|
||||||
|
for select
|
||||||
|
using (public.is_conversation_member(conversation_id));
|
||||||
|
|
||||||
|
create policy conversation_watch_sessions_insert
|
||||||
|
on public.conversation_watch_sessions
|
||||||
|
for insert
|
||||||
|
with check (
|
||||||
|
public.is_conversation_member(conversation_id)
|
||||||
|
and owner_user_id = auth.uid()
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy conversation_watch_sessions_update
|
||||||
|
on public.conversation_watch_sessions
|
||||||
|
for update
|
||||||
|
using (owner_user_id = auth.uid())
|
||||||
|
with check (owner_user_id = auth.uid());
|
||||||
|
|
||||||
|
alter table public.conversation_watch_sessions replica identity full;
|
||||||
|
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from pg_publication_tables
|
||||||
|
where pubname = 'supabase_realtime'
|
||||||
|
and schemaname = 'public'
|
||||||
|
and tablename = 'conversation_watch_sessions'
|
||||||
|
) then
|
||||||
|
execute 'alter publication supabase_realtime add table public.conversation_watch_sessions';
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
-- Phase 5B: per-conversation mini-games (Tic-Tac-Toe + Vier-Gewinnt /
|
||||||
|
-- Connect Four).
|
||||||
|
--
|
||||||
|
-- conversation_games: one row per game. The bubble in the chat is a
|
||||||
|
-- regular `messages` row whose plaintext payload is
|
||||||
|
-- `{v:1, type:'game', game_id:<id>, game_type:<'ttt'|'c4'>}`.
|
||||||
|
--
|
||||||
|
-- state JSON shape:
|
||||||
|
-- ttt: { kind: 'ttt', board: [null|0|1 × 9] } (row-major 3×3)
|
||||||
|
-- c4: { kind: 'c4', board: [null|0|1 × 42] } (row-major 6 rows × 7 cols)
|
||||||
|
--
|
||||||
|
-- players JSON: [user_id_a, user_id_b] — indices 0 and 1 map to board cells.
|
||||||
|
-- current_turn_user_id alternates; null once finished.
|
||||||
|
-- winner_user_id null on draw or unfinished.
|
||||||
|
|
||||||
|
create table if not exists public.conversation_games (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
conversation_id uuid not null references public.conversations(id) on delete cascade,
|
||||||
|
game_type text not null check (game_type in ('ttt', 'c4')),
|
||||||
|
state jsonb not null,
|
||||||
|
players jsonb not null,
|
||||||
|
current_turn_user_id uuid null references auth.users(id) on delete set null,
|
||||||
|
winner_user_id uuid null references auth.users(id) on delete set null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
finished_at timestamptz null
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists conversation_games_conv_idx
|
||||||
|
on public.conversation_games(conversation_id, created_at desc);
|
||||||
|
|
||||||
|
alter table public.conversation_games enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists conversation_games_select on public.conversation_games;
|
||||||
|
drop policy if exists conversation_games_insert on public.conversation_games;
|
||||||
|
|
||||||
|
create policy conversation_games_select
|
||||||
|
on public.conversation_games
|
||||||
|
for select
|
||||||
|
using (public.is_conversation_member(conversation_id));
|
||||||
|
|
||||||
|
create policy conversation_games_insert
|
||||||
|
on public.conversation_games
|
||||||
|
for insert
|
||||||
|
with check (
|
||||||
|
public.is_conversation_member(conversation_id)
|
||||||
|
and current_turn_user_id = auth.uid()
|
||||||
|
and (players->>0)::uuid = auth.uid()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.conversation_games replica identity full;
|
||||||
|
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from pg_publication_tables
|
||||||
|
where pubname = 'supabase_realtime'
|
||||||
|
and schemaname = 'public'
|
||||||
|
and tablename = 'conversation_games'
|
||||||
|
) then
|
||||||
|
execute 'alter publication supabase_realtime add table public.conversation_games';
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
-- Pure winner-check + board-full helpers.
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
create or replace function public.ttt_check_winner(board jsonb)
|
||||||
|
returns int
|
||||||
|
language plpgsql
|
||||||
|
immutable
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
lines int[][] := array[
|
||||||
|
array[0,1,2], array[3,4,5], array[6,7,8],
|
||||||
|
array[0,3,6], array[1,4,7], array[2,5,8],
|
||||||
|
array[0,4,8], array[2,4,6]
|
||||||
|
];
|
||||||
|
ln int[];
|
||||||
|
a jsonb;
|
||||||
|
b jsonb;
|
||||||
|
c jsonb;
|
||||||
|
begin
|
||||||
|
foreach ln slice 1 in array lines loop
|
||||||
|
a := board->ln[1];
|
||||||
|
b := board->ln[2];
|
||||||
|
c := board->ln[3];
|
||||||
|
if jsonb_typeof(a) = 'number' and a = b and b = c then
|
||||||
|
return (a)::int;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.c4_check_winner(board jsonb)
|
||||||
|
returns int
|
||||||
|
language plpgsql
|
||||||
|
immutable
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
rows constant int := 6;
|
||||||
|
cols constant int := 7;
|
||||||
|
r int;
|
||||||
|
c int;
|
||||||
|
i int;
|
||||||
|
d_r int;
|
||||||
|
d_c int;
|
||||||
|
directions int[][] := array[
|
||||||
|
array[0, 1],
|
||||||
|
array[1, 0],
|
||||||
|
array[1, 1],
|
||||||
|
array[1, -1]
|
||||||
|
];
|
||||||
|
dir int[];
|
||||||
|
base jsonb;
|
||||||
|
cell jsonb;
|
||||||
|
ok boolean;
|
||||||
|
begin
|
||||||
|
for r in 0..rows-1 loop
|
||||||
|
for c in 0..cols-1 loop
|
||||||
|
base := board->(r * cols + c);
|
||||||
|
if jsonb_typeof(base) <> 'number' then continue; end if;
|
||||||
|
foreach dir slice 1 in array directions loop
|
||||||
|
d_r := dir[1];
|
||||||
|
d_c := dir[2];
|
||||||
|
if r + d_r * 3 < 0 or r + d_r * 3 >= rows then continue; end if;
|
||||||
|
if c + d_c * 3 < 0 or c + d_c * 3 >= cols then continue; end if;
|
||||||
|
ok := true;
|
||||||
|
for i in 1..3 loop
|
||||||
|
cell := board->((r + d_r * i) * cols + (c + d_c * i));
|
||||||
|
if cell is null or cell <> base then ok := false; exit; end if;
|
||||||
|
end loop;
|
||||||
|
if ok then return (base)::int; end if;
|
||||||
|
end loop;
|
||||||
|
end loop;
|
||||||
|
end loop;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.board_is_full(board jsonb)
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
as $$
|
||||||
|
select not exists (
|
||||||
|
select 1 from jsonb_array_elements(board) elt where jsonb_typeof(elt) is distinct from 'number'
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
-- The state-machine RPC.
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
create or replace function public.game_make_move(p_game_id uuid, p_move jsonb)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_game record;
|
||||||
|
v_state jsonb;
|
||||||
|
v_board jsonb;
|
||||||
|
v_kind text;
|
||||||
|
v_player_idx int;
|
||||||
|
v_other_user uuid;
|
||||||
|
v_winner_idx int;
|
||||||
|
v_finished boolean;
|
||||||
|
v_cell int;
|
||||||
|
v_col int;
|
||||||
|
v_row int;
|
||||||
|
v_cols constant int := 7;
|
||||||
|
v_rows constant int := 6;
|
||||||
|
v_target_row int;
|
||||||
|
begin
|
||||||
|
if auth.uid() is null then
|
||||||
|
raise exception 'not_authenticated' using errcode = '28000';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select * into v_game from public.conversation_games where id = p_game_id for update;
|
||||||
|
if not found then
|
||||||
|
raise exception 'game_not_found' using errcode = 'P0002';
|
||||||
|
end if;
|
||||||
|
if v_game.finished_at is not null then
|
||||||
|
raise exception 'game_finished' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
if v_game.current_turn_user_id is null
|
||||||
|
or v_game.current_turn_user_id <> auth.uid() then
|
||||||
|
raise exception 'not_your_turn' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if (v_game.players->>0)::uuid = auth.uid() then
|
||||||
|
v_player_idx := 0;
|
||||||
|
v_other_user := (v_game.players->>1)::uuid;
|
||||||
|
elsif (v_game.players->>1)::uuid = auth.uid() then
|
||||||
|
v_player_idx := 1;
|
||||||
|
v_other_user := (v_game.players->>0)::uuid;
|
||||||
|
else
|
||||||
|
raise exception 'not_a_player' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_state := v_game.state;
|
||||||
|
v_kind := v_state->>'kind';
|
||||||
|
v_board := v_state->'board';
|
||||||
|
|
||||||
|
if v_kind = 'ttt' then
|
||||||
|
if (p_move->'cell') is null or jsonb_typeof(p_move->'cell') <> 'number' then
|
||||||
|
raise exception 'bad_move' using errcode = '22023';
|
||||||
|
end if;
|
||||||
|
v_cell := (p_move->>'cell')::int;
|
||||||
|
if v_cell < 0 or v_cell > 8 then
|
||||||
|
raise exception 'bad_move' using errcode = '22023';
|
||||||
|
end if;
|
||||||
|
if jsonb_typeof(v_board->v_cell) = 'number' then
|
||||||
|
raise exception 'cell_taken' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
v_board := jsonb_set(v_board, array[v_cell::text], to_jsonb(v_player_idx));
|
||||||
|
v_winner_idx := public.ttt_check_winner(v_board);
|
||||||
|
|
||||||
|
elsif v_kind = 'c4' then
|
||||||
|
if (p_move->'column') is null or jsonb_typeof(p_move->'column') <> 'number' then
|
||||||
|
raise exception 'bad_move' using errcode = '22023';
|
||||||
|
end if;
|
||||||
|
v_col := (p_move->>'column')::int;
|
||||||
|
if v_col < 0 or v_col >= v_cols then
|
||||||
|
raise exception 'bad_move' using errcode = '22023';
|
||||||
|
end if;
|
||||||
|
v_target_row := -1;
|
||||||
|
for v_row in reverse v_rows - 1 .. 0 loop
|
||||||
|
if jsonb_typeof(v_board->(v_row * v_cols + v_col)) is distinct from 'number' then
|
||||||
|
v_target_row := v_row;
|
||||||
|
exit;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
if v_target_row < 0 then
|
||||||
|
raise exception 'column_full' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
v_board := jsonb_set(v_board, array[(v_target_row * v_cols + v_col)::text], to_jsonb(v_player_idx));
|
||||||
|
v_winner_idx := public.c4_check_winner(v_board);
|
||||||
|
|
||||||
|
else
|
||||||
|
raise exception 'unknown_game_kind' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_state := jsonb_set(v_state, '{board}', v_board);
|
||||||
|
v_finished := v_winner_idx is not null or public.board_is_full(v_board);
|
||||||
|
|
||||||
|
update public.conversation_games
|
||||||
|
set state = v_state,
|
||||||
|
current_turn_user_id = case when v_finished then null else v_other_user end,
|
||||||
|
winner_user_id = case when v_winner_idx is not null then (v_game.players->>v_winner_idx)::uuid else null end,
|
||||||
|
finished_at = case when v_finished then now() else null end
|
||||||
|
where id = p_game_id;
|
||||||
|
|
||||||
|
return v_state;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.game_make_move(uuid, jsonb) from public;
|
||||||
|
grant execute on function public.game_make_move(uuid, jsonb) to authenticated;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Phase 5C: per-conversation "notify only on @mentions" toggle.
|
||||||
|
-- Lives alongside the existing muted_until column on conversation_members.
|
||||||
|
-- When true: the renderer's incoming-message notification gate suppresses
|
||||||
|
-- the alert unless the message contains an @-mention of the local user.
|
||||||
|
-- Mentions always fire regardless (override-by-design via the independent
|
||||||
|
-- useMentionNotifications subscription on message_mentions).
|
||||||
|
|
||||||
|
alter table public.conversation_members
|
||||||
|
add column if not exists mentions_only boolean not null default false;
|
||||||
Reference in New Issue
Block a user