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:
@@ -1,29 +1,62 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
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() {
|
||||
// Step state
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
const [direction, setDirection] = useState<'forward' | 'back'>('forward');
|
||||
|
||||
// Step 1 fields
|
||||
const [username, setUsername] = useState('');
|
||||
const [displayName, setDisplayName] = 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 [isRegistering, setIsRegistering] = useState(false);
|
||||
|
||||
const register = useAuthStore((s) => s.register);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const updateProfile = useAuthStore((s) => s.updateProfile);
|
||||
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();
|
||||
setError('');
|
||||
|
||||
if (!username.trim()) {
|
||||
const trimmed = username.trim();
|
||||
if (!trimmed) {
|
||||
setError('Username is required');
|
||||
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');
|
||||
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');
|
||||
return;
|
||||
}
|
||||
@@ -35,86 +68,275 @@ export function RegisterPage() {
|
||||
setError('Password must be at least 6 characters');
|
||||
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 {
|
||||
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');
|
||||
} catch (err) {
|
||||
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 (
|
||||
<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="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-txt-primary">Create an account</h1>
|
||||
<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>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
||||
{error}
|
||||
{step === 1 ? (
|
||||
<div key="step1" className={`w-full${direction === 'back' ? ' animate-step-back' : ''}`}>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-txt-primary">Create an account</h1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Username <span className="text-txt-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(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"
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
/>
|
||||
<form onSubmit={handleContinue}>
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Username <span className="text-txt-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(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"
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Password <span className="text-txt-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(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>
|
||||
|
||||
<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
|
||||
type="submit"
|
||||
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
|
||||
<p className="mt-3 text-sm text-txt-tertiary">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-accent-primary hover:underline">
|
||||
Log In
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</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>
|
||||
|
||||
<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"
|
||||
/>
|
||||
{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 className="mb-5">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Password <span className="text-txt-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(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
|
||||
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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Continue'}
|
||||
</button>
|
||||
|
||||
<p className="mt-3 text-sm text-txt-tertiary">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-accent-primary hover:underline">
|
||||
Log In
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image Crop Modal */}
|
||||
{avatarCropSrc && (
|
||||
<ImageCropModal
|
||||
isOpen={true}
|
||||
onClose={() => setAvatarCropSrc(null)}
|
||||
imageSrc={avatarCropSrc}
|
||||
onCropComplete={handleAvatarCropComplete}
|
||||
title="Crop Avatar"
|
||||
aspectRatio={1}
|
||||
cropShape="round"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -305,9 +305,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
|
||||
case 'presence_update':
|
||||
updateMemberPresence(event.userId, event.status);
|
||||
if (isHome) {
|
||||
useSocialStore.getState().updateFriendPresence(event.userId, event.status);
|
||||
}
|
||||
useSocialStore.getState().updateFriendPresence(event.userId, event.status);
|
||||
break;
|
||||
|
||||
case 'user_updated': {
|
||||
|
||||
@@ -14,7 +14,7 @@ interface AuthState {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
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;
|
||||
loadUser: () => 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;
|
||||
}
|
||||
|
||||
/** 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) => ({
|
||||
token: localStorage.getItem('backspace_token'),
|
||||
user: null,
|
||||
@@ -32,6 +41,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await api.auth.login({ username, password });
|
||||
resetUserStores();
|
||||
localStorage.setItem('backspace_token', response.token);
|
||||
set({ token: response.token, user: response.user, isLoading: false });
|
||||
// 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 });
|
||||
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);
|
||||
set({ token: response.token, user: response.user, isLoading: false });
|
||||
// Auto-connect to remote instances (fire-and-forget)
|
||||
useInstanceStore.getState().autoConnectAll().catch(() => {});
|
||||
} catch (err) {
|
||||
set({ isLoading: false, error: err instanceof Error ? err.message : 'Registration failed' });
|
||||
throw err;
|
||||
@@ -56,12 +69,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('backspace_token');
|
||||
// Clear all user-scoped state to prevent data leaking between sessions
|
||||
useChatStore.getState().clearAllMessages();
|
||||
useSpaceStore.getState().populateFromReady('', [], [], []);
|
||||
useSocialStore.getState().reset();
|
||||
useVoiceStore.getState().clearAllVoiceUsers();
|
||||
useInstanceStore.getState().reset();
|
||||
resetUserStores();
|
||||
set({ token: null, user: null });
|
||||
},
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ interface VoiceState {
|
||||
setPermissionMutedUser: (spaceId: string, userId: string, muted: boolean) => void;
|
||||
getVoiceUsers: (channelId: string) => string[];
|
||||
clearAllVoiceUsers: () => void;
|
||||
resetSession: () => void;
|
||||
clearVoiceUsersForOrigin: (origin: string) => void;
|
||||
leaveVoice: () => void;
|
||||
handleForceDisconnect: () => void;
|
||||
@@ -365,6 +366,38 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
|
||||
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) => {
|
||||
const { channelOriginMap } = useSpaceStore.getState();
|
||||
set((state) => {
|
||||
|
||||
@@ -260,3 +260,18 @@
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
.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,
|
||||
bio: homeUser.bio || undefined,
|
||||
customStatus: homeUser.customStatus || undefined,
|
||||
status: homeUser.status || undefined,
|
||||
};
|
||||
|
||||
// Sync avatar
|
||||
@@ -66,8 +67,8 @@ export async function syncProfileToRemote(inst: ConnectedInstance): Promise<void
|
||||
|
||||
// ─── Incremental sync (profile update) ──────────────────────────────────────
|
||||
|
||||
/** Sync-eligible text fields (no status, replicatedInstances, homeUserId). */
|
||||
const SYNC_FIELDS = ['displayName', 'avatarColor', 'accentColor', 'bio', 'customStatus'] as const;
|
||||
/** Sync-eligible text fields (no replicatedInstances, homeUserId). */
|
||||
const SYNC_FIELDS = ['displayName', 'avatarColor', 'accentColor', 'bio', 'customStatus', 'status'] as const;
|
||||
|
||||
/**
|
||||
* Push a partial profile update to all connected remote instances.
|
||||
|
||||
Reference in New Issue
Block a user