From f873d334937e7228cc8103b36f2e949f1fb816b2 Mon Sep 17 00:00:00 2001 From: niki <295591807+nikiwastaken@users.noreply.github.com> Date: Wed, 24 Jun 2026 06:30:19 +0000 Subject: [PATCH 1/2] Redesign the user menu tab --- src/app/components/presence/Presence.tsx | 2 +- src/app/components/user-profile/UserHero.tsx | 112 ++- src/app/pages/client/SidebarNav.tsx | 15 +- src/app/pages/client/sidebar/UserMenuTab.tsx | 743 ++++++++++++++++++ .../pages/client/sidebar/UserQuickTools.tsx | 4 +- src/app/pages/client/sidebar/index.ts | 1 - src/app/utils/presence.ts | 26 + 7 files changed, 871 insertions(+), 32 deletions(-) create mode 100644 src/app/pages/client/sidebar/UserMenuTab.tsx create mode 100644 src/app/utils/presence.ts diff --git a/src/app/components/presence/Presence.tsx b/src/app/components/presence/Presence.tsx index 88543b7f6..560d8f86e 100644 --- a/src/app/components/presence/Presence.tsx +++ b/src/app/components/presence/Presence.tsx @@ -5,7 +5,7 @@ import { useId } from 'react'; import { Presence, usePresenceLabel } from '$hooks/useUserPresence'; import * as css from './styles.css'; -const PresenceToColor: Record = { +export const PresenceToColor: Record = { [Presence.Online]: 'Success', [Presence.Unavailable]: 'Warning', [Presence.Offline]: 'Secondary', diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index eaf56ac9d..f32d6728f 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -42,6 +42,7 @@ import * as css from './styles.css'; import { copyToClipboard } from '$utils/dom'; import { useTimeoutToggle } from '$hooks/useTimeoutToggle'; import { CopyIcon, CrossIcon } from '@phosphor-icons/react'; +import { useOpenSettings } from '$features/settings'; type UserHeroProps = { userId: string; @@ -49,8 +50,18 @@ type UserHeroProps = { bannerUrl?: string; presence?: UserPresence; autoplayGifs?: boolean; + showColor?: boolean; + allowEditing?: boolean; }; -export function UserHero({ userId, avatarUrl, bannerUrl, presence, autoplayGifs }: UserHeroProps) { +export function UserHero({ + userId, + avatarUrl, + bannerUrl, + presence, + autoplayGifs, + allowEditing = false, + showColor = true, +}: UserHeroProps) { const [viewAvatar, setViewAvatar] = useState(); const [isFullStatus, setIsFullStatus] = useState(false); @@ -96,9 +107,14 @@ export function UserHero({ userId, avatarUrl, bannerUrl, presence, autoplayGifs ((fetchedBrightness === 'light' || areColorsTooSimilar('#FFFFFF', cardColor)) && '#000000') || undefined; const statusHoverBrightness = fetchedBrightness === 'light' ? 0.94 : 1.08; + const openSettings = useOpenSettings(); return ( - +
)}
- {status && status.length > 0 && ( + {((status && status.length > 0) || allowEditing) && (
setIsFullStatus(!isFullStatus) : undefined} + role={allowEditing ? 'button' : undefined} + onClick={ + allowEditing + ? () => openSettings('account', 'status') + : isExpandable + ? () => setIsFullStatus(!isFullStatus) + : undefined + } className={classNames( css.UserHeroStatusTooltip, isExpandable && css.UserHeroStatusTooltipInteractive )} style={{ maxHeight: isFullStatus ? toRem(105) : toRem(48), - cursor: isExpandable ? 'pointer' : 'default', - transform: 'none', - transition: 'none', + cursor: allowEditing || isExpandable ? 'pointer' : 'default', display: 'flex', + width: 'fit-content', padding: `${toRem(8)} ${toRem(12)}`, backgroundColor: statusSurfaceColor, color: textColor, @@ -195,8 +217,14 @@ export function UserHero({ userId, avatarUrl, bannerUrl, presence, autoplayGifs {isFullStatus ? ( - - {status} + + {status || (allowEditing && "What's on your mind?")} ) : ( @@ -208,9 +236,10 @@ export function UserHero({ userId, avatarUrl, bannerUrl, presence, autoplayGifs WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', + fontStyle: allowEditing && !status ? 'italic' : 'normal', }} > - {status} + {status || (allowEditing && "What's on your mind?")} )} @@ -241,17 +270,29 @@ type UserHeroNameProps = { server?: string; customHeroCards?: boolean; }; -export function UserHeroName({ displayName, userId, server, customHeroCards }: UserHeroNameProps) { - const username = getMxIdLocalPart(userId); - const nick = useNickname(userId); + +type UserHeroNameInnerProps = { + shownName: string; + username?: string; + nick?: string; + server?: string; + color?: string; + font?: string; + customHeroCards?: boolean; +}; + +function UserHeroNameInner({ + shownName, + nick, + username, + server, + color, + font, +}: UserHeroNameInnerProps) { const [copied, setCopied] = useTimeoutToggle(); const [isHovered, setIsHovered] = useState(false); const isSuccess = useRef(false); - // Sable username color and fonts - const { color, font } = useSableCosmetics(userId, useRoom(), customHeroCards); - const shownName = nick ?? displayName ?? username ?? userId; - return ( @@ -296,3 +337,40 @@ export function UserHeroName({ displayName, userId, server, customHeroCards }: U ); } + +export function UserHeroName({ displayName, userId, server, customHeroCards }: UserHeroNameProps) { + const username = getMxIdLocalPart(userId); + const nick = useNickname(userId); + + // Sable username color and fonts + const { color, font } = useSableCosmetics(userId, useRoom(), customHeroCards); + const shownName = nick ?? displayName ?? username ?? userId; + + return ( + + ); +} + +export function GlobalUserHeroName({ displayName, userId, server }: UserHeroNameProps) { + const username = getMxIdLocalPart(userId); + const nick = useNickname(userId); + const profile = useUserProfile(userId); + + const shownName = nick ?? displayName ?? username ?? userId; + + return ( + + ); +} diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx index 99ca73d64..d92ff4690 100644 --- a/src/app/pages/client/SidebarNav.tsx +++ b/src/app/pages/client/SidebarNav.tsx @@ -6,20 +6,13 @@ import { stopPropagation } from '$utils/keyboard'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { Sidebar, SidebarContent, SidebarStack } from '$components/sidebar'; -import { - DirectTab, - DirectDMsList, - HomeTab, - SpaceTabs, - InboxTab, - UnverifiedTab, - AccountSwitcherTab, -} from './sidebar'; +import { DirectTab, DirectDMsList, HomeTab, SpaceTabs, InboxTab, UnverifiedTab } from './sidebar'; import { CreateTab } from './sidebar/CreateTab'; import { SearchTab } from './sidebar/SearchTab'; import { SettingsTab } from './sidebar/SettingsTab'; import { UserQuickTools } from './sidebar/UserQuickTools'; import { useScreenSizeContext, ScreenSize } from '$hooks/useScreenSize'; +import { UserMenuTab } from './sidebar/UserMenuTab'; export function SidebarNav() { const scrollRef = useRef(null); @@ -152,7 +145,7 @@ export function SidebarNav() {
{/*PROBS ADD SETTINGSTAB HERE WHEN ADDING THE STATUSES*/} - +
) : ( @@ -166,7 +159,7 @@ export function SidebarNav() { )} - + )} diff --git a/src/app/pages/client/sidebar/UserMenuTab.tsx b/src/app/pages/client/sidebar/UserMenuTab.tsx new file mode 100644 index 000000000..82af5f0b3 --- /dev/null +++ b/src/app/pages/client/sidebar/UserMenuTab.tsx @@ -0,0 +1,743 @@ +import type { MouseEventHandler } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import type { RectCords } from 'folds'; +import { + Badge, + Box, + Button, + Chip, + Dialog, + Header, + Icon, + Icons, + Line, + Menu, + MenuItem, + PopOut, + Spinner, + Text, + config, + toRem, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; +import { UserAvatar } from '../../../components/user-avatar'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; +import { nameInitials } from '../../../utils/common'; +import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { useOpenSettings } from '../../../features/settings'; +import { useUserProfile } from '../../../hooks/useUserProfile'; +import { Modal500 } from '../../../components/Modal500'; +import { stopPropagation } from '../../../utils/keyboard'; +import { useUserPresence, Presence } from '../../../hooks/useUserPresence'; +import { UserHero, GlobalUserHeroName } from '../../../components/user-profile/UserHero'; +import { AvatarPresence, PresenceBadge, PresenceToColor } from '../../../components/presence'; +import { createLogger } from '$utils/debug'; +import type { Session } from '$state/sessions'; +import { activeSessionIdAtom, backgroundUnreadCountsAtom, sessionsAtom } from '$state/sessions'; +import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; +import { Check, chipIcon, Plus } from '$components/icons/phosphor'; +import { useSessionProfiles } from '$hooks/useSessionProfiles'; +import { useClientConfig } from '$hooks/useClientConfig'; +import { getHomePath, getLoginPath, withSearchParam } from '$pages/pathUtils'; +import { initClient, logoutClient, stopClient } from '$client/initMatrix'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { useNavigate } from 'react-router-dom'; +import { useFocusWithin, useHover } from 'react-aria'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { setUserPresence } from '$utils/presence'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; + +const log = createLogger('AccountSwitcherTab'); + +function AccountRow({ + session, + isActive, + displayName, + avatarUrl, + isBusy, + unread, + onSwitch, + onSignOut, +}: { + session: Session; + isActive: boolean; + displayName?: string; + avatarUrl?: string; + isBusy?: boolean; + unread?: { total: number; highlight: number }; + onSwitch: (session: Session) => void; + onSignOut: (session: Session) => void; +}) { + const localPart = getMxIdLocalPart(session.userId) ?? session.userId; + const server = session.userId.split(':')[1] ?? session.baseUrl; + const label = displayName ?? localPart; + + return ( + + {nameInitials(label)}} + /> + + } + after={ + + {!isActive && unread && unread.total > 0 && ( + + 0} count={unread.total} /> + + )} + {isActive && chipIcon(Check, { style: { color: 'var(--mx-c-success)' } })} + {isBusy ? ( + + ) : ( + { + e.stopPropagation(); + onSignOut(session); + }} + > + Sign out + + )} + + } + onClick={() => !isActive && !isBusy && onSwitch(session)} + > + + + {label} + + + {isActive ? session.userId : server} + + + + ); +} + +export function AccountMenuOption() { + const mx = useMatrixClient(); + const navigate = useNavigate(); + const sessions = useAtomValue(sessionsAtom); + const [activeSessionId, setActiveSessionId] = useAtom(activeSessionIdAtom); + const setSessions = useSetAtom(sessionsAtom); + const useAuthentication = useMediaAuthentication(); + const backgroundUnreads = useAtomValue(backgroundUnreadCountsAtom); + const setBackgroundUnreads = useSetAtom(backgroundUnreadCountsAtom); + const screenSize = useScreenSizeContext(); + const isMobile = screenSize === ScreenSize.Mobile; + + const [isOpen, setIsOpen] = useState(false); + const { hoverProps } = useHover({ + onHoverChange: (h) => { + if (!isMobile) setIsOpen(h); + }, + }); + const { focusWithinProps } = useFocusWithin({ + onFocusWithinChange: (f) => { + if (!isMobile) setIsOpen(f); + }, + }); + + const [busyUserIds, setBusyUserIds] = useState(new Set()); + const [confirmSignOutSession, setConfirmSignOutSession] = useState( + undefined + ); + + const activeSession = sessions.find((s) => s.userId === activeSessionId) ?? sessions[0]; + + const myUserId = mx.getUserId() ?? ''; + const activeProfile = useUserProfile(myUserId); + const activeAvatarUrl = activeProfile.avatarUrl + ? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined) + : undefined; + const activeDisplayName = activeProfile.displayName; + + const sessionProfiles = useSessionProfiles(sessions); + + const { disableAccountSwitcher } = useClientConfig(); + + const handleSwitch = useCallback( + (session: Session) => { + log.log('switching to account', session.userId); + navigate(getHomePath(), { replace: true }); + setActiveSessionId(session.userId); + // Clear the unread badge for the account we're now switching into. + setBackgroundUnreads((prev) => { + const next = { ...prev }; + delete next[session.userId]; + return next; + }); + }, + [navigate, setActiveSessionId, setBackgroundUnreads] + ); + + const handleSignOut = useCallback( + async (session: Session) => { + log.log('signing out', session.userId); + setBusyUserIds((prev) => new Set(prev).add(session.userId)); + try { + if (session.userId === mx.getUserId()) { + await logoutClient(mx, session); + setSessions({ type: 'DELETE', session }); + setActiveSessionId( + sessions.find((s) => s.userId !== session.userId)?.userId ?? undefined + ); + window.location.reload(); + } else { + try { + const tempMx = await initClient(session); + await logoutClient(tempMx, session); + } catch (err) { + log.error('failed to logout background session, IndexedDB may remain', err); + } + setSessions({ type: 'DELETE', session }); + if (activeSessionId === session.userId) { + setActiveSessionId( + sessions.find((s) => s.userId !== session.userId)?.userId ?? undefined + ); + } + } + } catch (err) { + log.error('Logout failed', err); + } finally { + setBusyUserIds((prev) => { + const next = new Set(prev); + next.delete(session.userId); + return next; + }); + } + }, + [mx, sessions, activeSessionId, setSessions, setActiveSessionId] + ); + + const handleAddAccount = () => { + const url = withSearchParam(getLoginPath(), { addAccount: '1' }); + stopClient(mx); + setTimeout(() => window.location.assign(url), 100); + }; + + if (!activeSession || disableAccountSwitcher) return null; + + return ( + <> + + + } + after={ + + } + style={{ + position: 'relative', + }} + onClick={() => isMobile && setIsOpen(!isOpen)} + {...hoverProps} + {...focusWithinProps} + > + + Switch account + + + {isOpen && ( +
+ + + {sessions.map((session) => { + const isActive = session.userId === (activeSessionId ?? sessions[0]?.userId); + let rowDisplayName: string | undefined; + let rowAvatarUrl: string | undefined; + if (isActive) { + rowDisplayName = activeDisplayName; + rowAvatarUrl = activeAvatarUrl; + } else { + const prof = sessionProfiles[session.userId]; + rowDisplayName = prof?.displayName; + rowAvatarUrl = prof?.avatarHttpUrl; + } + return ( + { + setConfirmSignOutSession(pendingSession); + }} + /> + ); + })} + + Add Account + + + +
+ )} + {confirmSignOutSession && ( + setConfirmSignOutSession(undefined)}> + +
+ + Sign out + +
+ + + Are you sure you want to sign out of {confirmSignOutSession.userId}? + + + + + + +
+
+ )} + + ); +} + +const PresenceOptions: Array<{ value: Presence; label: string }> = [ + { value: Presence.Online, label: 'Online' }, + { value: Presence.Unavailable, label: 'Busy' }, + { value: Presence.Offline, label: 'Offline' }, +]; + +export function PresenceMenuOption() { + const mx = useMatrixClient(); + const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); + + const userId = mx.getUserId() ?? ''; + const presence = useUserPresence(userId); + const screenSize = useScreenSizeContext(); + const isMobile = screenSize === ScreenSize.Mobile; + const currentPresence = presence?.presence ?? Presence.Online; + + const [isOpen, setIsOpen] = useState(false); + const { hoverProps } = useHover({ + onHoverChange: (h) => { + if (!isMobile) setIsOpen(h); + }, + }); + const { focusWithinProps } = useFocusWithin({ + onFocusWithinChange: (f) => { + if (!isMobile) setIsOpen(f); + }, + }); + + const [savingStatus, setSavingStatus] = useState(false); + const [submittedState, setSubmittedState] = useState(null); + + useEffect(() => { + if (!submittedState) return; + if (currentPresence === submittedState) { + setSubmittedState(null); + setSavingStatus(false); + } + }, [currentPresence, submittedState]); + + const handleSelectPresence = async (presenceValue: Presence) => { + if (savingStatus) return; + setSavingStatus(true); + setSubmittedState(presenceValue); + try { + await setUserPresence(mx, presenceValue); + } catch { + setSubmittedState(null); + setSavingStatus(false); + } + }; + + if (!sendPresence) return null; + + return ( + <> + + {savingStatus ? ( + + ) : ( + + )} +
+ } + after={ + + } + style={{ + position: 'relative', + }} + onClick={() => isMobile && setIsOpen(!isOpen)} + {...hoverProps} + {...focusWithinProps} + > + + {PresenceOptions.find((v) => v.value == currentPresence)?.label} + + + {isOpen && ( +
+ + + {PresenceOptions.map((option) => ( + { + handleSelectPresence(option.value).catch(() => undefined); + }} + after={} + > + + {option.label} + + + ))} + + { + // handleSelectPresence(option.value).catch(() => undefined); + }} + after={} + > + + Automatic + + + + +
+ )} + + ); +} + +export function UserMenuTab({ isBottom }: { isBottom?: boolean }) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + + const userId = mx.getUserId() ?? ''; + const profile = useUserProfile(userId); + const presence = useUserPresence(userId); + const currentStatus = presence?.status ?? ''; + const currentPresence = presence?.presence ?? Presence.Online; + + const [menuAnchor, setMenuAnchor] = useState(); + const openSettings = useOpenSettings(); + + const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; + const avatarUrl = profile.avatarUrl + ? (mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined) + : undefined; + const heroAvatarUrl = profile.avatarUrl + ? (mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 160, 160, 'crop') ?? undefined) + : undefined; + + const parsedBanner = + typeof profile.bannerUrl === 'string' ? profile.bannerUrl.replace(/^"|"$/g, '') : undefined; + const heroBannerUrl = parsedBanner + ? (mxcUrlToHttp(mx, parsedBanner, useAuthentication, 640, 192, 'scale') ?? undefined) + : undefined; + + const handleToggle: MouseEventHandler = (evt) => { + const cords = evt.currentTarget.getBoundingClientRect(); + setMenuAnchor((cur) => (cur ? undefined : cords)); + }; + + const handleCloseMenu = () => setMenuAnchor(undefined); + + return ( + + + {(triggerRef) => ( + } + > + + {nameInitials(displayName)}} + /> + + + )} + + + evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + + + + + + + + + + + openSettings('account')} + size="300" + radii="300" + before={} + > + + Edit Profile + + + + {/* + + + + + ) + } + /> + + {showSaveStatus && ( + + )} + */} + {/* {PresenceOptions.map((option) => ( + { + handleSelectPresence(option.value).catch(() => undefined); + }} + after={} + > + + {option.label} + + + ))} */} + + + + + + } + onClick={() => openSettings()} + > + + Settings + + + + + + + } + /> + + ); +} diff --git a/src/app/pages/client/sidebar/UserQuickTools.tsx b/src/app/pages/client/sidebar/UserQuickTools.tsx index 783b0db2a..a6f96dcfc 100644 --- a/src/app/pages/client/sidebar/UserQuickTools.tsx +++ b/src/app/pages/client/sidebar/UserQuickTools.tsx @@ -1,5 +1,4 @@ import { Box, config, toRem } from 'folds'; -import { AccountSwitcherTab } from './AccountSwitcherTab'; import { InboxTab } from './InboxTab'; import { SearchTab } from './SearchTab'; import { SettingsTab } from './SettingsTab'; @@ -7,6 +6,7 @@ import { useAtom } from 'jotai'; import { isResizingSidebarAtom } from '$state/isResizingSidebar'; import * as css from './UserQuickTools.css'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { UserMenuTab } from './UserMenuTab'; export function UserQuickTools({ width, @@ -38,7 +38,7 @@ export function UserQuickTools({ paddingRight: config.space.S300, }} > - + = { + [Presence.Online]: SetPresence.Online, + [Presence.Unavailable]: SetPresence.Unavailable, + [Presence.Offline]: SetPresence.Offline, +}; + +export const presenceToSetPresence = (presence: Presence): SetPresence => + PRESENCE_TO_SET_PRESENCE[presence]; + +export const setUserPresence = async ( + mx: MatrixClient, + presence: Presence, + statusMsg?: string +): Promise => { + Promise.all([ + mx.setSyncPresence(presenceToSetPresence(presence)), + mx.setPresence({ + presence, + status_msg: statusMsg, + }), + ]); +}; From 68554474d7290f5a407c79721e1021b786ec8998 Mon Sep 17 00:00:00 2001 From: niki <295591807+nikiwastaken@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:51:53 +0300 Subject: [PATCH 2/2] Add changeset Signed-off-by: niki <295591807+nikiwastaken@users.noreply.github.com> --- .changeset/feat_redesign_user_menu.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/feat_redesign_user_menu.md diff --git a/.changeset/feat_redesign_user_menu.md b/.changeset/feat_redesign_user_menu.md new file mode 100644 index 000000000..2166a30a8 --- /dev/null +++ b/.changeset/feat_redesign_user_menu.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Redesign the user menu tab