Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 888ed1b217 | |||
| a974e5b8ab | |||
| 7ebc5f6c9d | |||
| b8b451ef4f | |||
| d7c0c3d0a2 | |||
| 70ce824120 | |||
| d10840e0b2 | |||
| cd59ee30d6 | |||
| e56e22631b | |||
| dbf90504b4 | |||
| 256a613134 | |||
| e1423dba32 | |||
| 7730828403 | |||
| ecbd11e369 | |||
| 7d5f3b37cd | |||
| 7ce6b1c1d4 | |||
| 4399d39f08 | |||
| ad239ec549 | |||
| d1193142ae | |||
| 997252c4cb | |||
| 890d5dc2b7 | |||
| 759113b9ce | |||
| f981904e7f | |||
| 1fe196b839 | |||
| 1b4cf63e07 | |||
| 18197a9f95 | |||
| 383245ec90 | |||
| 5e59ee22f6 | |||
| be6f5b9c6f | |||
| 2243c646ac | |||
| 3df7cc01ea | |||
| f95858c703 | |||
| d29e2c1174 | |||
| 11a9c8b173 | |||
| d623a59c87 | |||
| 2a2e334f51 | |||
| 9228cc635c | |||
| 7056bfdd1c |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chat-app/desktop",
|
"name": "@chat-app/desktop",
|
||||||
"version": "0.19.0",
|
"version": "0.19.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { XIcon } from './icons';
|
||||||
|
|
||||||
|
export type AnnotatorTool = 'pen' | 'arrow' | 'rect' | 'circle' | 'text' | 'highlighter';
|
||||||
|
export type AnnotatorColor = '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7' | '#ffffff';
|
||||||
|
export type AnnotatorWidth = 2 | 4 | 8;
|
||||||
|
|
||||||
|
export interface AnnotatorOp {
|
||||||
|
tool: AnnotatorTool;
|
||||||
|
color: AnnotatorColor;
|
||||||
|
width: AnnotatorWidth;
|
||||||
|
points?: Array<{ x: number; y: number }>;
|
||||||
|
from?: { x: number; y: number };
|
||||||
|
to?: { x: number; y: number };
|
||||||
|
text?: string;
|
||||||
|
at?: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
file: File;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSave: (next: File) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageAnnotator({ file, onCancel, onSave }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
|
const [ops, setOps] = useState<AnnotatorOp[]>([]);
|
||||||
|
const [redoStack, setRedoStack] = useState<AnnotatorOp[]>([]);
|
||||||
|
// tool/color/width are read by Task 2 (drawing engine) and Task 3 (toolbar).
|
||||||
|
// Defaults shown here are intentional so T2's pointer handlers work
|
||||||
|
// immediately with sensible behavior before T3's UI is wired.
|
||||||
|
const [tool, setTool] = useState<AnnotatorTool>('pen');
|
||||||
|
const [color, setColor] = useState<AnnotatorColor>('#ef4444');
|
||||||
|
const [width, setWidth] = useState<AnnotatorWidth>(4);
|
||||||
|
|
||||||
|
const draftRef = useRef<AnnotatorOp | null>(null);
|
||||||
|
const [draftTick, setDraftTick] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
imageRef.current = img;
|
||||||
|
setImageLoaded(true);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
console.error('ImageAnnotator: failed to decode source image');
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
return () => URL.revokeObjectURL(url);
|
||||||
|
}, [file, onCancel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageLoaded) return;
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
const img = imageRef.current;
|
||||||
|
if (!cv || !img) return;
|
||||||
|
cv.width = img.naturalWidth;
|
||||||
|
cv.height = img.naturalHeight;
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
for (const op of ops) {
|
||||||
|
renderOp(ctx, op);
|
||||||
|
}
|
||||||
|
if (draftRef.current) {
|
||||||
|
renderOp(ctx, draftRef.current);
|
||||||
|
}
|
||||||
|
}, [imageLoaded, ops, draftTick]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onCancel();
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleUndo();
|
||||||
|
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleRedo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUndo = () => {
|
||||||
|
setOps((cur) => {
|
||||||
|
if (cur.length === 0) return cur;
|
||||||
|
const next = cur.slice(0, -1);
|
||||||
|
setRedoStack((r) => [...r, cur[cur.length - 1]!]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRedo = () => {
|
||||||
|
setRedoStack((r) => {
|
||||||
|
if (r.length === 0) return r;
|
||||||
|
const top = r[r.length - 1]!;
|
||||||
|
setOps((cur) => [...cur, top]);
|
||||||
|
return r.slice(0, -1);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setOps([]);
|
||||||
|
setRedoStack([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
if (!cv) return;
|
||||||
|
cv.toBlob((blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
console.error('ImageAnnotator: toBlob returned null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const baseName = file.name.replace(/\.[^.]+$/, '');
|
||||||
|
const next = new File([blob], baseName + '-annotated.png', {
|
||||||
|
type: 'image/png',
|
||||||
|
lastModified: Date.now(),
|
||||||
|
});
|
||||||
|
onSave(next);
|
||||||
|
}, 'image/png');
|
||||||
|
};
|
||||||
|
|
||||||
|
function canvasPoint(e: React.PointerEvent<HTMLCanvasElement>): { x: number; y: number } {
|
||||||
|
const cv = canvasRef.current!;
|
||||||
|
const rect = cv.getBoundingClientRect();
|
||||||
|
const scaleX = cv.width / rect.width;
|
||||||
|
const scaleY = cv.height / rect.height;
|
||||||
|
return {
|
||||||
|
x: (e.clientX - rect.left) * scaleX,
|
||||||
|
y: (e.clientY - rect.top) * scaleY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!imageLoaded) return;
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
if (!cv) return;
|
||||||
|
cv.setPointerCapture(e.pointerId);
|
||||||
|
const p = canvasPoint(e);
|
||||||
|
|
||||||
|
if (tool === 'text') {
|
||||||
|
const value = window.prompt(
|
||||||
|
t('app:annotator.text_prompt', { defaultValue: 'Text eingeben:' }),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
if (value !== null && value.trim().length > 0) {
|
||||||
|
setOps((cur) => [...cur, { tool: 'text', color, width, text: value, at: p }]);
|
||||||
|
setRedoStack([]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === 'pen' || tool === 'highlighter') {
|
||||||
|
draftRef.current = { tool, color, width, points: [p] };
|
||||||
|
} else {
|
||||||
|
draftRef.current = { tool, color, width, from: p, to: p };
|
||||||
|
}
|
||||||
|
setDraftTick((n) => n + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!draftRef.current) return;
|
||||||
|
const p = canvasPoint(e);
|
||||||
|
const cur = draftRef.current;
|
||||||
|
if (cur.tool === 'pen' || cur.tool === 'highlighter') {
|
||||||
|
cur.points = [...(cur.points ?? []), p];
|
||||||
|
} else {
|
||||||
|
cur.to = p;
|
||||||
|
}
|
||||||
|
setDraftTick((n) => n + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId);
|
||||||
|
const cur = draftRef.current;
|
||||||
|
draftRef.current = null;
|
||||||
|
if (!cur) return;
|
||||||
|
const hasContent =
|
||||||
|
(cur.tool === 'pen' || cur.tool === 'highlighter')
|
||||||
|
? (cur.points?.length ?? 0) >= 2
|
||||||
|
: !!(cur.from && cur.to && (cur.from.x !== cur.to.x || cur.from.y !== cur.to.y));
|
||||||
|
if (hasContent) {
|
||||||
|
setOps((p) => [...p, cur]);
|
||||||
|
setRedoStack([]);
|
||||||
|
}
|
||||||
|
setDraftTick((n) => n + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
|
||||||
|
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">
|
||||||
|
{t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
aria-label={t('app:annotator.cancel', { defaultValue: 'Abbrechen' })}
|
||||||
|
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 p-6">
|
||||||
|
{imageLoaded ? (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerLeave={handlePointerUp}
|
||||||
|
className="max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-black shadow-2xl"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-fg-muted">
|
||||||
|
{t('app:annotator.loading', { defaultValue: 'Bild wird geladen…' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="flex shrink-0 flex-wrap items-center gap-4 border-t border-line/40 bg-surface-2 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{(['pen', 'highlighter', 'arrow', 'rect', 'circle', 'text'] as AnnotatorTool[]).map((id) => {
|
||||||
|
const label = t('app:annotator.tool.' + id, {
|
||||||
|
defaultValue:
|
||||||
|
id === 'pen' ? 'Stift'
|
||||||
|
: id === 'highlighter' ? 'Marker'
|
||||||
|
: id === 'arrow' ? 'Pfeil'
|
||||||
|
: id === 'rect' ? 'Rechteck'
|
||||||
|
: id === 'circle' ? 'Kreis'
|
||||||
|
: 'Text',
|
||||||
|
});
|
||||||
|
const active = tool === id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTool(id)}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={label}
|
||||||
|
className={
|
||||||
|
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-xs font-semibold transition ' +
|
||||||
|
(active
|
||||||
|
? 'bg-accent/20 text-accent ring-2 ring-accent/40'
|
||||||
|
: 'bg-surface-3 text-fg-muted hover:bg-surface hover:text-fg')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{toolGlyph(id)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{(['#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7', '#ffffff'] as AnnotatorColor[]).map((c) => {
|
||||||
|
const active = color === c;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
aria-pressed={active}
|
||||||
|
aria-label={c}
|
||||||
|
className={
|
||||||
|
'h-6 w-6 cursor-pointer rounded-full border-2 transition ' +
|
||||||
|
(active ? 'border-fg scale-110' : 'border-line/40 hover:scale-105')
|
||||||
|
}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{([2, 4, 8] as AnnotatorWidth[]).map((w) => {
|
||||||
|
const active = width === w;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={w}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWidth(w)}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={w + 'px'}
|
||||||
|
className={
|
||||||
|
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md transition ' +
|
||||||
|
(active
|
||||||
|
? 'bg-accent/20 ring-2 ring-accent/40'
|
||||||
|
: 'bg-surface-3 hover:bg-surface')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-full bg-fg"
|
||||||
|
style={{ width: w + 2 + 'px', height: w + 2 + 'px' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleUndo}
|
||||||
|
disabled={ops.length === 0}
|
||||||
|
title={t('app:annotator.undo', { defaultValue: 'Rückgängig (Ctrl+Z)' })}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
↶
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRedo}
|
||||||
|
disabled={redoStack.length === 0}
|
||||||
|
title={t('app:annotator.redo', { defaultValue: 'Wiederholen (Ctrl+Y)' })}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
↷
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={ops.length === 0}
|
||||||
|
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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('app:annotator.reset', { defaultValue: 'Zurücksetzen' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!imageLoaded}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{t('app:annotator.save', { defaultValue: 'Speichern' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolGlyph(t: AnnotatorTool): string {
|
||||||
|
switch (t) {
|
||||||
|
case 'pen': return '✎';
|
||||||
|
case 'highlighter': return '🖍';
|
||||||
|
case 'arrow': return '↗';
|
||||||
|
case 'rect': return '▭';
|
||||||
|
case 'circle': return '◯';
|
||||||
|
case 'text': return 'T';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOp(ctx: CanvasRenderingContext2D, op: AnnotatorOp): void {
|
||||||
|
ctx.save();
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.strokeStyle = op.color;
|
||||||
|
ctx.fillStyle = op.color;
|
||||||
|
ctx.lineWidth = op.width;
|
||||||
|
|
||||||
|
switch (op.tool) {
|
||||||
|
case 'pen': {
|
||||||
|
const pts = op.points;
|
||||||
|
if (!pts || pts.length < 1) break;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
ctx.lineTo(pts[i]!.x, pts[i]!.y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'highlighter': {
|
||||||
|
const pts = op.points;
|
||||||
|
if (!pts || pts.length < 1) break;
|
||||||
|
ctx.globalAlpha = 0.35;
|
||||||
|
ctx.lineWidth = op.width * 4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
ctx.lineTo(pts[i]!.x, pts[i]!.y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'rect': {
|
||||||
|
const { from, to } = op;
|
||||||
|
if (!from || !to) break;
|
||||||
|
ctx.strokeRect(
|
||||||
|
Math.min(from.x, to.x),
|
||||||
|
Math.min(from.y, to.y),
|
||||||
|
Math.abs(to.x - from.x),
|
||||||
|
Math.abs(to.y - from.y),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'circle': {
|
||||||
|
const { from, to } = op;
|
||||||
|
if (!from || !to) break;
|
||||||
|
const cx = (from.x + to.x) / 2;
|
||||||
|
const cy = (from.y + to.y) / 2;
|
||||||
|
const rx = Math.abs(to.x - from.x) / 2;
|
||||||
|
const ry = Math.abs(to.y - from.y) / 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'arrow': {
|
||||||
|
const { from, to } = op;
|
||||||
|
if (!from || !to) break;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(from.x, from.y);
|
||||||
|
ctx.lineTo(to.x, to.y);
|
||||||
|
ctx.stroke();
|
||||||
|
const angle = Math.atan2(to.y - from.y, to.x - from.x);
|
||||||
|
const head = Math.max(12, op.width * 3);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(to.x, to.y);
|
||||||
|
ctx.lineTo(
|
||||||
|
to.x - head * Math.cos(angle - Math.PI / 6),
|
||||||
|
to.y - head * Math.sin(angle - Math.PI / 6),
|
||||||
|
);
|
||||||
|
ctx.moveTo(to.x, to.y);
|
||||||
|
ctx.lineTo(
|
||||||
|
to.x - head * Math.cos(angle + Math.PI / 6),
|
||||||
|
to.y - head * Math.sin(angle + Math.PI / 6),
|
||||||
|
);
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'text': {
|
||||||
|
const { at, text } = op;
|
||||||
|
if (!at || !text) break;
|
||||||
|
const fontSize = Math.max(14, op.width * 6);
|
||||||
|
ctx.font = '600 ' + fontSize + 'px Inter, system-ui, sans-serif';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.fillText(text, at.x, at.y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
@@ -442,6 +442,84 @@ 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' ? (
|
||||||
|
<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="13" rx="2" />
|
||||||
|
<path d="M8 21h8M12 17v4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-semibold text-fg">Whiteboard</div>
|
||||||
|
<div className="text-xs text-fg-muted">Gemeinsames Zeichnen</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('chatapp:open-whiteboard', { detail: { id: parsed.whiteboardId } }),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={!parsed.whiteboardId}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Öffnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : parsed.kind === 'poll' ? (
|
) : parsed.kind === 'poll' ? (
|
||||||
<PollCard
|
<PollCard
|
||||||
question={parsed.question}
|
question={parsed.question}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { deleteSound as deleteRemoteSound } from '@chat-app/shared/chat';
|
||||||
import { getPttSettings } from '../lib/pttSettings';
|
import { getPttSettings } from '../lib/pttSettings';
|
||||||
import { codeToShortcut } from '../lib/globalShortcut';
|
import { codeToShortcut } from '../lib/globalShortcut';
|
||||||
import { invalidate as invalidateSoundCache, preload } from '../lib/soundboardPlayback';
|
import { invalidate as invalidateSoundCache, preload } from '../lib/soundboardPlayback';
|
||||||
@@ -15,6 +16,8 @@ import {
|
|||||||
subscribeSoundboardChanges,
|
subscribeSoundboardChanges,
|
||||||
updateSound,
|
updateSound,
|
||||||
} from '../lib/soundboardStorage';
|
} from '../lib/soundboardStorage';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { useSoundboardSync, type SyncBadge } from '../hooks/useSoundboardSync';
|
||||||
import { Modal } from './Modal';
|
import { Modal } from './Modal';
|
||||||
import {
|
import {
|
||||||
AlertIcon,
|
AlertIcon,
|
||||||
@@ -46,6 +49,7 @@ export function SoundboardManagerDialog({ open, onClose }: Props) {
|
|||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||||
|
const { badges } = useSoundboardSync();
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -167,6 +171,11 @@ export function SoundboardManagerDialog({ open, onClose }: Props) {
|
|||||||
}
|
}
|
||||||
setBusyId(id);
|
setBusyId(id);
|
||||||
try {
|
try {
|
||||||
|
try {
|
||||||
|
await deleteRemoteSound(supabase, id);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('remote sound delete failed (local delete proceeds)', err);
|
||||||
|
}
|
||||||
await deleteSound(id);
|
await deleteSound(id);
|
||||||
invalidateSoundCache(id);
|
invalidateSoundCache(id);
|
||||||
if (previewingId === id) stopPreview();
|
if (previewingId === id) stopPreview();
|
||||||
@@ -301,6 +310,7 @@ export function SoundboardManagerDialog({ open, onClose }: Props) {
|
|||||||
entriesTotal={entries}
|
entriesTotal={entries}
|
||||||
busyId={busyId}
|
busyId={busyId}
|
||||||
previewingId={previewingId}
|
previewingId={previewingId}
|
||||||
|
badges={badges}
|
||||||
onPatch={handlePatch}
|
onPatch={handlePatch}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onPreview={handlePreview}
|
onPreview={handlePreview}
|
||||||
@@ -323,6 +333,7 @@ interface GroupProps {
|
|||||||
entriesTotal: SoundboardEntry[];
|
entriesTotal: SoundboardEntry[];
|
||||||
busyId: string | null;
|
busyId: string | null;
|
||||||
previewingId: string | null;
|
previewingId: string | null;
|
||||||
|
badges: Map<string, SyncBadge>;
|
||||||
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
||||||
onDelete: (id: string) => Promise<void>;
|
onDelete: (id: string) => Promise<void>;
|
||||||
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
||||||
@@ -335,6 +346,7 @@ function SoundboardCategoryGroup({
|
|||||||
entriesTotal,
|
entriesTotal,
|
||||||
busyId,
|
busyId,
|
||||||
previewingId,
|
previewingId,
|
||||||
|
badges,
|
||||||
onPatch,
|
onPatch,
|
||||||
onDelete,
|
onDelete,
|
||||||
onPreview,
|
onPreview,
|
||||||
@@ -370,6 +382,7 @@ function SoundboardCategoryGroup({
|
|||||||
isLast={idx === entries.length - 1}
|
isLast={idx === entries.length - 1}
|
||||||
busy={busyId === entry.id}
|
busy={busyId === entry.id}
|
||||||
previewing={previewingId === entry.id}
|
previewing={previewingId === entry.id}
|
||||||
|
badge={badges.get(entry.id)}
|
||||||
onPatch={onPatch}
|
onPatch={onPatch}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onPreview={onPreview}
|
onPreview={onPreview}
|
||||||
@@ -392,6 +405,7 @@ interface RowProps {
|
|||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
previewing: boolean;
|
previewing: boolean;
|
||||||
|
badge: SyncBadge | undefined;
|
||||||
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
||||||
onDelete: (id: string) => Promise<void>;
|
onDelete: (id: string) => Promise<void>;
|
||||||
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
||||||
@@ -406,6 +420,7 @@ function SoundboardRow({
|
|||||||
isLast,
|
isLast,
|
||||||
busy,
|
busy,
|
||||||
previewing,
|
previewing,
|
||||||
|
badge,
|
||||||
onPatch,
|
onPatch,
|
||||||
onDelete,
|
onDelete,
|
||||||
onPreview,
|
onPreview,
|
||||||
@@ -503,6 +518,13 @@ function SoundboardRow({
|
|||||||
)}
|
)}
|
||||||
<p className="text-[10px] text-fg-muted">
|
<p className="text-[10px] text-fg-muted">
|
||||||
{(entry.size / BYTES_PER_MB).toFixed(2)} MB · {entry.mime}
|
{(entry.size / BYTES_PER_MB).toFixed(2)} MB · {entry.mime}
|
||||||
|
<span
|
||||||
|
title={badgeTitle(badge)}
|
||||||
|
aria-label={badgeTitle(badge)}
|
||||||
|
className="ml-2 inline-flex items-center text-[10px] font-medium text-fg-muted"
|
||||||
|
>
|
||||||
|
{badgeGlyph(badge)}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -623,3 +645,25 @@ function SoundboardRow({
|
|||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function badgeGlyph(b: SyncBadge | undefined): string {
|
||||||
|
switch (b) {
|
||||||
|
case 'uploading': return '↑';
|
||||||
|
case 'downloading': return '↓';
|
||||||
|
case 'error': return '⚠';
|
||||||
|
case 'synced':
|
||||||
|
default: return '☁';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeTitle(b: SyncBadge | undefined): string {
|
||||||
|
switch (b) {
|
||||||
|
case 'uploading': return 'Hochladen…';
|
||||||
|
case 'downloading': return 'Wird heruntergeladen…';
|
||||||
|
case 'error': return 'Synchronisationsfehler';
|
||||||
|
case 'synced':
|
||||||
|
default: return 'Synchronisiert';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import type { WhiteboardStroke } from '@chat-app/shared/chat';
|
||||||
|
|
||||||
|
export type WhiteboardTool = 'pen' | 'eraser';
|
||||||
|
export type WhiteboardColor = '#000000' | '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7';
|
||||||
|
export type WhiteboardWidth = 2 | 4 | 8;
|
||||||
|
|
||||||
|
export interface WhiteboardStrokePayload {
|
||||||
|
tool: WhiteboardTool;
|
||||||
|
color: WhiteboardColor;
|
||||||
|
width: WhiteboardWidth;
|
||||||
|
// [x, y, t-ms-since-stroke-start]
|
||||||
|
points: Array<[number, number, number]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
strokes: WhiteboardStroke[];
|
||||||
|
tool: WhiteboardTool;
|
||||||
|
color: WhiteboardColor;
|
||||||
|
width: WhiteboardWidth;
|
||||||
|
onStroke: (payload: WhiteboardStrokePayload) => void;
|
||||||
|
logicalWidth?: number;
|
||||||
|
logicalHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LOGICAL_W = 1280;
|
||||||
|
const DEFAULT_LOGICAL_H = 720;
|
||||||
|
|
||||||
|
export function WhiteboardCanvas({
|
||||||
|
strokes,
|
||||||
|
tool,
|
||||||
|
color,
|
||||||
|
width,
|
||||||
|
onStroke,
|
||||||
|
logicalWidth = DEFAULT_LOGICAL_W,
|
||||||
|
logicalHeight = DEFAULT_LOGICAL_H,
|
||||||
|
}: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const draftRef = useRef<WhiteboardStrokePayload | null>(null);
|
||||||
|
const strokeStartRef = useRef<number>(0);
|
||||||
|
const [, forceTick] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
if (!cv) return;
|
||||||
|
cv.width = logicalWidth;
|
||||||
|
cv.height = logicalHeight;
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, cv.width, cv.height);
|
||||||
|
for (const s of strokes) {
|
||||||
|
const payload = s.strokeJson as Partial<WhiteboardStrokePayload> | null;
|
||||||
|
if (payload) renderStroke(ctx, payload);
|
||||||
|
}
|
||||||
|
if (draftRef.current) renderStroke(ctx, draftRef.current);
|
||||||
|
});
|
||||||
|
|
||||||
|
function canvasPoint(e: React.PointerEvent<HTMLCanvasElement>): [number, number] {
|
||||||
|
const cv = canvasRef.current!;
|
||||||
|
const rect = cv.getBoundingClientRect();
|
||||||
|
const scaleX = cv.width / rect.width;
|
||||||
|
const scaleY = cv.height / rect.height;
|
||||||
|
return [(e.clientX - rect.left) * scaleX, (e.clientY - rect.top) * scaleY];
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
if (!cv) return;
|
||||||
|
cv.setPointerCapture(e.pointerId);
|
||||||
|
const [x, y] = canvasPoint(e);
|
||||||
|
strokeStartRef.current = Date.now();
|
||||||
|
draftRef.current = {
|
||||||
|
tool,
|
||||||
|
color,
|
||||||
|
width,
|
||||||
|
points: [[x, y, 0]],
|
||||||
|
};
|
||||||
|
forceTick((n) => n + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!draftRef.current) return;
|
||||||
|
const [x, y] = canvasPoint(e);
|
||||||
|
draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]);
|
||||||
|
forceTick((n) => n + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId);
|
||||||
|
const draft = draftRef.current;
|
||||||
|
draftRef.current = null;
|
||||||
|
if (!draft) return;
|
||||||
|
if (draft.points.length < 2) {
|
||||||
|
forceTick((n) => n + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onStroke(draft);
|
||||||
|
forceTick((n) => n + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerLeave={handlePointerUp}
|
||||||
|
className="block max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-white shadow-2xl"
|
||||||
|
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStroke(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
s: Partial<WhiteboardStrokePayload>,
|
||||||
|
): void {
|
||||||
|
const points = Array.isArray(s.points) ? s.points : null;
|
||||||
|
if (!points || points.length < 1) return;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.lineWidth = typeof s.width === 'number' ? s.width : 4;
|
||||||
|
if (s.tool === 'eraser') {
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.lineWidth = Math.max(8, (typeof s.width === 'number' ? s.width : 4) * 4);
|
||||||
|
} else {
|
||||||
|
ctx.strokeStyle = typeof s.color === 'string' ? s.color : '#000000';
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(points[0]![0], points[0]![1]);
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
ctx.lineTo(points[i]![0], points[i]![1]);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useWhiteboardStrokes } from '../hooks/useWhiteboardStrokes';
|
||||||
|
import { XIcon } from './icons';
|
||||||
|
import {
|
||||||
|
WhiteboardCanvas,
|
||||||
|
type WhiteboardColor,
|
||||||
|
type WhiteboardTool,
|
||||||
|
type WhiteboardWidth,
|
||||||
|
} from './WhiteboardCanvas';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
whiteboardId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS: WhiteboardColor[] = ['#000000', '#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7'];
|
||||||
|
const WIDTHS: WhiteboardWidth[] = [2, 4, 8];
|
||||||
|
|
||||||
|
export function WhiteboardModal({ whiteboardId, onClose }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { strokes, loading, error, insertStroke, clearAll } = useWhiteboardStrokes(whiteboardId);
|
||||||
|
const [tool, setTool] = useState<WhiteboardTool>('pen');
|
||||||
|
const [color, setColor] = useState<WhiteboardColor>('#000000');
|
||||||
|
const [width, setWidth] = useState<WhiteboardWidth>(4);
|
||||||
|
const [confirmClear, setConfirmClear] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleConfirmClear = async () => {
|
||||||
|
setConfirmClear(false);
|
||||||
|
try {
|
||||||
|
await clearAll();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('clearAll failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('app:whiteboard.title', { defaultValue: 'Whiteboard' })}
|
||||||
|
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">
|
||||||
|
{t('app:whiteboard.title', { defaultValue: 'Whiteboard' })}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('app:whiteboard.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 p-6">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-fg-muted">
|
||||||
|
{t('app:whiteboard.loading', { defaultValue: 'Lädt…' })}
|
||||||
|
</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-sm text-rose-400">
|
||||||
|
{t('app:whiteboard.error', { defaultValue: 'Whiteboard konnte nicht geladen werden.' })}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<WhiteboardCanvas
|
||||||
|
strokes={strokes}
|
||||||
|
tool={tool}
|
||||||
|
color={color}
|
||||||
|
width={width}
|
||||||
|
onStroke={(payload) => void insertStroke(payload)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="flex shrink-0 flex-wrap items-center gap-4 border-t border-line/40 bg-surface-2 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{(['pen', 'eraser'] as WhiteboardTool[]).map((id) => {
|
||||||
|
const label = id === 'pen' ? 'Stift' : 'Radierer';
|
||||||
|
const active = tool === id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTool(id)}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={label}
|
||||||
|
className={
|
||||||
|
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-xs font-semibold transition ' +
|
||||||
|
(active
|
||||||
|
? 'bg-accent/20 text-accent ring-2 ring-accent/40'
|
||||||
|
: 'bg-surface-3 text-fg-muted hover:bg-surface hover:text-fg')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{id === 'pen' ? '✎' : '⌫'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{COLORS.map((c) => {
|
||||||
|
const active = color === c;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
aria-pressed={active}
|
||||||
|
aria-label={c}
|
||||||
|
className={
|
||||||
|
'h-6 w-6 cursor-pointer rounded-full border-2 transition ' +
|
||||||
|
(active ? 'border-fg scale-110' : 'border-line/40 hover:scale-105')
|
||||||
|
}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{WIDTHS.map((w) => {
|
||||||
|
const active = width === w;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={w}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWidth(w)}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={w + 'px'}
|
||||||
|
className={
|
||||||
|
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md transition ' +
|
||||||
|
(active
|
||||||
|
? 'bg-accent/20 ring-2 ring-accent/40'
|
||||||
|
: 'bg-surface-3 hover:bg-surface')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-full bg-fg"
|
||||||
|
style={{ width: w + 2 + 'px', height: w + 2 + 'px' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{confirmClear ? (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-fg-muted">
|
||||||
|
{t('app:whiteboard.confirm_clear', { defaultValue: 'Alles löschen?' })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmClear(false)}
|
||||||
|
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:whiteboard.cancel', { defaultValue: 'Abbrechen' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleConfirmClear()}
|
||||||
|
className="cursor-pointer rounded-md bg-rose-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-rose-500/90"
|
||||||
|
>
|
||||||
|
{t('app:whiteboard.confirm', { defaultValue: 'Ja, löschen' })}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmClear(true)}
|
||||||
|
disabled={strokes.length === 0}
|
||||||
|
className="cursor-pointer rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||||
|
>
|
||||||
|
{t('app:whiteboard.clear_all', { defaultValue: 'Alles löschen' })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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,276 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { getCryptoBackend } from '@chat-app/shared/crypto';
|
||||||
|
import {
|
||||||
|
decryptSoundEnvelope,
|
||||||
|
downloadSoundCiphertext,
|
||||||
|
encryptSoundBlob,
|
||||||
|
listOwnSounds,
|
||||||
|
type RemoteSound,
|
||||||
|
upsertSound,
|
||||||
|
uploadSoundCiphertext,
|
||||||
|
} from '@chat-app/shared/chat';
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import {
|
||||||
|
deleteRawStoredSound,
|
||||||
|
getRawStoredSound,
|
||||||
|
listSounds,
|
||||||
|
putRawStoredSound,
|
||||||
|
type SoundboardEntry,
|
||||||
|
subscribeSoundboardChanges,
|
||||||
|
} from '../lib/soundboardStorage';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { cachedUserKey } from '../lib/userIdentity';
|
||||||
|
|
||||||
|
export type SyncBadge = 'synced' | 'uploading' | 'downloading' | 'error';
|
||||||
|
|
||||||
|
const PUSH_DEBOUNCE_MS = 500;
|
||||||
|
const DELETE_GRACE_MS = 5000;
|
||||||
|
|
||||||
|
function storagePathFor(userId: string, soundId: string): string {
|
||||||
|
return userId + '/' + soundId + '.bin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSoundboardSync(): {
|
||||||
|
badges: Map<string, SyncBadge>;
|
||||||
|
initialPullDone: boolean;
|
||||||
|
} {
|
||||||
|
const { session } = useAuth();
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
const [badges, setBadges] = useState<Map<string, SyncBadge>>(new Map());
|
||||||
|
const [initialPullDone, setInitialPullDone] = useState(false);
|
||||||
|
const debounceRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
function setBadge(id: string, badge: SyncBadge): void {
|
||||||
|
setBadges((cur) => {
|
||||||
|
const next = new Map(cur);
|
||||||
|
next.set(id, badge);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId) {
|
||||||
|
setInitialPullDone(false);
|
||||||
|
setBadges(new Map());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
let teardown: (() => void) | null = null;
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
const priv = await cachedUserKey(userId);
|
||||||
|
if (!priv) return;
|
||||||
|
const pub = getCryptoBackend().scalarMultBase(priv);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runDiff(userId, priv, pub);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('soundboard initial diff failed', err);
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
setInitialPullDone(true);
|
||||||
|
|
||||||
|
const unsubLocal = subscribeSoundboardChanges(() => {
|
||||||
|
if (debounceRef.current !== null) window.clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = window.setTimeout(() => {
|
||||||
|
debounceRef.current = null;
|
||||||
|
void runDiff(userId, priv, pub).catch((err) => {
|
||||||
|
console.error('soundboard push diff failed', err);
|
||||||
|
});
|
||||||
|
}, PUSH_DEBOUNCE_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
const channel = supabase
|
||||||
|
.channel('soundboards:' + userId)
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{
|
||||||
|
event: '*',
|
||||||
|
schema: 'public',
|
||||||
|
table: 'user_soundboards',
|
||||||
|
filter: 'user_id=eq.' + userId,
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
void runDiff(userId, priv, pub).catch((err) => {
|
||||||
|
console.error('soundboard realtime pull failed', err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
teardown = () => {
|
||||||
|
unsubLocal();
|
||||||
|
void supabase.removeChannel(channel);
|
||||||
|
if (debounceRef.current !== null) {
|
||||||
|
window.clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
void init();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
teardown?.();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
async function runDiff(uid: string, priv: Uint8Array, pub: Uint8Array): Promise<void> {
|
||||||
|
const [localList, remoteList] = await Promise.all([
|
||||||
|
listSounds(),
|
||||||
|
listOwnSounds(supabase),
|
||||||
|
]);
|
||||||
|
const remoteById = new Map<string, RemoteSound>();
|
||||||
|
for (const r of remoteList) remoteById.set(r.id, r);
|
||||||
|
const localById = new Map<string, SoundboardEntry>();
|
||||||
|
for (const l of localList) localById.set(l.id, l);
|
||||||
|
|
||||||
|
// Push pass
|
||||||
|
for (const local of localList) {
|
||||||
|
const remote = remoteById.get(local.id);
|
||||||
|
const localIso = new Date(local.updatedAt).toISOString();
|
||||||
|
if (!remote) {
|
||||||
|
await uploadAndUpsert(local, uid, priv, pub, localIso);
|
||||||
|
} else {
|
||||||
|
const remoteMs = Date.parse(remote.updatedAt);
|
||||||
|
if (local.updatedAt > remoteMs) {
|
||||||
|
await upsertMetadataOnly(local, remote, localIso);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull pass
|
||||||
|
for (const remote of remoteList) {
|
||||||
|
const local = localById.get(remote.id);
|
||||||
|
const remoteMs = Date.parse(remote.updatedAt);
|
||||||
|
if (!local) {
|
||||||
|
await pullAndStore(remote, priv, pub);
|
||||||
|
} else if (remoteMs > local.updatedAt) {
|
||||||
|
const stored = await getRawStoredSound(remote.id);
|
||||||
|
if (stored) {
|
||||||
|
await putRawStoredSound({
|
||||||
|
...stored,
|
||||||
|
name: remote.name,
|
||||||
|
mime: remote.mime,
|
||||||
|
size: remote.size,
|
||||||
|
category: remote.category,
|
||||||
|
hotkey: remote.hotkey,
|
||||||
|
gain: remote.gain,
|
||||||
|
order: remote.sortOrder,
|
||||||
|
updatedAt: remoteMs,
|
||||||
|
});
|
||||||
|
setBadge(remote.id, 'synced');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setBadge(remote.id, 'synced');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote-absence → local delete (with grace window for fresh adds).
|
||||||
|
const now = Date.now();
|
||||||
|
for (const local of localList) {
|
||||||
|
if (!remoteById.has(local.id) && now - local.updatedAt > DELETE_GRACE_MS) {
|
||||||
|
await deleteRawStoredSound(local.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAndUpsert(
|
||||||
|
local: SoundboardEntry,
|
||||||
|
uid: string,
|
||||||
|
priv: Uint8Array,
|
||||||
|
pub: Uint8Array,
|
||||||
|
localIso: string,
|
||||||
|
): Promise<void> {
|
||||||
|
setBadge(local.id, 'uploading');
|
||||||
|
try {
|
||||||
|
const stored = await getRawStoredSound(local.id);
|
||||||
|
if (!stored) return;
|
||||||
|
const ciphertext = await encryptSoundBlob(stored.blob, pub, priv);
|
||||||
|
const path = storagePathFor(uid, local.id);
|
||||||
|
await uploadSoundCiphertext(supabase, path, ciphertext);
|
||||||
|
await upsertSound(supabase, {
|
||||||
|
id: local.id,
|
||||||
|
name: local.name,
|
||||||
|
mime: local.mime,
|
||||||
|
size: local.size,
|
||||||
|
category: local.category,
|
||||||
|
hotkey: local.hotkey,
|
||||||
|
gain: local.gain,
|
||||||
|
sortOrder: local.order,
|
||||||
|
storagePath: path,
|
||||||
|
updatedAtIso: localIso,
|
||||||
|
});
|
||||||
|
setBadge(local.id, 'synced');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('soundboard upload failed', err);
|
||||||
|
setBadge(local.id, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertMetadataOnly(
|
||||||
|
local: SoundboardEntry,
|
||||||
|
remote: RemoteSound,
|
||||||
|
localIso: string,
|
||||||
|
): Promise<void> {
|
||||||
|
setBadge(local.id, 'uploading');
|
||||||
|
try {
|
||||||
|
await upsertSound(supabase, {
|
||||||
|
id: local.id,
|
||||||
|
name: local.name,
|
||||||
|
mime: local.mime,
|
||||||
|
size: local.size,
|
||||||
|
category: local.category,
|
||||||
|
hotkey: local.hotkey,
|
||||||
|
gain: local.gain,
|
||||||
|
sortOrder: local.order,
|
||||||
|
storagePath: remote.storagePath,
|
||||||
|
updatedAtIso: localIso,
|
||||||
|
});
|
||||||
|
setBadge(local.id, 'synced');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('soundboard metadata upload failed', err);
|
||||||
|
setBadge(local.id, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pullAndStore(
|
||||||
|
remote: RemoteSound,
|
||||||
|
priv: Uint8Array,
|
||||||
|
pub: Uint8Array,
|
||||||
|
): Promise<void> {
|
||||||
|
setBadge(remote.id, 'downloading');
|
||||||
|
try {
|
||||||
|
const envelope = await downloadSoundCiphertext(supabase, remote.storagePath);
|
||||||
|
const plain = await decryptSoundEnvelope(envelope, pub, priv);
|
||||||
|
// Copy into a fresh ArrayBuffer so Blob accepts it across TS lib variants
|
||||||
|
// (mirrors the pattern in @chat-app/shared/chat/attachments.ts).
|
||||||
|
const copy = new Uint8Array(plain.byteLength);
|
||||||
|
copy.set(plain);
|
||||||
|
const blob = new Blob([copy.buffer], { type: remote.mime });
|
||||||
|
const ms = Date.parse(remote.updatedAt);
|
||||||
|
await putRawStoredSound({
|
||||||
|
id: remote.id,
|
||||||
|
name: remote.name,
|
||||||
|
mime: remote.mime,
|
||||||
|
size: remote.size,
|
||||||
|
category: remote.category,
|
||||||
|
hotkey: remote.hotkey,
|
||||||
|
gain: remote.gain,
|
||||||
|
order: remote.sortOrder,
|
||||||
|
createdAt: Date.parse(remote.createdAt),
|
||||||
|
updatedAt: ms,
|
||||||
|
blob,
|
||||||
|
});
|
||||||
|
setBadge(remote.id, 'synced');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('soundboard pull failed', err);
|
||||||
|
setBadge(remote.id, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { badges, initialPullDone };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearWhiteboardStrokes,
|
||||||
|
insertWhiteboardStroke,
|
||||||
|
listWhiteboardStrokes,
|
||||||
|
type WhiteboardStroke,
|
||||||
|
} from '@chat-app/shared/chat';
|
||||||
|
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
strokes: WhiteboardStroke[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWhiteboardStrokes(whiteboardId: string | null): {
|
||||||
|
strokes: WhiteboardStroke[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
insertStroke: (strokeJson: unknown) => Promise<void>;
|
||||||
|
clearAll: () => Promise<void>;
|
||||||
|
} {
|
||||||
|
const [state, setState] = useState<State>({ strokes: [], loading: true, error: null });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!whiteboardId) {
|
||||||
|
setState({ strokes: [], loading: false, error: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
setState((s) => ({ ...s, loading: true, error: null }));
|
||||||
|
const list = await listWhiteboardStrokes(supabase, whiteboardId);
|
||||||
|
if (!cancelled) setState({ strokes: list, loading: false, error: null });
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setState({
|
||||||
|
strokes: [],
|
||||||
|
loading: false,
|
||||||
|
error: err instanceof Error ? err.message : 'failed to load strokes',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const channel = supabase
|
||||||
|
.channel('whiteboard:' + whiteboardId)
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{
|
||||||
|
event: 'INSERT',
|
||||||
|
schema: 'public',
|
||||||
|
table: 'whiteboard_strokes',
|
||||||
|
filter: 'whiteboard_id=eq.' + whiteboardId,
|
||||||
|
},
|
||||||
|
(payload) => {
|
||||||
|
const row = payload.new as {
|
||||||
|
id?: string;
|
||||||
|
whiteboard_id?: string;
|
||||||
|
author_user_id?: string;
|
||||||
|
stroke_json?: unknown;
|
||||||
|
created_at?: string;
|
||||||
|
} | null;
|
||||||
|
if (!row?.id || !row.whiteboard_id || !row.author_user_id || !row.created_at) return;
|
||||||
|
const next: WhiteboardStroke = {
|
||||||
|
id: row.id,
|
||||||
|
whiteboardId: row.whiteboard_id,
|
||||||
|
authorUserId: row.author_user_id,
|
||||||
|
strokeJson: row.stroke_json,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
};
|
||||||
|
setState((s) => {
|
||||||
|
if (s.strokes.some((x) => x.id === next.id)) return s;
|
||||||
|
return { ...s, strokes: [...s.strokes, next] };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{
|
||||||
|
event: 'DELETE',
|
||||||
|
schema: 'public',
|
||||||
|
table: 'whiteboard_strokes',
|
||||||
|
filter: 'whiteboard_id=eq.' + whiteboardId,
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Bulk delete via "Clear all" — drop everything; future inserts
|
||||||
|
// come back via the INSERT branch above.
|
||||||
|
setState((s) => ({ ...s, strokes: [] }));
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
void supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
}, [whiteboardId]);
|
||||||
|
|
||||||
|
const insertStroke = useCallback(
|
||||||
|
async (strokeJson: unknown) => {
|
||||||
|
if (!whiteboardId) return;
|
||||||
|
try {
|
||||||
|
await insertWhiteboardStroke(supabase, { whiteboardId, strokeJson });
|
||||||
|
// No optimistic append — realtime echoes the row back in <150ms.
|
||||||
|
} catch (err) {
|
||||||
|
console.error('insertWhiteboardStroke failed', err);
|
||||||
|
setState((s) => ({
|
||||||
|
...s,
|
||||||
|
error: err instanceof Error ? err.message : 'stroke insert failed',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[whiteboardId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearAll = useCallback(async () => {
|
||||||
|
if (!whiteboardId) return;
|
||||||
|
await clearWhiteboardStrokes(supabase, whiteboardId);
|
||||||
|
}, [whiteboardId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
strokes: state.strokes,
|
||||||
|
loading: state.loading,
|
||||||
|
error: state.error,
|
||||||
|
insertStroke,
|
||||||
|
clearAll,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
type AttachmentHandle,
|
type AttachmentHandle,
|
||||||
type DecryptedMessage,
|
type DecryptedMessage,
|
||||||
type PollOption,
|
type PollOption,
|
||||||
|
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';
|
||||||
@@ -97,6 +100,34 @@ export function createPollPayload(question: string, optionTexts: string[]): stri
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createWhiteboardPayload(whiteboardId: string): string {
|
||||||
|
const payload: WhiteboardPayload = {
|
||||||
|
v: 1,
|
||||||
|
type: 'whiteboard',
|
||||||
|
whiteboard_id: whiteboardId,
|
||||||
|
};
|
||||||
|
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[],
|
||||||
|
|||||||
@@ -369,3 +369,52 @@ export async function isHotkeyTaken(
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Sync-engine bypass helpers ------------------------------------------
|
||||||
|
// Used by useSoundboardSync to write/delete IndexedDB rows without firing
|
||||||
|
// notifyChange — pulls and remote-driven deletes are not "edits". If they
|
||||||
|
// triggered notifyChange the engine would loop:
|
||||||
|
// push debounce → upsert → realtime → pull → notifyChange → push debounce → ...
|
||||||
|
|
||||||
|
export async function putRawStoredSound(stored: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
category: string | null;
|
||||||
|
hotkey: string | null;
|
||||||
|
gain: number;
|
||||||
|
order: number;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
blob: Blob;
|
||||||
|
}): Promise<void> {
|
||||||
|
await tx(SOUNDS_STORE, 'readwrite', (s) => s.put(stored));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRawStoredSound(id: string): Promise<void> {
|
||||||
|
await tx(SOUNDS_STORE, 'readwrite', (s) => s.delete(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRawStoredSound(id: string): Promise<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
category: string | null;
|
||||||
|
hotkey: string | null;
|
||||||
|
gain: number;
|
||||||
|
order: number;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
blob: Blob;
|
||||||
|
} | null> {
|
||||||
|
const db = await openDb();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const t = db.transaction(SOUNDS_STORE, 'readonly');
|
||||||
|
const s = t.objectStore(SOUNDS_STORE);
|
||||||
|
const req = s.get(id);
|
||||||
|
req.onsuccess = () => resolve((req.result as any) ?? null);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronUpIcon,
|
ChevronUpIcon,
|
||||||
EyeOffIcon,
|
EyeOffIcon,
|
||||||
|
PencilIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
PollIcon,
|
PollIcon,
|
||||||
ReplyIcon,
|
ReplyIcon,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
SpinnerIcon,
|
SpinnerIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '../components/icons';
|
} from '../components/icons';
|
||||||
|
import { ImageAnnotator } from '../components/ImageAnnotator';
|
||||||
import { InCallPanel } from '../components/InCallPanel';
|
import { InCallPanel } from '../components/InCallPanel';
|
||||||
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
||||||
import { CallPreviewPanel } from '../components/CallPreviewPanel';
|
import { CallPreviewPanel } from '../components/CallPreviewPanel';
|
||||||
@@ -40,7 +42,17 @@ import type { DecryptedMessage } from '@chat-app/shared/chat';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useCall } from '../context/CallContext';
|
import { useCall } from '../context/CallContext';
|
||||||
import { useConversationsContext } from '../context/ConversationsContext';
|
import { useConversationsContext } from '../context/ConversationsContext';
|
||||||
import { collectConversationAttachments, createPollPayload } from '../lib/conversationFeatures';
|
import {
|
||||||
|
collectConversationAttachments,
|
||||||
|
createPollPayload,
|
||||||
|
createWhiteboardPayload,
|
||||||
|
createWatchTogetherPayload,
|
||||||
|
createGamePayload,
|
||||||
|
} from '../lib/conversationFeatures';
|
||||||
|
import { WhiteboardModal } from '../components/WhiteboardModal';
|
||||||
|
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';
|
||||||
@@ -154,10 +166,22 @@ export function ConversationPage() {
|
|||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const [stickToBottom, setStickToBottom] = useState(true);
|
const [stickToBottom, setStickToBottom] = useState(true);
|
||||||
const [attachments, setAttachments] = useState<File[]>([]);
|
const [attachments, setAttachments] = useState<File[]>([]);
|
||||||
|
const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(null);
|
||||||
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
|
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
|
||||||
const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false);
|
const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false);
|
||||||
const [pollDialogOpen, setPollDialogOpen] = useState(false);
|
const [pollDialogOpen, setPollDialogOpen] = useState(false);
|
||||||
const [pollSending, setPollSending] = useState(false);
|
const [pollSending, setPollSending] = useState(false);
|
||||||
|
const [openWhiteboardId, setOpenWhiteboardId] = useState<string | null>(null);
|
||||||
|
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);
|
||||||
@@ -452,6 +476,33 @@ export function ConversationPage() {
|
|||||||
};
|
};
|
||||||
}, [id, setActiveConversation]);
|
}, [id, setActiveConversation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onOpen = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent<{ id?: string }>).detail;
|
||||||
|
if (detail?.id) setOpenWhiteboardId(detail.id);
|
||||||
|
};
|
||||||
|
window.addEventListener('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]);
|
||||||
@@ -585,6 +636,80 @@ export function ConversationPage() {
|
|||||||
[send, replyTo?.id, notifyStopTyping],
|
[send, replyTo?.id, notifyStopTyping],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCreateWhiteboard = useCallback(async () => {
|
||||||
|
if (!id || creatingWhiteboard) return;
|
||||||
|
setCreatingWhiteboard(true);
|
||||||
|
try {
|
||||||
|
const board = await createWhiteboard(supabase, id);
|
||||||
|
const payload = createWhiteboardPayload(board.id);
|
||||||
|
await send(payload, [], replyTo?.id ?? null);
|
||||||
|
setReplyTo(null);
|
||||||
|
setStickToBottom(true);
|
||||||
|
setOpenWhiteboardId(board.id);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden');
|
||||||
|
} finally {
|
||||||
|
setCreatingWhiteboard(false);
|
||||||
|
}
|
||||||
|
}, [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[] = [];
|
||||||
@@ -917,6 +1042,9 @@ export function ConversationPage() {
|
|||||||
key={idx}
|
key={idx}
|
||||||
file={file}
|
file={file}
|
||||||
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
|
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
|
||||||
|
{...(file.type.startsWith('image/')
|
||||||
|
? { onEdit: () => setAnnotatingIndex(idx) }
|
||||||
|
: {})}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -975,6 +1103,34 @@ export function ConversationPage() {
|
|||||||
>
|
>
|
||||||
<PollIcon className="h-4 w-4" />
|
<PollIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCreateWhiteboard()}
|
||||||
|
disabled={creatingWhiteboard}
|
||||||
|
title={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
|
||||||
|
aria-label={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
|
||||||
|
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 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-[#313338]"
|
||||||
|
>
|
||||||
|
<WhiteboardIcon className="h-4 w-4" />
|
||||||
|
</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"
|
||||||
@@ -1163,6 +1319,133 @@ export function ConversationPage() {
|
|||||||
}}
|
}}
|
||||||
onUnpin={(messageId) => void handleTogglePin(messageId)}
|
onUnpin={(messageId) => void handleTogglePin(messageId)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{annotatingIndex !== null && attachments[annotatingIndex] && (
|
||||||
|
<ImageAnnotator
|
||||||
|
file={attachments[annotatingIndex]!}
|
||||||
|
onCancel={() => setAnnotatingIndex(null)}
|
||||||
|
onSave={(next) => {
|
||||||
|
setAttachments((prev) => prev.map((f, i) => (i === annotatingIndex ? next : f)));
|
||||||
|
setAnnotatingIndex(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{openWhiteboardId && (
|
||||||
|
<WhiteboardModal
|
||||||
|
whiteboardId={openWhiteboardId}
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1390,7 +1673,15 @@ function Banner({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => void }) {
|
function AttachmentPreview({
|
||||||
|
file,
|
||||||
|
onRemove,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
file: File;
|
||||||
|
onRemove: () => void;
|
||||||
|
onEdit?: () => void;
|
||||||
|
}) {
|
||||||
const isImage = file.type.startsWith('image/');
|
const isImage = file.type.startsWith('image/');
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1400,7 +1691,7 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
|
|||||||
return () => URL.revokeObjectURL(u);
|
return () => URL.revokeObjectURL(u);
|
||||||
}, [file, isImage]);
|
}, [file, isImage]);
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-lg border border-line bg-surface-2 dark:bg-[#2b2d31]">
|
<div className="group relative overflow-hidden rounded-lg border border-line bg-surface-2 dark:bg-[#2b2d31]">
|
||||||
{isImage && url ? (
|
{isImage && url ? (
|
||||||
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
|
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
|
||||||
) : (
|
) : (
|
||||||
@@ -1412,6 +1703,17 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
|
|||||||
<span className="text-fg-muted">{(file.size / 1024).toFixed(0)} KB</span>
|
<span className="text-fg-muted">{(file.size / 1024).toFixed(0)} KB</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isImage && onEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
aria-label="Bearbeiten"
|
||||||
|
title="Bearbeiten"
|
||||||
|
className="absolute bottom-1 left-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white opacity-0 transition group-hover:opacity-100 hover:bg-accent/80"
|
||||||
|
>
|
||||||
|
<PencilIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
@@ -1423,3 +1725,33 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WhiteboardIcon(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="13" rx="2" />
|
||||||
|
<path d="M8 21h8M12 17v4" />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,849 @@
|
|||||||
|
# Phase 4A — Image Annotation vor Send
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let the user mark up an image attachment (pen, arrow, rectangle, circle, text, highlighter) BEFORE it's sent. The annotated copy replaces the original `File` in the composer's attachments[] state; the recipient sees the flattened PNG with the annotation baked in (no separate metadata, no decoding work on receive).
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- New self-contained component `ImageAnnotator.tsx` (~400 LOC, no external deps): a full-screen modal with a single `<canvas>` rendering the original image plus an in-memory "ops stack" of drawing commands. Every tool stroke is one op; undo pops from the stack into a redo buffer; redo moves it back. The canvas re-renders the whole stack on every change — simple, debuggable, fast at typical image sizes.
|
||||||
|
- New `AttachmentPreview` prop `onEdit?: () => void`. When the preview is an image and `onEdit` is wired, a `✏` button overlays the thumb. Clicking it opens `ImageAnnotator`; on Save the modal calls `onSave(newFile)` and `ConversationPage` swaps the entry in `attachments[]`.
|
||||||
|
- Save flow: `canvas.toBlob({ type: 'image/png' })` → wrap in `new File([blob], original.name.replace(/\.\w+$/, '') + '-annotated.png', { type: 'image/png', lastModified: Date.now() })` → return through `onSave`.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18 + TypeScript + HTML5 Canvas. Reuses Tailwind classes from the existing modal/toolbar code in the app. No new dependencies.
|
||||||
|
|
||||||
|
**Non-goals:**
|
||||||
|
- No image-only crop / rotate / filter (just annotation).
|
||||||
|
- No persistence of in-progress annotations between modal opens.
|
||||||
|
- No collaboration / sharing of the op-stack (recipient sees flattened PNG only).
|
||||||
|
- No SVG / vector output; PNG raster only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-flight
|
||||||
|
|
||||||
|
- [ ] **Verify clean working tree on `main`**
|
||||||
|
|
||||||
|
Run: `cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status`
|
||||||
|
Expected: clean (ignored `.env.local` is fine).
|
||||||
|
|
||||||
|
- [ ] **Confirm tooling is green**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||||
|
Expected: PASS. If it's red, STOP and report — don't start on a broken baseline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: ImageAnnotator component skeleton + canvas mount + op-stack types
|
||||||
|
|
||||||
|
**Why:** Get the modal rendering with the image visible inside the canvas before adding any drawing logic. Establishes the file's structure (state, refs, types) that the next tasks fill in.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/desktop/src/components/ImageAnnotator.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the file**
|
||||||
|
|
||||||
|
Create `apps/desktop/src/components/ImageAnnotator.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { XIcon } from './icons';
|
||||||
|
|
||||||
|
export type AnnotatorTool = 'pen' | 'arrow' | 'rect' | 'circle' | 'text' | 'highlighter';
|
||||||
|
export type AnnotatorColor = '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7' | '#ffffff';
|
||||||
|
export type AnnotatorWidth = 2 | 4 | 8;
|
||||||
|
|
||||||
|
export interface AnnotatorOp {
|
||||||
|
tool: AnnotatorTool;
|
||||||
|
color: AnnotatorColor;
|
||||||
|
width: AnnotatorWidth;
|
||||||
|
points?: Array<{ x: number; y: number }>;
|
||||||
|
from?: { x: number; y: number };
|
||||||
|
to?: { x: number; y: number };
|
||||||
|
text?: string;
|
||||||
|
at?: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
file: File;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSave: (next: File) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageAnnotator({ file, onCancel, onSave }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
|
const [ops, setOps] = useState<AnnotatorOp[]>([]);
|
||||||
|
const [redoStack, setRedoStack] = useState<AnnotatorOp[]>([]);
|
||||||
|
const [tool, setTool] = useState<AnnotatorTool>('pen');
|
||||||
|
const [color, setColor] = useState<AnnotatorColor>('#ef4444');
|
||||||
|
const [width, setWidth] = useState<AnnotatorWidth>(4);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
imageRef.current = img;
|
||||||
|
setImageLoaded(true);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
console.error('ImageAnnotator: failed to decode source image');
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
return () => URL.revokeObjectURL(url);
|
||||||
|
}, [file, onCancel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageLoaded) return;
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
const img = imageRef.current;
|
||||||
|
if (!cv || !img) return;
|
||||||
|
cv.width = img.naturalWidth;
|
||||||
|
cv.height = img.naturalHeight;
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
for (const op of ops) {
|
||||||
|
renderOp(ctx, op);
|
||||||
|
}
|
||||||
|
}, [imageLoaded, ops]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onCancel();
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleUndo();
|
||||||
|
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleRedo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUndo = () => {
|
||||||
|
setOps((cur) => {
|
||||||
|
if (cur.length === 0) return cur;
|
||||||
|
const next = cur.slice(0, -1);
|
||||||
|
setRedoStack((r) => [...r, cur[cur.length - 1]!]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRedo = () => {
|
||||||
|
setRedoStack((r) => {
|
||||||
|
if (r.length === 0) return r;
|
||||||
|
const top = r[r.length - 1]!;
|
||||||
|
setOps((cur) => [...cur, top]);
|
||||||
|
return r.slice(0, -1);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setOps([]);
|
||||||
|
setRedoStack([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
if (!cv) return;
|
||||||
|
cv.toBlob((blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
console.error('ImageAnnotator: toBlob returned null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const baseName = file.name.replace(/\.[^.]+$/, '');
|
||||||
|
const next = new File([blob], baseName + '-annotated.png', {
|
||||||
|
type: 'image/png',
|
||||||
|
lastModified: Date.now(),
|
||||||
|
});
|
||||||
|
onSave(next);
|
||||||
|
}, 'image/png');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
|
||||||
|
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">
|
||||||
|
{t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
aria-label={t('app:annotator.cancel', { defaultValue: 'Abbrechen' })}
|
||||||
|
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 p-6">
|
||||||
|
{imageLoaded ? (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="max-h-full max-w-full cursor-crosshair rounded-lg border border-line/40 bg-black shadow-2xl"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-fg-muted">
|
||||||
|
{t('app:annotator.loading', { defaultValue: 'Bild wird geladen…' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-line/40 bg-surface-2 px-4 py-3">
|
||||||
|
<div className="text-xs text-fg-muted">
|
||||||
|
{ops.length === 0
|
||||||
|
? t('app:annotator.no_changes', { defaultValue: 'Keine Änderungen' })
|
||||||
|
: ops.length + ' ' + t('app:annotator.changes', { defaultValue: 'Änderungen' })}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={ops.length === 0}
|
||||||
|
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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('app:annotator.reset', { defaultValue: 'Zurücksetzen' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!imageLoaded}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{t('app:annotator.save', { defaultValue: 'Speichern' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderOp is filled in by Task 2. Stub for now so the canvas-replay loop
|
||||||
|
// in the main useEffect compiles cleanly.
|
||||||
|
function renderOp(_ctx: CanvasRenderingContext2D, _op: AnnotatorOp): void {
|
||||||
|
// implemented in Task 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `text-accent-fg` doesn't exist in this project's Tailwind, use the same class the existing accent buttons use — grep `Grep -n "bg-accent " apps/desktop/src/components/RemoteRevokedScreen.tsx` (P3.T7 wrote this very recently) to find the conventional pair.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Typecheck**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "D:\Programmieren\ChatApp-Electron\chat-app"
|
||||||
|
git add apps/desktop/src/components/ImageAnnotator.tsx
|
||||||
|
git commit -m "feat(P4A.T1): ImageAnnotator modal skeleton with canvas mount + op-stack types"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Drawing implementation — render all 6 tools + capture pointer events
|
||||||
|
|
||||||
|
**Why:** This is the drawing engine. After this task the user can free-hand-draw + shapes on the canvas with the defaults (pen / red / width 4). Tool/color/width pickers come in Task 3.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/desktop/src/components/ImageAnnotator.tsx` (replace the `renderOp` stub + add pointer handlers + draft state)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the `renderOp` stub with the real renderer**
|
||||||
|
|
||||||
|
At the bottom of the file, replace the stub with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function renderOp(ctx: CanvasRenderingContext2D, op: AnnotatorOp): void {
|
||||||
|
ctx.save();
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.strokeStyle = op.color;
|
||||||
|
ctx.fillStyle = op.color;
|
||||||
|
ctx.lineWidth = op.width;
|
||||||
|
|
||||||
|
switch (op.tool) {
|
||||||
|
case 'pen': {
|
||||||
|
const pts = op.points;
|
||||||
|
if (!pts || pts.length < 1) break;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
ctx.lineTo(pts[i]!.x, pts[i]!.y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'highlighter': {
|
||||||
|
const pts = op.points;
|
||||||
|
if (!pts || pts.length < 1) break;
|
||||||
|
ctx.globalAlpha = 0.35;
|
||||||
|
ctx.lineWidth = op.width * 4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
ctx.lineTo(pts[i]!.x, pts[i]!.y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'rect': {
|
||||||
|
const { from, to } = op;
|
||||||
|
if (!from || !to) break;
|
||||||
|
ctx.strokeRect(
|
||||||
|
Math.min(from.x, to.x),
|
||||||
|
Math.min(from.y, to.y),
|
||||||
|
Math.abs(to.x - from.x),
|
||||||
|
Math.abs(to.y - from.y),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'circle': {
|
||||||
|
const { from, to } = op;
|
||||||
|
if (!from || !to) break;
|
||||||
|
const cx = (from.x + to.x) / 2;
|
||||||
|
const cy = (from.y + to.y) / 2;
|
||||||
|
const rx = Math.abs(to.x - from.x) / 2;
|
||||||
|
const ry = Math.abs(to.y - from.y) / 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'arrow': {
|
||||||
|
const { from, to } = op;
|
||||||
|
if (!from || !to) break;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(from.x, from.y);
|
||||||
|
ctx.lineTo(to.x, to.y);
|
||||||
|
ctx.stroke();
|
||||||
|
const angle = Math.atan2(to.y - from.y, to.x - from.x);
|
||||||
|
const head = Math.max(12, op.width * 3);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(to.x, to.y);
|
||||||
|
ctx.lineTo(
|
||||||
|
to.x - head * Math.cos(angle - Math.PI / 6),
|
||||||
|
to.y - head * Math.sin(angle - Math.PI / 6),
|
||||||
|
);
|
||||||
|
ctx.moveTo(to.x, to.y);
|
||||||
|
ctx.lineTo(
|
||||||
|
to.x - head * Math.cos(angle + Math.PI / 6),
|
||||||
|
to.y - head * Math.sin(angle + Math.PI / 6),
|
||||||
|
);
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'text': {
|
||||||
|
const { at, text } = op;
|
||||||
|
if (!at || !text) break;
|
||||||
|
const fontSize = Math.max(14, op.width * 6);
|
||||||
|
ctx.font = '600 ' + fontSize + 'px Inter, system-ui, sans-serif';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.fillText(text, at.x, at.y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add pointer-event capture + draft state**
|
||||||
|
|
||||||
|
Inside the component, near the other refs, add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const draftRef = useRef<AnnotatorOp | null>(null);
|
||||||
|
const [draftTick, setDraftTick] = useState(0);
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the existing main render `useEffect` body so it ALSO draws the in-progress draft (live preview during pointer-down):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageLoaded) return;
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
const img = imageRef.current;
|
||||||
|
if (!cv || !img) return;
|
||||||
|
cv.width = img.naturalWidth;
|
||||||
|
cv.height = img.naturalHeight;
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
for (const op of ops) {
|
||||||
|
renderOp(ctx, op);
|
||||||
|
}
|
||||||
|
if (draftRef.current) {
|
||||||
|
renderOp(ctx, draftRef.current);
|
||||||
|
}
|
||||||
|
}, [imageLoaded, ops, draftTick]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add this helper right above the `return` (to convert client coords to internal canvas coords — important because the canvas is scaled to fit):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function canvasPoint(e: React.PointerEvent<HTMLCanvasElement>): { x: number; y: number } {
|
||||||
|
const cv = canvasRef.current!;
|
||||||
|
const rect = cv.getBoundingClientRect();
|
||||||
|
const scaleX = cv.width / rect.width;
|
||||||
|
const scaleY = cv.height / rect.height;
|
||||||
|
return {
|
||||||
|
x: (e.clientX - rect.left) * scaleX,
|
||||||
|
y: (e.clientY - rect.top) * scaleY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add these handlers (also above the `return`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!imageLoaded) return;
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
if (!cv) return;
|
||||||
|
cv.setPointerCapture(e.pointerId);
|
||||||
|
const p = canvasPoint(e);
|
||||||
|
|
||||||
|
if (tool === 'text') {
|
||||||
|
const value = window.prompt(
|
||||||
|
t('app:annotator.text_prompt', { defaultValue: 'Text eingeben:' }),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
if (value !== null && value.trim().length > 0) {
|
||||||
|
setOps((cur) => [...cur, { tool: 'text', color, width, text: value, at: p }]);
|
||||||
|
setRedoStack([]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === 'pen' || tool === 'highlighter') {
|
||||||
|
draftRef.current = { tool, color, width, points: [p] };
|
||||||
|
} else {
|
||||||
|
draftRef.current = { tool, color, width, from: p, to: p };
|
||||||
|
}
|
||||||
|
setDraftTick((n) => n + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!draftRef.current) return;
|
||||||
|
const p = canvasPoint(e);
|
||||||
|
const cur = draftRef.current;
|
||||||
|
if (cur.tool === 'pen' || cur.tool === 'highlighter') {
|
||||||
|
cur.points = [...(cur.points ?? []), p];
|
||||||
|
} else {
|
||||||
|
cur.to = p;
|
||||||
|
}
|
||||||
|
setDraftTick((n) => n + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId);
|
||||||
|
const cur = draftRef.current;
|
||||||
|
draftRef.current = null;
|
||||||
|
if (!cur) return;
|
||||||
|
const hasContent =
|
||||||
|
(cur.tool === 'pen' || cur.tool === 'highlighter')
|
||||||
|
? (cur.points?.length ?? 0) >= 2
|
||||||
|
: !!(cur.from && cur.to && (cur.from.x !== cur.to.x || cur.from.y !== cur.to.y));
|
||||||
|
if (hasContent) {
|
||||||
|
setOps((p) => [...p, cur]);
|
||||||
|
setRedoStack([]);
|
||||||
|
}
|
||||||
|
setDraftTick((n) => n + 1);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire the handlers onto the `<canvas>` element — replace the existing `<canvas ref={canvasRef} className="..." />` JSX with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerLeave={handlePointerUp}
|
||||||
|
className="max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-black shadow-2xl"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Typecheck**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/desktop/src/components/ImageAnnotator.tsx
|
||||||
|
git commit -m "feat(P4A.T2): annotator drawing engine — pen/arrow/rect/circle/text/highlighter"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Toolbar — tool picker, color swatches, width swatches, undo/redo
|
||||||
|
|
||||||
|
**Why:** The user can already DRAW (default pen + red + width 4) — but can't change tool/color/width or visibly undo. This task adds the controls.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/desktop/src/components/ImageAnnotator.tsx` (replace the footer)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the tool/color/width palette to the footer**
|
||||||
|
|
||||||
|
Replace the existing `<footer>` element with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<footer className="flex shrink-0 flex-wrap items-center gap-4 border-t border-line/40 bg-surface-2 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{(['pen', 'highlighter', 'arrow', 'rect', 'circle', 'text'] as AnnotatorTool[]).map((id) => {
|
||||||
|
const label = t('app:annotator.tool.' + id, {
|
||||||
|
defaultValue:
|
||||||
|
id === 'pen' ? 'Stift'
|
||||||
|
: id === 'highlighter' ? 'Marker'
|
||||||
|
: id === 'arrow' ? 'Pfeil'
|
||||||
|
: id === 'rect' ? 'Rechteck'
|
||||||
|
: id === 'circle' ? 'Kreis'
|
||||||
|
: 'Text',
|
||||||
|
});
|
||||||
|
const active = tool === id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTool(id)}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={label}
|
||||||
|
className={
|
||||||
|
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-xs font-semibold transition ' +
|
||||||
|
(active
|
||||||
|
? 'bg-accent/20 text-accent ring-2 ring-accent/40'
|
||||||
|
: 'bg-surface-3 text-fg-muted hover:bg-surface hover:text-fg')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{toolGlyph(id)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{(['#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7', '#ffffff'] as AnnotatorColor[]).map((c) => {
|
||||||
|
const active = color === c;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
aria-pressed={active}
|
||||||
|
aria-label={c}
|
||||||
|
className={
|
||||||
|
'h-6 w-6 cursor-pointer rounded-full border-2 transition ' +
|
||||||
|
(active ? 'border-fg scale-110' : 'border-line/40 hover:scale-105')
|
||||||
|
}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{([2, 4, 8] as AnnotatorWidth[]).map((w) => {
|
||||||
|
const active = width === w;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={w}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWidth(w)}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={w + 'px'}
|
||||||
|
className={
|
||||||
|
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md transition ' +
|
||||||
|
(active
|
||||||
|
? 'bg-accent/20 ring-2 ring-accent/40'
|
||||||
|
: 'bg-surface-3 hover:bg-surface')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-full bg-fg"
|
||||||
|
style={{ width: w + 2 + 'px', height: w + 2 + 'px' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleUndo}
|
||||||
|
disabled={ops.length === 0}
|
||||||
|
title={t('app:annotator.undo', { defaultValue: 'Rückgängig (Ctrl+Z)' })}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
↶
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRedo}
|
||||||
|
disabled={redoStack.length === 0}
|
||||||
|
title={t('app:annotator.redo', { defaultValue: 'Wiederholen (Ctrl+Y)' })}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
↷
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={ops.length === 0}
|
||||||
|
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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('app:annotator.reset', { defaultValue: 'Zurücksetzen' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!imageLoaded}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{t('app:annotator.save', { defaultValue: 'Speichern' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
```
|
||||||
|
|
||||||
|
And add this helper at the bottom of the file (after `renderOp`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function toolGlyph(t: AnnotatorTool): string {
|
||||||
|
switch (t) {
|
||||||
|
case 'pen': return '✎';
|
||||||
|
case 'highlighter': return '🖍';
|
||||||
|
case 'arrow': return '↗';
|
||||||
|
case 'rect': return '▭';
|
||||||
|
case 'circle': return '◯';
|
||||||
|
case 'text': return 'T';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Typecheck**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/desktop/src/components/ImageAnnotator.tsx
|
||||||
|
git commit -m "feat(P4A.T3): annotator toolbar — tool/color/width/undo/redo controls"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Wire annotator into the composer — "✏" overlay on image previews + Save round-trip
|
||||||
|
|
||||||
|
**Why:** The annotator is fully functional but unreachable from the UI. This task adds the entry point.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/desktop/src/pages/ConversationPage.tsx` (the `AttachmentPreview` function at line ~1393 + the call site at line ~915 + add ImageAnnotator import/state)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Extend `AttachmentPreview` with an `onEdit` prop**
|
||||||
|
|
||||||
|
Replace the existing `AttachmentPreview` function (line ~1393) with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function AttachmentPreview({
|
||||||
|
file,
|
||||||
|
onRemove,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
file: File;
|
||||||
|
onRemove: () => void;
|
||||||
|
onEdit?: () => void;
|
||||||
|
}) {
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isImage) return;
|
||||||
|
const u = URL.createObjectURL(file);
|
||||||
|
setUrl(u);
|
||||||
|
return () => URL.revokeObjectURL(u);
|
||||||
|
}, [file, isImage]);
|
||||||
|
return (
|
||||||
|
<div className="group relative overflow-hidden rounded-lg border border-line bg-surface-2 dark:bg-[#2b2d31]">
|
||||||
|
{isImage && url ? (
|
||||||
|
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-20 w-32 flex-col justify-center gap-0.5 px-2 text-[10px]">
|
||||||
|
<span className="truncate font-semibold text-fg" title={file.name}>
|
||||||
|
{file.name || 'Datei'}
|
||||||
|
</span>
|
||||||
|
<span className="text-fg-muted">{file.type || 'unbekannt'}</span>
|
||||||
|
<span className="text-fg-muted">{(file.size / 1024).toFixed(0)} KB</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isImage && onEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
aria-label="Bearbeiten"
|
||||||
|
title="Bearbeiten"
|
||||||
|
className="absolute bottom-1 left-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white opacity-0 transition group-hover:opacity-100 hover:bg-accent/80"
|
||||||
|
>
|
||||||
|
<PencilIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
aria-label="Entfernen"
|
||||||
|
className="absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white transition hover:bg-rose-500/80"
|
||||||
|
>
|
||||||
|
<XIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `PencilIcon` isn't already exported from `./components/icons` (grep first: `Grep -n "PencilIcon\|EditIcon" apps/desktop/src/components/icons*`), inline this small SVG below `AttachmentPreview`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function PencilIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||||
|
<path d="M12 20h9" />
|
||||||
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add ImageAnnotator state + import to ConversationPage**
|
||||||
|
|
||||||
|
In the import block at the top of the file (alongside other `../components/...` imports), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { ImageAnnotator } from '../components/ImageAnnotator';
|
||||||
|
```
|
||||||
|
|
||||||
|
Near the top of `ConversationPage` where other `useState`s live (e.g. near `attachments`), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Pass `onEdit` to AttachmentPreview**
|
||||||
|
|
||||||
|
In the `attachments.map(...)` at line ~915, change the JSX to:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{attachments.map((file, idx) => (
|
||||||
|
<AttachmentPreview
|
||||||
|
key={idx}
|
||||||
|
file={file}
|
||||||
|
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
|
||||||
|
onEdit={
|
||||||
|
file.type.startsWith('image/')
|
||||||
|
? () => setAnnotatingIndex(idx)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Render the annotator at the page root when `annotatingIndex !== null`**
|
||||||
|
|
||||||
|
Place this near the end of the JSX return — just before the closing `</div>` of the page root:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{annotatingIndex !== null && attachments[annotatingIndex] && (
|
||||||
|
<ImageAnnotator
|
||||||
|
file={attachments[annotatingIndex]!}
|
||||||
|
onCancel={() => setAnnotatingIndex(null)}
|
||||||
|
onSave={(next) => {
|
||||||
|
setAttachments((prev) => prev.map((f, i) => (i === annotatingIndex ? next : f)));
|
||||||
|
setAnnotatingIndex(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Typecheck**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/desktop/src/pages/ConversationPage.tsx
|
||||||
|
git commit -m "feat(P4A.T4): wire ImageAnnotator into composer attachment preview"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 (sanity — these changes don't touch shared)**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/shared test -- --run`
|
||||||
|
Expected: PASS — same count as Phase 3 baseline (33 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify no uncommitted changes**
|
||||||
|
|
||||||
|
Run: `git status`
|
||||||
|
Expected: clean working tree on `main`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Report**
|
||||||
|
|
||||||
|
Report: "Phase 4A (Image Annotation) code-complete on `main`. Restart dev, attach an image in any chat, hover the preview → ✏ button appears → click → annotator opens. Draw, choose tools/colors/widths, undo/redo, Save → preview updates with the annotated PNG, ready to send. No migration. Released? defer to user."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review (resolved inline)
|
||||||
|
|
||||||
|
1. **Spec coverage** (against `docs/superpowers/specs/2026-05-16-fifteen-features-design.md` lines 97-101):
|
||||||
|
- "Attachment-picker for images shows a new ✏ Bearbeiten button before send" → T4 adds the overlay button (image-only via `file.type.startsWith('image/')`).
|
||||||
|
- "Opens `<ImageAnnotator>` modal: canvas overlay on the image" → T1 mounts canvas at image's natural size.
|
||||||
|
- "Tools: pen, arrow, rectangle, circle, text, highlighter" → T2 implements all 6 in `renderOp`.
|
||||||
|
- "6 colors, 3 stroke widths" → T3 swatches use the exact AnnotatorColor/AnnotatorWidth tuples from T1.
|
||||||
|
- "Undo/Redo stack, Reset, Save" → T1 callbacks + T3 toolbar buttons; Ctrl+Z / Ctrl+Y bindings in T1 keydown effect.
|
||||||
|
- "On Save: canvas.toBlob({type: 'image/png'}) flattens" → T1's `handleSave` + T4's `setAttachments(... map ... idx === annotatingIndex ? next : f)`.
|
||||||
|
|
||||||
|
2. **Placeholders:** none. Every step has concrete code.
|
||||||
|
|
||||||
|
3. **Type consistency:**
|
||||||
|
- `AnnotatorTool`/`AnnotatorColor`/`AnnotatorWidth` defined in T1, consumed everywhere downstream.
|
||||||
|
- `AnnotatorOp` fields match `renderOp` switch arms in T2.
|
||||||
|
- `ImageAnnotator` props shape (`file`/`onCancel`/`onSave`) used identically in T4's call site.
|
||||||
|
- `AttachmentPreview` extended with optional `onEdit?: () => void` in T4; existing callers passing only `file`/`onRemove` continue to compile because it's optional.
|
||||||
|
|
||||||
|
4. **One ambiguity surfaced + resolved:** the spec doesn't say whether to overwrite the original File or keep a side-by-side copy. Plan replaces the slot with a `<basename>-annotated.png` File so the recipient sees the marked-up version with a clear filename hint.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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
|
||||||
}
|
}
|
||||||
@@ -477,6 +480,176 @@ export type Database = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
conversation_whiteboards: {
|
||||||
|
Row: {
|
||||||
|
id: string
|
||||||
|
conversation_id: string
|
||||||
|
owner_user_id: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
id?: string
|
||||||
|
conversation_id: string
|
||||||
|
owner_user_id: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
id?: string
|
||||||
|
conversation_id?: string
|
||||||
|
owner_user_id?: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "conversation_whiteboards_conversation_id_fkey"
|
||||||
|
columns: ["conversation_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "conversations"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
whiteboard_strokes: {
|
||||||
|
Row: {
|
||||||
|
id: string
|
||||||
|
whiteboard_id: string
|
||||||
|
author_user_id: string
|
||||||
|
stroke_json: Json
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
id?: string
|
||||||
|
whiteboard_id: string
|
||||||
|
author_user_id: string
|
||||||
|
stroke_json: Json
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
id?: string
|
||||||
|
whiteboard_id?: string
|
||||||
|
author_user_id?: string
|
||||||
|
stroke_json?: Json
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "whiteboard_strokes_whiteboard_id_fkey"
|
||||||
|
columns: ["whiteboard_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "conversation_whiteboards"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
user_soundboards: {
|
||||||
|
Row: {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
name: string
|
||||||
|
mime: string
|
||||||
|
size: number
|
||||||
|
category: string | null
|
||||||
|
hotkey: string | null
|
||||||
|
gain: number
|
||||||
|
sort_order: number
|
||||||
|
storage_path: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
id?: string
|
||||||
|
user_id: string
|
||||||
|
name: string
|
||||||
|
mime: string
|
||||||
|
size: number
|
||||||
|
category?: string | null
|
||||||
|
hotkey?: string | null
|
||||||
|
gain?: number
|
||||||
|
sort_order?: number
|
||||||
|
storage_path: string
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
id?: string
|
||||||
|
user_id?: string
|
||||||
|
name?: string
|
||||||
|
mime?: string
|
||||||
|
size?: number
|
||||||
|
category?: string | null
|
||||||
|
hotkey?: string | null
|
||||||
|
gain?: number
|
||||||
|
sort_order?: number
|
||||||
|
storage_path?: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
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
|
||||||
@@ -485,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
|
||||||
|
|||||||
@@ -76,7 +76,32 @@ export interface PollPayload {
|
|||||||
options: PollOption[];
|
options: PollOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MessagePayload = TextMessagePayload | CallEventPayload | PollPayload;
|
export interface WhiteboardPayload {
|
||||||
|
v: 1;
|
||||||
|
type: 'whiteboard';
|
||||||
|
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 =
|
||||||
|
| TextMessagePayload
|
||||||
|
| CallEventPayload
|
||||||
|
| PollPayload
|
||||||
|
| WhiteboardPayload
|
||||||
|
| WatchTogetherPayload
|
||||||
|
| GamePayload;
|
||||||
|
|
||||||
export type ParsedMessagePayload =
|
export type ParsedMessagePayload =
|
||||||
| {
|
| {
|
||||||
@@ -95,6 +120,19 @@ export type ParsedMessagePayload =
|
|||||||
kind: 'poll';
|
kind: 'poll';
|
||||||
question: string;
|
question: string;
|
||||||
options: PollOption[];
|
options: PollOption[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'whiteboard';
|
||||||
|
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 {
|
||||||
@@ -151,6 +189,26 @@ export function parseMessagePayload(raw: string | null): ParsedMessagePayload {
|
|||||||
options,
|
options,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (obj.type === 'whiteboard') {
|
||||||
|
const p = obj as Partial<WhiteboardPayload>;
|
||||||
|
const id = typeof p.whiteboard_id === 'string' && p.whiteboard_id.length > 0
|
||||||
|
? p.whiteboard_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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ export * from './userKeyMigration';
|
|||||||
export * from './pinnedMessages';
|
export * from './pinnedMessages';
|
||||||
export * from './mentions';
|
export * from './mentions';
|
||||||
export * from './viewOnceAttachments';
|
export * from './viewOnceAttachments';
|
||||||
|
export * from './whiteboards';
|
||||||
|
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(
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { getCryptoBackend, setCryptoBackend } from '../crypto/backend';
|
||||||
|
import { makeWasmTestBackend } from '../crypto/testBackend';
|
||||||
|
import {
|
||||||
|
decryptSoundEnvelope,
|
||||||
|
encryptSoundBlob,
|
||||||
|
listOwnSounds,
|
||||||
|
upsertSound,
|
||||||
|
} from './soundboards';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
setCryptoBackend(await makeWasmTestBackend());
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeClient(opts: {
|
||||||
|
user?: { id: string } | null;
|
||||||
|
selectData?: unknown[];
|
||||||
|
upsertReturn?: { data: unknown; error: unknown };
|
||||||
|
}): any {
|
||||||
|
const order = vi.fn().mockResolvedValue({ data: opts.selectData ?? [], error: null });
|
||||||
|
const eq = vi.fn().mockReturnValue({ order });
|
||||||
|
const selectChain = vi.fn().mockReturnValue({ eq });
|
||||||
|
const single = vi.fn().mockResolvedValue(opts.upsertReturn ?? { data: {}, error: null });
|
||||||
|
const upsertSelect = vi.fn().mockReturnValue({ single });
|
||||||
|
const upsertChain = vi.fn().mockReturnValue({ select: upsertSelect });
|
||||||
|
const from = vi.fn().mockReturnValue({
|
||||||
|
select: selectChain,
|
||||||
|
upsert: upsertChain,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: opts.user ?? { id: 'u-1' } } }) },
|
||||||
|
from,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('encryptSoundBlob ↔ decryptSoundEnvelope', () => {
|
||||||
|
it('round-trips bytes via sealed-to-self crypto_box', async () => {
|
||||||
|
const backend = getCryptoBackend();
|
||||||
|
const seed = backend.randomBytes(32);
|
||||||
|
const pub = backend.scalarMultBase(seed);
|
||||||
|
const blob = new Blob([new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])]);
|
||||||
|
|
||||||
|
const envelope = await encryptSoundBlob(blob, pub, seed);
|
||||||
|
expect(envelope.length).toBeGreaterThan(24);
|
||||||
|
|
||||||
|
const plain = await decryptSoundEnvelope(envelope, pub, seed);
|
||||||
|
expect(Array.from(plain)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an envelope that is too short', async () => {
|
||||||
|
const backend = getCryptoBackend();
|
||||||
|
const seed = backend.randomBytes(32);
|
||||||
|
const pub = backend.scalarMultBase(seed);
|
||||||
|
await expect(
|
||||||
|
decryptSoundEnvelope(new Uint8Array(20), pub, seed),
|
||||||
|
).rejects.toThrow(/too_short/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listOwnSounds', () => {
|
||||||
|
it('maps DB rows to camelCase', async () => {
|
||||||
|
const client = makeClient({
|
||||||
|
selectData: [
|
||||||
|
{
|
||||||
|
id: 's-1',
|
||||||
|
user_id: 'u-1',
|
||||||
|
name: 'horn',
|
||||||
|
mime: 'audio/mpeg',
|
||||||
|
size: 1234,
|
||||||
|
category: 'fx',
|
||||||
|
hotkey: 'F1',
|
||||||
|
gain: 0.8,
|
||||||
|
sort_order: 0,
|
||||||
|
storage_path: 'u-1/s-1.bin',
|
||||||
|
created_at: '2026-05-16T00:00:00Z',
|
||||||
|
updated_at: '2026-05-16T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const out = await listOwnSounds(client);
|
||||||
|
expect(out[0]).toEqual({
|
||||||
|
id: 's-1',
|
||||||
|
userId: 'u-1',
|
||||||
|
name: 'horn',
|
||||||
|
mime: 'audio/mpeg',
|
||||||
|
size: 1234,
|
||||||
|
category: 'fx',
|
||||||
|
hotkey: 'F1',
|
||||||
|
gain: 0.8,
|
||||||
|
sortOrder: 0,
|
||||||
|
storagePath: 'u-1/s-1.bin',
|
||||||
|
createdAt: '2026-05-16T00:00:00Z',
|
||||||
|
updatedAt: '2026-05-16T00:00:00Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('upsertSound', () => {
|
||||||
|
it('returns mapped RemoteSound after upsert', async () => {
|
||||||
|
const upsertReturn = {
|
||||||
|
data: {
|
||||||
|
id: 's-1',
|
||||||
|
user_id: 'u-1',
|
||||||
|
name: 'horn',
|
||||||
|
mime: 'audio/mpeg',
|
||||||
|
size: 1234,
|
||||||
|
category: null,
|
||||||
|
hotkey: null,
|
||||||
|
gain: 1,
|
||||||
|
sort_order: 0,
|
||||||
|
storage_path: 'u-1/s-1.bin',
|
||||||
|
created_at: '2026-05-16T00:00:00Z',
|
||||||
|
updated_at: '2026-05-16T00:00:00Z',
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
const client = makeClient({ upsertReturn });
|
||||||
|
const out = await upsertSound(client, {
|
||||||
|
id: 's-1',
|
||||||
|
name: 'horn',
|
||||||
|
mime: 'audio/mpeg',
|
||||||
|
size: 1234,
|
||||||
|
category: null,
|
||||||
|
hotkey: null,
|
||||||
|
gain: 1,
|
||||||
|
sortOrder: 0,
|
||||||
|
storagePath: 'u-1/s-1.bin',
|
||||||
|
updatedAtIso: '2026-05-16T00:00:00Z',
|
||||||
|
});
|
||||||
|
expect(out.id).toBe('s-1');
|
||||||
|
expect(out.userId).toBe('u-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { decryptFrom, encryptFor } from '../crypto/box';
|
||||||
|
import type { AppSupabaseClient } from '../supabase/client';
|
||||||
|
|
||||||
|
export const SOUNDBOARDS_BUCKET = 'soundboards';
|
||||||
|
|
||||||
|
export interface RemoteSound {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
category: string | null;
|
||||||
|
hotkey: string | null;
|
||||||
|
gain: number;
|
||||||
|
sortOrder: number;
|
||||||
|
storagePath: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertSoundInput {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
category: string | null;
|
||||||
|
hotkey: string | null;
|
||||||
|
gain: number;
|
||||||
|
sortOrder: number;
|
||||||
|
storagePath: string;
|
||||||
|
updatedAtIso: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOwnSounds(client: AppSupabaseClient): Promise<RemoteSound[]> {
|
||||||
|
const { data: session } = await client.auth.getUser();
|
||||||
|
if (!session.user) throw new Error('not authenticated');
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('user_soundboards')
|
||||||
|
.select(
|
||||||
|
'id, user_id, name, mime, size, category, hotkey, gain, sort_order, storage_path, created_at, updated_at',
|
||||||
|
)
|
||||||
|
.eq('user_id', session.user.id)
|
||||||
|
.order('updated_at', { ascending: false });
|
||||||
|
if (error) throw error;
|
||||||
|
return data.map(mapRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertSound(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
input: UpsertSoundInput,
|
||||||
|
): Promise<RemoteSound> {
|
||||||
|
const { data: session } = await client.auth.getUser();
|
||||||
|
if (!session.user) throw new Error('not authenticated');
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('user_soundboards')
|
||||||
|
.upsert(
|
||||||
|
{
|
||||||
|
id: input.id,
|
||||||
|
user_id: session.user.id,
|
||||||
|
name: input.name,
|
||||||
|
mime: input.mime,
|
||||||
|
size: input.size,
|
||||||
|
category: input.category,
|
||||||
|
hotkey: input.hotkey,
|
||||||
|
gain: input.gain,
|
||||||
|
sort_order: input.sortOrder,
|
||||||
|
storage_path: input.storagePath,
|
||||||
|
updated_at: input.updatedAtIso,
|
||||||
|
},
|
||||||
|
{ onConflict: 'id' },
|
||||||
|
)
|
||||||
|
.select(
|
||||||
|
'id, user_id, name, mime, size, category, hotkey, gain, sort_order, storage_path, created_at, updated_at',
|
||||||
|
)
|
||||||
|
.single();
|
||||||
|
if (error) throw error;
|
||||||
|
return mapRow(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSound(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
soundId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const { data: session } = await client.auth.getUser();
|
||||||
|
if (!session.user) throw new Error('not authenticated');
|
||||||
|
const { data: row } = await client
|
||||||
|
.from('user_soundboards')
|
||||||
|
.select('storage_path')
|
||||||
|
.eq('id', soundId)
|
||||||
|
.eq('user_id', session.user.id)
|
||||||
|
.maybeSingle();
|
||||||
|
if (row?.storage_path) {
|
||||||
|
const { error: storageErr } = await client.storage
|
||||||
|
.from(SOUNDBOARDS_BUCKET)
|
||||||
|
.remove([row.storage_path]);
|
||||||
|
if (storageErr && !/not found/i.test(storageErr.message)) throw storageErr;
|
||||||
|
}
|
||||||
|
const { error } = await client
|
||||||
|
.from('user_soundboards')
|
||||||
|
.delete()
|
||||||
|
.eq('id', soundId)
|
||||||
|
.eq('user_id', session.user.id);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sealed-to-self envelope: nonce || ciphertext. encryptFor's sender and
|
||||||
|
// recipient are both the current user, equivalent to crypto_box_seal but
|
||||||
|
// reuses the existing helper (no new backend method).
|
||||||
|
export async function encryptSoundBlob(
|
||||||
|
blob: Blob,
|
||||||
|
myPublicKey: Uint8Array,
|
||||||
|
myPrivateKey: Uint8Array,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||||
|
const { ciphertext, nonce } = await encryptFor(bytes, myPublicKey, myPrivateKey);
|
||||||
|
const out = new Uint8Array(nonce.length + ciphertext.length);
|
||||||
|
out.set(nonce, 0);
|
||||||
|
out.set(ciphertext, nonce.length);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptSoundEnvelope(
|
||||||
|
envelope: Uint8Array,
|
||||||
|
myPublicKey: Uint8Array,
|
||||||
|
myPrivateKey: Uint8Array,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const NONCE_LEN = 24;
|
||||||
|
if (envelope.length < NONCE_LEN + 16) {
|
||||||
|
throw new Error('sound_envelope_too_short');
|
||||||
|
}
|
||||||
|
const nonce = envelope.slice(0, NONCE_LEN);
|
||||||
|
const ciphertext = envelope.slice(NONCE_LEN);
|
||||||
|
return decryptFrom(ciphertext, nonce, myPublicKey, myPrivateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadSoundCiphertext(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
storagePath: string,
|
||||||
|
ciphertext: Uint8Array,
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await client.storage
|
||||||
|
.from(SOUNDBOARDS_BUCKET)
|
||||||
|
.upload(storagePath, ciphertext, {
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadSoundCiphertext(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
storagePath: string,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const { data, error } = await client.storage
|
||||||
|
.from(SOUNDBOARDS_BUCKET)
|
||||||
|
.download(storagePath);
|
||||||
|
if (error) throw error;
|
||||||
|
return new Uint8Array(await data.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRow(row: {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
category: string | null;
|
||||||
|
hotkey: string | null;
|
||||||
|
gain: number;
|
||||||
|
sort_order: number;
|
||||||
|
storage_path: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}): RemoteSound {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
name: row.name,
|
||||||
|
mime: row.mime,
|
||||||
|
size: row.size,
|
||||||
|
category: row.category,
|
||||||
|
hotkey: row.hotkey,
|
||||||
|
gain: row.gain,
|
||||||
|
sortOrder: row.sort_order,
|
||||||
|
storagePath: row.storage_path,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearWhiteboardStrokes,
|
||||||
|
createWhiteboard,
|
||||||
|
insertWhiteboardStroke,
|
||||||
|
listWhiteboardStrokes,
|
||||||
|
} from './whiteboards';
|
||||||
|
|
||||||
|
function makeClient(opts: {
|
||||||
|
user?: { id: string } | null;
|
||||||
|
insertReturn?: { data: unknown; error: unknown };
|
||||||
|
selectReturn?: { data: unknown[]; error: unknown };
|
||||||
|
deleteReturn?: { error: unknown };
|
||||||
|
}): any {
|
||||||
|
const single = vi.fn().mockResolvedValue(opts.insertReturn ?? { data: {}, error: null });
|
||||||
|
const order = vi.fn().mockResolvedValue(opts.selectReturn ?? { data: [], error: null });
|
||||||
|
const eqDelete = vi.fn().mockResolvedValue(opts.deleteReturn ?? { error: null });
|
||||||
|
const insertSelect = vi.fn().mockReturnValue({ single });
|
||||||
|
const insertChain = vi.fn().mockReturnValue({ select: insertSelect });
|
||||||
|
const selectChain = vi.fn().mockReturnValue({
|
||||||
|
eq: vi.fn().mockReturnValue({ order }),
|
||||||
|
});
|
||||||
|
const deleteChain = vi.fn().mockReturnValue({ eq: eqDelete });
|
||||||
|
const from = vi.fn().mockReturnValue({
|
||||||
|
insert: insertChain,
|
||||||
|
select: selectChain,
|
||||||
|
delete: deleteChain,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: opts.user ?? { id: 'u-1' } } }) },
|
||||||
|
from,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createWhiteboard', () => {
|
||||||
|
it('inserts with owner = current user', async () => {
|
||||||
|
const client = makeClient({
|
||||||
|
insertReturn: {
|
||||||
|
data: {
|
||||||
|
id: 'w-1',
|
||||||
|
conversation_id: 'c-1',
|
||||||
|
owner_user_id: 'u-1',
|
||||||
|
created_at: '2026-05-16T00:00:00Z',
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const out = await createWhiteboard(client, 'c-1');
|
||||||
|
expect(out).toEqual({
|
||||||
|
id: 'w-1',
|
||||||
|
conversationId: 'c-1',
|
||||||
|
ownerUserId: 'u-1',
|
||||||
|
createdAt: '2026-05-16T00:00:00Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listWhiteboardStrokes', () => {
|
||||||
|
it('maps DB rows to camelCase + ascending order', async () => {
|
||||||
|
const client = makeClient({
|
||||||
|
selectReturn: {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 's-1',
|
||||||
|
whiteboard_id: 'w-1',
|
||||||
|
author_user_id: 'u-1',
|
||||||
|
stroke_json: { tool: 'pen' },
|
||||||
|
created_at: '2026-05-16T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const out = await listWhiteboardStrokes(client, 'w-1');
|
||||||
|
expect(out).toEqual([
|
||||||
|
{
|
||||||
|
id: 's-1',
|
||||||
|
whiteboardId: 'w-1',
|
||||||
|
authorUserId: 'u-1',
|
||||||
|
strokeJson: { tool: 'pen' },
|
||||||
|
createdAt: '2026-05-16T00:00:00Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('insertWhiteboardStroke', () => {
|
||||||
|
it('writes author + stroke_json', async () => {
|
||||||
|
const client = makeClient({
|
||||||
|
insertReturn: {
|
||||||
|
data: {
|
||||||
|
id: 's-2',
|
||||||
|
whiteboard_id: 'w-1',
|
||||||
|
author_user_id: 'u-1',
|
||||||
|
stroke_json: { tool: 'pen', points: [[0, 0, 0]] },
|
||||||
|
created_at: '2026-05-16T00:00:01Z',
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const out = await insertWhiteboardStroke(client, {
|
||||||
|
whiteboardId: 'w-1',
|
||||||
|
strokeJson: { tool: 'pen', points: [[0, 0, 0]] },
|
||||||
|
});
|
||||||
|
expect(out.id).toBe('s-2');
|
||||||
|
expect((out.strokeJson as { tool: string }).tool).toBe('pen');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearWhiteboardStrokes', () => {
|
||||||
|
it('does not throw on a successful delete', async () => {
|
||||||
|
const client = makeClient({ deleteReturn: { error: null } });
|
||||||
|
await expect(clearWhiteboardStrokes(client, 'w-1')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when delete returns an error', async () => {
|
||||||
|
const client = makeClient({ deleteReturn: { error: { message: 'rls denied' } as any } });
|
||||||
|
await expect(clearWhiteboardStrokes(client, 'w-1')).rejects.toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { Json } from '@chat-app/db-types';
|
||||||
|
|
||||||
|
import type { AppSupabaseClient } from '../supabase/client';
|
||||||
|
|
||||||
|
export interface Whiteboard {
|
||||||
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WhiteboardStroke {
|
||||||
|
id: string;
|
||||||
|
whiteboardId: string;
|
||||||
|
authorUserId: string;
|
||||||
|
strokeJson: unknown;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWhiteboard(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
conversationId: string,
|
||||||
|
): Promise<Whiteboard> {
|
||||||
|
const { data: session } = await client.auth.getUser();
|
||||||
|
if (!session.user) throw new Error('not authenticated');
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('conversation_whiteboards')
|
||||||
|
.insert({
|
||||||
|
conversation_id: conversationId,
|
||||||
|
owner_user_id: session.user.id,
|
||||||
|
})
|
||||||
|
.select('id, conversation_id, owner_user_id, created_at')
|
||||||
|
.single();
|
||||||
|
if (error) throw error;
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
conversationId: data.conversation_id,
|
||||||
|
ownerUserId: data.owner_user_id,
|
||||||
|
createdAt: data.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWhiteboardStrokes(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
whiteboardId: string,
|
||||||
|
): Promise<WhiteboardStroke[]> {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('whiteboard_strokes')
|
||||||
|
.select('id, whiteboard_id, author_user_id, stroke_json, created_at')
|
||||||
|
.eq('whiteboard_id', whiteboardId)
|
||||||
|
.order('created_at', { ascending: true });
|
||||||
|
if (error) throw error;
|
||||||
|
return data.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
whiteboardId: row.whiteboard_id,
|
||||||
|
authorUserId: row.author_user_id,
|
||||||
|
strokeJson: row.stroke_json,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertWhiteboardStroke(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
params: { whiteboardId: string; strokeJson: unknown },
|
||||||
|
): Promise<WhiteboardStroke> {
|
||||||
|
const { data: session } = await client.auth.getUser();
|
||||||
|
if (!session.user) throw new Error('not authenticated');
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('whiteboard_strokes')
|
||||||
|
.insert({
|
||||||
|
whiteboard_id: params.whiteboardId,
|
||||||
|
author_user_id: session.user.id,
|
||||||
|
stroke_json: params.strokeJson as unknown as Json,
|
||||||
|
})
|
||||||
|
.select('id, whiteboard_id, author_user_id, stroke_json, created_at')
|
||||||
|
.single();
|
||||||
|
if (error) throw error;
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
whiteboardId: data.whiteboard_id,
|
||||||
|
authorUserId: data.author_user_id,
|
||||||
|
strokeJson: data.stroke_json,
|
||||||
|
createdAt: data.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearWhiteboardStrokes(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
whiteboardId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await client
|
||||||
|
.from('whiteboard_strokes')
|
||||||
|
.delete()
|
||||||
|
.eq('whiteboard_id', whiteboardId);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
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,122 @@
|
|||||||
|
-- Phase 4B: per-conversation whiteboards.
|
||||||
|
--
|
||||||
|
-- conversation_whiteboards: one row per whiteboard. Created by a member; the
|
||||||
|
-- bubble that appears in the chat message timeline is a normal `messages`
|
||||||
|
-- row whose plaintext payload is `{v:1, type:'whiteboard', whiteboard_id:<id>}`.
|
||||||
|
--
|
||||||
|
-- whiteboard_strokes: append-only stream of drawing operations. Each row is
|
||||||
|
-- one user gesture (pen-down → pen-up); stroke_json carries the tool/color/
|
||||||
|
-- width/points. Realtime subscribers replay rows in created_at order.
|
||||||
|
|
||||||
|
create table if not exists public.conversation_whiteboards (
|
||||||
|
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,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists conversation_whiteboards_conv_idx
|
||||||
|
on public.conversation_whiteboards(conversation_id, created_at desc);
|
||||||
|
|
||||||
|
create table if not exists public.whiteboard_strokes (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
whiteboard_id uuid not null references public.conversation_whiteboards(id) on delete cascade,
|
||||||
|
author_user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
|
stroke_json jsonb not null,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists whiteboard_strokes_board_idx
|
||||||
|
on public.whiteboard_strokes(whiteboard_id, created_at asc);
|
||||||
|
|
||||||
|
alter table public.conversation_whiteboards enable row level security;
|
||||||
|
alter table public.whiteboard_strokes enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists conversation_whiteboards_select on public.conversation_whiteboards;
|
||||||
|
drop policy if exists conversation_whiteboards_insert on public.conversation_whiteboards;
|
||||||
|
drop policy if exists conversation_whiteboards_delete on public.conversation_whiteboards;
|
||||||
|
drop policy if exists whiteboard_strokes_select on public.whiteboard_strokes;
|
||||||
|
drop policy if exists whiteboard_strokes_insert on public.whiteboard_strokes;
|
||||||
|
drop policy if exists whiteboard_strokes_delete on public.whiteboard_strokes;
|
||||||
|
|
||||||
|
create policy conversation_whiteboards_select
|
||||||
|
on public.conversation_whiteboards
|
||||||
|
for select
|
||||||
|
using (public.is_conversation_member(conversation_id));
|
||||||
|
|
||||||
|
create policy conversation_whiteboards_insert
|
||||||
|
on public.conversation_whiteboards
|
||||||
|
for insert
|
||||||
|
with check (
|
||||||
|
public.is_conversation_member(conversation_id)
|
||||||
|
and owner_user_id = auth.uid()
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy conversation_whiteboards_delete
|
||||||
|
on public.conversation_whiteboards
|
||||||
|
for delete
|
||||||
|
using (owner_user_id = auth.uid());
|
||||||
|
|
||||||
|
create policy whiteboard_strokes_select
|
||||||
|
on public.whiteboard_strokes
|
||||||
|
for select
|
||||||
|
using (
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.conversation_whiteboards w
|
||||||
|
where w.id = whiteboard_strokes.whiteboard_id
|
||||||
|
and public.is_conversation_member(w.conversation_id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy whiteboard_strokes_insert
|
||||||
|
on public.whiteboard_strokes
|
||||||
|
for insert
|
||||||
|
with check (
|
||||||
|
author_user_id = auth.uid()
|
||||||
|
and exists (
|
||||||
|
select 1
|
||||||
|
from public.conversation_whiteboards w
|
||||||
|
where w.id = whiteboard_id
|
||||||
|
and public.is_conversation_member(w.conversation_id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy whiteboard_strokes_delete
|
||||||
|
on public.whiteboard_strokes
|
||||||
|
for delete
|
||||||
|
using (
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.conversation_whiteboards w
|
||||||
|
where w.id = whiteboard_strokes.whiteboard_id
|
||||||
|
and public.is_conversation_member(w.conversation_id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.conversation_whiteboards replica identity full;
|
||||||
|
alter table public.whiteboard_strokes replica identity full;
|
||||||
|
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from pg_publication_tables
|
||||||
|
where pubname = 'supabase_realtime'
|
||||||
|
and schemaname = 'public'
|
||||||
|
and tablename = 'conversation_whiteboards'
|
||||||
|
) then
|
||||||
|
execute 'alter publication supabase_realtime add table public.conversation_whiteboards';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from pg_publication_tables
|
||||||
|
where pubname = 'supabase_realtime'
|
||||||
|
and schemaname = 'public'
|
||||||
|
and tablename = 'whiteboard_strokes'
|
||||||
|
) then
|
||||||
|
execute 'alter publication supabase_realtime add table public.whiteboard_strokes';
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
-- Phase 4C: cloud-synced soundboard.
|
||||||
|
--
|
||||||
|
-- user_soundboards: one row per cloud-known sound. The audio payload itself
|
||||||
|
-- lives in the `soundboards` storage bucket at `<user_id>/<sound_id>.bin`,
|
||||||
|
-- E2E-encrypted with the user's own X25519 key (sealed-to-self). The first
|
||||||
|
-- 24 bytes of the stored object are the XSalsa20 nonce; the rest is the
|
||||||
|
-- Poly1305-authenticated ciphertext.
|
||||||
|
--
|
||||||
|
-- Metadata (name, gain, hotkey, category, sort_order) is stored plaintext —
|
||||||
|
-- not considered sensitive by the spec — so other devices of the same user
|
||||||
|
-- can read it without holding the private key.
|
||||||
|
|
||||||
|
create table if not exists public.user_soundboards (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
mime text not null,
|
||||||
|
size bigint not null,
|
||||||
|
category text null,
|
||||||
|
hotkey text null,
|
||||||
|
gain real not null default 1.0,
|
||||||
|
sort_order integer not null default 0,
|
||||||
|
storage_path text not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint user_soundboards_name_len check (length(name) between 1 and 200),
|
||||||
|
constraint user_soundboards_size_pos check (size > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists user_soundboards_user_idx
|
||||||
|
on public.user_soundboards(user_id, updated_at desc);
|
||||||
|
|
||||||
|
alter table public.user_soundboards enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists user_soundboards_select on public.user_soundboards;
|
||||||
|
drop policy if exists user_soundboards_insert on public.user_soundboards;
|
||||||
|
drop policy if exists user_soundboards_update on public.user_soundboards;
|
||||||
|
drop policy if exists user_soundboards_delete on public.user_soundboards;
|
||||||
|
|
||||||
|
create policy user_soundboards_select
|
||||||
|
on public.user_soundboards
|
||||||
|
for select
|
||||||
|
using (user_id = auth.uid());
|
||||||
|
|
||||||
|
create policy user_soundboards_insert
|
||||||
|
on public.user_soundboards
|
||||||
|
for insert
|
||||||
|
with check (user_id = auth.uid());
|
||||||
|
|
||||||
|
create policy user_soundboards_update
|
||||||
|
on public.user_soundboards
|
||||||
|
for update
|
||||||
|
using (user_id = auth.uid())
|
||||||
|
with check (user_id = auth.uid());
|
||||||
|
|
||||||
|
create policy user_soundboards_delete
|
||||||
|
on public.user_soundboards
|
||||||
|
for delete
|
||||||
|
using (user_id = auth.uid());
|
||||||
|
|
||||||
|
alter table public.user_soundboards replica identity full;
|
||||||
|
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from pg_publication_tables
|
||||||
|
where pubname = 'supabase_realtime'
|
||||||
|
and schemaname = 'public'
|
||||||
|
and tablename = 'user_soundboards'
|
||||||
|
) then
|
||||||
|
execute 'alter publication supabase_realtime add table public.user_soundboards';
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Storage bucket: private, owner-only.
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (select 1 from storage.buckets where id = 'soundboards') then
|
||||||
|
insert into storage.buckets (id, name, public)
|
||||||
|
values ('soundboards', 'soundboards', false);
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop policy if exists soundboards_select on storage.objects;
|
||||||
|
drop policy if exists soundboards_insert on storage.objects;
|
||||||
|
drop policy if exists soundboards_update on storage.objects;
|
||||||
|
drop policy if exists soundboards_delete on storage.objects;
|
||||||
|
|
||||||
|
create policy soundboards_select
|
||||||
|
on storage.objects
|
||||||
|
for select
|
||||||
|
using (
|
||||||
|
bucket_id = 'soundboards'
|
||||||
|
and auth.uid()::text = (storage.foldername(name))[1]
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy soundboards_insert
|
||||||
|
on storage.objects
|
||||||
|
for insert
|
||||||
|
with check (
|
||||||
|
bucket_id = 'soundboards'
|
||||||
|
and auth.uid()::text = (storage.foldername(name))[1]
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy soundboards_update
|
||||||
|
on storage.objects
|
||||||
|
for update
|
||||||
|
using (
|
||||||
|
bucket_id = 'soundboards'
|
||||||
|
and auth.uid()::text = (storage.foldername(name))[1]
|
||||||
|
)
|
||||||
|
with check (
|
||||||
|
bucket_id = 'soundboards'
|
||||||
|
and auth.uid()::text = (storage.foldername(name))[1]
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy soundboards_delete
|
||||||
|
on storage.objects
|
||||||
|
for delete
|
||||||
|
using (
|
||||||
|
bucket_id = 'soundboards'
|
||||||
|
and auth.uid()::text = (storage.foldername(name))[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