feat: profile avatar upload + share_conv_keys rpc + favicon + smtp tweaks
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled

This commit is contained in:
2026-04-19 23:04:03 +02:00
parent ff5ea274b9
commit 0ca29952ba
30 changed files with 3135 additions and 37 deletions
+123 -1
View File
@@ -4,12 +4,13 @@ import {
SUPPORTED_LOCALES,
type SupportedLocale,
} from '@chat-app/shared/i18n';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LockIcon } from '../components/icons';
import { useAuth } from '../context/AuthContext';
import { loadDevicePrivateKey, saveDevicePrivateKey } from '@chat-app/shared/auth';
import { deleteAvatarObject, uploadAvatar } from '../lib/avatarUpload';
import { exportDeviceKey, importDeviceKey } from '../lib/deviceBackup';
import { devLocalSecretStore } from '../lib/secretStore';
import {
@@ -83,6 +84,10 @@ export function SettingsPage() {
{/* Account */}
<Section title={t('app:settings.section_account')}>
<AvatarControls
patchProfile={patchProfile}
busy={busy}
/>
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
<Row label={t('auth:signed_in.display_name')} value={profile?.displayName ?? '—'} />
<Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
@@ -327,6 +332,123 @@ function AudioQualityControls() {
);
}
interface AvatarControlsProps {
patchProfile: (patch: Parameters<typeof updateOwnProfile>[1]) => Promise<void>;
busy: boolean;
}
function AvatarControls({ patchProfile, busy }: AvatarControlsProps) {
const { t } = useTranslation(['app']);
const { profile } = useAuth();
const inputRef = useRef<HTMLInputElement | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const userId = profile?.userId;
const url = profile?.avatarUrl ?? null;
const letter = (profile?.displayName ?? profile?.username ?? '?')
.trim()
.charAt(0)
.toUpperCase() || '?';
async function handleFile(file: File) {
if (!userId) return;
setError(null);
setUploading(true);
try {
const newUrl = await uploadAvatar(userId, file);
const oldUrl = url;
await patchProfile({ avatarUrl: newUrl });
if (oldUrl) {
// Best-effort cleanup of the previous file (don't block on it).
void deleteAvatarObject(oldUrl).catch(() => {
/* ignore */
});
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'upload failed');
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
}
async function handleRemove() {
if (!userId || !url) return;
setError(null);
setUploading(true);
try {
await patchProfile({ avatarUrl: null });
void deleteAvatarObject(url).catch(() => undefined);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'remove failed');
} finally {
setUploading(false);
}
}
return (
<div className="flex items-center gap-4">
<div className="relative h-16 w-16 overflow-hidden rounded-full bg-brand-500/30 ring-1 ring-brand-400/30">
{url ? (
<img src={url} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-2xl font-semibold text-white">
{letter}
</div>
)}
</div>
<div className="flex flex-1 flex-col gap-1">
<div className="text-sm font-medium text-neutral-200">
{t('app:settings.avatar', { defaultValue: 'Avatar' })}
</div>
<div className="text-xs text-neutral-500">
{t('app:settings.avatar_hint', {
defaultValue: 'Quadratisch, max 512×512, WebP. Sichtbar für deine Freunde.',
})}
</div>
{error && (
<div className="text-xs text-rose-300">{error}</div>
)}
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleFile(f);
}}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={busy || uploading}
className="cursor-pointer rounded-lg bg-brand-500/80 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-400 disabled:cursor-not-allowed disabled:opacity-50"
>
{uploading
? t('app:settings.avatar_uploading', { defaultValue: 'Lade…' })
: url
? t('app:settings.avatar_change', { defaultValue: 'Ändern' })
: t('app:settings.avatar_upload', { defaultValue: 'Hochladen' })}
</button>
{url && (
<button
type="button"
onClick={() => void handleRemove()}
disabled={busy || uploading}
className="cursor-pointer rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-200 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('app:settings.avatar_remove', { defaultValue: 'Entfernen' })}
</button>
)}
</div>
);
}
function DeviceKeyBackupControls() {
const { t } = useTranslation(['app']);
const { profile, device } = useAuth();