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 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..da6f57fab 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -13,6 +13,7 @@ import { Tooltip, toRem, Chip, + config, } from 'folds'; import classNames from 'classnames'; import FocusTrap from 'focus-trap-react'; @@ -42,6 +43,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 +51,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 +108,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 +218,14 @@ export function UserHero({ userId, avatarUrl, bannerUrl, presence, autoplayGifs {isFullStatus ? ( - - {status} + + {status || (allowEditing && "What's on your mind?")} ) : ( @@ -208,9 +237,11 @@ export function UserHero({ userId, avatarUrl, bannerUrl, presence, autoplayGifs WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', + fontStyle: allowEditing && !status ? 'italic' : 'normal', + opacity: allowEditing && !status ? config.opacity.Placeholder : 1, }} > - {status} + {status || (allowEditing && "What's on your mind?")} )} @@ -241,17 +272,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 +339,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/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx deleted file mode 100644 index bb1ff8b29..000000000 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ /dev/null @@ -1,423 +0,0 @@ -import type { MouseEvent, MouseEventHandler } from 'react'; -import { useCallback, useState } from 'react'; -import type { RectCords } from 'folds'; -import { - Box, - Button, - Dialog, - Header, - Menu, - MenuItem, - PopOut, - Text, - config, - toRem, - Chip, - Spinner, - Overlay, - OverlayBackdrop, - OverlayCenter, - Line, -} from 'folds'; -import FocusTrap from 'focus-trap-react'; -import { useAtom, useAtomValue, useSetAtom } from 'jotai'; -import { useNavigate } from 'react-router-dom'; -import type { Session } from '$state/sessions'; -import { sessionsAtom, activeSessionIdAtom, backgroundUnreadCountsAtom } from '$state/sessions'; -import { - SidebarItemTooltip, - SidebarAvatar, - SidebarUnreadBadge, - SidebarItem, -} from '$components/sidebar'; -import { UserAvatar } from '$components/user-avatar'; -import { nameInitials } from '$utils/common'; -import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix'; -import { stopPropagation } from '$utils/keyboard'; -import { getHomePath, getLoginPath, withSearchParam } from '$pages/pathUtils'; -import { logoutClient, initClient, stopClient } from '$client/initMatrix'; -import { useMatrixClient } from '$hooks/useMatrixClient'; -import { useUserProfile } from '$hooks/useUserProfile'; -import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; -import { useSessionProfiles } from '$hooks/useSessionProfiles'; -import { useOpenSettings } from '$features/settings'; -import { createLogger } from '$utils/debug'; -import { useClientConfig } from '$hooks/useClientConfig'; -import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; -import { Check, chipIcon, GearSix, menuIcon, Plus } from '$components/icons/phosphor'; -import { useSetting } from '$state/hooks/settings'; -import { settingsAtom } from '$state/settings'; -import { usePathWithOrigin } from '$hooks/usePathWithOrigin'; - -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 AccountSwitcherTab({ isBottom }: { isBottom?: boolean }) { - 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 openSettings = useOpenSettings(); - const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar'); - - // Total unread count across all background sessions (for the sidebar badge). - const totalBackgroundUnread = Object.entries(backgroundUnreads) - .filter(([uid]) => uid !== (activeSessionId ?? sessions[0]?.userId)) - .reduce((acc, [, u]) => acc + u.total, 0); - const totalBackgroundHighlight = Object.entries(backgroundUnreads) - .filter(([uid]) => uid !== (activeSessionId ?? sessions[0]?.userId)) - .reduce((acc, [, u]) => acc + u.highlight, 0); - const anyBackgroundHighlight = totalBackgroundHighlight > 0; - - const [menuAnchor, setMenuAnchor] = useState(); - 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 loginUrl = usePathWithOrigin(getLoginPath()); - - const handleToggle: MouseEventHandler = (evt) => { - if (disableAccountSwitcher) { - openSettings(); - return; - } - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((cur) => (cur ? undefined : cords)); - }; - - const handleSwitch = useCallback( - (session: Session) => { - log.log('switching to account', session.userId); - setMenuAnchor(undefined); - 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); - setMenuAnchor(undefined); - 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(loginUrl, { addAccount: '1' }); - setMenuAnchor(undefined); - stopClient(mx); - setTimeout(() => window.location.assign(url), 100); - }; - - const handleOpenSettings = () => { - setMenuAnchor(undefined); - openSettings(); - }; - - const activeLocalPart = - getMxIdLocalPart(activeSession?.userId ?? '') ?? activeSession?.userId ?? ''; - const label = activeDisplayName ?? activeLocalPart; - - if (!activeSession) return null; - - return ( - - - {(triggerRef) => ( - 1} - > - {nameInitials(label)}} - /> - - )} - - {(totalBackgroundUnread > 0 || anyBackgroundHighlight) && ( - - )} - - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - - - - Accounts - - {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 ( - { - setMenuAnchor(undefined); - setConfirmSignOutSession(pendingSession); - }} - /> - ); - })} - - Add Account - - {/*This will defo need to be reverted w the new statuses, w the right changes in the SidebarNav to make the cog a permanent fixture but democracy wants old style*/} - {oldSidebar && ( - <> - - - Settings - - - )} - - - - } - /> - - {confirmSignOutSession && ( - }> - - document.body, - clickOutsideDeactivates: true, - onDeactivate: () => setConfirmSignOutSession(undefined), - escapeDeactivates: stopPropagation, - }} - > - -
- - Sign out - -
- - - Are you sure you want to sign out of {confirmSignOutSession.userId}? - - - - - - -
-
-
-
- )} -
- ); -} diff --git a/src/app/pages/client/sidebar/UserMenuTab.tsx b/src/app/pages/client/sidebar/UserMenuTab.tsx new file mode 100644 index 000000000..efbb30283 --- /dev/null +++ b/src/app/pages/client/sidebar/UserMenuTab.tsx @@ -0,0 +1,655 @@ +import type { MouseEventHandler } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import type { RectCords } from 'folds'; +import { + 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} + + + ))} + + +
+ )} + + ); +} + +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 + + + + + + + + + + + + } + 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, + }), + ]); +};