Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0288d7a476 | |||
| 34b972ec2a | |||
| c55173800b | |||
| fc9f1ec143 | |||
| 12e4b597a0 | |||
| 5c05afb009 | |||
| a636a3c1c1 | |||
| c9fe4879e0 | |||
| 61462516d2 | |||
| d39a0fb6dc |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chat-app/desktop",
|
"name": "@chat-app/desktop",
|
||||||
"version": "0.18.2",
|
"version": "0.18.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -416,7 +416,18 @@ export function MessageBubble({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{message.plaintext === null ? (
|
{message.plaintext === null ? (
|
||||||
<span className="italic opacity-70">…cannot decrypt</span>
|
<span
|
||||||
|
className={
|
||||||
|
'italic ' +
|
||||||
|
// Mine = blue/accent bubble → use accent-fg with reduced opacity
|
||||||
|
// (still meets 4.5:1). Peer = surface-2 grey → muted-fg works.
|
||||||
|
(mine ? 'text-accent-fg/80' : 'text-fg-muted')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('app:chats.unreadable', {
|
||||||
|
defaultValue: 'Nachricht nicht lesbar',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
) : parsed.kind === 'poll' ? (
|
) : parsed.kind === 'poll' ? (
|
||||||
<PollCard
|
<PollCard
|
||||||
question={parsed.question}
|
question={parsed.question}
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Avatar } from '../components/Avatar';
|
import { Avatar } from '../components/Avatar';
|
||||||
|
import {
|
||||||
|
AtIcon,
|
||||||
|
BellIcon,
|
||||||
|
LockIcon,
|
||||||
|
MicIcon,
|
||||||
|
MonitorShareIcon,
|
||||||
|
MusicIcon,
|
||||||
|
ShieldIcon,
|
||||||
|
SignOutIcon,
|
||||||
|
SunIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from '../components/icons';
|
||||||
import { MicTestSection } from '../components/MicTestSection';
|
import { MicTestSection } from '../components/MicTestSection';
|
||||||
import { NotificationSoundSettings } from '../components/NotificationSoundSettings';
|
import { NotificationSoundSettings } from '../components/NotificationSoundSettings';
|
||||||
import { RingtoneSettings } from '../components/RingtoneSettings';
|
import { RingtoneSettings } from '../components/RingtoneSettings';
|
||||||
@@ -99,140 +111,267 @@ export function SettingsPage() {
|
|||||||
void patchProfile({ locale });
|
void patchProfile({ locale });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tab pattern (macOS / Discord / GitHub style): the sidebar selects ONE
|
||||||
|
// section and only that section renders. activeTab is the single source of
|
||||||
|
// truth — no IntersectionObserver to drift, no smooth-scroll, no anchor-link
|
||||||
|
// routing conflict with HashRouter.
|
||||||
|
type TabId =
|
||||||
|
| 'profile' | 'appearance' | 'privacy' | 'notifications'
|
||||||
|
| 'voice' | 'screen-share' | 'soundboard' | 'security' | 'account';
|
||||||
|
|
||||||
|
const tabs: Array<{ id: TabId; label: string; Icon: typeof UsersIcon }> = [
|
||||||
|
{ id: 'profile', label: t('app:settings.nav_profile', { defaultValue: 'Profil' }), Icon: UsersIcon },
|
||||||
|
{ id: 'appearance', label: t('app:settings.nav_appearance', { defaultValue: 'Erscheinungsbild' }), Icon: SunIcon },
|
||||||
|
{ id: 'privacy', label: t('app:settings.nav_privacy', { defaultValue: 'Privatsphäre' }), Icon: ShieldIcon },
|
||||||
|
{ id: 'notifications', label: t('app:settings.nav_notifications', { defaultValue: 'Benachrichtigungen' }), Icon: BellIcon },
|
||||||
|
{ id: 'voice', label: t('app:settings.nav_voice', { defaultValue: 'Sprache & Anrufe' }), Icon: MicIcon },
|
||||||
|
{ id: 'screen-share', label: t('app:settings.nav_screen_share', { defaultValue: 'Bildschirmfreigabe' }), Icon: MonitorShareIcon },
|
||||||
|
{ id: 'soundboard', label: t('app:settings.nav_soundboard', { defaultValue: 'Soundboard' }), Icon: MusicIcon },
|
||||||
|
{ id: 'security', label: t('app:settings.nav_security', { defaultValue: 'Sicherheit' }), Icon: LockIcon },
|
||||||
|
{ id: 'account', label: t('app:settings.nav_account', { defaultValue: 'Konto' }), Icon: SignOutIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>('profile');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-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">
|
<div className="mx-auto grid max-w-6xl gap-8 px-6 py-8 lg:grid-cols-[14rem_minmax(0,1fr)]">
|
||||||
<header className="mb-2">
|
{/* Sidebar */}
|
||||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
<aside className="hidden lg:block">
|
||||||
{t('app:settings.title')}
|
<div className="sticky top-8 space-y-1">
|
||||||
</h1>
|
<h1 className="mb-4 px-3 font-display text-2xl font-semibold tracking-tight text-fg">
|
||||||
</header>
|
{t('app:settings.title')}
|
||||||
|
</h1>
|
||||||
{/* Account */}
|
<nav aria-label={t('app:settings.title')} role="tablist" aria-orientation="vertical">
|
||||||
<Section title={t('app:settings.section_account')}>
|
{tabs.map(({ id, label, Icon }) => {
|
||||||
<ProfileVisualsControls patchProfile={patchProfile} busy={busy} />
|
const active = activeTab === id;
|
||||||
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
|
return (
|
||||||
<DisplayNameControls patchProfile={patchProfile} busy={busy} />
|
<button
|
||||||
<Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
|
key={id}
|
||||||
</Section>
|
type="button"
|
||||||
|
role="tab"
|
||||||
{/* Startup */}
|
aria-selected={active}
|
||||||
<Section title={t('app:settings.section_startup', { defaultValue: 'Start' })}>
|
aria-controls={'settings-panel-' + id}
|
||||||
<AutoStartControls />
|
onClick={() => setActiveTab(id)}
|
||||||
</Section>
|
className={
|
||||||
|
'flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
{/* Appearance */}
|
(active
|
||||||
<Section title={t('app:settings.section_appearance')}>
|
? 'bg-accent/15 text-fg'
|
||||||
<ThemeRow />
|
: 'text-fg-muted hover:bg-surface-2 hover:text-fg')
|
||||||
<SettingRow label={t('app:settings.language')}>
|
}
|
||||||
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
|
>
|
||||||
{SUPPORTED_LOCALES.map((locale) => {
|
<Icon
|
||||||
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
|
className={
|
||||||
return (
|
'h-4 w-4 shrink-0 ' + (active ? 'text-accent' : 'text-fg-muted')
|
||||||
<button
|
}
|
||||||
key={locale}
|
/>
|
||||||
type="button"
|
<span className="truncate">{label}</span>
|
||||||
disabled={busy}
|
</button>
|
||||||
onClick={() => void handleLocaleChange(locale)}
|
);
|
||||||
className={
|
})}
|
||||||
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
</nav>
|
||||||
(active
|
|
||||||
? 'bg-accent text-accent-fg'
|
|
||||||
: 'text-fg-muted hover:text-fg')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{LOCALE_LABELS[locale]}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</SettingRow>
|
</aside>
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Privacy */}
|
{/* Content panel — only the active tab renders */}
|
||||||
<Section title={t('app:settings.section_privacy')}>
|
<main className="min-w-0">
|
||||||
<Toggle
|
{/* Mobile-only header + tab selector (sidebar is hidden below lg) */}
|
||||||
label={t('app:settings.show_read_receipts')}
|
<div className="mb-6 space-y-3 lg:hidden">
|
||||||
hint={t('app:settings.show_read_receipts_hint')}
|
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||||
checked={profile?.showReadReceipts ?? true}
|
{t('app:settings.title')}
|
||||||
disabled={busy || !profile}
|
</h1>
|
||||||
onChange={(v) => void patchProfile({ showReadReceipts: v })}
|
<select
|
||||||
/>
|
value={activeTab}
|
||||||
<Toggle
|
onChange={(e) => setActiveTab(e.target.value as TabId)}
|
||||||
label={t('app:settings.allow_dms_strangers')}
|
className="w-full cursor-pointer rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
hint={t('app:settings.allow_dms_strangers_hint')}
|
aria-label={t('app:settings.title')}
|
||||||
checked={profile?.allowDmsFromStrangers ?? true}
|
>
|
||||||
disabled={busy || !profile}
|
{tabs.map(({ id, label }) => (
|
||||||
onChange={(v) => void patchProfile({ allowDmsFromStrangers: v })}
|
<option key={id} value={id}>{label}</option>
|
||||||
/>
|
))}
|
||||||
</Section>
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Notification sound (new messages) */}
|
<div
|
||||||
<Section
|
id={'settings-panel-' + activeTab}
|
||||||
title={t('app:settings.section_notifications', { defaultValue: 'Benachrichtigungen' })}
|
role="tabpanel"
|
||||||
>
|
aria-labelledby={'settings-tab-' + activeTab}
|
||||||
<NotificationSoundSettings disabled={busy} />
|
>
|
||||||
</Section>
|
{activeTab === 'profile' && (
|
||||||
|
<Section
|
||||||
|
title={t('app:settings.section_account')}
|
||||||
|
description={t('app:settings.section_account_hint', {
|
||||||
|
defaultValue: 'Dein öffentliches Profil und wie andere dich sehen.',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ProfileVisualsControls patchProfile={patchProfile} busy={busy} />
|
||||||
|
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
|
||||||
|
<DisplayNameControls patchProfile={patchProfile} busy={busy} />
|
||||||
|
<Row icon={<AtIcon className="h-3.5 w-3.5" />} label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Ringtone (incoming custom) */}
|
{activeTab === 'appearance' && (
|
||||||
<Section title={t('app:settings.section_ringtone', { defaultValue: 'Klingelton' })}>
|
<Section
|
||||||
<RingtoneSettings disabled={busy} />
|
title={t('app:settings.section_appearance')}
|
||||||
</Section>
|
description={t('app:settings.section_appearance_hint', {
|
||||||
|
defaultValue: 'Theme, Sprache und Verhalten beim Systemstart.',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ThemeRow />
|
||||||
|
<SettingRow label={t('app:settings.language')}>
|
||||||
|
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
|
||||||
|
{SUPPORTED_LOCALES.map((locale) => {
|
||||||
|
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={locale}
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void handleLocaleChange(locale)}
|
||||||
|
className={
|
||||||
|
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
|
(active
|
||||||
|
? 'bg-accent text-accent-fg'
|
||||||
|
: 'text-fg-muted hover:text-fg')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{LOCALE_LABELS[locale]}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
<SubGroup>
|
||||||
|
<AutoStartControls />
|
||||||
|
</SubGroup>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Soundboard */}
|
{activeTab === 'privacy' && (
|
||||||
<Section title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}>
|
<Section
|
||||||
<SoundboardSettings />
|
title={t('app:settings.section_privacy')}
|
||||||
</Section>
|
description={t('app:settings.section_privacy_hint', {
|
||||||
|
defaultValue: 'Wer dich kontaktieren darf und was Friends von dir sehen.',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Toggle
|
||||||
|
label={t('app:settings.show_read_receipts')}
|
||||||
|
hint={t('app:settings.show_read_receipts_hint')}
|
||||||
|
checked={profile?.showReadReceipts ?? true}
|
||||||
|
disabled={busy || !profile}
|
||||||
|
onChange={(v) => void patchProfile({ showReadReceipts: v })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label={t('app:settings.allow_dms_strangers')}
|
||||||
|
hint={t('app:settings.allow_dms_strangers_hint')}
|
||||||
|
checked={profile?.allowDmsFromStrangers ?? true}
|
||||||
|
disabled={busy || !profile}
|
||||||
|
onChange={(v) => void patchProfile({ allowDmsFromStrangers: v })}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
|
{activeTab === 'notifications' && (
|
||||||
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
|
<Section
|
||||||
<AudioDeviceControls />
|
title={t('app:settings.section_notifications', { defaultValue: 'Benachrichtigungen' })}
|
||||||
<div className="mt-3 border-t border-line pt-3">
|
description={t('app:settings.section_notifications_hint', {
|
||||||
<AudioQualityControls />
|
defaultValue: 'Töne für eingehende Nachrichten und Anrufe.',
|
||||||
</div>
|
})}
|
||||||
<div className="mt-3 border-t border-line pt-3">
|
>
|
||||||
<PttControls />
|
<SubSection title={t('app:settings.subsection_message_sound', { defaultValue: 'Nachrichten-Ton' })}>
|
||||||
</div>
|
<NotificationSoundSettings disabled={busy} />
|
||||||
<div className="mt-3 border-t border-line pt-3">
|
</SubSection>
|
||||||
<VoiceHotkeyControls kind="mute" />
|
<SubSection title={t('app:settings.subsection_ringtone', { defaultValue: 'Klingelton bei Anruf' })}>
|
||||||
</div>
|
<RingtoneSettings disabled={busy} />
|
||||||
<div className="mt-3 border-t border-line pt-3">
|
</SubSection>
|
||||||
<VoiceHotkeyControls kind="deafen" />
|
</Section>
|
||||||
</div>
|
)}
|
||||||
<div className="mt-3 border-t border-line pt-3">
|
|
||||||
<VoiceHotkeyControls kind="hangup" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 border-t border-line pt-3">
|
|
||||||
<VoiceHotkeyControls kind="screenShare" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 border-t border-line pt-3">
|
|
||||||
<VoiceHotkeyControls kind="video" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 border-t border-line pt-3">
|
|
||||||
<CallE2EEControls />
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Screen-share quality */}
|
{activeTab === 'voice' && (
|
||||||
<Section title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}>
|
<Section
|
||||||
<ScreenShareControls />
|
title={t('app:settings.section_voice', { defaultValue: 'Sprache & Anrufe' })}
|
||||||
</Section>
|
description={t('app:settings.section_voice_hint', {
|
||||||
|
defaultValue: 'Mikrofon, Audio-Qualität und Hotkeys für Anrufe.',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SubSection title={t('app:settings.subsection_audio_device', { defaultValue: 'Audio-Gerät' })}>
|
||||||
|
<AudioDeviceControls />
|
||||||
|
</SubSection>
|
||||||
|
<SubSection title={t('app:settings.subsection_audio_quality', { defaultValue: 'Audio-Qualität' })}>
|
||||||
|
<AudioQualityControls />
|
||||||
|
</SubSection>
|
||||||
|
<SubSection title={t('app:settings.subsection_ptt', { defaultValue: 'Push-to-Talk' })}>
|
||||||
|
<PttControls />
|
||||||
|
</SubSection>
|
||||||
|
<SubSection title={t('app:settings.subsection_hotkeys', { defaultValue: 'Hotkeys' })}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<VoiceHotkeyControls kind="mute" />
|
||||||
|
<VoiceHotkeyControls kind="deafen" />
|
||||||
|
<VoiceHotkeyControls kind="hangup" />
|
||||||
|
<VoiceHotkeyControls kind="screenShare" />
|
||||||
|
<VoiceHotkeyControls kind="video" />
|
||||||
|
</div>
|
||||||
|
</SubSection>
|
||||||
|
<SubSection title={t('app:settings.subsection_call_e2ee', { defaultValue: 'Anruf-Verschlüsselung' })}>
|
||||||
|
<CallE2EEControls />
|
||||||
|
</SubSection>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Security */}
|
{activeTab === 'screen-share' && (
|
||||||
<Section title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })}>
|
<Section
|
||||||
{profile?.userId && <SecurityCenter userId={profile.userId} />}
|
title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}
|
||||||
</Section>
|
description={t('app:settings.section_screen_share_hint', {
|
||||||
|
defaultValue: 'Auflösung und Bitrate beim Teilen deines Bildschirms.',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ScreenShareControls />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Danger zone */}
|
{activeTab === 'soundboard' && (
|
||||||
<Section title={t('app:settings.danger_zone')}>
|
<Section
|
||||||
<button
|
title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}
|
||||||
type="button"
|
description={t('app:settings.section_soundboard_hint', {
|
||||||
onClick={() => void signOut()}
|
defaultValue: 'Eigene Sounds für Anrufe — verwaltet & abspielbar mit Hotkey.',
|
||||||
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50 dark:text-rose-300"
|
})}
|
||||||
>
|
>
|
||||||
{t('app:settings.sign_out')}
|
<SoundboardSettings />
|
||||||
</button>
|
</Section>
|
||||||
</Section>
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'security' && (
|
||||||
|
<Section
|
||||||
|
title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })}
|
||||||
|
description={t('app:settings.section_security_hint', {
|
||||||
|
defaultValue: 'PIN, Recovery-Code und Schlüssel-Reparatur.',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{profile?.userId && <SecurityCenter userId={profile.userId} />}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'account' && (
|
||||||
|
<Section
|
||||||
|
title={t('app:settings.section_account_mgmt', { defaultValue: 'Konto verwalten' })}
|
||||||
|
description={t('app:settings.section_account_mgmt_hint', {
|
||||||
|
defaultValue: 'Abmelden oder Konto-Aktionen.',
|
||||||
|
})}
|
||||||
|
tone="danger"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void signOut()}
|
||||||
|
className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50 dark:text-rose-300"
|
||||||
|
>
|
||||||
|
<SignOutIcon className="h-4 w-4" />
|
||||||
|
{t('app:settings.sign_out')}
|
||||||
|
</button>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1138,21 +1277,76 @@ function formatBitrate(kbps: number): string {
|
|||||||
return kbps + ' kbps';
|
return kbps + ' kbps';
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
function Section({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
tone?: 'default' | 'danger';
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-2xl border border-line bg-surface-2 p-5">
|
<section
|
||||||
<h2 className="mb-4 text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
className={
|
||||||
{title}
|
'rounded-2xl border bg-surface-2 p-6 ' +
|
||||||
</h2>
|
(tone === 'danger' ? 'border-rose-500/30' : 'border-line')
|
||||||
<div className="space-y-3">{children}</div>
|
}
|
||||||
|
>
|
||||||
|
<header className="mb-5 border-b border-line pb-4">
|
||||||
|
<h2 className={'font-display text-lg font-semibold ' + (tone === 'danger' ? 'text-rose-500 dark:text-rose-300' : 'text-fg')}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 text-xs text-fg-muted">{description}</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<div className="space-y-4">{children}</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
// Sub-heading inside a Section — used to chunk dense sections like Voice into
|
||||||
|
// smaller named groups (Audio-Gerät / Qualität / PTT / Hotkeys / E2EE).
|
||||||
|
function SubSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 rounded-xl border border-line/60 bg-surface-3/40 p-4">
|
||||||
|
<h3 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lighter wrapper for a single related extra control inside a Section that
|
||||||
|
// doesn't warrant its own SubSection card (e.g., autostart toggle inside
|
||||||
|
// Appearance).
|
||||||
|
function SubGroup({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 border-t border-line pt-4">{children}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
mono?: boolean;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<dt className="text-sm text-fg-muted">{label}</dt>
|
<dt className="flex items-center gap-1.5 text-sm text-fg-muted">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
<dd
|
<dd
|
||||||
className={
|
className={
|
||||||
'max-w-[60%] truncate text-right text-sm text-fg ' +
|
'max-w-[60%] truncate text-right text-sm text-fg ' +
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ describe('auth/userKey', () => {
|
|||||||
expect(res.lockedUntil).toBe(lockedUntil);
|
expect(res.lockedUntil).toBe(lockedUntil);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uploadUserKeyBlob upserts via reset_user_key RPC', async () => {
|
it('uploadUserKeyBlob upserts via upsert_user_key RPC (non-destructive)', async () => {
|
||||||
mock.setRpcResponse('reset_user_key', { data: 0, error: null });
|
mock.setRpcResponse('upsert_user_key', { data: null, error: null });
|
||||||
await uploadUserKeyBlob(mock.client, {
|
await uploadUserKeyBlob(mock.client, {
|
||||||
userId: USER_ID,
|
userId: USER_ID,
|
||||||
publicKey: new Uint8Array([1, 2, 3]),
|
publicKey: new Uint8Array([1, 2, 3]),
|
||||||
@@ -64,7 +64,9 @@ describe('auth/userKey', () => {
|
|||||||
kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 268435456 },
|
kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 268435456 },
|
||||||
});
|
});
|
||||||
const params = mock.rpcCalls.at(-1)?.params as Record<string, unknown>;
|
const params = mock.rpcCalls.at(-1)?.params as Record<string, unknown>;
|
||||||
expect(mock.rpcCalls.at(-1)?.name).toBe('reset_user_key');
|
// Crucial: must hit upsert_user_key, NOT reset_user_key — the latter
|
||||||
|
// deletes every conversation_keys row for the user (0.18.0–0.18.2 bug).
|
||||||
|
expect(mock.rpcCalls.at(-1)?.name).toBe('upsert_user_key');
|
||||||
expect(params.p_user_id).toBe(USER_ID);
|
expect(params.p_user_id).toBe(USER_ID);
|
||||||
expect(params.p_public_key_b64).toBe('AQID');
|
expect(params.p_public_key_b64).toBe('AQID');
|
||||||
expect(params.p_sealed_private_b64).toBe('BAU=');
|
expect(params.p_sealed_private_b64).toBe('BAU=');
|
||||||
|
|||||||
@@ -92,7 +92,13 @@ export async function uploadUserKeyBlob(
|
|||||||
client: AppSupabaseClient,
|
client: AppSupabaseClient,
|
||||||
params: UploadParams,
|
params: UploadParams,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { error } = await rpc(client).rpc('reset_user_key', {
|
// Non-destructive UPSERT — must NOT touch conversation_keys. Used for the
|
||||||
|
// first-time PIN setup, PIN change, and recovery-code regeneration. The
|
||||||
|
// 0.18.0–0.18.2 builds wired this to `reset_user_key` which DELETED every
|
||||||
|
// legacy conv-key bundle for the user before the migration could re-wrap
|
||||||
|
// them, leaving people unable to read or send. `upsert_user_key` writes
|
||||||
|
// only the user_keys row and leaves conversation_keys alone.
|
||||||
|
const { error } = await rpc(client).rpc('upsert_user_key', {
|
||||||
p_user_id: params.userId,
|
p_user_id: params.userId,
|
||||||
p_public_key_b64: bytesToB64(params.publicKey),
|
p_public_key_b64: bytesToB64(params.publicKey),
|
||||||
p_sealed_private_b64: bytesToB64(params.sealedPrivateKey),
|
p_sealed_private_b64: bytesToB64(params.sealedPrivateKey),
|
||||||
|
|||||||
@@ -138,11 +138,105 @@ export async function getOrCreateConvKey(
|
|||||||
.eq('key_version', version);
|
.eq('key_version', version);
|
||||||
if (cntErr) throw cntErr;
|
if (cntErr) throw cntErr;
|
||||||
if ((count ?? 0) > 0) {
|
if ((count ?? 0) > 0) {
|
||||||
throw new Error('Awaiting conversation key — another user must share it with this user.');
|
// Rows exist for this version, but none for me. Either I lost the device-key
|
||||||
|
// that originally received my bundle, or my own bundle was wiped by the
|
||||||
|
// 0.18.0 reset_user_key bug. Either way, the only way out is to mint a fresh
|
||||||
|
// conv-key at version+1 and wrap it for everyone we can. Old messages stay
|
||||||
|
// unreadable for me; new ones flow.
|
||||||
|
console.info('[conv-key] no bundle for me at v' + version + ' — auto-rotating');
|
||||||
|
return rotateConvKey(client, conversationId, own);
|
||||||
}
|
}
|
||||||
return bootstrapConvKey(client, conversationId, own, version);
|
return bootstrapConvKey(client, conversationId, own, version);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mints a fresh conv-key at active_key_version + 1 and wraps it for every
|
||||||
|
// accepted member. Per-user bundles take priority; for members lacking a
|
||||||
|
// user_keys row we fall back to per-device wrapping (one bundle per device)
|
||||||
|
// so peers still on the legacy 0.17.x client can decrypt with their device
|
||||||
|
// private key. Caller must own a copy of their private key in `own`.
|
||||||
|
export async function rotateConvKey(
|
||||||
|
client: AppSupabaseClient,
|
||||||
|
conversationId: string,
|
||||||
|
own: OwnUserCtx,
|
||||||
|
): Promise<ConvKeyHandle> {
|
||||||
|
const currentVersion = await fetchActiveKeyVersion(client, conversationId);
|
||||||
|
const newVersion = currentVersion + 1;
|
||||||
|
|
||||||
|
const { data: members, error: mErr } = await client
|
||||||
|
.from('conversation_members')
|
||||||
|
.select('user_id, accepted')
|
||||||
|
.eq('conversation_id', conversationId);
|
||||||
|
if (mErr) throw mErr;
|
||||||
|
const memberIds = (members ?? []).filter((m) => m.accepted).map((m) => m.user_id);
|
||||||
|
if (memberIds.length === 0) throw new Error('cannot rotate — no accepted members');
|
||||||
|
|
||||||
|
const userKeys = await fetchPeerPublicKeys(client, memberIds);
|
||||||
|
const userKeyByUserId = new Map(userKeys.map((k) => [k.userId, k.publicKey]));
|
||||||
|
const missingUserKeyMembers = memberIds.filter((id) => !userKeyByUserId.has(id));
|
||||||
|
|
||||||
|
let legacyDevices: { userId: string; deviceId: string; publicKey: Uint8Array }[] = [];
|
||||||
|
if (missingUserKeyMembers.length > 0) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const { data: devs, error: dErr } = await (client as any)
|
||||||
|
.from('devices')
|
||||||
|
.select('id, user_id, public_key')
|
||||||
|
.in('user_id', missingUserKeyMembers)
|
||||||
|
.not('public_key', 'is', null);
|
||||||
|
if (dErr) throw dErr;
|
||||||
|
legacyDevices = (devs ?? []).map((d: { id: string; user_id: string; public_key: string }) => ({
|
||||||
|
userId: d.user_id,
|
||||||
|
deviceId: d.id,
|
||||||
|
publicKey: pgHexToBytes(d.public_key),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const convKey = generateConvKey();
|
||||||
|
const bundles: Array<{
|
||||||
|
recipient_user_id?: string;
|
||||||
|
recipient_device_id?: string;
|
||||||
|
encrypted_key: string;
|
||||||
|
nonce: string;
|
||||||
|
}> = [];
|
||||||
|
for (const k of userKeys) {
|
||||||
|
const wrapped = await wrapConvKeyForRecipient(convKey, k.publicKey, own.privateKey);
|
||||||
|
bundles.push({
|
||||||
|
recipient_user_id: k.userId,
|
||||||
|
encrypted_key: hexNoPrefix(wrapped.ciphertext),
|
||||||
|
nonce: hexNoPrefix(wrapped.nonce),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const d of legacyDevices) {
|
||||||
|
const wrapped = await wrapConvKeyForRecipient(convKey, d.publicKey, own.privateKey);
|
||||||
|
bundles.push({
|
||||||
|
recipient_device_id: d.deviceId,
|
||||||
|
encrypted_key: hexNoPrefix(wrapped.ciphertext),
|
||||||
|
nonce: hexNoPrefix(wrapped.nonce),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (bundles.length === 0) {
|
||||||
|
throw new Error('cannot rotate — no peers have a public key (no user_keys, no devices)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const rpc = (client as unknown as { rpc: (n: string, p: object) => Promise<{ error: any }> }).rpc;
|
||||||
|
const { error } = await rpc.call(client, 'rotate_conv_key', {
|
||||||
|
p_conv_id: conversationId,
|
||||||
|
p_sender_user_id: own.userId,
|
||||||
|
p_new_version: newVersion,
|
||||||
|
p_bundles: bundles,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const handle = { conversationId, keyVersion: newVersion, key: convKey };
|
||||||
|
cache.set(cacheKey(conversationId, newVersion), handle);
|
||||||
|
console.info(
|
||||||
|
'[conv-key] rotated conversation ' + conversationId.slice(0, 8) +
|
||||||
|
' from v' + currentVersion + ' to v' + newVersion +
|
||||||
|
' — wrapped for ' + userKeys.length + ' user-keys + ' + legacyDevices.length + ' legacy devices',
|
||||||
|
);
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
export async function tryGetConvKey(
|
export async function tryGetConvKey(
|
||||||
client: AppSupabaseClient,
|
client: AppSupabaseClient,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
|||||||
@@ -91,7 +91,11 @@ export interface SendMessageParams {
|
|||||||
conversationId: string;
|
conversationId: string;
|
||||||
plaintext: string;
|
plaintext: string;
|
||||||
senderUserId: string;
|
senderUserId: string;
|
||||||
senderDeviceId: string;
|
// Optional now: post-conv-keys this is pure telemetry. The 0.18 builds
|
||||||
|
// started passing a localStorage UUID that doesn't exist in the devices
|
||||||
|
// table; messages.sender_device_id RLS then 403'd every insert. Senders
|
||||||
|
// pass null (or an actually-registered device id, if they have one).
|
||||||
|
senderDeviceId?: string | null;
|
||||||
senderPrivateKey: Uint8Array;
|
senderPrivateKey: Uint8Array;
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
// Optional encrypted attachments — their handles are already materialised
|
// Optional encrypted attachments — their handles are already materialised
|
||||||
@@ -124,7 +128,12 @@ export async function sendEncryptedMessage(params: SendMessageParams): Promise<C
|
|||||||
const insertPayload: Record<string, unknown> = {
|
const insertPayload: Record<string, unknown> = {
|
||||||
conversation_id: params.conversationId,
|
conversation_id: params.conversationId,
|
||||||
sender_id: params.senderUserId,
|
sender_id: params.senderUserId,
|
||||||
sender_device_id: params.senderDeviceId,
|
// ALWAYS null until we re-introduce a real per-install devices row.
|
||||||
|
// Desktop callers currently pass a localStorage UUID (ensureInstallId)
|
||||||
|
// which doesn't exist in the devices table; the messages_insert_member
|
||||||
|
// RLS policy then 403s because the id can't be proven to belong to the
|
||||||
|
// caller. NULL satisfies the policy ("sender_device_id IS NULL OR …").
|
||||||
|
sender_device_id: null,
|
||||||
ciphertext: bytesToPgHex(cipher.ciphertext),
|
ciphertext: bytesToPgHex(cipher.ciphertext),
|
||||||
nonce: bytesToPgHex(cipher.nonce),
|
nonce: bytesToPgHex(cipher.nonce),
|
||||||
key_version: handle.keyVersion,
|
key_version: handle.keyVersion,
|
||||||
@@ -174,7 +183,7 @@ export interface EditMessageParams {
|
|||||||
// Re-encrypts the message body with the conv-key and updates the row.
|
// Re-encrypts the message body with the conv-key and updates the row.
|
||||||
// Server-side trigger enforces 24h window + sender-only rule.
|
// Server-side trigger enforces 24h window + sender-only rule.
|
||||||
export async function editEncryptedMessage(
|
export async function editEncryptedMessage(
|
||||||
params: EditMessageParams & { senderUserId: string; senderDeviceId: string },
|
params: EditMessageParams & { senderUserId: string; senderDeviceId?: string | null },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ownCtx: OwnUserCtx = {
|
const ownCtx: OwnUserCtx = {
|
||||||
userId: params.senderUserId,
|
userId: params.senderUserId,
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
-- 0.18.3 hotfix: split user_keys upload into a non-destructive `upsert_user_key`
|
||||||
|
-- and the existing destructive `reset_user_key`. Add `rotate_conv_key` so the
|
||||||
|
-- client can escape "Awaiting key" deadlocks by minting a fresh per-conversation
|
||||||
|
-- key and wrapping it for every member (per-user where possible, per-device as
|
||||||
|
-- a legacy fallback for peers still on 0.17.x).
|
||||||
|
--
|
||||||
|
-- Why: `reset_user_key` was being called from EVERY upload path
|
||||||
|
-- (setupNewUserIdentity, changePin, regenerateRecoveryCode), wiping every
|
||||||
|
-- legacy `conversation_keys` row for the user before the migration could
|
||||||
|
-- re-wrap them. Users ended up with `user_keys` set, zero un-migrated
|
||||||
|
-- bundles, and no way to send or read.
|
||||||
|
|
||||||
|
-- 1) upsert_user_key — same UPSERT as reset_user_key but WITHOUT the DELETE.
|
||||||
|
-- Safe to call on every PIN-set / PIN-change / recovery-regen.
|
||||||
|
|
||||||
|
create or replace function public.upsert_user_key(
|
||||||
|
p_user_id uuid,
|
||||||
|
p_public_key_b64 text,
|
||||||
|
p_sealed_private_b64 text,
|
||||||
|
p_salt_b64 text,
|
||||||
|
p_kdf_params jsonb,
|
||||||
|
p_recovery_sealed_b64 text default null,
|
||||||
|
p_recovery_salt_b64 text default null
|
||||||
|
) returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
caller uuid := auth.uid();
|
||||||
|
begin
|
||||||
|
if caller is null or caller <> p_user_id then
|
||||||
|
raise exception 'not authenticated as %', p_user_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.user_keys (
|
||||||
|
user_id, public_key, sealed_private_key, salt, kdf_params,
|
||||||
|
recovery_sealed_private_key, recovery_salt,
|
||||||
|
failed_attempts, locked_until,
|
||||||
|
failed_recovery_attempts, recovery_locked_until,
|
||||||
|
key_version, created_at, updated_at
|
||||||
|
) values (
|
||||||
|
p_user_id,
|
||||||
|
decode(p_public_key_b64, 'base64'),
|
||||||
|
decode(p_sealed_private_b64, 'base64'),
|
||||||
|
decode(p_salt_b64, 'base64'),
|
||||||
|
p_kdf_params,
|
||||||
|
case when p_recovery_sealed_b64 is null then null else decode(p_recovery_sealed_b64, 'base64') end,
|
||||||
|
case when p_recovery_salt_b64 is null then null else decode(p_recovery_salt_b64, 'base64') end,
|
||||||
|
0, null, 0, null,
|
||||||
|
1, now(), now()
|
||||||
|
)
|
||||||
|
on conflict (user_id) do update set
|
||||||
|
public_key = excluded.public_key,
|
||||||
|
sealed_private_key = excluded.sealed_private_key,
|
||||||
|
salt = excluded.salt,
|
||||||
|
kdf_params = excluded.kdf_params,
|
||||||
|
recovery_sealed_private_key = excluded.recovery_sealed_private_key,
|
||||||
|
recovery_salt = excluded.recovery_salt,
|
||||||
|
failed_attempts = 0,
|
||||||
|
locked_until = null,
|
||||||
|
failed_recovery_attempts = 0,
|
||||||
|
recovery_locked_until = null,
|
||||||
|
-- Don't bump key_version here — the public key is unchanged.
|
||||||
|
updated_at = now();
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke execute on function public.upsert_user_key(uuid, text, text, text, jsonb, text, text) from public, anon;
|
||||||
|
grant execute on function public.upsert_user_key(uuid, text, text, text, jsonb, text, text) to authenticated;
|
||||||
|
|
||||||
|
-- 2) rotate_conv_key — atomically bumps active_key_version and inserts a new
|
||||||
|
-- set of bundles. Each bundle may carry recipient_user_id (per-user wrap)
|
||||||
|
-- OR recipient_device_id (per-device fallback for peers on the legacy
|
||||||
|
-- client). Caller must ensure the new version is strictly greater than
|
||||||
|
-- the current one (we lock the row to prevent races).
|
||||||
|
|
||||||
|
create or replace function public.rotate_conv_key(
|
||||||
|
p_conv_id uuid,
|
||||||
|
p_sender_user_id uuid,
|
||||||
|
p_new_version int,
|
||||||
|
p_bundles jsonb
|
||||||
|
) returns int
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
caller uuid := auth.uid();
|
||||||
|
cur_version int;
|
||||||
|
bundle jsonb;
|
||||||
|
inserted int := 0;
|
||||||
|
recipient_uid uuid;
|
||||||
|
recipient_did uuid;
|
||||||
|
member_user_id uuid;
|
||||||
|
enc_key_hex text;
|
||||||
|
nonce_hex text;
|
||||||
|
begin
|
||||||
|
if caller is null or caller <> p_sender_user_id then
|
||||||
|
raise exception 'not authenticated as %', p_sender_user_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (
|
||||||
|
select 1 from public.conversation_members
|
||||||
|
where conversation_id = p_conv_id
|
||||||
|
and user_id = caller
|
||||||
|
and accepted = true
|
||||||
|
) then
|
||||||
|
raise exception 'caller is not an accepted member of %', p_conv_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Lock the conversation row so concurrent rotations don't race the version bump.
|
||||||
|
select active_key_version into cur_version
|
||||||
|
from public.conversations
|
||||||
|
where id = p_conv_id
|
||||||
|
for update;
|
||||||
|
if cur_version is null then
|
||||||
|
raise exception 'conversation % not found', p_conv_id;
|
||||||
|
end if;
|
||||||
|
if p_new_version <= cur_version then
|
||||||
|
raise exception 'new key version % must be greater than current %',
|
||||||
|
p_new_version, cur_version;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.conversations
|
||||||
|
set active_key_version = p_new_version
|
||||||
|
where id = p_conv_id;
|
||||||
|
|
||||||
|
-- Insert each bundle. We don't auto-derive recipient_user_id from the
|
||||||
|
-- device anymore — for per-device fallback rows the column stays NULL so
|
||||||
|
-- multiple devices of the same user can each get their own bundle.
|
||||||
|
for bundle in select * from jsonb_array_elements(p_bundles) loop
|
||||||
|
recipient_uid := nullif(bundle->>'recipient_user_id', '')::uuid;
|
||||||
|
recipient_did := nullif(bundle->>'recipient_device_id', '')::uuid;
|
||||||
|
enc_key_hex := bundle->>'encrypted_key';
|
||||||
|
nonce_hex := bundle->>'nonce';
|
||||||
|
|
||||||
|
-- Validate membership regardless of mode.
|
||||||
|
if recipient_uid is not null then
|
||||||
|
member_user_id := recipient_uid;
|
||||||
|
elsif recipient_did is not null then
|
||||||
|
select user_id into member_user_id from public.devices where id = recipient_did;
|
||||||
|
else
|
||||||
|
continue;
|
||||||
|
end if;
|
||||||
|
if member_user_id is null then continue; end if;
|
||||||
|
if not exists (
|
||||||
|
select 1 from public.conversation_members
|
||||||
|
where conversation_id = p_conv_id
|
||||||
|
and user_id = member_user_id
|
||||||
|
and accepted = true
|
||||||
|
) then continue; end if;
|
||||||
|
|
||||||
|
insert into public.conversation_keys
|
||||||
|
(conversation_id, recipient_user_id, recipient_device_id,
|
||||||
|
key_version, sender_user_id, sender_device_id,
|
||||||
|
encrypted_key, nonce)
|
||||||
|
values
|
||||||
|
(p_conv_id, recipient_uid, recipient_did,
|
||||||
|
p_new_version, p_sender_user_id, null,
|
||||||
|
decode(enc_key_hex, 'hex'),
|
||||||
|
decode(nonce_hex, 'hex'))
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
if found then inserted := inserted + 1; end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
return inserted;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke execute on function public.rotate_conv_key(uuid, uuid, int, jsonb) from public, anon;
|
||||||
|
grant execute on function public.rotate_conv_key(uuid, uuid, int, jsonb) to authenticated;
|
||||||
Reference in New Issue
Block a user