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:
Jannis Braun
2026-03-11 03:43:20 +01:00
parent 10be7a44f8
commit c8e2945c07
8 changed files with 364 additions and 84 deletions
+17 -9
View File
@@ -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 });
},
+33
View File
@@ -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) => {