Compare commits
32 Commits
v0.19.1
...
phase6c-done
| Author | SHA1 | Date | |
|---|---|---|---|
| b1f37752d6 | |||
| 854c4b91a8 | |||
| d3b708636f | |||
| c449943b52 | |||
| db59e3f658 | |||
| 6c6828006b | |||
| 21376daf39 | |||
| 6d0e4fb1f0 | |||
| 36ab7eca8a | |||
| ddd696a790 | |||
| 53b3b5e1fc | |||
| a4573b315d | |||
| c271e95100 | |||
| 888ed1b217 | |||
| a974e5b8ab | |||
| 7ebc5f6c9d | |||
| b8b451ef4f | |||
| d7c0c3d0a2 | |||
| 70ce824120 | |||
| d10840e0b2 | |||
| cd59ee30d6 | |||
| e56e22631b | |||
| dbf90504b4 | |||
| 256a613134 | |||
| e1423dba32 | |||
| 7730828403 | |||
| ecbd11e369 | |||
| 7d5f3b37cd | |||
| 7ce6b1c1d4 | |||
| 4399d39f08 | |||
| ad239ec549 | |||
| d1193142ae |
@@ -33,6 +33,10 @@ web-build/
|
|||||||
apps/desktop/out/
|
apps/desktop/out/
|
||||||
apps/desktop/release/
|
apps/desktop/release/
|
||||||
|
|
||||||
|
# Bundle visualizer reports
|
||||||
|
apps/desktop/stats.html
|
||||||
|
apps/desktop/stats.json
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { visualizer } from 'rollup-plugin-visualizer';
|
||||||
|
|
||||||
|
const ANALYZE = process.env.ANALYZE === 'true';
|
||||||
|
|
||||||
const rendererAliases = {
|
const rendererAliases = {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
@@ -44,7 +47,30 @@ export default defineConfig({
|
|||||||
// bundle. Relative base produces `./assets/...` which works in both
|
// bundle. Relative base produces `./assets/...` which works in both
|
||||||
// dev (served from /) and packaged builds.
|
// dev (served from /) and packaged builds.
|
||||||
base: './',
|
base: './',
|
||||||
plugins: [react()],
|
// Visualizer plugins are gated behind ANALYZE=true so the production
|
||||||
|
// build never pays the analysis cost. Re-enable with:
|
||||||
|
// ANALYZE=true pnpm --filter @chat-app/desktop build
|
||||||
|
// which writes apps/desktop/stats.html (treemap) + stats.json (raw).
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
...(ANALYZE
|
||||||
|
? [
|
||||||
|
visualizer({
|
||||||
|
filename: 'stats.html',
|
||||||
|
template: 'treemap',
|
||||||
|
gzipSize: true,
|
||||||
|
brotliSize: true,
|
||||||
|
open: false,
|
||||||
|
}),
|
||||||
|
visualizer({
|
||||||
|
filename: 'stats.json',
|
||||||
|
template: 'raw-data',
|
||||||
|
gzipSize: true,
|
||||||
|
brotliSize: true,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: rendererAliases,
|
alias: rendererAliases,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -34,10 +35,12 @@
|
|||||||
"react-easy-crop": "^5.5.7",
|
"react-easy-crop": "^5.5.7",
|
||||||
"react-i18next": "^15.1.1",
|
"react-i18next": "^15.1.1",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
|
"react-virtuoso": "^4.18.7",
|
||||||
"zustand": "^5.0.1"
|
"zustand": "^5.0.1"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -49,6 +52,7 @@
|
|||||||
"electron-vite": "^2.3.0",
|
"electron-vite": "^2.3.0",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"rimraf": "^6.0.0",
|
"rimraf": "^6.0.0",
|
||||||
|
"rollup-plugin-visualizer": "^7.0.1",
|
||||||
"tailwindcss": "^3.4.15",
|
"tailwindcss": "^3.4.15",
|
||||||
"vite": "^5.4.11"
|
"vite": "^5.4.11"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
|||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useIdleAutoLock } from '../hooks/useIdleAutoLock';
|
||||||
import { startConversationKeySync } from '../lib/conversationKeySync';
|
import { startConversationKeySync } from '../lib/conversationKeySync';
|
||||||
import { startDeviceApprovalListener } from '../lib/deviceApproval';
|
import { startDeviceApprovalListener } from '../lib/deviceApproval';
|
||||||
import { ensureInstallId } from '../lib/installId';
|
import { ensureInstallId } from '../lib/installId';
|
||||||
@@ -14,6 +15,7 @@ import { Sidebar } from './Sidebar';
|
|||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { session } = useAuth();
|
const { session } = useAuth();
|
||||||
|
useIdleAutoLock();
|
||||||
useMentionNotifications(session?.user.id);
|
useMentionNotifications(session?.user.id);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prompt once per authenticated shell mount. Module-level guard prevents
|
// Prompt once per authenticated shell mount. Module-level guard prevents
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
import {
|
||||||
|
type AttachmentHandle,
|
||||||
|
downloadAndDecryptAttachment,
|
||||||
|
downloadAndDecryptAttachmentThumb,
|
||||||
|
} from '@chat-app/shared/chat';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
@@ -56,6 +60,17 @@ export function AttachmentImage({ handle, mine = false }: Props) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
|
|
||||||
|
// Whether the sender shipped a pre-built WebP thumb alongside this attachment
|
||||||
|
// (Phase 6B+). When true we render the bubble from just the thumb and only
|
||||||
|
// fetch the full blob when the user opens the lightbox or for view-once.
|
||||||
|
const hasServerThumb = Boolean(handle.thumbStoragePath && handle.thumbNonceB64);
|
||||||
|
// View-once needs the full blob ready instantly the moment the recipient
|
||||||
|
// taps (otherwise we'd show a spinner during the burn animation, then race
|
||||||
|
// the "mark viewed" RPC). Same for the legacy path with no server thumb —
|
||||||
|
// we have to download the whole thing just to render the client-side
|
||||||
|
// makeThumbnail fallback.
|
||||||
|
const needsEagerFull = handle.viewOnce === true || !hasServerThumb;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const created: string[] = [];
|
const created: string[] = [];
|
||||||
@@ -69,9 +84,39 @@ export function AttachmentImage({ handle, mine = false }: Props) {
|
|||||||
return u;
|
return u;
|
||||||
};
|
};
|
||||||
|
|
||||||
// OPFS cache → decrypt → generate thumbnail for inline display.
|
const thumbCacheId = handle.id + '-thumb';
|
||||||
// Lightbox swaps to the full blob when opened.
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
// Phase 6B fast path: if the sender shipped a server-side WebP thumb,
|
||||||
|
// grab it first so the bubble paints from ~20KB instead of waiting on
|
||||||
|
// the multi-MB full blob. The OPFS cache is keyed separately so the
|
||||||
|
// thumb survives independent of full-blob eviction.
|
||||||
|
if (hasServerThumb) {
|
||||||
|
try {
|
||||||
|
let thumbBlob: Blob | null = await getCachedAttachment(thumbCacheId);
|
||||||
|
if (!thumbBlob) {
|
||||||
|
thumbBlob = await downloadAndDecryptAttachmentThumb({
|
||||||
|
client: supabase,
|
||||||
|
handle,
|
||||||
|
});
|
||||||
|
if (thumbBlob) void putCachedAttachment(thumbCacheId, thumbBlob);
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
if (thumbBlob) {
|
||||||
|
setThumbUrl(take(thumbBlob));
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Thumb decrypt failure isn't fatal — fall through to the full
|
||||||
|
// blob path below so the user still sees the image.
|
||||||
|
console.warn('thumb load failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eagerly resolve the full blob when we need it for view-once or as
|
||||||
|
// the only render source (no server thumb). For the thumb-first path
|
||||||
|
// the full blob is deferred until the lightbox opens (see below).
|
||||||
|
if (!needsEagerFull) return;
|
||||||
|
|
||||||
const cached = await getCachedAttachment(handle.id);
|
const cached = await getCachedAttachment(handle.id);
|
||||||
let blob: Blob;
|
let blob: Blob;
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -90,10 +135,14 @@ export function AttachmentImage({ handle, mine = false }: Props) {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const full = take(blob);
|
const full = take(blob);
|
||||||
setFullUrl(full);
|
setFullUrl(full);
|
||||||
const thumb = await makeThumbnail(blob);
|
// Pre-Phase-6B fallback: no server thumb shipped, so re-derive a
|
||||||
if (cancelled) return;
|
// smaller preview on the client to keep memory pressure down.
|
||||||
if (thumb) {
|
if (!hasServerThumb) {
|
||||||
setThumbUrl(take(thumb));
|
const thumb = await makeThumbnail(blob);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (thumb) {
|
||||||
|
setThumbUrl(take(thumb));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -101,7 +150,51 @@ export function AttachmentImage({ handle, mine = false }: Props) {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
for (const u of created) URL.revokeObjectURL(u);
|
for (const u of created) URL.revokeObjectURL(u);
|
||||||
};
|
};
|
||||||
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
}, [
|
||||||
|
handle,
|
||||||
|
handle.id,
|
||||||
|
handle.storagePath,
|
||||||
|
handle.keyB64,
|
||||||
|
handle.nonceB64,
|
||||||
|
handle.thumbStoragePath,
|
||||||
|
handle.thumbNonceB64,
|
||||||
|
hasServerThumb,
|
||||||
|
needsEagerFull,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Lazy full-image fetch for click-to-expand (only kicks in when we
|
||||||
|
// skipped the eager full-blob download above). Resolves into the same
|
||||||
|
// `fullUrl` state that the Lightbox consumes; the thumb URL keeps
|
||||||
|
// backing the bubble until the lightbox actually mounts.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lightboxOpen) return;
|
||||||
|
if (fullUrl) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const created: string[] = [];
|
||||||
|
void (async () => {
|
||||||
|
const cached = await getCachedAttachment(handle.id);
|
||||||
|
let blob: Blob;
|
||||||
|
if (cached) {
|
||||||
|
blob = cached;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||||
|
void putCachedAttachment(handle.id, blob);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : 'download failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
const u = URL.createObjectURL(blob);
|
||||||
|
created.push(u);
|
||||||
|
setFullUrl(u);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
for (const u of created) URL.revokeObjectURL(u);
|
||||||
|
};
|
||||||
|
}, [lightboxOpen, fullUrl, handle]);
|
||||||
|
|
||||||
const blobUrl = thumbUrl ?? fullUrl;
|
const blobUrl = thumbUrl ?? fullUrl;
|
||||||
|
|
||||||
@@ -157,7 +250,15 @@ export function AttachmentImage({ handle, mine = false }: Props) {
|
|||||||
className="block h-auto max-h-80 w-auto max-w-full object-contain"
|
className="block h-auto max-h-80 w-auto max-w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{lightboxOpen && fullUrl && <Lightbox url={fullUrl} onClose={() => setLightboxOpen(false)} />}
|
{lightboxOpen && (
|
||||||
|
<Lightbox
|
||||||
|
// Prefer the full blob the moment it's available; otherwise show
|
||||||
|
// the thumb so the user sees *something* during the lazy fetch
|
||||||
|
// (typical full-blob fetch is 100ms–2s depending on size).
|
||||||
|
url={fullUrl ?? blobUrl}
|
||||||
|
onClose={() => setLightboxOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,19 +1,22 @@
|
|||||||
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';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useConversationsContext } from '../context/ConversationsContext';
|
||||||
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,8 +53,9 @@ 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 { patchConversation } = useConversationsContext();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
|
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
|
||||||
const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
|
const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
|
||||||
@@ -120,29 +124,59 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
|
|||||||
const isMuted =
|
const isMuted =
|
||||||
mutedUntil !== null && new Date(mutedUntil).getTime() > Date.now();
|
mutedUntil !== null && new Date(mutedUntil).getTime() > Date.now();
|
||||||
|
|
||||||
|
// Optimistic updates: flip the local state before the server RPC so the
|
||||||
|
// bell / archive icon / checkmark update on the same frame as the click.
|
||||||
|
// Realtime echo via ConversationsContext will reconcile (no-op since the
|
||||||
|
// optimistic patch already matches the server row). On error we restore
|
||||||
|
// the previous value so the menu doesn't lie about persisted state.
|
||||||
const handleArchive = useCallback(
|
const handleArchive = useCallback(
|
||||||
async (next: boolean) => {
|
async (next: boolean) => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
const previous = archived;
|
||||||
|
patchConversation(conversationId, { archived: next });
|
||||||
try {
|
try {
|
||||||
await setConversationArchived(supabase, conversationId, next);
|
await setConversationArchived(supabase, conversationId, next);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
patchConversation(conversationId, { archived: previous });
|
||||||
console.error('archive toggle failed', err);
|
console.error('archive toggle failed', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[conversationId],
|
[conversationId, archived, patchConversation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMute = useCallback(
|
const handleMute = useCallback(
|
||||||
async (minutes: number | null) => {
|
async (minutes: number | null) => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setSubmenuOpen(null);
|
setSubmenuOpen(null);
|
||||||
|
const previous = mutedUntil;
|
||||||
|
const nextIso = muteDurationToIso(minutes);
|
||||||
|
patchConversation(conversationId, { mutedUntil: nextIso });
|
||||||
try {
|
try {
|
||||||
await setConversationMutedUntil(supabase, conversationId, muteDurationToIso(minutes));
|
await setConversationMutedUntil(supabase, conversationId, nextIso);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
patchConversation(conversationId, { mutedUntil: previous });
|
||||||
console.error('mute toggle failed', err);
|
console.error('mute toggle failed', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[conversationId],
|
[conversationId, mutedUntil, patchConversation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMentionsOnly = useCallback(
|
||||||
|
async (next: boolean) => {
|
||||||
|
setOpen(false);
|
||||||
|
const previous = mentionsOnly;
|
||||||
|
patchConversation(conversationId, { mentionsOnly: next });
|
||||||
|
try {
|
||||||
|
await setConversationMentionsOnly(supabase, {
|
||||||
|
conversationId,
|
||||||
|
mentionsOnly: next,
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
patchConversation(conversationId, { mentionsOnly: previous });
|
||||||
|
console.warn('mentions-only toggle failed', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[conversationId, mentionsOnly, patchConversation],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -196,6 +230,16 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
|
|||||||
}}
|
}}
|
||||||
hasSubmenu={!isMuted}
|
hasSubmenu={!isMuted}
|
||||||
/>
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={<AtIcon className="h-4 w-4" />}
|
||||||
|
label={
|
||||||
|
(mentionsOnly ? '✓ ' : '') +
|
||||||
|
t('app:chats.mentions_only', {
|
||||||
|
defaultValue: 'Nur bei @Mentions benachrichtigen',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onClick={() => void handleMentionsOnly(!mentionsOnly)}
|
||||||
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,58 @@ export function MessageBubble({
|
|||||||
defaultValue: 'Nachricht nicht lesbar',
|
defaultValue: 'Nachricht nicht lesbar',
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
) : parsed.kind === 'game' ? (
|
||||||
|
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
||||||
|
<rect x="3" y="6" width="18" height="12" rx="3" />
|
||||||
|
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-semibold text-fg">{parsed.gameType === 'c4' ? 'Vier-Gewinnt' : 'Tic-Tac-Toe'}</div>
|
||||||
|
<div className="text-xs text-fg-muted">Gemeinsam spielen</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('chatapp:open-game', { detail: { id: parsed.gameId } }),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={!parsed.gameId}
|
||||||
|
className="cursor-pointer rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Spielen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : parsed.kind === 'watch_together' ? (
|
||||||
|
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
||||||
|
<rect x="3" y="4" width="18" height="14" rx="2" />
|
||||||
|
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-semibold text-fg">Watch Together</div>
|
||||||
|
<div className="text-xs text-fg-muted">YouTube synchronisiert ansehen</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('chatapp:open-watch-together', { detail: { id: parsed.sessionId } }),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={!parsed.sessionId}
|
||||||
|
className="cursor-pointer rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Beitreten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : parsed.kind === 'whiteboard' ? (
|
) : parsed.kind === 'whiteboard' ? (
|
||||||
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
|
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
|
||||||
@@ -475,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)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -541,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
|
||||||
@@ -691,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,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AutoLockMinutes,
|
||||||
|
getAutoLockMinutes,
|
||||||
|
notifyAutoLockChanged,
|
||||||
|
setAutoLockMinutes,
|
||||||
|
} from '../lib/autoLockSettings';
|
||||||
import { isWipeOnCloseEnabled, setWipeOnClose } from '../lib/memoryWipeSettings';
|
import { isWipeOnCloseEnabled, setWipeOnClose } from '../lib/memoryWipeSettings';
|
||||||
import {
|
import {
|
||||||
changePin,
|
changePin,
|
||||||
@@ -21,6 +27,7 @@ export function SecurityCenter({ userId }: Props) {
|
|||||||
const [recovery, setRecovery] = useState<string | null>(null);
|
const [recovery, setRecovery] = useState<string | null>(null);
|
||||||
const [migration, setMigration] = useState<LegacyMigrationReport | null>(null);
|
const [migration, setMigration] = useState<LegacyMigrationReport | null>(null);
|
||||||
const [wipeOnClose, setWipeOnCloseState] = useState<boolean>(() => isWipeOnCloseEnabled());
|
const [wipeOnClose, setWipeOnCloseState] = useState<boolean>(() => isWipeOnCloseEnabled());
|
||||||
|
const [autoLockMinutes, setAutoLockMinutesState] = useState<AutoLockMinutes>(() => getAutoLockMinutes());
|
||||||
|
|
||||||
async function handleRetryMigration() {
|
async function handleRetryMigration() {
|
||||||
setBusy(true); setMsg(null); setMigration(null);
|
setBusy(true); setMsg(null); setMigration(null);
|
||||||
@@ -149,6 +156,39 @@ export function SecurityCenter({ userId }: Props) {
|
|||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||||
|
Auto-Lock nach Inaktivität
|
||||||
|
</h3>
|
||||||
|
<p className="mb-2 text-xs text-fg-muted">
|
||||||
|
Verlangt erneute PIN-Eingabe nach der gewählten Inaktivitätsdauer. Empfohlen für gemeinsam genutzte Rechner.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-fg">Automatisch sperren</div>
|
||||||
|
<div className="text-xs text-fg-muted">
|
||||||
|
Verlangt erneute PIN-Eingabe nach X Minuten Inaktivität.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={autoLockMinutes}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = Number(e.target.value) as AutoLockMinutes;
|
||||||
|
setAutoLockMinutes(next);
|
||||||
|
notifyAutoLockChanged(next);
|
||||||
|
setAutoLockMinutesState(next);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||||
|
>
|
||||||
|
<option value={0}>Aus</option>
|
||||||
|
<option value={5}>5 min</option>
|
||||||
|
<option value={15}>15 min</option>
|
||||||
|
<option value={30}>30 min</option>
|
||||||
|
<option value={60}>60 min</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-rose-400">Identität zurücksetzen</h3>
|
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-rose-400">Identität zurücksetzen</h3>
|
||||||
<p className="mb-2 text-xs text-fg-muted">Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.</p>
|
<p className="mb-2 text-xs text-fg-muted">Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.</p>
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ interface ConversationsContextValue {
|
|||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
markRead: (conversationId: string) => void;
|
markRead: (conversationId: string) => void;
|
||||||
setActiveConversation: (conversationId: string | null) => void;
|
setActiveConversation: (conversationId: string | null) => void;
|
||||||
|
// Optimistic patch for the caller's per-membership preferences (mute /
|
||||||
|
// mentions-only / archive). Mutations to `conversation_members` echo back
|
||||||
|
// via the realtime channel and `refresh()` reconciles canonically, but the
|
||||||
|
// ~100-200ms roundtrip leaves the UI looking unresponsive. Callers patch
|
||||||
|
// immediately, snapshot the previous state, and roll back on failure.
|
||||||
|
patchConversation: (
|
||||||
|
conversationId: string,
|
||||||
|
patch: Partial<Pick<ConversationSummary, 'archived' | 'mutedUntil' | 'mentionsOnly'>>,
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConversationsContext = createContext<ConversationsContextValue | null>(null);
|
const ConversationsContext = createContext<ConversationsContextValue | null>(null);
|
||||||
@@ -164,6 +173,19 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
|||||||
[markRead],
|
[markRead],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const patchConversation = useCallback<ConversationsContextValue['patchConversation']>(
|
||||||
|
(convId, patch) => {
|
||||||
|
setConversations((prev) => {
|
||||||
|
const idx = prev.findIndex((c) => c.id === convId);
|
||||||
|
if (idx === -1) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = { ...next[idx]!, ...patch };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!myId) {
|
if (!myId) {
|
||||||
setConversations([]);
|
setConversations([]);
|
||||||
@@ -225,7 +247,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,
|
||||||
@@ -303,6 +330,7 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
|||||||
refresh,
|
refresh,
|
||||||
markRead,
|
markRead,
|
||||||
setActiveConversation,
|
setActiveConversation,
|
||||||
|
patchConversation,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
conversations,
|
conversations,
|
||||||
@@ -313,6 +341,7 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
|||||||
refresh,
|
refresh,
|
||||||
markRead,
|
markRead,
|
||||||
setActiveConversation,
|
setActiveConversation,
|
||||||
|
patchConversation,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { getGame, makeGameMove, type GameRecord } from '@chat-app/shared/chat';
|
||||||
|
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
|
||||||
|
export function useGame(gameId: string | null): {
|
||||||
|
game: GameRecord | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
makeMove: (move: object) => Promise<void>;
|
||||||
|
} {
|
||||||
|
const [game, setGame] = useState<GameRecord | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!gameId) {
|
||||||
|
setGame(null);
|
||||||
|
setLoading(false);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const fresh = await getGame(supabase, gameId);
|
||||||
|
if (!cancelled) {
|
||||||
|
setGame(fresh);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
setError(err instanceof Error ? err.message : 'failed to load game');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const channel = supabase
|
||||||
|
.channel('game:' + gameId)
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{
|
||||||
|
event: 'UPDATE',
|
||||||
|
schema: 'public',
|
||||||
|
table: 'conversation_games',
|
||||||
|
filter: 'id=eq.' + gameId,
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
void getGame(supabase, gameId)
|
||||||
|
.then((fresh) => { if (fresh) setGame(fresh); })
|
||||||
|
.catch((err) => { console.warn('game realtime refetch failed', err); });
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
void supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
}, [gameId]);
|
||||||
|
|
||||||
|
const makeMove = useCallback(async (move: object) => {
|
||||||
|
if (!gameId) return;
|
||||||
|
try {
|
||||||
|
await makeGameMove(supabase, { gameId, move });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'move failed');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [gameId]);
|
||||||
|
|
||||||
|
return { game, loading, error, makeMove };
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import {
|
||||||
|
getAutoLockMinutes,
|
||||||
|
subscribeAutoLockSetting,
|
||||||
|
type AutoLockMinutes,
|
||||||
|
} from '../lib/autoLockSettings';
|
||||||
|
|
||||||
|
const ACTIVITY_EVENTS: Array<keyof WindowEventMap> = [
|
||||||
|
'keydown',
|
||||||
|
'mousedown',
|
||||||
|
'pointermove',
|
||||||
|
'touchstart',
|
||||||
|
'wheel',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Throttle activity-event resets to once per second to avoid thrashing the
|
||||||
|
// timer on rapid mouse movement.
|
||||||
|
const RESET_THROTTLE_MS = 1000;
|
||||||
|
|
||||||
|
export function useIdleAutoLock(): void {
|
||||||
|
const { session, signOut } = useAuth();
|
||||||
|
const minutesRef = useRef<AutoLockMinutes>(getAutoLockMinutes());
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
const lastResetAtRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// Keep minutesRef live to the setting.
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribeAutoLockSetting((v) => {
|
||||||
|
minutesRef.current = v;
|
||||||
|
scheduleNext();
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Helper: schedule the lock based on the current setting.
|
||||||
|
function scheduleNext(): void {
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
const min = minutesRef.current;
|
||||||
|
if (min === 0) return; // disabled
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
timerRef.current = null;
|
||||||
|
// Fire the lock. signOut wipes local state and navigates to /device
|
||||||
|
// (PIN re-entry screen).
|
||||||
|
void signOut().catch((err) => console.warn('auto-lock signOut failed', err));
|
||||||
|
}, min * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session) return;
|
||||||
|
scheduleNext();
|
||||||
|
|
||||||
|
const onActivity = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastResetAtRef.current < RESET_THROTTLE_MS) return;
|
||||||
|
lastResetAtRef.current = now;
|
||||||
|
scheduleNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const ev of ACTIVITY_EVENTS) {
|
||||||
|
window.addEventListener(ev, onActivity, { passive: true });
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
for (const ev of ACTIVITY_EVENTS) {
|
||||||
|
window.removeEventListener(ev, onActivity);
|
||||||
|
}
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [session]);
|
||||||
|
}
|
||||||
@@ -64,10 +64,37 @@ export function useOwnDevices(): {
|
|||||||
|
|
||||||
const revoke = useCallback(
|
const revoke = useCallback(
|
||||||
async (deviceId: string) => {
|
async (deviceId: string) => {
|
||||||
await revokeDevice(supabase, deviceId);
|
// Optimistic: flip `revokedAt` on the row so the "Abgemeldet" badge
|
||||||
await refresh();
|
// appears on the same frame as the click. Capture a snapshot so we
|
||||||
|
// can restore exactly on RPC failure (the realtime subscription's
|
||||||
|
// own UPDATE echo would otherwise reconcile back to "not revoked"
|
||||||
|
// anyway). Skip if the row isn't in our list — nothing to undo.
|
||||||
|
let snapshot: DeviceRecord[] | null = null;
|
||||||
|
const stampedAt = new Date().toISOString();
|
||||||
|
setState((prev) => {
|
||||||
|
if (!prev.devices.some((d) => d.id === deviceId)) return prev;
|
||||||
|
snapshot = prev.devices;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
devices: prev.devices.map((d) =>
|
||||||
|
d.id === deviceId ? { ...d, revokedAt: d.revokedAt ?? stampedAt } : d,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await revokeDevice(supabase, deviceId);
|
||||||
|
// Skip the eager refresh: the realtime UPDATE on `devices` triggers
|
||||||
|
// refresh() via the subscription and the optimistic row already
|
||||||
|
// shows the badge. Avoids a list flicker between optimistic and
|
||||||
|
// canonical state.
|
||||||
|
} catch (err) {
|
||||||
|
if (snapshot) {
|
||||||
|
setState((prev) => ({ ...prev, devices: snapshot! }));
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[refresh],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { devices: state.devices, loading: state.loading, error: state.error, refresh, revoke };
|
return { devices: state.devices, loading: state.loading, error: state.error, refresh, revoke };
|
||||||
|
|||||||
@@ -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,43 @@
|
|||||||
|
// Per-install setting for PIN-idle-auto-lock. 0 = disabled.
|
||||||
|
// Values match the dropdown options (5/15/30/60 minutes).
|
||||||
|
|
||||||
|
const KEY = 'chatapp.autoLockMinutes.v1';
|
||||||
|
|
||||||
|
export type AutoLockMinutes = 0 | 5 | 15 | 30 | 60;
|
||||||
|
|
||||||
|
const VALID: AutoLockMinutes[] = [0, 5, 15, 30, 60];
|
||||||
|
|
||||||
|
export function getAutoLockMinutes(): AutoLockMinutes {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(KEY);
|
||||||
|
if (!raw) return 0;
|
||||||
|
const n = Number(raw);
|
||||||
|
if (VALID.includes(n as AutoLockMinutes)) return n as AutoLockMinutes;
|
||||||
|
return 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Listener = (value: AutoLockMinutes) => void;
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
export function subscribeAutoLockSetting(l: Listener): () => void {
|
||||||
|
listeners.add(l);
|
||||||
|
return () => listeners.delete(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyAutoLockChanged(value: AutoLockMinutes): void {
|
||||||
|
for (const l of listeners) {
|
||||||
|
try { l(value); } catch (err) { console.warn(err); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAutoLockMinutes(value: AutoLockMinutes): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(KEY, String(value));
|
||||||
|
} catch {
|
||||||
|
/* quota */
|
||||||
|
}
|
||||||
|
notifyAutoLockChanged(value);
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
type DecryptedMessage,
|
type DecryptedMessage,
|
||||||
type PollOption,
|
type PollOption,
|
||||||
type WhiteboardPayload,
|
type WhiteboardPayload,
|
||||||
|
type WatchTogetherPayload,
|
||||||
|
type GamePayload,
|
||||||
} from '@chat-app/shared/chat';
|
} from '@chat-app/shared/chat';
|
||||||
|
|
||||||
export type AttachmentBucket = 'media' | 'audio' | 'files';
|
export type AttachmentBucket = 'media' | 'audio' | 'files';
|
||||||
@@ -107,6 +109,25 @@ export function createWhiteboardPayload(whiteboardId: string): string {
|
|||||||
return serializeMessagePayload(payload);
|
return serializeMessagePayload(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createWatchTogetherPayload(sessionId: string): string {
|
||||||
|
const payload: WatchTogetherPayload = {
|
||||||
|
v: 1,
|
||||||
|
type: 'watch_together',
|
||||||
|
session_id: sessionId,
|
||||||
|
};
|
||||||
|
return serializeMessagePayload(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGamePayload(gameId: string, gameType: 'ttt' | 'c4'): string {
|
||||||
|
const payload: GamePayload = {
|
||||||
|
v: 1,
|
||||||
|
type: 'game',
|
||||||
|
game_id: gameId,
|
||||||
|
game_type: gameType,
|
||||||
|
};
|
||||||
|
return serializeMessagePayload(payload);
|
||||||
|
}
|
||||||
|
|
||||||
export function summarizePollVotes(
|
export function summarizePollVotes(
|
||||||
options: PollOption[],
|
options: PollOption[],
|
||||||
reactions: ReactionSummaryInput[],
|
reactions: ReactionSummaryInput[],
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Main-thread wrapper around the crypto Web Worker (Argon2id pwhash +
|
||||||
|
// sealed user-key open). Each unlock attempt spawns a fresh one-shot worker
|
||||||
|
// — workers are cheap and the pwhash is a one-time cost per login, so we
|
||||||
|
// avoid the bookkeeping needed for a persistent request queue.
|
||||||
|
//
|
||||||
|
// Falls back to inline (main-thread) `openUserKey` when the `Worker`
|
||||||
|
// constructor is unavailable (e.g. vitest's jsdom environment, strict CSPs).
|
||||||
|
// The fallback path is identical in semantics to the worker path; the only
|
||||||
|
// difference is whether it blocks the main thread.
|
||||||
|
//
|
||||||
|
// Why not a long-lived worker? The KDF cost dwarfs the spawn cost (~1-2 s
|
||||||
|
// vs. a few ms), and PIN-unlock happens at most once per session. Keeping a
|
||||||
|
// worker resident would also require a request-id correlation map which the
|
||||||
|
// decrypt.worker uses (because per-message decrypts are high-volume).
|
||||||
|
|
||||||
|
import { openUserKey } from '@chat-app/shared/crypto';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
OpenUserKeyInput,
|
||||||
|
OpenUserKeyResult,
|
||||||
|
} from '../workers/crypto.worker';
|
||||||
|
|
||||||
|
type WorkerResponse =
|
||||||
|
| { ok: true; result: OpenUserKeyResult }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
export type { OpenUserKeyInput, OpenUserKeyResult };
|
||||||
|
|
||||||
|
export async function openUserKeyInWorker(
|
||||||
|
input: OpenUserKeyInput,
|
||||||
|
): Promise<OpenUserKeyResult> {
|
||||||
|
const worker = new Worker(
|
||||||
|
new URL('../workers/crypto.worker.ts', import.meta.url),
|
||||||
|
{ type: 'module' },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return await new Promise<OpenUserKeyResult>((resolve, reject) => {
|
||||||
|
worker.addEventListener('message', (ev: MessageEvent<WorkerResponse>) => {
|
||||||
|
const msg = ev.data;
|
||||||
|
if (msg && msg.ok) resolve(msg.result);
|
||||||
|
else reject(new Error(msg?.error ?? 'crypto worker returned malformed response'));
|
||||||
|
});
|
||||||
|
worker.addEventListener('error', (ev: ErrorEvent) => {
|
||||||
|
reject(new Error(ev.message || 'crypto worker error'));
|
||||||
|
});
|
||||||
|
worker.postMessage({ op: 'openUserKey', input });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
worker.terminate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public entry point: route through the worker when possible, fall back to
|
||||||
|
// the synchronous-on-main-thread path otherwise. Callers should prefer this
|
||||||
|
// over importing `openUserKey` directly so we get the worker speedup
|
||||||
|
// everywhere it's available.
|
||||||
|
export async function openUserKeyMaybeWorker(
|
||||||
|
input: OpenUserKeyInput,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
if (typeof Worker === 'undefined') {
|
||||||
|
return openUserKey(input);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await openUserKeyInWorker(input);
|
||||||
|
return result.privateKey;
|
||||||
|
} catch (err) {
|
||||||
|
// If the worker spawn or message round-trip fails (e.g. CSP blocks
|
||||||
|
// module workers in some packaging modes), fall back to inline so the
|
||||||
|
// unlock still succeeds — just with a brief main-thread hitch.
|
||||||
|
console.warn('[cryptoWorker] worker path failed, falling back to inline', err);
|
||||||
|
return openUserKey(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,3 +55,51 @@ function renameToWebp(original: string): string {
|
|||||||
export async function compressImages(files: File[]): Promise<File[]> {
|
export async function compressImages(files: File[]): Promise<File[]> {
|
||||||
return Promise.all(files.map((f) => compressImage(f)));
|
return Promise.all(files.map((f) => compressImage(f)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bandwidth threshold for thumb generation. Below ~50KB the WebP overhead
|
||||||
|
// of a fresh re-encode can exceed the original; not worth a second upload.
|
||||||
|
const THUMB_SKIP_BELOW_BYTES = 50 * 1024;
|
||||||
|
const THUMB_MAX_DIM = 320;
|
||||||
|
const THUMB_QUALITY = 0.7;
|
||||||
|
// Animated formats lose motion when redrawn onto a Canvas, so we skip them
|
||||||
|
// and let the receiver render the full file. GIF is the dominant case; the
|
||||||
|
// rest stay too (apng/animated-webp).
|
||||||
|
const THUMB_ANIMATED_MIME = /^image\/(gif|apng)$/;
|
||||||
|
|
||||||
|
// Generates a small WebP preview thumb (max 320×320) from an image file.
|
||||||
|
// Used by the send path so each image attachment can ship a tiny inline
|
||||||
|
// preview alongside the encrypted full blob. Returns `null` when:
|
||||||
|
// - the input isn't an image,
|
||||||
|
// - the input is animated (GIF/APNG — would lose motion),
|
||||||
|
// - the input is already small enough that a thumb wouldn't save bandwidth,
|
||||||
|
// - OffscreenCanvas / createImageBitmap aren't available, or
|
||||||
|
// - decode/encode threw (corrupt input).
|
||||||
|
// The caller treats `null` as "skip thumb" and uploads only the full blob.
|
||||||
|
export async function generateWebPThumb(
|
||||||
|
file: File,
|
||||||
|
maxDim: number = THUMB_MAX_DIM,
|
||||||
|
): Promise<Blob | null> {
|
||||||
|
if (!file.type.startsWith('image/')) return null;
|
||||||
|
if (THUMB_ANIMATED_MIME.test(file.type)) return null;
|
||||||
|
if (file.size < THUMB_SKIP_BELOW_BYTES) return null;
|
||||||
|
if (typeof createImageBitmap !== 'function') return null;
|
||||||
|
if (typeof OffscreenCanvas !== 'function') return null;
|
||||||
|
try {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
const ratio = Math.min(maxDim / bitmap.width, maxDim / bitmap.height, 1);
|
||||||
|
const w = Math.max(1, Math.round(bitmap.width * ratio));
|
||||||
|
const h = Math.max(1, Math.round(bitmap.height * ratio));
|
||||||
|
const canvas = new OffscreenCanvas(w, h);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
bitmap.close();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||||
|
bitmap.close();
|
||||||
|
return await canvas.convertToBlob({ type: 'image/webp', quality: THUMB_QUALITY });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('generateWebPThumb failed', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const PRESERVE_LOCAL_STORAGE = new Set([
|
|||||||
'chatapp.locale',
|
'chatapp.locale',
|
||||||
'chatapp.installId',
|
'chatapp.installId',
|
||||||
'chatapp.wipeOnClose.v1',
|
'chatapp.wipeOnClose.v1',
|
||||||
|
'chatapp.autoLockMinutes.v1',
|
||||||
'i18nextLng',
|
'i18nextLng',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { decryptBatch as decryptBatchWorker } from './decryptWorker';
|
import { decryptBatch as decryptBatchWorker } from './decryptWorker';
|
||||||
|
import { generateWebPThumb } from './imageCompress';
|
||||||
import {
|
import {
|
||||||
loadCachedMessages,
|
loadCachedMessages,
|
||||||
persistMessages,
|
persistMessages,
|
||||||
@@ -632,6 +633,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
throw new Error('attachment exceeds max size (10 MB)');
|
throw new Error('attachment exceeds max size (10 MB)');
|
||||||
}
|
}
|
||||||
const dims = await readImageDimensions(file);
|
const dims = await readImageDimensions(file);
|
||||||
|
// Phase 6B: generate a small WebP preview thumb so the receiver's
|
||||||
|
// bubble loads fast (typical 320×240 WebP is 10–30KB vs the full
|
||||||
|
// image's 1–10MB). `generateWebPThumb` short-circuits to null on
|
||||||
|
// non-images, animated formats, and small files — and on failure;
|
||||||
|
// the upload helper then just skips the second upload.
|
||||||
|
const thumbBlob = await generateWebPThumb(file);
|
||||||
const res = await encryptAndUploadAttachment({
|
const res = await encryptAndUploadAttachment({
|
||||||
client: supabase,
|
client: supabase,
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -640,6 +647,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
sizeBytes: file.size,
|
sizeBytes: file.size,
|
||||||
...(dims.width !== undefined ? { width: dims.width } : {}),
|
...(dims.width !== undefined ? { width: dims.width } : {}),
|
||||||
...(dims.height !== undefined ? { height: dims.height } : {}),
|
...(dims.height !== undefined ? { height: dims.height } : {}),
|
||||||
|
...(thumbBlob ? { thumbBlob } : {}),
|
||||||
});
|
});
|
||||||
// Stamp the view-once flag on each handle the caller requested it
|
// Stamp the view-once flag on each handle the caller requested it
|
||||||
// for. The flag rides inside the encrypted payload (so peers can
|
// for. The flag rides inside the encrypted payload (so peers can
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
import { listPinnedMessages, type PinnedMessage } from '@chat-app/shared/chat';
|
import { listPinnedMessages, type PinnedMessage } from '@chat-app/shared/chat';
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { supabase } from './supabase';
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
|
export interface UsePinnedMessagesResult {
|
||||||
|
pins: PinnedMessage[];
|
||||||
|
// Optimistic insert. Caller flips the UI immediately; server insert +
|
||||||
|
// realtime echo will reconcile (dedup'd by messageId). Returns the
|
||||||
|
// previous snapshot so the caller can roll back on error.
|
||||||
|
applyOptimisticPin: (messageId: string, pinnedBy: string) => PinnedMessage[];
|
||||||
|
applyOptimisticUnpin: (messageId: string) => PinnedMessage[];
|
||||||
|
// Hard restore for rollback after a failed server call.
|
||||||
|
restorePins: (snapshot: PinnedMessage[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
// Live list of pinned messages for one conversation. Subscribes to the
|
// Live list of pinned messages for one conversation. Subscribes to the
|
||||||
// `pinned_messages` realtime channel for the conv so the header pill +
|
// `pinned_messages` realtime channel for the conv so the header pill +
|
||||||
// side-panel update without a refetch.
|
// side-panel update without a refetch. The `applyOptimistic*` helpers let
|
||||||
export function usePinnedMessages(conversationId: string | undefined): PinnedMessage[] {
|
// callers flip local state synchronously on user action so the pin button
|
||||||
|
// doesn't appear unresponsive while the ~100-200ms server roundtrip + the
|
||||||
|
// realtime refetch round complete.
|
||||||
|
export function usePinnedMessages(conversationId: string | undefined): UsePinnedMessagesResult {
|
||||||
const [pins, setPins] = useState<PinnedMessage[]>([]);
|
const [pins, setPins] = useState<PinnedMessage[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -44,5 +58,42 @@ export function usePinnedMessages(conversationId: string | undefined): PinnedMes
|
|||||||
};
|
};
|
||||||
}, [conversationId]);
|
}, [conversationId]);
|
||||||
|
|
||||||
return pins;
|
const applyOptimisticPin = useCallback<UsePinnedMessagesResult['applyOptimisticPin']>(
|
||||||
|
(messageId, pinnedBy) => {
|
||||||
|
if (!conversationId) return pins;
|
||||||
|
let snapshot: PinnedMessage[] = pins;
|
||||||
|
setPins((prev) => {
|
||||||
|
snapshot = prev;
|
||||||
|
if (prev.some((p) => p.messageId === messageId)) return prev;
|
||||||
|
const optimistic: PinnedMessage = {
|
||||||
|
conversationId,
|
||||||
|
messageId,
|
||||||
|
pinnedBy,
|
||||||
|
pinnedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
// Newest first matches the listPinnedMessages order.
|
||||||
|
return [optimistic, ...prev];
|
||||||
|
});
|
||||||
|
return snapshot;
|
||||||
|
},
|
||||||
|
[conversationId, pins],
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyOptimisticUnpin = useCallback<UsePinnedMessagesResult['applyOptimisticUnpin']>(
|
||||||
|
(messageId) => {
|
||||||
|
let snapshot: PinnedMessage[] = pins;
|
||||||
|
setPins((prev) => {
|
||||||
|
snapshot = prev;
|
||||||
|
return prev.filter((p) => p.messageId !== messageId);
|
||||||
|
});
|
||||||
|
return snapshot;
|
||||||
|
},
|
||||||
|
[pins],
|
||||||
|
);
|
||||||
|
|
||||||
|
const restorePins = useCallback<UsePinnedMessagesResult['restorePins']>((snapshot) => {
|
||||||
|
setPins(snapshot);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { pins, applyOptimisticPin, applyOptimisticUnpin, restorePins };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import {
|
|||||||
} from '@chat-app/shared/auth';
|
} from '@chat-app/shared/auth';
|
||||||
import {
|
import {
|
||||||
generateRecoveryCode, generateUserKeyPair, getCryptoBackend, normalizeRecoveryCode,
|
generateRecoveryCode, generateUserKeyPair, getCryptoBackend, normalizeRecoveryCode,
|
||||||
openUserKey, sealUserKey,
|
sealUserKey,
|
||||||
} from '@chat-app/shared/crypto';
|
} from '@chat-app/shared/crypto';
|
||||||
import { migrateOwnLegacyBundles } from '@chat-app/shared/chat';
|
import { migrateOwnLegacyBundles } from '@chat-app/shared/chat';
|
||||||
|
|
||||||
|
import { openUserKeyMaybeWorker } from './cryptoWorker';
|
||||||
import { devLocalSecretStore } from './secretStore';
|
import { devLocalSecretStore } from './secretStore';
|
||||||
import { supabase } from './supabase';
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ export async function loadOrUnlockUserKey(p: UnlockParams): Promise<UnlockOutcom
|
|||||||
if (!sealed || !salt) throw new Error('no recovery blob configured');
|
if (!sealed || !salt) throw new Error('no recovery blob configured');
|
||||||
let priv: Uint8Array;
|
let priv: Uint8Array;
|
||||||
try {
|
try {
|
||||||
priv = await openUserKey({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
|
priv = await openUserKeyMaybeWorker({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await recordPinAttempt(supabase, p.userId, false, p.isRecoveryCode === true).catch(() => {});
|
await recordPinAttempt(supabase, p.userId, false, p.isRecoveryCode === true).catch(() => {});
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
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, 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';
|
||||||
|
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
|
||||||
|
|
||||||
import { ConversationHeader } from '../components/ConversationHeader';
|
import { ConversationHeader } from '../components/ConversationHeader';
|
||||||
import { EmojiPicker } from '../components/EmojiPicker';
|
import { EmojiPicker } from '../components/EmojiPicker';
|
||||||
@@ -26,13 +27,16 @@ import {
|
|||||||
SpinnerIcon,
|
SpinnerIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '../components/icons';
|
} from '../components/icons';
|
||||||
import { ImageAnnotator } from '../components/ImageAnnotator';
|
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';
|
||||||
@@ -46,9 +50,19 @@ import {
|
|||||||
collectConversationAttachments,
|
collectConversationAttachments,
|
||||||
createPollPayload,
|
createPollPayload,
|
||||||
createWhiteboardPayload,
|
createWhiteboardPayload,
|
||||||
|
createWatchTogetherPayload,
|
||||||
|
createGamePayload,
|
||||||
} from '../lib/conversationFeatures';
|
} from '../lib/conversationFeatures';
|
||||||
import { WhiteboardModal } from '../components/WhiteboardModal';
|
const WhiteboardModal = lazy(() =>
|
||||||
import { createWhiteboard } from '@chat-app/shared/chat';
|
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';
|
||||||
@@ -64,16 +78,35 @@ import { usePeerPresence } from '../lib/usePeerPresence';
|
|||||||
import { usePinnedMessages } from '../lib/usePinnedMessages';
|
import { usePinnedMessages } from '../lib/usePinnedMessages';
|
||||||
import { useTypingChannel } from '../lib/useTypingChannel';
|
import { useTypingChannel } from '../lib/useTypingChannel';
|
||||||
|
|
||||||
const STICK_THRESHOLD = 80;
|
// Discriminated union for rows inside the virtualized message list. Keeping
|
||||||
|
// pending bubbles and the "load older" tile inside the same Virtuoso
|
||||||
|
// instance means scroll-to-bottom / followOutput stay coherent across both
|
||||||
|
// (we don't need a sibling scroll container for pending items).
|
||||||
|
type VirtuosoRow =
|
||||||
|
| { kind: 'loader'; key: string }
|
||||||
|
| { kind: 'message'; key: string; message: DecryptedMessage; idx: number }
|
||||||
|
| { kind: 'pending'; key: string; item: OutboxItem };
|
||||||
|
|
||||||
|
// 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
|
||||||
// (lost on reload, like Discord). The `stickToBottom` flag is preserved
|
// (lost on reload, like Discord). The `stickToBottom` flag is preserved
|
||||||
// alongside the pixel offset so a chat the user left at the bottom keeps
|
// alongside the topmost-visible row index so a chat the user left at the
|
||||||
// auto-following new messages when they return; a chat scrolled up
|
// bottom keeps auto-following new messages when they return; a chat
|
||||||
// returns to the exact spot the user was reading.
|
// scrolled up returns to roughly the same row the user was reading.
|
||||||
const scrollPositions = new Map<string, { scrollTop: number; stickToBottom: boolean }>();
|
//
|
||||||
|
// We track the topmost-visible row index rather than a pixel `scrollTop`
|
||||||
|
// because `react-virtuoso` virtualizes the list — the underlying scroll
|
||||||
|
// element's pixel offset depends on dynamically-measured row heights and
|
||||||
|
// is not stable across re-mounts. Using a row index restores the user's
|
||||||
|
// reading position even if some rows above re-render at different heights.
|
||||||
|
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();
|
||||||
|
|
||||||
export function ConversationPage() {
|
export function ConversationPage() {
|
||||||
const { t } = useTranslation(['app', 'errors']);
|
const { t } = useTranslation(['app', 'errors']);
|
||||||
@@ -169,6 +202,15 @@ export function ConversationPage() {
|
|||||||
const [pollSending, setPollSending] = useState(false);
|
const [pollSending, setPollSending] = useState(false);
|
||||||
const [openWhiteboardId, setOpenWhiteboardId] = useState<string | null>(null);
|
const [openWhiteboardId, setOpenWhiteboardId] = useState<string | null>(null);
|
||||||
const [creatingWhiteboard, setCreatingWhiteboard] = useState(false);
|
const [creatingWhiteboard, setCreatingWhiteboard] = useState(false);
|
||||||
|
const [openWatchSessionId, setOpenWatchSessionId] = useState<string | null>(null);
|
||||||
|
const [watchDialogOpen, setWatchDialogOpen] = useState(false);
|
||||||
|
const [watchUrl, setWatchUrl] = useState('');
|
||||||
|
const [watchError, setWatchError] = useState<string | null>(null);
|
||||||
|
const [watchCreating, setWatchCreating] = useState(false);
|
||||||
|
const [openGameId, setOpenGameId] = useState<string | null>(null);
|
||||||
|
const [gameDialogOpen, setGameDialogOpen] = useState(false);
|
||||||
|
const [gameError, setGameError] = useState<string | null>(null);
|
||||||
|
const [gameCreating, setGameCreating] = useState(false);
|
||||||
const [pollError, setPollError] = useState<string | null>(null);
|
const [pollError, setPollError] = useState<string | null>(null);
|
||||||
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
|
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
|
||||||
const [forwardTarget, setForwardTarget] = useState<DecryptedMessage | null>(null);
|
const [forwardTarget, setForwardTarget] = useState<DecryptedMessage | null>(null);
|
||||||
@@ -199,24 +241,34 @@ export function ConversationPage() {
|
|||||||
// Auto-clears on a successful send so the composer doesn't accidentally
|
// Auto-clears on a successful send so the composer doesn't accidentally
|
||||||
// burn the message-after-next.
|
// burn the message-after-next.
|
||||||
const [viewOnceNext, setViewOnceNext] = useState(false);
|
const [viewOnceNext, setViewOnceNext] = useState(false);
|
||||||
const pins = usePinnedMessages(id);
|
const { pins, applyOptimisticPin, applyOptimisticUnpin, restorePins } = usePinnedMessages(id);
|
||||||
const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false);
|
const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false);
|
||||||
const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]);
|
const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]);
|
||||||
|
|
||||||
|
// Optimistic pin/unpin: flip the local list synchronously so the pin badge
|
||||||
|
// / panel updates on the same frame as the click. Realtime echo via
|
||||||
|
// usePinnedMessages will refetch and reconcile (no-op since the optimistic
|
||||||
|
// row matches the server). On error we restore the snapshot so the badge
|
||||||
|
// doesn't lie about persisted state.
|
||||||
const handleTogglePin = useCallback(
|
const handleTogglePin = useCallback(
|
||||||
async (messageId: string) => {
|
async (messageId: string) => {
|
||||||
if (!id || !myId) return;
|
if (!id || !myId) return;
|
||||||
|
const wasPinned = pinnedIds.has(messageId);
|
||||||
|
const snapshot = wasPinned
|
||||||
|
? applyOptimisticUnpin(messageId)
|
||||||
|
: applyOptimisticPin(messageId, myId);
|
||||||
try {
|
try {
|
||||||
if (pinnedIds.has(messageId)) {
|
if (wasPinned) {
|
||||||
await unpinMessage(supabase, id, messageId);
|
await unpinMessage(supabase, id, messageId);
|
||||||
} else {
|
} else {
|
||||||
await pinMessage(supabase, id, messageId, myId);
|
await pinMessage(supabase, id, messageId, myId);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
restorePins(snapshot);
|
||||||
console.warn('pin toggle failed', err);
|
console.warn('pin toggle failed', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[id, myId, pinnedIds],
|
[id, myId, pinnedIds, applyOptimisticPin, applyOptimisticUnpin, restorePins],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleGifPick = useCallback(
|
const handleGifPick = useCallback(
|
||||||
@@ -235,8 +287,8 @@ export function ConversationPage() {
|
|||||||
},
|
},
|
||||||
[send],
|
[send],
|
||||||
);
|
);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||||
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
const topmostIndexRef = useRef<number>(0);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
@@ -281,21 +333,17 @@ export function ConversationPage() {
|
|||||||
previousMessageIdsRef.current = new Set(messages.map((message) => message.id));
|
previousMessageIdsRef.current = new Set(messages.map((message) => message.id));
|
||||||
}, [messages, myId, stickToBottom]);
|
}, [messages, myId, stickToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Replaces the previous IntersectionObserver-on-sentinel pattern: Virtuoso
|
||||||
const el = loadMoreSentinelRef.current;
|
// calls `startReached` when the user scrolls near the first row of the
|
||||||
if (!el) return;
|
// virtualized list. We bump `displayCount` the same way the old observer
|
||||||
if (displayCount >= messages.length) return;
|
// did. Wrapped in useCallback so Virtuoso doesn't tear down its scroll
|
||||||
const observer = new IntersectionObserver(
|
// observer on every parent re-render.
|
||||||
(entries) => {
|
const handleStartReached = useCallback(() => {
|
||||||
if (entries[0]?.isIntersecting) {
|
setDisplayCount((n) => {
|
||||||
setDisplayCount((n) => Math.min(messages.length, n * 2));
|
if (n >= messages.length) return n;
|
||||||
}
|
return Math.min(messages.length, n * 2);
|
||||||
},
|
});
|
||||||
{ root: scrollRef.current, rootMargin: '200px 0px' },
|
}, [messages.length]);
|
||||||
);
|
|
||||||
observer.observe(el);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [displayCount, messages.length]);
|
|
||||||
|
|
||||||
const messageById = useMemo(() => {
|
const messageById = useMemo(() => {
|
||||||
const m = new Map<string, DecryptedMessage>();
|
const m = new Map<string, DecryptedMessage>();
|
||||||
@@ -348,15 +396,101 @@ export function ConversationPage() {
|
|||||||
[messageById, senderNameFor, t],
|
[messageById, senderNameFor, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const jumpToMessage = useCallback((targetId: string) => {
|
// Pre-compute quoted refs per message into a stable map. Calling
|
||||||
const el = scrollRef.current?.querySelector<HTMLElement>(
|
// `buildQuoted(m.replyToId)` inline inside the `.map` returned a fresh
|
||||||
'[data-message-id="' + CSS.escape(targetId) + '"]',
|
// object on every parent render, defeating `React.memo` on MessageBubble.
|
||||||
);
|
// With the map memoized on the same deps as `buildQuoted`, each bubble
|
||||||
if (!el) return;
|
// gets a stable `quoted` reference until the underlying data actually
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
// changes (new messages, sender renames, language switch).
|
||||||
setHighlightedId(targetId);
|
const quotedByMessage = useMemo(() => {
|
||||||
window.setTimeout(() => setHighlightedId((cur) => (cur === targetId ? null : cur)), 1600);
|
const out = new Map<string, QuotedRef | null>();
|
||||||
}, []);
|
for (const m of messages) {
|
||||||
|
out.set(m.id, buildQuoted(m.replyToId));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [messages, buildQuoted]);
|
||||||
|
|
||||||
|
// Build the discriminated-union row list Virtuoso renders. We use a
|
||||||
|
// single virtualized list rather than separate "messages" and "pending"
|
||||||
|
// sections so the unsent items stay at the bottom of the scroll viewport
|
||||||
|
// (and Virtuoso's `followOutput` still triggers correctly when a new
|
||||||
|
// outbox item is appended). Optional row 0 is the "load older" tile —
|
||||||
|
// matches the old IntersectionObserver-sentinel pattern.
|
||||||
|
const virtuosoRows = useMemo<VirtuosoRow[]>(() => {
|
||||||
|
const out: VirtuosoRow[] = [];
|
||||||
|
const hasLoader = displayCount < messages.length;
|
||||||
|
if (hasLoader) {
|
||||||
|
out.push({ kind: 'loader', key: '__loader__' });
|
||||||
|
}
|
||||||
|
const sliceStart = Math.max(0, messages.length - displayCount);
|
||||||
|
for (let i = sliceStart; i < messages.length; i++) {
|
||||||
|
const m = messages[i];
|
||||||
|
if (!m) continue;
|
||||||
|
out.push({ kind: 'message', key: m.id, message: m, idx: i });
|
||||||
|
}
|
||||||
|
for (const p of pending) {
|
||||||
|
out.push({ kind: 'pending', key: 'pending-' + p.id, item: p });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [messages, pending, displayCount]);
|
||||||
|
|
||||||
|
// Initial scroll position for the freshly-mounted Virtuoso instance.
|
||||||
|
// Default = bottom (newest message). If we have a saved position from a
|
||||||
|
// previous visit to this chat AND the user wasn't sticking to the
|
||||||
|
// bottom, restore the saved row index (clamped to the current row
|
||||||
|
// count in case the cache was trimmed).
|
||||||
|
const initialTopMostIndex = useMemo(() => {
|
||||||
|
const saved = savedPositionRef.current;
|
||||||
|
if (saved && !saved.stickToBottom) {
|
||||||
|
return Math.max(0, Math.min(saved.topmostIndex, virtuosoRows.length - 1));
|
||||||
|
}
|
||||||
|
return virtuosoRows.length - 1;
|
||||||
|
// virtuosoRows.length changes when the conversation loads — that's the
|
||||||
|
// intentional trigger so a freshly-loaded chat anchors to the bottom
|
||||||
|
// on first paint. We deliberately don't re-derive this on every row
|
||||||
|
// append; Virtuoso owns scroll position from that point on.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [virtuosoRows.length > 0]);
|
||||||
|
|
||||||
|
const jumpToMessage = useCallback(
|
||||||
|
(targetId: string) => {
|
||||||
|
const msgIdx = messages.findIndex((m) => m.id === targetId);
|
||||||
|
if (msgIdx < 0) return; // pinned message outside loaded cache — no-op
|
||||||
|
// Make sure the target is actually inside the rendered slice; if the
|
||||||
|
// user has only loaded the most-recent 150 rows but is jumping to an
|
||||||
|
// older message, expand the slice so the row exists in the virtual
|
||||||
|
// list before we ask Virtuoso to scroll to it.
|
||||||
|
const needsAtLeast = messages.length - msgIdx;
|
||||||
|
if (needsAtLeast > displayCount) {
|
||||||
|
setDisplayCount(needsAtLeast);
|
||||||
|
}
|
||||||
|
// Convert message-array index into row index for the virtuoso rows
|
||||||
|
// array (see `rows` further down). The slice starts at
|
||||||
|
// `messages.length - displayCount`, and row 0 is the optional
|
||||||
|
// "load older" header.
|
||||||
|
const targetDisplayCount = Math.max(displayCount, needsAtLeast);
|
||||||
|
const sliceStart = Math.max(0, messages.length - targetDisplayCount);
|
||||||
|
const hasLoader = targetDisplayCount < messages.length;
|
||||||
|
const rowIndex = (hasLoader ? 1 : 0) + (msgIdx - sliceStart);
|
||||||
|
// requestAnimationFrame: when we just bumped `displayCount`, Virtuoso
|
||||||
|
// needs a paint to register the new rows before scrollToIndex can
|
||||||
|
// resolve the target row. Without this, the scroll either no-ops or
|
||||||
|
// lands on a stale row.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
virtuosoRef.current?.scrollToIndex({
|
||||||
|
index: rowIndex,
|
||||||
|
align: 'center',
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setHighlightedId(targetId);
|
||||||
|
window.setTimeout(
|
||||||
|
() => setHighlightedId((cur) => (cur === targetId ? null : cur)),
|
||||||
|
1600,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[messages, displayCount],
|
||||||
|
);
|
||||||
|
|
||||||
const handleReply = useCallback((m: DecryptedMessage) => {
|
const handleReply = useCallback((m: DecryptedMessage) => {
|
||||||
setReplyTo(m);
|
setReplyTo(m);
|
||||||
@@ -367,6 +501,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 ||
|
||||||
@@ -472,89 +619,113 @@ export function ConversationPage() {
|
|||||||
return () => window.removeEventListener('chatapp:open-whiteboard', onOpen);
|
return () => window.removeEventListener('chatapp:open-whiteboard', onOpen);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onOpen = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent<{ id?: string }>).detail;
|
||||||
|
if (detail?.id) setOpenWatchSessionId(detail.id);
|
||||||
|
};
|
||||||
|
window.addEventListener('chatapp:open-watch-together', onOpen);
|
||||||
|
return () => window.removeEventListener('chatapp:open-watch-together', onOpen);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onOpen = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent<{ id?: string }>).detail;
|
||||||
|
if (detail?.id) setOpenGameId(detail.id);
|
||||||
|
};
|
||||||
|
window.addEventListener('chatapp:open-game', onOpen);
|
||||||
|
return () => window.removeEventListener('chatapp:open-game', onOpen);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id && messages.length > 0) markRead(id);
|
if (id && messages.length > 0) markRead(id);
|
||||||
}, [id, messages.length, markRead]);
|
}, [id, messages.length, markRead]);
|
||||||
|
|
||||||
// useLayoutEffect: run synchronously after DOM commit, before the
|
// Scroll-to-bottom is handled by Virtuoso's `followOutput` prop, which
|
||||||
// browser paints. Using useEffect here let one frame of "scrollTop = 0
|
// fires whenever the rendered row count grows and auto-scrolls down only
|
||||||
// (top of list)" paint between message-list mount and the auto-scroll,
|
// if the user was already at the bottom — exactly the Discord behavior
|
||||||
// which is exactly the "flickers to a different position, then jumps"
|
// we want for both outgoing sends and incoming realtime messages.
|
||||||
// glitch users saw when re-entering a chat. Layout-effect fires while
|
// The old useLayoutEffect that wrote `scrollTop = scrollHeight` is no
|
||||||
// the message list is in the DOM but before paint, so the first frame
|
// longer needed: Virtuoso owns scroll positioning now.
|
||||||
// already shows the correct scroll position.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el || !stickToBottom) return;
|
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
}, [messages.length, stickToBottom]);
|
|
||||||
|
|
||||||
// Restore saved scroll position once the conversation's messages have
|
// Snapshot of the saved position for this conversation, captured once on
|
||||||
// actually rendered. The earlier version fired on `[id]` alone and ran
|
// mount. Used to derive the `initialTopMostItemIndex` we hand to the
|
||||||
// before the message list populated — scrollHeight was still tiny, so
|
// Virtuoso instance below — Virtuoso applies that index synchronously
|
||||||
// `el.scrollTop = saved.scrollTop` got clamped to 0 by the browser
|
// before its first paint, so re-entering a chat shows the saved row in
|
||||||
// and the user landed at the top instead of the saved position. By
|
// one frame rather than a "starts at top, jumps" flicker.
|
||||||
// waiting for `messages.length > 0` we know the rendered scrollHeight
|
const savedPositionRef = useRef<{ topmostIndex: number; stickToBottom: boolean } | null>(
|
||||||
// is meaningful. `restoredForRef` ensures the restore runs at most
|
null,
|
||||||
// once per chat switch (subsequent message arrivals don't re-trigger).
|
);
|
||||||
const restoredForRef = useRef<string | null>(null);
|
if (savedPositionRef.current === null && id) {
|
||||||
const isRestoringRef = useRef(false);
|
savedPositionRef.current = scrollPositions.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
// useLayoutEffect, same reason as above: writing scrollTop here happens
|
// Track whether the user is currently scrolled to the bottom. Virtuoso
|
||||||
// before the first paint of the freshly-mounted chat, so the user
|
// calls this whenever the bottom-state changes; we feed it into
|
||||||
// doesn't see a frame at scrollTop=0 before the jump to the saved
|
// `stickToBottom` (used by the "jump to newest" pill and by the
|
||||||
// position. Combined with the messages.length gate this means the
|
// "new messages while away" counter logic). Also clears the unread-
|
||||||
// re-entry shows the message list AT the saved scroll location in one
|
// away counter when the user actually reaches the bottom.
|
||||||
// single paint — no "loaded then jumped" effect.
|
const handleAtBottomStateChange = useCallback(
|
||||||
useLayoutEffect(() => {
|
(atBottom: boolean) => {
|
||||||
const el = scrollRef.current;
|
setStickToBottom(atBottom);
|
||||||
if (!el || !id) return;
|
if (atBottom) setNewMessagesWhileAway(0);
|
||||||
if (restoredForRef.current === id) return;
|
if (id) {
|
||||||
// Wait for the conversation's messages to populate; for a chat that
|
scrollPositions.set(id, {
|
||||||
// truly has zero messages the bottom and the top are the same anyway.
|
topmostIndex: topmostIndexRef.current,
|
||||||
if (messages.length === 0) return;
|
stickToBottom: atBottom,
|
||||||
restoredForRef.current = id;
|
});
|
||||||
const saved = scrollPositions.get(id);
|
}
|
||||||
// Suppress handleScroll's persistence during the programmatic scroll
|
},
|
||||||
// below — otherwise the browser's clamp/normalisation could write a
|
[id],
|
||||||
// different scrollTop back into the Map and lose the saved position.
|
);
|
||||||
isRestoringRef.current = true;
|
|
||||||
if (saved && !saved.stickToBottom) {
|
|
||||||
el.scrollTop = saved.scrollTop;
|
|
||||||
setStickToBottom(false);
|
|
||||||
} else {
|
|
||||||
setStickToBottom(true);
|
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
}
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
isRestoringRef.current = false;
|
|
||||||
});
|
|
||||||
}, [id, messages.length]);
|
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
// Persists the topmost-visible row index per conversation so re-entering
|
||||||
const el = scrollRef.current;
|
// the chat lands roughly where the user left off (see scrollPositions
|
||||||
if (!el) return;
|
// Map). Virtuoso fires `rangeChanged` whenever the visible range shifts;
|
||||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
// we only care about the start of the range here.
|
||||||
const nextStick = distanceFromBottom < STICK_THRESHOLD;
|
const handleRangeChanged = useCallback(
|
||||||
setStickToBottom(nextStick);
|
(range: { startIndex: number; endIndex: number }) => {
|
||||||
if (nextStick) setNewMessagesWhileAway(0);
|
topmostIndexRef.current = range.startIndex;
|
||||||
// Persist position per chat so re-entering this conversation lands
|
if (id) {
|
||||||
// where the user left off (see scrollPositions module-level Map).
|
const prev = scrollPositions.get(id);
|
||||||
// Skipped during the in-flight restore so we don't immediately
|
scrollPositions.set(id, {
|
||||||
// overwrite the saved position with a clamped value.
|
topmostIndex: range.startIndex,
|
||||||
if (id && !isRestoringRef.current) {
|
stickToBottom: prev?.stickToBottom ?? true,
|
||||||
scrollPositions.set(id, { scrollTop: el.scrollTop, stickToBottom: nextStick });
|
});
|
||||||
}
|
}
|
||||||
}, [id]);
|
},
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
const jumpToBottom = useCallback(() => {
|
const jumpToBottom = useCallback(() => {
|
||||||
const el = scrollRef.current;
|
virtuosoRef.current?.scrollToIndex({
|
||||||
if (!el) return;
|
index: 'LAST',
|
||||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
align: 'end',
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
setNewMessagesWhileAway(0);
|
setNewMessagesWhileAway(0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Whenever an outgoing pending row appears, snap the viewport to the
|
||||||
|
// bottom so the user sees their freshly-sent message land. This replaces
|
||||||
|
// the old `setStickToBottom(true)` pattern that piggy-backed on a
|
||||||
|
// `useLayoutEffect` writing scrollTop — Virtuoso owns scroll positioning
|
||||||
|
// now, so we have to call it explicitly. Tracked via a ref so we only
|
||||||
|
// scroll when the count actually grew (not on every render where it
|
||||||
|
// happens to be > 0).
|
||||||
|
const lastPendingCountRef = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (pending.length > lastPendingCountRef.current) {
|
||||||
|
virtuosoRef.current?.scrollToIndex({
|
||||||
|
index: 'LAST',
|
||||||
|
align: 'end',
|
||||||
|
behavior: 'auto',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lastPendingCountRef.current = pending.length;
|
||||||
|
}, [pending.length]);
|
||||||
|
|
||||||
async function handleSend(e?: React.FormEvent) {
|
async function handleSend(e?: React.FormEvent) {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
if ((!text.trim() && attachments.length === 0) || sending) return;
|
if ((!text.trim() && attachments.length === 0) || sending) return;
|
||||||
@@ -622,6 +793,63 @@ export function ConversationPage() {
|
|||||||
}
|
}
|
||||||
}, [id, creatingWhiteboard, send, replyTo?.id]);
|
}, [id, creatingWhiteboard, send, replyTo?.id]);
|
||||||
|
|
||||||
|
const handleStartWatchTogether = useCallback(async () => {
|
||||||
|
if (!id) return;
|
||||||
|
const videoId = parseYouTubeUrl(watchUrl);
|
||||||
|
if (!videoId) {
|
||||||
|
setWatchError('Ungültige YouTube-URL.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWatchCreating(true);
|
||||||
|
setWatchError(null);
|
||||||
|
try {
|
||||||
|
const ws = await createWatchSession(supabase, { conversationId: id, videoId });
|
||||||
|
const payload = createWatchTogetherPayload(ws.id);
|
||||||
|
await send(payload, [], replyTo?.id ?? null);
|
||||||
|
setReplyTo(null);
|
||||||
|
setStickToBottom(true);
|
||||||
|
setWatchDialogOpen(false);
|
||||||
|
setWatchUrl('');
|
||||||
|
setOpenWatchSessionId(ws.id);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten');
|
||||||
|
} finally {
|
||||||
|
setWatchCreating(false);
|
||||||
|
}
|
||||||
|
}, [id, watchUrl, send, replyTo?.id]);
|
||||||
|
|
||||||
|
const handleStartGame = useCallback(async (gameType: GameType) => {
|
||||||
|
if (!id) return;
|
||||||
|
if (!conversation || conversation.members.length !== 2) {
|
||||||
|
setGameError('Spiele aktuell nur in 1:1-Chats.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const opponent = conversation.members.find((m) => m.userId !== myId);
|
||||||
|
if (!opponent) {
|
||||||
|
setGameError('Kein Gegner gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGameCreating(true);
|
||||||
|
setGameError(null);
|
||||||
|
try {
|
||||||
|
const game = await createGame(supabase, {
|
||||||
|
conversationId: id,
|
||||||
|
gameType,
|
||||||
|
opponentUserId: opponent.userId,
|
||||||
|
});
|
||||||
|
const payload = createGamePayload(game.id, gameType);
|
||||||
|
await send(payload, [], replyTo?.id ?? null);
|
||||||
|
setReplyTo(null);
|
||||||
|
setStickToBottom(true);
|
||||||
|
setGameDialogOpen(false);
|
||||||
|
setOpenGameId(game.id);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten');
|
||||||
|
} finally {
|
||||||
|
setGameCreating(false);
|
||||||
|
}
|
||||||
|
}, [id, conversation, myId, send, replyTo?.id]);
|
||||||
|
|
||||||
async function ingestFiles(files: File[]) {
|
async function ingestFiles(files: File[]) {
|
||||||
const compressed = await compressImages(files);
|
const compressed = await compressImages(files);
|
||||||
const next: File[] = [];
|
const next: File[] = [];
|
||||||
@@ -749,39 +977,69 @@ export function ConversationPage() {
|
|||||||
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
|
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
|
||||||
{conversation && <InCallPanel conversation={conversation} />}
|
{conversation && <InCallPanel conversation={conversation} />}
|
||||||
|
|
||||||
<div
|
<div className="discord-chat-surface flex min-h-0 flex-1 flex-col bg-surface-3">
|
||||||
ref={scrollRef}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
className="discord-chat-surface flex-1 overflow-y-auto bg-surface-3 px-5 py-4"
|
|
||||||
>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
<div className="flex items-center gap-2 px-5 py-4 text-xs text-fg-muted">
|
||||||
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<Banner>{error}</Banner>
|
<div className="px-5 py-4">
|
||||||
|
<Banner>{error}</Banner>
|
||||||
|
</div>
|
||||||
) : messages.length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
<EmptyState
|
<div className="px-5 py-4">
|
||||||
icon={<SendIcon className="h-8 w-8" />}
|
<EmptyState
|
||||||
title={t('app:chats.conv_empty_title', { defaultValue: 'Sag Hallo 👋' })}
|
icon={<SendIcon className="h-8 w-8" />}
|
||||||
description={t('app:chats.conv_empty_desc', {
|
title={t('app:chats.conv_empty_title', { defaultValue: 'Sag Hallo 👋' })}
|
||||||
defaultValue: 'Hier ist noch nichts. Schreibe die erste Nachricht.',
|
description={t('app:chats.conv_empty_desc', {
|
||||||
})}
|
defaultValue: 'Hier ist noch nichts. Schreibe die erste Nachricht.',
|
||||||
/>
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-0.5">
|
<Virtuoso
|
||||||
{displayCount < messages.length && (
|
ref={virtuosoRef}
|
||||||
<li>
|
className="flex-1"
|
||||||
<div
|
style={{ height: '100%' }}
|
||||||
ref={loadMoreSentinelRef}
|
data={virtuosoRows}
|
||||||
className="flex items-center justify-center py-2 text-xs text-fg-muted"
|
computeItemKey={(_idx, row) => row.key}
|
||||||
>
|
// Initial position: either restored from per-conv memory, or
|
||||||
Lade ältere Nachrichten…
|
// pinned to the bottom for fresh entry. Virtuoso applies this
|
||||||
</div>
|
// synchronously before its first paint so the user doesn't see
|
||||||
</li>
|
// a "loaded at top, then jumped" flicker (matches the layout-
|
||||||
)}
|
// effect behavior we used in the non-virtualized version).
|
||||||
{messages.slice(Math.max(0, messages.length - displayCount)).map((m, sliceIdx) => {
|
initialTopMostItemIndex={initialTopMostIndex}
|
||||||
const idx = Math.max(0, messages.length - displayCount) + sliceIdx;
|
// followOutput auto-scrolls only when the user was already at
|
||||||
|
// the bottom; returning `false` from the callback when they're
|
||||||
|
// scrolled up preserves their reading position when realtime
|
||||||
|
// messages arrive (critical UX: do NOT jerk the user).
|
||||||
|
followOutput={(isAtBottom) => (isAtBottom ? 'smooth' : false)}
|
||||||
|
atBottomStateChange={handleAtBottomStateChange}
|
||||||
|
atBottomThreshold={80}
|
||||||
|
rangeChanged={handleRangeChanged}
|
||||||
|
startReached={handleStartReached}
|
||||||
|
// Render rows just outside the viewport so fast scrolling
|
||||||
|
// doesn't briefly flash empty space.
|
||||||
|
increaseViewportBy={400}
|
||||||
|
itemContent={(_index, row) => {
|
||||||
|
if (row.kind === 'loader') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-2 text-xs text-fg-muted">
|
||||||
|
Lade ältere Nachrichten…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (row.kind === 'pending') {
|
||||||
|
return (
|
||||||
|
<PendingBubble
|
||||||
|
item={row.item}
|
||||||
|
onRetry={() => retryPending(row.item.id)}
|
||||||
|
onCancel={() => cancelPending(row.item.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const m = row.message;
|
||||||
|
const idx = row.idx;
|
||||||
const prevRaw = messages[idx - 1];
|
const prevRaw = messages[idx - 1];
|
||||||
const nextRaw = messages[idx + 1];
|
const nextRaw = messages[idx + 1];
|
||||||
const prevIsCallEvent =
|
const prevIsCallEvent =
|
||||||
@@ -795,7 +1053,7 @@ export function ConversationPage() {
|
|||||||
const senderProfile =
|
const senderProfile =
|
||||||
memberProfile ?? (m.senderId !== myId ? (conversation?.peer ?? null) : null);
|
memberProfile ?? (m.senderId !== myId ? (conversation?.peer ?? null) : null);
|
||||||
return (
|
return (
|
||||||
<li key={m.id}>
|
<div className="px-5">
|
||||||
{firstUnreadId === m.id && (
|
{firstUnreadId === m.id && (
|
||||||
<div
|
<div
|
||||||
aria-label="Neue Nachrichten"
|
aria-label="Neue Nachrichten"
|
||||||
@@ -816,9 +1074,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
|
||||||
? {
|
? {
|
||||||
@@ -833,35 +1091,19 @@ 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}
|
||||||
/>
|
/>
|
||||||
</li>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
}}
|
||||||
{pending.map((p) => (
|
/>
|
||||||
<li key={p.id}>
|
|
||||||
<PendingBubble
|
|
||||||
item={p}
|
|
||||||
onRetry={() => retryPending(p.id)}
|
|
||||||
onCancel={() => cancelPending(p.id)}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1025,6 +1267,24 @@ export function ConversationPage() {
|
|||||||
>
|
>
|
||||||
<WhiteboardIcon className="h-4 w-4" />
|
<WhiteboardIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWatchDialogOpen(true)}
|
||||||
|
title={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
|
||||||
|
aria-label={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
|
||||||
|
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
|
||||||
|
>
|
||||||
|
<PlayBoxIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGameDialogOpen(true)}
|
||||||
|
title={t('app:composer.game', { defaultValue: 'Spielen' })}
|
||||||
|
aria-label={t('app:composer.game', { defaultValue: 'Spielen' })}
|
||||||
|
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-3 hover:text-fg"
|
||||||
|
>
|
||||||
|
<GameIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1207,29 +1467,149 @@ export function ConversationPage() {
|
|||||||
open={pinnedPanelOpen}
|
open={pinnedPanelOpen}
|
||||||
pins={pins}
|
pins={pins}
|
||||||
onClose={() => setPinnedPanelOpen(false)}
|
onClose={() => setPinnedPanelOpen(false)}
|
||||||
onJump={(_messageId) => {
|
onJump={(messageId) => {
|
||||||
// Future: scroll to message. For now just close the panel.
|
// Close the panel first so the underlying message-list viewport
|
||||||
|
// is fully visible before the smooth-scroll runs (otherwise the
|
||||||
|
// panel would briefly cover the highlighted target row).
|
||||||
setPinnedPanelOpen(false);
|
setPinnedPanelOpen(false);
|
||||||
|
jumpToMessage(messageId);
|
||||||
}}
|
}}
|
||||||
onUnpin={(messageId) => void handleTogglePin(messageId)}
|
onUnpin={(messageId) => void handleTogglePin(messageId)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{annotatingIndex !== null && attachments[annotatingIndex] && (
|
{annotatingIndex !== null && attachments[annotatingIndex] && (
|
||||||
<ImageAnnotator
|
<Suspense fallback={null}>
|
||||||
file={attachments[annotatingIndex]!}
|
<ImageAnnotator
|
||||||
onCancel={() => setAnnotatingIndex(null)}
|
file={attachments[annotatingIndex]!}
|
||||||
onSave={(next) => {
|
onCancel={() => setAnnotatingIndex(null)}
|
||||||
setAttachments((prev) => prev.map((f, i) => (i === annotatingIndex ? next : f)));
|
onSave={(next) => {
|
||||||
setAnnotatingIndex(null);
|
setAttachments((prev) => prev.map((f, i) => (i === annotatingIndex ? next : f)));
|
||||||
}}
|
setAnnotatingIndex(null);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{openWhiteboardId && (
|
{openWhiteboardId && (
|
||||||
<WhiteboardModal
|
<Suspense fallback={null}>
|
||||||
whiteboardId={openWhiteboardId}
|
<WhiteboardModal
|
||||||
onClose={() => setOpenWhiteboardId(null)}
|
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>
|
||||||
);
|
);
|
||||||
@@ -1520,3 +1900,23 @@ function WhiteboardIcon(props: React.SVGProps<SVGSVGElement>) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlayBoxIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||||
|
<rect x="3" y="4" width="18" height="14" rx="2" />
|
||||||
|
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GameIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||||
|
<rect x="3" y="6" width="18" height="12" rx="3" />
|
||||||
|
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,78 @@
|
|||||||
|
// Web Worker — runs Argon2id pwhash + sealed user-key open off the main
|
||||||
|
// thread. PIN-unlock used to freeze the UI for ~1-2 s on mid-hardware while
|
||||||
|
// the moderate-preset KDF ran; pushing it here keeps the unlock screen
|
||||||
|
// responsive.
|
||||||
|
//
|
||||||
|
// The worker bundles its own libsodium-wrappers-sumo instance and registers
|
||||||
|
// it as the shared CryptoBackend inside this worker realm — there is no
|
||||||
|
// shared state with the main thread, so we initialise once per worker and
|
||||||
|
// re-use it across messages (the client wrapper currently spawns one-shot,
|
||||||
|
// but the worker is safe to keep alive too).
|
||||||
|
//
|
||||||
|
// Message protocol (one-shot RPC):
|
||||||
|
// request: { op: 'openUserKey', input: OpenUserKeyInput }
|
||||||
|
// response: { ok: true, result: { privateKey: Uint8Array } }
|
||||||
|
// | { ok: false, error: string }
|
||||||
|
//
|
||||||
|
// The private key bytes are transferred (zero-copy) back to the caller via
|
||||||
|
// the structured-clone Transferable list; the worker's view of the buffer is
|
||||||
|
// detached on transfer which also clears the only worker-side reference.
|
||||||
|
|
||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
import { setCryptoBackend, openUserKey } from '@chat-app/shared/crypto';
|
||||||
|
import type { KdfParams } from '@chat-app/shared/crypto';
|
||||||
|
import sodium from 'libsodium-wrappers-sumo';
|
||||||
|
|
||||||
|
import { createLibsodiumBackend } from '../lib/cryptoBackend';
|
||||||
|
|
||||||
|
export interface OpenUserKeyInput {
|
||||||
|
sealed: Uint8Array;
|
||||||
|
pin: string;
|
||||||
|
salt: Uint8Array;
|
||||||
|
kdfParams: KdfParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenUserKeyResult {
|
||||||
|
privateKey: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkerRequest = { op: 'openUserKey'; input: OpenUserKeyInput };
|
||||||
|
type WorkerResponse =
|
||||||
|
| { ok: true; result: OpenUserKeyResult }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
let backendReady: Promise<void> | null = null;
|
||||||
|
|
||||||
|
async function ensureBackend(): Promise<void> {
|
||||||
|
if (!backendReady) {
|
||||||
|
backendReady = (async () => {
|
||||||
|
await sodium.ready;
|
||||||
|
setCryptoBackend(await createLibsodiumBackend());
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return backendReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('message', (ev: MessageEvent<WorkerRequest>) => {
|
||||||
|
const msg = ev.data;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
if (!msg || msg.op !== 'openUserKey') {
|
||||||
|
throw new Error('unknown op: ' + String((msg as { op?: unknown })?.op));
|
||||||
|
}
|
||||||
|
await ensureBackend();
|
||||||
|
const privateKey = await openUserKey(msg.input);
|
||||||
|
const response: WorkerResponse = { ok: true, result: { privateKey } };
|
||||||
|
const transfers: Transferable[] = [];
|
||||||
|
if (privateKey?.buffer instanceof ArrayBuffer) {
|
||||||
|
transfers.push(privateKey.buffer);
|
||||||
|
}
|
||||||
|
(self as unknown as Worker).postMessage(response, transfers);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
const response: WorkerResponse = { ok: false, error: message };
|
||||||
|
(self as unknown as Worker).postMessage(response);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
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).
|
||||||
@@ -57,6 +57,7 @@ export type Database = {
|
|||||||
accepted: boolean
|
accepted: boolean
|
||||||
conversation_id: string
|
conversation_id: string
|
||||||
joined_at: string
|
joined_at: string
|
||||||
|
mentions_only: boolean
|
||||||
role: Database["public"]["Enums"]["member_role"]
|
role: Database["public"]["Enums"]["member_role"]
|
||||||
user_id: string
|
user_id: string
|
||||||
}
|
}
|
||||||
@@ -64,6 +65,7 @@ export type Database = {
|
|||||||
accepted?: boolean
|
accepted?: boolean
|
||||||
conversation_id: string
|
conversation_id: string
|
||||||
joined_at?: string
|
joined_at?: string
|
||||||
|
mentions_only?: boolean
|
||||||
role?: Database["public"]["Enums"]["member_role"]
|
role?: Database["public"]["Enums"]["member_role"]
|
||||||
user_id: string
|
user_id: string
|
||||||
}
|
}
|
||||||
@@ -71,6 +73,7 @@ export type Database = {
|
|||||||
accepted?: boolean
|
accepted?: boolean
|
||||||
conversation_id?: string
|
conversation_id?: string
|
||||||
joined_at?: string
|
joined_at?: string
|
||||||
|
mentions_only?: boolean
|
||||||
role?: Database["public"]["Enums"]["member_role"]
|
role?: Database["public"]["Enums"]["member_role"]
|
||||||
user_id?: string
|
user_id?: string
|
||||||
}
|
}
|
||||||
@@ -582,6 +585,71 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Relationships: []
|
Relationships: []
|
||||||
}
|
}
|
||||||
|
conversation_watch_sessions: {
|
||||||
|
Row: {
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
owner_user_id: string;
|
||||||
|
video_id: string;
|
||||||
|
started_at: string;
|
||||||
|
ended_at: string | null;
|
||||||
|
current_state: { playing: boolean; position_seconds: number; updated_at_ms: number };
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
id?: string;
|
||||||
|
conversation_id: string;
|
||||||
|
owner_user_id: string;
|
||||||
|
video_id: string;
|
||||||
|
started_at?: string;
|
||||||
|
ended_at?: string | null;
|
||||||
|
current_state?: { playing: boolean; position_seconds: number; updated_at_ms: number };
|
||||||
|
};
|
||||||
|
Update: {
|
||||||
|
id?: string;
|
||||||
|
conversation_id?: string;
|
||||||
|
owner_user_id?: string;
|
||||||
|
video_id?: string;
|
||||||
|
started_at?: string;
|
||||||
|
ended_at?: string | null;
|
||||||
|
current_state?: { playing: boolean; position_seconds: number; updated_at_ms: number };
|
||||||
|
};
|
||||||
|
Relationships: [];
|
||||||
|
};
|
||||||
|
conversation_games: {
|
||||||
|
Row: {
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
game_type: 'ttt' | 'c4';
|
||||||
|
state: { kind: 'ttt' | 'c4'; board: Array<number | null> };
|
||||||
|
players: [string, string];
|
||||||
|
current_turn_user_id: string | null;
|
||||||
|
winner_user_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
finished_at: string | null;
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
id?: string;
|
||||||
|
conversation_id: string;
|
||||||
|
game_type: 'ttt' | 'c4';
|
||||||
|
state: { kind: 'ttt' | 'c4'; board: Array<number | null> };
|
||||||
|
players: [string, string];
|
||||||
|
current_turn_user_id: string | null;
|
||||||
|
winner_user_id?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
finished_at?: string | null;
|
||||||
|
};
|
||||||
|
Update: {
|
||||||
|
id?: string;
|
||||||
|
conversation_id?: string;
|
||||||
|
game_type?: 'ttt' | 'c4';
|
||||||
|
state?: { kind: 'ttt' | 'c4'; board: Array<number | null> };
|
||||||
|
players?: [string, string];
|
||||||
|
current_turn_user_id?: string | null;
|
||||||
|
winner_user_id?: string | null;
|
||||||
|
finished_at?: string | null;
|
||||||
|
};
|
||||||
|
Relationships: [];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
Views: {
|
Views: {
|
||||||
[_ in never]: never
|
[_ in never]: never
|
||||||
@@ -590,6 +658,10 @@ export type Database = {
|
|||||||
accept_dm: { Args: { conversation_id: string }; Returns: undefined }
|
accept_dm: { Args: { conversation_id: string }; Returns: undefined }
|
||||||
are_friends: { Args: { a: string; b: string }; Returns: boolean }
|
are_friends: { Args: { a: string; b: string }; Returns: boolean }
|
||||||
revoke_device: { Args: { p_device_id: string }; Returns: undefined }
|
revoke_device: { Args: { p_device_id: string }; Returns: undefined }
|
||||||
|
game_make_move: {
|
||||||
|
Args: { p_game_id: string; p_move: object };
|
||||||
|
Returns: { kind: 'ttt' | 'c4'; board: Array<number | null> };
|
||||||
|
}
|
||||||
attachment_object_conv_id: {
|
attachment_object_conv_id: {
|
||||||
Args: { object_name: string }
|
Args: { object_name: string }
|
||||||
Returns: string
|
Returns: string
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ export interface AttachmentHandle {
|
|||||||
/** UUID of the first non-sender who viewed. Populated alongside `viewedAt`
|
/** UUID of the first non-sender who viewed. Populated alongside `viewedAt`
|
||||||
* by the mark-viewed RPC so the renderer can render attribution. */
|
* by the mark-viewed RPC so the renderer can render attribution. */
|
||||||
viewedBy?: string | null;
|
viewedBy?: string | null;
|
||||||
|
/** Storage path of the encrypted WebP preview thumb (max 320×320). The
|
||||||
|
* thumb shares the per-attachment symmetric key with the full blob but
|
||||||
|
* uses its own nonce. Absent on pre-Phase-6B messages — the receiver
|
||||||
|
* falls through to downloading the full blob in that case. */
|
||||||
|
thumbStoragePath?: string;
|
||||||
|
/** Base-64 nonce that decrypts `<id>-thumb.bin`. Always present iff
|
||||||
|
* `thumbStoragePath` is set. */
|
||||||
|
thumbNonceB64?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CallEventStatus = 'ended' | 'missed' | 'declined';
|
export type CallEventStatus = 'ended' | 'missed' | 'declined';
|
||||||
@@ -82,11 +90,26 @@ export interface WhiteboardPayload {
|
|||||||
whiteboard_id: string;
|
whiteboard_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WatchTogetherPayload {
|
||||||
|
v: 1;
|
||||||
|
type: 'watch_together';
|
||||||
|
session_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamePayload {
|
||||||
|
v: 1;
|
||||||
|
type: 'game';
|
||||||
|
game_id: string;
|
||||||
|
game_type: 'ttt' | 'c4';
|
||||||
|
}
|
||||||
|
|
||||||
export type MessagePayload =
|
export type MessagePayload =
|
||||||
| TextMessagePayload
|
| TextMessagePayload
|
||||||
| CallEventPayload
|
| CallEventPayload
|
||||||
| PollPayload
|
| PollPayload
|
||||||
| WhiteboardPayload;
|
| WhiteboardPayload
|
||||||
|
| WatchTogetherPayload
|
||||||
|
| GamePayload;
|
||||||
|
|
||||||
export type ParsedMessagePayload =
|
export type ParsedMessagePayload =
|
||||||
| {
|
| {
|
||||||
@@ -109,6 +132,15 @@ export type ParsedMessagePayload =
|
|||||||
| {
|
| {
|
||||||
kind: 'whiteboard';
|
kind: 'whiteboard';
|
||||||
whiteboardId: string;
|
whiteboardId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'watch_together';
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'game';
|
||||||
|
gameId: string;
|
||||||
|
gameType: 'ttt' | 'c4';
|
||||||
};
|
};
|
||||||
|
|
||||||
export function serializeMessagePayload(payload: MessagePayload): string {
|
export function serializeMessagePayload(payload: MessagePayload): string {
|
||||||
@@ -172,6 +204,19 @@ export function parseMessagePayload(raw: string | null): ParsedMessagePayload {
|
|||||||
: '';
|
: '';
|
||||||
return { kind: 'whiteboard', whiteboardId: id };
|
return { kind: 'whiteboard', whiteboardId: id };
|
||||||
}
|
}
|
||||||
|
if (obj.type === 'watch_together') {
|
||||||
|
const p = obj as Partial<WatchTogetherPayload>;
|
||||||
|
const id = typeof p.session_id === 'string' && p.session_id.length > 0
|
||||||
|
? p.session_id
|
||||||
|
: '';
|
||||||
|
return { kind: 'watch_together', sessionId: id };
|
||||||
|
}
|
||||||
|
if (obj.type === 'game') {
|
||||||
|
const p = obj as Partial<GamePayload>;
|
||||||
|
const id = typeof p.game_id === 'string' && p.game_id.length > 0 ? p.game_id : '';
|
||||||
|
const t = p.game_type === 'ttt' || p.game_type === 'c4' ? p.game_type : 'ttt';
|
||||||
|
return { kind: 'game', gameId: id, gameType: t };
|
||||||
|
}
|
||||||
const t = obj as TextMessagePayload;
|
const t = obj as TextMessagePayload;
|
||||||
return {
|
return {
|
||||||
kind: 'text',
|
kind: 'text',
|
||||||
@@ -204,6 +249,13 @@ export interface EncryptedAttachmentResult {
|
|||||||
// Encrypt + upload a blob. Does NOT insert the message_attachments row —
|
// Encrypt + upload a blob. Does NOT insert the message_attachments row —
|
||||||
// the caller combines this with a message insert so everything commits
|
// the caller combines this with a message insert so everything commits
|
||||||
// atomically at the application layer.
|
// atomically at the application layer.
|
||||||
|
//
|
||||||
|
// When `thumbBlob` is supplied (Phase 6B image-thumbnail path) a second
|
||||||
|
// ciphertext is uploaded to `<conversationId>/<id>-thumb.bin` encrypted
|
||||||
|
// with the SAME per-attachment key + a fresh nonce. The handle's
|
||||||
|
// `thumbStoragePath` / `thumbNonceB64` get populated so the receiver
|
||||||
|
// can prefer the thumb for inline preview. Thumb-upload failure is
|
||||||
|
// non-fatal — we log and fall through so the full image still posts.
|
||||||
export async function encryptAndUploadAttachment(params: {
|
export async function encryptAndUploadAttachment(params: {
|
||||||
client: AppSupabaseClient;
|
client: AppSupabaseClient;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
@@ -212,6 +264,7 @@ export async function encryptAndUploadAttachment(params: {
|
|||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
thumbBlob?: Blob | null;
|
||||||
}): Promise<EncryptedAttachmentResult> {
|
}): Promise<EncryptedAttachmentResult> {
|
||||||
const backend = getCryptoBackend();
|
const backend = getCryptoBackend();
|
||||||
|
|
||||||
@@ -231,6 +284,35 @@ export async function encryptAndUploadAttachment(params: {
|
|||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
|
let thumbStoragePath: string | undefined;
|
||||||
|
let thumbNonceB64: string | undefined;
|
||||||
|
if (params.thumbBlob) {
|
||||||
|
try {
|
||||||
|
const thumbBytes = new Uint8Array(await params.thumbBlob.arrayBuffer());
|
||||||
|
const thumbNonce = backend.randomBytes(backend.secretboxNonceLength);
|
||||||
|
const thumbCipher = backend.secretbox(thumbBytes, thumbNonce, key);
|
||||||
|
const thumbPath = params.conversationId + '/' + id + '-thumb.bin';
|
||||||
|
const { error: thumbErr } = await params.client.storage
|
||||||
|
.from(ATTACHMENT_BUCKET)
|
||||||
|
.upload(thumbPath, thumbCipher, {
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
upsert: false,
|
||||||
|
});
|
||||||
|
if (thumbErr) {
|
||||||
|
// Non-fatal: log and continue with full-only handle. Receiver will
|
||||||
|
// fall back to fetching the full blob.
|
||||||
|
console.warn('thumb upload failed', thumbErr);
|
||||||
|
} else {
|
||||||
|
thumbStoragePath = thumbPath;
|
||||||
|
thumbNonceB64 = await toBase64(thumbNonce);
|
||||||
|
}
|
||||||
|
// Wipe nonce buffer.
|
||||||
|
for (let i = 0; i < thumbNonce.length; i++) thumbNonce[i] = 0;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('thumb encrypt/upload failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handle: AttachmentHandle = {
|
const handle: AttachmentHandle = {
|
||||||
id,
|
id,
|
||||||
storagePath,
|
storagePath,
|
||||||
@@ -240,6 +322,8 @@ export async function encryptAndUploadAttachment(params: {
|
|||||||
...(params.height !== undefined ? { height: params.height } : {}),
|
...(params.height !== undefined ? { height: params.height } : {}),
|
||||||
keyB64: await toBase64(key),
|
keyB64: await toBase64(key),
|
||||||
nonceB64: await toBase64(nonce),
|
nonceB64: await toBase64(nonce),
|
||||||
|
...(thumbStoragePath !== undefined ? { thumbStoragePath } : {}),
|
||||||
|
...(thumbNonceB64 !== undefined ? { thumbNonceB64 } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return { handle, key, nonce };
|
return { handle, key, nonce };
|
||||||
@@ -272,6 +356,51 @@ export async function downloadAndDecryptAttachment(params: {
|
|||||||
return new Blob([copy.buffer], { type: params.handle.mimeType });
|
return new Blob([copy.buffer], { type: params.handle.mimeType });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Download + decrypt the small WebP preview thumb that the sender uploaded
|
||||||
|
// alongside an image attachment (Phase 6B optimisation). Returns `null` if
|
||||||
|
// the handle has no thumb metadata (pre-Phase-6B message) or if the thumb
|
||||||
|
// blob is missing from storage — caller falls back to the full image.
|
||||||
|
//
|
||||||
|
// We deliberately swallow ANY download error (missing object, transient
|
||||||
|
// 5xx) so the receiver gracefully degrades to the full-blob path; only a
|
||||||
|
// successful decrypt-failure throws, since that signals a real corruption.
|
||||||
|
export async function downloadAndDecryptAttachmentThumb(params: {
|
||||||
|
client: AppSupabaseClient;
|
||||||
|
handle: AttachmentHandle;
|
||||||
|
}): Promise<Blob | null> {
|
||||||
|
if (!params.handle.thumbStoragePath || !params.handle.thumbNonceB64) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const backend = getCryptoBackend();
|
||||||
|
|
||||||
|
let data: Blob | null = null;
|
||||||
|
try {
|
||||||
|
const res = await params.client.storage
|
||||||
|
.from(ATTACHMENT_BUCKET)
|
||||||
|
.download(params.handle.thumbStoragePath);
|
||||||
|
if (res.error) {
|
||||||
|
// Likely 404 — sender failed to upload thumb, or it's been GC'd.
|
||||||
|
// Receiver falls back to full image.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
data = res.data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const ciphertext = new Uint8Array(await data.arrayBuffer());
|
||||||
|
const key = await fromBase64(params.handle.keyB64);
|
||||||
|
const nonce = await fromBase64(params.handle.thumbNonceB64);
|
||||||
|
const plainBytes = backend.secretboxOpen(ciphertext, nonce, key);
|
||||||
|
for (let i = 0; i < key.length; i++) key[i] = 0;
|
||||||
|
for (let i = 0; i < nonce.length; i++) nonce[i] = 0;
|
||||||
|
const copy = new Uint8Array(plainBytes.byteLength);
|
||||||
|
copy.set(plainBytes);
|
||||||
|
// Thumbs are always image/webp regardless of original mime.
|
||||||
|
return new Blob([copy.buffer], { type: 'image/webp' });
|
||||||
|
}
|
||||||
|
|
||||||
// Insert the public metadata row for an attachment. The ciphertext itself has
|
// Insert the public metadata row for an attachment. The ciphertext itself has
|
||||||
// already been uploaded to storage under `handle.storagePath`.
|
// already been uploaded to storage under `handle.storagePath`.
|
||||||
export async function insertAttachmentRow(
|
export async function insertAttachmentRow(
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
|
|||||||
// cast the select to bypass typing.
|
// cast the select to bypass typing.
|
||||||
const { data: myMembers, error: mErr } = await client
|
const { data: myMembers, error: mErr } = await client
|
||||||
.from('conversation_members')
|
.from('conversation_members')
|
||||||
.select('conversation_id, role, accepted, archived, muted_until' as '*')
|
.select('conversation_id, role, accepted, archived, muted_until, mentions_only' as '*')
|
||||||
.eq('user_id', myId);
|
.eq('user_id', myId);
|
||||||
if (mErr) throw mErr;
|
if (mErr) throw mErr;
|
||||||
const myMembersList = (myMembers ?? []) as unknown as Array<{
|
const myMembersList = (myMembers ?? []) as unknown as Array<{
|
||||||
@@ -44,6 +44,7 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
|
|||||||
accepted: boolean;
|
accepted: boolean;
|
||||||
archived: boolean | null;
|
archived: boolean | null;
|
||||||
muted_until: string | null;
|
muted_until: string | null;
|
||||||
|
mentions_only: boolean | null;
|
||||||
}>;
|
}>;
|
||||||
if (myMembersList.length === 0) return [];
|
if (myMembersList.length === 0) return [];
|
||||||
|
|
||||||
@@ -114,7 +115,13 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
|
|||||||
? (members.find((m) => m.userId !== myId)?.profile ?? null)
|
? (members.find((m) => m.userId !== myId)?.profile ?? null)
|
||||||
: null;
|
: null;
|
||||||
const mineRow = mine as
|
const mineRow = mine as
|
||||||
| { accepted: boolean; role: string; archived?: boolean; muted_until?: string | null }
|
| {
|
||||||
|
accepted: boolean;
|
||||||
|
role: string;
|
||||||
|
archived?: boolean;
|
||||||
|
muted_until?: string | null;
|
||||||
|
mentions_only?: boolean | null;
|
||||||
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
return {
|
return {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
@@ -129,6 +136,7 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
|
|||||||
lastMessageAt: lastSeen.get(c.id) ?? null,
|
lastMessageAt: lastSeen.get(c.id) ?? null,
|
||||||
archived: mineRow?.archived ?? false,
|
archived: mineRow?.archived ?? false,
|
||||||
mutedUntil: mineRow?.muted_until ?? null,
|
mutedUntil: mineRow?.muted_until ?? null,
|
||||||
|
mentionsOnly: mineRow?.mentions_only ?? false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -164,6 +172,24 @@ export async function setConversationMutedUntil(
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle "mentions only" — when true the renderer's notification gate
|
||||||
|
// suppresses non-mention alerts for this conversation. Mentions still fire
|
||||||
|
// via the independent useMentionNotifications subscription on
|
||||||
|
// message_mentions, so the @-alerts are never lost.
|
||||||
|
export async function setConversationMentionsOnly(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
params: { conversationId: string; mentionsOnly: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
const { data: session } = await client.auth.getUser();
|
||||||
|
if (!session.user) throw new Error('not authenticated');
|
||||||
|
const { error } = await client
|
||||||
|
.from('conversation_members')
|
||||||
|
.update({ mentions_only: params.mentionsOnly } as never)
|
||||||
|
.eq('conversation_id', params.conversationId)
|
||||||
|
.eq('user_id', session.user.id);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
// Convenience: `null` unmutes, number means minutes from now. For "forever"
|
// Convenience: `null` unmutes, number means minutes from now. For "forever"
|
||||||
// pass a very large number (e.g. 100 years worth of minutes).
|
// pass a very large number (e.g. 100 years worth of minutes).
|
||||||
export function muteDurationToIso(minutes: number | null): string | null {
|
export function muteDurationToIso(minutes: number | null): string | null {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
c4DropRow,
|
||||||
|
c4WinningCells,
|
||||||
|
emptyC4Board,
|
||||||
|
emptyTttBoard,
|
||||||
|
isBoardFull,
|
||||||
|
tttWinningLine,
|
||||||
|
type Cell,
|
||||||
|
} from './games';
|
||||||
|
|
||||||
|
describe('tttWinningLine', () => {
|
||||||
|
it('detects a row win', () => {
|
||||||
|
const b: Cell[] = [0, 0, 0, null, null, null, null, null, null];
|
||||||
|
expect(tttWinningLine(b)).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects a diagonal win', () => {
|
||||||
|
const b: Cell[] = [1, null, null, null, 1, null, null, null, 1];
|
||||||
|
expect(tttWinningLine(b)).toEqual([0, 4, 8]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no winner', () => {
|
||||||
|
expect(tttWinningLine(emptyTttBoard())).toBeNull();
|
||||||
|
const mixed: Cell[] = [0, 1, 0, 1, 0, 1, 1, 0, 1];
|
||||||
|
expect(tttWinningLine(mixed)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('c4WinningCells', () => {
|
||||||
|
it('detects a horizontal win', () => {
|
||||||
|
const b = emptyC4Board();
|
||||||
|
b[35] = 1; b[36] = 1; b[37] = 1; b[38] = 1;
|
||||||
|
expect(c4WinningCells(b)).toEqual([35, 36, 37, 38]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects a vertical win', () => {
|
||||||
|
const b = emptyC4Board();
|
||||||
|
b[14] = 0; b[21] = 0; b[28] = 0; b[35] = 0;
|
||||||
|
expect(c4WinningCells(b)).toEqual([14, 21, 28, 35]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects a diagonal ↘ win', () => {
|
||||||
|
const b = emptyC4Board();
|
||||||
|
b[14] = 1; b[22] = 1; b[30] = 1; b[38] = 1;
|
||||||
|
expect(c4WinningCells(b)).toEqual([14, 22, 30, 38]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no winner', () => {
|
||||||
|
expect(c4WinningCells(emptyC4Board())).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('c4DropRow', () => {
|
||||||
|
it('returns the bottom row on an empty column', () => {
|
||||||
|
expect(c4DropRow(emptyC4Board(), 0)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stacks on top of an existing piece', () => {
|
||||||
|
const b = emptyC4Board();
|
||||||
|
b[35] = 0;
|
||||||
|
expect(c4DropRow(b, 0)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns -1 when the column is full', () => {
|
||||||
|
const b = emptyC4Board();
|
||||||
|
for (let r = 0; r < 6; r++) b[r * 7 + 3] = 0;
|
||||||
|
expect(c4DropRow(b, 3)).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isBoardFull', () => {
|
||||||
|
it('true for a fully filled board, false otherwise', () => {
|
||||||
|
expect(isBoardFull(emptyTttBoard())).toBe(false);
|
||||||
|
const filled: Cell[] = [0, 1, 0, 1, 0, 1, 1, 0, 1];
|
||||||
|
expect(isBoardFull(filled)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import type { AppSupabaseClient } from '../supabase/client';
|
||||||
|
|
||||||
|
export type GameType = 'ttt' | 'c4';
|
||||||
|
export type Cell = 0 | 1 | null;
|
||||||
|
|
||||||
|
export const TTT_CELLS = 9;
|
||||||
|
export const C4_ROWS = 6;
|
||||||
|
export const C4_COLS = 7;
|
||||||
|
export const C4_CELLS = C4_ROWS * C4_COLS;
|
||||||
|
|
||||||
|
export const TTT_LINES: ReadonlyArray<readonly [number, number, number]> = [
|
||||||
|
[0, 1, 2], [3, 4, 5], [6, 7, 8],
|
||||||
|
[0, 3, 6], [1, 4, 7], [2, 5, 8],
|
||||||
|
[0, 4, 8], [2, 4, 6],
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface GameRecord {
|
||||||
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
|
gameType: GameType;
|
||||||
|
state: { kind: GameType; board: Cell[] };
|
||||||
|
players: [string, string];
|
||||||
|
currentTurnUserId: string | null;
|
||||||
|
winnerUserId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyTttBoard(): Cell[] {
|
||||||
|
return new Array(TTT_CELLS).fill(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyC4Board(): Cell[] {
|
||||||
|
return new Array(C4_CELLS).fill(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tttWinningLine(board: Cell[]): readonly [number, number, number] | null {
|
||||||
|
for (const line of TTT_LINES) {
|
||||||
|
const a = board[line[0]];
|
||||||
|
const b = board[line[1]];
|
||||||
|
const c = board[line[2]];
|
||||||
|
if (a !== null && a === b && b === c) return line;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function c4WinningCells(board: Cell[]): readonly number[] | null {
|
||||||
|
const directions: Array<[number, number]> = [
|
||||||
|
[0, 1], [1, 0], [1, 1], [1, -1],
|
||||||
|
];
|
||||||
|
for (let r = 0; r < C4_ROWS; r++) {
|
||||||
|
for (let c = 0; c < C4_COLS; c++) {
|
||||||
|
const base = board[r * C4_COLS + c];
|
||||||
|
if (base === null) continue;
|
||||||
|
for (const [dr, dc] of directions) {
|
||||||
|
const rEnd = r + dr * 3;
|
||||||
|
const cEnd = c + dc * 3;
|
||||||
|
if (rEnd < 0 || rEnd >= C4_ROWS || cEnd < 0 || cEnd >= C4_COLS) continue;
|
||||||
|
let ok = true;
|
||||||
|
const cells: number[] = [r * C4_COLS + c];
|
||||||
|
for (let i = 1; i < 4; i++) {
|
||||||
|
const idx = (r + dr * i) * C4_COLS + (c + dc * i);
|
||||||
|
if (board[idx] !== base) { ok = false; break; }
|
||||||
|
cells.push(idx);
|
||||||
|
}
|
||||||
|
if (ok) return cells;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBoardFull(board: Cell[]): boolean {
|
||||||
|
return board.every((c) => c !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function c4DropRow(board: Cell[], column: number): number {
|
||||||
|
for (let r = C4_ROWS - 1; r >= 0; r--) {
|
||||||
|
if (board[r * C4_COLS + column] === null) return r;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGame(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
params: { conversationId: string; gameType: GameType; opponentUserId: string },
|
||||||
|
): Promise<GameRecord> {
|
||||||
|
const { data: session } = await client.auth.getUser();
|
||||||
|
if (!session.user) throw new Error('not authenticated');
|
||||||
|
const board = params.gameType === 'ttt' ? emptyTttBoard() : emptyC4Board();
|
||||||
|
const state = { kind: params.gameType, board };
|
||||||
|
const players: [string, string] = [session.user.id, params.opponentUserId];
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('conversation_games')
|
||||||
|
.insert({
|
||||||
|
conversation_id: params.conversationId,
|
||||||
|
game_type: params.gameType,
|
||||||
|
state,
|
||||||
|
players,
|
||||||
|
current_turn_user_id: session.user.id,
|
||||||
|
})
|
||||||
|
.select(
|
||||||
|
'id, conversation_id, game_type, state, players, current_turn_user_id, winner_user_id, created_at, finished_at',
|
||||||
|
)
|
||||||
|
.single();
|
||||||
|
if (error) throw error;
|
||||||
|
return mapRow(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGame(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
gameId: string,
|
||||||
|
): Promise<GameRecord | null> {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('conversation_games')
|
||||||
|
.select(
|
||||||
|
'id, conversation_id, game_type, state, players, current_turn_user_id, winner_user_id, created_at, finished_at',
|
||||||
|
)
|
||||||
|
.eq('id', gameId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) throw error;
|
||||||
|
return data ? mapRow(data) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function makeGameMove(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
params: { gameId: string; move: object },
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await client.rpc('game_make_move', {
|
||||||
|
p_game_id: params.gameId,
|
||||||
|
p_move: params.move,
|
||||||
|
});
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRow(row: {
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
game_type: string;
|
||||||
|
state: unknown;
|
||||||
|
players: unknown;
|
||||||
|
current_turn_user_id: string | null;
|
||||||
|
winner_user_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
finished_at: string | null;
|
||||||
|
}): GameRecord {
|
||||||
|
const rawState = (row.state ?? {}) as { kind?: string; board?: unknown };
|
||||||
|
const kind: GameType = rawState.kind === 'c4' ? 'c4' : 'ttt';
|
||||||
|
const rawBoard = Array.isArray(rawState.board) ? rawState.board : [];
|
||||||
|
const board: Cell[] = rawBoard.map((c) =>
|
||||||
|
typeof c === 'number' && (c === 0 || c === 1) ? (c as Cell) : null,
|
||||||
|
);
|
||||||
|
const players = Array.isArray(row.players) ? row.players : [];
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
conversationId: row.conversation_id,
|
||||||
|
gameType: row.game_type === 'c4' ? 'c4' : 'ttt',
|
||||||
|
state: { kind, board },
|
||||||
|
players: [String(players[0] ?? ''), String(players[1] ?? '')],
|
||||||
|
currentTurnUserId: row.current_turn_user_id,
|
||||||
|
winnerUserId: row.winner_user_id,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
finishedAt: row.finished_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ export * from './mentions';
|
|||||||
export * from './viewOnceAttachments';
|
export * from './viewOnceAttachments';
|
||||||
export * from './whiteboards';
|
export * from './whiteboards';
|
||||||
export * from './soundboards';
|
export * from './soundboards';
|
||||||
|
export * from './watchTogether';
|
||||||
|
export * from './games';
|
||||||
|
|
||||||
// ----- RPC wrappers ---------------------------------------------------------
|
// ----- RPC wrappers ---------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -226,6 +226,33 @@ export async function editEncryptedMessage(
|
|||||||
} as never)
|
} as never)
|
||||||
.eq('id', params.messageId);
|
.eq('id', params.messageId);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Recompute mentions: an edit can add or remove @-tokens. Drop old rows
|
||||||
|
// then re-insert from the new plaintext (best-effort, same as insertMessage).
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const { error: delErr } = await (params.client as any)
|
||||||
|
.from('message_mentions')
|
||||||
|
.delete()
|
||||||
|
.eq('message_id', params.messageId);
|
||||||
|
if (delErr) throw delErr;
|
||||||
|
|
||||||
|
const mentionUsernames = parseMentionUsernames(params.newPlaintext);
|
||||||
|
if (mentionUsernames.length > 0) {
|
||||||
|
const resolver = makeMentionResolver(params.client);
|
||||||
|
const resolved = await resolver.resolveUsernames(params.conversationId, mentionUsernames);
|
||||||
|
if (resolved.size > 0) {
|
||||||
|
await insertMentions(
|
||||||
|
params.client,
|
||||||
|
params.messageId,
|
||||||
|
params.conversationId,
|
||||||
|
[...resolved.values()],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('mention recompute on edit failed', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function softDeleteMessage(
|
export async function softDeleteMessage(
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export interface ConversationSummary {
|
|||||||
// ISO timestamp. null = not muted. Past timestamp = expired mute (treat as
|
// ISO timestamp. null = not muted. Past timestamp = expired mute (treat as
|
||||||
// not muted — the server row is kept for history until the next toggle).
|
// not muted — the server row is kept for history until the next toggle).
|
||||||
mutedUntil: string | null;
|
mutedUntil: string | null;
|
||||||
|
// When true the renderer suppresses non-mention notifications. Mentions
|
||||||
|
// still fire via the independent useMentionNotifications subscription on
|
||||||
|
// message_mentions, so this flag never silences @-alerts.
|
||||||
|
mentionsOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createWatchSession,
|
||||||
|
getWatchSession,
|
||||||
|
parseYouTubeUrl,
|
||||||
|
updateWatchSessionState,
|
||||||
|
} from './watchTogether';
|
||||||
|
|
||||||
|
describe('parseYouTubeUrl', () => {
|
||||||
|
it.each([
|
||||||
|
['https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
|
||||||
|
['https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42', 'dQw4w9WgXcQ'],
|
||||||
|
['https://youtu.be/dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
|
||||||
|
['https://youtu.be/dQw4w9WgXcQ?t=1', 'dQw4w9WgXcQ'],
|
||||||
|
['https://www.youtube.com/embed/dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
|
||||||
|
['https://www.youtube.com/shorts/dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
|
||||||
|
['https://m.youtube.com/watch?v=dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
|
||||||
|
['dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
|
||||||
|
])('extracts the id from %s', (url, expected) => {
|
||||||
|
expect(parseYouTubeUrl(url)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'',
|
||||||
|
' ',
|
||||||
|
'https://vimeo.com/123',
|
||||||
|
'not a url',
|
||||||
|
'short_id',
|
||||||
|
'https://www.youtube.com/playlist?list=PL123',
|
||||||
|
])('returns null for %s', (input) => {
|
||||||
|
expect(parseYouTubeUrl(input)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeClient(opts: {
|
||||||
|
user?: { id: string } | null;
|
||||||
|
insertReturn?: { data: unknown; error: unknown };
|
||||||
|
selectReturn?: { data: unknown; error: unknown };
|
||||||
|
updateReturn?: { error: unknown };
|
||||||
|
}): any {
|
||||||
|
const single = vi.fn().mockResolvedValue(opts.insertReturn ?? { data: {}, error: null });
|
||||||
|
const insertSelect = vi.fn().mockReturnValue({ single });
|
||||||
|
const insertChain = vi.fn().mockReturnValue({ select: insertSelect });
|
||||||
|
const maybeSingle = vi.fn().mockResolvedValue(opts.selectReturn ?? { data: null, error: null });
|
||||||
|
const eqSelect = vi.fn().mockReturnValue({ maybeSingle });
|
||||||
|
const selectChain = vi.fn().mockReturnValue({ eq: eqSelect });
|
||||||
|
const eqUpdate = vi.fn().mockResolvedValue(opts.updateReturn ?? { error: null });
|
||||||
|
const updateChain = vi.fn().mockReturnValue({ eq: eqUpdate });
|
||||||
|
const from = vi.fn().mockReturnValue({
|
||||||
|
insert: insertChain,
|
||||||
|
select: selectChain,
|
||||||
|
update: updateChain,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: opts.user ?? { id: 'u-1' } } }) },
|
||||||
|
from,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createWatchSession', () => {
|
||||||
|
it('inserts row + maps response to camelCase', async () => {
|
||||||
|
const client = makeClient({
|
||||||
|
insertReturn: {
|
||||||
|
data: {
|
||||||
|
id: 'w-1',
|
||||||
|
conversation_id: 'c-1',
|
||||||
|
owner_user_id: 'u-1',
|
||||||
|
video_id: 'dQw4w9WgXcQ',
|
||||||
|
started_at: '2026-05-16T00:00:00Z',
|
||||||
|
ended_at: null,
|
||||||
|
current_state: { playing: false, position_seconds: 0, updated_at_ms: 0 },
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const out = await createWatchSession(client, { conversationId: 'c-1', videoId: 'dQw4w9WgXcQ' });
|
||||||
|
expect(out.id).toBe('w-1');
|
||||||
|
expect(out.ownerUserId).toBe('u-1');
|
||||||
|
expect(out.videoId).toBe('dQw4w9WgXcQ');
|
||||||
|
expect(out.endedAt).toBeNull();
|
||||||
|
expect(out.currentState.playing).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getWatchSession', () => {
|
||||||
|
it('returns null when row not found', async () => {
|
||||||
|
const client = makeClient({ selectReturn: { data: null, error: null } });
|
||||||
|
const out = await getWatchSession(client, 'w-missing');
|
||||||
|
expect(out).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces missing current_state fields to safe defaults', async () => {
|
||||||
|
const client = makeClient({
|
||||||
|
selectReturn: {
|
||||||
|
data: {
|
||||||
|
id: 'w-1',
|
||||||
|
conversation_id: 'c-1',
|
||||||
|
owner_user_id: 'u-1',
|
||||||
|
video_id: 'dQw4w9WgXcQ',
|
||||||
|
started_at: '2026-05-16T00:00:00Z',
|
||||||
|
ended_at: null,
|
||||||
|
current_state: {},
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const out = await getWatchSession(client, 'w-1');
|
||||||
|
expect(out?.currentState).toEqual({ playing: false, positionSeconds: 0, updatedAtMs: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateWatchSessionState', () => {
|
||||||
|
it('does not throw on success', async () => {
|
||||||
|
const client = makeClient({ updateReturn: { error: null } });
|
||||||
|
await expect(
|
||||||
|
updateWatchSessionState(client, 'w-1', {
|
||||||
|
playing: true,
|
||||||
|
positionSeconds: 42.5,
|
||||||
|
updatedAtMs: Date.now(),
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { AppSupabaseClient } from '../supabase/client';
|
||||||
|
|
||||||
|
export interface WatchSessionState {
|
||||||
|
playing: boolean;
|
||||||
|
positionSeconds: number;
|
||||||
|
updatedAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchSession {
|
||||||
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
videoId: string;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string | null;
|
||||||
|
currentState: WatchSessionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseYouTubeUrl(input: string): string | null {
|
||||||
|
const s = input.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
if (/^[A-Za-z0-9_-]{11}$/.test(s)) return s;
|
||||||
|
const patterns = [
|
||||||
|
/[?&]v=([A-Za-z0-9_-]{11})/,
|
||||||
|
/youtu\.be\/([A-Za-z0-9_-]{11})/,
|
||||||
|
/youtube\.com\/embed\/([A-Za-z0-9_-]{11})/,
|
||||||
|
/youtube\.com\/shorts\/([A-Za-z0-9_-]{11})/,
|
||||||
|
];
|
||||||
|
for (const re of patterns) {
|
||||||
|
const m = re.exec(s);
|
||||||
|
if (m && m[1]) return m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWatchSession(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
params: { conversationId: string; videoId: string },
|
||||||
|
): Promise<WatchSession> {
|
||||||
|
const { data: session } = await client.auth.getUser();
|
||||||
|
if (!session.user) throw new Error('not authenticated');
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('conversation_watch_sessions')
|
||||||
|
.insert({
|
||||||
|
conversation_id: params.conversationId,
|
||||||
|
owner_user_id: session.user.id,
|
||||||
|
video_id: params.videoId,
|
||||||
|
})
|
||||||
|
.select('id, conversation_id, owner_user_id, video_id, started_at, ended_at, current_state')
|
||||||
|
.single();
|
||||||
|
if (error) throw error;
|
||||||
|
return mapRow(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWatchSession(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<WatchSession | null> {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('conversation_watch_sessions')
|
||||||
|
.select('id, conversation_id, owner_user_id, video_id, started_at, ended_at, current_state')
|
||||||
|
.eq('id', sessionId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) throw error;
|
||||||
|
return data ? mapRow(data) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWatchSessionState(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
sessionId: string,
|
||||||
|
state: WatchSessionState,
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await client
|
||||||
|
.from('conversation_watch_sessions')
|
||||||
|
.update({
|
||||||
|
current_state: {
|
||||||
|
playing: state.playing,
|
||||||
|
position_seconds: state.positionSeconds,
|
||||||
|
updated_at_ms: state.updatedAtMs,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.eq('id', sessionId);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function endWatchSession(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await client
|
||||||
|
.from('conversation_watch_sessions')
|
||||||
|
.update({ ended_at: new Date().toISOString() })
|
||||||
|
.eq('id', sessionId);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRow(row: {
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
owner_user_id: string;
|
||||||
|
video_id: string;
|
||||||
|
started_at: string;
|
||||||
|
ended_at: string | null;
|
||||||
|
current_state: unknown;
|
||||||
|
}): WatchSession {
|
||||||
|
const raw = (row.current_state ?? {}) as Partial<{
|
||||||
|
playing: boolean;
|
||||||
|
position_seconds: number;
|
||||||
|
updated_at_ms: number;
|
||||||
|
}>;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
conversationId: row.conversation_id,
|
||||||
|
ownerUserId: row.owner_user_id,
|
||||||
|
videoId: row.video_id,
|
||||||
|
startedAt: row.started_at,
|
||||||
|
endedAt: row.ended_at,
|
||||||
|
currentState: {
|
||||||
|
playing: typeof raw.playing === 'boolean' ? raw.playing : false,
|
||||||
|
positionSeconds: typeof raw.position_seconds === 'number' ? raw.position_seconds : 0,
|
||||||
|
updatedAtMs: typeof raw.updated_at_ms === 'number' ? raw.updated_at_ms : 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -53,8 +53,6 @@
|
|||||||
"archive": "Archivieren",
|
"archive": "Archivieren",
|
||||||
"unarchive": "Entarchivieren",
|
"unarchive": "Entarchivieren",
|
||||||
"archived_title": "Archiv",
|
"archived_title": "Archiv",
|
||||||
"show_archived": "Archiv anzeigen",
|
|
||||||
"show_active": "Aktive anzeigen",
|
|
||||||
"archived_empty_title": "Nichts archiviert",
|
"archived_empty_title": "Nichts archiviert",
|
||||||
"archived_empty_subtitle": "Archivierte Unterhaltungen erscheinen hier.",
|
"archived_empty_subtitle": "Archivierte Unterhaltungen erscheinen hier.",
|
||||||
"mute": "Stummschalten",
|
"mute": "Stummschalten",
|
||||||
@@ -84,8 +82,6 @@
|
|||||||
"join": "Beitreten",
|
"join": "Beitreten",
|
||||||
"in_call": "Im Anruf",
|
"in_call": "Im Anruf",
|
||||||
"waiting_for_peers": "Warte auf andere…",
|
"waiting_for_peers": "Warte auf andere…",
|
||||||
"voice_connected": "Sprachchat verbunden",
|
|
||||||
"still_live": "Anruf läuft noch",
|
|
||||||
"share_screen": "Bildschirm teilen",
|
"share_screen": "Bildschirm teilen",
|
||||||
"stop_share_screen": "Screen-Share stoppen",
|
"stop_share_screen": "Screen-Share stoppen",
|
||||||
"is_sharing_screen": "{{name}} teilt den Bildschirm",
|
"is_sharing_screen": "{{name}} teilt den Bildschirm",
|
||||||
@@ -133,11 +129,9 @@
|
|||||||
"action_unfriend": "Entfernen",
|
"action_unfriend": "Entfernen",
|
||||||
"action_accept": "Annehmen",
|
"action_accept": "Annehmen",
|
||||||
"action_decline": "Ablehnen",
|
"action_decline": "Ablehnen",
|
||||||
"action_cancel": "Abbrechen",
|
"action_cancel": "Abbrechen"
|
||||||
"confirm_unfriend": "Diesen Freund entfernen?"
|
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"nav": "Admin",
|
|
||||||
"title": "Admin-Panel",
|
"title": "Admin-Panel",
|
||||||
"settings_title": "Globale Einstellungen",
|
"settings_title": "Globale Einstellungen",
|
||||||
"invites_enabled": "Neue Registrierungen erlauben",
|
"invites_enabled": "Neue Registrierungen erlauben",
|
||||||
@@ -188,15 +182,11 @@
|
|||||||
"screen_share_quality": "Qualität",
|
"screen_share_quality": "Qualität",
|
||||||
"screen_share_hint": "WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an (SVC/VP9). Die Werte sind Obergrenzen. Änderungen greifen beim nächsten Call.",
|
"screen_share_hint": "WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an (SVC/VP9). Die Werte sind Obergrenzen. Änderungen greifen beim nächsten Call.",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
"presence": "Status",
|
|
||||||
"show_read_receipts": "Lesebestätigungen anzeigen",
|
"show_read_receipts": "Lesebestätigungen anzeigen",
|
||||||
"show_read_receipts_hint": "Wenn aus, sehen andere nicht wann du ihre Nachrichten gelesen hast — und du siehst nicht wann sie deine gelesen haben.",
|
"show_read_receipts_hint": "Wenn aus, sehen andere nicht wann du ihre Nachrichten gelesen hast — und du siehst nicht wann sie deine gelesen haben.",
|
||||||
"allow_dms_strangers": "DMs von Fremden erlauben",
|
"allow_dms_strangers": "DMs von Fremden erlauben",
|
||||||
"allow_dms_strangers_hint": "Wenn aus, können dich nur Freunde direkt anschreiben.",
|
"allow_dms_strangers_hint": "Wenn aus, können dich nur Freunde direkt anschreiben.",
|
||||||
"this_device": "Dieses Gerät",
|
|
||||||
"danger_zone": "Gefahrenzone",
|
|
||||||
"sign_out": "Abmelden",
|
"sign_out": "Abmelden",
|
||||||
"section_ringtone": "Klingelton",
|
|
||||||
"ringtone_incoming": "Eingehender Anruf",
|
"ringtone_incoming": "Eingehender Anruf",
|
||||||
"ringtone_default_active": "Standard-Klingelton (Doppelton)",
|
"ringtone_default_active": "Standard-Klingelton (Doppelton)",
|
||||||
"ringtone_custom_active": "{{name}} · {{size}} MB",
|
"ringtone_custom_active": "{{name}} · {{size}} MB",
|
||||||
|
|||||||
@@ -50,28 +50,8 @@
|
|||||||
"footer_studio": "Supabase Studio",
|
"footer_studio": "Supabase Studio",
|
||||||
"legal_note": "Mit der Registrierung akzeptierst du, dass der Server nur Chiffretext sieht.",
|
"legal_note": "Mit der Registrierung akzeptierst du, dass der Server nur Chiffretext sieht.",
|
||||||
"signed_in": {
|
"signed_in": {
|
||||||
"title": "Angemeldet",
|
|
||||||
"session_active": "Sitzung aktiv",
|
|
||||||
"user_id": "Benutzer-ID",
|
|
||||||
"email": "E-Mail",
|
"email": "E-Mail",
|
||||||
"username": "Benutzername",
|
"username": "Benutzername",
|
||||||
"display_name": "Anzeigename",
|
"display_name": "Anzeigename"
|
||||||
"admin": "Admin",
|
|
||||||
"yes": "Ja",
|
|
||||||
"no": "Nein",
|
|
||||||
"sign_out": "Abmelden",
|
|
||||||
"device_active": "Aktives Gerät",
|
|
||||||
"device_platform": "Plattform",
|
|
||||||
"device_registered_at": "Registriert"
|
|
||||||
},
|
|
||||||
"device": {
|
|
||||||
"title": "Dieses Gerät registrieren",
|
|
||||||
"subtitle": "Erzeugt ein X25519-Schlüsselpaar. Der private Schlüssel bleibt auf diesem Gerät.",
|
|
||||||
"name_label": "Gerätename",
|
|
||||||
"name_hint": "Erscheint in deiner Geräteliste. Wähle einen erkennbaren Namen.",
|
|
||||||
"name_placeholder": "Dennis Laptop",
|
|
||||||
"cta": "Gerät registrieren",
|
|
||||||
"cta_loading": "Schlüsselpaar wird erzeugt…",
|
|
||||||
"security_note_dev": "Dev-Build: Privater Schlüssel liegt unverschlüsselt im localStorage. Stronghold folgt vor dem Release."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
{
|
{
|
||||||
"app_name": "ChatApp",
|
"app_name": "ChatApp",
|
||||||
"loading": "Lädt…",
|
|
||||||
"finalising_session": "Sitzung wird abgeschlossen…",
|
"finalising_session": "Sitzung wird abgeschlossen…",
|
||||||
"cancel": "Abbrechen",
|
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
"retry": "Wiederholen",
|
|
||||||
"online": "Online",
|
|
||||||
"offline": "Offline",
|
|
||||||
"idle": "Abwesend",
|
|
||||||
"dnd": "Nicht stören",
|
|
||||||
"invisible": "Unsichtbar",
|
|
||||||
"local_stack_online": "Lokaler Stack online",
|
"local_stack_online": "Lokaler Stack online",
|
||||||
"dev_build": "Dev-Build"
|
"dev_build": "Dev-Build"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,6 @@
|
|||||||
"archive": "Archive",
|
"archive": "Archive",
|
||||||
"unarchive": "Unarchive",
|
"unarchive": "Unarchive",
|
||||||
"archived_title": "Archive",
|
"archived_title": "Archive",
|
||||||
"show_archived": "Show archive",
|
|
||||||
"show_active": "Show active",
|
|
||||||
"archived_empty_title": "Nothing archived",
|
"archived_empty_title": "Nothing archived",
|
||||||
"archived_empty_subtitle": "Archived conversations appear here.",
|
"archived_empty_subtitle": "Archived conversations appear here.",
|
||||||
"mute": "Mute",
|
"mute": "Mute",
|
||||||
@@ -84,8 +82,6 @@
|
|||||||
"join": "Join",
|
"join": "Join",
|
||||||
"in_call": "In call",
|
"in_call": "In call",
|
||||||
"waiting_for_peers": "Waiting for others…",
|
"waiting_for_peers": "Waiting for others…",
|
||||||
"voice_connected": "Voice connected",
|
|
||||||
"still_live": "Call still live",
|
|
||||||
"share_screen": "Share screen",
|
"share_screen": "Share screen",
|
||||||
"stop_share_screen": "Stop sharing",
|
"stop_share_screen": "Stop sharing",
|
||||||
"is_sharing_screen": "{{name}} is sharing their screen",
|
"is_sharing_screen": "{{name}} is sharing their screen",
|
||||||
@@ -133,11 +129,9 @@
|
|||||||
"action_unfriend": "Unfriend",
|
"action_unfriend": "Unfriend",
|
||||||
"action_accept": "Accept",
|
"action_accept": "Accept",
|
||||||
"action_decline": "Decline",
|
"action_decline": "Decline",
|
||||||
"action_cancel": "Cancel",
|
"action_cancel": "Cancel"
|
||||||
"confirm_unfriend": "Remove this friend?"
|
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"nav": "Admin",
|
|
||||||
"title": "Admin panel",
|
"title": "Admin panel",
|
||||||
"settings_title": "Global settings",
|
"settings_title": "Global settings",
|
||||||
"invites_enabled": "Allow new signups",
|
"invites_enabled": "Allow new signups",
|
||||||
@@ -188,15 +182,11 @@
|
|||||||
"screen_share_quality": "Quality",
|
"screen_share_quality": "Quality",
|
||||||
"screen_share_hint": "WebRTC dynamically adjusts bitrate + resolution to match network conditions (SVC/VP9). Values are upper bounds. Changes apply on the next call.",
|
"screen_share_hint": "WebRTC dynamically adjusts bitrate + resolution to match network conditions (SVC/VP9). Values are upper bounds. Changes apply on the next call.",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"presence": "Presence",
|
|
||||||
"show_read_receipts": "Show read receipts",
|
"show_read_receipts": "Show read receipts",
|
||||||
"show_read_receipts_hint": "When off, others can't see when you read their messages — and you won't see when they read yours.",
|
"show_read_receipts_hint": "When off, others can't see when you read their messages — and you won't see when they read yours.",
|
||||||
"allow_dms_strangers": "Allow DMs from strangers",
|
"allow_dms_strangers": "Allow DMs from strangers",
|
||||||
"allow_dms_strangers_hint": "When off, only friends can DM you.",
|
"allow_dms_strangers_hint": "When off, only friends can DM you.",
|
||||||
"this_device": "This device",
|
|
||||||
"danger_zone": "Danger zone",
|
|
||||||
"sign_out": "Sign out",
|
"sign_out": "Sign out",
|
||||||
"section_ringtone": "Ringtone",
|
|
||||||
"ringtone_incoming": "Incoming call",
|
"ringtone_incoming": "Incoming call",
|
||||||
"ringtone_default_active": "Default ringtone (double beep)",
|
"ringtone_default_active": "Default ringtone (double beep)",
|
||||||
"ringtone_custom_active": "{{name}} · {{size}} MB",
|
"ringtone_custom_active": "{{name}} · {{size}} MB",
|
||||||
|
|||||||
@@ -50,28 +50,8 @@
|
|||||||
"footer_studio": "Supabase Studio",
|
"footer_studio": "Supabase Studio",
|
||||||
"legal_note": "By signing up you accept that the server sees only ciphertext.",
|
"legal_note": "By signing up you accept that the server sees only ciphertext.",
|
||||||
"signed_in": {
|
"signed_in": {
|
||||||
"title": "Signed in",
|
|
||||||
"session_active": "Session active",
|
|
||||||
"user_id": "User ID",
|
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"display_name": "Display name",
|
"display_name": "Display name"
|
||||||
"admin": "Admin",
|
|
||||||
"yes": "yes",
|
|
||||||
"no": "no",
|
|
||||||
"sign_out": "Sign out",
|
|
||||||
"device_active": "Active device",
|
|
||||||
"device_platform": "Platform",
|
|
||||||
"device_registered_at": "Registered"
|
|
||||||
},
|
|
||||||
"device": {
|
|
||||||
"title": "Register this device",
|
|
||||||
"subtitle": "Generates an X25519 keypair. Private key stays on this device.",
|
|
||||||
"name_label": "Device name",
|
|
||||||
"name_hint": "Shown to you in your device list. Keep it recognisable.",
|
|
||||||
"name_placeholder": "Dennis Laptop",
|
|
||||||
"cta": "Register device",
|
|
||||||
"cta_loading": "Generating keypair…",
|
|
||||||
"security_note_dev": "Dev build: private key stored unencrypted in localStorage. Stronghold comes before release."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
{
|
{
|
||||||
"app_name": "ChatApp",
|
"app_name": "ChatApp",
|
||||||
"loading": "Loading…",
|
|
||||||
"finalising_session": "Finalising session…",
|
"finalising_session": "Finalising session…",
|
||||||
"cancel": "Cancel",
|
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"retry": "Retry",
|
|
||||||
"online": "Online",
|
|
||||||
"offline": "Offline",
|
|
||||||
"idle": "Idle",
|
|
||||||
"dnd": "Do not disturb",
|
|
||||||
"invisible": "Invisible",
|
|
||||||
"local_stack_online": "local stack online",
|
"local_stack_online": "local stack online",
|
||||||
"dev_build": "dev build"
|
"dev_build": "dev build"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+214
@@ -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
|
||||||
@@ -95,6 +98,9 @@ importers:
|
|||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^6.28.0
|
specifier: ^6.28.0
|
||||||
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
react-virtuoso:
|
||||||
|
specifier: ^4.18.7
|
||||||
|
version: 4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.1
|
specifier: ^5.0.1
|
||||||
version: 5.0.12(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
|
version: 5.0.12(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
|
||||||
@@ -102,6 +108,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
|
||||||
@@ -135,6 +144,9 @@ importers:
|
|||||||
rimraf:
|
rimraf:
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.1.3
|
version: 6.1.3
|
||||||
|
rollup-plugin-visualizer:
|
||||||
|
specifier: ^7.0.1
|
||||||
|
version: 7.0.1(rollup@4.60.1)
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^3.4.15
|
specifier: ^3.4.15
|
||||||
version: 3.4.19
|
version: 3.4.19
|
||||||
@@ -1967,6 +1979,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==}
|
||||||
|
|
||||||
@@ -2603,6 +2618,10 @@ packages:
|
|||||||
builder-util@25.1.7:
|
builder-util@25.1.7:
|
||||||
resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==}
|
resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==}
|
||||||
|
|
||||||
|
bundle-name@4.1.0:
|
||||||
|
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
bytes@3.1.2:
|
bytes@3.1.2:
|
||||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -2670,6 +2689,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'}
|
||||||
@@ -2745,6 +2767,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
cliui@9.0.1:
|
||||||
|
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
clone-deep@4.0.1:
|
clone-deep@4.0.1:
|
||||||
resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==}
|
resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2976,6 +3002,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
default-browser-id@5.0.1:
|
||||||
|
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
default-browser@5.5.0:
|
||||||
|
resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
default-gateway@4.2.0:
|
default-gateway@4.2.0:
|
||||||
resolution: {integrity: sha512-h6sMrVB1VMWVrW13mSc6ia/DwYYw5MN6+exNu1OaJeFac5aSAvwM7lZ0NVfTABuSkQelr4h5oebg3KB1XPdjgA==}
|
resolution: {integrity: sha512-h6sMrVB1VMWVrW13mSc6ia/DwYYw5MN6+exNu1OaJeFac5aSAvwM7lZ0NVfTABuSkQelr4h5oebg3KB1XPdjgA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2995,6 +3029,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
|
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
define-lazy-prop@3.0.0:
|
||||||
|
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
define-properties@1.2.1:
|
define-properties@1.2.1:
|
||||||
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
|
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3116,6 +3154,9 @@ packages:
|
|||||||
engines: {node: '>= 12.20.55'}
|
engines: {node: '>= 12.20.55'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
emoji-regex@10.6.0:
|
||||||
|
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||||
|
|
||||||
emoji-regex@8.0.0:
|
emoji-regex@8.0.0:
|
||||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||||
|
|
||||||
@@ -3707,6 +3748,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||||
engines: {node: 6.* || 8.* || >= 10.*}
|
engines: {node: 6.* || 8.* || >= 10.*}
|
||||||
|
|
||||||
|
get-east-asian-width@1.6.0:
|
||||||
|
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
get-intrinsic@1.3.0:
|
get-intrinsic@1.3.0:
|
||||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4039,6 +4084,11 @@ packages:
|
|||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
is-docker@3.0.0:
|
||||||
|
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
||||||
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
is-extglob@2.1.1:
|
is-extglob@2.1.1:
|
||||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -4059,6 +4109,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
is-in-ssh@1.0.0:
|
||||||
|
resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
is-inside-container@1.0.0:
|
||||||
|
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
|
||||||
|
engines: {node: '>=14.16'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
is-interactive@1.0.0:
|
is-interactive@1.0.0:
|
||||||
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
|
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -4157,6 +4216,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
|
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
is-wsl@3.1.1:
|
||||||
|
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
isarray@1.0.0:
|
isarray@1.0.0:
|
||||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||||
|
|
||||||
@@ -4950,6 +5013,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
|
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
open@11.0.0:
|
||||||
|
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
open@7.4.2:
|
open@7.4.2:
|
||||||
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
|
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -5175,6 +5242,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
|
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
powershell-utils@0.1.0:
|
||||||
|
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
prebuild-install@7.1.3:
|
prebuild-install@7.1.3:
|
||||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -5408,6 +5479,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.3.1
|
react: ^18.3.1
|
||||||
|
|
||||||
|
react-virtuoso@4.18.7:
|
||||||
|
resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16 || >=17 || >= 18 || >= 19'
|
||||||
|
react-dom: '>=16 || >=17 || >= 18 || >=19'
|
||||||
|
|
||||||
react@18.3.1:
|
react@18.3.1:
|
||||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -5565,11 +5642,28 @@ packages:
|
|||||||
resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
|
resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
|
||||||
engines: {node: '>=8.0'}
|
engines: {node: '>=8.0'}
|
||||||
|
|
||||||
|
rollup-plugin-visualizer@7.0.1:
|
||||||
|
resolution: {integrity: sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==}
|
||||||
|
engines: {node: '>=22'}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
rolldown: 1.x || ^1.0.0-beta || ^1.0.0-rc
|
||||||
|
rollup: 2.x || 3.x || 4.x
|
||||||
|
peerDependenciesMeta:
|
||||||
|
rolldown:
|
||||||
|
optional: true
|
||||||
|
rollup:
|
||||||
|
optional: true
|
||||||
|
|
||||||
rollup@4.60.1:
|
rollup@4.60.1:
|
||||||
resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==}
|
resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==}
|
||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
run-applescript@7.1.0:
|
||||||
|
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
run-parallel@1.2.0:
|
run-parallel@1.2.0:
|
||||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||||
|
|
||||||
@@ -5805,6 +5899,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
source-map@0.7.6:
|
||||||
|
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
|
||||||
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
split-on-first@1.1.0:
|
split-on-first@1.1.0:
|
||||||
resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
|
resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -5872,6 +5970,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
|
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
string-width@7.2.0:
|
||||||
|
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
string.prototype.trim@1.2.10:
|
string.prototype.trim@1.2.10:
|
||||||
resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==}
|
resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -6458,6 +6560,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
|
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
wrap-ansi@9.0.2:
|
||||||
|
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
@@ -6503,6 +6609,10 @@ packages:
|
|||||||
utf-8-validate:
|
utf-8-validate:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
wsl-utils@0.3.1:
|
||||||
|
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
xcode@3.0.1:
|
xcode@3.0.1:
|
||||||
resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==}
|
resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
@@ -6544,10 +6654,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
yargs-parser@22.0.0:
|
||||||
|
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
|
||||||
|
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
||||||
|
|
||||||
yargs@17.7.2:
|
yargs@17.7.2:
|
||||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
yargs@18.0.0:
|
||||||
|
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
|
||||||
|
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
||||||
|
|
||||||
yauzl@2.10.0:
|
yauzl@2.10.0:
|
||||||
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
||||||
|
|
||||||
@@ -8703,6 +8821,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
|
||||||
@@ -9543,6 +9663,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
bundle-name@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
run-applescript: 7.1.0
|
||||||
|
|
||||||
bytes@3.1.2: {}
|
bytes@3.1.2: {}
|
||||||
|
|
||||||
cac@6.7.14: {}
|
cac@6.7.14: {}
|
||||||
@@ -9634,6 +9758,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
|
||||||
@@ -9725,6 +9851,12 @@ snapshots:
|
|||||||
strip-ansi: 6.0.1
|
strip-ansi: 6.0.1
|
||||||
wrap-ansi: 7.0.0
|
wrap-ansi: 7.0.0
|
||||||
|
|
||||||
|
cliui@9.0.1:
|
||||||
|
dependencies:
|
||||||
|
string-width: 7.2.0
|
||||||
|
strip-ansi: 7.2.0
|
||||||
|
wrap-ansi: 9.0.2
|
||||||
|
|
||||||
clone-deep@4.0.1:
|
clone-deep@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-plain-object: 2.0.4
|
is-plain-object: 2.0.4
|
||||||
@@ -9944,6 +10076,13 @@ snapshots:
|
|||||||
|
|
||||||
deepmerge@4.3.1: {}
|
deepmerge@4.3.1: {}
|
||||||
|
|
||||||
|
default-browser-id@5.0.1: {}
|
||||||
|
|
||||||
|
default-browser@5.5.0:
|
||||||
|
dependencies:
|
||||||
|
bundle-name: 4.1.0
|
||||||
|
default-browser-id: 5.0.1
|
||||||
|
|
||||||
default-gateway@4.2.0:
|
default-gateway@4.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
execa: 1.0.0
|
execa: 1.0.0
|
||||||
@@ -9963,6 +10102,8 @@ snapshots:
|
|||||||
|
|
||||||
define-lazy-prop@2.0.0: {}
|
define-lazy-prop@2.0.0: {}
|
||||||
|
|
||||||
|
define-lazy-prop@3.0.0: {}
|
||||||
|
|
||||||
define-properties@1.2.1:
|
define-properties@1.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
define-data-property: 1.1.4
|
define-data-property: 1.1.4
|
||||||
@@ -10136,6 +10277,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
emoji-regex@10.6.0: {}
|
||||||
|
|
||||||
emoji-regex@8.0.0: {}
|
emoji-regex@8.0.0: {}
|
||||||
|
|
||||||
emoji-regex@9.2.2: {}
|
emoji-regex@9.2.2: {}
|
||||||
@@ -10920,6 +11063,8 @@ snapshots:
|
|||||||
|
|
||||||
get-caller-file@2.0.5: {}
|
get-caller-file@2.0.5: {}
|
||||||
|
|
||||||
|
get-east-asian-width@1.6.0: {}
|
||||||
|
|
||||||
get-intrinsic@1.3.0:
|
get-intrinsic@1.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
@@ -11289,6 +11434,8 @@ snapshots:
|
|||||||
|
|
||||||
is-docker@2.2.1: {}
|
is-docker@2.2.1: {}
|
||||||
|
|
||||||
|
is-docker@3.0.0: {}
|
||||||
|
|
||||||
is-extglob@2.1.1: {}
|
is-extglob@2.1.1: {}
|
||||||
|
|
||||||
is-finalizationregistry@1.1.1:
|
is-finalizationregistry@1.1.1:
|
||||||
@@ -11309,6 +11456,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-extglob: 2.1.1
|
is-extglob: 2.1.1
|
||||||
|
|
||||||
|
is-in-ssh@1.0.0: {}
|
||||||
|
|
||||||
|
is-inside-container@1.0.0:
|
||||||
|
dependencies:
|
||||||
|
is-docker: 3.0.0
|
||||||
|
|
||||||
is-interactive@1.0.0: {}
|
is-interactive@1.0.0: {}
|
||||||
|
|
||||||
is-lambda@1.0.1: {}
|
is-lambda@1.0.1: {}
|
||||||
@@ -11390,6 +11543,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-docker: 2.2.1
|
is-docker: 2.2.1
|
||||||
|
|
||||||
|
is-wsl@3.1.1:
|
||||||
|
dependencies:
|
||||||
|
is-inside-container: 1.0.0
|
||||||
|
|
||||||
isarray@1.0.0: {}
|
isarray@1.0.0: {}
|
||||||
|
|
||||||
isarray@2.0.5: {}
|
isarray@2.0.5: {}
|
||||||
@@ -12322,6 +12479,15 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
mimic-fn: 2.1.0
|
mimic-fn: 2.1.0
|
||||||
|
|
||||||
|
open@11.0.0:
|
||||||
|
dependencies:
|
||||||
|
default-browser: 5.5.0
|
||||||
|
define-lazy-prop: 3.0.0
|
||||||
|
is-in-ssh: 1.0.0
|
||||||
|
is-inside-container: 1.0.0
|
||||||
|
powershell-utils: 0.1.0
|
||||||
|
wsl-utils: 0.3.1
|
||||||
|
|
||||||
open@7.4.2:
|
open@7.4.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-docker: 2.2.1
|
is-docker: 2.2.1
|
||||||
@@ -12523,6 +12689,8 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
powershell-utils@0.1.0: {}
|
||||||
|
|
||||||
prebuild-install@7.1.3:
|
prebuild-install@7.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
detect-libc: 2.1.2
|
detect-libc: 2.1.2
|
||||||
@@ -12804,6 +12972,11 @@ snapshots:
|
|||||||
react-shallow-renderer: 16.15.0(react@18.3.1)
|
react-shallow-renderer: 16.15.0(react@18.3.1)
|
||||||
scheduler: 0.23.2
|
scheduler: 0.23.2
|
||||||
|
|
||||||
|
react-virtuoso@4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
react@18.3.1:
|
react@18.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
@@ -12988,6 +13161,15 @@ snapshots:
|
|||||||
sprintf-js: 1.1.3
|
sprintf-js: 1.1.3
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
rollup-plugin-visualizer@7.0.1(rollup@4.60.1):
|
||||||
|
dependencies:
|
||||||
|
open: 11.0.0
|
||||||
|
picomatch: 4.0.4
|
||||||
|
source-map: 0.7.6
|
||||||
|
yargs: 18.0.0
|
||||||
|
optionalDependencies:
|
||||||
|
rollup: 4.60.1
|
||||||
|
|
||||||
rollup@4.60.1:
|
rollup@4.60.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
@@ -13019,6 +13201,8 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc': 4.60.1
|
'@rollup/rollup-win32-x64-msvc': 4.60.1
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
run-applescript@7.1.0: {}
|
||||||
|
|
||||||
run-parallel@1.2.0:
|
run-parallel@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
queue-microtask: 1.2.3
|
queue-microtask: 1.2.3
|
||||||
@@ -13278,6 +13462,8 @@ snapshots:
|
|||||||
|
|
||||||
source-map@0.6.1: {}
|
source-map@0.6.1: {}
|
||||||
|
|
||||||
|
source-map@0.7.6: {}
|
||||||
|
|
||||||
split-on-first@1.1.0: {}
|
split-on-first@1.1.0: {}
|
||||||
|
|
||||||
sprintf-js@1.0.3: {}
|
sprintf-js@1.0.3: {}
|
||||||
@@ -13334,6 +13520,12 @@ snapshots:
|
|||||||
emoji-regex: 9.2.2
|
emoji-regex: 9.2.2
|
||||||
strip-ansi: 7.2.0
|
strip-ansi: 7.2.0
|
||||||
|
|
||||||
|
string-width@7.2.0:
|
||||||
|
dependencies:
|
||||||
|
emoji-regex: 10.6.0
|
||||||
|
get-east-asian-width: 1.6.0
|
||||||
|
strip-ansi: 7.2.0
|
||||||
|
|
||||||
string.prototype.trim@1.2.10:
|
string.prototype.trim@1.2.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.9
|
call-bind: 1.0.9
|
||||||
@@ -13977,6 +14169,12 @@ snapshots:
|
|||||||
string-width: 5.1.2
|
string-width: 5.1.2
|
||||||
strip-ansi: 7.2.0
|
strip-ansi: 7.2.0
|
||||||
|
|
||||||
|
wrap-ansi@9.0.2:
|
||||||
|
dependencies:
|
||||||
|
ansi-styles: 6.2.3
|
||||||
|
string-width: 7.2.0
|
||||||
|
strip-ansi: 7.2.0
|
||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
write-file-atomic@2.4.3:
|
write-file-atomic@2.4.3:
|
||||||
@@ -13998,6 +14196,11 @@ snapshots:
|
|||||||
|
|
||||||
ws@8.20.0: {}
|
ws@8.20.0: {}
|
||||||
|
|
||||||
|
wsl-utils@0.3.1:
|
||||||
|
dependencies:
|
||||||
|
is-wsl: 3.1.1
|
||||||
|
powershell-utils: 0.1.0
|
||||||
|
|
||||||
xcode@3.0.1:
|
xcode@3.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
simple-plist: 1.3.1
|
simple-plist: 1.3.1
|
||||||
@@ -14026,6 +14229,8 @@ snapshots:
|
|||||||
|
|
||||||
yargs-parser@21.1.1: {}
|
yargs-parser@21.1.1: {}
|
||||||
|
|
||||||
|
yargs-parser@22.0.0: {}
|
||||||
|
|
||||||
yargs@17.7.2:
|
yargs@17.7.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
cliui: 8.0.1
|
cliui: 8.0.1
|
||||||
@@ -14036,6 +14241,15 @@ snapshots:
|
|||||||
y18n: 5.0.8
|
y18n: 5.0.8
|
||||||
yargs-parser: 21.1.1
|
yargs-parser: 21.1.1
|
||||||
|
|
||||||
|
yargs@18.0.0:
|
||||||
|
dependencies:
|
||||||
|
cliui: 9.0.1
|
||||||
|
escalade: 3.2.0
|
||||||
|
get-caller-file: 2.0.5
|
||||||
|
string-width: 7.2.0
|
||||||
|
y18n: 5.0.8
|
||||||
|
yargs-parser: 22.0.0
|
||||||
|
|
||||||
yauzl@2.10.0:
|
yauzl@2.10.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
buffer-crc32: 0.2.13
|
buffer-crc32: 0.2.13
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
-- Phase 5A: per-conversation synchronized YouTube playback.
|
||||||
|
--
|
||||||
|
-- conversation_watch_sessions: one row per Watch-Together session. The bubble
|
||||||
|
-- in the chat is a normal `messages` row whose plaintext payload is
|
||||||
|
-- `{v:1, type:'watch_together', session_id:<id>}`. The owner's player drives
|
||||||
|
-- current_state (jsonb {playing, position_seconds, updated_at_ms}); other
|
||||||
|
-- joiners reconcile via realtime postgres_changes UPDATE when local drift
|
||||||
|
-- exceeds 2 seconds.
|
||||||
|
|
||||||
|
create table if not exists public.conversation_watch_sessions (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
conversation_id uuid not null references public.conversations(id) on delete cascade,
|
||||||
|
owner_user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
|
video_id text not null,
|
||||||
|
started_at timestamptz not null default now(),
|
||||||
|
ended_at timestamptz null,
|
||||||
|
current_state jsonb not null default '{"playing":false,"position_seconds":0,"updated_at_ms":0}'::jsonb,
|
||||||
|
constraint conversation_watch_sessions_video_id_len check (length(video_id) between 1 and 64)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists conversation_watch_sessions_conv_idx
|
||||||
|
on public.conversation_watch_sessions(conversation_id, started_at desc);
|
||||||
|
|
||||||
|
alter table public.conversation_watch_sessions enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists conversation_watch_sessions_select on public.conversation_watch_sessions;
|
||||||
|
drop policy if exists conversation_watch_sessions_insert on public.conversation_watch_sessions;
|
||||||
|
drop policy if exists conversation_watch_sessions_update on public.conversation_watch_sessions;
|
||||||
|
|
||||||
|
create policy conversation_watch_sessions_select
|
||||||
|
on public.conversation_watch_sessions
|
||||||
|
for select
|
||||||
|
using (public.is_conversation_member(conversation_id));
|
||||||
|
|
||||||
|
create policy conversation_watch_sessions_insert
|
||||||
|
on public.conversation_watch_sessions
|
||||||
|
for insert
|
||||||
|
with check (
|
||||||
|
public.is_conversation_member(conversation_id)
|
||||||
|
and owner_user_id = auth.uid()
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy conversation_watch_sessions_update
|
||||||
|
on public.conversation_watch_sessions
|
||||||
|
for update
|
||||||
|
using (owner_user_id = auth.uid())
|
||||||
|
with check (owner_user_id = auth.uid());
|
||||||
|
|
||||||
|
alter table public.conversation_watch_sessions replica identity full;
|
||||||
|
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from pg_publication_tables
|
||||||
|
where pubname = 'supabase_realtime'
|
||||||
|
and schemaname = 'public'
|
||||||
|
and tablename = 'conversation_watch_sessions'
|
||||||
|
) then
|
||||||
|
execute 'alter publication supabase_realtime add table public.conversation_watch_sessions';
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
-- Phase 5B: per-conversation mini-games (Tic-Tac-Toe + Vier-Gewinnt /
|
||||||
|
-- Connect Four).
|
||||||
|
--
|
||||||
|
-- conversation_games: one row per game. The bubble in the chat is a
|
||||||
|
-- regular `messages` row whose plaintext payload is
|
||||||
|
-- `{v:1, type:'game', game_id:<id>, game_type:<'ttt'|'c4'>}`.
|
||||||
|
--
|
||||||
|
-- state JSON shape:
|
||||||
|
-- ttt: { kind: 'ttt', board: [null|0|1 × 9] } (row-major 3×3)
|
||||||
|
-- c4: { kind: 'c4', board: [null|0|1 × 42] } (row-major 6 rows × 7 cols)
|
||||||
|
--
|
||||||
|
-- players JSON: [user_id_a, user_id_b] — indices 0 and 1 map to board cells.
|
||||||
|
-- current_turn_user_id alternates; null once finished.
|
||||||
|
-- winner_user_id null on draw or unfinished.
|
||||||
|
|
||||||
|
create table if not exists public.conversation_games (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
conversation_id uuid not null references public.conversations(id) on delete cascade,
|
||||||
|
game_type text not null check (game_type in ('ttt', 'c4')),
|
||||||
|
state jsonb not null,
|
||||||
|
players jsonb not null,
|
||||||
|
current_turn_user_id uuid null references auth.users(id) on delete set null,
|
||||||
|
winner_user_id uuid null references auth.users(id) on delete set null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
finished_at timestamptz null
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists conversation_games_conv_idx
|
||||||
|
on public.conversation_games(conversation_id, created_at desc);
|
||||||
|
|
||||||
|
alter table public.conversation_games enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists conversation_games_select on public.conversation_games;
|
||||||
|
drop policy if exists conversation_games_insert on public.conversation_games;
|
||||||
|
|
||||||
|
create policy conversation_games_select
|
||||||
|
on public.conversation_games
|
||||||
|
for select
|
||||||
|
using (public.is_conversation_member(conversation_id));
|
||||||
|
|
||||||
|
create policy conversation_games_insert
|
||||||
|
on public.conversation_games
|
||||||
|
for insert
|
||||||
|
with check (
|
||||||
|
public.is_conversation_member(conversation_id)
|
||||||
|
and current_turn_user_id = auth.uid()
|
||||||
|
and (players->>0)::uuid = auth.uid()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.conversation_games replica identity full;
|
||||||
|
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from pg_publication_tables
|
||||||
|
where pubname = 'supabase_realtime'
|
||||||
|
and schemaname = 'public'
|
||||||
|
and tablename = 'conversation_games'
|
||||||
|
) then
|
||||||
|
execute 'alter publication supabase_realtime add table public.conversation_games';
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
-- Pure winner-check + board-full helpers.
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
create or replace function public.ttt_check_winner(board jsonb)
|
||||||
|
returns int
|
||||||
|
language plpgsql
|
||||||
|
immutable
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
lines int[][] := array[
|
||||||
|
array[0,1,2], array[3,4,5], array[6,7,8],
|
||||||
|
array[0,3,6], array[1,4,7], array[2,5,8],
|
||||||
|
array[0,4,8], array[2,4,6]
|
||||||
|
];
|
||||||
|
ln int[];
|
||||||
|
a jsonb;
|
||||||
|
b jsonb;
|
||||||
|
c jsonb;
|
||||||
|
begin
|
||||||
|
foreach ln slice 1 in array lines loop
|
||||||
|
a := board->ln[1];
|
||||||
|
b := board->ln[2];
|
||||||
|
c := board->ln[3];
|
||||||
|
if jsonb_typeof(a) = 'number' and a = b and b = c then
|
||||||
|
return (a)::int;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.c4_check_winner(board jsonb)
|
||||||
|
returns int
|
||||||
|
language plpgsql
|
||||||
|
immutable
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
rows constant int := 6;
|
||||||
|
cols constant int := 7;
|
||||||
|
r int;
|
||||||
|
c int;
|
||||||
|
i int;
|
||||||
|
d_r int;
|
||||||
|
d_c int;
|
||||||
|
directions int[][] := array[
|
||||||
|
array[0, 1],
|
||||||
|
array[1, 0],
|
||||||
|
array[1, 1],
|
||||||
|
array[1, -1]
|
||||||
|
];
|
||||||
|
dir int[];
|
||||||
|
base jsonb;
|
||||||
|
cell jsonb;
|
||||||
|
ok boolean;
|
||||||
|
begin
|
||||||
|
for r in 0..rows-1 loop
|
||||||
|
for c in 0..cols-1 loop
|
||||||
|
base := board->(r * cols + c);
|
||||||
|
if jsonb_typeof(base) <> 'number' then continue; end if;
|
||||||
|
foreach dir slice 1 in array directions loop
|
||||||
|
d_r := dir[1];
|
||||||
|
d_c := dir[2];
|
||||||
|
if r + d_r * 3 < 0 or r + d_r * 3 >= rows then continue; end if;
|
||||||
|
if c + d_c * 3 < 0 or c + d_c * 3 >= cols then continue; end if;
|
||||||
|
ok := true;
|
||||||
|
for i in 1..3 loop
|
||||||
|
cell := board->((r + d_r * i) * cols + (c + d_c * i));
|
||||||
|
if cell is null or cell <> base then ok := false; exit; end if;
|
||||||
|
end loop;
|
||||||
|
if ok then return (base)::int; end if;
|
||||||
|
end loop;
|
||||||
|
end loop;
|
||||||
|
end loop;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.board_is_full(board jsonb)
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
as $$
|
||||||
|
select not exists (
|
||||||
|
select 1 from jsonb_array_elements(board) elt where jsonb_typeof(elt) is distinct from 'number'
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
-- The state-machine RPC.
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
create or replace function public.game_make_move(p_game_id uuid, p_move jsonb)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_game record;
|
||||||
|
v_state jsonb;
|
||||||
|
v_board jsonb;
|
||||||
|
v_kind text;
|
||||||
|
v_player_idx int;
|
||||||
|
v_other_user uuid;
|
||||||
|
v_winner_idx int;
|
||||||
|
v_finished boolean;
|
||||||
|
v_cell int;
|
||||||
|
v_col int;
|
||||||
|
v_row int;
|
||||||
|
v_cols constant int := 7;
|
||||||
|
v_rows constant int := 6;
|
||||||
|
v_target_row int;
|
||||||
|
begin
|
||||||
|
if auth.uid() is null then
|
||||||
|
raise exception 'not_authenticated' using errcode = '28000';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select * into v_game from public.conversation_games where id = p_game_id for update;
|
||||||
|
if not found then
|
||||||
|
raise exception 'game_not_found' using errcode = 'P0002';
|
||||||
|
end if;
|
||||||
|
if v_game.finished_at is not null then
|
||||||
|
raise exception 'game_finished' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
if v_game.current_turn_user_id is null
|
||||||
|
or v_game.current_turn_user_id <> auth.uid() then
|
||||||
|
raise exception 'not_your_turn' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if (v_game.players->>0)::uuid = auth.uid() then
|
||||||
|
v_player_idx := 0;
|
||||||
|
v_other_user := (v_game.players->>1)::uuid;
|
||||||
|
elsif (v_game.players->>1)::uuid = auth.uid() then
|
||||||
|
v_player_idx := 1;
|
||||||
|
v_other_user := (v_game.players->>0)::uuid;
|
||||||
|
else
|
||||||
|
raise exception 'not_a_player' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_state := v_game.state;
|
||||||
|
v_kind := v_state->>'kind';
|
||||||
|
v_board := v_state->'board';
|
||||||
|
|
||||||
|
if v_kind = 'ttt' then
|
||||||
|
if (p_move->'cell') is null or jsonb_typeof(p_move->'cell') <> 'number' then
|
||||||
|
raise exception 'bad_move' using errcode = '22023';
|
||||||
|
end if;
|
||||||
|
v_cell := (p_move->>'cell')::int;
|
||||||
|
if v_cell < 0 or v_cell > 8 then
|
||||||
|
raise exception 'bad_move' using errcode = '22023';
|
||||||
|
end if;
|
||||||
|
if jsonb_typeof(v_board->v_cell) = 'number' then
|
||||||
|
raise exception 'cell_taken' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
v_board := jsonb_set(v_board, array[v_cell::text], to_jsonb(v_player_idx));
|
||||||
|
v_winner_idx := public.ttt_check_winner(v_board);
|
||||||
|
|
||||||
|
elsif v_kind = 'c4' then
|
||||||
|
if (p_move->'column') is null or jsonb_typeof(p_move->'column') <> 'number' then
|
||||||
|
raise exception 'bad_move' using errcode = '22023';
|
||||||
|
end if;
|
||||||
|
v_col := (p_move->>'column')::int;
|
||||||
|
if v_col < 0 or v_col >= v_cols then
|
||||||
|
raise exception 'bad_move' using errcode = '22023';
|
||||||
|
end if;
|
||||||
|
v_target_row := -1;
|
||||||
|
for v_row in reverse v_rows - 1 .. 0 loop
|
||||||
|
if jsonb_typeof(v_board->(v_row * v_cols + v_col)) is distinct from 'number' then
|
||||||
|
v_target_row := v_row;
|
||||||
|
exit;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
if v_target_row < 0 then
|
||||||
|
raise exception 'column_full' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
v_board := jsonb_set(v_board, array[(v_target_row * v_cols + v_col)::text], to_jsonb(v_player_idx));
|
||||||
|
v_winner_idx := public.c4_check_winner(v_board);
|
||||||
|
|
||||||
|
else
|
||||||
|
raise exception 'unknown_game_kind' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_state := jsonb_set(v_state, '{board}', v_board);
|
||||||
|
v_finished := v_winner_idx is not null or public.board_is_full(v_board);
|
||||||
|
|
||||||
|
update public.conversation_games
|
||||||
|
set state = v_state,
|
||||||
|
current_turn_user_id = case when v_finished then null else v_other_user end,
|
||||||
|
winner_user_id = case when v_winner_idx is not null then (v_game.players->>v_winner_idx)::uuid else null end,
|
||||||
|
finished_at = case when v_finished then now() else null end
|
||||||
|
where id = p_game_id;
|
||||||
|
|
||||||
|
return v_state;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.game_make_move(uuid, jsonb) from public;
|
||||||
|
grant execute on function public.game_make_move(uuid, jsonb) to authenticated;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Phase 5C: per-conversation "notify only on @mentions" toggle.
|
||||||
|
-- Lives alongside the existing muted_until column on conversation_members.
|
||||||
|
-- When true: the renderer's incoming-message notification gate suppresses
|
||||||
|
-- the alert unless the message contains an @-mention of the local user.
|
||||||
|
-- Mentions always fire regardless (override-by-design via the independent
|
||||||
|
-- useMentionNotifications subscription on message_mentions).
|
||||||
|
|
||||||
|
alter table public.conversation_members
|
||||||
|
add column if not exists mentions_only boolean not null default false;
|
||||||
Reference in New Issue
Block a user