feat: reply + search + forward + archive/mute + error boundary
Messages: - Reply-to: hover action, composer chip with cancel, quote bubble inside the replying message with tap-to-jump + amber highlight ring - Search: header search button toggles in-conversation search bar with prev/next + match counter, auto-jump to active match - Forward: multi-select conversation picker. Attachments are now carried over: download + decrypt source, re-encrypt under each target conv-key, re-upload with fresh per-attachment keys, insert new attachment rows Conversations: - Archive + mute per member. New migration 20260420000001 adds `archived` + `muted_until` on conversation_members. Shared helpers: setConversationArchived / setConversationMutedUntil / isConversationMuted - ChatsPage: archive toggle in header with unread badge for archived bucket, split active/archived lists, muted indicator (BellOff icon, dimmed unread badge) - ConversationRowMenu via createPortal (escapes sidebar overflow clip), forwardRef-based MenuItem so submenu positioning refs survive React 18 - ConversationsContext: suppresses notification sound + OS notif when target conversation is muted - Refresh on `profiles UPDATE` realtime so peer avatar / displayName changes flow to conversation.members without manual refresh Resilience: - ErrorBoundary (Discord-style): centred spinner + escalating copy, no manual reload button. Backoff retry schedule [2s, 4s, 8s, 15s, 30s]. Uses a keyed Fragment (not a div wrapper) so flex h-full chains survive - App wrapped root + per-route RouteBoundary, conversation-level boundary - AuthContext: flip `ready` immediately on cached session read; validate getUser in background so a stalled/offline Supabase doesn't freeze the app on the loading spinner Crypto: - Swap libsodium-wrappers -> libsodium-wrappers-sumo (compact build was missing crypto_pwhash so Argon2id vault KDF threw, falling back to plaintext localStorage on every launch) - Shim d.ts for sumo types (sumo is API superset, no official types ship) - vite optimizeDeps includes sumo with the "require" condition - secureFileStore: exists(dir) check before mkdir; surface genuine permission errors instead of silent catch Tauri: - fs scope adds `$APPLOCALDATA` direct entry (not just /**) so the app data directory itself can be mkdir'd on first launch Chat layout: - Skip call_event messages when computing avatar run boundaries so a regular bubble followed by a call event from the same sender still shows its avatar
This commit is contained in:
@@ -30,7 +30,7 @@
|
||||
"@tauri-apps/plugin-stronghold": "^2.0.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"i18next": "^23.16.4",
|
||||
"libsodium-wrappers": "0.7.15",
|
||||
"libsodium-wrappers-sumo": "0.7.15",
|
||||
"livekit-client": "^2.7.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -41,6 +41,7 @@
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.1.0",
|
||||
"@types/libsodium-wrappers": "^0.7.14",
|
||||
"@types/libsodium-wrappers-sumo": "^0.8.2",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [
|
||||
{ "path": "$APPLOCALDATA" },
|
||||
{ "path": "$APPLOCALDATA/**" }
|
||||
]
|
||||
}
|
||||
|
||||
+69
-37
@@ -1,6 +1,7 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { AppShell } from './components/AppShell';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { UpdateToast } from './components/UpdateToast';
|
||||
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
@@ -17,42 +18,73 @@ import { DevicePage } from './pages/DevicePage';
|
||||
import { FriendsPage } from './pages/FriendsPage';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
|
||||
export function App() {
|
||||
// Isolates each top-level route so a crash in one page doesn't take the whole
|
||||
// shell down. The boundary auto-retries via `ErrorBoundary`'s backoff schedule.
|
||||
function RouteBoundary({ scope }: { scope: string }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<FriendshipsProvider>
|
||||
<ConversationsProvider>
|
||||
<CallProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/auth" element={<AuthPage />} />
|
||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="/device" element={<DevicePage />} />
|
||||
<Route element={<RequireDevice />}>
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<Navigate to="/chats" replace />} />
|
||||
<Route path="/chats" element={<ChatsPage />}>
|
||||
<Route index element={<ChatsEmptyState />} />
|
||||
<Route path=":id" element={<ConversationPage />} />
|
||||
</Route>
|
||||
<Route path="/friends" element={<FriendsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route element={<RequireAdmin />}>
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/chats" replace />} />
|
||||
</Routes>
|
||||
<UpdateToast />
|
||||
</BrowserRouter>
|
||||
</CallProvider>
|
||||
</ConversationsProvider>
|
||||
</FriendshipsProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
<ErrorBoundary scope={scope}>
|
||||
<Outlet />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ErrorBoundary scope="root">
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<FriendshipsProvider>
|
||||
<ConversationsProvider>
|
||||
<CallProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<RouteBoundary scope="auth" />}>
|
||||
<Route path="/auth" element={<AuthPage />} />
|
||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||
</Route>
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<RouteBoundary scope="device" />}>
|
||||
<Route path="/device" element={<DevicePage />} />
|
||||
</Route>
|
||||
<Route element={<RequireDevice />}>
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<Navigate to="/chats" replace />} />
|
||||
<Route element={<RouteBoundary scope="chats" />}>
|
||||
<Route path="/chats" element={<ChatsPage />}>
|
||||
<Route index element={<ChatsEmptyState />} />
|
||||
<Route
|
||||
path=":id"
|
||||
element={
|
||||
<ErrorBoundary scope="conversation">
|
||||
<ConversationPage />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
<Route element={<RouteBoundary scope="friends" />}>
|
||||
<Route path="/friends" element={<FriendsPage />} />
|
||||
</Route>
|
||||
<Route element={<RouteBoundary scope="settings" />}>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
<Route element={<RequireAdmin />}>
|
||||
<Route element={<RouteBoundary scope="admin" />}>
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/chats" replace />} />
|
||||
</Routes>
|
||||
<UpdateToast />
|
||||
</BrowserRouter>
|
||||
</CallProvider>
|
||||
</ConversationsProvider>
|
||||
</FriendshipsProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,9 +28,10 @@ interface Props {
|
||||
conversation: ConversationSummary | null;
|
||||
peerPresence: PresenceState | null;
|
||||
onInfoClick?: () => void;
|
||||
onSearchClick?: () => void;
|
||||
}
|
||||
|
||||
export function ConversationHeader({ conversation, peerPresence, onInfoClick }: Props) {
|
||||
export function ConversationHeader({ conversation, peerPresence, onInfoClick, onSearchClick }: Props) {
|
||||
if (!conversation) {
|
||||
return <header className="h-[57px] border-b border-line px-6 py-3" aria-busy="true" />;
|
||||
}
|
||||
@@ -41,6 +42,7 @@ export function ConversationHeader({ conversation, peerPresence, onInfoClick }:
|
||||
conversation={conversation}
|
||||
peerPresence={peerPresence}
|
||||
{...(onInfoClick ? { onInfoClick } : {})}
|
||||
{...(onSearchClick ? { onSearchClick } : {})}
|
||||
/>
|
||||
<ActiveCallBanner conversationId={conversation.id} />
|
||||
</>
|
||||
@@ -51,9 +53,10 @@ interface HeaderBarProps {
|
||||
conversation: ConversationSummary;
|
||||
peerPresence: PresenceState | null;
|
||||
onInfoClick?: () => void;
|
||||
onSearchClick?: () => void;
|
||||
}
|
||||
|
||||
function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps) {
|
||||
function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: HeaderBarProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
|
||||
const isDm = conversation.type === 'dm';
|
||||
@@ -108,7 +111,11 @@ function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HeaderActionButton label={t('app:chats.search', { defaultValue: 'Suche' })} icon={SearchIcon} />
|
||||
<HeaderActionButton
|
||||
label={t('app:chats.search', { defaultValue: 'Suche' })}
|
||||
icon={SearchIcon}
|
||||
{...(onSearchClick ? { onClick: onSearchClick } : {})}
|
||||
/>
|
||||
<CallHeaderButton conversationId={conversation.id} kind="audio" />
|
||||
<CallHeaderButton conversationId={conversation.id} kind="video" />
|
||||
{!isDm && onInfoClick && (
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
muteDurationToIso,
|
||||
setConversationArchived,
|
||||
setConversationMutedUntil,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { ArchiveIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
conversationId: string;
|
||||
archived: boolean;
|
||||
mutedUntil: string | null;
|
||||
}
|
||||
|
||||
interface MuteOption {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
labelDefault: string;
|
||||
minutes: number | null;
|
||||
}
|
||||
|
||||
// Muted-forever sentinel ≈ 100 years. UI treats any future timestamp as muted
|
||||
// until that moment; 100y is indistinguishable from "forever" at the UX level
|
||||
// without requiring a dedicated `bool muted_forever` column.
|
||||
const FOREVER_MINUTES = 100 * 365 * 24 * 60;
|
||||
|
||||
const MUTE_OPTIONS: MuteOption[] = [
|
||||
{ key: '1h', labelKey: 'app:chats.mute_1h', labelDefault: '1 Stunde', minutes: 60 },
|
||||
{ key: '8h', labelKey: 'app:chats.mute_8h', labelDefault: '8 Stunden', minutes: 8 * 60 },
|
||||
{ key: '24h', labelKey: 'app:chats.mute_24h', labelDefault: '24 Stunden', minutes: 24 * 60 },
|
||||
{ key: '1w', labelKey: 'app:chats.mute_1w', labelDefault: '1 Woche', minutes: 7 * 24 * 60 },
|
||||
{
|
||||
key: 'forever',
|
||||
labelKey: 'app:chats.mute_forever',
|
||||
labelDefault: 'Bis auf Weiteres',
|
||||
minutes: FOREVER_MINUTES,
|
||||
},
|
||||
];
|
||||
|
||||
interface MenuPos {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
// Per-conversation row context-menu. Renders via portal so the submenu can
|
||||
// escape the sidebar's `overflow-y-auto` clipping context. Position is
|
||||
// computed from the trigger's bounding rect — menu anchors right-aligned
|
||||
// under the trigger so it doesn't push off-screen on narrow windows.
|
||||
export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
|
||||
const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
|
||||
const [submenuPos, setSubmenuPos] = useState<MenuPos | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const muteItemRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onDocClick(e: MouseEvent) {
|
||||
const target = e.target as Node;
|
||||
if (triggerRef.current?.contains(target)) return;
|
||||
if (menuRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
setSubmenuOpen(null);
|
||||
}
|
||||
function onEsc(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setSubmenuOpen(null);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
document.addEventListener('keydown', onEsc);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocClick);
|
||||
document.removeEventListener('keydown', onEsc);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setMenuPos(null);
|
||||
setSubmenuPos(null);
|
||||
return;
|
||||
}
|
||||
const rect = triggerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
// Anchor: right edge aligns with trigger's right edge, menu hangs below.
|
||||
const menuWidth = 208;
|
||||
setMenuPos({
|
||||
top: rect.bottom + 4,
|
||||
left: Math.max(8, rect.right - menuWidth),
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (submenuOpen !== 'mute') {
|
||||
setSubmenuPos(null);
|
||||
return;
|
||||
}
|
||||
const rect = muteItemRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const submenuWidth = 192;
|
||||
const viewportWidth = window.innerWidth;
|
||||
// Prefer right of the item. Flip to left when it would overflow viewport.
|
||||
const wantLeft = rect.right + 4;
|
||||
const flip = wantLeft + submenuWidth > viewportWidth - 8;
|
||||
setSubmenuPos({
|
||||
top: rect.top,
|
||||
left: flip ? rect.left - submenuWidth - 4 : wantLeft,
|
||||
});
|
||||
}, [submenuOpen]);
|
||||
|
||||
const isMuted =
|
||||
mutedUntil !== null && new Date(mutedUntil).getTime() > Date.now();
|
||||
|
||||
const handleArchive = useCallback(
|
||||
async (next: boolean) => {
|
||||
setOpen(false);
|
||||
try {
|
||||
await setConversationArchived(supabase, conversationId, next);
|
||||
} catch (err: unknown) {
|
||||
console.error('archive toggle failed', err);
|
||||
}
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
const handleMute = useCallback(
|
||||
async (minutes: number | null) => {
|
||||
setOpen(false);
|
||||
setSubmenuOpen(null);
|
||||
try {
|
||||
await setConversationMutedUntil(
|
||||
supabase,
|
||||
conversationId,
|
||||
muteDurationToIso(minutes),
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
console.error('mute toggle failed', err);
|
||||
}
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-label={t('app:chats.row_menu', { defaultValue: 'Aktionen' })}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted opacity-0 transition group-hover:opacity-100 hover:bg-surface-3 hover:text-fg focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<MoreVerticalIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
menuPos &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
style={{ top: menuPos.top, left: menuPos.left }}
|
||||
className="fixed z-50 w-52 rounded-lg border border-line bg-surface-3 p-1 shadow-xl"
|
||||
>
|
||||
<MenuItem
|
||||
icon={<ArchiveIcon className="h-4 w-4" />}
|
||||
label={
|
||||
archived
|
||||
? t('app:chats.unarchive', { defaultValue: 'Entarchivieren' })
|
||||
: t('app:chats.archive', { defaultValue: 'Archivieren' })
|
||||
}
|
||||
onClick={() => void handleArchive(!archived)}
|
||||
/>
|
||||
<MenuItem
|
||||
ref={muteItemRef}
|
||||
icon={
|
||||
isMuted ? (
|
||||
<BellOffIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<BellIcon className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
label={
|
||||
isMuted
|
||||
? t('app:chats.unmute', { defaultValue: 'Stummschaltung aufheben' })
|
||||
: t('app:chats.mute', { defaultValue: 'Stummschalten' })
|
||||
}
|
||||
onClick={() => {
|
||||
if (isMuted) void handleMute(null);
|
||||
else setSubmenuOpen((v) => (v === 'mute' ? null : 'mute'));
|
||||
}}
|
||||
hasSubmenu={!isMuted}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{open &&
|
||||
submenuOpen === 'mute' &&
|
||||
submenuPos &&
|
||||
createPortal(
|
||||
<div
|
||||
role="menu"
|
||||
style={{ top: submenuPos.top, left: submenuPos.left }}
|
||||
className="fixed z-50 w-48 rounded-lg border border-line bg-surface-3 p-1 shadow-xl"
|
||||
>
|
||||
{MUTE_OPTIONS.map((opt) => (
|
||||
<MenuItem
|
||||
key={opt.key}
|
||||
label={t(opt.labelKey, { defaultValue: opt.labelDefault })}
|
||||
onClick={() => void handleMute(opt.minutes)}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuItemProps {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
hasSubmenu?: boolean;
|
||||
}
|
||||
|
||||
// React 18 requires forwardRef for function components to receive refs —
|
||||
// without it the `ref` prop is stripped before reaching the component and
|
||||
// measurement-dependent submenus never position.
|
||||
const MenuItem = forwardRef<HTMLButtonElement, MenuItemProps>(
|
||||
({ icon, label, onClick, hasSubmenu }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{icon && <span className="shrink-0 text-fg-muted">{icon}</span>}
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
{hasSubmenu && <span className="shrink-0 text-xs text-fg-muted">›</span>}
|
||||
</button>
|
||||
),
|
||||
);
|
||||
MenuItem.displayName = 'MenuItem';
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Component, type ErrorInfo, Fragment, type ReactNode } from 'react';
|
||||
|
||||
import { SpinnerIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Optional scope label shown in logs / devtools. Defaults to `root` — set
|
||||
* per boundary (e.g. `route`, `conversation`) so multiple boundaries can be
|
||||
* distinguished at a glance.
|
||||
*/
|
||||
scope?: string;
|
||||
/**
|
||||
* If the retry count exceeds this, the boundary stops auto-retrying and
|
||||
* shows a more helpful message (still without a button — Discord-style,
|
||||
* the app keeps trying but hints the user to hold on or check network).
|
||||
*/
|
||||
maxAutoRetries?: number;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
retryKey: number;
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
const RETRY_DELAYS_MS = [2000, 4000, 8000, 15000, 30000];
|
||||
|
||||
// Discord-style error boundary.
|
||||
// - Catches render-time errors in its subtree.
|
||||
// - Shows a centred spinner + status text. Never renders a manual "Reload"
|
||||
// button; the boundary remounts its children on an exponential-backoff
|
||||
// schedule so the UI self-heals once the underlying issue clears (typical
|
||||
// causes: a realtime reconnect, a transient network blip, or a race that
|
||||
// only fires once).
|
||||
// - Escalates the label after each failed retry so the user sees that the
|
||||
// app is trying, rather than silent infinite spinning.
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null, retryKey: 0, attempt: 0 };
|
||||
|
||||
private retryTimer: number | null = null;
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
return { error };
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
const scope = this.props.scope ?? 'root';
|
||||
// We explicitly log here — the boundary itself swallows the error from
|
||||
// React, so without this the failure would be invisible in production.
|
||||
console.error('[ErrorBoundary:' + scope + '] caught render error', error, info);
|
||||
}
|
||||
|
||||
override componentDidUpdate(_prev: Props, prevState: State): void {
|
||||
if (this.state.error && !prevState.error) {
|
||||
this.scheduleRetry();
|
||||
}
|
||||
}
|
||||
|
||||
override componentWillUnmount(): void {
|
||||
if (this.retryTimer !== null) {
|
||||
window.clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRetry(): void {
|
||||
if (this.retryTimer !== null) return;
|
||||
const attempt = this.state.attempt;
|
||||
const delay =
|
||||
RETRY_DELAYS_MS[Math.min(attempt, RETRY_DELAYS_MS.length - 1)] ??
|
||||
RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1] ??
|
||||
30000;
|
||||
this.retryTimer = window.setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
this.setState((prev) => ({
|
||||
error: null,
|
||||
retryKey: prev.retryKey + 1,
|
||||
attempt: prev.attempt + 1,
|
||||
}));
|
||||
}, delay);
|
||||
}
|
||||
|
||||
override render(): ReactNode {
|
||||
if (this.state.error) {
|
||||
const max = this.props.maxAutoRetries ?? RETRY_DELAYS_MS.length;
|
||||
const escalated = this.state.attempt >= max;
|
||||
return <RetryingScreen escalated={escalated} attempt={this.state.attempt} />;
|
||||
}
|
||||
// `retryKey` forces a remount of the subtree so hooks re-run cleanly after
|
||||
// an error (otherwise stale state from the crashed tree can immediately
|
||||
// re-throw). Use a keyed Fragment so the boundary doesn't inject an extra
|
||||
// wrapper div — that would break `flex h-full` chains (e.g. AppShell →
|
||||
// Outlet → page column).
|
||||
return <Fragment key={this.state.retryKey}>{this.props.children}</Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
function RetryingScreen({ escalated, attempt }: { escalated: boolean; attempt: number }) {
|
||||
const primary = escalated
|
||||
? 'Verbindungsprobleme…'
|
||||
: attempt === 0
|
||||
? 'Einen Moment bitte'
|
||||
: 'Versuche erneut zu laden…';
|
||||
const secondary = escalated
|
||||
? 'Prüfe deine Internetverbindung. Wir versuchen es weiter.'
|
||||
: 'Die App lädt sich gleich selbst neu.';
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex min-h-screen w-full items-center justify-center bg-surface-3 px-6"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="relative flex h-16 w-16 items-center justify-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 rounded-full border-2 border-accent/20"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 rounded-full border-2 border-accent border-r-transparent border-b-transparent animate-spin"
|
||||
/>
|
||||
<SpinnerIcon className="hidden" />
|
||||
</div>
|
||||
<div className="max-w-sm space-y-1.5">
|
||||
<p className="font-display text-lg font-semibold text-fg">{primary}</p>
|
||||
<p className="text-sm text-fg-muted">{secondary}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import {
|
||||
type AttachmentHandle,
|
||||
type DecryptedMessage,
|
||||
downloadAndDecryptAttachment,
|
||||
encryptAndUploadAttachment,
|
||||
insertAttachmentRow,
|
||||
parseMessagePayload,
|
||||
sendEncryptedMessage,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { bytesToPgHex } from '@chat-app/shared/supabase';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { Avatar } from './Avatar';
|
||||
import { ForwardIcon, SpinnerIcon, UsersIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
message: DecryptedMessage | null;
|
||||
currentConversationId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Forwards a message's plaintext to one or more conversations. Attachments are
|
||||
// NOT carried over yet (would require re-uploading + re-encrypting under the
|
||||
// new conversation key); only the text payload is forwarded for now and the
|
||||
// preview hints at the dropped attachment.
|
||||
export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) {
|
||||
const { t } = useTranslation(['app', 'errors']);
|
||||
const { session, device } = useAuth();
|
||||
const { conversations } = useConversationsContext();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelected(new Set());
|
||||
setError(null);
|
||||
setDone(false);
|
||||
}, [open, message?.id]);
|
||||
|
||||
const targets = useMemo(() => {
|
||||
return conversations
|
||||
.filter((c) => c.id !== currentConversationId && c.acceptedByMe)
|
||||
.sort((a, b) => {
|
||||
const ta = a.lastMessageAt ?? a.createdAt;
|
||||
const tb = b.lastMessageAt ?? b.createdAt;
|
||||
return tb.localeCompare(ta);
|
||||
});
|
||||
}, [conversations, currentConversationId]);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
if (!message?.plaintext) return '';
|
||||
const p = parseMessagePayload(message.plaintext);
|
||||
if (p.kind !== 'text') return '';
|
||||
return p.text.length > 140 ? p.text.slice(0, 140) + '…' : p.text;
|
||||
}, [message]);
|
||||
|
||||
const sourceAttachments = useMemo<AttachmentHandle[]>(() => {
|
||||
if (!message?.plaintext) return [];
|
||||
const p = parseMessagePayload(message.plaintext);
|
||||
return p.kind === 'text' ? p.attachments : [];
|
||||
}, [message]);
|
||||
|
||||
if (!open || !message) return null;
|
||||
|
||||
function toggle(id: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!session?.user.id || !device?.id || !message) return;
|
||||
if (selected.size === 0) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
|
||||
if (!priv) throw new Error('private key not loaded');
|
||||
|
||||
const hasAttachments = sourceAttachments.length > 0;
|
||||
const text = preview || (hasAttachments ? '' : '');
|
||||
if (!text && !hasAttachments) throw new Error('nothing to forward');
|
||||
|
||||
// Download+decrypt source attachments ONCE (same plaintext goes to every
|
||||
// target). For each target conv we re-encrypt under fresh per-attachment
|
||||
// keys and re-upload under the target conv's storage folder — source and
|
||||
// target conv-keys differ, so the bytes must actually move.
|
||||
const decryptedBlobs: { mime: string; size: number; width?: number; height?: number; blob: Blob }[] =
|
||||
[];
|
||||
for (const h of sourceAttachments) {
|
||||
const blob = await downloadAndDecryptAttachment({ client: supabase, handle: h });
|
||||
const entry: {
|
||||
mime: string;
|
||||
size: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
blob: Blob;
|
||||
} = { mime: h.mimeType, size: h.sizeBytes, blob };
|
||||
if (h.width !== undefined) entry.width = h.width;
|
||||
if (h.height !== undefined) entry.height = h.height;
|
||||
decryptedBlobs.push(entry);
|
||||
}
|
||||
|
||||
for (const convId of selected) {
|
||||
const newHandles: AttachmentHandle[] = [];
|
||||
const blobNonceHex = new Map<string, string>();
|
||||
for (const src of decryptedBlobs) {
|
||||
const res = await encryptAndUploadAttachment({
|
||||
client: supabase,
|
||||
conversationId: convId,
|
||||
file: src.blob,
|
||||
mimeType: src.mime,
|
||||
sizeBytes: src.size,
|
||||
...(src.width !== undefined ? { width: src.width } : {}),
|
||||
...(src.height !== undefined ? { height: src.height } : {}),
|
||||
});
|
||||
newHandles.push(res.handle);
|
||||
blobNonceHex.set(res.handle.id, bytesToPgHex(res.nonce));
|
||||
}
|
||||
|
||||
const msg = await sendEncryptedMessage({
|
||||
client: supabase,
|
||||
conversationId: convId,
|
||||
plaintext: text,
|
||||
senderUserId: session.user.id,
|
||||
senderDeviceId: device.id,
|
||||
senderPrivateKey: priv,
|
||||
...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}),
|
||||
});
|
||||
|
||||
for (const h of newHandles) {
|
||||
const bn = blobNonceHex.get(h.id) ?? '\\x';
|
||||
await insertAttachmentRow(supabase, msg.id, h, bn);
|
||||
}
|
||||
}
|
||||
setDone(true);
|
||||
window.setTimeout(onClose, 700);
|
||||
} catch (err: unknown) {
|
||||
const code = extractErrorCode(err);
|
||||
setError(
|
||||
code
|
||||
? t('errors:' + code, { defaultValue: t('errors:generic') })
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: t('errors:generic'),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ForwardIcon className="h-4 w-4 text-accent" />
|
||||
<h3 className="font-display text-sm font-semibold text-fg">
|
||||
{t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="border-b border-line bg-surface-2 px-5 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t('app:chats.forward_preview', { defaultValue: 'Vorschau' })}
|
||||
</p>
|
||||
<p className="mt-1 line-clamp-3 break-words text-sm text-fg">
|
||||
{preview || (sourceAttachments.length > 0 ? '📎' : '…')}
|
||||
</p>
|
||||
{sourceAttachments.length > 0 && (
|
||||
<p className="mt-1 text-[11px] text-fg-muted">
|
||||
📎{' '}
|
||||
{t('app:chats.forward_attachments_count', {
|
||||
count: sourceAttachments.length,
|
||||
defaultValue: '{{count}} Anhang wird mit weitergeleitet',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{targets.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-sm text-fg-muted">
|
||||
{t('app:chats.forward_no_targets', {
|
||||
defaultValue: 'Keine anderen Unterhaltungen verfügbar.',
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{targets.map((c) => {
|
||||
const isGroup = c.type === 'group';
|
||||
const title = isGroup ? c.name ?? '?' : c.peer?.displayName ?? '?';
|
||||
const avatarUrl = isGroup ? c.avatarUrl ?? null : c.peer?.avatarUrl ?? null;
|
||||
const checked = selected.has(c.id);
|
||||
return (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(c.id)}
|
||||
className={
|
||||
'flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(checked ? 'bg-accent/15' : 'hover:bg-surface-2')
|
||||
}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<Avatar
|
||||
url={avatarUrl}
|
||||
displayName={title}
|
||||
className="h-9 w-9 text-sm"
|
||||
/>
|
||||
) : isGroup ? (
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-accent">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
</div>
|
||||
) : (
|
||||
<Avatar
|
||||
url={null}
|
||||
displayName={title}
|
||||
className="h-9 w-9 text-sm"
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-fg">
|
||||
{title}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center rounded border ' +
|
||||
(checked ? 'border-accent bg-accent text-accent-fg' : 'border-line bg-surface-2')
|
||||
}
|
||||
>
|
||||
{checked && (
|
||||
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="border-t border-rose-500/30 bg-rose-500/10 px-5 py-2 text-xs text-rose-700 dark:text-rose-200"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
|
||||
>
|
||||
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || selected.size === 0 || done}
|
||||
onClick={() => void handleSend()}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||
<span>
|
||||
{done
|
||||
? t('app:chats.forward_done', { defaultValue: 'Gesendet' })
|
||||
: t('app:chats.forward_send', {
|
||||
count: selected.size,
|
||||
defaultValue: 'An {{count}} senden',
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,11 +15,19 @@ import { supabase } from '../lib/supabase';
|
||||
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
||||
import { AttachmentImage } from './AttachmentImage';
|
||||
import { Avatar } from './Avatar';
|
||||
import { PencilIcon, PhoneIcon, PhoneOffIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
|
||||
import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
|
||||
|
||||
const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏'];
|
||||
const EDIT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface QuotedRef {
|
||||
id: string;
|
||||
senderName: string;
|
||||
snippet: string;
|
||||
isAttachment: boolean;
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
message: DecryptedMessage;
|
||||
mine: boolean;
|
||||
@@ -32,6 +40,16 @@ interface Props {
|
||||
reactions: AggregatedReaction[];
|
||||
onToggleReaction: (emoji: string) => Promise<void>;
|
||||
showSeen?: boolean;
|
||||
/** Resolved quoted message info (parent does the lookup). */
|
||||
quoted?: QuotedRef | null;
|
||||
/** Tap-to-jump on quote bubble. Receives the quoted message's id. */
|
||||
onJumpToMessage?: (id: string) => void;
|
||||
/** Hover action: parent receives current message to start a reply. */
|
||||
onReply?: (m: DecryptedMessage) => void;
|
||||
/** Hover action: parent opens forward dialog for current message. */
|
||||
onForward?: (m: DecryptedMessage) => void;
|
||||
/** Highlighted state — set briefly after a jump. */
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
@@ -45,6 +63,11 @@ export function MessageBubble({
|
||||
reactions,
|
||||
onToggleReaction,
|
||||
showSeen = false,
|
||||
quoted = null,
|
||||
onJumpToMessage,
|
||||
onReply,
|
||||
onForward,
|
||||
highlighted = false,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session, device } = useAuth();
|
||||
@@ -228,13 +251,46 @@ export function MessageBubble({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-message-id={message.id}
|
||||
className={
|
||||
'break-words px-3.5 py-2 text-sm ' +
|
||||
'break-words px-3.5 py-2 text-sm transition ' +
|
||||
(highlighted ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-surface-3 ' : '') +
|
||||
(mine
|
||||
? 'rounded-[18px_18px_4px_18px] bg-accent text-accent-fg'
|
||||
: 'rounded-[18px_18px_18px_4px] border border-line text-fg')
|
||||
}
|
||||
>
|
||||
{quoted && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJumpToMessage?.(quoted.id)}
|
||||
className={
|
||||
'mb-1.5 flex w-full cursor-pointer items-stretch gap-2 rounded-md px-2 py-1.5 text-left text-xs transition hover:opacity-90 ' +
|
||||
(mine
|
||||
? 'bg-white/15 text-accent-fg/90'
|
||||
: 'bg-surface-2 text-fg-muted')
|
||||
}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
'w-0.5 shrink-0 rounded-full ' + (mine ? 'bg-white/50' : 'bg-accent')
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={'block truncate font-semibold ' + (mine ? '' : 'text-fg')}>
|
||||
{quoted.senderName}
|
||||
</span>
|
||||
<span className="block truncate italic opacity-90">
|
||||
{quoted.deleted
|
||||
? t('app:chats.deleted')
|
||||
: quoted.isAttachment && !quoted.snippet
|
||||
? '📎 ' + t('app:chats.attachment', { defaultValue: 'Anhang' })
|
||||
: quoted.snippet}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{message.plaintext === null ? (
|
||||
<span className="italic opacity-70">…cannot decrypt</span>
|
||||
) : (
|
||||
@@ -299,6 +355,20 @@ export function MessageBubble({
|
||||
onClick={() => setPickerOpen((v) => !v)}
|
||||
icon={<SmileIcon className="h-4 w-4" />}
|
||||
/>
|
||||
{onReply && !message.deletedAt && (
|
||||
<ActionButton
|
||||
label={t('app:chats.reply', { defaultValue: 'Antworten' })}
|
||||
onClick={() => onReply(message)}
|
||||
icon={<ReplyIcon className="h-4 w-4" />}
|
||||
/>
|
||||
)}
|
||||
{onForward && !message.deletedAt && (
|
||||
<ActionButton
|
||||
label={t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
|
||||
onClick={() => onForward(message)}
|
||||
icon={<ForwardIcon className="h-4 w-4" />}
|
||||
/>
|
||||
)}
|
||||
{canEdit && (
|
||||
<ActionButton
|
||||
label="Edit"
|
||||
|
||||
@@ -409,6 +409,74 @@ export function SendIcon(props: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ArchiveIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="4" width="18" height="5" rx="1" />
|
||||
<path d="M5 9v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9" />
|
||||
<path d="M10 13h4" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function BellIcon(props: IconProps) {
|
||||
return (
|
||||
<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="M9.5 18a2.5 2.5 0 0 0 5 0" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function BellOffIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M9.5 18a2.5 2.5 0 0 0 5 0" />
|
||||
<path d="M13.73 4A2 2 0 0 0 10 4" />
|
||||
<path d="M18 8c0 1-.1 1.9-.2 2.7" />
|
||||
<path d="M4 4 20 20" />
|
||||
<path d="M6 8a6 6 0 0 1 .2-1.7" />
|
||||
<path d="M4 18h13.5L18 17.4c.5-1 2-2 2-6" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoreVerticalIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<circle cx="12" cy="5" r="1.5" />
|
||||
<circle cx="12" cy="12" r="1.5" />
|
||||
<circle cx="12" cy="19" r="1.5" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReplyIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<polyline points="9 17 4 12 9 7" />
|
||||
<path d="M20 18v-2a4 4 0 0 0-4-4H4" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForwardIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<polyline points="15 17 20 12 15 7" />
|
||||
<path d="M4 18v-2a4 4 0 0 1 4-4h12" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronUpIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddUserIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
|
||||
@@ -54,30 +54,34 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
(async () => {
|
||||
const { data: sessionRes } = await supabase.auth.getSession();
|
||||
if (cancelled) return;
|
||||
if (sessionRes.session) {
|
||||
const { error } = await supabase.auth.getUser();
|
||||
if (cancelled) return;
|
||||
if (error) {
|
||||
const status = (error as { status?: number }).status;
|
||||
if (status === 401 || status === 403) {
|
||||
// Token genuinely invalid — wipe.
|
||||
await supabase.auth.signOut({ scope: 'local' }).catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
setSession(null);
|
||||
} else {
|
||||
// Network / server unreachable — keep cached session, let reads
|
||||
// fail gracefully and recover when the stack is back.
|
||||
console.warn('auth.getUser failed, keeping cached session:', error);
|
||||
setSession(sessionRes.session);
|
||||
}
|
||||
} else {
|
||||
setSession(sessionRes.session);
|
||||
}
|
||||
} else {
|
||||
setSession(null);
|
||||
}
|
||||
// Flip `ready` immediately on cached session read so the UI unblocks even
|
||||
// if the network is slow/down. Validate the token in the background and
|
||||
// only wipe on an unambiguous 401/403 — a stalled getUser (Tauri WebView
|
||||
// with no network, server unreachable) must not keep the app on the
|
||||
// loading spinner forever.
|
||||
setSession(sessionRes.session ?? null);
|
||||
setReady(true);
|
||||
|
||||
if (sessionRes.session) {
|
||||
supabase.auth
|
||||
.getUser()
|
||||
.then(({ error }) => {
|
||||
if (cancelled || !error) return;
|
||||
const status = (error as { status?: number }).status;
|
||||
if (status === 401 || status === 403) {
|
||||
void supabase.auth.signOut({ scope: 'local' }).catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
setSession(null);
|
||||
} else {
|
||||
// Network / server unreachable — keep cached session.
|
||||
console.warn('auth.getUser failed, keeping cached session:', error);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.warn('auth.getUser rejected, keeping cached session:', err);
|
||||
});
|
||||
}
|
||||
})();
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_event, s) => {
|
||||
setSession(s);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { type ConversationSummary, listConversations } from '@chat-app/shared/chat';
|
||||
import {
|
||||
type ConversationSummary,
|
||||
isConversationMuted,
|
||||
listConversations,
|
||||
} from '@chat-app/shared/chat';
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
@@ -177,10 +181,15 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
...prev,
|
||||
[row.conversation_id]: (prev[row.conversation_id] ?? 0) + 1,
|
||||
}));
|
||||
// Notification sound + OS notification — respect DND. Body stays
|
||||
// empty because message content is E2E-encrypted and only
|
||||
// decryptable in the conversation view (not at this hook level).
|
||||
if (presenceRef.current !== 'dnd') {
|
||||
// Notification sound + OS notification — respect DND and
|
||||
// per-conversation mute. Body stays empty because message
|
||||
// content is E2E-encrypted and only decryptable in the
|
||||
// conversation view (not at this hook level).
|
||||
const convForMute = conversationsRef.current.find(
|
||||
(c) => c.id === row.conversation_id,
|
||||
);
|
||||
const muted = isConversationMuted(convForMute?.mutedUntil ?? null);
|
||||
if (presenceRef.current !== 'dnd' && !muted) {
|
||||
playNotificationTone();
|
||||
const conv = conversationsRef.current.find(
|
||||
(c) => c.id === row.conversation_id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CryptoBackend } from '@chat-app/shared/crypto';
|
||||
import _sodium from 'libsodium-wrappers';
|
||||
import _sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
// Build the libsodium-backed CryptoBackend. Awaits the WASM ready-gate once,
|
||||
// then returns a synchronous implementation of the CryptoBackend contract.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getCryptoBackend } from '@chat-app/shared/crypto';
|
||||
import sodium from 'libsodium-wrappers';
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
// Encrypts/decrypts the device private key with a user-provided passphrase
|
||||
// so the backup string can be safely written down or stored in a password
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { SecretStore } from '@chat-app/shared/auth';
|
||||
import { exists, mkdir, readFile, rename, writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { appLocalDataDir } from '@tauri-apps/api/path';
|
||||
import sodium from 'libsodium-wrappers';
|
||||
// sumo variant ships crypto_pwhash (Argon2id). Standard `libsodium-wrappers`
|
||||
// is the compact build without Argon2 — vault KDF would error otherwise.
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
// Encrypted single-file SecretStore for Tauri. Replaces the flaky
|
||||
// tauri-plugin-stronghold implementation.
|
||||
@@ -93,10 +95,13 @@ async function loadOrCreateVault(userId: string): Promise<VaultState> {
|
||||
const path = joinPath(dir, fileName);
|
||||
const tmpPath = path + '.tmp';
|
||||
|
||||
try {
|
||||
// First-run: AppLocalData dir may not exist yet. `mkdir(recursive)` is
|
||||
// idempotent on macOS/Linux, but we need to surface genuine permission
|
||||
// errors (silent catch masked a previous bug where the dir was never
|
||||
// created and every subsequent writeFile failed with ENOENT).
|
||||
const dirExists = await exists(dir).catch(() => false);
|
||||
if (!dirExists) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
} catch {
|
||||
/* parent likely already exists */
|
||||
}
|
||||
|
||||
const fileExists = await exists(path).catch(() => false);
|
||||
|
||||
@@ -51,7 +51,7 @@ function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
|
||||
}
|
||||
|
||||
export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & {
|
||||
send: (text: string, images?: File[]) => Promise<void>;
|
||||
send: (text: string, images?: File[], replyToId?: string | null) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
} {
|
||||
const [state, setState] = useState<State>({ messages: [], loading: true, error: null });
|
||||
@@ -262,7 +262,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
}, [conversationId, userId, deviceId, refresh, handleInsert, handleUpdate, handleDelete]);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string, images: File[] = []) => {
|
||||
async (text: string, images: File[] = [], replyToId: string | null = null) => {
|
||||
const trimmed = text.trim();
|
||||
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
|
||||
const priv = privateKeyRef.current;
|
||||
@@ -299,6 +299,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
senderDeviceId: deviceId,
|
||||
senderPrivateKey: priv,
|
||||
...(handles.length > 0 ? { attachmentHandles: handles } : {}),
|
||||
...(replyToId ? { replyToId } : {}),
|
||||
});
|
||||
|
||||
// 3. Optimistic insert — we already have the plaintext in hand and the
|
||||
|
||||
@@ -65,7 +65,7 @@ export function AdminPage() {
|
||||
}, [refresh]);
|
||||
|
||||
return (
|
||||
<div className="h-full bg-surface-3 text-fg">
|
||||
<div className="min-h-full bg-surface-3 text-fg">
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8">
|
||||
<header>
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { acceptDm, type ConversationSummary } from '@chat-app/shared/chat';
|
||||
import {
|
||||
acceptDm,
|
||||
type ConversationSummary,
|
||||
isConversationMuted,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, Outlet, useParams } from 'react-router-dom';
|
||||
|
||||
import { ConversationRowMenu } from '../components/ConversationRowMenu';
|
||||
import { CreateGroupDialog } from '../components/CreateGroupDialog';
|
||||
import {
|
||||
AddUserIcon,
|
||||
ArchiveIcon,
|
||||
BellOffIcon,
|
||||
ChatBubbleIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
@@ -21,6 +28,7 @@ export function ChatsPage() {
|
||||
const { id: activeId } = useParams<{ id: string }>();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...conversations].sort((a, b) => {
|
||||
@@ -30,7 +38,7 @@ export function ChatsPage() {
|
||||
});
|
||||
}, [conversations]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const queryFiltered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return sorted;
|
||||
return sorted.filter((c) => {
|
||||
@@ -40,10 +48,24 @@ export function ChatsPage() {
|
||||
});
|
||||
}, [sorted, query]);
|
||||
|
||||
// Split into active vs archived — the user toggles which bucket shows in the
|
||||
// main list. Archived conversations with unread messages still surface so
|
||||
// the user can't accidentally silence an ongoing conversation permanently.
|
||||
const activeItems = useMemo(
|
||||
() => queryFiltered.filter((c) => !c.archived),
|
||||
[queryFiltered],
|
||||
);
|
||||
const archivedItems = useMemo(
|
||||
() => queryFiltered.filter((c) => c.archived),
|
||||
[queryFiltered],
|
||||
);
|
||||
|
||||
const archivedUnread = archivedItems.reduce((s, c) => s + (unread[c.id] ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<ConversationList
|
||||
items={filtered}
|
||||
items={showArchived ? archivedItems : activeItems}
|
||||
loading={loading}
|
||||
error={error}
|
||||
activeId={activeId}
|
||||
@@ -60,6 +82,9 @@ export function ChatsPage() {
|
||||
}
|
||||
}}
|
||||
onNewGroup={() => setCreateOpen(true)}
|
||||
showArchived={showArchived}
|
||||
onToggleArchived={() => setShowArchived((v) => !v)}
|
||||
archivedUnread={archivedUnread}
|
||||
/>
|
||||
<div className="flex-1 border-l border-line bg-surface-3">
|
||||
<Outlet />
|
||||
@@ -79,6 +104,9 @@ interface ConversationListProps {
|
||||
onQueryChange: (q: string) => void;
|
||||
onAccept: (id: string) => void;
|
||||
onNewGroup: () => void;
|
||||
showArchived: boolean;
|
||||
onToggleArchived: () => void;
|
||||
archivedUnread: number;
|
||||
}
|
||||
|
||||
function ConversationList({
|
||||
@@ -91,6 +119,9 @@ function ConversationList({
|
||||
onQueryChange,
|
||||
onAccept,
|
||||
onNewGroup,
|
||||
showArchived,
|
||||
onToggleArchived,
|
||||
archivedUnread,
|
||||
}: ConversationListProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
|
||||
@@ -101,17 +132,53 @@ function ConversationList({
|
||||
>
|
||||
<header className="flex items-center justify-between px-4 pb-3 pt-5">
|
||||
<h2 className="font-display text-base font-semibold tracking-tight text-fg">
|
||||
{t('app:nav.chats')}
|
||||
{showArchived
|
||||
? t('app:chats.archived_title', { defaultValue: 'Archiv' })
|
||||
: t('app:nav.chats')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewGroup}
|
||||
aria-label={t('app:chats.new_group')}
|
||||
title={t('app:chats.new_group')}
|
||||
className="flex h-8 w-8 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/40"
|
||||
>
|
||||
<AddUserIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleArchived}
|
||||
aria-label={
|
||||
showArchived
|
||||
? t('app:chats.show_active', { defaultValue: 'Aktive anzeigen' })
|
||||
: t('app:chats.show_archived', { defaultValue: 'Archiv anzeigen' })
|
||||
}
|
||||
title={
|
||||
showArchived
|
||||
? t('app:chats.show_active', { defaultValue: 'Aktive anzeigen' })
|
||||
: t('app:chats.show_archived', { defaultValue: 'Archiv anzeigen' })
|
||||
}
|
||||
className={
|
||||
'relative flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(showArchived
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-fg-muted hover:bg-surface-3 hover:text-fg')
|
||||
}
|
||||
>
|
||||
<ArchiveIcon className="h-4 w-4" />
|
||||
{!showArchived && archivedUnread > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-0.5 -top-0.5 flex min-w-[16px] items-center justify-center rounded-full bg-accent px-1 text-[9px] font-bold leading-tight text-accent-fg"
|
||||
>
|
||||
{archivedUnread > 9 ? '9+' : archivedUnread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{!showArchived && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewGroup}
|
||||
aria-label={t('app:chats.new_group')}
|
||||
title={t('app:chats.new_group')}
|
||||
className="flex h-8 w-8 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/40"
|
||||
>
|
||||
<AddUserIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="px-3 pb-2">
|
||||
@@ -140,10 +207,24 @@ function ConversationList({
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-line bg-surface-3 text-accent">
|
||||
<ChatBubbleIcon className="h-5 w-5" />
|
||||
{showArchived ? (
|
||||
<ArchiveIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<ChatBubbleIcon className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-fg-muted">{t('app:chats.empty_title')}</p>
|
||||
<p className="text-xs text-fg-muted/80">{t('app:chats.empty_subtitle')}</p>
|
||||
<p className="text-sm text-fg-muted">
|
||||
{showArchived
|
||||
? t('app:chats.archived_empty_title', { defaultValue: 'Nichts archiviert' })
|
||||
: t('app:chats.empty_title')}
|
||||
</p>
|
||||
<p className="text-xs text-fg-muted/80">
|
||||
{showArchived
|
||||
? t('app:chats.archived_empty_subtitle', {
|
||||
defaultValue: 'Archivierte Unterhaltungen erscheinen hier.',
|
||||
})
|
||||
: t('app:chats.empty_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex-1 overflow-y-auto px-2 pb-2">
|
||||
@@ -213,11 +294,12 @@ function ConversationRow({
|
||||
);
|
||||
}
|
||||
|
||||
const muted = isConversationMuted(item.mutedUntil);
|
||||
return (
|
||||
<NavLink
|
||||
to={'/chats/' + item.id}
|
||||
className={
|
||||
'my-1 flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
'group relative my-1 flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(active
|
||||
? 'bg-accent/15 text-fg'
|
||||
: 'text-fg hover:bg-surface-3/70')
|
||||
@@ -225,24 +307,42 @@ function ConversationRow({
|
||||
>
|
||||
<ConvAvatar url={avatarUrl} title={title} isGroup={item.type === 'group'} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={
|
||||
'truncate text-sm ' +
|
||||
(unreadCount > 0 ? 'font-bold' : 'font-semibold')
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p
|
||||
className={
|
||||
'truncate text-sm ' +
|
||||
(unreadCount > 0 && !muted ? 'font-bold' : 'font-semibold')
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
{muted && (
|
||||
<BellOffIcon
|
||||
aria-hidden="true"
|
||||
className="h-3 w-3 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-fg-muted">{preview}</p>
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
aria-label={'Unread: ' + unreadCount}
|
||||
className="inline-flex min-w-[20px] items-center justify-center rounded-full bg-accent px-1.5 text-[10px] font-bold leading-tight text-accent-fg"
|
||||
className={
|
||||
'inline-flex min-w-[20px] items-center justify-center rounded-full px-1.5 text-[10px] font-bold leading-tight ' +
|
||||
(muted
|
||||
? 'bg-fg-muted/30 text-fg-muted'
|
||||
: 'bg-accent text-accent-fg')
|
||||
}
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
<ConversationRowMenu
|
||||
conversationId={item.id}
|
||||
archived={item.archived}
|
||||
mutedUntil={item.mutedUntil}
|
||||
/>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,24 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { ConversationHeader } from '../components/ConversationHeader';
|
||||
import { ForwardDialog } from '../components/ForwardDialog';
|
||||
import { GroupInfoPanel } from '../components/GroupInfoPanel';
|
||||
import { AlertIcon, ArrowRightIcon, PlusIcon, SpinnerIcon, XIcon } from '../components/icons';
|
||||
import {
|
||||
AlertIcon,
|
||||
ArrowRightIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
PlusIcon,
|
||||
ReplyIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '../components/icons';
|
||||
import { InCallPanel } from '../components/InCallPanel';
|
||||
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
||||
import { MessageBubble } from '../components/MessageBubble';
|
||||
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
|
||||
import { TypingIndicator } from '../components/TypingIndicator';
|
||||
import type { DecryptedMessage } from '@chat-app/shared/chat';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
@@ -83,8 +95,114 @@ export function ConversationPage() {
|
||||
const [stickToBottom, setStickToBottom] = useState(true);
|
||||
const [attachments, setAttachments] = useState<File[]>([]);
|
||||
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
|
||||
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
|
||||
const [forwardTarget, setForwardTarget] = useState<DecryptedMessage | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchIdx, setSearchIdx] = useState(0);
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Drop reply-to / clear search state when switching conversation.
|
||||
useEffect(() => {
|
||||
setReplyTo(null);
|
||||
setForwardTarget(null);
|
||||
setSearchOpen(false);
|
||||
setSearchQuery('');
|
||||
}, [id]);
|
||||
|
||||
const messageById = useMemo(() => {
|
||||
const m = new Map<string, DecryptedMessage>();
|
||||
for (const msg of messages) m.set(msg.id, msg);
|
||||
return m;
|
||||
}, [messages]);
|
||||
|
||||
const senderNameFor = useCallback(
|
||||
(senderId: string): string => {
|
||||
if (senderId === myId) return t('app:chats.you', { defaultValue: 'Du' });
|
||||
const profile =
|
||||
conversation?.members.find((mm) => mm.userId === senderId)?.profile ??
|
||||
(senderId !== myId ? conversation?.peer ?? null : null);
|
||||
return profile?.displayName ?? '?';
|
||||
},
|
||||
[conversation, myId, t],
|
||||
);
|
||||
|
||||
const buildQuoted = useCallback(
|
||||
(replyToId: string | null): QuotedRef | null => {
|
||||
if (!replyToId) return null;
|
||||
const target = messageById.get(replyToId);
|
||||
if (!target) {
|
||||
return {
|
||||
id: replyToId,
|
||||
senderName: '…',
|
||||
snippet: t('app:chats.quote_unavailable', { defaultValue: 'Nachricht nicht verfügbar' }),
|
||||
isAttachment: false,
|
||||
deleted: true,
|
||||
};
|
||||
}
|
||||
const parsed = parseMessagePayload(target.plaintext);
|
||||
const text = parsed.kind === 'text' ? parsed.text : '';
|
||||
const hasAttachment = parsed.kind === 'text' && parsed.attachments.length > 0;
|
||||
return {
|
||||
id: target.id,
|
||||
senderName: senderNameFor(target.senderId),
|
||||
snippet: text.length > 120 ? text.slice(0, 120) + '…' : text,
|
||||
isAttachment: hasAttachment,
|
||||
deleted: !!target.deletedAt,
|
||||
};
|
||||
},
|
||||
[messageById, senderNameFor, t],
|
||||
);
|
||||
|
||||
const jumpToMessage = useCallback((targetId: string) => {
|
||||
const el = scrollRef.current?.querySelector<HTMLElement>(
|
||||
'[data-message-id="' + CSS.escape(targetId) + '"]',
|
||||
);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
setHighlightedId(targetId);
|
||||
window.setTimeout(() => setHighlightedId((cur) => (cur === targetId ? null : cur)), 1600);
|
||||
}, []);
|
||||
|
||||
const handleReply = useCallback((m: DecryptedMessage) => {
|
||||
setReplyTo(m);
|
||||
composerRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleForward = useCallback((m: DecryptedMessage) => {
|
||||
setForwardTarget(m);
|
||||
}, []);
|
||||
|
||||
// Search matches: messages whose decrypted text includes the query.
|
||||
const searchMatches = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) return [] as DecryptedMessage[];
|
||||
return messages.filter((m) => {
|
||||
if (!m.plaintext) return false;
|
||||
const parsed = parseMessagePayload(m.plaintext);
|
||||
if (parsed.kind !== 'text') return false;
|
||||
return parsed.text.toLowerCase().includes(q);
|
||||
});
|
||||
}, [messages, searchQuery]);
|
||||
|
||||
// Reset/clamp the active match index when the match set changes.
|
||||
useEffect(() => {
|
||||
if (searchMatches.length === 0) {
|
||||
setSearchIdx(0);
|
||||
return;
|
||||
}
|
||||
setSearchIdx((cur) => Math.min(cur, searchMatches.length - 1));
|
||||
}, [searchMatches.length]);
|
||||
|
||||
// Auto-jump to current match.
|
||||
useEffect(() => {
|
||||
if (!searchOpen || searchMatches.length === 0) return;
|
||||
const target = searchMatches[searchIdx];
|
||||
if (target) jumpToMessage(target.id);
|
||||
}, [searchOpen, searchMatches, searchIdx, jumpToMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -123,9 +241,10 @@ export function ConversationPage() {
|
||||
setSending(true);
|
||||
setSendError(null);
|
||||
try {
|
||||
await send(text, attachments);
|
||||
await send(text, attachments, replyTo?.id ?? null);
|
||||
setText('');
|
||||
setAttachments([]);
|
||||
setReplyTo(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
setStickToBottom(true);
|
||||
notifyStopTyping();
|
||||
@@ -178,10 +297,32 @@ export function ConversationPage() {
|
||||
<ConversationHeader
|
||||
conversation={conversation}
|
||||
peerPresence={peerPresence}
|
||||
onSearchClick={() => setSearchOpen((v) => !v)}
|
||||
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!callHereActive && searchOpen && (
|
||||
<SearchBar
|
||||
query={searchQuery}
|
||||
onQueryChange={setSearchQuery}
|
||||
matches={searchMatches.length}
|
||||
activeIdx={searchIdx}
|
||||
onPrev={() =>
|
||||
setSearchIdx((cur) =>
|
||||
searchMatches.length === 0 ? 0 : (cur - 1 + searchMatches.length) % searchMatches.length,
|
||||
)
|
||||
}
|
||||
onNext={() =>
|
||||
setSearchIdx((cur) => (searchMatches.length === 0 ? 0 : (cur + 1) % searchMatches.length))
|
||||
}
|
||||
onClose={() => {
|
||||
setSearchOpen(false);
|
||||
setSearchQuery('');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGroup && conversation && (
|
||||
<GroupInfoPanel
|
||||
open={infoPanelOpen}
|
||||
@@ -205,19 +346,24 @@ export function ConversationPage() {
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{messages.map((m, idx) => {
|
||||
// Skip call_event messages when computing run boundaries — they
|
||||
// render as centred separators, not chat bubbles, so they
|
||||
// shouldn't count toward sender continuity. Without this,
|
||||
// a real bubble followed by a call event from the same sender
|
||||
// would be treated as "in the middle of a run" and lose its
|
||||
// avatar.
|
||||
const prev = findAdjacent(messages, idx, -1);
|
||||
const next = findAdjacent(messages, idx, +1);
|
||||
const grouped = !!prev && prev.senderId === m.senderId;
|
||||
// A "run" is consecutive bubbles from the same sender with
|
||||
// nothing between them. Call-event separators break the run —
|
||||
// a bubble whose immediate next neighbour is a call_event must
|
||||
// anchor the avatar, even if another bubble from the same
|
||||
// sender appears after the separator.
|
||||
const prevRaw = messages[idx - 1];
|
||||
const nextRaw = messages[idx + 1];
|
||||
const prevIsCallEvent =
|
||||
!!prevRaw && parseMessagePayload(prevRaw.plaintext).kind === 'call_event';
|
||||
const nextIsCallEvent =
|
||||
!!nextRaw && parseMessagePayload(nextRaw.plaintext).kind === 'call_event';
|
||||
const grouped =
|
||||
!!prevRaw && prevRaw.senderId === m.senderId && !prevIsCallEvent;
|
||||
// Anchor avatar on the LAST message of a run so it aligns with
|
||||
// the bubble's tail (bottom corner). Tail is bottom-left for
|
||||
// mine, bottom-right for peer — see rounded-[…_4px_…] above.
|
||||
const isLastOfRun = !next || next.senderId !== m.senderId;
|
||||
const isLastOfRun =
|
||||
!nextRaw || nextRaw.senderId !== m.senderId || nextIsCallEvent;
|
||||
// DM fallback: if member lookup fails (e.g. transient sync), fall
|
||||
// back to conversation.peer so the peer's avatar still resolves.
|
||||
const memberProfile =
|
||||
@@ -238,6 +384,11 @@ export function ConversationPage() {
|
||||
reactions={reactionsByMessage.get(m.id) ?? []}
|
||||
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
|
||||
showSeen={m.id === lastSeenMessageId}
|
||||
quoted={buildQuoted(m.replyToId)}
|
||||
onJumpToMessage={jumpToMessage}
|
||||
onReply={handleReply}
|
||||
onForward={handleForward}
|
||||
highlighted={highlightedId === m.id}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
@@ -258,6 +409,38 @@ export function ConversationPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{replyTo && (
|
||||
<div className="mb-2 flex items-stretch gap-2 rounded-lg border border-line bg-surface-2 p-2 text-sm">
|
||||
<span aria-hidden="true" className="w-1 shrink-0 rounded-full bg-accent" />
|
||||
<ReplyIcon className="mt-0.5 h-4 w-4 shrink-0 text-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-semibold text-fg">
|
||||
{t('app:chats.replying_to', {
|
||||
name: senderNameFor(replyTo.senderId),
|
||||
defaultValue: 'Antwort an ' + senderNameFor(replyTo.senderId),
|
||||
})}
|
||||
</p>
|
||||
<p className="truncate text-xs italic text-fg-muted">
|
||||
{(() => {
|
||||
if (!replyTo.plaintext) return '…';
|
||||
const p = parseMessagePayload(replyTo.plaintext);
|
||||
if (p.kind !== 'text') return '';
|
||||
if (!p.text && p.attachments.length > 0) return '📎';
|
||||
return p.text;
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReplyTo(null)}
|
||||
aria-label={t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
||||
className="shrink-0 cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{attachments.map((file, idx) => (
|
||||
@@ -291,6 +474,7 @@ export function ConversationPage() {
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<textarea
|
||||
ref={composerRef}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
@@ -316,6 +500,81 @@ export function ConversationPage() {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ForwardDialog
|
||||
open={forwardTarget !== null}
|
||||
message={forwardTarget}
|
||||
currentConversationId={id ?? null}
|
||||
onClose={() => setForwardTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SearchBarProps {
|
||||
query: string;
|
||||
onQueryChange: (q: string) => void;
|
||||
matches: number;
|
||||
activeIdx: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function SearchBar({ query, onQueryChange, matches, activeIdx, onPrev, onNext, onClose }: SearchBarProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b border-line bg-surface-2 px-4 py-2">
|
||||
<SearchIcon className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||
<input
|
||||
type="search"
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) onPrev();
|
||||
else onNext();
|
||||
}
|
||||
}}
|
||||
placeholder={t('app:chats.search_in_conv', { defaultValue: 'In Unterhaltung suchen…' })}
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-fg placeholder-fg-muted outline-none"
|
||||
/>
|
||||
<span className="shrink-0 text-xs tabular-nums text-fg-muted">
|
||||
{matches === 0
|
||||
? query.trim().length > 0
|
||||
? t('app:chats.search_none', { defaultValue: 'Keine Treffer' })
|
||||
: ''
|
||||
: activeIdx + 1 + ' / ' + matches}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrev}
|
||||
disabled={matches === 0}
|
||||
aria-label="Previous"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={matches === 0}
|
||||
aria-label="Next"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -324,21 +583,6 @@ export function ConversationPage() {
|
||||
// regular bubble is found or the array boundary is reached. Used to decide
|
||||
// run-grouping for avatar placement so call separators don't bleed into
|
||||
// sender continuity.
|
||||
function findAdjacent<T extends { plaintext: string | null }>(
|
||||
messages: T[],
|
||||
idx: number,
|
||||
step: 1 | -1,
|
||||
): T | undefined {
|
||||
let i = idx + step;
|
||||
while (i >= 0 && i < messages.length) {
|
||||
const m = messages[i];
|
||||
if (!m) return undefined;
|
||||
if (parseMessagePayload(m.plaintext).kind !== 'call_event') return m;
|
||||
i += step;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function Banner({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -107,8 +107,8 @@ export function FriendsPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full bg-surface-3 text-fg">
|
||||
<div className="mx-auto flex h-full max-w-3xl flex-col gap-5 px-6 py-8">
|
||||
<div className="min-h-full bg-surface-3 text-fg">
|
||||
<div className="mx-auto flex min-h-full max-w-3xl flex-col gap-5 px-6 py-8">
|
||||
<header className="flex items-center justify-between gap-4">
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
{t('app:friends.title')}
|
||||
|
||||
@@ -76,7 +76,7 @@ export function SettingsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full bg-surface-3 text-fg">
|
||||
<div className="min-h-full bg-surface-3 text-fg">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||
<header className="mb-2">
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Shim sumo types to the non-sumo types package. sumo is an API superset of
|
||||
// libsodium-wrappers; its runtime exports match the standard wrappers module
|
||||
// and additionally include `crypto_pwhash` (Argon2id). Reuse the existing
|
||||
// `@types/libsodium-wrappers` definitions rather than duplicating them.
|
||||
declare module 'libsodium-wrappers-sumo' {
|
||||
import sodium from 'libsodium-wrappers';
|
||||
export default sodium;
|
||||
export * from 'libsodium-wrappers';
|
||||
}
|
||||
@@ -16,11 +16,11 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
// libsodium-wrappers 0.7.16 ships a broken "import" condition in its
|
||||
// package exports (the ESM bundle references a sibling ./libsodium.mjs
|
||||
// that isn't in the published artefact). Force esbuild to pick the
|
||||
// "require" condition so the self-contained CJS build is used.
|
||||
include: ['libsodium-wrappers'],
|
||||
// libsodium-wrappers-sumo (and the compact variant) ship broken "import"
|
||||
// conditions in package exports — the ESM bundle references a sibling
|
||||
// ./libsodium.mjs that isn't in the published artefact. Force esbuild to
|
||||
// pick the "require" condition so the self-contained CJS build is used.
|
||||
include: ['libsodium-wrappers-sumo'],
|
||||
esbuildOptions: {
|
||||
conditions: ['require', 'node', 'default'],
|
||||
},
|
||||
|
||||
@@ -28,13 +28,21 @@ async function currentUserId(client: AppSupabaseClient): Promise<string> {
|
||||
export async function listConversations(client: AppSupabaseClient): Promise<ConversationSummary[]> {
|
||||
const myId = await currentUserId(client);
|
||||
|
||||
// 1. Caller's memberships
|
||||
// 1. Caller's memberships. `archived` / `muted_until` live on the members
|
||||
// row (see migration 20260420000001). db-types snapshot predates them so
|
||||
// cast the select to bypass typing.
|
||||
const { data: myMembers, error: mErr } = await client
|
||||
.from('conversation_members')
|
||||
.select('conversation_id, role, accepted')
|
||||
.select('conversation_id, role, accepted, archived, muted_until' as '*')
|
||||
.eq('user_id', myId);
|
||||
if (mErr) throw mErr;
|
||||
const myMembersList = myMembers ?? [];
|
||||
const myMembersList = (myMembers ?? []) as unknown as Array<{
|
||||
conversation_id: string;
|
||||
role: string;
|
||||
accepted: boolean;
|
||||
archived: boolean | null;
|
||||
muted_until: string | null;
|
||||
}>;
|
||||
if (myMembersList.length === 0) return [];
|
||||
|
||||
const convIds = myMembersList.map((m) => m.conversation_id);
|
||||
@@ -103,6 +111,9 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
|
||||
c.type === 'dm'
|
||||
? (members.find((m) => m.userId !== myId)?.profile ?? null)
|
||||
: null;
|
||||
const mineRow = mine as
|
||||
| { accepted: boolean; role: string; archived?: boolean; muted_until?: string | null }
|
||||
| undefined;
|
||||
return {
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
@@ -110,10 +121,56 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
|
||||
avatarUrl: c.avatar_url,
|
||||
createdAt: c.created_at,
|
||||
peer,
|
||||
acceptedByMe: mine?.accepted ?? false,
|
||||
myRole: mine?.role ?? 'member',
|
||||
acceptedByMe: mineRow?.accepted ?? false,
|
||||
myRole: (mineRow?.role ?? 'member') as ConversationSummary['myRole'],
|
||||
members,
|
||||
lastMessageAt: lastSeen.get(c.id) ?? null,
|
||||
archived: mineRow?.archived ?? false,
|
||||
mutedUntil: mineRow?.muted_until ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle archive flag on the caller's own conversation_members row.
|
||||
export async function setConversationArchived(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
archived: boolean,
|
||||
): Promise<void> {
|
||||
const myId = await currentUserId(client);
|
||||
const { error } = await client
|
||||
.from('conversation_members')
|
||||
.update({ archived } as never)
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('user_id', myId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// Set mute until a specific ISO timestamp (null clears the mute). A
|
||||
// far-future timestamp is the "muted forever" representation.
|
||||
export async function setConversationMutedUntil(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
until: string | null,
|
||||
): Promise<void> {
|
||||
const myId = await currentUserId(client);
|
||||
const { error } = await client
|
||||
.from('conversation_members')
|
||||
.update({ muted_until: until } as never)
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('user_id', myId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// Convenience: `null` unmutes, number means minutes from now. For "forever"
|
||||
// pass a very large number (e.g. 100 years worth of minutes).
|
||||
export function muteDurationToIso(minutes: number | null): string | null {
|
||||
if (minutes === null) return null;
|
||||
return new Date(Date.now() + minutes * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
// True iff the member is currently muted (mutedUntil present and > now).
|
||||
export function isConversationMuted(mutedUntil: string | null): boolean {
|
||||
if (!mutedUntil) return false;
|
||||
return new Date(mutedUntil).getTime() > Date.now();
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ export interface ConversationSummary {
|
||||
members: ConversationMember[];
|
||||
// Latest message timestamp (server can't see content, only metadata).
|
||||
lastMessageAt: string | null;
|
||||
// Caller's per-member preferences.
|
||||
archived: boolean;
|
||||
// ISO timestamp. null = not muted. Past timestamp = expired mute (treat as
|
||||
// not muted — the server row is kept for history until the next toggle).
|
||||
mutedUntil: string | null;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
|
||||
@@ -33,7 +33,37 @@
|
||||
"call_incoming": "Eingehender Anruf",
|
||||
"call_missed": "Verpasster Anruf",
|
||||
"call_no_answer": "Keine Antwort",
|
||||
"call_declined": "Anruf abgelehnt"
|
||||
"call_declined": "Anruf abgelehnt",
|
||||
"you": "Du",
|
||||
"attachment": "Anhang",
|
||||
"reply": "Antworten",
|
||||
"forward": "Weiterleiten",
|
||||
"replying_to": "Antwort an {{name}}",
|
||||
"quote_unavailable": "Nachricht nicht verfügbar",
|
||||
"search_in_conv": "In Unterhaltung suchen…",
|
||||
"search_none": "Keine Treffer",
|
||||
"forward_preview": "Vorschau",
|
||||
"forward_attachments_dropped": "Anhänge werden nicht mit weitergeleitet.",
|
||||
"forward_no_targets": "Keine anderen Unterhaltungen verfügbar.",
|
||||
"forward_done": "Gesendet",
|
||||
"forward_send": "An {{count}} senden",
|
||||
"forward_attachments_count_one": "{{count}} Anhang wird mit weitergeleitet",
|
||||
"forward_attachments_count_other": "{{count}} Anhänge werden mit weitergeleitet",
|
||||
"archive": "Archivieren",
|
||||
"unarchive": "Entarchivieren",
|
||||
"archived_title": "Archiv",
|
||||
"show_archived": "Archiv anzeigen",
|
||||
"show_active": "Aktive anzeigen",
|
||||
"archived_empty_title": "Nichts archiviert",
|
||||
"archived_empty_subtitle": "Archivierte Unterhaltungen erscheinen hier.",
|
||||
"mute": "Stummschalten",
|
||||
"unmute": "Stummschaltung aufheben",
|
||||
"mute_1h": "1 Stunde",
|
||||
"mute_8h": "8 Stunden",
|
||||
"mute_24h": "24 Stunden",
|
||||
"mute_1w": "1 Woche",
|
||||
"mute_forever": "Bis auf Weiteres",
|
||||
"row_menu": "Aktionen"
|
||||
},
|
||||
"call": {
|
||||
"start_audio": "Sprachanruf",
|
||||
|
||||
@@ -33,7 +33,37 @@
|
||||
"call_incoming": "Incoming call",
|
||||
"call_missed": "Missed call",
|
||||
"call_no_answer": "No answer",
|
||||
"call_declined": "Call declined"
|
||||
"call_declined": "Call declined",
|
||||
"you": "You",
|
||||
"attachment": "Attachment",
|
||||
"reply": "Reply",
|
||||
"forward": "Forward",
|
||||
"replying_to": "Replying to {{name}}",
|
||||
"quote_unavailable": "Message not available",
|
||||
"search_in_conv": "Search in conversation…",
|
||||
"search_none": "No matches",
|
||||
"forward_preview": "Preview",
|
||||
"forward_attachments_dropped": "Attachments are not forwarded.",
|
||||
"forward_no_targets": "No other conversations available.",
|
||||
"forward_done": "Sent",
|
||||
"forward_send": "Send to {{count}}",
|
||||
"forward_attachments_count_one": "{{count}} attachment forwarded",
|
||||
"forward_attachments_count_other": "{{count}} attachments forwarded",
|
||||
"archive": "Archive",
|
||||
"unarchive": "Unarchive",
|
||||
"archived_title": "Archive",
|
||||
"show_archived": "Show archive",
|
||||
"show_active": "Show active",
|
||||
"archived_empty_title": "Nothing archived",
|
||||
"archived_empty_subtitle": "Archived conversations appear here.",
|
||||
"mute": "Mute",
|
||||
"unmute": "Unmute",
|
||||
"mute_1h": "1 hour",
|
||||
"mute_8h": "8 hours",
|
||||
"mute_24h": "24 hours",
|
||||
"mute_1w": "1 week",
|
||||
"mute_forever": "Until further notice",
|
||||
"row_menu": "Actions"
|
||||
},
|
||||
"call": {
|
||||
"start_audio": "Voice call",
|
||||
|
||||
Generated
+24
-1
@@ -86,7 +86,7 @@ importers:
|
||||
i18next:
|
||||
specifier: ^23.16.4
|
||||
version: 23.16.8
|
||||
libsodium-wrappers:
|
||||
libsodium-wrappers-sumo:
|
||||
specifier: 0.7.15
|
||||
version: 0.7.15
|
||||
livekit-client:
|
||||
@@ -114,6 +114,9 @@ importers:
|
||||
'@types/libsodium-wrappers':
|
||||
specifier: ^0.7.14
|
||||
version: 0.7.14
|
||||
'@types/libsodium-wrappers-sumo':
|
||||
specifier: ^0.8.2
|
||||
version: 0.8.2
|
||||
'@types/react':
|
||||
specifier: ^18.3.12
|
||||
version: 18.3.28
|
||||
@@ -1853,6 +1856,10 @@ packages:
|
||||
'@types/json5@0.0.29':
|
||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||
|
||||
'@types/libsodium-wrappers-sumo@0.8.2':
|
||||
resolution: {integrity: sha512-uFOBpg/r21hExVlh2ty8YpDfSR+Yy3Jn8XS4+SSjitbhTxdYq+pBz/49XRxyUFe8SzqujHf/Wu0/O4d+FUtNfQ==}
|
||||
deprecated: This is a stub types definition. libsodium-wrappers-sumo provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/libsodium-wrappers@0.7.14':
|
||||
resolution: {integrity: sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ==}
|
||||
|
||||
@@ -3642,9 +3649,15 @@ packages:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
libsodium-sumo@0.7.16:
|
||||
resolution: {integrity: sha512-x6atrz2AdXCJg6G709x9W9TTJRI6/0NcL5dD0l5GGVqNE48UJmDsjO4RUWYTeyXXUpg+NXZ2SHECaZnFRYzwGA==}
|
||||
|
||||
libsodium-sumo@0.8.3:
|
||||
resolution: {integrity: sha512-z5CLkGJqilCXpfYxrXWh8fHVv2C8lpnIVxsZAHkxbEFyS+zZtL8VyM8FjtAmuDYP/rHgw8ftdwxSV8Efhzb8GQ==}
|
||||
|
||||
libsodium-wrappers-sumo@0.7.15:
|
||||
resolution: {integrity: sha512-aSWY8wKDZh5TC7rMvEdTHoyppVq/1dTSAeAR7H6pzd6QRT3vQWcT5pGwCotLcpPEOLXX6VvqihSPkpEhYAjANA==}
|
||||
|
||||
libsodium-wrappers-sumo@0.8.3:
|
||||
resolution: {integrity: sha512-EfLSlxKJ7RUGVospOlvbvse0suAAVPR+CkZfFcFjPzPtTEgVfvIaZUPsWndVjxYp/om2HDc0iLeR5wLF8YbHZg==}
|
||||
|
||||
@@ -7492,6 +7505,10 @@ snapshots:
|
||||
|
||||
'@types/json5@0.0.29': {}
|
||||
|
||||
'@types/libsodium-wrappers-sumo@0.8.2':
|
||||
dependencies:
|
||||
libsodium-wrappers-sumo: 0.7.15
|
||||
|
||||
'@types/libsodium-wrappers@0.7.14': {}
|
||||
|
||||
'@types/node-forge@1.3.14':
|
||||
@@ -9591,8 +9608,14 @@ snapshots:
|
||||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
libsodium-sumo@0.7.16: {}
|
||||
|
||||
libsodium-sumo@0.8.3: {}
|
||||
|
||||
libsodium-wrappers-sumo@0.7.15:
|
||||
dependencies:
|
||||
libsodium-sumo: 0.7.16
|
||||
|
||||
libsodium-wrappers-sumo@0.8.3:
|
||||
dependencies:
|
||||
libsodium-sumo: 0.8.3
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Per-member conversation preferences: archive + mute.
|
||||
--
|
||||
-- Both live on `conversation_members` because they are per-user state, not
|
||||
-- shared across the conversation. Archive hides the conversation from the
|
||||
-- main list until explicitly unarchived. Mute silences notifications until
|
||||
-- `muted_until` (null = not muted, a far-future timestamp = muted forever).
|
||||
|
||||
alter table public.conversation_members
|
||||
add column if not exists archived boolean not null default false,
|
||||
add column if not exists muted_until timestamptz null;
|
||||
|
||||
-- Update policy on conversation_members already allows self-updates via
|
||||
-- `members_update_self_or_admin`. No additional policy needed — users can
|
||||
-- toggle their own archive / mute flags.
|
||||
|
||||
-- Realtime publication already includes `conversation_members`.
|
||||
Reference in New Issue
Block a user