feat: two-step registration with avatar/color picker, fix focus ring clipping
Refactor RegisterPage into a two-step flow: credentials first, then personalization (display name, avatar upload, avatar color). Replace the dual-panel sliding layout with conditional rendering and CSS keyframe animations to eliminate overflow-hidden clipping of focus rings. Supporting changes: - Server accepts avatarColor on registration - Auth store resets all user-scoped stores on login/register/logout - Voice store gains resetSession() for full session cleanup - Sync presence status to federated instances - Propagate presence_update to socialStore regardless of origin
This commit is contained in:
@@ -18,7 +18,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { username, password, displayName, homeInstance, homeUserId } = request.body;
|
const { username, password, displayName, avatarColor: requestedAvatarColor, homeInstance, homeUserId } = request.body;
|
||||||
|
|
||||||
if (!username || typeof username !== 'string') {
|
if (!username || typeof username !== 'string') {
|
||||||
return reply.code(400).send({ error: 'Username is required', statusCode: 400 });
|
return reply.code(400).send({ error: 'Username is required', statusCode: 400 });
|
||||||
@@ -96,7 +96,9 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const userCount = db.select().from(schema.users).all().length;
|
const userCount = db.select().from(schema.users).all().length;
|
||||||
const isFirstUser = userCount === 0 && !homeInstance;
|
const isFirstUser = userCount === 0 && !homeInstance;
|
||||||
|
|
||||||
const avatarColor = AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)];
|
const avatarColor = (requestedAvatarColor && (AVATAR_COLORS as readonly string[]).includes(requestedAvatarColor))
|
||||||
|
? requestedAvatarColor
|
||||||
|
: AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)];
|
||||||
|
|
||||||
db.insert(schema.users).values({
|
db.insert(schema.users).values({
|
||||||
id: userId,
|
id: userId,
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ export interface RegisterRequest {
|
|||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
avatarColor?: string;
|
||||||
homeInstance?: string;
|
homeInstance?: string;
|
||||||
homeUserId?: string;
|
homeUserId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,62 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { Avatar } from '../ui/Avatar';
|
||||||
|
import { ImageCropModal } from '../ui/ImageCropModal';
|
||||||
|
import { AVATAR_GRADIENT_MAP } from '../../utils/gradients';
|
||||||
|
import { AVATAR_COLORS } from '@backspace/shared';
|
||||||
|
import type { AvatarColor } from '@backspace/shared';
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
|
||||||
export function RegisterPage() {
|
export function RegisterPage() {
|
||||||
|
// Step state
|
||||||
|
const [step, setStep] = useState<1 | 2>(1);
|
||||||
|
const [direction, setDirection] = useState<'forward' | 'back'>('forward');
|
||||||
|
|
||||||
|
// Step 1 fields
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [displayName, setDisplayName] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
|
||||||
|
// Step 2 fields
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const [avatarColor, setAvatarColor] = useState<AvatarColor>(
|
||||||
|
() => AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)] ?? 'mint'
|
||||||
|
);
|
||||||
|
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||||
|
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||||
|
const [avatarCropSrc, setAvatarCropSrc] = useState<string | null>(null);
|
||||||
|
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [isRegistering, setIsRegistering] = useState(false);
|
||||||
|
|
||||||
const register = useAuthStore((s) => s.register);
|
const register = useAuthStore((s) => s.register);
|
||||||
const isLoading = useAuthStore((s) => s.isLoading);
|
const updateProfile = useAuthStore((s) => s.updateProfile);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
// Cleanup blob URL on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||||
|
};
|
||||||
|
}, [avatarPreview]);
|
||||||
|
|
||||||
|
// ── Step 1 validation ──
|
||||||
|
const handleContinue = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
if (!username.trim()) {
|
const trimmed = username.trim();
|
||||||
|
if (!trimmed) {
|
||||||
setError('Username is required');
|
setError('Username is required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (username.trim().length < 3 || username.trim().length > 32) {
|
if (trimmed.length < 3 || trimmed.length > 32) {
|
||||||
setError('Username must be between 3 and 32 characters');
|
setError('Username must be between 3 and 32 characters');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!/^[a-zA-Z0-9_]+$/.test(username.trim())) {
|
if (!/^[a-zA-Z0-9_]+$/.test(trimmed)) {
|
||||||
setError('Username can only contain letters, numbers, and underscores');
|
setError('Username can only contain letters, numbers, and underscores');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -35,24 +68,80 @@ export function RegisterPage() {
|
|||||||
setError('Password must be at least 6 characters');
|
setError('Password must be at least 6 characters');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDirection('forward');
|
||||||
|
setStep(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Avatar file selection ──
|
||||||
|
const handleAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => setAvatarCropSrc(reader.result as string);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
if (avatarInputRef.current) avatarInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarCropComplete = (blob: Blob) => {
|
||||||
|
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||||
|
const previewUrl = URL.createObjectURL(blob);
|
||||||
|
setAvatarPreview(previewUrl);
|
||||||
|
setAvatarFile(new File([blob], 'avatar.png', { type: 'image/png' }));
|
||||||
|
setAvatarCropSrc(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Registration ──
|
||||||
|
const handleRegister = async (skip: boolean) => {
|
||||||
|
setError('');
|
||||||
|
setIsRegistering(true);
|
||||||
try {
|
try {
|
||||||
await register(username.trim(), password, displayName.trim() || undefined);
|
const dn = skip ? undefined : displayName.trim() || undefined;
|
||||||
|
const ac = skip ? undefined : avatarColor;
|
||||||
|
await register(username.trim(), password, dn, ac);
|
||||||
|
|
||||||
|
// Upload avatar if chosen (non-fatal — account already created)
|
||||||
|
if (!skip && avatarFile) {
|
||||||
|
try {
|
||||||
|
const attachment = await api.uploads.upload(avatarFile);
|
||||||
|
await updateProfile({ avatar: attachment.filename });
|
||||||
|
} catch {
|
||||||
|
// Avatar upload failed — user can set it later in settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
navigate('/channels/@me');
|
navigate('/channels/@me');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||||
|
setIsRegistering(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const effectiveDisplayName = displayName.trim() || username.trim();
|
||||||
|
const initial = effectiveDisplayName.charAt(0).toUpperCase();
|
||||||
|
const gradient = AVATAR_GRADIENT_MAP[avatarColor];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-surface-base relative">
|
<div className="min-h-screen flex items-center justify-center bg-surface-base relative">
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
|
||||||
<div className="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10">
|
<div className="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10 overflow-hidden">
|
||||||
|
{/* Progress dots */}
|
||||||
|
<div className="flex justify-center gap-2 mb-5">
|
||||||
|
<div className={`w-2 h-2 rounded-full transition-colors duration-300 ${step === 1 ? 'bg-accent-primary' : 'bg-txt-tertiary/30'}`} />
|
||||||
|
<div className={`w-2 h-2 rounded-full transition-colors duration-300 ${step === 2 ? 'bg-accent-primary' : 'bg-txt-tertiary/30'}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 1 ? (
|
||||||
|
<div key="step1" className={`w-full${direction === 'back' ? ' animate-step-back' : ''}`}>
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<h1 className="text-2xl font-bold text-txt-primary">Create an account</h1>
|
<h1 className="text-2xl font-bold text-txt-primary">Create an account</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleContinue}>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-3 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
<div className="mb-4 p-3 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
||||||
{error}
|
{error}
|
||||||
@@ -73,19 +162,6 @@ export function RegisterPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-5">
|
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
|
||||||
Display Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all"
|
|
||||||
autoComplete="name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||||
Password <span className="text-txt-danger">*</span>
|
Password <span className="text-txt-danger">*</span>
|
||||||
@@ -99,12 +175,24 @@ export function RegisterPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-5">
|
||||||
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||||
|
Confirm Password <span className="text-txt-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors"
|
||||||
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
>
|
||||||
{isLoading ? 'Creating account...' : 'Continue'}
|
Continue
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="mt-3 text-sm text-txt-tertiary">
|
<p className="mt-3 text-sm text-txt-tertiary">
|
||||||
@@ -115,6 +203,140 @@ export function RegisterPage() {
|
|||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key="step2" className={`w-full${direction === 'forward' ? ' animate-step-forward' : ''}`}>
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-txt-primary">Make it yours</h1>
|
||||||
|
<p className="text-txt-tertiary text-sm mt-1">Personalize your profile, or skip for now</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Avatar preview */}
|
||||||
|
<div className="flex flex-col items-center mb-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => avatarInputRef.current?.click()}
|
||||||
|
className="relative group"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
src={avatarPreview}
|
||||||
|
name={effectiveDisplayName}
|
||||||
|
size={80}
|
||||||
|
avatarColor={avatarColor}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 rounded-full bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => avatarInputRef.current?.click()}
|
||||||
|
className="text-xs text-accent-primary hover:underline mt-2"
|
||||||
|
>
|
||||||
|
Upload photo
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={avatarInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleAvatarSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Display Name */}
|
||||||
|
<div className="mb-5">
|
||||||
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||||
|
Display Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder={username.trim() || 'Display name'}
|
||||||
|
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all placeholder:text-txt-tertiary"
|
||||||
|
autoComplete="name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar Color Picker */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||||
|
Avatar Color
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2.5 justify-center">
|
||||||
|
{AVATAR_COLORS.map((key) => {
|
||||||
|
const entry = AVATAR_GRADIENT_MAP[key];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAvatarColor(key)}
|
||||||
|
className="w-8 h-8 rounded-full border-2 transition-all hover:scale-110"
|
||||||
|
style={{
|
||||||
|
background: entry.gradient,
|
||||||
|
borderColor: avatarColor === key ? 'white' : 'transparent',
|
||||||
|
boxShadow: avatarColor === key ? `0 0 0 2px ${entry.glow}40` : 'none',
|
||||||
|
}}
|
||||||
|
title={key.charAt(0).toUpperCase() + key.slice(1)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRegister(false)}
|
||||||
|
disabled={isRegistering}
|
||||||
|
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isRegistering ? 'Creating account...' : 'Get Started'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setError(''); setDirection('back'); setStep(1); }}
|
||||||
|
disabled={isRegistering}
|
||||||
|
className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRegister(true)}
|
||||||
|
disabled={isRegistering}
|
||||||
|
className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image Crop Modal */}
|
||||||
|
{avatarCropSrc && (
|
||||||
|
<ImageCropModal
|
||||||
|
isOpen={true}
|
||||||
|
onClose={() => setAvatarCropSrc(null)}
|
||||||
|
imageSrc={avatarCropSrc}
|
||||||
|
onCropComplete={handleAvatarCropComplete}
|
||||||
|
title="Crop Avatar"
|
||||||
|
aspectRatio={1}
|
||||||
|
cropShape="round"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,9 +305,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
|
|
||||||
case 'presence_update':
|
case 'presence_update':
|
||||||
updateMemberPresence(event.userId, event.status);
|
updateMemberPresence(event.userId, event.status);
|
||||||
if (isHome) {
|
|
||||||
useSocialStore.getState().updateFriendPresence(event.userId, event.status);
|
useSocialStore.getState().updateFriendPresence(event.userId, event.status);
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'user_updated': {
|
case 'user_updated': {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface AuthState {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
register: (username: string, password: string, displayName?: string) => Promise<void>;
|
register: (username: string, password: string, displayName?: string, avatarColor?: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
loadUser: () => Promise<void>;
|
loadUser: () => Promise<void>;
|
||||||
updateProfile: (data: { displayName?: string; avatar?: string; banner?: string; accentColor?: string; avatarColor?: string; bio?: string; customStatus?: string; status?: UserStatus }) => Promise<void>;
|
updateProfile: (data: { displayName?: string; avatar?: string; banner?: string; accentColor?: string; avatarColor?: string; bio?: string; customStatus?: string; status?: UserStatus }) => Promise<void>;
|
||||||
@@ -22,6 +22,15 @@ interface AuthState {
|
|||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reset all user-scoped stores to prevent data leaking between sessions */
|
||||||
|
function resetUserStores() {
|
||||||
|
useChatStore.getState().clearAllMessages();
|
||||||
|
useSpaceStore.getState().populateFromReady('', [], [], []);
|
||||||
|
useSocialStore.getState().reset();
|
||||||
|
useVoiceStore.getState().resetSession();
|
||||||
|
useInstanceStore.getState().reset();
|
||||||
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
token: localStorage.getItem('backspace_token'),
|
token: localStorage.getItem('backspace_token'),
|
||||||
user: null,
|
user: null,
|
||||||
@@ -32,6 +41,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const response = await api.auth.login({ username, password });
|
const response = await api.auth.login({ username, password });
|
||||||
|
resetUserStores();
|
||||||
localStorage.setItem('backspace_token', response.token);
|
localStorage.setItem('backspace_token', response.token);
|
||||||
set({ token: response.token, user: response.user, isLoading: false });
|
set({ token: response.token, user: response.user, isLoading: false });
|
||||||
// Auto-connect to remote instances (fire-and-forget)
|
// Auto-connect to remote instances (fire-and-forget)
|
||||||
@@ -42,12 +52,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
register: async (username: string, password: string, displayName?: string) => {
|
register: async (username: string, password: string, displayName?: string, avatarColor?: string) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const response = await api.auth.register({ username, password, displayName });
|
const response = await api.auth.register({ username, password, displayName, avatarColor });
|
||||||
|
resetUserStores();
|
||||||
localStorage.setItem('backspace_token', response.token);
|
localStorage.setItem('backspace_token', response.token);
|
||||||
set({ token: response.token, user: response.user, isLoading: false });
|
set({ token: response.token, user: response.user, isLoading: false });
|
||||||
|
// Auto-connect to remote instances (fire-and-forget)
|
||||||
|
useInstanceStore.getState().autoConnectAll().catch(() => {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ isLoading: false, error: err instanceof Error ? err.message : 'Registration failed' });
|
set({ isLoading: false, error: err instanceof Error ? err.message : 'Registration failed' });
|
||||||
throw err;
|
throw err;
|
||||||
@@ -56,12 +69,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
localStorage.removeItem('backspace_token');
|
localStorage.removeItem('backspace_token');
|
||||||
// Clear all user-scoped state to prevent data leaking between sessions
|
resetUserStores();
|
||||||
useChatStore.getState().clearAllMessages();
|
|
||||||
useSpaceStore.getState().populateFromReady('', [], [], []);
|
|
||||||
useSocialStore.getState().reset();
|
|
||||||
useVoiceStore.getState().clearAllVoiceUsers();
|
|
||||||
useInstanceStore.getState().reset();
|
|
||||||
set({ token: null, user: null });
|
set({ token: null, user: null });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ interface VoiceState {
|
|||||||
setPermissionMutedUser: (spaceId: string, userId: string, muted: boolean) => void;
|
setPermissionMutedUser: (spaceId: string, userId: string, muted: boolean) => void;
|
||||||
getVoiceUsers: (channelId: string) => string[];
|
getVoiceUsers: (channelId: string) => string[];
|
||||||
clearAllVoiceUsers: () => void;
|
clearAllVoiceUsers: () => void;
|
||||||
|
resetSession: () => void;
|
||||||
clearVoiceUsersForOrigin: (origin: string) => void;
|
clearVoiceUsersForOrigin: (origin: string) => void;
|
||||||
leaveVoice: () => void;
|
leaveVoice: () => void;
|
||||||
handleForceDisconnect: () => void;
|
handleForceDisconnect: () => void;
|
||||||
@@ -365,6 +366,38 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
|
|
||||||
clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }),
|
clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }),
|
||||||
|
|
||||||
|
resetSession: () => set({
|
||||||
|
// Connection state
|
||||||
|
voiceUsers: new Map(),
|
||||||
|
voiceUserStates: new Map(),
|
||||||
|
currentVoiceChannelId: null,
|
||||||
|
participants: [],
|
||||||
|
speakingParticipantIds: new Set(),
|
||||||
|
connectionError: null,
|
||||||
|
isLiveKitConnected: false,
|
||||||
|
connectionQuality: 'unknown',
|
||||||
|
focusedParticipantId: null,
|
||||||
|
// Call state
|
||||||
|
incomingCall: null,
|
||||||
|
outgoingCall: null,
|
||||||
|
activeDmCall: null,
|
||||||
|
// Per-session media state
|
||||||
|
isCameraOn: false,
|
||||||
|
isScreenSharing: false,
|
||||||
|
// Per-session maps
|
||||||
|
participantVolumes: new Map(),
|
||||||
|
participantMutes: new Map(),
|
||||||
|
deafenedUserIds: new Set(),
|
||||||
|
streamVolumes: new Map(),
|
||||||
|
streamMutes: new Map(),
|
||||||
|
watchingStreams: new Set(),
|
||||||
|
unwatchedCameras: new Set(),
|
||||||
|
// Server-enforced restrictions
|
||||||
|
serverMutedUserIds: new Set(),
|
||||||
|
serverDeafenedUserIds: new Set(),
|
||||||
|
permissionMutedUserIds: new Set(),
|
||||||
|
}),
|
||||||
|
|
||||||
clearVoiceUsersForOrigin: (origin: string) => {
|
clearVoiceUsersForOrigin: (origin: string) => {
|
||||||
const { channelOriginMap } = useSpaceStore.getState();
|
const { channelOriginMap } = useSpaceStore.getState();
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
|||||||
@@ -260,3 +260,18 @@
|
|||||||
100% { background-color: transparent; }
|
100% { background-color: transparent; }
|
||||||
}
|
}
|
||||||
.search-highlight { animation: search-flash 2s ease-out; }
|
.search-highlight { animation: search-flash 2s ease-out; }
|
||||||
|
|
||||||
|
@keyframes stepForward {
|
||||||
|
from { transform: translateX(24px); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes stepBack {
|
||||||
|
from { transform: translateX(-24px); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
.animate-step-forward {
|
||||||
|
animation: stepForward 0.25s ease-out;
|
||||||
|
}
|
||||||
|
.animate-step-back {
|
||||||
|
animation: stepBack 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export async function syncProfileToRemote(inst: ConnectedInstance): Promise<void
|
|||||||
accentColor: homeUser.accentColor || undefined,
|
accentColor: homeUser.accentColor || undefined,
|
||||||
bio: homeUser.bio || undefined,
|
bio: homeUser.bio || undefined,
|
||||||
customStatus: homeUser.customStatus || undefined,
|
customStatus: homeUser.customStatus || undefined,
|
||||||
|
status: homeUser.status || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sync avatar
|
// Sync avatar
|
||||||
@@ -66,8 +67,8 @@ export async function syncProfileToRemote(inst: ConnectedInstance): Promise<void
|
|||||||
|
|
||||||
// ─── Incremental sync (profile update) ──────────────────────────────────────
|
// ─── Incremental sync (profile update) ──────────────────────────────────────
|
||||||
|
|
||||||
/** Sync-eligible text fields (no status, replicatedInstances, homeUserId). */
|
/** Sync-eligible text fields (no replicatedInstances, homeUserId). */
|
||||||
const SYNC_FIELDS = ['displayName', 'avatarColor', 'accentColor', 'bio', 'customStatus'] as const;
|
const SYNC_FIELDS = ['displayName', 'avatarColor', 'accentColor', 'bio', 'customStatus', 'status'] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Push a partial profile update to all connected remote instances.
|
* Push a partial profile update to all connected remote instances.
|
||||||
|
|||||||
Reference in New Issue
Block a user