Compare commits

..

44 Commits

Author SHA1 Message Date
byGalax 6d0e4fb1f0 perf(P6A.T5): pre-warm Supabase + avatar loading hints
Fire a no-await profiles query in AuthContext on session establish to absorb
cold-connection latency before the first user-triggered request. Add
loading='lazy' default to the central Avatar component so all off-screen
avatars (chat list, friends list, popovers, message senders) skip eager
Supabase Storage fetches; set loading='eager' on ConversationHeader (active
conv header) and CallParticipantTile inline imgs (both AudioContent and
VideoStub) which are always above-the-fold when visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:43:04 +02:00
byGalax 36ab7eca8a perf(P6A.T4): wrap all icon components in React.memo
All 57 exported SVG icon components in icons.tsx are now memoised via
React.memo, giving React permission to skip re-renders when props are
referentially equal.  Consumer icon-prop types updated from the legacy
SVGProps (includes string refs) to ComponentPropsWithoutRef<'svg'> so
the MemoExoticComponent return type satisfies TypeScript without casts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:39:47 +02:00
byGalax ddd696a790 perf(P6A.T3): memoize MessageBubble + stabilize parent callbacks 2026-05-16 23:30:50 +02:00
byGalax 53b3b5e1fc perf(P6A.T2): respect prefers-reduced-motion globally + gate confetti
- Update globals.css reduced-motion block: 0.01ms → 0.001ms durations
  and add scroll-behavior: auto to suppress all transitions/animations
  when OS reduced-motion preference is active.
- Gate canvas-confetti burst in GameModal behind matchMedia check so
  the particle effect is skipped entirely for users who opt out of motion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:25:01 +02:00
byGalax a4573b315d perf(P6A.T1): lazy-load ImageAnnotator/Whiteboard/WatchTogether/GameModal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:22:52 +02:00
byGalax c271e95100 docs(P6): Phase 6 performance pack plan (Groups A/B/C + deferred channel pooling) 2026-05-16 23:16:42 +02:00
byGalax 888ed1b217 feat(P5C.T4): confetti burst on game win
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:26:25 +02:00
byGalax a974e5b8ab feat(P5C.T3): recompute message_mentions on edit
After the ciphertext UPDATE succeeds in editEncryptedMessage, delete the
existing message_mentions rows for that message then re-resolve and
re-insert from the new plaintext — matching the best-effort pattern used
by insertMessage. No signature change needed; conversationId and
newPlaintext were already present in EditMessageParams.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:24:17 +02:00
byGalax 7ebc5f6c9d feat(P5C.T2): per-conv 'mentions only' toggle + notification gate 2026-05-16 22:22:04 +02:00
byGalax b8b451ef4f feat(P5C.T2-sql): conversation_members.mentions_only column 2026-05-16 22:17:04 +02:00
byGalax d7c0c3d0a2 feat(P5C.T1): empty-state for empty chat-list search results 2026-05-16 22:14:33 +02:00
byGalax 70ce824120 docs(P5C): Phase 5C polish plan (4 spec sub-items) 2026-05-16 22:12:27 +02:00
byGalax d10840e0b2 feat(P5B.T6): composer Spielen button + bubble dispatch + game-picker
Wires mini-game entry points: createGamePayload helper in conversationFeatures.ts (GamePayload import added), game state/handler/picker-dialog/modal-mount/open-event-listener in ConversationPage.tsx (DM-only guard uses conversation.members[].userId camelCase as confirmed), and parsed.kind==='game' bubble branch in MessageBubble.tsx dispatching chatapp:open-game CustomEvent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:58:10 +02:00
byGalax cd59ee30d6 feat(P5B.T5): GameModal — picks board + turn/winner status 2026-05-16 21:53:42 +02:00
byGalax e56e22631b feat(P5B.T4): TicTacToeBoard + ConnectFourBoard render-only components 2026-05-16 21:51:06 +02:00
byGalax dbf90504b4 feat(P5B.T3): useGame hook with realtime + makeMove 2026-05-16 21:49:00 +02:00
byGalax 256a613134 feat(P5B.T2): GamePayload + games wrappers + winner-detect helpers + tests
Adds conversation_games table type + game_make_move RPC to db-types, GamePayload variant and parseMessagePayload branch to shared/attachments, games.ts with createGame/getGame/makeGameMove wrappers plus pure tttWinningLine/c4WinningCells/c4DropRow/isBoardFull helpers, 11 tests covering all winner helpers, and re-export from chat/index.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:47:36 +02:00
byGalax e1423dba32 feat(P5B.T1): conversation_games table + game_make_move RPC + RLS + realtime 2026-05-16 21:42:47 +02:00
byGalax 7730828403 docs(P5B): Phase 5B implementation plan (Mini-Games) 2026-05-16 21:39:12 +02:00
byGalax ecbd11e369 feat(P5A.T5): composer Watch-Together button + bubble dispatch 2026-05-16 21:30:22 +02:00
byGalax 7d5f3b37cd feat(P5A.T4): WatchTogetherModal — YouTube IFrame + drift reconcile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:26:59 +02:00
byGalax 7ce6b1c1d4 feat(P5A.T3): useWatchSession hook with realtime + throttled push
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:23:39 +02:00
byGalax 4399d39f08 feat(P5A.T2): WatchTogetherPayload + wrappers + parseYouTubeUrl + tests 2026-05-16 21:21:30 +02:00
byGalax ad239ec549 feat(P5A.T1): conversation_watch_sessions table with RLS + realtime 2026-05-16 21:16:38 +02:00
byGalax d1193142ae docs(P5A): Phase 5A implementation plan (Watch-Together / YouTube) 2026-05-16 21:14:38 +02:00
byGalax 997252c4cb chore(desktop): release v0.19.1 2026-05-16 21:07:11 +02:00
byGalax 890d5dc2b7 feat(P4C.T4): SoundboardSettings — sync hook mount + per-row badges + remote-delete
Mount useSoundboardSync in SoundboardManagerDialog, thread badges map through
SoundboardCategoryGroup/SoundboardRow, render a cloud-state glyph badge inline
with each row's size/mime metadata, and wrap handleDelete to attempt a
best-effort remote delete via deleteRemoteSound before the local deleteSound call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:02:26 +02:00
byGalax 759113b9ce feat(P4C.T3): useSoundboardSync — initial diff + realtime + debounced push 2026-05-16 20:58:48 +02:00
byGalax f981904e7f feat(P4C.T2): shared soundboards wrappers + sealed-blob crypto + tests 2026-05-16 20:53:48 +02:00
byGalax 1fe196b839 feat(P4C.T1): user_soundboards table + soundboards storage bucket + RLS 2026-05-16 20:49:10 +02:00
byGalax 1b4cf63e07 docs(P4C): Phase 4C implementation plan (Soundboard Cloud-Sync, E2E encrypted) 2026-05-16 20:46:57 +02:00
byGalax 18197a9f95 feat(P4B.T7): MessageBubble renders whiteboard kind with Öffnen button
Adds a whiteboard branch to the MessageBubble ternary chain that renders
a card with an icon, label, and Öffnen button dispatching the
chatapp:open-whiteboard CustomEvent. ConversationPage now listens for
that event via a useEffect and calls setOpenWhiteboardId to open the
modal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 20:38:03 +02:00
byGalax 383245ec90 feat(P4B.T6): composer 'Whiteboard' button creates board + sends bubble 2026-05-16 20:36:03 +02:00
byGalax 5e59ee22f6 feat(P4B.T5): WhiteboardModal — fullscreen container + toolbar + clear-confirm 2026-05-16 20:29:24 +02:00
byGalax be6f5b9c6f feat(P4B.T4): WhiteboardCanvas — pen/eraser drawing engine 2026-05-16 20:27:09 +02:00
byGalax 2243c646ac feat(P4B.T3): useWhiteboardStrokes hook with INSERT + bulk DELETE realtime
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 20:25:22 +02:00
byGalax 3df7cc01ea feat(P4B.T2): WhiteboardPayload + shared whiteboards CRUD wrappers + tests
Adds WhiteboardPayload wire-format variant to MessagePayload/ParsedMessagePayload,
creates the whiteboards.ts CRUD wrapper (createWhiteboard, listWhiteboardStrokes,
insertWhiteboardStroke, clearWhiteboardStrokes), writes 5 unit tests (38/38 pass),
re-exports from chat/index.ts, and registers conversation_whiteboards +
whiteboard_strokes in packages/db-types so typecheck passes cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 20:23:45 +02:00
byGalax f95858c703 feat(P4B.T1): whiteboards + whiteboard_strokes tables with RLS + realtime 2026-05-16 20:01:28 +02:00
byGalax d29e2c1174 docs(P4B): Phase 4B implementation plan (Whiteboard / Snapshot-Sync) 2026-05-16 19:59:10 +02:00
byGalax 11a9c8b173 feat(P4A.T4): wire ImageAnnotator into composer attachment preview
Hover an image attachment in the composer to reveal a pencil-edit button;
clicking it opens ImageAnnotator and Save replaces the File in attachments[].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 19:48:57 +02:00
byGalax d623a59c87 feat(P4A.T3): annotator toolbar — tool/color/width/undo/redo controls 2026-05-16 19:46:00 +02:00
byGalax 2a2e334f51 feat(P4A.T2): annotator drawing engine — pen/arrow/rect/circle/text/highlighter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 19:44:04 +02:00
byGalax 9228cc635c feat(P4A.T1): ImageAnnotator modal skeleton with canvas mount + op-stack types 2026-05-16 19:41:36 +02:00
byGalax 7056bfdd1c docs(P4A): Phase 4A implementation plan (Image Annotation vor Send) 2026-05-16 19:38:26 +02:00
54 changed files with 11184 additions and 101 deletions
+3 -1
View File
@@ -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",
+7
View File
@@ -11,6 +11,11 @@ interface Props {
// brand-tinted fallback; callers can override (e.g. to colour-by-id). // brand-tinted fallback; callers can override (e.g. to colour-by-id).
fallbackClass?: string; fallbackClass?: string;
alt?: string; alt?: string;
// Browser loading hint. Use 'eager' for above-the-fold avatars (e.g. the
// active conversation header, call tiles). Defaults to 'lazy' so off-screen
// avatars (chat list rows, friends list, popovers) don't hammer Supabase
// Storage on initial render.
loading?: 'eager' | 'lazy';
} }
export function Avatar({ export function Avatar({
@@ -19,6 +24,7 @@ export function Avatar({
className = 'h-10 w-10', className = 'h-10 w-10',
fallbackClass = 'bg-accent/20 text-accent', fallbackClass = 'bg-accent/20 text-accent',
alt, alt,
loading = 'lazy',
}: Props) { }: Props) {
const effectiveUrl = useCachedAvatarUrl(url); const effectiveUrl = useCachedAvatarUrl(url);
if (effectiveUrl) { if (effectiveUrl) {
@@ -28,6 +34,7 @@ export function Avatar({
alt={alt ?? displayName ?? ''} alt={alt ?? displayName ?? ''}
className={'shrink-0 rounded-full object-cover ' + className} className={'shrink-0 rounded-full object-cover ' + className}
draggable={false} draggable={false}
loading={loading}
/> />
); );
} }
@@ -291,6 +291,7 @@ function AudioContent({
src={avatarUrl} src={avatarUrl}
alt="" alt=""
className="relative h-full w-full rounded-full object-cover" className="relative h-full w-full rounded-full object-cover"
loading="eager"
/> />
) : ( ) : (
<span <span
@@ -366,7 +367,7 @@ function VideoStub({
} }
> >
{avatarUrl ? ( {avatarUrl ? (
<img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" /> <img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" loading="eager" />
) : ( ) : (
letter letter
)} )}
@@ -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>
);
}
@@ -129,9 +129,9 @@ function HeaderBar({
className="relative shrink-0 rounded-full text-left transition enabled:cursor-pointer enabled:hover:ring-2 enabled:hover:ring-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-default" className="relative shrink-0 rounded-full text-left transition enabled:cursor-pointer enabled:hover:ring-2 enabled:hover:ring-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-default"
> >
{isDm ? ( {isDm ? (
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" /> <Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" loading="eager" />
) : peerAvatar ? ( ) : peerAvatar ? (
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" /> <Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" loading="eager" />
) : ( ) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/20 text-accent"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/20 text-accent">
<UsersIcon className="h-5 w-5" /> <UsersIcon className="h-5 w-5" />
@@ -197,7 +197,7 @@ function HeaderBar({
interface HeaderActionButtonProps { interface HeaderActionButtonProps {
label: string; label: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element; icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
onClick?: () => void; onClick?: () => void;
disabled?: boolean; disabled?: boolean;
tone?: 'default' | 'accent'; tone?: 'default' | 'accent';
@@ -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,
)} )}
+133
View File
@@ -0,0 +1,133 @@
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) {
// Respect the OS-level reduced-motion preference.
if (
typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
return;
}
// 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();
}
+105 -8
View File
@@ -5,7 +5,7 @@ import {
softDeleteMessage, softDeleteMessage,
} from '@chat-app/shared/chat'; } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n'; import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -60,8 +60,17 @@ interface Props {
senderAvatarUrl?: string | null | undefined; senderAvatarUrl?: string | null | undefined;
conversationId: string; conversationId: string;
reactions: AggregatedReaction[]; reactions: AggregatedReaction[];
onToggleReaction: (emoji: string) => Promise<void>; /**
onVotePoll?: (emoji: string, optionEmojis: string[]) => Promise<void>; * Toggle a reaction on this message. Receives the message id so the parent
* can pass a stable handler reference across every row (lets `React.memo`
* actually skip re-renders triggered by composer keystrokes / typing pings).
*/
onToggleReaction: (messageId: string, emoji: string) => Promise<void>;
/**
* Cast/clear an exclusive poll vote. Receives the message id for the same
* reason as `onToggleReaction`.
*/
onVotePoll?: (messageId: string, emoji: string, optionEmojis: string[]) => Promise<void>;
showSeen?: boolean; showSeen?: boolean;
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */ /** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
deliveryState?: 'sent' | 'delivered' | 'read'; deliveryState?: 'sent' | 'delivered' | 'read';
@@ -83,7 +92,7 @@ interface Props {
onTogglePin?: (messageId: string) => void; onTogglePin?: (messageId: string) => void;
} }
export function MessageBubble({ function MessageBubbleInner({
message, message,
mine, mine,
groupedWithPrev, groupedWithPrev,
@@ -257,12 +266,12 @@ export function MessageBubble({
async (emoji: string) => { async (emoji: string) => {
setPickerOpen(false); setPickerOpen(false);
try { try {
await onToggleReaction(emoji); await onToggleReaction(message.id, emoji);
} catch (err: unknown) { } catch (err: unknown) {
console.error('toggleReaction failed', err); console.error('toggleReaction failed', err);
} }
}, },
[onToggleReaction], [onToggleReaction, message.id],
); );
const copyableText = const copyableText =
@@ -442,6 +451,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}
@@ -449,7 +536,9 @@ export function MessageBubble({
reactions={reactions} reactions={reactions}
mine={mine} mine={mine}
onVote={(emoji) => onVote={(emoji) =>
onVotePoll ? onVotePoll(emoji, pollOptionEmojis) : onToggleReaction(emoji) onVotePoll
? onVotePoll(message.id, emoji, pollOptionEmojis)
: onToggleReaction(message.id, emoji)
} }
/> />
) : ( ) : (
@@ -515,7 +604,7 @@ export function MessageBubble({
<button <button
key={r.emoji + ':' + r.count} key={r.emoji + ':' + r.count}
type="button" type="button"
onClick={() => void onToggleReaction(r.emoji)} onClick={() => void onToggleReaction(message.id, r.emoji)}
className={ className={
'inline-flex origin-bottom animate-reaction-pop cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' + 'inline-flex origin-bottom animate-reaction-pop cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(r.mine (r.mine
@@ -665,6 +754,14 @@ export function MessageBubble({
); );
} }
/**
* Memoized export. Skips re-rendering when none of its props' shallow
* references change — i.e. when the parent re-renders due to composer
* keystrokes, typing-indicator updates, presence pings, etc. Relies on
* the parent passing stable callback refs (see `ConversationPage`).
*/
export const MessageBubble = memo(MessageBubbleInner);
function PollCard({ function PollCard({
question, question,
options, options,
+3 -3
View File
@@ -17,7 +17,7 @@ import {
interface NavItem { interface NavItem {
to: string; to: string;
labelKey: string; labelKey: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element; icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
badge?: 'friends' | 'chats'; badge?: 'friends' | 'chats';
} }
@@ -95,7 +95,7 @@ export function Sidebar() {
interface RailNavLinkProps { interface RailNavLinkProps {
to: string; to: string;
label: string; label: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element; icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
badge?: number; badge?: number;
} }
@@ -135,7 +135,7 @@ function RailNavLink({ to, label, icon: Icon, badge = 0 }: RailNavLinkProps) {
interface RailIconButtonProps { interface RailIconButtonProps {
label: string; label: string;
onClick: () => void; onClick: () => void;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element; icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
tone?: 'default' | 'danger'; tone?: 'default' | 'danger';
} }
@@ -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>
);
}
+122 -61
View File
@@ -1,6 +1,7 @@
// Inline SVG icons — no icon library dependency. Lucide-style stroke=1.75. // Inline SVG icons — no icon library dependency. Lucide-style stroke=1.75.
import { memo } from 'react';
type IconProps = React.SVGProps<SVGSVGElement>; type IconProps = React.ComponentPropsWithoutRef<'svg'>;
function Base({ children, ...props }: IconProps & { children: React.ReactNode }) { function Base({ children, ...props }: IconProps & { children: React.ReactNode }) {
return ( return (
@@ -20,7 +21,7 @@ function Base({ children, ...props }: IconProps & { children: React.ReactNode })
); );
} }
export function MailIcon(props: IconProps) { function MailIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="3" y="5" width="18" height="14" rx="2" /> <rect x="3" y="5" width="18" height="14" rx="2" />
@@ -28,8 +29,9 @@ export function MailIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const MailIcon = memo(MailIconInner);
export function AtIcon(props: IconProps) { function AtIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<circle cx="12" cy="12" r="4" /> <circle cx="12" cy="12" r="4" />
@@ -37,8 +39,9 @@ export function AtIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const AtIcon = memo(AtIconInner);
export function TicketIcon(props: IconProps) { function TicketIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M3 8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2a2 2 0 1 0 0 4v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2a2 2 0 1 0 0-4V8Z" /> <path d="M3 8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2a2 2 0 1 0 0 4v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2a2 2 0 1 0 0-4V8Z" />
@@ -46,8 +49,9 @@ export function TicketIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const TicketIcon = memo(TicketIconInner);
export function ArrowRightIcon(props: IconProps) { function ArrowRightIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M5 12h14" /> <path d="M5 12h14" />
@@ -55,8 +59,9 @@ export function ArrowRightIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const ArrowRightIcon = memo(ArrowRightIconInner);
export function CheckCircleIcon(props: IconProps) { function CheckCircleIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<circle cx="12" cy="12" r="9" /> <circle cx="12" cy="12" r="9" />
@@ -64,8 +69,9 @@ export function CheckCircleIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const CheckCircleIcon = memo(CheckCircleIconInner);
export function AlertIcon(props: IconProps) { function AlertIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M12 9v4" /> <path d="M12 9v4" />
@@ -74,8 +80,9 @@ export function AlertIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const AlertIcon = memo(AlertIconInner);
export function ShieldIcon(props: IconProps) { function ShieldIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6l-8-3Z" /> <path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6l-8-3Z" />
@@ -83,8 +90,9 @@ export function ShieldIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const ShieldIcon = memo(ShieldIconInner);
export function LockIcon(props: IconProps) { function LockIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="4" y="11" width="16" height="10" rx="2" /> <rect x="4" y="11" width="16" height="10" rx="2" />
@@ -92,8 +100,9 @@ export function LockIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const LockIcon = memo(LockIconInner);
export function WifiLowIcon(props: IconProps) { function WifiLowIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M5 12.55a11 11 0 0 1 14 0" opacity="0.35" /> <path d="M5 12.55a11 11 0 0 1 14 0" opacity="0.35" />
@@ -102,8 +111,9 @@ export function WifiLowIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const WifiLowIcon = memo(WifiLowIconInner);
export function WifiOffIcon(props: IconProps) { function WifiOffIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="m2 2 20 20" /> <path d="m2 2 20 20" />
@@ -116,8 +126,9 @@ export function WifiOffIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const WifiOffIcon = memo(WifiOffIconInner);
export function PinIcon(props: IconProps) { function PinIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M12 17v5" /> <path d="M12 17v5" />
@@ -125,8 +136,9 @@ export function PinIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const PinIcon = memo(PinIconInner);
export function EyeOffIcon(props: IconProps) { function EyeOffIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="m2 2 20 20" /> <path d="m2 2 20 20" />
@@ -136,8 +148,9 @@ export function EyeOffIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const EyeOffIcon = memo(EyeOffIconInner);
export function CaptionsIcon(props: IconProps) { function CaptionsIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="3" y="6" width="18" height="12" rx="2" /> <rect x="3" y="6" width="18" height="12" rx="2" />
@@ -146,8 +159,9 @@ export function CaptionsIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const CaptionsIcon = memo(CaptionsIconInner);
export function PinOffIcon(props: IconProps) { function PinOffIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="m2 2 20 20" /> <path d="m2 2 20 20" />
@@ -157,8 +171,9 @@ export function PinOffIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const PinOffIcon = memo(PinOffIconInner);
export function SparklesIcon(props: IconProps) { function SparklesIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M12 3v4" /> <path d="M12 3v4" />
@@ -172,8 +187,9 @@ export function SparklesIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const SparklesIcon = memo(SparklesIconInner);
export function SpinnerIcon(props: IconProps) { function SpinnerIconInner(props: IconProps) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -208,16 +224,18 @@ export function SpinnerIcon(props: IconProps) {
</svg> </svg>
); );
} }
export const SpinnerIcon = memo(SpinnerIconInner);
export function ChatBubbleIcon(props: IconProps) { function ChatBubbleIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z" /> <path d="M21 15a2 2 0 0 1-2 2H8l-5 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z" />
</Base> </Base>
); );
} }
export const ChatBubbleIcon = memo(ChatBubbleIconInner);
export function UsersIcon(props: IconProps) { function UsersIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /> <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
@@ -227,8 +245,9 @@ export function UsersIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const UsersIcon = memo(UsersIconInner);
export function GearIcon(props: IconProps) { function GearIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
@@ -236,8 +255,9 @@ export function GearIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const GearIcon = memo(GearIconInner);
export function SearchIcon(props: IconProps) { function SearchIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<circle cx="11" cy="11" r="7" /> <circle cx="11" cy="11" r="7" />
@@ -245,16 +265,18 @@ export function SearchIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const SearchIcon = memo(SearchIconInner);
export function PlusIcon(props: IconProps) { function PlusIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M12 5v14M5 12h14" /> <path d="M12 5v14M5 12h14" />
</Base> </Base>
); );
} }
export const PlusIcon = memo(PlusIconInner);
export function SignOutIcon(props: IconProps) { function SignOutIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
@@ -263,32 +285,36 @@ export function SignOutIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const SignOutIcon = memo(SignOutIconInner);
export function MenuIcon(props: IconProps) { function MenuIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M3 6h18M3 12h18M3 18h18" /> <path d="M3 6h18M3 12h18M3 18h18" />
</Base> </Base>
); );
} }
export const MenuIcon = memo(MenuIconInner);
export function ChevronDownIcon(props: IconProps) { function ChevronDownIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="m6 9 6 6 6-6" /> <path d="m6 9 6 6 6-6" />
</Base> </Base>
); );
} }
export const ChevronDownIcon = memo(ChevronDownIconInner);
export function PencilIcon(props: IconProps) { function PencilIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" /> <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</Base> </Base>
); );
} }
export const PencilIcon = memo(PencilIconInner);
export function TrashIcon(props: IconProps) { function TrashIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M3 6h18" /> <path d="M3 6h18" />
@@ -299,8 +325,9 @@ export function TrashIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const TrashIcon = memo(TrashIconInner);
export function SmileIcon(props: IconProps) { function SmileIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<circle cx="12" cy="12" r="9" /> <circle cx="12" cy="12" r="9" />
@@ -310,8 +337,9 @@ export function SmileIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const SmileIcon = memo(SmileIconInner);
export function CopyIcon(props: IconProps) { function CopyIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="9" y="9" width="13" height="13" rx="2" /> <rect x="9" y="9" width="13" height="13" rx="2" />
@@ -319,16 +347,18 @@ export function CopyIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const CopyIcon = memo(CopyIconInner);
export function PhoneIcon(props: IconProps) { function PhoneIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.79 19.79 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92Z" /> <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.79 19.79 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92Z" />
</Base> </Base>
); );
} }
export const PhoneIcon = memo(PhoneIconInner);
export function PhoneOffIcon(props: IconProps) { function PhoneOffIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-3.48-3.05" /> <path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-3.48-3.05" />
@@ -337,8 +367,9 @@ export function PhoneOffIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const PhoneOffIcon = memo(PhoneOffIconInner);
export function MicIcon(props: IconProps) { function MicIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="9" y="2" width="6" height="12" rx="3" /> <rect x="9" y="2" width="6" height="12" rx="3" />
@@ -348,8 +379,9 @@ export function MicIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const MicIcon = memo(MicIconInner);
export function MicOffIcon(props: IconProps) { function MicOffIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M1 1l22 22" /> <path d="M1 1l22 22" />
@@ -362,8 +394,9 @@ export function MicOffIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const MicOffIcon = memo(MicOffIconInner);
export function InfoIcon(props: IconProps) { function InfoIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<circle cx="12" cy="12" r="9" /> <circle cx="12" cy="12" r="9" />
@@ -372,16 +405,18 @@ export function InfoIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const InfoIcon = memo(InfoIconInner);
export function XIcon(props: IconProps) { function XIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M18 6 6 18M6 6l12 12" /> <path d="M18 6 6 18M6 6l12 12" />
</Base> </Base>
); );
} }
export const XIcon = memo(XIconInner);
export function MonitorShareIcon(props: IconProps) { function MonitorShareIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="3" y="4" width="18" height="12" rx="2" /> <rect x="3" y="4" width="18" height="12" rx="2" />
@@ -390,8 +425,9 @@ export function MonitorShareIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const MonitorShareIcon = memo(MonitorShareIconInner);
export function MonitorStopIcon(props: IconProps) { function MonitorStopIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="3" y="4" width="18" height="12" rx="2" /> <rect x="3" y="4" width="18" height="12" rx="2" />
@@ -400,8 +436,9 @@ export function MonitorStopIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const MonitorStopIcon = memo(MonitorStopIconInner);
export function SunIcon(props: IconProps) { function SunIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<circle cx="12" cy="12" r="4" /> <circle cx="12" cy="12" r="4" />
@@ -409,16 +446,18 @@ export function SunIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const SunIcon = memo(SunIconInner);
export function MoonIcon(props: IconProps) { function MoonIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79Z" /> <path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79Z" />
</Base> </Base>
); );
} }
export const MoonIcon = memo(MoonIconInner);
export function GridIcon(props: IconProps) { function GridIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="3" y="3" width="7" height="7" rx="1.5" /> <rect x="3" y="3" width="7" height="7" rx="1.5" />
@@ -428,8 +467,9 @@ export function GridIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const GridIcon = memo(GridIconInner);
export function ImageIcon(props: IconProps) { function ImageIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="3" y="5" width="18" height="14" rx="2" /> <rect x="3" y="5" width="18" height="14" rx="2" />
@@ -438,8 +478,9 @@ export function ImageIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const ImageIcon = memo(ImageIconInner);
export function FileIcon(props: IconProps) { function FileIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z" /> <path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z" />
@@ -447,8 +488,9 @@ export function FileIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const FileIcon = memo(FileIconInner);
export function PollIcon(props: IconProps) { function PollIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M5 19V9" /> <path d="M5 19V9" />
@@ -458,8 +500,9 @@ export function PollIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const PollIcon = memo(PollIconInner);
export function FocusIcon(props: IconProps) { function FocusIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="3" y="3" width="18" height="18" rx="2" /> <rect x="3" y="3" width="18" height="18" rx="2" />
@@ -467,8 +510,9 @@ export function FocusIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const FocusIcon = memo(FocusIconInner);
export function MaximizeIcon(props: IconProps) { function MaximizeIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M4 9V5a1 1 0 0 1 1-1h4" /> <path d="M4 9V5a1 1 0 0 1 1-1h4" />
@@ -478,8 +522,9 @@ export function MaximizeIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const MaximizeIcon = memo(MaximizeIconInner);
export function VideoIcon(props: IconProps) { function VideoIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="2" y="6" width="15" height="12" rx="2" /> <rect x="2" y="6" width="15" height="12" rx="2" />
@@ -487,16 +532,18 @@ export function VideoIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const VideoIcon = memo(VideoIconInner);
export function CrownIcon(props: IconProps) { function CrownIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="m3 7 4 4 5-6 5 6 4-4v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" /> <path d="m3 7 4 4 5-6 5 6 4-4v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
</Base> </Base>
); );
} }
export const CrownIcon = memo(CrownIconInner);
export function MusicIcon(props: IconProps) { function MusicIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M9 18V5l12-2v13" /> <path d="M9 18V5l12-2v13" />
@@ -505,16 +552,18 @@ export function MusicIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const MusicIcon = memo(MusicIconInner);
export function SendIcon(props: IconProps) { function SendIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="m3 11 18-8-8 18-2-8-8-2Z" /> <path d="m3 11 18-8-8 18-2-8-8-2Z" />
</Base> </Base>
); );
} }
export const SendIcon = memo(SendIconInner);
export function ArchiveIcon(props: IconProps) { function ArchiveIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="3" y="4" width="18" height="5" rx="1" /> <rect x="3" y="4" width="18" height="5" rx="1" />
@@ -523,8 +572,9 @@ export function ArchiveIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const ArchiveIcon = memo(ArchiveIconInner);
export function BellIcon(props: IconProps) { function BellIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M6 8a6 6 0 1 1 12 0c0 4 1.5 5 2 6H4c.5-1 2-2 2-6Z" /> <path d="M6 8a6 6 0 1 1 12 0c0 4 1.5 5 2 6H4c.5-1 2-2 2-6Z" />
@@ -532,8 +582,9 @@ export function BellIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const BellIcon = memo(BellIconInner);
export function BellOffIcon(props: IconProps) { function BellOffIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M9.5 18a2.5 2.5 0 0 0 5 0" /> <path d="M9.5 18a2.5 2.5 0 0 0 5 0" />
@@ -545,8 +596,9 @@ export function BellOffIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const BellOffIcon = memo(BellOffIconInner);
export function MoreVerticalIcon(props: IconProps) { function MoreVerticalIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<circle cx="12" cy="5" r="1.5" /> <circle cx="12" cy="5" r="1.5" />
@@ -555,8 +607,9 @@ export function MoreVerticalIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const MoreVerticalIcon = memo(MoreVerticalIconInner);
export function HeadphonesIcon(props: IconProps) { function HeadphonesIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M3 14v-2a9 9 0 0 1 18 0v2" /> <path d="M3 14v-2a9 9 0 0 1 18 0v2" />
@@ -565,8 +618,9 @@ export function HeadphonesIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const HeadphonesIcon = memo(HeadphonesIconInner);
export function HeadphonesOffIcon(props: IconProps) { function HeadphonesOffIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M3 14v-2a9 9 0 0 1 11.3-8.7" /> <path d="M3 14v-2a9 9 0 0 1 11.3-8.7" />
@@ -577,8 +631,9 @@ export function HeadphonesOffIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const HeadphonesOffIcon = memo(HeadphonesOffIconInner);
export function ReplyIcon(props: IconProps) { function ReplyIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<polyline points="9 17 4 12 9 7" /> <polyline points="9 17 4 12 9 7" />
@@ -586,8 +641,9 @@ export function ReplyIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const ReplyIcon = memo(ReplyIconInner);
export function ForwardIcon(props: IconProps) { function ForwardIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<polyline points="15 17 20 12 15 7" /> <polyline points="15 17 20 12 15 7" />
@@ -595,16 +651,18 @@ export function ForwardIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const ForwardIcon = memo(ForwardIconInner);
export function ChevronUpIcon(props: IconProps) { function ChevronUpIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<polyline points="18 15 12 9 6 15" /> <polyline points="18 15 12 9 6 15" />
</Base> </Base>
); );
} }
export const ChevronUpIcon = memo(ChevronUpIconInner);
export function AddUserIcon(props: IconProps) { function AddUserIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /> <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
@@ -613,12 +671,13 @@ export function AddUserIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const AddUserIcon = memo(AddUserIconInner);
// Soft-N mark: rounded-square violet tile with a stylised "N" glyph. Used // Soft-N mark: rounded-square violet tile with a stylised "N" glyph. Used
// wherever the app needs a standalone icon (sidebar rail, auth screen, // wherever the app needs a standalone icon (sidebar rail, auth screen,
// favicon). Colour decisions sit inside the SVG so consumers just size the // favicon). Colour decisions sit inside the SVG so consumers just size the
// element via `className`. // element via `className`.
export function LogoMark(props: IconProps) { function LogoMarkInner(props: IconProps) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -638,10 +697,11 @@ export function LogoMark(props: IconProps) {
</svg> </svg>
); );
} }
export const LogoMark = memo(LogoMarkInner);
// Full lockup: soft-N mark + "Netralax" wordmark. `tone` decides text colour: // Full lockup: soft-N mark + "Netralax" wordmark. `tone` decides text colour:
// "dark" = white text (use on dark background), "light" = near-black. // "dark" = white text (use on dark background), "light" = near-black.
export function LogoLockup({ function LogoLockupInner({
tone = 'dark', tone = 'dark',
...props ...props
}: IconProps & { tone?: 'dark' | 'light' }) { }: IconProps & { tone?: 'dark' | 'light' }) {
@@ -676,3 +736,4 @@ export function LogoLockup({
</svg> </svg>
); );
} }
export const LogoLockup = memo(LogoLockupInner);
+8
View File
@@ -204,6 +204,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
void registerWebPush(installId); void registerWebPush(installId);
}, [session]); }, [session]);
// Pre-warm Supabase: fires the first round-trip in the background so the
// first user-triggered query (e.g. loading conversations) doesn't pay
// the cold-connection latency.
useEffect(() => {
if (!session) return;
void supabase.from('profiles').select('id').limit(1).then(() => undefined);
}, [session]);
// Phase 3: ensure this install owns exactly one devices row. The row is // Phase 3: ensure this install owns exactly one devices row. The row is
// pure session-list telemetry — it does not carry any cryptographic // pure session-list telemetry — it does not carry any cryptographic
// material since the per-user-key refactor. We re-use the row across // material since the per-user-key refactor. We re-use the row across
@@ -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,
+75
View File
@@ -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 };
}
+276
View File
@@ -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 };
}
+145
View File
@@ -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[],
+49
View File
@@ -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);
});
}
+9
View File
@@ -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>
); );
+391 -16
View File
@@ -1,6 +1,6 @@
import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat'; import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n'; import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
@@ -16,6 +16,7 @@ import {
ChevronDownIcon, ChevronDownIcon,
ChevronUpIcon, ChevronUpIcon,
EyeOffIcon, EyeOffIcon,
PencilIcon,
PlusIcon, PlusIcon,
PollIcon, PollIcon,
ReplyIcon, ReplyIcon,
@@ -25,12 +26,16 @@ import {
SpinnerIcon, SpinnerIcon,
XIcon, XIcon,
} from '../components/icons'; } from '../components/icons';
const ImageAnnotator = lazy(() =>
import('../components/ImageAnnotator').then((m) => ({ default: m.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';
import { MediaFilesDrawer } from '../components/MediaFilesDrawer'; import { MediaFilesDrawer } from '../components/MediaFilesDrawer';
import { MentionAutocomplete } from '../components/MentionAutocomplete'; import { MentionAutocomplete } from '../components/MentionAutocomplete';
import { MessageBubble, type QuotedRef } from '../components/MessageBubble'; import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
import type { AggregatedReaction } from '../lib/useMessageReactions';
import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel'; import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel';
import { PollComposerDialog } from '../components/PollComposerDialog'; import { PollComposerDialog } from '../components/PollComposerDialog';
import { UserProfilePopover } from '../components/UserProfilePopover'; import { UserProfilePopover } from '../components/UserProfilePopover';
@@ -40,7 +45,23 @@ 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';
const WhiteboardModal = lazy(() =>
import('../components/WhiteboardModal').then((m) => ({ default: m.WhiteboardModal })),
);
const WatchTogetherModal = lazy(() =>
import('../components/WatchTogetherModal').then((m) => ({ default: m.WatchTogetherModal })),
);
const GameModal = lazy(() =>
import('../components/GameModal').then((m) => ({ default: m.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';
@@ -58,6 +79,12 @@ import { useTypingChannel } from '../lib/useTypingChannel';
const STICK_THRESHOLD = 80; const STICK_THRESHOLD = 80;
// Stable empty-reactions sentinel. We pass this when a message has no
// reactions instead of `[]` literal — a fresh array per render would defeat
// `React.memo` on `MessageBubble` since the `reactions` prop reference would
// change on every parent render.
const EMPTY_REACTIONS: AggregatedReaction[] = [];
// Per-conversation scroll memory. Module-scoped so it survives re-mounts // Per-conversation scroll memory. Module-scoped so it survives re-mounts
// of ConversationPage when the route param (`id`) changes — switching // of ConversationPage when the route param (`id`) changes — switching
// chats unmounts/remounts the page in our router setup. Session-only // chats unmounts/remounts the page in our router setup. Session-only
@@ -154,10 +181,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);
@@ -337,6 +376,20 @@ export function ConversationPage() {
[messageById, senderNameFor, t], [messageById, senderNameFor, t],
); );
// Pre-compute quoted refs per message into a stable map. Calling
// `buildQuoted(m.replyToId)` inline inside the `.map` returned a fresh
// object on every parent render, defeating `React.memo` on MessageBubble.
// With the map memoized on the same deps as `buildQuoted`, each bubble
// gets a stable `quoted` reference until the underlying data actually
// changes (new messages, sender renames, language switch).
const quotedByMessage = useMemo(() => {
const out = new Map<string, QuotedRef | null>();
for (const m of messages) {
out.set(m.id, buildQuoted(m.replyToId));
}
return out;
}, [messages, buildQuoted]);
const jumpToMessage = useCallback((targetId: string) => { const jumpToMessage = useCallback((targetId: string) => {
const el = scrollRef.current?.querySelector<HTMLElement>( const el = scrollRef.current?.querySelector<HTMLElement>(
'[data-message-id="' + CSS.escape(targetId) + '"]', '[data-message-id="' + CSS.escape(targetId) + '"]',
@@ -356,6 +409,19 @@ export function ConversationPage() {
setForwardTarget(m); setForwardTarget(m);
}, []); }, []);
// Stable handler for MessageBubble's `onAvatarClick`. Previously this was
// an inline arrow in the `.map`, which gave every row a fresh callback ref
// and defeated `React.memo` on the bubble (every parent re-render — every
// keystroke in the composer — re-rendered all 200 bubbles).
const handleAvatarClick = useCallback((uid: string, ev: React.MouseEvent) => {
ev.stopPropagation();
setProfilePopover({
userId: uid,
x: ev.clientX,
y: ev.clientY,
});
}, []);
const searchActive = useMemo( const searchActive = useMemo(
() => () =>
searchQuery.trim().length > 0 || searchQuery.trim().length > 0 ||
@@ -452,6 +518,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 +678,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[] = [];
@@ -779,9 +946,9 @@ export function ConversationPage() {
senderDisplayName={senderProfile?.displayName} senderDisplayName={senderProfile?.displayName}
senderAvatarUrl={senderProfile?.avatarUrl} senderAvatarUrl={senderProfile?.avatarUrl}
conversationId={id ?? ''} conversationId={id ?? ''}
reactions={reactionsByMessage.get(m.id) ?? []} reactions={reactionsByMessage.get(m.id) ?? EMPTY_REACTIONS}
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)} onToggleReaction={toggleReaction}
onVotePoll={(emoji, optionEmojis) => votePoll(m.id, emoji, optionEmojis)} onVotePoll={votePoll}
showSeen={m.id === lastSeenMessageId} showSeen={m.id === lastSeenMessageId}
{...(m.senderId === myId {...(m.senderId === myId
? { ? {
@@ -796,18 +963,11 @@ export function ConversationPage() {
}), }),
} }
: {})} : {})}
quoted={buildQuoted(m.replyToId)} quoted={quotedByMessage.get(m.id) ?? null}
onJumpToMessage={jumpToMessage} onJumpToMessage={jumpToMessage}
onReply={handleReply} onReply={handleReply}
onForward={handleForward} onForward={handleForward}
onAvatarClick={(uid, ev) => { onAvatarClick={handleAvatarClick}
ev.stopPropagation();
setProfilePopover({
userId: uid,
x: ev.clientX,
y: ev.clientY,
});
}}
highlighted={highlightedId === m.id} highlighted={highlightedId === m.id}
isPinned={pinnedIds.has(m.id)} isPinned={pinnedIds.has(m.id)}
onTogglePin={handleTogglePin} onTogglePin={handleTogglePin}
@@ -917,6 +1077,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 +1138,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 +1354,141 @@ export function ConversationPage() {
}} }}
onUnpin={(messageId) => void handleTogglePin(messageId)} onUnpin={(messageId) => void handleTogglePin(messageId)}
/> />
{annotatingIndex !== null && attachments[annotatingIndex] && (
<Suspense fallback={null}>
<ImageAnnotator
file={attachments[annotatingIndex]!}
onCancel={() => setAnnotatingIndex(null)}
onSave={(next) => {
setAttachments((prev) => prev.map((f, i) => (i === annotatingIndex ? next : f)));
setAnnotatingIndex(null);
}}
/>
</Suspense>
)}
{openWhiteboardId && (
<Suspense fallback={null}>
<WhiteboardModal
whiteboardId={openWhiteboardId}
onClose={() => setOpenWhiteboardId(null)}
/>
</Suspense>
)}
{openWatchSessionId && (
<Suspense fallback={null}>
<WatchTogetherModal
sessionId={openWatchSessionId}
onClose={() => setOpenWatchSessionId(null)}
/>
</Suspense>
)}
{openGameId && (
<Suspense fallback={null}>
<GameModal
gameId={openGameId}
onClose={() => setOpenGameId(null)}
/>
</Suspense>
)}
{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 +1716,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 +1734,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 +1746,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 +1768,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>
);
}
+3 -2
View File
@@ -85,9 +85,10 @@
*, *,
*::before, *::before,
*::after { *::after {
animation-duration: 0.01ms !important; animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important; animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important; transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
} }
} }
} }
@@ -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."
@@ -0,0 +1,260 @@
# Phase 6 — Performance Pack
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. This plan is grouped into 3 risk tiers — Group A is parallel-safe quick wins, Group B is medium-scope, Group C is audits.
**Goal:** A focused performance pass after the fifteen-features initiative shipped. Faster startup, smoother long chats, smaller bundle, less main-thread blocking on PIN-unlock, no UX regressions.
**Rollback anchor:** tag `pre-phase6-perf``888ed1b` (already pushed to origin).
**Strategy:** ship Group A first (5 tiny safe wins ≈ 5h), pause + smoke-test, then B (medium ≈ 2-3 days), then C (audits ≈ 1 day). No release between groups; one combined release at the very end.
---
## Group A — Safe quick wins (~5h, low risk)
### T1: Lazy-load four fat modals
**What:** Convert eager imports of `WhiteboardModal`, `WatchTogetherModal`, `ImageAnnotator`, `GameModal` to `React.lazy(() => import(...))` inside `ConversationPage.tsx`. Wrap each conditional render in `<Suspense fallback={null}>`.
**Why:** These modals total ~200-300 KB (canvas-confetti dep, IFrame player loader, ImageAnnotator's full op-stack, etc.) and render in <1 % of sessions. Initial bundle drops by that amount → faster cold load.
**Files:** `apps/desktop/src/pages/ConversationPage.tsx` only.
**Risk:** trivial. `Suspense` with `fallback={null}` means a few ms blank flicker the first time each modal opens (chunk download). Acceptable.
**Effort:** ~30 min.
---
### T2: `prefers-reduced-motion` global rule
**What:**
- Global CSS rule in `apps/desktop/src/index.css` (or wherever global styles live): `@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; animation: none !important; } }`.
- Gate the confetti burst in `GameModal.tsx` behind `window.matchMedia('(prefers-reduced-motion: reduce)').matches`.
**Why:** Accessibility + CPU savings for users who set the OS preference. Confetti is the most visible offender.
**Risk:** very low. Tailwind already respects motion-reduce variants in some classes; this is the global default.
**Effort:** ~30 min.
---
### T3: Memoize `MessageBubble` + audit callback stability
**What:**
- Wrap `MessageBubble` export in `React.memo` with shallow equality (default).
- Audit the message-list render site (ConversationPage or a MessagesList component) — every callback prop passed into the row (`onReply`, `onPin`, `onDelete`, …) must be `useCallback`-stable with no per-render closures. Replace anonymous `() => doX(message.id)` patterns with stable handlers that receive the id at call time.
**Why:** Typing in the composer currently re-runs the entire `messages.map(...)` and re-renders every bubble. With memoization + stable callbacks, only the new bubble appears; existing rows stay mounted. Big win on long chats.
**Risk:** medium-low. Possible bugs if a callback captures stale state (e.g. closure over `pinnedSet` that doesn't update). Mitigation: pass volatile state as props on the bubble and let `React.memo` handle the diff.
**Effort:** ~2h.
---
### T4: Memoize icon components (pragmatic "sprite-sheet" alternative)
**What:** Original idea was a real SVG sprite-sheet (single `<svg>` with `<symbol>` defs + `<use href="#name">`). Pragmatic alternative: wrap every icon component in `React.memo`. They're pure functions of `className`/`...props` so memoization is free, and 90 % of the perf win (avoiding React reconciliation on identical icon trees) comes from this without the sprite refactor risk.
**Files:** `apps/desktop/src/components/icons.tsx` (or `icons/` folder — whichever the codebase uses).
**Why:** Real sprite-sheet is invasive (refactor 60+ icon usages, change className/fill inheritance). Memoizing achieves the bulk of the win at <30 min effort. Real sprite-sheet stays available as a follow-up if bundle-analyzer (T11) shows icons are a top-3 bundle hog.
**Risk:** none — `React.memo` is purely a perf hint.
**Effort:** ~30 min.
---
### T5: Pre-warm Supabase + avatar loading hints
**What:**
- In `AuthContext.tsx`, fire one trivial query early (e.g. `supabase.from('profiles').select('id').limit(1)`) so the connection is warm by the time the user does anything.
- Audit `<img>` tags for avatars: add `loading="lazy"` to off-screen ones (chat list rows below the fold, deep history) and keep `loading="eager"` only for above-the-fold (current conv header, top of chat list).
**Why:** First real query after login currently pays cold-connection latency (~100-200 ms). Pre-warm hides it. Lazy avatars stop the browser from hammering Supabase Storage on initial render.
**Risk:** none.
**Effort:** ~1h.
---
### Group A final gate
- [ ] `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck`
- [ ] `pnpm --filter @chat-app/shared test -- --run` (still 71/71)
- [ ] User smoke-test: cold-start the app, send a few messages, type in composer, open one of the 4 modals — verify nothing broke + the visible improvements (faster initial render, smoother typing in long chats).
- [ ] Tag `phase6a-done` for incremental rollback granularity if Group B introduces issues.
---
## Group B — Medium scope (~2-3 days, moderate risk)
### T6: Web-Worker for Argon2 + crypto_box_open (PIN-unlock path)
**What:**
- Create `apps/desktop/src/lib/workers/crypto.worker.ts` that imports libsodium-wrappers and exposes a postMessage RPC: `{ op: 'unsealUserKey', sealedKey, pin, salt, kdfParams }``{ privateKey: Uint8Array }` (transferred).
- Build with Vite's worker syntax: `new Worker(new URL('./workers/crypto.worker.ts', import.meta.url), { type: 'module' })`.
- Refactor `apps/desktop/src/lib/userIdentity.ts`'s `unlockUserKey` (and any other hot Argon2 callers) to call the worker instead of the inline crypto backend.
**Why:** PIN-unlock currently runs Argon2id (~1-2 sec on mid hardware) on the main thread → UI freeze during login. Worker offloads it, login screen stays responsive.
**Risk:** medium. libsodium-wrappers needs to be initialized in both contexts. structured-clone transfers `Uint8Array` cleanly. The risk is that libsodium-wrappers might ship a bigger worker bundle than expected (we accept the trade-off because the main bundle gets smaller too).
**Effort:** ~1 day. Includes typing the postMessage RPC + ensuring the existing PIN-unlock flow keeps its error semantics (wrong PIN, etc.).
---
### T7: WebP thumbnails for image attachments
**What:**
- When sending an image attachment: in addition to encrypting+uploading the full image (`<convId>/<attachmentId>.bin`), generate a 320×320 max-dim WebP thumb via `<canvas>.toBlob({ type: 'image/webp', quality: 0.7 })`, encrypt with the SAME per-attachment key, upload to `<convId>/<attachmentId>-thumb.bin`.
- `MessageBubble` image render: try downloading the thumb first; fall back to full image on 404 (graceful for pre-Phase-6 attachments).
- Click-to-expand: fetch the full image.
**Why:** A 5 MB image in the chat scroll loads 5 MB even off-screen. Thumb is ~10-30 KB. Scroll is silky, bandwidth drops 99 %.
**Files:** `packages/shared/src/chat/attachments.ts` (extend `encryptAndUploadAttachment` to optionally generate+upload thumb), `apps/desktop/src/components/MessageBubble.tsx` (try-thumb-first logic), maybe `Lightbox.tsx` (full image on click).
**Schema:** none — naming-convention based, 404-fallback preserves backward compat.
**Risk:** low-medium. Edge cases: very small images (thumb is bigger than full → skip thumb gen), animated GIFs (don't generate static-frame thumb, just use full).
**Effort:** ~½ day.
---
### T8: Virtual-scroll for message list
**What:**
- `pnpm --filter @chat-app/desktop add react-virtuoso`
- Replace the message-list `.map(...)` in (likely) `ConversationPage.tsx` / `MessagesList.tsx` with `<Virtuoso>`.
- Configure: `data={messages}`, `itemContent={(_, msg) => <MessageBubble ... />}`, `followOutput="smooth"` for auto-scroll on new messages, `initialTopMostItemIndex={messages.length - 1}` to start at bottom.
- If date-day headers exist: switch to `<GroupedVirtuoso>` with `groupCounts` + `groupContent`.
**Why:** Long conversations (1000+ messages) currently render all rows → scroll jank, layout thrashing. Virtuoso renders only visible rows + a small overscan buffer.
**Risk:** medium-high. Things that can go wrong:
- Scroll-anchor preservation when Pinned-Messages panel opens.
- Auto-scroll-to-bottom on send.
- Smooth-scroll-to-message when clicking a pin or a reply.
- Image-load reflow (Virtuoso handles this but needs proper height detection).
Mitigation: thorough manual smoke-test before commit. Keep the old render behind a feature flag for one release if jitters appear.
**Effort:** ~½ day to 1 day depending on edge cases.
---
### T9: PIN-Idle-Auto-Lock
**What:**
- Settings → Sicherheit: new toggle "Auto-Lock nach Inaktivität" + dropdown (5 / 15 / 30 / 60 Minuten). Default OFF.
- localStorage key `chatapp.autoLockMinutes` (or similar) — added to `PRESERVE_LOCAL_STORAGE` so memory-wipe doesn't disable the setting silently (same pattern as wipe-on-close toggle).
- In `AuthContext` (or a new top-level hook): listen on `keydown` / `mousedown` / `pointermove`, reset a timer on each event. When the timer fires: `wipeLocalState(uid)` + navigate to `/device` (the PIN-unlock screen).
**Why:** Spec mentioned this as polish + a Security win — laptop left unattended, auto-locks after X min, attacker can't read messages without PIN.
**Risk:** low. The wipe-on-close infrastructure (P1.T12-T13) already handles all the local-state clearing — same call site.
**Effort:** ~½ day.
---
### T10: i18next tree-shake audit
**What:**
- `pnpm --filter @chat-app/desktop add -D i18next-parser`
- Configure it to scan `apps/desktop/src/**/*.{ts,tsx}` for `t('app:...')` calls + extract used keys.
- Diff against `apps/desktop/locales/de/app.json` (or wherever the resource files live). List dead keys.
- Prune them. Verify nothing visible regresses.
**Why:** Resource files accumulate keys from removed/redesigned features. Smaller resource bundle = faster app start (in-memory JSON parse).
**Risk:** low — `t()` always falls back to `defaultValue` if a key is missing, so even an accidental over-prune doesn't crash the UI; it just shows the German default.
**Effort:** ~2h (mostly looking at the diff + judgment calls).
---
### Group B final gate
- [ ] Both typechecks green
- [ ] All shared tests green
- [ ] User smoke-test: cold start (Argon2 worker), open a long chat (virtual scroll), send an image (thumb generation), idle 5+min (auto-lock if enabled), check console for noise.
- [ ] Tag `phase6b-done`.
---
## Group C — Audits + judgment calls (~1 day)
### T11: Bundle-analyzer audit + targeted dep swaps
**What:**
- `pnpm dlx vite-bundle-visualizer` against the desktop build → outputs HTML report.
- Review the treemap. Common offenders to check:
- Full lodash vs lodash-es (or no lodash at all if only a few utils)
- Moment.js vs date-fns / native `Intl.DateTimeFormat`
- Multiple realtime/socket clients
- Icon libs pulling all icons
- Dev-only deps accidentally in prod bundle
- Apply targeted swaps (max ~3-5) based on the worst findings.
**Why:** Shrinks bundle further beyond T1's lazy-load. Each ~50 KB shaved is a real cold-start win.
**Files:** `apps/desktop/package.json`, the consumer files that import from swapped deps.
**Risk:** variable per swap. A Moment-to-date-fns swap touches many call sites. Cap at the 3 biggest offenders to keep risk bounded.
**Effort:** ~2h audit + variable fixes (estimate 2-3h additional).
---
### T12: Optimistic-UI audit + targeted gap fills
**What:**
- Audit each user-write action across the app:
- `send` (message) → likely already optimistic; verify
- `editEncryptedMessage` → likely already optimistic
- Pin / unpin
- Add / remove reaction (doesn't exist yet — skip)
- Vote on poll
- Revoke device
- Toggle mentions-only
- Toggle mute
- For each action that currently waits for the server roundtrip before updating local state: add optimistic-update with rollback on error.
**Why:** Perceived latency drops to ~0 ms for most clicks. Server roundtrip happens silently.
**Risk:** medium. Each optimistic-update is its own potential rollback bug. Mitigation: only touch actions where rollback is straightforward (e.g. a toggle's previous state is trivially recoverable). Skip if rollback is hairy.
**Effort:** ~1 day total (each action is ~30-60 min including verification).
---
### Group C final gate
- [ ] Both typechecks green, all shared tests green
- [ ] Bundle size measured before/after (note in report)
- [ ] User smoke-test of any actions that gained optimistic UI
---
## Deferred / skipped (with reasoning)
### Realtime-Channel-Pooling
**Skipped for now.** The current UI keeps only one conversation actively open at a time. Concurrent channels at steady state are typically 5-8 (auth-self, conversations-list, current conv messages, current conv typing, mentions, maybe whiteboard / game / watch). Pooling into a single multiplexed channel would require a manager singleton + per-call-site refactor (~15-20 sites), with a meaningful risk of subtle realtime bugs during the transition.
**Reconsider when:** sustained active channel count exceeds 15, or Supabase invoices a noticeable channel-quota line item. Then a focused 1-day refactor with thorough realtime smoke testing makes sense.
---
## Release strategy
- No `pnpm release` between Group A/B/C — single combined release after Group C (or earlier if Group B+C get deferred).
- Suggested version when releasing: `0.20.0` (combines unreleased Phase 5 + 5C + Phase 6).
- Rollback at any commit boundary via `git reset --hard pre-phase6-perf` (Group A) or `git reset --hard phase6a-done` / `phase6b-done` (per-group).
+177
View File
@@ -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
+59 -1
View File
@@ -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',
+28 -2
View File
@@ -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 {
+79
View File
@@ -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);
});
});
+165
View File
@@ -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,
};
}
+4
View File
@@ -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 ---------------------------------------------------------
+27
View File
@@ -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');
});
});
+189
View File
@@ -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,
};
}
+4
View File
@@ -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();
});
});
+124
View File
@@ -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();
});
});
+96
View File
@@ -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;
}
+16
View File
@@ -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;