Files
backspace/packages/web/src/stores/uiStore.ts
T
cnrd 8456b8f976 fix(web): stop the profile card re-anchoring to its own avatar (#39)
Avatar opened the profile popout whenever it received a user prop. Since user is how every avatar gets its gradient, colour and status dot, all 22 call sites became profile triggers by accident — including the picture inside the profile card itself, which re-anchored the card to that picture on every click and walked it across the screen (120px right, 36px down, until it pinned at the viewport clamp).

Avatar is now presentational. A new ProfileAvatar carries the open-the-profile behaviour at the five call sites that actually want it. The card's own picture escalates to the full profile modal instead of reopening the card.

The card also places itself off its measured size via the shared computeFloatingPosition engine, replacing six call sites that each hand-computed coordinates against a guessed 460px card height.

Closes #37
2026-08-25 15:51:04 +02:00

202 lines
7.1 KiB
TypeScript

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { User } from '@backspace/shared';
import type { AnchorRect, Placement } from '../hooks/useFloatingPosition';
type ModalType =
| 'createSpace'
| 'joinSpace'
| 'createChannel'
| 'createCategory'
| 'invite'
| 'userSettings'
| 'spaceSettings'
| 'channelSettings'
| 'categorySettings'
| 'imagePreview'
| 'newDm'
| 'addDmMember'
| 'groupDmSettings'
| 'userProfile'
| null;
interface MobileStackEntry {
screen: string;
params?: Record<string, string>;
}
interface Toast {
id: string;
message: string;
type: 'info' | 'warning' | 'success';
}
interface UIState {
sidebarOpen: boolean;
memberListOpen: boolean;
activeModal: ModalType;
modalData: Record<string, unknown>;
isMobile: boolean;
showDms: boolean;
imagePreviewUrl: string | null;
userProfilePopout: {
user: User | null;
/** Rect of the element the card was opened from. The card places itself off
* this rect once it knows its own measured size — callers never compute
* coordinates, so no surface can drift by re-anchoring to itself. */
anchor: AnchorRect | null;
placement: Placement;
};
toasts: Toast[];
toggleSidebar: () => void;
toggleMemberList: () => void;
openModal: (modal: ModalType, data?: Record<string, unknown>) => void;
closeModal: () => void;
setIsMobile: (isMobile: boolean) => void;
setShowDms: (show: boolean) => void;
openImagePreview: (url: string) => void;
closeImagePreview: () => void;
openUserProfile: (user: User, anchor: AnchorRect, placement?: Placement) => void;
closeUserProfile: () => void;
addToast: (message: string, type?: 'info' | 'warning' | 'success', duration?: number) => void;
removeToast: (id: string) => void;
lastChannelPerSpace: Record<string, string>;
setLastChannel: (spaceId: string, channelId: string) => void;
voiceChatOpen: boolean;
voiceFullscreen: boolean;
pipCollapsed: boolean;
toggleVoiceChat: () => void;
toggleVoiceFullscreen: () => void;
setVoiceFullscreen: (fullscreen: boolean) => void;
setPipCollapsed: (collapsed: boolean) => void;
floatingPanelHeight: number;
setFloatingPanelHeight: (height: number) => void;
// Mobile navigation
mobileScreen: 'spaces' | 'dms' | 'you';
mobileStack: MobileStackEntry[];
setMobileTab: (tab: 'spaces' | 'dms' | 'you') => void;
pushMobileScreen: (screen: string, params?: Record<string, string>) => void;
popMobileScreen: () => void;
// Federation approval-count badge (surfaced by MobileInstancePanel; kept fresh
// by the FederationPanel via `onApprovalCountChange` whenever an admin
// approves/denies a request from inside the panel)
federationApprovalCount: number;
setFederationApprovalCount: (count: number) => void;
}
export const useUIStore = create<UIState>()(
persist(
(set, get) => ({
sidebarOpen: true,
memberListOpen: true,
activeModal: null,
modalData: {},
isMobile: false,
showDms: false,
imagePreviewUrl: null,
userProfilePopout: {
user: null,
anchor: null,
placement: 'right',
},
toasts: [],
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
openModal: (modal, data = {}) => set({ activeModal: modal, modalData: data }),
closeModal: () => set({ activeModal: null, modalData: {} }),
setIsMobile: (isMobile) => {
const prev = get().isMobile;
if (prev === isMobile) return;
if (isMobile) {
set({ isMobile, sidebarOpen: false, memberListOpen: false });
} else {
// On desktop transition: restore sidebar, clear mobile nav state
// memberListOpen is NOT reset here — it keeps its persisted/toggled value
set({ isMobile, sidebarOpen: true, mobileScreen: 'spaces' as const, mobileStack: [] });
}
},
setShowDms: (show) => set({ showDms: show }),
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),
openUserProfile: (user, anchor, placement = 'right') => {
if (get().isMobile) {
// On mobile, push a full-screen user profile instead of a positioned popout
set((state) => ({
mobileStack: [...state.mobileStack, { screen: 'user-profile', params: { userId: user.id } }],
}));
history.pushState({ mobileScreen: 'user-profile' }, '');
} else {
set({ userProfilePopout: { user, anchor, placement } });
}
},
closeUserProfile: () => set({
userProfilePopout: { user: null, anchor: null, placement: 'right' }
}),
addToast: (message, type = 'info', duration = 5000) => {
const id = Date.now().toString(36) + Math.random().toString(36).slice(2);
set((state) => ({ toasts: [...state.toasts, { id, message, type }] }));
setTimeout(() => {
set((state) => ({ toasts: state.toasts.filter(t => t.id !== id) }));
}, duration);
},
removeToast: (id) => set((state) => ({ toasts: state.toasts.filter(t => t.id !== id) })),
lastChannelPerSpace: {},
setLastChannel: (spaceId, channelId) => set((state) => ({
lastChannelPerSpace: { ...state.lastChannelPerSpace, [spaceId]: channelId },
})),
voiceChatOpen: false,
voiceFullscreen: false,
pipCollapsed: false,
toggleVoiceChat: () => set((state) => ({ voiceChatOpen: !state.voiceChatOpen })),
toggleVoiceFullscreen: () => set((state) => ({ voiceFullscreen: !state.voiceFullscreen })),
setVoiceFullscreen: (fullscreen) => set({ voiceFullscreen: fullscreen }),
setPipCollapsed: (collapsed) => set({ pipCollapsed: collapsed }),
floatingPanelHeight: 140,
setFloatingPanelHeight: (height) => set({ floatingPanelHeight: height }),
mobileScreen: 'spaces',
mobileStack: [],
setMobileTab: (tab) => set({ mobileScreen: tab, mobileStack: [] }),
pushMobileScreen: (screen, params) => {
set((state) => ({
mobileStack: [...state.mobileStack, { screen, params }],
}));
// Sync with browser history so hardware back button works
history.pushState({ mobileScreen: screen }, '');
},
popMobileScreen: () => {
const state = get();
if (state.mobileStack.length === 0) return;
set({ mobileStack: state.mobileStack.slice(0, -1) });
// Note: do NOT call history.back() here if triggered by popstate event.
// The MobileShell popstate handler manages this — see Task 5.
},
federationApprovalCount: 0,
setFederationApprovalCount: (count) => set({ federationApprovalCount: count }),
}),
{
name: 'backspace-ui-settings',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
memberListOpen: state.memberListOpen,
lastChannelPerSpace: state.lastChannelPerSpace,
}),
}
)
);