Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ca29952ba | |||
| ff5ea274b9 | |||
| b0f9f1dada | |||
| 961ac2dde5 | |||
| 7efbcf7e39 | |||
| 49c64cc5d9 | |||
| d20c7e210b | |||
| 58fa9487e3 | |||
| d9b08592da | |||
| 4a80bf1c0e | |||
| 05c962d46f | |||
| cf3fef6936 | |||
| 8c878b3718 | |||
| 389f00e85c |
@@ -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>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@livekit/components-react": "^2.9.0",
|
||||
"@supabase/supabase-js": "^2.46.0",
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
"@tauri-apps/plugin-notification": "^2.0.1",
|
||||
"@tauri-apps/plugin-sql": "^2.0.1",
|
||||
|
||||
@@ -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 |
@@ -684,6 +684,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-sql",
|
||||
@@ -5483,6 +5484,30 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-global-shortcut"
|
||||
version = "2.3.1"
|
||||
|
||||
@@ -14,10 +14,11 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||
tauri-plugin-stronghold = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
|
||||
@@ -15,6 +15,27 @@
|
||||
"updater:allow-check",
|
||||
"updater:allow-download",
|
||||
"updater:allow-install",
|
||||
"updater:allow-download-and-install"
|
||||
"updater:allow-download-and-install",
|
||||
"stronghold:default",
|
||||
"stronghold:allow-initialize",
|
||||
"stronghold:allow-load-client",
|
||||
"stronghold:allow-create-client",
|
||||
"stronghold:allow-save",
|
||||
"stronghold:allow-get-store-record",
|
||||
"stronghold:allow-save-store-record",
|
||||
"stronghold:allow-remove-store-record",
|
||||
"fs:default",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-exists",
|
||||
"fs:allow-rename",
|
||||
"fs:allow-remove",
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [
|
||||
{ "path": "$APPLOCALDATA/**" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ pub fn run() {
|
||||
let mut builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_sql::Builder::default().build())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(
|
||||
tauri_plugin_stronghold::Builder::new(|password| {
|
||||
// TODO: derive stronghold key from password using argon2 / blake2b.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ChatApp",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.0",
|
||||
"identifier": "com.meinname.chatapp",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm vite:dev",
|
||||
|
||||
@@ -61,7 +61,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const status = (error as { status?: number }).status;
|
||||
if (status === 401 || status === 403) {
|
||||
// Token genuinely invalid — wipe.
|
||||
await supabase.auth.signOut().catch(() => {
|
||||
await supabase.auth.signOut({ scope: 'local' }).catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
setSession(null);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -5,16 +5,28 @@ import { pgHexToBytes } from '@chat-app/shared/supabase';
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
// Watches the `devices` table for INSERTs and, whenever a peer registers a
|
||||
// new device that's in any of our conversations, wraps the active
|
||||
// conversation key for the freshly-arrived device. This makes Sender-Key
|
||||
// onboarding "just work" — the new device picks up the bundle from
|
||||
// `conversation_keys` and can decrypt the entire history once at least one
|
||||
// of our existing devices was online to do the wrapping.
|
||||
// Watches the `devices` table for new entries AND, on mount, scans every
|
||||
// conversation we participate in for missing key bundles. Fills gaps by
|
||||
// re-wrapping our active conv-key for the missing recipient devices.
|
||||
//
|
||||
// At-least-once delivery: if no existing device of any participant is online
|
||||
// at the moment the new device joins, the new device stays unable to decrypt
|
||||
// until SOMEONE comes online and runs this loop. Standard Signal trade-off.
|
||||
// This fixes the "cannot decrypt" cliff for devices that registered while
|
||||
// no other participant device was online to share the key with them.
|
||||
|
||||
interface SyncCtx {
|
||||
myUserId: string;
|
||||
myDeviceId: string;
|
||||
priv: Uint8Array;
|
||||
}
|
||||
|
||||
// db-types is stale for `conversation_keys`/`active_key_version`; bypass.
|
||||
function rawFrom(table: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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,
|
||||
@@ -22,9 +34,15 @@ export function startConversationKeySync(
|
||||
): () => void {
|
||||
let cancelled = false;
|
||||
let priv: Uint8Array | null = null;
|
||||
const dedupeKey = ownUserId + ':' + ownDeviceId;
|
||||
|
||||
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then((pk) => {
|
||||
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => {
|
||||
if (cancelled) return;
|
||||
priv = pk;
|
||||
if (!priv) return;
|
||||
if (backfilledKey.has(dedupeKey)) return;
|
||||
backfilledKey.add(dedupeKey);
|
||||
await syncAllExistingGaps({ myUserId: ownUserId, myDeviceId: ownDeviceId, priv });
|
||||
});
|
||||
|
||||
const channel = supabase
|
||||
@@ -36,73 +54,183 @@ export function startConversationKeySync(
|
||||
if (cancelled) return;
|
||||
const row = payload.new;
|
||||
if (!row?.id || !row.user_id || !row.public_key) return;
|
||||
// Skip our own devices — we don't need to send keys to ourselves
|
||||
// (each install bootstraps its own keys via getOrCreateConvKey).
|
||||
if (row.user_id === ownUserId && row.id === ownDeviceId) return;
|
||||
void wrapKeysForNewDevice(ownUserId, ownDeviceId, row.id, row.user_id, row.public_key);
|
||||
if (!priv) return; // backfill on mount will catch it later
|
||||
void wrapForOneDevice(
|
||||
{ myUserId: ownUserId, myDeviceId: ownDeviceId, priv },
|
||||
row.id,
|
||||
row.user_id,
|
||||
row.public_key,
|
||||
);
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
async function wrapKeysForNewDevice(
|
||||
myUserId: string,
|
||||
myDeviceId: string,
|
||||
newDeviceId: string,
|
||||
newDeviceUserId: string,
|
||||
newDevicePubKeyHex: string,
|
||||
) {
|
||||
if (!priv) {
|
||||
priv = await loadDevicePrivateKey(devLocalSecretStore, myUserId, myDeviceId);
|
||||
if (!priv) return;
|
||||
}
|
||||
const newPub = pgHexToBytes(newDevicePubKeyHex);
|
||||
const ownCtx: OwnDeviceCtx = {
|
||||
userId: myUserId,
|
||||
deviceId: myDeviceId,
|
||||
privateKey: priv,
|
||||
};
|
||||
|
||||
// Find conversations I'm in that the new device's user is also in.
|
||||
const { data: shared, error: sErr } = await supabase
|
||||
.from('conversation_members')
|
||||
.select('conversation_id')
|
||||
.eq('user_id', newDeviceUserId);
|
||||
if (sErr) {
|
||||
console.warn('keySync member lookup failed', sErr);
|
||||
return;
|
||||
}
|
||||
const peerConvs = new Set((shared ?? []).map((r) => r.conversation_id as string));
|
||||
if (peerConvs.size === 0) return;
|
||||
|
||||
const { data: mine, error: mErr } = await supabase
|
||||
.from('conversation_members')
|
||||
.select('conversation_id')
|
||||
.eq('user_id', myUserId)
|
||||
.eq('accepted', true);
|
||||
if (mErr) {
|
||||
console.warn('keySync own-member lookup failed', mErr);
|
||||
return;
|
||||
}
|
||||
|
||||
const targets: string[] = [];
|
||||
for (const row of mine ?? []) {
|
||||
const id = row.conversation_id as string;
|
||||
if (peerConvs.has(id)) targets.push(id);
|
||||
}
|
||||
|
||||
for (const convId of targets) {
|
||||
try {
|
||||
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx);
|
||||
} catch (err: unknown) {
|
||||
// Common: this device has no key for that conv yet (was offline at
|
||||
// bootstrap). Other online devices will handle it.
|
||||
console.warn('shareConvKeyToDevice failed', { convId, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}
|
||||
|
||||
async function listMyConversationIds(myUserId: string): Promise<string[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_members')
|
||||
.select('conversation_id')
|
||||
.eq('user_id', myUserId)
|
||||
.eq('accepted', true);
|
||||
if (error) {
|
||||
console.warn('keySync: own-member lookup failed', error);
|
||||
return [];
|
||||
}
|
||||
return (data ?? []).map((r) => r.conversation_id as string);
|
||||
}
|
||||
|
||||
async function listConversationDevices(
|
||||
conversationId: string,
|
||||
): Promise<{ id: string; user_id: string; public_key: string }[]> {
|
||||
const { data: members, error: mErr } = await supabase
|
||||
.from('conversation_members')
|
||||
.select('user_id')
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('accepted', true);
|
||||
if (mErr) {
|
||||
console.warn('keySync: members lookup failed', mErr);
|
||||
return [];
|
||||
}
|
||||
const userIds = (members ?? []).map((m) => m.user_id as string);
|
||||
if (userIds.length === 0) return [];
|
||||
|
||||
const { data: devices, error: dErr } = await supabase
|
||||
.from('devices')
|
||||
.select('id, user_id, public_key')
|
||||
.in('user_id', userIds);
|
||||
if (dErr) {
|
||||
console.warn('keySync: devices lookup failed', dErr);
|
||||
return [];
|
||||
}
|
||||
return (devices ?? []) as { id: string; user_id: string; public_key: string }[];
|
||||
}
|
||||
|
||||
async function listExistingKeyRecipients(
|
||||
conversationId: string,
|
||||
keyVersion: number,
|
||||
): Promise<Set<string>> {
|
||||
const { data, error } = await rawFrom('conversation_keys')
|
||||
.select('recipient_device_id')
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('key_version', keyVersion);
|
||||
if (error) {
|
||||
console.warn('keySync: existing keys lookup failed', error);
|
||||
return new Set();
|
||||
}
|
||||
return new Set((data ?? []).map((r: { recipient_device_id: string }) => r.recipient_device_id));
|
||||
}
|
||||
|
||||
async function getActiveKeyVersion(conversationId: string): Promise<number> {
|
||||
const { data, error } = await rawFrom('conversations')
|
||||
.select('active_key_version')
|
||||
.eq('id', conversationId)
|
||||
.single();
|
||||
if (error) {
|
||||
console.warn('keySync: active key version lookup failed', error);
|
||||
return 1;
|
||||
}
|
||||
return (data as { active_key_version: number }).active_key_version;
|
||||
}
|
||||
|
||||
async function syncAllExistingGaps(ctx: SyncCtx): Promise<void> {
|
||||
const convs = await listMyConversationIds(ctx.myUserId);
|
||||
for (const convId of convs) {
|
||||
try {
|
||||
await syncOneConversationGaps(ctx, convId);
|
||||
} catch (err: unknown) {
|
||||
console.warn('keySync: conv gap sync failed', { convId, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
|
||||
const version = await getActiveKeyVersion(convId);
|
||||
const devices = await listConversationDevices(convId);
|
||||
if (devices.length === 0) return;
|
||||
|
||||
const recipients = await listExistingKeyRecipients(convId, version);
|
||||
const ownCtx: OwnDeviceCtx = {
|
||||
userId: ctx.myUserId,
|
||||
deviceId: ctx.myDeviceId,
|
||||
privateKey: ctx.priv,
|
||||
};
|
||||
|
||||
for (const dev of devices) {
|
||||
if (recipients.has(dev.id)) continue;
|
||||
// Skip our own device — we already have the bundle if we're capable of
|
||||
// sharing (or don't need it if we ourselves haven't been wrapped yet).
|
||||
if (dev.id === ctx.myDeviceId) continue;
|
||||
try {
|
||||
await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx);
|
||||
} catch (err: unknown) {
|
||||
// Backfill is best-effort. Most common silent failures:
|
||||
// - tryGetConvKey couldn't unwrap (another peer will fill the gap).
|
||||
// - RLS rejects because the recipient's owner is a pending (not-yet
|
||||
// accepted) DM member, or was removed from the conv.
|
||||
// Both are recoverable / expected, swallow without spam.
|
||||
if (isExpectedShareFailure(err)) continue;
|
||||
console.warn('keySync: shareConvKeyToDevice gap-fill failed', {
|
||||
convId,
|
||||
recipient: dev.id,
|
||||
err,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isExpectedShareFailure(err: unknown): boolean {
|
||||
if (!err || typeof err !== 'object') return false;
|
||||
const e = err as { message?: string; code?: string; details?: string; status?: number };
|
||||
const code = (e.code ?? '').toString();
|
||||
const status = e.status;
|
||||
const haystack = (e.message ?? '') + ' ' + (e.details ?? '');
|
||||
return (
|
||||
status === 403 ||
|
||||
code === '42501' || // postgres: insufficient_privilege (RLS)
|
||||
code === '23505' || // unique_violation
|
||||
haystack.includes('row-level security') ||
|
||||
haystack.includes('does not have it yet') ||
|
||||
haystack.includes('Forbidden')
|
||||
);
|
||||
}
|
||||
|
||||
async function wrapForOneDevice(
|
||||
ctx: SyncCtx,
|
||||
newDeviceId: string,
|
||||
newDeviceUserId: string,
|
||||
newDevicePubHex: string,
|
||||
): Promise<void> {
|
||||
const myConvs = new Set(await listMyConversationIds(ctx.myUserId));
|
||||
const { data: peerMember, error: pErr } = await supabase
|
||||
.from('conversation_members')
|
||||
.select('conversation_id')
|
||||
.eq('user_id', newDeviceUserId);
|
||||
if (pErr) {
|
||||
console.warn('keySync: peer-member lookup failed', pErr);
|
||||
return;
|
||||
}
|
||||
const sharedConvs = (peerMember ?? [])
|
||||
.map((r) => r.conversation_id as string)
|
||||
.filter((id) => myConvs.has(id));
|
||||
if (sharedConvs.length === 0) return;
|
||||
|
||||
const newPub = pgHexToBytes(newDevicePubHex);
|
||||
const ownCtx: OwnDeviceCtx = {
|
||||
userId: ctx.myUserId,
|
||||
deviceId: ctx.myDeviceId,
|
||||
privateKey: ctx.priv,
|
||||
};
|
||||
for (const convId of sharedConvs) {
|
||||
try {
|
||||
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx);
|
||||
} catch (err: unknown) {
|
||||
console.warn('keySync: shareConvKeyToDevice failed', { convId, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
sendNotification,
|
||||
} from '@tauri-apps/plugin-notification';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
// Tracks whether permission has already been requested this session so we
|
||||
// don't spam the OS prompt. Actual permission state lives in the OS.
|
||||
let permissionChecked = false;
|
||||
@@ -12,6 +14,11 @@ let permissionGranted = false;
|
||||
export async function ensureNotificationPermission(): Promise<boolean> {
|
||||
if (permissionChecked) return permissionGranted;
|
||||
permissionChecked = true;
|
||||
if (!isTauriRuntime()) {
|
||||
// Web preview / Chrome — Tauri notification plugin not available.
|
||||
permissionGranted = false;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
let granted = await isPermissionGranted();
|
||||
if (!granted) {
|
||||
@@ -20,7 +27,6 @@ export async function ensureNotificationPermission(): Promise<boolean> {
|
||||
}
|
||||
permissionGranted = granted;
|
||||
} catch (err: unknown) {
|
||||
// Not running under Tauri (e.g. web preview) — fall back silently.
|
||||
permissionGranted = false;
|
||||
console.warn('notification permission check failed', err);
|
||||
}
|
||||
@@ -40,6 +46,7 @@ interface NotifyOpts {
|
||||
|
||||
export async function notify({ title, body, force = false }: NotifyOpts): Promise<void> {
|
||||
if (!force && isAppFocused()) return;
|
||||
if (!isTauriRuntime()) return;
|
||||
const granted = await ensureNotificationPermission();
|
||||
if (!granted) return;
|
||||
try {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { base64FromBytes, bytesFromBase64, type SecretStore } from '@chat-app/shared/auth';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
import { makeStrongholdStore, migrateLocalStorageToStronghold } from './strongholdStore';
|
||||
import { makeSecureFileStore, migrateLocalStorageToVault } from './secureFileStore';
|
||||
|
||||
// Secret store with two backends:
|
||||
// - Tauri: Stronghold-encrypted vault file in appLocalDataDir. Survives app
|
||||
// reinstalls and is encrypted at rest with a password derived from the
|
||||
// authenticated user-id.
|
||||
// - Web / pre-auth: localStorage (legacy dev fallback).
|
||||
// Two-tier SecretStore:
|
||||
// - Tauri runtime: encrypted single-file vault in `appLocalDataDir`
|
||||
// (`secureFileStore` — XSalsa20-Poly1305 + Argon2id KDF). Survives app
|
||||
// reinstalls when the OS preserves the data dir.
|
||||
// - Web / pre-auth: plain localStorage (legacy fallback).
|
||||
//
|
||||
// Callers don't need to care which one is active — they import a singleton
|
||||
// and call setSecretStoreUser(userId) once the session is known. Until that
|
||||
// happens, calls fall through to localStorage.
|
||||
// Callers import `devLocalSecretStore` and call `setSecretStoreUser(userId)`
|
||||
// once the session is known. The singleton object's identity is stable so
|
||||
// existing imports keep working.
|
||||
|
||||
const PREFIX = 'chatapp.secret:';
|
||||
|
||||
@@ -38,21 +38,27 @@ export async function setSecretStoreUser(userId: string | null): Promise<void> {
|
||||
activeUserId = userId;
|
||||
|
||||
if (userId && isTauriRuntime()) {
|
||||
const stronghold = makeStrongholdStore(userId);
|
||||
activeBackend = stronghold;
|
||||
const fileStore = makeSecureFileStore(userId);
|
||||
try {
|
||||
await migrateLocalStorageToStronghold(userId, PREFIX);
|
||||
// Probe write/read to confirm the vault is usable on this machine.
|
||||
// If anything throws (perm denied, disk full, KDF error), fall back to
|
||||
// localStorage so the rest of the app keeps working.
|
||||
await fileStore.getSecret('__probe');
|
||||
activeBackend = fileStore;
|
||||
try {
|
||||
await migrateLocalStorageToVault(userId, PREFIX);
|
||||
} catch (err: unknown) {
|
||||
console.warn('vault migration failed', err);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('stronghold migration failed', err);
|
||||
console.warn('secure file vault init failed — falling back to localStorage', err);
|
||||
activeBackend = localStore;
|
||||
}
|
||||
} else {
|
||||
activeBackend = localStore;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton with stable identity — internals delegate to whichever backend is
|
||||
// currently active. Existing call-sites that imported `devLocalSecretStore`
|
||||
// keep working without changes.
|
||||
export const devLocalSecretStore: SecretStore = {
|
||||
async getSecret(key) {
|
||||
return activeBackend.getSecret(key);
|
||||
@@ -65,6 +71,6 @@ export const devLocalSecretStore: SecretStore = {
|
||||
},
|
||||
};
|
||||
|
||||
export function isStrongholdActive(): boolean {
|
||||
export function isEncryptedVaultActive(): boolean {
|
||||
return activeBackend !== localStore;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
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';
|
||||
|
||||
// Encrypted single-file SecretStore for Tauri. Replaces the flaky
|
||||
// tauri-plugin-stronghold implementation.
|
||||
//
|
||||
// File layout (binary, little-endian):
|
||||
// bytes 0..7 magic: ASCII "CHATVLT1"
|
||||
// bytes 8..23 salt for KDF (16 bytes)
|
||||
// bytes 24..47 XSalsa20-Poly1305 nonce (24 bytes)
|
||||
// bytes 48.. secretbox(plaintext_json, key, nonce)
|
||||
//
|
||||
// `plaintext_json` is a UTF-8 JSON object { [key: string]: base64url(value) }.
|
||||
//
|
||||
// Key derivation: Argon2id (libsodium MODERATE ops/mem) over a passphrase
|
||||
// derived from the authenticated user-id + a constant. Same userId on the
|
||||
// same machine after re-install ⇒ same key ⇒ vault recovers automatically.
|
||||
//
|
||||
// Atomic writes: serialised vault is first written to `<file>.tmp` then
|
||||
// renamed onto `<file>` so an interrupted write never corrupts the existing
|
||||
// vault.
|
||||
|
||||
// Per-user vault filename so multiple accounts on the same machine each get
|
||||
// their own file (and Argon2 derives a different key per user, so cross-user
|
||||
// decrypt is also blocked even if filenames collided).
|
||||
async function vaultFileName(userId: string): Promise<string> {
|
||||
const enc = new TextEncoder();
|
||||
const buf = await crypto.subtle.digest('SHA-256', enc.encode('chatapp-vault-name:' + userId));
|
||||
const hex = Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
return 'chatapp-vault-' + hex.slice(0, 16) + '.bin';
|
||||
}
|
||||
const MAGIC = new TextEncoder().encode('CHATVLT1'); // 8 bytes
|
||||
const SALT_LEN = 16;
|
||||
const NONCE_LEN = 24;
|
||||
const KEY_LEN = 32;
|
||||
|
||||
interface VaultState {
|
||||
path: string;
|
||||
tmpPath: string;
|
||||
key: Uint8Array; // derived encryption key
|
||||
data: Map<string, Uint8Array>;
|
||||
}
|
||||
|
||||
let initPromise: Promise<VaultState> | null = null;
|
||||
let vault: VaultState | null = null;
|
||||
let initializedFor: string | null = null;
|
||||
|
||||
async function ensureSodium(): Promise<typeof sodium> {
|
||||
await sodium.ready;
|
||||
return sodium;
|
||||
}
|
||||
|
||||
function joinPath(dir: string, name: string): string {
|
||||
const sep = dir.endsWith('/') || dir.endsWith('\\') ? '' : '/';
|
||||
return dir + sep + name;
|
||||
}
|
||||
|
||||
function b64url(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function unb64url(s: string): Uint8Array {
|
||||
let str = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (str.length % 4) str += '=';
|
||||
const bin = atob(str);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function deriveKey(userId: string, salt: Uint8Array, s: typeof sodium): Promise<Uint8Array> {
|
||||
const passphrase = 'chatapp-vault-v1:' + userId;
|
||||
return s.crypto_pwhash(
|
||||
KEY_LEN,
|
||||
passphrase,
|
||||
salt,
|
||||
s.crypto_pwhash_OPSLIMIT_MODERATE,
|
||||
s.crypto_pwhash_MEMLIMIT_MODERATE,
|
||||
s.crypto_pwhash_ALG_ARGON2ID13,
|
||||
);
|
||||
}
|
||||
|
||||
async function loadOrCreateVault(userId: string): Promise<VaultState> {
|
||||
const s = await ensureSodium();
|
||||
const dir = await appLocalDataDir();
|
||||
const fileName = await vaultFileName(userId);
|
||||
const path = joinPath(dir, fileName);
|
||||
const tmpPath = path + '.tmp';
|
||||
|
||||
try {
|
||||
await mkdir(dir, { recursive: true });
|
||||
} catch {
|
||||
/* parent likely already exists */
|
||||
}
|
||||
|
||||
const fileExists = await exists(path).catch(() => false);
|
||||
if (!fileExists) {
|
||||
const salt = s.randombytes_buf(SALT_LEN);
|
||||
const key = await deriveKey(userId, salt, s);
|
||||
const state: VaultState = { path, tmpPath, key, data: new Map() };
|
||||
await persist(state, salt, s);
|
||||
return state;
|
||||
}
|
||||
|
||||
const raw = await readFile(path);
|
||||
if (raw.length < MAGIC.length + SALT_LEN + NONCE_LEN + 1) {
|
||||
throw new Error('vault file too short');
|
||||
}
|
||||
for (let i = 0; i < MAGIC.length; i++) {
|
||||
if (raw[i] !== MAGIC[i]) throw new Error('vault magic mismatch');
|
||||
}
|
||||
const salt = raw.slice(MAGIC.length, MAGIC.length + SALT_LEN);
|
||||
const nonce = raw.slice(MAGIC.length + SALT_LEN, MAGIC.length + SALT_LEN + NONCE_LEN);
|
||||
const ciphertext = raw.slice(MAGIC.length + SALT_LEN + NONCE_LEN);
|
||||
|
||||
const key = await deriveKey(userId, salt, s);
|
||||
let plain: Uint8Array;
|
||||
try {
|
||||
plain = s.crypto_secretbox_open_easy(ciphertext, nonce, key);
|
||||
} catch (err: unknown) {
|
||||
throw new Error(
|
||||
'vault decrypt failed (wrong user / corrupted file): ' +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
const json = new TextDecoder().decode(plain) || '{}';
|
||||
const obj = JSON.parse(json) as Record<string, string>;
|
||||
const data = new Map<string, Uint8Array>();
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
try {
|
||||
data.set(k, unb64url(v));
|
||||
} catch {
|
||||
/* skip malformed entries */
|
||||
}
|
||||
}
|
||||
return { path, tmpPath, key, data };
|
||||
}
|
||||
|
||||
async function persist(state: VaultState, salt: Uint8Array, s: typeof sodium): Promise<void> {
|
||||
const obj: Record<string, string> = {};
|
||||
for (const [k, v] of state.data) obj[k] = b64url(v);
|
||||
const plain = new TextEncoder().encode(JSON.stringify(obj));
|
||||
const nonce = s.randombytes_buf(NONCE_LEN);
|
||||
const ciphertext = s.crypto_secretbox_easy(plain, nonce, state.key);
|
||||
|
||||
const out = new Uint8Array(MAGIC.length + SALT_LEN + NONCE_LEN + ciphertext.length);
|
||||
out.set(MAGIC, 0);
|
||||
out.set(salt, MAGIC.length);
|
||||
out.set(nonce, MAGIC.length + SALT_LEN);
|
||||
out.set(ciphertext, MAGIC.length + SALT_LEN + NONCE_LEN);
|
||||
|
||||
// Atomic write: tmp → rename. `rename` on the same filesystem is atomic
|
||||
// on macOS, Linux, and Windows (NTFS).
|
||||
await writeFile(state.tmpPath, out);
|
||||
await rename(state.tmpPath, state.path);
|
||||
}
|
||||
|
||||
// Re-derives the salt by reading the existing file header so persist() can
|
||||
// keep using the same KDF salt across writes (we don't rotate KDF on every
|
||||
// save — only on initial vault creation).
|
||||
async function readSalt(state: VaultState): Promise<Uint8Array> {
|
||||
const raw = await readFile(state.path);
|
||||
return raw.slice(MAGIC.length, MAGIC.length + SALT_LEN);
|
||||
}
|
||||
|
||||
async function ensureInit(userId: string): Promise<VaultState> {
|
||||
if (initializedFor === userId && vault) return vault;
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = loadOrCreateVault(userId)
|
||||
.then((v) => {
|
||||
vault = v;
|
||||
initializedFor = userId;
|
||||
return v;
|
||||
})
|
||||
.finally(() => {
|
||||
initPromise = null;
|
||||
});
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
export function makeSecureFileStore(userId: string): SecretStore {
|
||||
return {
|
||||
async getSecret(key: string): Promise<Uint8Array | null> {
|
||||
const v = await ensureInit(userId);
|
||||
const found = v.data.get(key);
|
||||
return found ? new Uint8Array(found) : null;
|
||||
},
|
||||
async setSecret(key: string, value: Uint8Array): Promise<void> {
|
||||
const v = await ensureInit(userId);
|
||||
v.data.set(key, new Uint8Array(value));
|
||||
const s = await ensureSodium();
|
||||
const salt = await readSalt(v);
|
||||
await persist(v, salt, s);
|
||||
},
|
||||
async removeSecret(key: string): Promise<void> {
|
||||
const v = await ensureInit(userId);
|
||||
v.data.delete(key);
|
||||
const s = await ensureSodium();
|
||||
const salt = await readSalt(v);
|
||||
await persist(v, salt, s);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Migrates legacy localStorage entries (chatapp.secret:*) into the encrypted
|
||||
// vault on first init. Idempotent — checks for marker key.
|
||||
export async function migrateLocalStorageToVault(
|
||||
userId: string,
|
||||
prefix: string,
|
||||
): Promise<void> {
|
||||
const v = await ensureInit(userId);
|
||||
if (v.data.has('__migrated_from_localstorage')) return;
|
||||
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const fullKey = window.localStorage.key(i);
|
||||
if (!fullKey || !fullKey.startsWith(prefix)) continue;
|
||||
const raw = window.localStorage.getItem(fullKey);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const decoded = Uint8Array.from(atob(raw), (c) => c.charCodeAt(0));
|
||||
const shortKey = fullKey.slice(prefix.length);
|
||||
v.data.set(shortKey, decoded);
|
||||
} catch {
|
||||
/* skip malformed */
|
||||
}
|
||||
}
|
||||
v.data.set('__migrated_from_localstorage', new Uint8Array([1]));
|
||||
const s = await ensureSodium();
|
||||
const salt = await readSalt(v);
|
||||
await persist(v, salt, s);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type MessageWithCipher,
|
||||
sendEncryptedMessage,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { bytesToPgHex, pgHexToBytes } from '@chat-app/shared/supabase';
|
||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
@@ -44,8 +44,8 @@ function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
|
||||
editedAt: row.edited_at ? String(row.edited_at) : null,
|
||||
deletedAt: row.deleted_at ? String(row.deleted_at) : null,
|
||||
createdAt: String(row.created_at),
|
||||
ciphertext: pgHexToBytes(String(row.ciphertext ?? '\\x')),
|
||||
nonce: pgHexToBytes(String(row.nonce ?? '\\x')),
|
||||
ciphertext: pgBytesToBytes(String(row.ciphertext ?? '\\x')),
|
||||
nonce: pgBytesToBytes(String(row.nonce ?? '\\x')),
|
||||
keyVersion: typeof row.key_version === 'number' ? row.key_version : 1,
|
||||
};
|
||||
}
|
||||
@@ -98,26 +98,81 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
}
|
||||
}, [conversationId, decryptBatch]);
|
||||
|
||||
// Realtime INSERT handler — decrypt + append (with retry for envelope race).
|
||||
// Realtime INSERT handler — refetches the row via REST so we get the
|
||||
// canonical bytea encoding (postgres_changes payloads serialize bytea
|
||||
// differently and decoding them inline is brittle). Then decrypt + append.
|
||||
// Skips if the message is already in state (e.g. optimistic insert from our
|
||||
// own send), so the sender's cached copy isn't overwritten with a flicker.
|
||||
const handleInsert = useCallback(
|
||||
async (row: Record<string, unknown>) => {
|
||||
if (!deviceId) return;
|
||||
const msg = rowToMessage(row);
|
||||
let decrypted: DecryptedMessage = { ...msg, plaintext: null };
|
||||
if (!conversationId || !deviceId) return;
|
||||
const id = String(row.id);
|
||||
|
||||
let alreadyHave = false;
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === id)) alreadyHave = true;
|
||||
return prev;
|
||||
});
|
||||
if (alreadyHave) return;
|
||||
|
||||
let decrypted: DecryptedMessage | null = null;
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
const { data, error } = await supabase
|
||||
.from('messages')
|
||||
.select(
|
||||
'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at, ciphertext, nonce, key_version',
|
||||
)
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
console.warn('handleInsert refetch failed', error);
|
||||
return;
|
||||
}
|
||||
if (!data) {
|
||||
await new Promise((r) => window.setTimeout(r, 120 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
// db-types snapshot predates the sender-key columns; cast to bypass.
|
||||
const r = data as unknown as {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
sender_id: string;
|
||||
sender_device_id: string | null;
|
||||
reply_to_id: string | null;
|
||||
edited_at: string | null;
|
||||
deleted_at: string | null;
|
||||
created_at: string;
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
key_version: number;
|
||||
};
|
||||
const msg: MessageWithCipher = {
|
||||
id: r.id,
|
||||
conversationId: r.conversation_id,
|
||||
senderId: r.sender_id,
|
||||
senderDeviceId: r.sender_device_id,
|
||||
replyToId: r.reply_to_id,
|
||||
editedAt: r.edited_at,
|
||||
deletedAt: r.deleted_at,
|
||||
createdAt: r.created_at,
|
||||
ciphertext: pgBytesToBytes(String(r.ciphertext)),
|
||||
nonce: pgBytesToBytes(String(r.nonce)),
|
||||
keyVersion: r.key_version,
|
||||
};
|
||||
const [d] = await decryptBatch([msg]);
|
||||
if (d) {
|
||||
decrypted = d;
|
||||
if (d.plaintext !== null) break;
|
||||
}
|
||||
await new Promise((r) => window.setTimeout(r, 120 * (attempt + 1)));
|
||||
await new Promise((r) => window.setTimeout(r, 200 * (attempt + 1)));
|
||||
}
|
||||
if (!decrypted) return;
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === decrypted.id)) return prev;
|
||||
return { ...prev, messages: [...prev.messages, decrypted] };
|
||||
if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
|
||||
return { ...prev, messages: [...prev.messages, decrypted!] };
|
||||
});
|
||||
},
|
||||
[deviceId, decryptBatch],
|
||||
[conversationId, deviceId, decryptBatch],
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
@@ -183,6 +238,22 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
}
|
||||
},
|
||||
)
|
||||
// When a peer device wraps the conversation-key for us (e.g. we just
|
||||
// registered a fresh device), re-decrypt the visible messages.
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'conversation_keys',
|
||||
filter: 'conversation_id=eq.' + conversationId,
|
||||
},
|
||||
(payload: { new: { recipient_device_id?: string } }) => {
|
||||
if (payload.new?.recipient_device_id === deviceId) {
|
||||
void refresh();
|
||||
}
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
@@ -219,7 +290,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
|
||||
}
|
||||
|
||||
// 2. Send message (inserts messages + envelopes in one helper).
|
||||
// 2. Send message (inserts messages + per-conversation key bundles).
|
||||
const msg = await sendEncryptedMessage({
|
||||
client: supabase,
|
||||
conversationId,
|
||||
@@ -230,7 +301,28 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
...(handles.length > 0 ? { attachmentHandles: handles } : {}),
|
||||
});
|
||||
|
||||
// 3. Insert public attachment metadata rows pointing at the new message.
|
||||
// 3. Optimistic insert — we already have the plaintext in hand and the
|
||||
// server returned the row id, so add the message to local state
|
||||
// immediately. Realtime will then no-op (handleInsert dedupes by id).
|
||||
const attachmentsPayload =
|
||||
handles.length === 0
|
||||
? trimmed
|
||||
: JSON.stringify({ v: 1, text: trimmed, attachments: handles });
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||
return {
|
||||
...prev,
|
||||
messages: [
|
||||
...prev.messages,
|
||||
{
|
||||
...msg,
|
||||
plaintext: attachmentsPayload,
|
||||
} as DecryptedMessage,
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// 4. Insert public attachment metadata rows pointing at the new message.
|
||||
for (const h of handles) {
|
||||
const blobNonce = blobNonceHexByHandleId.get(h.id) ?? '\\x';
|
||||
await insertAttachmentRow(supabase, msg.id, h, blobNonce);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>ChatApp Redesign — 6 Varianten</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@500;600;700;800&family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Fraunces:wght@600;700;800&family=Archivo+Black&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="styles/base.css">
|
||||
<link rel="stylesheet" href="styles/variants.css">
|
||||
<link rel="stylesheet" href="styles/rail.css">
|
||||
<link rel="stylesheet" href="styles/call.css">
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- TWEAKS state -->
|
||||
<script>
|
||||
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||
"showCall": false,
|
||||
"density": "normal",
|
||||
"font": "default"
|
||||
}/*EDITMODE-END*/;
|
||||
</script>
|
||||
|
||||
<!-- Variant switcher -->
|
||||
<div class="variant-switcher" id="variant-switcher"></div>
|
||||
|
||||
<div class="variant-container" id="root"></div>
|
||||
|
||||
<!-- Tweaks panel -->
|
||||
<div class="tweaks-panel" id="tweaks-panel">
|
||||
<h3>Tweaks</h3>
|
||||
<div class="tweaks-row">
|
||||
<label>Incoming call overlay</label>
|
||||
<select id="tweak-call">
|
||||
<option value="false">Off</option>
|
||||
<option value="true">Show</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="tweaks-row">
|
||||
<label>Content density</label>
|
||||
<select id="tweak-density">
|
||||
<option value="normal">Normal</option>
|
||||
<option value="compact">Compact</option>
|
||||
<option value="spacious">Spacious</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="tweaks-row" style="font-size: 11px; opacity: 0.6; margin-top: 16px;">
|
||||
Tipp: Klicke oben auf die Varianten um zu wechseln.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="components/icons.jsx" type="text/babel"></script>
|
||||
<script src="components/data.jsx" type="text/babel"></script>
|
||||
<script src="components/call.jsx" type="text/babel"></script>
|
||||
<script src="components/app.jsx" type="text/babel"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
const VARIANTS = [
|
||||
{ key: 'clean', label: 'Clean Minimal', layout: 'topnav' },
|
||||
{ key: 'clean-rail', label: 'Clean Rail', baseKey: 'clean', layout: 'rail' },
|
||||
{ key: 'playful', label: 'Playful', layout: 'topnav' },
|
||||
{ key: 'y2k', label: 'Y2K Glass', layout: 'topnav' },
|
||||
{ key: 'cyber', label: 'Cyberpunk', layout: 'topnav' },
|
||||
{ key: 'warm', label: 'Warm Soft', layout: 'topnav' },
|
||||
{ key: 'brutal', label: 'Brutal Gaming', layout: 'topnav' },
|
||||
];
|
||||
|
||||
const App = () => {
|
||||
const [activeVariant, setActiveVariant] = React.useState(() => {
|
||||
return localStorage.getItem('chatapp-variant') || 'clean';
|
||||
});
|
||||
const [isDark, setIsDark] = React.useState(() => {
|
||||
return localStorage.getItem('chatapp-dark') === 'true';
|
||||
});
|
||||
const [tweaks, setTweaks] = React.useState(TWEAK_DEFAULTS);
|
||||
|
||||
React.useEffect(() => {
|
||||
localStorage.setItem('chatapp-variant', activeVariant);
|
||||
}, [activeVariant]);
|
||||
React.useEffect(() => {
|
||||
localStorage.setItem('chatapp-dark', String(isDark));
|
||||
}, [isDark]);
|
||||
|
||||
// Tweaks wiring
|
||||
React.useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (e.data?.type === '__activate_edit_mode') {
|
||||
document.getElementById('tweaks-panel').classList.add('open');
|
||||
} else if (e.data?.type === '__deactivate_edit_mode') {
|
||||
document.getElementById('tweaks-panel').classList.remove('open');
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handler);
|
||||
window.parent.postMessage({type: '__edit_mode_available'}, '*');
|
||||
|
||||
const callSel = document.getElementById('tweak-call');
|
||||
const densSel = document.getElementById('tweak-density');
|
||||
callSel.value = String(tweaks.showCall);
|
||||
densSel.value = tweaks.density;
|
||||
callSel.onchange = () => {
|
||||
const v = callSel.value === 'true';
|
||||
setTweaks(t => ({...t, showCall: v}));
|
||||
window.parent.postMessage({type: '__edit_mode_set_keys', edits: {showCall: v}}, '*');
|
||||
};
|
||||
densSel.onchange = () => {
|
||||
setTweaks(t => ({...t, density: densSel.value}));
|
||||
window.parent.postMessage({type: '__edit_mode_set_keys', edits: {density: densSel.value}}, '*');
|
||||
};
|
||||
|
||||
return () => window.removeEventListener('message', handler);
|
||||
}, []);
|
||||
|
||||
// Render variant switcher buttons
|
||||
React.useEffect(() => {
|
||||
const sw = document.getElementById('variant-switcher');
|
||||
sw.innerHTML = '';
|
||||
VARIANTS.forEach((v, i) => {
|
||||
const b = document.createElement('button');
|
||||
b.textContent = `${String(i+1).padStart(2,'0')} ${v.label}`;
|
||||
if (v.key === activeVariant) b.classList.add('active');
|
||||
b.onclick = () => setActiveVariant(v.key);
|
||||
sw.appendChild(b);
|
||||
});
|
||||
}, [activeVariant]);
|
||||
|
||||
const idx = VARIANTS.findIndex(v => v.key === activeVariant);
|
||||
const variant = { ...VARIANTS[idx], idx };
|
||||
|
||||
return (
|
||||
<ChatApp
|
||||
key={activeVariant}
|
||||
variant={variant}
|
||||
showCall={tweaks.showCall}
|
||||
showProfile={false}
|
||||
isDark={isDark}
|
||||
onToggleDark={() => setIsDark(d => !d)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,314 @@
|
||||
# Handoff: ChatApp — Clean Rail Redesign + Discord-Style Call UI
|
||||
|
||||
## Overview
|
||||
|
||||
This package contains the full design spec for redesigning the existing ChatApp (Netralax) interface around a **Clean Rail** layout aesthetic, plus a complete **Discord-style call system** (incoming call, in-call dock, picture-in-picture, fullscreen cinema mode).
|
||||
|
||||
The redesign covers:
|
||||
1. The main chat interface (icon-rail sidebar + chat list + conversation area) in both **light** and **dark** mode.
|
||||
2. The call UX: incoming call overlay → active call docked above the chat (so users can keep typing while on a call) → fullscreen cinema mode → minimizable PiP bubble.
|
||||
|
||||
## About the Design Files
|
||||
|
||||
The files in this bundle are **design references created in HTML/React/CSS** — prototypes showing the intended look, layout, and behavior. They are **not production code** to copy verbatim.
|
||||
|
||||
Your task is to **recreate these designs inside the existing ChatApp codebase**, using its established patterns (existing React components, styling conventions, state management, icon library, etc.). If some tokens or helper components don't exist yet in the codebase, add them but keep them aligned with what's already there.
|
||||
|
||||
The design files are organized as:
|
||||
- `ChatApp Redesign.html` — entry HTML that boots the prototype
|
||||
- `components/app.jsx` — main layout (rail + chat list + chat area, plus call state machine)
|
||||
- `components/call.jsx` — all call UI components (IncomingCallScreen, ActiveCallScreen, ParticipantTile, CallControls, PipCall)
|
||||
- `components/icons.jsx` — SVG icon set used
|
||||
- `components/data.jsx` — dummy chat/message data
|
||||
- `styles/base.css` — shared base tokens + layout primitives
|
||||
- `styles/variants.css` — `variant-clean` light + dark token definitions (this is the look you're targeting)
|
||||
- `styles/rail.css` — rail-layout specifics
|
||||
- `styles/call.css` — **all call UI styles** (the most important file for the call feature)
|
||||
- `screenshots/` — reference images of each state
|
||||
|
||||
## Fidelity
|
||||
|
||||
**High-fidelity.** Exact colors, radii, spacing, typography and motion are specified. Recreate pixel-perfectly using the codebase's existing component library — but prefer the codebase's primitives over duplicating the prototype's raw CSS where equivalents exist.
|
||||
|
||||
---
|
||||
|
||||
## Design Tokens (Clean Rail)
|
||||
|
||||
### Colors — Light mode (`.variant-clean`)
|
||||
```
|
||||
--bg: #fafaf9 (app background)
|
||||
--bg-2: #f4f4f2 (secondary surfaces, hover, search input)
|
||||
--bg-3: #ffffff (elevated surfaces — chat list, chat area, cards)
|
||||
--fg: #0a0a0f (primary text)
|
||||
--fg-muted: #737380 (secondary text, icons)
|
||||
--line: rgba(10,10,15,0.08) (dividers, borders)
|
||||
--accent: #4F46E5 (indigo — primary actions, active states, my-bubble)
|
||||
--accent-fg: #ffffff
|
||||
```
|
||||
|
||||
### Colors — Dark mode (`.variant-clean.dark`)
|
||||
```
|
||||
--bg: #050507
|
||||
--bg-2: #111118
|
||||
--bg-3: #0A0A0F
|
||||
--fg: #f4f4f5
|
||||
--fg-muted: #8a8a99
|
||||
--line: rgba(255,255,255,0.07)
|
||||
--accent: #6D73FF (slightly lighter indigo for contrast)
|
||||
--accent-fg: #ffffff
|
||||
```
|
||||
|
||||
### Status / Semantic Colors (both modes)
|
||||
```
|
||||
Speaking/Online/Success: #16a34a light · #22c55e / #4ade80 dark
|
||||
Danger/Decline/Hangup: #dc2626 (hover #b91c1c) · #fb7185 dark accent
|
||||
Warning/Idle: #f59e0b
|
||||
Live/Recording: #ef4444
|
||||
E2EE indicator (light): #16a34a (dark: #4ade80)
|
||||
```
|
||||
|
||||
### Avatar color swatches (assigned per chat)
|
||||
```
|
||||
violet: bg #ddd6fe / fg #5b21b6 (dark: #312E81 / #C4B5FD)
|
||||
amber: bg #fde68a / fg #78350f
|
||||
rose: bg #fecdd3 / fg #881337
|
||||
teal: bg #99f6e4 / fg #134e4a (dark: #134e4a / #99f6e4)
|
||||
```
|
||||
|
||||
### Typography
|
||||
- Family: `'Inter', system-ui, sans-serif` — Inter is the base. Display/headline use `'Outfit'` (participant name in IncomingCall).
|
||||
- Scale:
|
||||
- Chat title / participant name large: 24px / 700
|
||||
- Primary labels, chat name: 14–15px / 600
|
||||
- Body (bubbles, messages): 14px / 400–500
|
||||
- Meta, timestamps, presence: 11–12px / 400–500
|
||||
- Rail tooltips, badges: 10–11px / 500–600
|
||||
- Uppercase eyebrow (e.g. "EINGEHENDER ANRUF"): 12px / 600 / letter-spacing 0.12em
|
||||
|
||||
### Spacing
|
||||
- Container padding: 16px (call-stage), 14–20px (topbars, composer)
|
||||
- Tile gap in grid: 8–10px
|
||||
- Control-button row gap: 8–10px
|
||||
- Border radius scale: 4 (small), 8 (tiles/badges), 10 (buttons), 14 (cards, large buttons), 18 (bubbles, bottom-fs controls), 24 (modal/card)
|
||||
|
||||
### Shadows
|
||||
- Elevated card light: `0 20px 60px rgba(0,0,0,0.1)`
|
||||
- Elevated card dark: `0 20px 60px rgba(0,0,0,0.5)`
|
||||
- Accent button: `0 4px 14px rgba(22,163,74,0.3)` (green accept) / same pattern for accent
|
||||
- PiP glow: `0 12px 40px rgba(0,0,0,0.2), 0 0 0 3px rgba(79,70,229,0.15)`
|
||||
|
||||
### Motion
|
||||
- Standard UI transition: `all 0.15s`
|
||||
- Pulse ring (incoming call): `2s ease-out infinite`, scale 0.85→1.25, opacity 0.6→0
|
||||
- Audio pulse (speaking ring): `1.3s ease-out infinite`, scale 1→1.3
|
||||
- Live dot: `1.5s ease-in-out infinite`, opacity 1↔0.4
|
||||
- PiP entrance: `slideInCall 0.4s cubic-bezier(0.22,1,0.36,1)`
|
||||
- Fullscreen hint fade: 3.5s total (0–15% fade in, 75–100% fade out)
|
||||
|
||||
---
|
||||
|
||||
## Screens / Views
|
||||
|
||||
### 1. Main Chat — Clean Rail Layout
|
||||
|
||||
**Structure (left → right):**
|
||||
- **Icon Rail** (72px wide): brand logo at top, tab buttons (Chats / Friends / Admin), spacer, Settings + theme-toggle at bottom. Active tab shows a vertical pill indicator on the left edge.
|
||||
- **Chat List** (280px wide): header with title + "new chat" icon, search input, list of `ChatItem`s (avatar + name + time + preview, optional unread dot), `rail-user` footer showing own profile + quick mute/settings.
|
||||
- **Chat Area** (flex, fills rest): chat header (counterpart avatar + name + presence/handle + search/voice/video buttons), messages scroll area, composer at bottom.
|
||||
|
||||
**Chat header actions (right side):** search icon, phone icon, video icon. Phone triggers voice call (`callScreen → 'active'`, `callState.video = false`), Video triggers video call (`callState.video = true`).
|
||||
|
||||
**Message bubbles:**
|
||||
- Mine (outgoing): filled accent bg, white text, radius `18px 18px 4px 18px`.
|
||||
- Theirs (incoming): transparent bg, 1px line border, fg text, radius `18px 18px 18px 4px`.
|
||||
- Avatars appear only on the **last message of a run** from that sender.
|
||||
- Reactions: pill row below bubble, `.mine` = filled accent.
|
||||
- System events ("Kaiwandi hat einen Anruf gestartet — Anruf beitreten"): centered `event-pill`, muted bg, muted fg, 11px.
|
||||
|
||||
**Composer:** plus button (attach), rounded input bar with inline emoji + mic icons, send button.
|
||||
|
||||
---
|
||||
|
||||
### 2. Incoming Call Overlay
|
||||
|
||||
Full-bleed overlay replacing the chat area (takes over `.chat-area` space), radial-gradient background tinted with accent.
|
||||
|
||||
Centered card (420px wide):
|
||||
- Eyebrow "EINGEHENDER ANRUF" (uppercase, 12px, tracked)
|
||||
- Large avatar (96px) with **two pulsing rings** (staggered 1s)
|
||||
- Caller name (24px / 700 / Outfit)
|
||||
- Subtitle row: lock icon + "E2E verschlüsselt · Voice Call"
|
||||
- Two side-by-side action buttons:
|
||||
- **Ablehnen**: transparent, red border/text, hover = 8% red bg
|
||||
- **Annehmen**: green #16a34a, white, shadow
|
||||
|
||||
### 3. Active Call — Docked (Default)
|
||||
|
||||
**CRITICAL layout decision:** The active call does NOT take over the chat area. Instead it renders as a **dock above the messages**, taking roughly 50% of the chat-area height (min 280px, max 420px). The chat header is **hidden** while the dock is active (redundant — call topbar shows the channel name). Messages + composer remain fully visible and usable below, so users can keep chatting during the call. This is the Discord desktop pattern.
|
||||
|
||||
**Dock content:**
|
||||
- **Call topbar**: left side shows group icon + "Die Squad" + duration (tabular-nums), with E2E verschlüsselt below in success-green. Right side has 3 mode toggles: Grid / Focus / Fullscreen.
|
||||
- **Call stage**: fills the remaining space, renders either:
|
||||
- **Grid**: CSS grid, 2×2 for 4 people, 3×2 for 5–6, 1fr 1fr for 2.
|
||||
- **Focus**: main speaker tile takes most height, bottom strip with 110px-tall smaller tiles for others.
|
||||
- **Fullscreen**: switches to `mode === 'fullscreen'` — see screen 4.
|
||||
- **Call controls bar** at the bottom of the dock: Mic, Video, Screen-share, Participants, **Hangup** (wider, red). Compact 36px buttons when docked.
|
||||
|
||||
### 4. Active Call — Fullscreen Cinema Mode
|
||||
|
||||
When mode = `fullscreen`:
|
||||
- Entire call-dock container becomes absolute-positioned and takes over everything below the window bar (chat list still visible on the left).
|
||||
- The **call topbar is hidden** entirely.
|
||||
- Main speaker fills the whole frame, no border/radius.
|
||||
- Participants strip moves to a **floating column in the bottom-right** (`.fs-strip`): 180px wide, 100px tall tiles, glass-blurred bg.
|
||||
- **Call controls bar floats** at bottom center — glass pill: `rgba(10,10,15,0.7)` + `backdrop-filter: blur(20px)` + subtle border + 18px radius.
|
||||
- **"Esc zum Verlassen" hint** fades in top-center on entry and auto-fades out over 3.5s. Pressing Esc or clicking Grid/Focus buttons exits.
|
||||
- **No visible X button** (intentional — keyboard-only exit, confirmed design decision).
|
||||
|
||||
### 5. Participant Tile — Shared Component
|
||||
|
||||
A participant is either in **audio-only**, **video**, or **sharing screen** mode.
|
||||
- Tile border: 1px `var(--line)`, radius 14 (10 in dock). Hover → border becomes accent. `.speaking` → 2px green border + green glow shadow. `.focused` → accent border.
|
||||
- **Audio-only**: centered avatar (72px large, 44px small). When `speaking`, a green ring pulses around the avatar (`::before` pseudo).
|
||||
- **Video**: placeholder stub (gradient background + centered avatar, since real video not in scope here).
|
||||
- **Screenshare**: renders a "fake window" — chrome row with 3 dots, content row with grey skeleton lines + one accent-tinted block. A glass badge top-left: monitor icon + "Max teilt Bildschirm" (badge hidden on small tiles).
|
||||
- **Meta row** (absolute, bottom 8px, glass): left shows crown (if me) + name + lock (if E2EE), right shows muted mic icon (red chip) and/or sharing icon (green chip).
|
||||
|
||||
### 6. Picture-in-Picture (PiP)
|
||||
|
||||
A compact 260px pill docked bottom-right of the chat area:
|
||||
- 44px preview square (avatar of speaker, or mini shared-window placeholder if someone's sharing)
|
||||
- Title "Die Squad · {participantCount}"
|
||||
- Subtitle row: red pulsing dot + "Live · tippe zum Öffnen"
|
||||
- Circular red hangup button on the right
|
||||
- Whole thing is clickable → expands back to active call
|
||||
- Entrance animation: slide from bottom + fade in (`slideInCall`)
|
||||
- Shadow: standard + 3px accent glow ring
|
||||
|
||||
---
|
||||
|
||||
## Interactions & Behavior
|
||||
|
||||
### Call State Machine (lives in `ChatApp`)
|
||||
|
||||
```js
|
||||
// Primary state
|
||||
const [callScreen, setCallScreen] = useState('none'); // 'none' | 'incoming' | 'active' | 'pip'
|
||||
const [callMode, setCallMode] = useState('grid'); // 'grid' | 'focus' | 'fullscreen'
|
||||
const [focusedId, setFocusedId] = useState(firstSharingOrSpeakerId);
|
||||
const [callState, setCallState] = useState({ muted: false, sharing: false, video: false, duration });
|
||||
```
|
||||
|
||||
### Triggers & Transitions
|
||||
- Chat-header **phone icon** click → `setCallScreen('active')`, `callState.video = false`
|
||||
- Chat-header **video icon** click → `setCallScreen('active')`, `callState.video = true`
|
||||
- Grid tile click → `setFocusedId(id); setCallMode('focus')`
|
||||
- Focus/Grid/Fullscreen buttons in call topbar → set mode directly
|
||||
- Incoming "Annehmen" → `setCallScreen('active')`
|
||||
- Incoming "Ablehnen" → `setCallScreen('none')`
|
||||
- Hangup → `setCallScreen('none')`
|
||||
- PiP clicked → `setCallScreen('active')`
|
||||
- PiP hangup (circle red btn) → `stopPropagation()` + `setCallScreen('none')`
|
||||
- **Esc key** (global listener while `callScreen === 'active' && callMode === 'fullscreen'`) → `setCallMode('grid')`
|
||||
|
||||
### Chat Header Visibility Rule
|
||||
```
|
||||
if (callScreen === 'active' && callMode !== 'fullscreen') → hide chat header
|
||||
else → show chat header
|
||||
```
|
||||
|
||||
### Keyboard
|
||||
- `Esc` exits fullscreen (only). No other global keybindings.
|
||||
|
||||
---
|
||||
|
||||
## State Management
|
||||
|
||||
Data shapes to wire up (see `components/call.jsx`):
|
||||
|
||||
```ts
|
||||
type Participant = {
|
||||
id: string;
|
||||
name: string;
|
||||
initial: string;
|
||||
color: 'violet' | 'amber' | 'rose' | 'teal';
|
||||
speaking: boolean;
|
||||
muted: boolean;
|
||||
video: boolean;
|
||||
sharing?: boolean;
|
||||
me?: boolean;
|
||||
e2ee: boolean;
|
||||
};
|
||||
|
||||
type CallState = {
|
||||
muted: boolean;
|
||||
sharing: boolean;
|
||||
video: boolean;
|
||||
duration: string; // "HH:MM:SS"
|
||||
};
|
||||
```
|
||||
|
||||
In production these all come from your real-time signaling layer (WebRTC, LiveKit, etc.). The prototype uses static data for layout purposes only — wire them to your call store.
|
||||
|
||||
---
|
||||
|
||||
## Assets
|
||||
|
||||
No custom imagery. Everything is SVG icons (inlined in `components/icons.jsx`) and CSS. Fonts from Google Fonts: `Inter`, `Outfit`.
|
||||
|
||||
Icons used in this scope: Chat, Friends, Shield, Settings, Logo, AddUser, Search, Phone, Video, Plus, Smile, Mic, MicOff, Send, Sun, Moon, Monitor, Lock, Crown, Grid, Focus, Maximize, PhoneOff.
|
||||
|
||||
Replace with your codebase's icon library if one exists (e.g. lucide-react, heroicons) — names match lucide conventions.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
1. **Tokens**: add the Clean-variant light + dark CSS variables to your theme layer. If you already have a token system, map them to your existing names; otherwise create `--bg`, `--bg-2`, `--bg-3`, `--fg`, `--fg-muted`, `--line`, `--accent`, `--accent-fg`.
|
||||
2. **Icon rail + chat list + chat area**: refactor the current top-nav layout into the 3-column rail layout. Use your existing `Avatar`, `ChatItem`, `Message` components — only restyle.
|
||||
3. **Message bubbles**: outlined `them` / filled `me`, asymmetric radius, avatars only on last-of-run.
|
||||
4. **Call state machine**: add the four `useState` hooks to whatever component owns the chat screen. Hook the Esc key effect.
|
||||
5. **IncomingCallScreen**: new component, full-bleed overlay of `.chat-area`.
|
||||
6. **Call dock**: new component, renders above `.messages`. Hide chat-header while docked.
|
||||
7. **ParticipantTile**: audio / video / sharing modes, speaking ring, meta overlay.
|
||||
8. **Fullscreen mode**: switch call container to position absolute, floating strip + floating controls + Esc hint.
|
||||
9. **PiP**: minimizable widget bottom-right of chat area.
|
||||
10. **Wire real data**: replace `CALL_PARTICIPANTS` dummy with your live call-session participants.
|
||||
|
||||
---
|
||||
|
||||
## Files in This Handoff
|
||||
|
||||
```
|
||||
design_handoff_chatapp/
|
||||
├── README.md (this file)
|
||||
├── ChatApp Redesign.html (entry — open in browser to see the prototype)
|
||||
├── components/
|
||||
│ ├── app.jsx (chat app + rail layout + call state machine)
|
||||
│ ├── call.jsx (Incoming / Active / Pip / ParticipantTile / CallControls)
|
||||
│ ├── icons.jsx (all SVG icons)
|
||||
│ └── data.jsx (dummy chats, messages)
|
||||
├── styles/
|
||||
│ ├── base.css
|
||||
│ ├── variants.css (Clean light + dark tokens — this is your theme)
|
||||
│ ├── rail.css (rail layout)
|
||||
│ └── call.css (all call UI — highest priority file)
|
||||
└── screenshots/
|
||||
├── 01-chat-light.png
|
||||
├── 03-chat-dark.png
|
||||
├── 04-incoming-dark.png
|
||||
├── 05-incall-grid-dark.png
|
||||
├── 06-incall-focus-dark.png
|
||||
├── 07-incall-fullscreen-dark.png
|
||||
├── 09-pip-dark.png
|
||||
└── 11-incall-light.png
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for Claude Code
|
||||
|
||||
- The Clean Rail variant is **Variante 2** in the prototype's variant switcher — ignore the other 5 aesthetic variants (Playful, Y2K, Cyberpunk, Warm, Brutal), they were design exploration and not the chosen direction.
|
||||
- Prefer composition over copying raw CSS. If the codebase has a `Card`, `Button`, `Tooltip`, use them.
|
||||
- Duration ("00:12:47") should tick live from the call start timestamp, not be static.
|
||||
- For WebRTC/media handling: this package specifies **UI only**. The media/signaling layer is out of scope and already exists in the codebase (see `CallUI.tsx`, `InCallPanel.tsx`).
|
||||
@@ -0,0 +1,439 @@
|
||||
// Main ChatApp component — layout shared across all variants
|
||||
|
||||
const Avatar = ({ chat, size = '' }) => (
|
||||
<div className={`avatar ${size}`} data-color={chat.color}>
|
||||
{chat.initial}
|
||||
{chat.presence && <div className={`presence ${chat.presence}`}></div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ChatItem = ({ chat, onClick }) => (
|
||||
<div className={`chat-item ${chat.active ? 'active' : ''}`} onClick={onClick}>
|
||||
<Avatar chat={chat} />
|
||||
<div className="chat-item-body">
|
||||
<div className="chat-item-name">
|
||||
<span>{chat.name}</span>
|
||||
<span className="chat-item-time">{chat.time}</span>
|
||||
</div>
|
||||
<div className="chat-item-preview">
|
||||
{chat.preview}
|
||||
{chat.unread && <span className="unread-dot" style={{background: 'var(--accent, #a78bfa)', display: 'inline-block'}}></span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Reactions = ({ reactions }) => (
|
||||
<div className="reactions">
|
||||
{reactions.map((r, i) => (
|
||||
<div key={i} className={`reaction ${r.mine ? 'mine' : ''}`}>
|
||||
<span>{r.emoji}</span>
|
||||
<span className="count">{r.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Bubble = ({ msg }) => (
|
||||
<div className={`bubble ${msg.from}`}>
|
||||
{msg.text}
|
||||
<span className="ts">{msg.time}{msg.read && msg.from === 'me' ? ' · Gelesen' : ''}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MediaBubble = ({ msg }) => (
|
||||
<div className="bubble me" style={{padding: 4}}>
|
||||
<div className="media-preview">
|
||||
<div className="media-image">{msg.label}</div>
|
||||
<div className="media-meta">
|
||||
<span>{msg.filename}</span>
|
||||
<span>{msg.size}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="ts" style={{paddingLeft: 8, paddingBottom: 4}}>{msg.time}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const EventPill = ({ evt }) => {
|
||||
const IconComp = evt.icon === 'phone' ? Icon.Phone : Icon.PhoneOff;
|
||||
return (
|
||||
<div className="event-row">
|
||||
<div className={`event-pill ${evt.kind === 'rejected' ? 'rejected' : ''}`}>
|
||||
<IconComp />
|
||||
<span>{evt.text}</span>
|
||||
{evt.duration && <span>· {evt.duration}</span>}
|
||||
<span>· {evt.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TypingIndicator = () => (
|
||||
<div className="bubble them" style={{padding: '10px 14px'}}>
|
||||
<div className="typing"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MessageItem = ({ msg, showAvatar, chat, myChat }) => {
|
||||
if (msg.type === 'event') return <EventPill evt={msg} />;
|
||||
|
||||
const wrapWithAvatar = (inner) => (
|
||||
<div className={`message-row ${msg.from}`}>
|
||||
{msg.from === 'them' && (
|
||||
<div className="message-avatar-slot">
|
||||
{showAvatar && <Avatar chat={{...chat, presence: null}} size="sm" />}
|
||||
</div>
|
||||
)}
|
||||
<div className={`message-group ${msg.from}`}>
|
||||
{inner}
|
||||
</div>
|
||||
{msg.from === 'me' && (
|
||||
<div className="message-avatar-slot">
|
||||
{showAvatar && <Avatar chat={{...myChat, presence: null}} size="sm" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (msg.type === 'typing') {
|
||||
return wrapWithAvatar(<TypingIndicator />);
|
||||
}
|
||||
if (msg.type === 'media') {
|
||||
return wrapWithAvatar(<MediaBubble msg={msg} />);
|
||||
}
|
||||
return wrapWithAvatar(
|
||||
<>
|
||||
<Bubble msg={msg} />
|
||||
{msg.reactions && <Reactions reactions={msg.reactions} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileCard = ({ chat, position }) => (
|
||||
<div className="profile-card" style={{top: position.top, left: position.left}}>
|
||||
<div className="profile-card-banner"></div>
|
||||
<div className="profile-card-body">
|
||||
<div className="profile-card-avatar" data-color={chat.color} style={{background: 'var(--accent, #a78bfa)', color: '#fff'}}>
|
||||
{chat.initial}
|
||||
</div>
|
||||
<div className="profile-card-name">{chat.name}</div>
|
||||
<div className="profile-card-handle">{chat.handle}</div>
|
||||
<div className="profile-card-meta">
|
||||
<div>🟢 Online · seit 2h</div>
|
||||
<div>📍 Beigetreten am 14. Mär 2026</div>
|
||||
<div>🎮 Zockt gerade: Valorant</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CallOverlay = ({ chat }) => (
|
||||
<div className="call-overlay">
|
||||
<Avatar chat={{...chat, presence: null}} size="lg" />
|
||||
<div className="call-info">
|
||||
<div className="call-name">Max ruft an…</div>
|
||||
<div className="call-sub">Voice Call · klingelt</div>
|
||||
</div>
|
||||
<div className="call-actions">
|
||||
<button className="call-btn decline"><Icon.PhoneOff /></button>
|
||||
<button className="call-btn accept"><Icon.Phone /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
|
||||
const [activeChat, setActiveChat] = React.useState(CHATS[0]);
|
||||
const [activeTab, setActiveTab] = React.useState('chats');
|
||||
|
||||
// Call state: 'none' | 'incoming' | 'active' | 'pip'
|
||||
const [callScreen, setCallScreen] = React.useState('none');
|
||||
const [callMode, setCallMode] = React.useState('grid'); // grid | focus | fullscreen
|
||||
const [focusedId, setFocusedId] = React.useState('max');
|
||||
const [callState, setCallState] = React.useState({
|
||||
muted: false, sharing: false, video: false, duration: '00:12:47'
|
||||
});
|
||||
|
||||
// Esc exits fullscreen
|
||||
React.useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape' && callScreen === 'active' && callMode === 'fullscreen') {
|
||||
setCallMode('grid');
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [callScreen, callMode]);
|
||||
|
||||
const chats = CHATS.map(c => ({...c, active: c.id === activeChat.id}));
|
||||
const messagesEndRef = React.useRef(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.parentElement.scrollTop = messagesEndRef.current.parentElement.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`variant variant-${variant.baseKey || variant.key} ${isDark ? 'dark' : ''} ${variant.layout === 'rail' ? 'layout-rail' : 'layout-topnav'} active`} data-screen-label={`0${variant.idx+1} ${variant.label}`}>
|
||||
<div className="app-window" style={{position: 'relative'}}>
|
||||
<div className="window-bar">
|
||||
<div className="dots">
|
||||
<div className="dot" style={{background: '#ff5f57'}}></div>
|
||||
<div className="dot" style={{background: '#febc2e'}}></div>
|
||||
<div className="dot" style={{background: '#28c840'}}></div>
|
||||
</div>
|
||||
<div className="title">ChatApp — {variant.label}</div>
|
||||
<div style={{width: 52}}></div>
|
||||
</div>
|
||||
|
||||
<div className="app-body">
|
||||
{variant.layout === 'rail' ? (
|
||||
<div className="rail-layout">
|
||||
{/* Icon rail left */}
|
||||
<div className="icon-rail">
|
||||
<div className="rail-brand" title="Netralax"><Icon.Logo /></div>
|
||||
<div className="rail-divider"></div>
|
||||
<button className={`rail-btn ${activeTab === 'chats' ? 'active' : ''}`} onClick={() => setActiveTab('chats')} title="Chats">
|
||||
<Icon.Chat />
|
||||
{activeTab === 'chats' && <div className="rail-pill"></div>}
|
||||
</button>
|
||||
<button className={`rail-btn ${activeTab === 'friends' ? 'active' : ''}`} onClick={() => setActiveTab('friends')} title="Freunde">
|
||||
<Icon.Friends />
|
||||
{activeTab === 'friends' && <div className="rail-pill"></div>}
|
||||
</button>
|
||||
<button className={`rail-btn ${activeTab === 'admin' ? 'active' : ''}`} onClick={() => setActiveTab('admin')} title="Admin">
|
||||
<Icon.Shield />
|
||||
{activeTab === 'admin' && <div className="rail-pill"></div>}
|
||||
</button>
|
||||
<div style={{flex: 1}}></div>
|
||||
<button className={`rail-btn ${activeTab === 'settings' ? 'active' : ''}`} onClick={() => setActiveTab('settings')} title="Einstellungen">
|
||||
<Icon.Settings />
|
||||
</button>
|
||||
<button className="rail-btn" onClick={onToggleDark} title={isDark ? 'Light mode' : 'Dark mode'}>
|
||||
{isDark ? <Icon.Sun /> : <Icon.Moon />}
|
||||
</button>
|
||||
</div>
|
||||
{/* Chatlist + user profile at bottom */}
|
||||
<div className="chat-list rail-chatlist">
|
||||
<div className="chat-list-header">
|
||||
<div className="chat-list-title">Chats</div>
|
||||
<button className="icon-btn" title="Neuer Chat"><Icon.AddUser /></button>
|
||||
</div>
|
||||
<input className="chat-search" placeholder="Suche…" />
|
||||
<div className="chat-items">
|
||||
{chats.map(c => (
|
||||
<ChatItem key={c.id} chat={c} onClick={() => setActiveChat(c)} />
|
||||
))}
|
||||
</div>
|
||||
<div className="rail-user">
|
||||
<Avatar chat={{initial: 'D', color: 'teal', presence: 'online'}} size="sm" />
|
||||
<div style={{fontSize: 12, flex: 1, minWidth: 0}}>
|
||||
<div style={{fontWeight: 600}}>dennis</div>
|
||||
<div style={{opacity: 0.6, fontSize: 10}}>● Online</div>
|
||||
</div>
|
||||
<button className="icon-btn" title="Mute"><Icon.Mic /></button>
|
||||
<button className="icon-btn" title="Einstellungen"><Icon.Settings /></button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Chat area (unchanged below) */}
|
||||
<div className="chat-area">
|
||||
{!(callScreen === 'active' && callMode !== 'fullscreen') && (
|
||||
<div className="chat-header">
|
||||
<Avatar chat={activeChat} />
|
||||
<div className="chat-header-info">
|
||||
<div className="chat-header-name">{activeChat.name}</div>
|
||||
<div className="chat-header-status">
|
||||
{activeChat.presence === 'online' ? '● Online' :
|
||||
activeChat.presence === 'idle' ? '● Abwesend' :
|
||||
activeChat.presence === 'dnd' ? '● Nicht stören' :
|
||||
'● Offline · zuletzt vor 2h'}
|
||||
{' · '}{activeChat.handle}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chat-header-actions">
|
||||
<button className="icon-btn" title="Suche"><Icon.Search /></button>
|
||||
<button className="icon-btn" title="Voice" onClick={() => setCallScreen('active')}><Icon.Phone /></button>
|
||||
<button className="icon-btn" title="Video" onClick={() => { setCallState(s => ({...s, video: true})); setCallScreen('active'); }}><Icon.Video /></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Call dock — rendered above messages, Discord-style */}
|
||||
{callScreen === 'active' && callMode !== 'fullscreen' && (
|
||||
<div className="call-dock">
|
||||
<ActiveCallScreen
|
||||
participants={CALL_PARTICIPANTS}
|
||||
mode={callMode}
|
||||
setMode={setCallMode}
|
||||
focused={focusedId}
|
||||
setFocused={setFocusedId}
|
||||
onHangup={() => setCallScreen('none')}
|
||||
callState={callState}
|
||||
setCallState={setCallState}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="messages">
|
||||
{MESSAGES.map((m, i) => {
|
||||
const next = MESSAGES[i+1];
|
||||
const isLastOfRun = (m.from === 'them' || m.from === 'me') && (!next || next.from !== m.from || next.type === 'event');
|
||||
const myChat = { initial: 'D', color: 'teal' };
|
||||
return <MessageItem key={i} msg={m} showAvatar={isLastOfRun} chat={activeChat} myChat={myChat} />;
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<div className="composer">
|
||||
<button className="composer-btn" title="Attach"><Icon.Plus /></button>
|
||||
<div className="composer-box">
|
||||
<input placeholder="Nachricht schreiben…" />
|
||||
<button className="icon-btn"><Icon.Smile /></button>
|
||||
<button className="icon-btn"><Icon.Mic /></button>
|
||||
</div>
|
||||
<button className="composer-btn"><Icon.Send /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="top-nav">
|
||||
<div className="nav-brand">
|
||||
<Icon.Logo />
|
||||
<span>Netralax</span>
|
||||
</div>
|
||||
<div className="nav-tabs">
|
||||
<button className={`nav-tab ${activeTab === 'chats' ? 'active' : ''}`} onClick={() => setActiveTab('chats')}>
|
||||
<Icon.Chat /> Chats
|
||||
</button>
|
||||
<button className={`nav-tab ${activeTab === 'friends' ? 'active' : ''}`} onClick={() => setActiveTab('friends')}>
|
||||
<Icon.Friends /> Freunde
|
||||
</button>
|
||||
<button className={`nav-tab ${activeTab === 'settings' ? 'active' : ''}`} onClick={() => setActiveTab('settings')}>
|
||||
<Icon.Settings /> Einstellungen
|
||||
</button>
|
||||
<button className={`nav-tab ${activeTab === 'admin' ? 'active' : ''}`} onClick={() => setActiveTab('admin')}>
|
||||
<Icon.Shield /> Admin
|
||||
</button>
|
||||
</div>
|
||||
<div className="nav-user">
|
||||
<Avatar chat={{initial: 'D', color: 'teal', presence: 'online'}} size="sm" />
|
||||
<div style={{fontSize: 12}}>
|
||||
<div style={{fontWeight: 600}}>dennis</div>
|
||||
<div style={{opacity: 0.6, fontSize: 10}}>@dennis</div>
|
||||
</div>
|
||||
<Icon.Chevron />
|
||||
</div>
|
||||
{variant.key === 'clean' && (
|
||||
<button className="icon-btn theme-toggle" onClick={onToggleDark} title={isDark ? 'Light mode' : 'Dark mode'} style={{marginLeft: 6}}>
|
||||
{isDark ? <Icon.Sun /> : <Icon.Moon />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="main-area">
|
||||
<div className="chat-list">
|
||||
<div className="chat-list-header">
|
||||
<div className="chat-list-title">Chats</div>
|
||||
<button className="icon-btn" title="Neuer Chat"><Icon.AddUser /></button>
|
||||
</div>
|
||||
<input className="chat-search" placeholder="Suche…" />
|
||||
<div className="chat-items">
|
||||
{chats.map(c => (
|
||||
<ChatItem key={c.id} chat={c} onClick={() => setActiveChat(c)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chat-area">
|
||||
<div className="chat-header">
|
||||
<Avatar chat={activeChat} />
|
||||
<div className="chat-header-info">
|
||||
<div className="chat-header-name">{activeChat.name}</div>
|
||||
<div className="chat-header-status">
|
||||
{activeChat.presence === 'online' ? '● Online' :
|
||||
activeChat.presence === 'idle' ? '● Abwesend' :
|
||||
activeChat.presence === 'dnd' ? '● Nicht stören' :
|
||||
'● Offline · zuletzt vor 2h'}
|
||||
{' · '}{activeChat.handle}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chat-header-actions">
|
||||
<button className="icon-btn" title="Suche"><Icon.Search /></button>
|
||||
<button className="icon-btn" title="Voice"><Icon.Phone /></button>
|
||||
<button className="icon-btn" title="Video"><Icon.Video /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="messages">
|
||||
{MESSAGES.map((m, i) => {
|
||||
const next = MESSAGES[i+1];
|
||||
const isLastOfRun = (m.from === 'them' || m.from === 'me') && (!next || next.from !== m.from || next.type === 'event');
|
||||
const myChat = { initial: 'D', color: 'teal' };
|
||||
return <MessageItem key={i} msg={m} showAvatar={isLastOfRun} chat={activeChat} myChat={myChat} />;
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="composer">
|
||||
<button className="composer-btn" title="Attach"><Icon.Plus /></button>
|
||||
<div className="composer-box">
|
||||
<input placeholder="Nachricht schreiben…" />
|
||||
<button className="icon-btn"><Icon.Smile /></button>
|
||||
<button className="icon-btn"><Icon.Mic /></button>
|
||||
</div>
|
||||
<button className="composer-btn"><Icon.Send /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Call screen switcher — only in Clean Rail */}
|
||||
{variant.layout === 'rail' && (
|
||||
<div className="screen-switcher">
|
||||
<button className={callScreen === 'none' ? 'active' : ''} onClick={() => setCallScreen('none')}>Chat</button>
|
||||
<button className={callScreen === 'incoming' ? 'active' : ''} onClick={() => setCallScreen('incoming')}>Incoming</button>
|
||||
<button className={callScreen === 'active' ? 'active' : ''} onClick={() => setCallScreen('active')}>In-Call</button>
|
||||
<button className={callScreen === 'pip' ? 'active' : ''} onClick={() => setCallScreen('pip')}>PiP</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Incoming call is a full overlay */}
|
||||
{callScreen === 'incoming' && (
|
||||
<div className="call-screen">
|
||||
<IncomingCallScreen
|
||||
chat={activeChat}
|
||||
onAccept={() => setCallScreen('active')}
|
||||
onDecline={() => setCallScreen('none')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Fullscreen call hides chat entirely */}
|
||||
{callScreen === 'active' && callMode === 'fullscreen' && (
|
||||
<div className="call-screen">
|
||||
<ActiveCallScreen
|
||||
participants={CALL_PARTICIPANTS}
|
||||
mode={callMode}
|
||||
setMode={setCallMode}
|
||||
focused={focusedId}
|
||||
setFocused={setFocusedId}
|
||||
onHangup={() => setCallScreen('none')}
|
||||
callState={callState}
|
||||
setCallState={setCallState}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{callScreen === 'pip' && (
|
||||
<PipCall
|
||||
participants={CALL_PARTICIPANTS}
|
||||
onExpand={() => setCallScreen('active')}
|
||||
onHangup={() => setCallScreen('none')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCall && callScreen === 'none' && <CallOverlay chat={activeChat} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Object.assign(window, { ChatApp, Avatar, ChatItem, MessageItem, CallOverlay });
|
||||
@@ -0,0 +1,194 @@
|
||||
// Call UI components — Discord-style, matched to Clean-Rail aesthetic
|
||||
|
||||
const CALL_PARTICIPANTS = [
|
||||
{ id: 'dennis', name: 'dennis', initial: 'D', color: 'teal', speaking: false, muted: false, video: false, me: true, e2ee: true },
|
||||
{ id: 'max', name: 'Max', initial: 'M', color: 'teal', speaking: true, muted: false, video: true, sharing: true, e2ee: true },
|
||||
{ id: 'lena', name: 'Lena', initial: 'L', color: 'amber', speaking: false, muted: true, video: false, e2ee: true },
|
||||
{ id: 'tom', name: 'Tom', initial: 'T', color: 'violet', speaking: false, muted: false, video: false, e2ee: true },
|
||||
{ id: 'nico', name: 'nico', initial: 'N', color: 'teal', speaking: true, muted: false, video: false, e2ee: true },
|
||||
];
|
||||
|
||||
const ParticipantTile = ({ p, focused, onClick, small }) => (
|
||||
<div className={`participant-tile ${p.speaking ? 'speaking' : ''} ${focused ? 'focused' : ''} ${small ? 'small' : ''}`} onClick={onClick}>
|
||||
{p.video || p.sharing ? (
|
||||
<div className="participant-video">
|
||||
{p.sharing ? (
|
||||
<div className="screenshare-stub">
|
||||
<div className="ss-window">
|
||||
<div className="ss-chrome"><span></span><span></span><span></span></div>
|
||||
<div className="ss-content">
|
||||
<div className="ss-line" style={{width: '60%'}}></div>
|
||||
<div className="ss-line" style={{width: '80%'}}></div>
|
||||
<div className="ss-line" style={{width: '40%'}}></div>
|
||||
<div className="ss-block"></div>
|
||||
<div className="ss-line" style={{width: '70%'}}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ss-badge">
|
||||
<Icon.Monitor /> {p.name} teilt Bildschirm
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="video-stub">
|
||||
<Avatar chat={{initial: p.initial, color: p.color}} size="lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="participant-audio">
|
||||
<div className={`audio-ring ${p.speaking ? 'pulse' : ''}`}>
|
||||
<Avatar chat={{initial: p.initial, color: p.color}} size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="participant-meta">
|
||||
<div className="participant-name">
|
||||
{p.me && <Icon.Crown />}
|
||||
<span>{p.name}{p.me ? ' (du)' : ''}</span>
|
||||
{p.e2ee && <span className="e2ee-dot" title="End-to-End verschlüsselt"><Icon.Lock /></span>}
|
||||
</div>
|
||||
<div className="participant-icons">
|
||||
{p.muted && <span className="pi muted"><Icon.MicOff /></span>}
|
||||
{p.sharing && <span className="pi sharing"><Icon.Monitor /></span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CallControls = ({ muted, onToggleMute, sharing, onToggleShare, video, onToggleVideo, onHangup, compact }) => (
|
||||
<div className={`call-controls ${compact ? 'compact' : ''}`}>
|
||||
<button className={`cc-btn ${muted ? 'active-danger' : ''}`} onClick={onToggleMute} title={muted ? 'Unmute' : 'Mute'}>
|
||||
{muted ? <Icon.MicOff /> : <Icon.Mic />}
|
||||
</button>
|
||||
<button className={`cc-btn ${video ? 'active-brand' : ''}`} onClick={onToggleVideo} title="Video">
|
||||
<Icon.Video />
|
||||
</button>
|
||||
<button className={`cc-btn ${sharing ? 'active-brand' : ''}`} onClick={onToggleShare} title="Bildschirm teilen">
|
||||
<Icon.Monitor />
|
||||
</button>
|
||||
<button className="cc-btn" title="Teilnehmer"><Icon.Friends /></button>
|
||||
<button className="cc-btn hangup" onClick={onHangup} title="Auflegen"><Icon.PhoneOff /></button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const IncomingCallScreen = ({ chat, onAccept, onDecline }) => (
|
||||
<div className="incoming-call">
|
||||
<div className="incoming-card">
|
||||
<div className="incoming-ringing">Eingehender Anruf</div>
|
||||
<div className="incoming-avatar">
|
||||
<div className="pulse-ring"></div>
|
||||
<div className="pulse-ring d2"></div>
|
||||
<Avatar chat={{initial: chat.initial, color: chat.color}} size="lg" />
|
||||
</div>
|
||||
<div className="incoming-name">{chat.name}</div>
|
||||
<div className="incoming-sub">
|
||||
<Icon.Lock /> E2E verschlüsselt · Voice Call
|
||||
</div>
|
||||
<div className="incoming-actions">
|
||||
<button className="incoming-btn decline" onClick={onDecline}>
|
||||
<Icon.PhoneOff /> Ablehnen
|
||||
</button>
|
||||
<button className="incoming-btn accept" onClick={onAccept}>
|
||||
<Icon.Phone /> Annehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ActiveCallScreen = ({ participants, mode, setMode, focused, setFocused, onHangup, callState, setCallState }) => {
|
||||
const speaker = participants.find(p => p.id === focused) || participants.find(p => p.sharing) || participants[0];
|
||||
const others = participants.filter(p => p.id !== speaker.id);
|
||||
const sharingParticipant = participants.find(p => p.sharing);
|
||||
|
||||
return (
|
||||
<div className={`active-call mode-${mode}`}>
|
||||
<div className="call-topbar">
|
||||
<div className="call-topbar-left">
|
||||
<div className="call-title">
|
||||
<Icon.Friends />
|
||||
<span>Die Squad</span>
|
||||
<span className="call-dot">·</span>
|
||||
<span className="call-duration">{callState.duration}</span>
|
||||
</div>
|
||||
<div className="call-e2ee"><Icon.Lock /> E2E verschlüsselt</div>
|
||||
</div>
|
||||
<div className="call-topbar-right">
|
||||
<button className={`cc-btn sm ${mode === 'grid' ? 'active-brand' : ''}`} onClick={() => setMode('grid')} title="Grid"><Icon.Grid /></button>
|
||||
<button className={`cc-btn sm ${mode === 'focus' ? 'active-brand' : ''}`} onClick={() => setMode('focus')} title="Fokus"><Icon.Focus /></button>
|
||||
<button className={`cc-btn sm ${mode === 'fullscreen' ? 'active-brand' : ''}`} onClick={() => setMode('fullscreen')} title="Vollbild"><Icon.Maximize /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="call-stage">
|
||||
{mode === 'grid' && (
|
||||
<div className={`grid grid-${participants.length}`}>
|
||||
{participants.map(p => (
|
||||
<ParticipantTile key={p.id} p={p} onClick={() => { setFocused(p.id); setMode('focus'); }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{mode === 'focus' && (
|
||||
<div className="focus-layout">
|
||||
<div className="focus-main">
|
||||
<ParticipantTile p={speaker} focused />
|
||||
</div>
|
||||
<div className="focus-strip">
|
||||
{others.map(p => (
|
||||
<ParticipantTile key={p.id} p={p} small onClick={() => setFocused(p.id)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{mode === 'fullscreen' && (
|
||||
<div className="fullscreen-layout">
|
||||
<ParticipantTile p={speaker} focused />
|
||||
<div className="fs-strip">
|
||||
{others.slice(0,4).map(p => (
|
||||
<ParticipantTile key={p.id} p={p} small onClick={() => setFocused(p.id)} />
|
||||
))}
|
||||
</div>
|
||||
<div className="fs-hint">Esc zum Verlassen</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CallControls
|
||||
muted={callState.muted}
|
||||
onToggleMute={() => setCallState(s => ({...s, muted: !s.muted}))}
|
||||
sharing={callState.sharing}
|
||||
onToggleShare={() => setCallState(s => ({...s, sharing: !s.sharing}))}
|
||||
video={callState.video}
|
||||
onToggleVideo={() => setCallState(s => ({...s, video: !s.video}))}
|
||||
onHangup={onHangup}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PipCall = ({ onExpand, onHangup, participants }) => {
|
||||
const speaker = participants.find(p => p.sharing) || participants.find(p => p.speaking) || participants[0];
|
||||
return (
|
||||
<div className="pip-call" onClick={onExpand}>
|
||||
<div className="pip-preview">
|
||||
{speaker.sharing ? (
|
||||
<div className="screenshare-stub small"><div className="ss-window mini"></div></div>
|
||||
) : (
|
||||
<Avatar chat={{initial: speaker.initial, color: speaker.color}} size="sm" />
|
||||
)}
|
||||
</div>
|
||||
<div className="pip-info">
|
||||
<div className="pip-title">Die Squad · {participants.length}</div>
|
||||
<div className="pip-sub">
|
||||
<span className="live-dot"></span>
|
||||
Live · tippe zum Öffnen
|
||||
</div>
|
||||
</div>
|
||||
<button className="pip-hangup" onClick={(e) => { e.stopPropagation(); onHangup(); }}>
|
||||
<Icon.PhoneOff />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Object.assign(window, { CALL_PARTICIPANTS, ParticipantTile, CallControls, IncomingCallScreen, ActiveCallScreen, PipCall });
|
||||
@@ -0,0 +1,28 @@
|
||||
// Sample data
|
||||
|
||||
const CHATS = [
|
||||
{ id: 'test', name: 'test', handle: '@test', color: 'violet', initial: 'T', preview: 'Hallo 👋', time: '21:13', unread: false, presence: 'offline', active: true },
|
||||
{ id: 'max', name: 'Max', handle: '@maxx', color: 'teal', initial: 'M', preview: 'bro du musst das sehen', time: '20:44', unread: true, presence: 'online' },
|
||||
{ id: 'squad', name: 'Die Squad', handle: '5 Mitglieder', color: 'rose', initial: 'S', preview: 'leon: bin gleich da', time: '19:12', unread: true, presence: 'online', isGroup: true },
|
||||
{ id: 'lena', name: 'Lena', handle: '@lenae', color: 'amber', initial: 'L', preview: 'Du: ok passt', time: 'Gestern', unread: false, presence: 'idle' },
|
||||
{ id: 'tom', name: 'Tom', handle: '@tomg', color: 'violet', initial: 'T', preview: 'sent a photo', time: 'Gestern', unread: false, presence: 'dnd' },
|
||||
{ id: 'nico', name: 'nico', handle: '@nicoo', color: 'teal', initial: 'N', preview: 'gg wp', time: 'Mo.', unread: false, presence: 'offline' },
|
||||
];
|
||||
|
||||
const MESSAGES = [
|
||||
{ type: 'message', from: 'them', text: 'Hi', time: '20:12' },
|
||||
{ type: 'message', from: 'them', text: 'Lol', time: '20:14' },
|
||||
{ type: 'message', from: 'them', text: 'Wie gehts', time: '20:24' },
|
||||
{ type: 'message', from: 'them', text: 'einer da?', time: '20:25' },
|
||||
{ type: 'message', from: 'them', text: 'Hallo', time: '21:03', reactions: [{emoji: '👋', count: 1, mine: true}] },
|
||||
{ type: 'event', kind: 'call', icon: 'phone', text: 'Eingehender Anruf', duration: '1:01', time: '21:06' },
|
||||
{ type: 'event', kind: 'rejected', icon: 'phone-off', text: 'Anruf abgelehnt', time: '21:06' },
|
||||
{ type: 'event', kind: 'rejected', icon: 'phone-off', text: 'Anruf abgelehnt', time: '21:06' },
|
||||
{ type: 'message', from: 'me', text: 'sry war afk', time: '21:09', reactions: [{emoji: '😅', count: 2}, {emoji: '❤️', count: 1, mine: true}] },
|
||||
{ type: 'media', from: 'me', filename: 'IMG_4120.jpg', size: '2.4 MB', label: '[ PLACEHOLDER IMAGE ]', time: '21:10' },
|
||||
{ type: 'message', from: 'them', text: 'pushhh sick', time: '21:12' },
|
||||
{ type: 'message', from: 'me', text: 'Hallo', time: '21:13', read: true },
|
||||
{ type: 'typing', from: 'them' },
|
||||
];
|
||||
|
||||
Object.assign(window, { CHATS, MESSAGES });
|
||||
@@ -0,0 +1,87 @@
|
||||
// Shared SVG icon components
|
||||
const Icon = {
|
||||
Logo: () => (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M4 6 L12 2 L20 6 L20 18 L12 22 L4 18 Z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round"/>
|
||||
<path d="M4 6 L12 10 L20 6" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round"/>
|
||||
<path d="M12 10 L12 22" stroke="currentColor" strokeWidth="1.8"/>
|
||||
</svg>
|
||||
),
|
||||
Chat: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
),
|
||||
Friends: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
),
|
||||
Settings: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
),
|
||||
Shield: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
),
|
||||
Phone: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
|
||||
),
|
||||
PhoneOff: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"/><line x1="23" y1="1" x2="1" y2="23"/></svg>
|
||||
),
|
||||
Video: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
|
||||
),
|
||||
Search: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
),
|
||||
Plus: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
),
|
||||
Send: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
),
|
||||
AddUser: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
|
||||
),
|
||||
Chevron: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
),
|
||||
Paperclip: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
),
|
||||
Smile: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
|
||||
),
|
||||
Mic: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
|
||||
),
|
||||
Sun: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
|
||||
),
|
||||
Moon: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
),
|
||||
MicOff: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/></svg>
|
||||
),
|
||||
Monitor: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
),
|
||||
Lock: () => (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
),
|
||||
Crown: () => (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M5 16l-2-8 5 4 4-7 4 7 5-4-2 8z"/></svg>
|
||||
),
|
||||
Grid: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
),
|
||||
Focus: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="12" rx="1"/><rect x="3" y="18" width="4" height="3" rx="0.5"/><rect x="9" y="18" width="4" height="3" rx="0.5"/><rect x="15" y="18" width="4" height="3" rx="0.5"/></svg>
|
||||
),
|
||||
Maximize: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
|
||||
),
|
||||
Minimize: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"/></svg>
|
||||
),
|
||||
};
|
||||
|
||||
Object.assign(window, { Icon });
|
||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,459 @@
|
||||
/* ================= BASE ================= */
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body, 'Inter', system-ui, sans-serif);
|
||||
background: var(--bg, #0e0e12);
|
||||
color: var(--fg, #eaeaea);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
|
||||
input, textarea { font-family: inherit; }
|
||||
|
||||
/* Variant switcher — top fixed */
|
||||
.variant-switcher {
|
||||
position: fixed;
|
||||
top: 12px; left: 50%; transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
display: flex; gap: 6px;
|
||||
padding: 6px;
|
||||
background: rgba(20,20,24,0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||
}
|
||||
.variant-switcher button {
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(255,255,255,0.6);
|
||||
border-radius: 9px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
.variant-switcher button:hover { color: #fff; background: rgba(255,255,255,0.05); }
|
||||
.variant-switcher button.active {
|
||||
color: #fff;
|
||||
background: rgba(255,255,255,0.12);
|
||||
}
|
||||
|
||||
.variant-container {
|
||||
padding-top: 72px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Variant wrapper */
|
||||
.variant {
|
||||
display: none;
|
||||
width: 100%;
|
||||
}
|
||||
.variant.active { display: block; }
|
||||
|
||||
/* Device frame — desktop window */
|
||||
.app-window {
|
||||
width: min(1320px, 96vw);
|
||||
margin: 0 auto 60px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 30px 80px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.05);
|
||||
height: 820px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.window-bar {
|
||||
height: 38px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
gap: 8px;
|
||||
}
|
||||
.window-bar .dots { display: flex; gap: 7px; }
|
||||
.window-bar .dot { width: 12px; height: 12px; border-radius: 50%; }
|
||||
.window-bar .title {
|
||||
flex: 1; text-align: center; font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.app-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
gap: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
margin-left: 20px;
|
||||
}
|
||||
.nav-tab {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
border-radius: 8px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nav-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 10px 4px 4px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.main-area {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Chat list */
|
||||
.chat-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chat-list-header {
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.chat-list-title { font-weight: 600; font-size: 14px; letter-spacing: 0.01em; }
|
||||
.chat-search {
|
||||
margin: 0 14px 10px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
.chat-items { flex: 1; overflow-y: auto; padding: 4px 10px; }
|
||||
.chat-item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.chat-item-body { flex: 1; min-width: 0; }
|
||||
.chat-item-name { font-weight: 600; font-size: 14px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.chat-item-preview {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.chat-item-time { font-size: 10px; opacity: 0.5; font-weight: 400; }
|
||||
.unread-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
margin-left: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Avatar */
|
||||
.avatar {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
.avatar.sm { width: 28px; height: 28px; font-size: 11px; }
|
||||
.avatar.lg { width: 44px; height: 44px; font-size: 16px; }
|
||||
|
||||
.presence {
|
||||
position: absolute;
|
||||
bottom: -1px; right: -1px;
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg, #0e0e12);
|
||||
}
|
||||
.presence.online { background: #22c55e; }
|
||||
.presence.idle { background: #f59e0b; }
|
||||
.presence.dnd { background: #ef4444; }
|
||||
.presence.offline { background: #71717a; }
|
||||
|
||||
/* Chat area */
|
||||
.chat-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
height: 64px;
|
||||
padding: 0 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chat-header-info { flex: 1; min-width: 0; }
|
||||
.chat-header-name { font-weight: 600; font-size: 15px; }
|
||||
.chat-header-status { font-size: 11px; opacity: 0.6; margin-top: 2px; }
|
||||
.chat-header-actions { display: flex; gap: 4px; }
|
||||
.icon-btn {
|
||||
width: 34px; height: 34px;
|
||||
border-radius: 9px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 26px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
.message-row.me { justify-content: flex-end; }
|
||||
.message-row.them { justify-content: flex-start; }
|
||||
.message-avatar-slot {
|
||||
width: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.message-row .message-group {
|
||||
margin-bottom: 0;
|
||||
min-width: 0;
|
||||
max-width: calc(100% - 40px);
|
||||
flex: 1 1 auto;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.message-row.me .message-group { align-items: flex-end; }
|
||||
|
||||
.message-row .bubble { max-width: 100%; width: fit-content; }
|
||||
.message-row.me .bubble { margin-left: auto; }
|
||||
|
||||
.message-group { display: flex; flex-direction: column; gap: 2px; margin-bottom: 14px; }
|
||||
.message-group.me { align-items: flex-end; }
|
||||
.message-group.them { align-items: flex-start; }
|
||||
.message-sender {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
opacity: 0.7;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 70%;
|
||||
min-width: 60px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 18px;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
position: relative;
|
||||
word-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
.bubble .ts {
|
||||
font-size: 10px;
|
||||
opacity: 0.5;
|
||||
margin-top: 2px;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reactions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.reaction {
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.reaction .count { font-weight: 600; opacity: 0.8; }
|
||||
|
||||
/* System events (calls etc) */
|
||||
.event-row { display: flex; justify-content: center; margin: 10px 0; }
|
||||
.event-pill {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.event-pill svg { width: 12px; height: 12px; }
|
||||
|
||||
/* Media preview */
|
||||
.media-preview {
|
||||
width: 240px;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.media-preview .media-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
rgba(255,255,255,0.04),
|
||||
rgba(255,255,255,0.04) 10px,
|
||||
rgba(255,255,255,0.07) 10px,
|
||||
rgba(255,255,255,0.07) 20px
|
||||
);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 10px; font-family: 'JetBrains Mono', monospace; opacity: 0.5;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.media-preview .media-meta {
|
||||
padding: 10px 12px;
|
||||
font-size: 11px;
|
||||
display: flex; justify-content: space-between;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Composer */
|
||||
.composer {
|
||||
padding: 14px 20px 18px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.composer-box {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 4px 4px 12px;
|
||||
border-radius: 12px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.composer-box input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.composer-btn {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 9px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Incoming call overlay */
|
||||
.call-overlay {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
bottom: 100px;
|
||||
width: 300px;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||||
animation: slideInCall 0.4s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
@keyframes slideInCall {
|
||||
from { transform: translateX(120%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
.call-info { flex: 1; min-width: 0; }
|
||||
.call-name { font-weight: 600; font-size: 14px; }
|
||||
.call-sub { font-size: 11px; opacity: 0.7; margin-top: 2px; }
|
||||
.call-actions { display: flex; gap: 6px; }
|
||||
.call-btn {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
.call-btn.accept { background: #22c55e; }
|
||||
.call-btn.decline { background: #ef4444; }
|
||||
|
||||
/* Profile hover card */
|
||||
.profile-card {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
width: 280px;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.4);
|
||||
}
|
||||
.profile-card-banner { height: 64px; }
|
||||
.profile-card-body { padding: 14px; margin-top: -20px; position: relative; }
|
||||
.profile-card-avatar {
|
||||
width: 60px; height: 60px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid var(--bg);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
}
|
||||
.profile-card-name { font-weight: 700; font-size: 16px; margin-top: 10px; }
|
||||
.profile-card-handle { font-size: 12px; opacity: 0.6; }
|
||||
.profile-card-meta {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
font-size: 11px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
|
||||
@@ -0,0 +1,529 @@
|
||||
/* ============ CALL UI — Clean Rail matched ============ */
|
||||
|
||||
/* Discord-style call dock: call takes top portion, chat continues below */
|
||||
.call-dock {
|
||||
flex-shrink: 0;
|
||||
height: 50%;
|
||||
max-height: 420px;
|
||||
min-height: 280px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.call-dock > .active-call {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.call-dock .call-topbar { padding: 8px 14px; }
|
||||
.call-dock .call-stage { padding: 10px; }
|
||||
.call-dock .grid { gap: 8px; }
|
||||
.call-dock .participant-tile { border-radius: 10px; }
|
||||
.call-dock .audio-ring .avatar.lg { width: 52px; height: 52px; font-size: 20px; }
|
||||
.call-dock .participant-meta { padding: 4px 8px; font-size: 11px; bottom: 6px; left: 6px; right: 6px; }
|
||||
.call-dock .participant-name { font-size: 11px; }
|
||||
.call-dock .pi { width: 18px; height: 18px; }
|
||||
/* When docked, compact the controls */
|
||||
.call-dock .call-controls { padding: 8px; gap: 8px; }
|
||||
.call-dock .cc-btn { width: 36px; height: 36px; border-radius: 10px; }
|
||||
.call-dock .cc-btn.hangup { width: 54px; }
|
||||
.call-dock .ss-content { padding: 8px; gap: 4px; }
|
||||
.call-dock .ss-line { height: 4px; }
|
||||
.call-dock .ss-block { height: 20px; }
|
||||
.call-dock .ss-badge { font-size: 9px; padding: 3px 7px; }
|
||||
|
||||
/* Screen switcher pill (top-right of window) */
|
||||
.screen-switcher {
|
||||
position: absolute;
|
||||
top: 52px;
|
||||
right: 20px;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.screen-switcher button {
|
||||
padding: 6px 10px;
|
||||
border-radius: 7px;
|
||||
color: var(--fg-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.screen-switcher button.active { background: var(--accent); color: #fff; }
|
||||
|
||||
/* Call stage overlay */
|
||||
.call-screen {
|
||||
position: absolute;
|
||||
inset: 38px 0 0 0; /* below window bar */
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* === Incoming Call === */
|
||||
.incoming-call {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background:
|
||||
radial-gradient(circle at 30% 20%, rgba(79,70,229,0.15), transparent 50%),
|
||||
radial-gradient(circle at 70% 80%, rgba(109,115,255,0.1), transparent 50%),
|
||||
var(--bg);
|
||||
}
|
||||
.incoming-card {
|
||||
width: 420px;
|
||||
padding: 40px 32px 28px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
text-align: center;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.1);
|
||||
}
|
||||
.variant-clean.dark .incoming-card { box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
|
||||
.incoming-ringing {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--fg-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.incoming-avatar {
|
||||
margin: 22px auto 18px;
|
||||
width: 112px; height: 112px;
|
||||
position: relative;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.incoming-avatar .avatar.lg {
|
||||
width: 96px; height: 96px;
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
border: 3px solid var(--bg-3);
|
||||
}
|
||||
.pulse-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
animation: pulseRing 2s ease-out infinite;
|
||||
}
|
||||
.pulse-ring.d2 { animation-delay: 1s; }
|
||||
@keyframes pulseRing {
|
||||
0% { transform: scale(0.85); opacity: 0.6; }
|
||||
100% { transform: scale(1.25); opacity: 0; }
|
||||
}
|
||||
.incoming-name {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
font-family: 'Outfit', 'Inter', sans-serif;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.incoming-sub {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
}
|
||||
.incoming-actions {
|
||||
margin-top: 28px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
.incoming-btn {
|
||||
flex: 1;
|
||||
padding: 14px 20px;
|
||||
border-radius: 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.incoming-btn.decline {
|
||||
background: transparent;
|
||||
color: #dc2626;
|
||||
border: 1px solid rgba(220,38,38,0.3);
|
||||
}
|
||||
.variant-clean.dark .incoming-btn.decline { color: #fb7185; border-color: rgba(251,113,133,0.3); }
|
||||
.incoming-btn.decline:hover { background: rgba(220,38,38,0.08); }
|
||||
.incoming-btn.accept {
|
||||
background: #16a34a;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(22,163,74,0.3);
|
||||
}
|
||||
.incoming-btn.accept:hover { background: #15803d; }
|
||||
|
||||
/* === Active Call === */
|
||||
.active-call {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, var(--bg-2), var(--bg));
|
||||
}
|
||||
.call-topbar {
|
||||
padding: 14px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.call-topbar-left { display: flex; flex-direction: column; gap: 2px; }
|
||||
.call-title {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-weight: 600; font-size: 14px;
|
||||
}
|
||||
.call-dot { opacity: 0.4; }
|
||||
.call-duration { font-variant-numeric: tabular-nums; color: var(--fg-muted); font-size: 13px; }
|
||||
.call-e2ee {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
font-size: 11px; color: #16a34a; font-weight: 500;
|
||||
}
|
||||
.variant-clean.dark .call-e2ee { color: #4ade80; }
|
||||
.call-topbar-right { display: flex; gap: 4px; }
|
||||
|
||||
.call-stage {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Participant tiles */
|
||||
.participant-tile {
|
||||
position: relative;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.participant-tile:hover { border-color: var(--accent); }
|
||||
.participant-tile.speaking { border-color: #16a34a; box-shadow: 0 0 0 2px rgba(22,163,74,0.25); }
|
||||
.participant-tile.focused { border-color: var(--accent); }
|
||||
.participant-tile.small { min-width: 140px; }
|
||||
|
||||
.participant-audio, .participant-video {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.audio-ring {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.audio-ring .avatar.lg {
|
||||
width: 72px; height: 72px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.participant-tile.small .audio-ring .avatar.lg { width: 44px; height: 44px; font-size: 18px; }
|
||||
.audio-ring.pulse::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -6px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #22c55e;
|
||||
animation: audioPulse 1.3s ease-out infinite;
|
||||
}
|
||||
@keyframes audioPulse {
|
||||
0% { transform: scale(1); opacity: 0.8; }
|
||||
100% { transform: scale(1.3); opacity: 0; }
|
||||
}
|
||||
|
||||
.video-stub {
|
||||
width: 100%; height: 100%;
|
||||
background:
|
||||
radial-gradient(circle at 30% 40%, rgba(109,115,255,0.2), transparent 60%),
|
||||
linear-gradient(135deg, #1a1a24, #0A0A0F);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.variant-clean:not(.dark) .video-stub { background: linear-gradient(135deg, #e0e7ff, #c7d2fe); }
|
||||
|
||||
/* Screenshare stub */
|
||||
.screenshare-stub {
|
||||
width: 100%; height: 100%;
|
||||
position: relative;
|
||||
background: #0A0A0F;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.variant-clean:not(.dark) .screenshare-stub { background: #111118; }
|
||||
.ss-window {
|
||||
width: 90%; height: 85%;
|
||||
background: #1a1a24;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.ss-chrome {
|
||||
height: 20px;
|
||||
background: #252533;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
.ss-chrome span { width: 6px; height: 6px; border-radius: 50%; background: #3A3A4D; }
|
||||
.ss-content { padding: 14px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.ss-line { height: 6px; background: #3A3A4D; border-radius: 3px; }
|
||||
.ss-block { height: 40px; background: rgba(109,115,255,0.25); border-radius: 4px; margin: 4px 0; }
|
||||
.participant-tile.small .ss-content { padding: 6px; gap: 3px; }
|
||||
.participant-tile.small .ss-line { height: 3px; }
|
||||
.participant-tile.small .ss-block { height: 14px; }
|
||||
.ss-badge {
|
||||
position: absolute;
|
||||
top: 12px; left: 12px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.participant-tile.small .ss-badge { display: none; }
|
||||
|
||||
.participant-meta {
|
||||
position: absolute;
|
||||
bottom: 8px; left: 8px; right: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(0,0,0,0.55);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
}
|
||||
.participant-tile.small .participant-meta { padding: 4px 8px; font-size: 10px; }
|
||||
.participant-name {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.e2ee-dot { opacity: 0.7; display: flex; }
|
||||
.participant-icons { display: flex; gap: 4px; }
|
||||
.pi {
|
||||
width: 20px; height: 20px;
|
||||
border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.pi.muted { background: rgba(220,38,38,0.8); }
|
||||
.pi.sharing { background: rgba(34,197,94,0.8); }
|
||||
|
||||
/* Grid layouts */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
width: 100%; height: 100%;
|
||||
}
|
||||
.grid-1 { grid-template-columns: 1fr; }
|
||||
.grid-2 { grid-template-columns: 1fr 1fr; }
|
||||
.grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.grid-4 { grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(2, 1fr); }
|
||||
.grid-5, .grid-6 { grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(2, 1fr); }
|
||||
|
||||
/* Focus layout */
|
||||
.focus-layout {
|
||||
width: 100%; height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.focus-main { flex: 1; min-height: 0; }
|
||||
.focus-main .participant-tile { height: 100%; }
|
||||
.focus-strip {
|
||||
height: 110px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.focus-strip .participant-tile { min-width: 160px; height: 100%; }
|
||||
|
||||
/* Fullscreen layout (hides topbar, pure cinema) */
|
||||
.active-call.mode-fullscreen .call-topbar { display: none; }
|
||||
.active-call.mode-fullscreen .call-stage { padding: 0; position: relative; }
|
||||
.fullscreen-layout {
|
||||
width: 100%; height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.fullscreen-layout > .participant-tile { width: 100%; height: 100%; border-radius: 0; border: none; }
|
||||
.fs-strip {
|
||||
position: absolute;
|
||||
bottom: 90px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 180px;
|
||||
}
|
||||
.fs-strip .participant-tile { height: 100px; backdrop-filter: blur(20px); background: rgba(0,0,0,0.4); }
|
||||
|
||||
/* Call controls bar */
|
||||
.call-controls {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
background: var(--bg-3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.active-call.mode-fullscreen .call-controls {
|
||||
position: absolute;
|
||||
bottom: 16px; left: 50%; transform: translateX(-50%);
|
||||
background: rgba(10,10,15,0.7);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 18px;
|
||||
padding: 10px;
|
||||
}
|
||||
.cc-btn {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 14px;
|
||||
background: var(--bg-2);
|
||||
color: var(--fg);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.cc-btn:hover { background: var(--bg); transform: translateY(-1px); }
|
||||
.cc-btn.sm { width: 32px; height: 32px; border-radius: 8px; }
|
||||
.cc-btn.active-brand { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.cc-btn.active-danger { background: #dc2626; color: #fff; border-color: #dc2626; }
|
||||
.cc-btn.hangup { background: #dc2626; color: #fff; border-color: #dc2626; width: 72px; }
|
||||
.cc-btn.hangup:hover { background: #b91c1c; }
|
||||
|
||||
.active-call.mode-fullscreen .cc-btn { background: rgba(255,255,255,0.08); color: #fff; border-color: rgba(255,255,255,0.1); }
|
||||
.active-call.mode-fullscreen .cc-btn.active-brand { background: var(--accent); border-color: var(--accent); }
|
||||
.active-call.mode-fullscreen .cc-btn.active-danger { background: #dc2626; border-color: #dc2626; }
|
||||
.active-call.mode-fullscreen .cc-btn.hangup { background: #dc2626; border-color: #dc2626; }
|
||||
|
||||
/* Exit fullscreen button */
|
||||
.fs-exit {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 10;
|
||||
padding: 8px 14px;
|
||||
background: rgba(10,10,15,0.7);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.fs-exit:hover { background: rgba(10,10,15,0.9); transform: translateY(-1px); }
|
||||
|
||||
/* Fullscreen hint — subtle, fades in then out */
|
||||
.fs-hint {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 6px 14px;
|
||||
background: rgba(10,10,15,0.6);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 8px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
z-index: 10;
|
||||
animation: fsHintFade 3.5s ease-out forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes fsHintFade {
|
||||
0% { opacity: 0; transform: translate(-50%, -6px); }
|
||||
15%, 75% { opacity: 1; transform: translate(-50%, 0); }
|
||||
100% { opacity: 0; transform: translate(-50%, -6px); }
|
||||
}
|
||||
|
||||
/* PiP in chat view */
|
||||
.pip-call {
|
||||
position: absolute;
|
||||
bottom: 82px; right: 20px;
|
||||
width: 260px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.2), 0 0 0 3px rgba(79,70,229,0.15);
|
||||
cursor: pointer;
|
||||
z-index: 30;
|
||||
animation: slideInCall 0.4s cubic-bezier(0.22,1,0.36,1);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.pip-call:hover { transform: translateY(-2px); }
|
||||
.pip-preview {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pip-preview .ss-window.mini {
|
||||
width: 36px; height: 28px;
|
||||
background: #1a1a24;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pip-info { flex: 1; min-width: 0; }
|
||||
.pip-title { font-weight: 600; font-size: 13px; }
|
||||
.pip-sub {
|
||||
font-size: 11px; color: var(--fg-muted);
|
||||
margin-top: 2px;
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.live-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: #ef4444;
|
||||
animation: liveDotPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes liveDotPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.pip-hangup {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/* Rail layout — Discord-style icon sidebar */
|
||||
.rail-layout {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 64px 280px 1fr;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
.icon-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
gap: 6px;
|
||||
}
|
||||
.rail-brand {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--accent, #4F46E5);
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.rail-divider { width: 32px; height: 2px; background: rgba(128,128,128,0.2); border-radius: 1px; margin: 4px 0; }
|
||||
.rail-btn {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--fg-muted);
|
||||
position: relative;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.rail-btn:hover { background: rgba(128,128,128,0.1); color: var(--fg); border-radius: 12px; }
|
||||
.rail-btn.active { background: var(--accent); color: #fff; border-radius: 12px; }
|
||||
.rail-pill {
|
||||
position: absolute;
|
||||
left: -12px; top: 50%; transform: translateY(-50%);
|
||||
width: 4px; height: 24px;
|
||||
background: var(--fg);
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
.rail-chatlist { display: flex; flex-direction: column; }
|
||||
.rail-chatlist .chat-items { flex: 1; }
|
||||
.rail-user {
|
||||
padding: 10px 12px;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.variant-clean.layout-rail .icon-rail { background: var(--bg-2); border-right: 1px solid var(--line); }
|
||||
.variant-clean.layout-rail .rail-chatlist { background: var(--bg-3); border-right: 1px solid var(--line); }
|
||||
.variant-clean.layout-rail .rail-user { background: var(--bg-2); border-top: 1px solid var(--line); }
|
||||
.variant-clean.layout-rail .rail-brand { background: var(--accent); color: #fff; }
|
||||
@@ -0,0 +1,461 @@
|
||||
/* ================= VARIANT 1: CLEAN MINIMAL (Light) ================= */
|
||||
.variant-clean {
|
||||
--bg: #fafaf9;
|
||||
--bg-2: #f4f4f2;
|
||||
--bg-3: #ffffff;
|
||||
--fg: #0a0a0f;
|
||||
--fg-muted: #737380;
|
||||
--line: rgba(10,10,15,0.08);
|
||||
--accent: #4F46E5;
|
||||
--accent-fg: #ffffff;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
.variant-clean .nav-brand { font-family: 'Outfit', 'Inter', sans-serif; font-weight: 700; letter-spacing: -0.02em; }
|
||||
|
||||
/* Dark mode for Clean */
|
||||
.variant-clean.dark {
|
||||
--bg: #050507;
|
||||
--bg-2: #111118;
|
||||
--bg-3: #0A0A0F;
|
||||
--fg: #f4f4f5;
|
||||
--fg-muted: #8a8a99;
|
||||
--line: rgba(255,255,255,0.07);
|
||||
--accent: #6D73FF;
|
||||
--accent-fg: #ffffff;
|
||||
}
|
||||
.variant-clean.dark .app-window { background: var(--bg-3); box-shadow: 0 30px 80px rgba(0,0,0,0.5), 0 0 0 1px var(--line); }
|
||||
.variant-clean.dark .avatar { background: #1A1A24; color: #DDE0FF; }
|
||||
.variant-clean.dark .avatar[data-color="violet"] { background: #312E81; color: #C4B5FD; }
|
||||
.variant-clean.dark .avatar[data-color="amber"] { background: #422e05; color: #fde68a; }
|
||||
.variant-clean.dark .avatar[data-color="rose"] { background: #4a1d29; color: #fecdd3; }
|
||||
.variant-clean.dark .avatar[data-color="teal"] { background: #134e4a; color: #99f6e4; }
|
||||
.variant-clean.dark .bubble.them { border-color: rgba(255,255,255,0.1); }
|
||||
.variant-clean.dark .bubble.me { background: var(--accent); color: #fff; }
|
||||
.variant-clean.dark .nav-tab.active { background: var(--accent); }
|
||||
.variant-clean.dark .reaction.mine { background: var(--accent); }
|
||||
.variant-clean.dark .composer-btn { background: var(--accent); }
|
||||
.variant-clean .app-window { background: var(--bg-3); box-shadow: 0 30px 80px rgba(0,0,0,0.08), 0 0 0 1px var(--line); }
|
||||
.variant-clean .window-bar { background: var(--bg-2); border-bottom: 1px solid var(--line); }
|
||||
.variant-clean .window-bar .title { color: var(--fg-muted); font-weight: 500; }
|
||||
.variant-clean .top-nav { border-bottom: 1px solid var(--line); background: var(--bg-3); }
|
||||
.variant-clean .nav-brand svg { color: #0a0a0a; }
|
||||
.variant-clean .nav-tab { color: var(--fg-muted); }
|
||||
.variant-clean .nav-tab:hover { background: var(--bg-2); color: var(--fg); }
|
||||
.variant-clean .nav-tab.active { background: var(--accent); color: var(--accent-fg); }
|
||||
.variant-clean .nav-user { border: 1px solid var(--line); }
|
||||
.variant-clean .chat-list { border-right: 1px solid var(--line); background: var(--bg-3); }
|
||||
.variant-clean .chat-list-header { border-bottom: 1px solid var(--line); }
|
||||
.variant-clean .chat-search { background: var(--bg-2); color: var(--fg); }
|
||||
.variant-clean .chat-item:hover { background: var(--bg-2); }
|
||||
.variant-clean .chat-item.active { background: var(--bg-2); }
|
||||
.variant-clean .avatar { background: #e5e5e5; color: #0a0a0a; }
|
||||
.variant-clean .avatar[data-color="violet"] { background: #ddd6fe; color: #5b21b6; }
|
||||
.variant-clean .avatar[data-color="amber"] { background: #fde68a; color: #78350f; }
|
||||
.variant-clean .avatar[data-color="rose"] { background: #fecdd3; color: #881337; }
|
||||
.variant-clean .avatar[data-color="teal"] { background: #99f6e4; color: #134e4a; }
|
||||
.variant-clean .chat-header { border-bottom: 1px solid var(--line); }
|
||||
.variant-clean .icon-btn { color: var(--fg-muted); }
|
||||
.variant-clean .icon-btn:hover { background: var(--bg-2); color: var(--fg); }
|
||||
.variant-clean .bubble.me { background: var(--accent); color: var(--accent-fg); border-radius: 18px 18px 4px 18px; }
|
||||
.variant-clean .bubble.them { background: transparent; color: var(--fg); border: 1px solid var(--line); border-radius: 18px 18px 18px 4px; }
|
||||
.variant-clean .reaction { background: var(--bg-2); border: 1px solid var(--line); }
|
||||
.variant-clean .reaction.mine { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||
.variant-clean .event-pill { background: var(--bg-2); color: var(--fg-muted); }
|
||||
.variant-clean .composer { border-top: 1px solid var(--line); }
|
||||
.variant-clean .composer-box { background: var(--bg-2); }
|
||||
.variant-clean .composer-btn { background: var(--accent); color: var(--accent-fg); }
|
||||
.variant-clean .presence { border-color: var(--bg-3); }
|
||||
|
||||
|
||||
/* ================= VARIANT 2: PLAYFUL DISCORD-VIBES ================= */
|
||||
.variant-playful {
|
||||
--bg: #1a1625;
|
||||
--bg-2: #2a2338;
|
||||
--bg-3: #221b30;
|
||||
--fg: #f5f3ff;
|
||||
--fg-muted: #9691b3;
|
||||
--accent: #a78bfa;
|
||||
--accent-2: #f472b6;
|
||||
--accent-3: #60a5fa;
|
||||
background: radial-gradient(ellipse at top left, #3d1a54 0%, #1a1625 50%);
|
||||
color: var(--fg);
|
||||
font-family: 'Plus Jakarta Sans', 'Inter', sans-serif;
|
||||
}
|
||||
.variant-playful .app-window { background: var(--bg-3); }
|
||||
.variant-playful .window-bar { background: rgba(0,0,0,0.25); }
|
||||
.variant-playful .window-bar .title { color: #fff; font-weight: 700; font-size: 13px; }
|
||||
.variant-playful .top-nav { background: linear-gradient(90deg, #2a1a48 0%, #1a1625 100%); border-bottom: 1px solid rgba(255,255,255,0.05); }
|
||||
.variant-playful .nav-brand { font-size: 18px; background: linear-gradient(135deg, #f472b6, #a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.variant-playful .nav-tab { color: var(--fg-muted); font-weight: 600; }
|
||||
.variant-playful .nav-tab:hover { background: rgba(255,255,255,0.06); color: #fff; }
|
||||
.variant-playful .nav-tab.active { background: linear-gradient(135deg, #a78bfa, #f472b6); color: #fff; box-shadow: 0 4px 14px rgba(167,139,250,0.4); }
|
||||
.variant-playful .nav-user { background: rgba(255,255,255,0.05); }
|
||||
.variant-playful .chat-list { background: rgba(0,0,0,0.15); border-right: 1px solid rgba(255,255,255,0.04); }
|
||||
.variant-playful .chat-search { background: rgba(0,0,0,0.3); color: #fff; }
|
||||
.variant-playful .chat-item:hover { background: rgba(255,255,255,0.04); }
|
||||
.variant-playful .chat-item.active { background: linear-gradient(90deg, rgba(167,139,250,0.2), rgba(244,114,182,0.1)); }
|
||||
.variant-playful .avatar { background: linear-gradient(135deg, #a78bfa, #f472b6); color: #fff; font-weight: 800; }
|
||||
.variant-playful .avatar[data-color="teal"] { background: linear-gradient(135deg, #34d399, #60a5fa); }
|
||||
.variant-playful .avatar[data-color="amber"] { background: linear-gradient(135deg, #fbbf24, #f472b6); }
|
||||
.variant-playful .avatar[data-color="rose"] { background: linear-gradient(135deg, #fb7185, #a78bfa); }
|
||||
.variant-playful .avatar[data-color="violet"] { background: linear-gradient(135deg, #a78bfa, #60a5fa); }
|
||||
.variant-playful .chat-header { background: rgba(0,0,0,0.2); border-bottom: 1px solid rgba(255,255,255,0.05); }
|
||||
.variant-playful .icon-btn:hover { background: rgba(255,255,255,0.08); }
|
||||
.variant-playful .messages { background: radial-gradient(ellipse at bottom right, rgba(244,114,182,0.06), transparent 50%); }
|
||||
.variant-playful .bubble.me { background: linear-gradient(135deg, #a78bfa, #8b5cf6); color: #fff; border-radius: 20px 20px 4px 20px; box-shadow: 0 4px 14px rgba(167,139,250,0.25); }
|
||||
.variant-playful .bubble.them { background: rgba(255,255,255,0.06); color: #fff; border-radius: 20px 20px 20px 4px; backdrop-filter: blur(10px); }
|
||||
.variant-playful .reaction { background: rgba(255,255,255,0.08); }
|
||||
.variant-playful .reaction.mine { background: rgba(167,139,250,0.3); border: 1px solid #a78bfa; }
|
||||
.variant-playful .event-pill { background: rgba(52,211,153,0.15); color: #34d399; }
|
||||
.variant-playful .event-pill.rejected { background: rgba(251,113,133,0.15); color: #fb7185; }
|
||||
.variant-playful .composer { background: rgba(0,0,0,0.2); }
|
||||
.variant-playful .composer-box { background: rgba(255,255,255,0.06); }
|
||||
.variant-playful .composer-btn { background: linear-gradient(135deg, #a78bfa, #f472b6); color: #fff; box-shadow: 0 4px 14px rgba(167,139,250,0.4); }
|
||||
.variant-playful .presence { border-color: var(--bg-3); }
|
||||
|
||||
|
||||
/* ================= VARIANT 3: Y2K GLASSMORPHISM ================= */
|
||||
.variant-y2k {
|
||||
--bg: #0a0a1f;
|
||||
--fg: #f8fafc;
|
||||
--fg-muted: rgba(248,250,252,0.6);
|
||||
--accent: #c7d2fe;
|
||||
color: var(--fg);
|
||||
font-family: 'Space Grotesk', 'Inter', sans-serif;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 20% 20%, rgba(168,85,247,0.4), transparent),
|
||||
radial-gradient(ellipse 60% 40% at 80% 80%, rgba(59,130,246,0.35), transparent),
|
||||
radial-gradient(ellipse 70% 60% at 50% 50%, rgba(236,72,153,0.2), transparent),
|
||||
#0a0a1f;
|
||||
}
|
||||
.variant-y2k .app-window {
|
||||
background: rgba(255,255,255,0.04);
|
||||
backdrop-filter: blur(30px);
|
||||
-webkit-backdrop-filter: blur(30px);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
box-shadow: 0 30px 80px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.15);
|
||||
}
|
||||
.variant-y2k .window-bar { background: rgba(255,255,255,0.04); border-bottom: 1px solid rgba(255,255,255,0.08); }
|
||||
.variant-y2k .window-bar .title { color: rgba(255,255,255,0.7); font-weight: 500; letter-spacing: 0.08em; font-size: 11px; text-transform: uppercase; }
|
||||
.variant-y2k .top-nav { background: rgba(255,255,255,0.02); border-bottom: 1px solid rgba(255,255,255,0.08); }
|
||||
.variant-y2k .nav-brand { font-weight: 700; letter-spacing: -0.03em; font-size: 18px; }
|
||||
.variant-y2k .nav-tab {
|
||||
color: var(--fg-muted);
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.variant-y2k .nav-tab:hover { background: rgba(255,255,255,0.06); color: #fff; border-color: rgba(255,255,255,0.1); }
|
||||
.variant-y2k .nav-tab.active {
|
||||
background: rgba(255,255,255,0.15);
|
||||
color: #fff;
|
||||
border-color: rgba(255,255,255,0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.2);
|
||||
}
|
||||
.variant-y2k .nav-user { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); }
|
||||
.variant-y2k .chat-list { background: rgba(255,255,255,0.02); border-right: 1px solid rgba(255,255,255,0.08); }
|
||||
.variant-y2k .chat-search { background: rgba(255,255,255,0.06); color: #fff; border: 1px solid rgba(255,255,255,0.08); }
|
||||
.variant-y2k .chat-item:hover { background: rgba(255,255,255,0.05); }
|
||||
.variant-y2k .chat-item.active {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
}
|
||||
.variant-y2k .avatar {
|
||||
background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
|
||||
color: #312e81;
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
}
|
||||
.variant-y2k .avatar[data-color="violet"] { background: linear-gradient(135deg, #e9d5ff, #c4b5fd); color: #4c1d95; }
|
||||
.variant-y2k .avatar[data-color="rose"] { background: linear-gradient(135deg, #fecdd3, #f9a8d4); color: #831843; }
|
||||
.variant-y2k .avatar[data-color="teal"] { background: linear-gradient(135deg, #a7f3d0, #7dd3fc); color: #064e3b; }
|
||||
.variant-y2k .avatar[data-color="amber"] { background: linear-gradient(135deg, #fde68a, #fdba74); color: #7c2d12; }
|
||||
.variant-y2k .chat-header { background: rgba(255,255,255,0.02); border-bottom: 1px solid rgba(255,255,255,0.08); }
|
||||
.variant-y2k .icon-btn:hover { background: rgba(255,255,255,0.08); }
|
||||
.variant-y2k .bubble.me {
|
||||
background: linear-gradient(135deg, rgba(199,210,254,0.9), rgba(196,181,253,0.8));
|
||||
color: #1e1b4b;
|
||||
border-radius: 20px 20px 6px 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.4), 0 4px 14px rgba(124,58,237,0.2);
|
||||
}
|
||||
.variant-y2k .bubble.them {
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 20px 20px 20px 6px;
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
}
|
||||
.variant-y2k .reaction { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); backdrop-filter: blur(10px); }
|
||||
.variant-y2k .reaction.mine { background: rgba(199,210,254,0.25); border-color: rgba(199,210,254,0.5); }
|
||||
.variant-y2k .event-pill { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.1); backdrop-filter: blur(10px); }
|
||||
.variant-y2k .event-pill.rejected { background: rgba(251,113,133,0.15); color: #fda4af; border-color: rgba(251,113,133,0.3); }
|
||||
.variant-y2k .composer { background: rgba(255,255,255,0.02); border-top: 1px solid rgba(255,255,255,0.08); }
|
||||
.variant-y2k .composer-box { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); backdrop-filter: blur(10px); }
|
||||
.variant-y2k .composer-btn { background: linear-gradient(135deg, #e0e7ff, #c4b5fd); color: #312e81; border: 1px solid rgba(255,255,255,0.3); box-shadow: inset 0 1px 0 rgba(255,255,255,0.4); }
|
||||
.variant-y2k .presence { border-color: #0a0a1f; }
|
||||
|
||||
|
||||
/* ================= VARIANT 4: CYBERPUNK NEON ================= */
|
||||
.variant-cyber {
|
||||
--bg: #05050a;
|
||||
--fg: #e0e0e8;
|
||||
--fg-muted: #6b7280;
|
||||
--neon-cyan: #00f0ff;
|
||||
--neon-magenta: #ff00aa;
|
||||
--neon-lime: #b6ff3d;
|
||||
color: var(--fg);
|
||||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, transparent 0, transparent 2px, rgba(0,240,255,0.015) 2px, rgba(0,240,255,0.015) 4px),
|
||||
radial-gradient(ellipse at center, #0c0c18 0%, #05050a 70%);
|
||||
}
|
||||
.variant-cyber .app-window {
|
||||
background: #08080f;
|
||||
border: 1px solid rgba(0,240,255,0.2);
|
||||
box-shadow: 0 0 40px rgba(255,0,170,0.15), 0 0 80px rgba(0,240,255,0.08);
|
||||
}
|
||||
.variant-cyber .window-bar { background: #05050a; border-bottom: 1px solid rgba(0,240,255,0.15); }
|
||||
.variant-cyber .window-bar .title { color: var(--neon-cyan); font-size: 11px; letter-spacing: 0.15em; text-transform: uppercase; }
|
||||
.variant-cyber .window-bar .title::before { content: "// "; opacity: 0.5; }
|
||||
.variant-cyber .top-nav { background: #05050a; border-bottom: 1px solid rgba(0,240,255,0.15); }
|
||||
.variant-cyber .nav-brand { color: var(--neon-cyan); text-transform: uppercase; letter-spacing: 0.1em; font-size: 13px; text-shadow: 0 0 10px rgba(0,240,255,0.5); }
|
||||
.variant-cyber .nav-tab { color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.1em; font-size: 11px; font-weight: 500; border: 1px solid transparent; }
|
||||
.variant-cyber .nav-tab:hover { color: var(--neon-cyan); border-color: rgba(0,240,255,0.2); }
|
||||
.variant-cyber .nav-tab.active { color: var(--neon-lime); border-color: var(--neon-lime); background: rgba(182,255,61,0.05); box-shadow: 0 0 10px rgba(182,255,61,0.2); }
|
||||
.variant-cyber .nav-tab.active::before { content: "> "; }
|
||||
.variant-cyber .nav-user { border: 1px solid rgba(0,240,255,0.15); }
|
||||
.variant-cyber .chat-list { background: #05050a; border-right: 1px solid rgba(0,240,255,0.15); }
|
||||
.variant-cyber .chat-list-title { color: var(--neon-cyan); text-transform: uppercase; letter-spacing: 0.15em; font-size: 11px; }
|
||||
.variant-cyber .chat-list-title::before { content: "[ "; opacity: 0.5; }
|
||||
.variant-cyber .chat-list-title::after { content: " ]"; opacity: 0.5; }
|
||||
.variant-cyber .chat-search { background: #0c0c18; border: 1px solid rgba(0,240,255,0.15); color: var(--fg); }
|
||||
.variant-cyber .chat-item:hover { background: rgba(0,240,255,0.04); }
|
||||
.variant-cyber .chat-item.active { background: rgba(0,240,255,0.06); border-left: 2px solid var(--neon-cyan); padding-left: 10px; }
|
||||
.variant-cyber .chat-item-name { color: var(--fg); }
|
||||
.variant-cyber .avatar {
|
||||
background: #0c0c18;
|
||||
border: 1px solid var(--neon-cyan);
|
||||
color: var(--neon-cyan);
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
text-shadow: 0 0 8px rgba(0,240,255,0.5);
|
||||
}
|
||||
.variant-cyber .avatar[data-color="violet"] { border-color: var(--neon-magenta); color: var(--neon-magenta); text-shadow: 0 0 8px rgba(255,0,170,0.5); }
|
||||
.variant-cyber .avatar[data-color="amber"] { border-color: var(--neon-lime); color: var(--neon-lime); text-shadow: 0 0 8px rgba(182,255,61,0.5); }
|
||||
.variant-cyber .avatar[data-color="rose"] { border-color: #ff5500; color: #ff5500; text-shadow: 0 0 8px rgba(255,85,0,0.5); }
|
||||
.variant-cyber .avatar[data-color="teal"] { border-color: var(--neon-cyan); color: var(--neon-cyan); }
|
||||
.variant-cyber .chat-header { background: #05050a; border-bottom: 1px solid rgba(0,240,255,0.15); }
|
||||
.variant-cyber .chat-header-name { color: var(--neon-cyan); font-family: 'JetBrains Mono', monospace; }
|
||||
.variant-cyber .chat-header-name::before { content: "@"; opacity: 0.5; }
|
||||
.variant-cyber .icon-btn { color: var(--neon-cyan); }
|
||||
.variant-cyber .icon-btn:hover { background: rgba(0,240,255,0.1); }
|
||||
.variant-cyber .bubble.me {
|
||||
background: transparent;
|
||||
color: var(--neon-magenta);
|
||||
border: 1px solid var(--neon-magenta);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 15px rgba(255,0,170,0.15), inset 0 0 15px rgba(255,0,170,0.05);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.variant-cyber .bubble.them {
|
||||
background: transparent;
|
||||
color: var(--neon-cyan);
|
||||
border: 1px solid var(--neon-cyan);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 15px rgba(0,240,255,0.15), inset 0 0 15px rgba(0,240,255,0.05);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.variant-cyber .bubble .ts { color: rgba(255,255,255,0.4); font-size: 9px; letter-spacing: 0.1em; }
|
||||
.variant-cyber .reaction { background: #0c0c18; border: 1px solid rgba(0,240,255,0.2); border-radius: 2px; color: var(--neon-lime); }
|
||||
.variant-cyber .reaction.mine { border-color: var(--neon-lime); box-shadow: 0 0 10px rgba(182,255,61,0.2); }
|
||||
.variant-cyber .event-pill { background: transparent; border: 1px dashed var(--neon-lime); color: var(--neon-lime); border-radius: 2px; text-transform: uppercase; letter-spacing: 0.1em; font-size: 10px; }
|
||||
.variant-cyber .event-pill.rejected { border-color: var(--neon-magenta); color: var(--neon-magenta); }
|
||||
.variant-cyber .composer { background: #05050a; border-top: 1px solid rgba(0,240,255,0.15); }
|
||||
.variant-cyber .composer-box { background: #0c0c18; border: 1px solid rgba(0,240,255,0.2); border-radius: 4px; }
|
||||
.variant-cyber .composer-box::before { content: ">"; color: var(--neon-lime); margin-right: 6px; font-weight: bold; }
|
||||
.variant-cyber .composer-btn { background: var(--neon-lime); color: #05050a; border-radius: 4px; box-shadow: 0 0 15px rgba(182,255,61,0.3); }
|
||||
.variant-cyber .presence { border-color: #08080f; }
|
||||
|
||||
|
||||
/* ================= VARIANT 5: WARM SOFT ================= */
|
||||
.variant-warm {
|
||||
--bg: #f7f2e9;
|
||||
--bg-2: #efe6d4;
|
||||
--bg-3: #fdfaf3;
|
||||
--fg: #3d2e1f;
|
||||
--fg-muted: #8a7560;
|
||||
--accent: #c75d3a;
|
||||
--accent-2: #e8a87c;
|
||||
--line: rgba(61,46,31,0.1);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
.variant-warm .nav-brand { font-family: 'Fraunces', 'Playfair Display', serif; font-weight: 700; font-size: 20px; letter-spacing: -0.02em; }
|
||||
.variant-warm .chat-header-name { font-family: 'Fraunces', 'Playfair Display', serif; font-size: 17px; }
|
||||
.variant-warm .chat-list-title { font-family: 'Fraunces', serif; font-size: 16px; font-weight: 600; }
|
||||
.variant-warm .app-window { background: var(--bg-3); box-shadow: 0 20px 60px rgba(61,46,31,0.12); border: 1px solid var(--line); }
|
||||
.variant-warm .window-bar { background: var(--bg-2); border-bottom: 1px solid var(--line); }
|
||||
.variant-warm .window-bar .title { color: var(--fg-muted); font-weight: 500; }
|
||||
.variant-warm .top-nav { background: var(--bg-3); border-bottom: 1px solid var(--line); }
|
||||
.variant-warm .nav-tab { color: var(--fg-muted); font-weight: 500; border-radius: 999px; }
|
||||
.variant-warm .nav-tab:hover { background: var(--bg-2); color: var(--fg); }
|
||||
.variant-warm .nav-tab.active { background: var(--accent); color: #fff; }
|
||||
.variant-warm .nav-user { background: var(--bg-2); border-radius: 999px; }
|
||||
.variant-warm .chat-list { background: var(--bg-2); }
|
||||
.variant-warm .chat-search { background: var(--bg-3); border: 1px solid var(--line); color: var(--fg); border-radius: 999px; }
|
||||
.variant-warm .chat-item { border-radius: 14px; }
|
||||
.variant-warm .chat-item:hover { background: rgba(255,255,255,0.5); }
|
||||
.variant-warm .chat-item.active { background: var(--bg-3); box-shadow: 0 2px 8px rgba(61,46,31,0.06); }
|
||||
.variant-warm .avatar { background: #e8a87c; color: #7c3a1e; font-family: 'Fraunces', serif; font-weight: 700; }
|
||||
.variant-warm .avatar[data-color="violet"] { background: #bfa4c9; color: #4a2a5c; }
|
||||
.variant-warm .avatar[data-color="amber"] { background: #e8c87c; color: #5c3e1a; }
|
||||
.variant-warm .avatar[data-color="rose"] { background: #e8a4a4; color: #5c1e1e; }
|
||||
.variant-warm .avatar[data-color="teal"] { background: #a4c9bf; color: #1e4a3e; }
|
||||
.variant-warm .chat-header { background: var(--bg-3); border-bottom: 1px solid var(--line); }
|
||||
.variant-warm .icon-btn:hover { background: var(--bg-2); }
|
||||
.variant-warm .messages { background: var(--bg-3); }
|
||||
.variant-warm .bubble.me { background: var(--accent); color: #fff; border-radius: 20px 20px 6px 20px; }
|
||||
.variant-warm .bubble.them { background: var(--bg-2); color: var(--fg); border-radius: 20px 20px 20px 6px; }
|
||||
.variant-warm .reaction { background: var(--bg-2); border: 1px solid var(--line); }
|
||||
.variant-warm .reaction.mine { background: var(--accent); color: #fff; }
|
||||
.variant-warm .event-pill { background: var(--bg-2); color: var(--fg-muted); border: 1px solid var(--line); }
|
||||
.variant-warm .event-pill.rejected { background: rgba(199,93,58,0.1); color: var(--accent); }
|
||||
.variant-warm .composer { background: var(--bg-3); border-top: 1px solid var(--line); }
|
||||
.variant-warm .composer-box { background: var(--bg-2); border-radius: 999px; padding-left: 16px; }
|
||||
.variant-warm .composer-btn { background: var(--accent); color: #fff; border-radius: 50%; }
|
||||
.variant-warm .presence { border-color: var(--bg-3); }
|
||||
|
||||
|
||||
/* ================= VARIANT 6: BRUTAL GAMING ================= */
|
||||
.variant-brutal {
|
||||
--bg: #0d0d0d;
|
||||
--bg-2: #161616;
|
||||
--bg-3: #1a1a1a;
|
||||
--fg: #ffffff;
|
||||
--fg-muted: #808080;
|
||||
--lime: #d4ff3d;
|
||||
--line: #2a2a2a;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
.variant-brutal .nav-brand {
|
||||
font-family: 'Archivo Black', 'Inter', sans-serif;
|
||||
font-weight: 900;
|
||||
font-size: 22px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.variant-brutal .chat-header-name { font-weight: 800; font-size: 16px; text-transform: uppercase; letter-spacing: -0.01em; }
|
||||
.variant-brutal .chat-list-title { font-weight: 900; font-size: 18px; text-transform: uppercase; letter-spacing: -0.02em; }
|
||||
.variant-brutal .app-window { background: var(--bg); border: 2px solid var(--lime); border-radius: 0; box-shadow: 12px 12px 0 var(--lime); }
|
||||
.variant-brutal .window-bar { background: var(--bg); border-bottom: 2px solid var(--lime); }
|
||||
.variant-brutal .window-bar .title { color: var(--lime); font-weight: 800; text-transform: uppercase; letter-spacing: 0.1em; font-size: 11px; }
|
||||
.variant-brutal .top-nav { background: var(--bg); border-bottom: 2px solid var(--line); }
|
||||
.variant-brutal .nav-tab { color: var(--fg-muted); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; font-size: 12px; border-radius: 0; border: 2px solid transparent; }
|
||||
.variant-brutal .nav-tab:hover { color: var(--fg); background: var(--bg-2); }
|
||||
.variant-brutal .nav-tab.active { background: var(--lime); color: #000; }
|
||||
.variant-brutal .nav-user { background: var(--bg-2); border: 2px solid var(--line); border-radius: 0; }
|
||||
.variant-brutal .chat-list { background: var(--bg); border-right: 2px solid var(--line); }
|
||||
.variant-brutal .chat-search { background: var(--bg-2); border: 2px solid var(--line); color: var(--fg); border-radius: 0; font-weight: 500; }
|
||||
.variant-brutal .chat-item { border-radius: 0; border-left: 4px solid transparent; }
|
||||
.variant-brutal .chat-item:hover { background: var(--bg-2); }
|
||||
.variant-brutal .chat-item.active { background: var(--bg-2); border-left-color: var(--lime); }
|
||||
.variant-brutal .chat-item-name { font-weight: 800; text-transform: uppercase; font-size: 13px; letter-spacing: -0.01em; }
|
||||
.variant-brutal .avatar { background: var(--lime); color: #000; border-radius: 0; font-weight: 900; font-family: 'Archivo Black', sans-serif; }
|
||||
.variant-brutal .avatar[data-color="violet"] { background: #a78bfa; }
|
||||
.variant-brutal .avatar[data-color="amber"] { background: #fbbf24; }
|
||||
.variant-brutal .avatar[data-color="rose"] { background: #fb7185; }
|
||||
.variant-brutal .avatar[data-color="teal"] { background: #2dd4bf; }
|
||||
.variant-brutal .chat-header { background: var(--bg); border-bottom: 2px solid var(--line); }
|
||||
.variant-brutal .icon-btn { border-radius: 0; border: 2px solid var(--line); }
|
||||
.variant-brutal .icon-btn:hover { background: var(--lime); color: #000; border-color: var(--lime); }
|
||||
.variant-brutal .bubble.me { background: var(--lime); color: #000; border-radius: 0; font-weight: 600; border: 2px solid #000; box-shadow: 4px 4px 0 #fff; }
|
||||
.variant-brutal .bubble.them { background: var(--fg); color: #000; border-radius: 0; font-weight: 600; border: 2px solid var(--fg); box-shadow: 4px 4px 0 var(--lime); }
|
||||
.variant-brutal .bubble .ts { color: rgba(0,0,0,0.6); font-weight: 700; font-size: 10px; text-transform: uppercase; }
|
||||
.variant-brutal .reaction { background: var(--bg-2); border: 2px solid var(--line); border-radius: 0; font-weight: 700; }
|
||||
.variant-brutal .reaction.mine { background: var(--lime); color: #000; border-color: var(--lime); }
|
||||
.variant-brutal .event-pill { background: var(--bg-2); border: 2px solid var(--line); border-radius: 0; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 800; font-size: 11px; }
|
||||
.variant-brutal .event-pill.rejected { border-color: #fb7185; color: #fb7185; }
|
||||
.variant-brutal .composer { background: var(--bg); border-top: 2px solid var(--line); }
|
||||
.variant-brutal .composer-box { background: var(--bg-2); border: 2px solid var(--line); border-radius: 0; }
|
||||
.variant-brutal .composer-btn { background: var(--lime); color: #000; border-radius: 0; font-weight: 900; }
|
||||
.variant-brutal .presence { border-color: var(--bg); border-width: 3px; }
|
||||
|
||||
/* Profile card & call overlay — inherit from variant */
|
||||
.variant-clean .profile-card { background: #fff; border: 1px solid var(--line); }
|
||||
.variant-clean .profile-card-banner { background: linear-gradient(135deg, #e5e5e5, #d4d4d4); }
|
||||
.variant-clean .profile-card-avatar { border-color: #fff; }
|
||||
.variant-clean .profile-card-meta { border-top-color: var(--line); color: var(--fg-muted); }
|
||||
.variant-clean .call-overlay { background: #fff; border: 1px solid var(--line); color: var(--fg); }
|
||||
|
||||
.variant-playful .profile-card { background: linear-gradient(180deg, #2a1a48, #1a1625); border: 1px solid rgba(255,255,255,0.08); }
|
||||
.variant-playful .profile-card-banner { background: linear-gradient(135deg, #a78bfa, #f472b6); }
|
||||
.variant-playful .profile-card-avatar { border-color: #2a1a48; }
|
||||
.variant-playful .call-overlay { background: linear-gradient(135deg, #2a1a48, #1a1625); border: 1px solid rgba(167,139,250,0.3); }
|
||||
|
||||
.variant-y2k .profile-card { background: rgba(255,255,255,0.08); backdrop-filter: blur(30px); border: 1px solid rgba(255,255,255,0.15); }
|
||||
.variant-y2k .profile-card-banner { background: linear-gradient(135deg, #c4b5fd, #f9a8d4); }
|
||||
.variant-y2k .profile-card-avatar { border-color: #1a1a2e; }
|
||||
.variant-y2k .call-overlay { background: rgba(255,255,255,0.08); backdrop-filter: blur(30px); border: 1px solid rgba(255,255,255,0.15); }
|
||||
|
||||
.variant-cyber .profile-card { background: #0c0c18; border: 1px solid var(--neon-cyan); box-shadow: 0 0 30px rgba(0,240,255,0.2); }
|
||||
.variant-cyber .profile-card-banner { background: repeating-linear-gradient(90deg, #00f0ff, #00f0ff 2px, transparent 2px, transparent 8px), linear-gradient(135deg, #ff00aa 0%, #00f0ff 100%); }
|
||||
.variant-cyber .profile-card-avatar { border-color: #0c0c18; border-radius: 4px; }
|
||||
.variant-cyber .call-overlay { background: #0c0c18; border: 1px solid var(--neon-lime); box-shadow: 0 0 30px rgba(182,255,61,0.2); }
|
||||
|
||||
.variant-warm .profile-card { background: var(--bg-3); border: 1px solid var(--line); color: var(--fg); }
|
||||
.variant-warm .profile-card-banner { background: linear-gradient(135deg, #e8a87c, #c75d3a); }
|
||||
.variant-warm .profile-card-avatar { border-color: var(--bg-3); }
|
||||
.variant-warm .profile-card-meta { border-top-color: var(--line); }
|
||||
.variant-warm .call-overlay { background: var(--bg-3); border: 1px solid var(--line); color: var(--fg); }
|
||||
|
||||
.variant-brutal .profile-card { background: var(--bg); border: 2px solid var(--lime); border-radius: 0; box-shadow: 8px 8px 0 var(--lime); }
|
||||
.variant-brutal .profile-card-banner { background: var(--lime); }
|
||||
.variant-brutal .profile-card-avatar { border-color: var(--bg); border-radius: 0; border-width: 3px; }
|
||||
.variant-brutal .profile-card-meta { border-top-color: var(--line); }
|
||||
.variant-brutal .call-overlay { background: var(--bg); border: 2px solid var(--lime); border-radius: 0; box-shadow: 6px 6px 0 var(--lime); }
|
||||
|
||||
|
||||
/* Typing indicator */
|
||||
.typing { display: inline-flex; gap: 3px; padding: 4px 2px; }
|
||||
.typing span { width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: 0.4; animation: typingDot 1.3s infinite; }
|
||||
.typing span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.typing span:nth-child(3) { animation-delay: 0.3s; }
|
||||
@keyframes typingDot {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
30% { transform: translateY(-4px); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Tweaks panel */
|
||||
.tweaks-panel {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 280px;
|
||||
background: rgba(20,20,24,0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
z-index: 200;
|
||||
color: #fff;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
|
||||
display: none;
|
||||
}
|
||||
.tweaks-panel.open { display: block; }
|
||||
.tweaks-panel h3 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em; opacity: 0.7; margin-bottom: 14px; font-weight: 600; }
|
||||
.tweaks-row { margin-bottom: 12px; }
|
||||
.tweaks-row label { display: block; font-size: 11px; opacity: 0.6; margin-bottom: 6px; }
|
||||
.tweaks-row input[type="color"] { width: 100%; height: 34px; border-radius: 8px; background: transparent; border: 1px solid rgba(255,255,255,0.1); cursor: pointer; }
|
||||
.tweaks-row input[type="range"] { width: 100%; }
|
||||
.tweaks-row select { width: 100%; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 8px; border-radius: 8px; font-size: 12px; }
|
||||
@@ -86,7 +86,11 @@ export async function completeSessionFromUrl(
|
||||
}
|
||||
|
||||
export async function signOut(client: AppSupabaseClient): Promise<void> {
|
||||
const { error } = await client.auth.signOut();
|
||||
// `scope: 'local'` only ends the session in THIS client. Without it Supabase
|
||||
// defaults to 'global', which invalidates the user's refresh tokens
|
||||
// everywhere — meaning a logout in the browser would also kick the desktop
|
||||
// app (and vice versa) the next time it tries to refresh its token.
|
||||
const { error } = await client.auth.signOut({ scope: 'local' });
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,9 +126,17 @@ async function fetchKeyBundle(
|
||||
};
|
||||
}
|
||||
|
||||
// Strips the leading `\x` postgres bytea hex prefix so the RPC's
|
||||
// `decode(text, 'hex')` accepts it.
|
||||
function hexNoPrefix(bytes: Uint8Array): string {
|
||||
return bytesToPgHex(bytes).slice(2);
|
||||
}
|
||||
|
||||
// Bootstraps a brand-new conv-key, wrapping it for every member device that
|
||||
// currently exists (including the caller's own devices). Used the first time
|
||||
// a conversation needs a key, or when rotation is requested.
|
||||
// a conversation needs a key, or when rotation is requested. All inserts go
|
||||
// through `share_conv_keys` (SECURITY DEFINER) — silently skips invalid
|
||||
// recipients, no per-row 403 console spam.
|
||||
export async function bootstrapConvKey(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
@@ -141,27 +149,24 @@ export async function bootstrapConvKey(
|
||||
throw new Error('cannot bootstrap conv key — no recipient devices');
|
||||
}
|
||||
|
||||
const rows: Array<{
|
||||
conversation_id: string;
|
||||
recipient_device_id: string;
|
||||
key_version: number;
|
||||
sender_device_id: string;
|
||||
encrypted_key: string;
|
||||
nonce: string;
|
||||
}> = [];
|
||||
const bundles: Array<{ recipient_device_id: string; encrypted_key: string; nonce: string }> = [];
|
||||
for (const r of recipients) {
|
||||
const wrapped = await wrapConvKeyForRecipient(convKey, r.publicKey, own.privateKey);
|
||||
rows.push({
|
||||
conversation_id: conversationId,
|
||||
bundles.push({
|
||||
recipient_device_id: r.deviceId,
|
||||
key_version: keyVersion,
|
||||
sender_device_id: own.deviceId,
|
||||
encrypted_key: bytesToPgHex(wrapped.ciphertext),
|
||||
nonce: bytesToPgHex(wrapped.nonce),
|
||||
encrypted_key: hexNoPrefix(wrapped.ciphertext),
|
||||
nonce: hexNoPrefix(wrapped.nonce),
|
||||
});
|
||||
}
|
||||
|
||||
const { error } = await rawFrom(client, 'conversation_keys').insert(rows);
|
||||
// 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, 'share_conv_keys', {
|
||||
p_conv_id: conversationId,
|
||||
p_sender_device_id: own.deviceId,
|
||||
p_key_version: keyVersion,
|
||||
p_bundles: bundles,
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
const handle = { conversationId, keyVersion, key: convKey };
|
||||
@@ -262,15 +267,21 @@ export async function shareConvKeyToDevice(
|
||||
recipientPublicKey,
|
||||
own.privateKey,
|
||||
);
|
||||
const { error } = await rawFrom(client, 'conversation_keys').insert({
|
||||
conversation_id: conversationId,
|
||||
recipient_device_id: recipientDeviceId,
|
||||
key_version: version,
|
||||
sender_device_id: own.deviceId,
|
||||
encrypted_key: bytesToPgHex(wrapped.ciphertext),
|
||||
nonce: bytesToPgHex(wrapped.nonce),
|
||||
// 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, 'share_conv_keys', {
|
||||
p_conv_id: conversationId,
|
||||
p_sender_device_id: own.deviceId,
|
||||
p_key_version: version,
|
||||
p_bundles: [
|
||||
{
|
||||
recipient_device_id: recipientDeviceId,
|
||||
encrypted_key: hexNoPrefix(wrapped.ciphertext),
|
||||
nonce: hexNoPrefix(wrapped.nonce),
|
||||
},
|
||||
],
|
||||
});
|
||||
if (error && !String(error.message ?? '').includes('duplicate')) throw error;
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// Re-exports for convenience.
|
||||
|
||||
@@ -23,3 +23,20 @@ export function pgHexToBytes(hex: string): Uint8Array {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Accepts either:
|
||||
// - PostgREST/REST `\x<hex>` strings (what the .from('table').select() path
|
||||
// returns for bytea), or
|
||||
// - Realtime `postgres_changes` payloads, which encode bytea as plain
|
||||
// base64 (no `\x` prefix).
|
||||
// Useful when the same row can arrive through both paths in the same UI.
|
||||
export function pgBytesToBytes(value: string): Uint8Array {
|
||||
if (value.startsWith('\\x')) {
|
||||
return pgHexToBytes(value);
|
||||
}
|
||||
// Assume base64 (the realtime serializer's default for bytea).
|
||||
const bin = atob(value);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,20 @@ import type { Database, SupabaseConfig } from './types.js';
|
||||
// Typed client alias used throughout the app.
|
||||
export type AppSupabaseClient = SupabaseClient<Database>;
|
||||
|
||||
// Inline serial lock — replaces Supabase's default `navigator.locks` based
|
||||
// lock that occasionally throws "Lock was stolen by another request" when
|
||||
// the same origin opens multiple tabs / Tauri windows / HMR-reloaded
|
||||
// modules. We only have one client instance per process so a simple promise
|
||||
// chain serialises token-refresh fine without cross-tab coordination.
|
||||
const acquireLock = (() => {
|
||||
let chain: Promise<unknown> = Promise.resolve();
|
||||
return async <R>(_name: string, _acquireTimeout: number, fn: () => Promise<R>): Promise<R> => {
|
||||
const next = chain.then(() => fn(), () => fn());
|
||||
chain = next.catch(() => undefined);
|
||||
return next;
|
||||
};
|
||||
})();
|
||||
|
||||
export function createClient(config: SupabaseConfig): AppSupabaseClient {
|
||||
return createSupabaseClient<Database>(config.url, config.anonKey, {
|
||||
auth: {
|
||||
@@ -12,6 +26,7 @@ export function createClient(config: SupabaseConfig): AppSupabaseClient {
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: config.detectSessionInUrl ?? false,
|
||||
lock: acquireLock,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ importers:
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2.1.1
|
||||
version: 2.10.1
|
||||
'@tauri-apps/plugin-fs':
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0
|
||||
'@tauri-apps/plugin-global-shortcut':
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.1
|
||||
@@ -1766,6 +1769,9 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@tauri-apps/plugin-fs@2.5.0':
|
||||
resolution: {integrity: sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==}
|
||||
|
||||
'@tauri-apps/plugin-global-shortcut@2.3.1':
|
||||
resolution: {integrity: sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==}
|
||||
|
||||
@@ -7401,6 +7407,10 @@ snapshots:
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
|
||||
|
||||
'@tauri-apps/plugin-fs@2.5.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
|
||||
'@tauri-apps/plugin-global-shortcut@2.3.1':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
-- 1) Test-Daten-Wipe: alle conversation/message Daten weg, User+Devices+
|
||||
-- Friendships+Invites bleiben erhalten so dass bestehende Logins weiter
|
||||
-- funktionieren. Cascade räumt mitlaufende rows (envelopes, attachments,
|
||||
-- reactions, reads, conversation_keys, conversation_members).
|
||||
|
||||
truncate
|
||||
public.message_reactions,
|
||||
public.message_reads,
|
||||
public.message_attachments,
|
||||
public.messages,
|
||||
public.conversation_keys,
|
||||
public.conversation_members,
|
||||
public.conversations
|
||||
restart identity cascade;
|
||||
|
||||
-- 2) RPC: share conv-key bundles to multiple recipients atomically.
|
||||
-- Runs as SECURITY DEFINER so RLS doesn't reject individual rows. The
|
||||
-- function itself enforces the same invariants as the policy. This avoids
|
||||
-- the per-row 403 console spam we got with direct inserts.
|
||||
|
||||
create or replace function public.share_conv_keys(
|
||||
p_conv_id uuid,
|
||||
p_sender_device_id uuid,
|
||||
p_key_version int,
|
||||
p_bundles jsonb
|
||||
) returns int
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
caller uuid := auth.uid();
|
||||
bundle jsonb;
|
||||
recipient_user_id uuid;
|
||||
inserted int := 0;
|
||||
recipient_did uuid;
|
||||
enc_key_hex text;
|
||||
nonce_hex text;
|
||||
begin
|
||||
if caller is null then
|
||||
raise exception 'not authenticated';
|
||||
end if;
|
||||
|
||||
-- Caller must be an accepted member of the conversation.
|
||||
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;
|
||||
|
||||
-- Sender device must belong to the caller.
|
||||
if not exists (
|
||||
select 1
|
||||
from public.devices
|
||||
where id = p_sender_device_id
|
||||
and user_id = caller
|
||||
) then
|
||||
raise exception 'sender_device % not owned by caller', p_sender_device_id;
|
||||
end if;
|
||||
|
||||
-- Iterate bundles and only insert ones whose recipient is a current
|
||||
-- accepted member. Silently skip otherwise (no error → no 403 noise).
|
||||
for bundle in
|
||||
select * from jsonb_array_elements(p_bundles)
|
||||
loop
|
||||
recipient_did := (bundle->>'recipient_device_id')::uuid;
|
||||
enc_key_hex := bundle->>'encrypted_key';
|
||||
nonce_hex := bundle->>'nonce';
|
||||
|
||||
select user_id
|
||||
into recipient_user_id
|
||||
from public.devices
|
||||
where id = recipient_did;
|
||||
if recipient_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 = recipient_user_id
|
||||
and accepted = true
|
||||
) then continue; end if;
|
||||
|
||||
insert into public.conversation_keys
|
||||
(conversation_id, recipient_device_id, key_version,
|
||||
sender_device_id, encrypted_key, nonce)
|
||||
values
|
||||
(p_conv_id, recipient_did, p_key_version,
|
||||
p_sender_device_id,
|
||||
decode(enc_key_hex, 'hex'),
|
||||
decode(nonce_hex, 'hex'))
|
||||
on conflict (conversation_id, recipient_device_id, key_version)
|
||||
do nothing;
|
||||
|
||||
if found then
|
||||
inserted := inserted + 1;
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
return inserted;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke execute on function public.share_conv_keys(uuid, uuid, int, jsonb) from public, anon;
|
||||
grant execute on function public.share_conv_keys(uuid, uuid, int, jsonb) to authenticated;
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Profile-Avatar storage bucket.
|
||||
--
|
||||
-- Layout: <user_id>/<filename>.webp
|
||||
-- Public read so peers can see each other's avatar without auth roundtrip
|
||||
-- (avatars are non-sensitive). Write/update/delete restricted to the owning
|
||||
-- user via path prefix matching their auth.uid().
|
||||
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('profile-avatars', 'profile-avatars', true)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
-- Read: anyone authenticated can view any avatar (public bucket also lets
|
||||
-- unauth fetch by URL but our RLS keeps the table-level policy explicit).
|
||||
drop policy if exists profile_avatars_select on storage.objects;
|
||||
create policy profile_avatars_select
|
||||
on storage.objects
|
||||
for select
|
||||
to authenticated, anon
|
||||
using (bucket_id = 'profile-avatars');
|
||||
|
||||
-- Write only into your own folder (first path segment must equal auth.uid()).
|
||||
drop policy if exists profile_avatars_insert_own on storage.objects;
|
||||
create policy profile_avatars_insert_own
|
||||
on storage.objects
|
||||
for insert
|
||||
to authenticated
|
||||
with check (
|
||||
bucket_id = 'profile-avatars'
|
||||
and (storage.foldername(name))[1] = auth.uid()::text
|
||||
);
|
||||
|
||||
drop policy if exists profile_avatars_update_own on storage.objects;
|
||||
create policy profile_avatars_update_own
|
||||
on storage.objects
|
||||
for update
|
||||
to authenticated
|
||||
using (
|
||||
bucket_id = 'profile-avatars'
|
||||
and (storage.foldername(name))[1] = auth.uid()::text
|
||||
)
|
||||
with check (
|
||||
bucket_id = 'profile-avatars'
|
||||
and (storage.foldername(name))[1] = auth.uid()::text
|
||||
);
|
||||
|
||||
drop policy if exists profile_avatars_delete_own on storage.objects;
|
||||
create policy profile_avatars_delete_own
|
||||
on storage.objects
|
||||
for delete
|
||||
to authenticated
|
||||
using (
|
||||
bucket_id = 'profile-avatars'
|
||||
and (storage.foldername(name))[1] = auth.uid()::text
|
||||
);
|
||||