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
+2 -1
View File
@@ -4,7 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>ChatApp</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Netralax</title>
</head>
<body class="bg-[#0b0b0f] text-white antialiased">
<div id="root"></div>
+14
View File
@@ -0,0 +1,14 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="cp02">
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"/>
</clipPath>
</defs>
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065"/>
<g clip-path="url(#cp02)">
<path d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z" fill="#7c4dff"/>
<path d="M-4 32 Q 16 18 32 32 T 68 32" stroke="#a78bfa" stroke-width="2" fill="none"/>
</g>
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"
fill="none" stroke="#a78bfa" stroke-width="1.5" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 667 B

+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ChatApp",
"version": "0.4.4",
"version": "0.5.0",
"identifier": "com.meinname.chatapp",
"build": {
"beforeDevCommand": "pnpm vite:dev",
+77
View File
@@ -0,0 +1,77 @@
import { supabase } from './supabase';
const BUCKET = 'profile-avatars';
const MAX_DIM = 512;
const QUALITY = 0.85;
// Resizes the source image to a centred-cropped square ≤ MAX_DIM and
// re-encodes as WebP. Falls back to JPEG if WebP isn't supported (rare).
async function resizeToSquare(file: File): Promise<Blob> {
const url = URL.createObjectURL(file);
try {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error('image load failed'));
i.src = url;
});
const minSide = Math.min(img.naturalWidth, img.naturalHeight);
const sx = (img.naturalWidth - minSide) / 2;
const sy = (img.naturalHeight - minSide) / 2;
const target = Math.min(MAX_DIM, minSide);
const canvas = document.createElement('canvas');
canvas.width = target;
canvas.height = target;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas context unavailable');
ctx.drawImage(img, sx, sy, minSide, minSide, 0, 0, target, target);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/webp', QUALITY),
);
if (blob) return blob;
const jpeg = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/jpeg', QUALITY),
);
if (!jpeg) throw new Error('canvas toBlob returned null');
return jpeg;
} finally {
URL.revokeObjectURL(url);
}
}
export async function uploadAvatar(userId: string, file: File): Promise<string> {
if (!file.type.startsWith('image/')) {
throw new Error('only image files are accepted');
}
const blob = await resizeToSquare(file);
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
// Random filename so old uploads don't get overwritten before we update
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
// also forces clients to fetch the new image.
const name =
userId + '/' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8) + '.' + ext;
const { error: upErr } = await supabase.storage.from(BUCKET).upload(name, blob, {
contentType: blob.type,
cacheControl: '604800',
upsert: false,
});
if (upErr) throw upErr;
const { data: pub } = supabase.storage.from(BUCKET).getPublicUrl(name);
return pub.publicUrl;
}
export async function deleteAvatarObject(publicUrl: string): Promise<void> {
// Public URLs look like
// https://<host>/storage/v1/object/public/profile-avatars/<path>
// Extract <path> and remove.
const marker = '/object/public/' + BUCKET + '/';
const idx = publicUrl.indexOf(marker);
if (idx === -1) return;
const path = publicUrl.slice(idx + marker.length);
const { error } = await supabase.storage.from(BUCKET).remove([path]);
if (error) throw error;
}
+7 -2
View File
@@ -24,19 +24,24 @@ function rawFrom(table: string) {
return (supabase as unknown as { from: (t: string) => any }).from(table);
}
// Module-level flag — gap-fill runs once per (user, device) combo per
// process lifetime. Page reloads / route changes don't re-trigger it.
const backfilledKey = new Set<string>();
export function startConversationKeySync(
ownUserId: string,
ownDeviceId: string,
): () => void {
let cancelled = false;
let priv: Uint8Array | null = null;
const dedupeKey = ownUserId + ':' + ownDeviceId;
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => {
if (cancelled) return;
priv = pk;
if (!priv) return;
// Run a full backfill once we have the private key — covers devices that
// registered while we were offline.
if (backfilledKey.has(dedupeKey)) return;
backfilledKey.add(dedupeKey);
await syncAllExistingGaps({ myUserId: ownUserId, myDeviceId: ownDeviceId, priv });
});
+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();