diff --git a/.changeset/feat-change-unverified-view.md b/.changeset/feat-change-unverified-view.md new file mode 100644 index 0000000000..c089ceeafe --- /dev/null +++ b/.changeset/feat-change-unverified-view.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Change unverified device flow. diff --git a/.changeset/feat_rework_mobile_view.md b/.changeset/feat_rework_mobile_view.md new file mode 100644 index 0000000000..8ebeeb4676 --- /dev/null +++ b/.changeset/feat_rework_mobile_view.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Modify large parts of the mobile view of Sable! diff --git a/src/app/components/UserQuickToolsProvider.tsx b/src/app/components/UserQuickToolsProvider.tsx new file mode 100644 index 0000000000..23cf8c71de --- /dev/null +++ b/src/app/components/UserQuickToolsProvider.tsx @@ -0,0 +1,9 @@ +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { UserQuickTools } from '$pages/client/sidebar/UserQuickTools'; + +export function UserQuickToolsProvider() { + const screenSize = useScreenSizeContext(); + const compact = screenSize === ScreenSize.Mobile; + if (!compact) return null; + return ; +} diff --git a/src/app/components/message/modals/GlobalModalManager.tsx b/src/app/components/message/modals/GlobalModalManager.tsx index be48d49be0..509d0c8fd0 100644 --- a/src/app/components/message/modals/GlobalModalManager.tsx +++ b/src/app/components/message/modals/GlobalModalManager.tsx @@ -34,7 +34,11 @@ export function GlobalModalManager() { focusTrapOptions={{ initialFocus: false, onDeactivate: close, - clickOutsideDeactivates: true, + allowOutsideClick: (e: { preventDefault?: () => void }) => { + if (e.preventDefault) e.preventDefault(); + close(); + return false; + }, escapeDeactivates: stopPropagation, }} > diff --git a/src/app/components/message/modals/MessageForward.tsx b/src/app/components/message/modals/MessageForward.tsx index 946f764a0b..aef10a95cb 100644 --- a/src/app/components/message/modals/MessageForward.tsx +++ b/src/app/components/message/modals/MessageForward.tsx @@ -17,7 +17,7 @@ import * as Sentry from '@sentry/react'; import { isRoomPrivate } from '$utils/roomVisibility'; import { canForwardEvent } from '$utils/room'; import * as prefix from '$unstable/prefixes'; -import { RoomSearchModal } from '$features/search'; +import { SearchWrapper } from '$features/navigate'; const debugLog = createDebugLogger('MessageForward'); // Message forwarding component @@ -270,7 +270,7 @@ export function MessageForwardInternal({ if (!forwardable) return null; - return ; + return ; } type MessageForwardItemProps = { diff --git a/src/app/components/message/modals/Options.tsx b/src/app/components/message/modals/Options.tsx index 7ad72bfaec..da37d35bd9 100644 --- a/src/app/components/message/modals/Options.tsx +++ b/src/app/components/message/modals/Options.tsx @@ -599,7 +599,7 @@ export function OptionMenu({ onReactionToggle(mEvent.getId() ?? '', key, shortcode); onTotalClose(); }} - count={isModal ? 6 : 4} + count={isModal ? 8 : 4} /> )} @@ -615,6 +615,23 @@ export function OptionMenu({ )} + + {canEditEvent(mx, mEvent) && onEditId && isModal && ( + { + onEditId(mEvent.getId()); + onTotalClose(); + }} + > + + Edit Message + + + )} {/* Only show "Add to User Sticker Pack" if the sticker isn't already in the default pack and isn't encrypted */} {isStickerMessage && mEvent.getContent().url && @@ -670,7 +687,7 @@ export function OptionMenu({ )} - {canEditEvent(mx, mEvent) && onEditId && ( + {canEditEvent(mx, mEvent) && onEditId && !isModal && ( 0); + const [dismissCount, setDismissCount] = useState(unverifiedDeviceCount); + + const [isDismissedNotice, setIsDismissedNotice] = useState( + getLocalStorageItem('dismissNotice', false) + ); + useEffect(() => { + if ( + unverifiedDeviceCount && + dismissCount && + unverifiedDeviceCount > 0 && + dismissCount < unverifiedDeviceCount + ) { + setLocalStorageItem('dismissNotice', false); + setIsDismissedNotice(false); + } + setDismissCount(unverifiedDeviceCount); + }, [unverifiedDeviceCount, dismissCount]); + + if (!hasUnverified || isDismissedNotice) return null; + + const handleVerify = () => { + openSettings('devices'); + }; + const handleDismiss = () => { + setLocalStorageItem('dismissNotice', true); + setIsDismissedNotice(true); + }; + + return ( +
+
+
+ {sizedIcon(ShieldWarningIcon, '400')} +
+ + {isUnverified + ? 'This Device is Unverified!' + : `You have ${unverifiedDeviceCount === 1 ? 'an' : ''} Unverified Device${unverifiedDeviceCount !== 1 ? 's' : ''}!`} + + + An unverified device is unable to read old encrypted messages and might result in + other people being unable to read your messages or to send you messages! + +
+
+ + + + +
+
+ ); +} diff --git a/src/app/components/unverified-notice/index.ts b/src/app/components/unverified-notice/index.ts new file mode 100644 index 0000000000..a7f06aeea3 --- /dev/null +++ b/src/app/components/unverified-notice/index.ts @@ -0,0 +1 @@ +export * from './UnverifiedNoticeBanner'; diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index a6b0340340..3e14b49d9a 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -215,7 +215,11 @@ export function UserHero({ } as CSSProperties), }} > - + {isFullStatus ? ( {status || (allowEditing && "What's on your mind?")} @@ -240,12 +245,13 @@ export function UserHero({ fontStyle: allowEditing && !status ? 'italic' : 'normal', opacity: allowEditing && !status ? config.opacity.Placeholder : 1, }} + truncate={allowEditing} > {status || (allowEditing && "What's on your mind?")} )} - {isExpandable && ( + {isExpandable && !allowEditing && ( { + if (prefix === '#') return SearchRoomType.Rooms; + if (prefix === '*') return SearchRoomType.Spaces; + if (prefix === '@') return SearchRoomType.Directs; + return undefined; +}; + +const useTopActiveRooms = ( + searchRoomType: SearchRoomType | undefined, + rooms: string[], + directs: string[], + spaces: string[] +) => { + const mx = useMatrixClient(); + + return useMemo(() => { + if (searchRoomType === SearchRoomType.Spaces) { + return spaces; + } + if (searchRoomType === SearchRoomType.Directs) { + return [...directs].toSorted(factoryRoomIdByActivity(mx)).slice(0, 20); + } + if (searchRoomType === SearchRoomType.Rooms) { + return [...rooms].toSorted(factoryRoomIdByActivity(mx)).slice(0, 20); + } + return [...rooms, ...directs].toSorted(factoryRoomIdByActivity(mx)).slice(0, 20); + }, [mx, rooms, directs, spaces, searchRoomType]); +}; + +const getDmUserId = ( + roomId: string, + getRoom: (roomId: string) => Room | undefined, + myUserId: string +): string | undefined => { + const room = getRoom(roomId); + const targetUserId = room && guessDmRoomUserId(room, myUserId); + return targetUserId; +}; + +const useSearchTargetRooms = ( + searchRoomType: SearchRoomType | undefined, + rooms: string[], + directs: string[], + spaces: string[] +) => + useMemo(() => { + if (searchRoomType === undefined) { + return [...rooms, ...directs, ...spaces]; + } + if (searchRoomType === SearchRoomType.Rooms) return rooms; + if (searchRoomType === SearchRoomType.Spaces) return spaces; + if (searchRoomType === SearchRoomType.Directs) return directs; + + return []; + }, [rooms, spaces, directs, searchRoomType]); + +const SEARCH_OPTIONS: UseAsyncSearchOptions = { + matchOptions: { + contain: true, + }, + normalizeOptions: { + ignoreWhitespace: false, + }, +}; + +export type RoomSearchPickRoomConfig = { + title: string; + eligibleRoomIds: readonly string[]; + onPickRoom: (roomId: string) => void; + errorMessage?: string | null; + busy?: boolean; +}; + +export type RoomSearchModalProps = { + requestClose?: () => void; + pickRoom?: RoomSearchPickRoomConfig; + isMobile?: boolean; +}; + +export function RoomSearchModal({ requestClose, pickRoom, isMobile }: RoomSearchModalProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const scrollRef = useRef(null); + const { navigateRoom, navigateSpace } = useRoomNavigate(); + const roomToUnread = useAtomValue(roomToUnreadAtom); + + const [searchRoomType, setSearchRoomType] = useState(); + + const allRoomsSet = useAllJoinedRoomsSet(); + const getRoom = useGetRoom(allRoomsSet); + + const roomToParents = useAtomValue(roomToParentsAtom); + const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents); + const mDirects = useAtomValue(mDirectAtom); + const rooms = useRooms(mx, allRoomsAtom, mDirects); + const spaces = useSpaces(mx, allRoomsAtom); + const directs = useDirects(mx, allRoomsAtom, mDirects); + + const eligibleSet = useMemo( + () => (pickRoom ? new Set(pickRoom.eligibleRoomIds) : null), + [pickRoom] + ); + + const rawTopActiveRooms = useTopActiveRooms(searchRoomType, rooms, directs, spaces); + const rawTargetRooms = useSearchTargetRooms(searchRoomType, rooms, directs, spaces); + + const topActiveRooms = useMemo(() => { + if (!eligibleSet) return rawTopActiveRooms; + return rawTopActiveRooms.filter((id) => eligibleSet.has(id)); + }, [eligibleSet, rawTopActiveRooms]); + + const targetRooms = useMemo(() => { + if (!eligibleSet) return rawTargetRooms; + return rawTargetRooms.filter((id) => eligibleSet.has(id)); + }, [eligibleSet, rawTargetRooms]); + + const getTargetStr: SearchItemStrGetter = useCallback( + (roomId: string) => { + const roomName = getRoom(roomId)?.name ?? roomId; + if (mDirects.has(roomId)) { + const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId()); + const targetUsername = targetUserId && getMxIdLocalPart(targetUserId); + if (targetUsername) return [roomName, targetUsername]; + } + return roomName; + }, + [getRoom, mDirects, mx] + ); + + const [result, search, resetSearch] = useAsyncSearch(targetRooms, getTargetStr, SEARCH_OPTIONS); + const selectedSpaceId = useSelectedSpace(); + + const roomsToRender = useMemo(() => { + const items = result ? result.items : topActiveRooms; + if (!selectedSpaceId) return items; + + return [...items].toSorted((a, b) => { + const aInSpace = getAllParents(roomToParents, a)?.has(selectedSpaceId) ? 1 : 0; + const bInSpace = getAllParents(roomToParents, b)?.has(selectedSpaceId) ? 1 : 0; + return bInSpace - aInSpace; + }); + }, [result, topActiveRooms, selectedSpaceId, roomToParents]); + + const listFocus = useListFocusIndex(roomsToRender.length, 0); + + const queryHighlighRegex = result?.query + ? makeHighlightRegex(result.query.split(' ')) + : undefined; + + const handleActivateRoom = (roomId: string, isSpace: boolean) => { + if (pickRoom) { + if (pickRoom.busy) return; + pickRoom.onPickRoom(roomId); + return; + } + if (isSpace) navigateSpace(roomId); + else navigateRoom(roomId); + requestClose?.(); + }; + + const handleInputChange: ChangeEventHandler = (evt) => { + listFocus.reset(); + + const target = evt.currentTarget; + let value = target.value.trim(); + const prefix = value.match(/^[#@*]/)?.[0]; + const searchType = typeof prefix === 'string' && getSearchPrefixToRoomType(prefix); + if (searchType) { + value = value.slice(1); + setSearchRoomType(searchType); + } else { + setSearchRoomType(undefined); + } + + if (value === '') { + resetSearch(); + return; + } + search(value); + }; + + const handleInputKeyDown: KeyboardEventHandler = (evt) => { + const roomId = roomsToRender[listFocus.index]; + if (isKeyHotkey('enter', evt) && roomId) { + handleActivateRoom(roomId, spaces.includes(roomId)); + return; + } + if (isKeyHotkey('arrowdown', evt)) { + evt.preventDefault(); + listFocus.next(); + return; + } + if (isKeyHotkey('arrowup', evt)) { + evt.preventDefault(); + listFocus.previous(); + } + }; + + const handleRoomClick: MouseEventHandler = (evt) => { + const target = evt.currentTarget; + const roomId = target.getAttribute('data-room-id'); + const isSpace = target.getAttribute('data-space') === 'true'; + if (!roomId) return; + handleActivateRoom(roomId, isSpace); + }; + + useEffect(() => { + const scrollView = scrollRef.current; + const focusedItem = scrollView?.querySelector(`[data-focus-index="${listFocus.index}"]`); + + if (focusedItem && scrollView) { + focusedItem.scrollIntoView({ + block: 'center', + }); + } + }, [listFocus.index]); + + return ( + + {pickRoom && ( + + {pickRoom.title} + + {composerIcon(X)} + + + )} + {pickRoom?.errorMessage ? ( + + + {pickRoom.errorMessage} + + + ) : null} + + + + + {roomsToRender.length === 0 && ( + + + {pickRoom + ? result + ? 'No Match Found' + : pickRoom.eligibleRoomIds.length === 0 + ? 'No rooms to forward to' + : 'No rooms match this filter' + : result + ? 'No Match Found' + : 'No Rooms'} + + + {pickRoom + ? result + ? `No match found for "${result.query}".` + : pickRoom.eligibleRoomIds.length === 0 + ? 'You cannot send messages in any joined room yet.' + : 'Try another search, or use # for group rooms and @ for direct messages.' + : result + ? `No match found for "${result.query}".` + : 'You do not have any Rooms to display yet.'} + + + )} + {roomsToRender.length > 0 && ( + +
+ {roomsToRender.map((roomId, index) => { + const room = getRoom(roomId); + if (!room) return null; + + const dm = mDirects.has(roomId); + const dmUserId = dm && getDmUserId(roomId, getRoom, mx.getSafeUserId()); + const dmUsername = dmUserId && getMxIdLocalPart(dmUserId); + const dmUserServer = dmUserId && getMxIdServer(dmUserId); + + const allParents = getAllParents(roomToParents, roomId); + const orphanParents = allParents && orphanSpaces.filter((o) => allParents.has(o)); + const perfectOrphanParent = + orphanParents && guessPerfectParent(mx, roomId, orphanParents); + + const exactParents = roomToParents.get(roomId); + const perfectParent = + exactParents && guessPerfectParent(mx, roomId, Array.from(exactParents)); + + const unread = roomToUnread.get(roomId); + + return ( + + {dmUserServer && ( + + {dmUserServer} + + )} + {!dm && perfectOrphanParent && ( + + {getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent} + + )} + {unread && ( + + 0} + count={unread.highlight > 0 ? unread.highlight : unread.total} + /> + + )} + + } + before={ + + {dm || room.isSpaceRoom() ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + + )} + + } + > + + + {queryHighlighRegex + ? highlightText(queryHighlighRegex, [room.name]) + : room.name} + + {dmUsername && ( + + @ + {queryHighlighRegex + ? highlightText(queryHighlighRegex, [dmUsername]) + : dmUsername} + + )} + {!dm && perfectParent && perfectParent !== perfectOrphanParent && ( + + — {getRoom(perfectParent)?.name ?? perfectParent} + + )} + + + ); + })} +
+
+ )} +
+ + + + {pickRoom ? ( + <> + Type # for rooms and @ for direct messages. Choose a room to forward + this message. + + ) : ( + <> + Type # for rooms, @ for DMs and * for spaces. Hotkey:{' '} + {isMacOS() ? KeySymbol.Command : 'Ctrl'} + k + {' / '} + {isMacOS() ? KeySymbol.Command : 'Ctrl'} + f + + )} + + +
+ ); +} + +export function SearchModalRenderer() { + const [opened, setOpen] = useAtom(searchModalAtom); + + useKeyDown( + window, + useCallback( + (event) => { + if (isKeyHotkey('mod+k', event)) { + event.preventDefault(); + setOpen(!opened); + return; + } + }, + [opened, setOpen] + ) + ); + + return opened && setOpen(false)} />; +} + +export function SearchWrapper({ requestClose, pickRoom }: RoomSearchModalProps) { + return ( + + + { + evt.stopPropagation(); + return true; + }, + }} + > + + + + + + + ); +} + +export function Search(props: { requestClose: () => void }) { + return ; +} diff --git a/src/app/features/navigate/index.ts b/src/app/features/navigate/index.ts new file mode 100644 index 0000000000..d72d0d0571 --- /dev/null +++ b/src/app/features/navigate/index.ts @@ -0,0 +1 @@ +export * from './NavigateModal'; diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index 0e930730c3..1e0ab5f5f5 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -343,7 +343,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { setCurWidth={setCurWidth} sidebarWidth={memberSidebarWidth} setSidebarWidth={setMemberSidebarWidth} - instep={64} + instep={50} outstep={176} minValue={50} maxValue={350} diff --git a/src/app/features/search/Search.tsx b/src/app/features/search/Search.tsx deleted file mode 100644 index c6acf0d2a7..0000000000 --- a/src/app/features/search/Search.tsx +++ /dev/null @@ -1,547 +0,0 @@ -import FocusTrap from 'focus-trap-react'; -import { - Avatar, - Box, - config, - IconButton, - Input, - Line, - MenuItem, - Modal, - Overlay, - OverlayCenter, - Scroll, - Text, - toRem, -} from 'folds'; -import { MagnifyingGlass, X, composerIcon, menuIcon } from '$components/icons/phosphor'; -import type { ChangeEventHandler, KeyboardEventHandler, MouseEventHandler } from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { isKeyHotkey } from 'is-hotkey'; -import { useAtom, useAtomValue } from 'jotai'; -import type { Room } from '$types/matrix-sdk'; -import { useDirects, useOrphanSpaces, useRooms, useSpaces } from '$state/hooks/roomList'; -import { useMatrixClient } from '$hooks/useMatrixClient'; -import { mDirectAtom } from '$state/mDirectList'; -import { allRoomsAtom } from '$state/room-list/roomList'; -import type { SearchItemStrGetter, UseAsyncSearchOptions } from '$hooks/useAsyncSearch'; -import { useAsyncSearch } from '$hooks/useAsyncSearch'; -import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom'; -import { RoomAvatar, RoomIcon } from '$components/room-avatar'; -import { - getAllParents, - getDirectRoomAvatarUrl, - getRoomAvatarUrl, - guessPerfectParent, -} from '$utils/room'; -import { highlightText, makeHighlightRegex } from '$plugins/react-custom-html-parser'; -import { factoryRoomIdByActivity } from '$utils/sort'; -import { nameInitials } from '$utils/common'; -import { useRoomNavigate } from '$hooks/useRoomNavigate'; -import { useListFocusIndex } from '$hooks/useListFocusIndex'; -import { getMxIdLocalPart, guessDmRoomUserId } from '$utils/matrix'; -import { roomToParentsAtom } from '$state/room/roomToParents'; -import { roomToUnreadAtom } from '$state/room/roomToUnread'; -import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; -import { searchModalAtom } from '$state/searchModal'; -import { useKeyDown } from '$hooks/useKeyDown'; -import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; -import { KeySymbol } from '$utils/key-symbol'; -import { isMacOS } from '$utils/user-agent'; -import { useSelectedSpace } from '$hooks/router/useSelectedSpace'; -import { getMxIdServer } from '$utils/mxIdHelper'; - -enum SearchRoomType { - Rooms = '#', - Spaces = '*', - Directs = '@', -} - -const getSearchPrefixToRoomType = (prefix: string): SearchRoomType | undefined => { - if (prefix === '#') return SearchRoomType.Rooms; - if (prefix === '*') return SearchRoomType.Spaces; - if (prefix === '@') return SearchRoomType.Directs; - return undefined; -}; - -const useTopActiveRooms = ( - searchRoomType: SearchRoomType | undefined, - rooms: string[], - directs: string[], - spaces: string[] -) => { - const mx = useMatrixClient(); - - return useMemo(() => { - if (searchRoomType === SearchRoomType.Spaces) { - return spaces; - } - if (searchRoomType === SearchRoomType.Directs) { - return [...directs].toSorted(factoryRoomIdByActivity(mx)).slice(0, 20); - } - if (searchRoomType === SearchRoomType.Rooms) { - return [...rooms].toSorted(factoryRoomIdByActivity(mx)).slice(0, 20); - } - return [...rooms, ...directs].toSorted(factoryRoomIdByActivity(mx)).slice(0, 20); - }, [mx, rooms, directs, spaces, searchRoomType]); -}; - -const getDmUserId = ( - roomId: string, - getRoom: (roomId: string) => Room | undefined, - myUserId: string -): string | undefined => { - const room = getRoom(roomId); - const targetUserId = room && guessDmRoomUserId(room, myUserId); - return targetUserId; -}; - -const useSearchTargetRooms = ( - searchRoomType: SearchRoomType | undefined, - rooms: string[], - directs: string[], - spaces: string[] -) => - useMemo(() => { - if (searchRoomType === undefined) { - return [...rooms, ...directs, ...spaces]; - } - if (searchRoomType === SearchRoomType.Rooms) return rooms; - if (searchRoomType === SearchRoomType.Spaces) return spaces; - if (searchRoomType === SearchRoomType.Directs) return directs; - - return []; - }, [rooms, spaces, directs, searchRoomType]); - -const SEARCH_OPTIONS: UseAsyncSearchOptions = { - matchOptions: { - contain: true, - }, - normalizeOptions: { - ignoreWhitespace: false, - }, -}; - -export type RoomSearchPickRoomConfig = { - title: string; - eligibleRoomIds: readonly string[]; - onPickRoom: (roomId: string) => void; - errorMessage?: string | null; - busy?: boolean; -}; - -export type RoomSearchModalProps = { - requestClose: () => void; - pickRoom?: RoomSearchPickRoomConfig; -}; - -export function RoomSearchModal({ requestClose, pickRoom }: RoomSearchModalProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const scrollRef = useRef(null); - const inputRef = useRef(null); - const { navigateRoom, navigateSpace } = useRoomNavigate(); - const roomToUnread = useAtomValue(roomToUnreadAtom); - - const [searchRoomType, setSearchRoomType] = useState(); - - const allRoomsSet = useAllJoinedRoomsSet(); - const getRoom = useGetRoom(allRoomsSet); - - const roomToParents = useAtomValue(roomToParentsAtom); - const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents); - const mDirects = useAtomValue(mDirectAtom); - const rooms = useRooms(mx, allRoomsAtom, mDirects); - const spaces = useSpaces(mx, allRoomsAtom); - const directs = useDirects(mx, allRoomsAtom, mDirects); - - const eligibleSet = useMemo( - () => (pickRoom ? new Set(pickRoom.eligibleRoomIds) : null), - [pickRoom] - ); - - const rawTopActiveRooms = useTopActiveRooms(searchRoomType, rooms, directs, spaces); - const rawTargetRooms = useSearchTargetRooms(searchRoomType, rooms, directs, spaces); - - const topActiveRooms = useMemo(() => { - if (!eligibleSet) return rawTopActiveRooms; - return rawTopActiveRooms.filter((id) => eligibleSet.has(id)); - }, [eligibleSet, rawTopActiveRooms]); - - const targetRooms = useMemo(() => { - if (!eligibleSet) return rawTargetRooms; - return rawTargetRooms.filter((id) => eligibleSet.has(id)); - }, [eligibleSet, rawTargetRooms]); - - const getTargetStr: SearchItemStrGetter = useCallback( - (roomId: string) => { - const roomName = getRoom(roomId)?.name ?? roomId; - if (mDirects.has(roomId)) { - const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId()); - const targetUsername = targetUserId && getMxIdLocalPart(targetUserId); - if (targetUsername) return [roomName, targetUsername]; - } - return roomName; - }, - [getRoom, mDirects, mx] - ); - - const [result, search, resetSearch] = useAsyncSearch(targetRooms, getTargetStr, SEARCH_OPTIONS); - const selectedSpaceId = useSelectedSpace(); - - const roomsToRender = useMemo(() => { - const items = result ? result.items : topActiveRooms; - if (!selectedSpaceId) return items; - - return [...items].toSorted((a, b) => { - const aInSpace = getAllParents(roomToParents, a)?.has(selectedSpaceId) ? 1 : 0; - const bInSpace = getAllParents(roomToParents, b)?.has(selectedSpaceId) ? 1 : 0; - return bInSpace - aInSpace; - }); - }, [result, topActiveRooms, selectedSpaceId, roomToParents]); - - const listFocus = useListFocusIndex(roomsToRender.length, 0); - - const queryHighlighRegex = result?.query - ? makeHighlightRegex(result.query.split(' ')) - : undefined; - - const handleActivateRoom = (roomId: string, isSpace: boolean) => { - if (pickRoom) { - if (pickRoom.busy) return; - pickRoom.onPickRoom(roomId); - return; - } - if (isSpace) navigateSpace(roomId); - else navigateRoom(roomId); - requestClose(); - }; - - const handleInputChange: ChangeEventHandler = (evt) => { - listFocus.reset(); - - const target = evt.currentTarget; - let value = target.value.trim(); - const prefix = value.match(/^[#@*]/)?.[0]; - const searchType = typeof prefix === 'string' && getSearchPrefixToRoomType(prefix); - if (searchType) { - value = value.slice(1); - setSearchRoomType(searchType); - } else { - setSearchRoomType(undefined); - } - - if (value === '') { - resetSearch(); - return; - } - search(value); - }; - - const handleInputKeyDown: KeyboardEventHandler = (evt) => { - const roomId = roomsToRender[listFocus.index]; - if (isKeyHotkey('enter', evt) && roomId) { - handleActivateRoom(roomId, spaces.includes(roomId)); - return; - } - if (isKeyHotkey('arrowdown', evt)) { - evt.preventDefault(); - listFocus.next(); - return; - } - if (isKeyHotkey('arrowup', evt)) { - evt.preventDefault(); - listFocus.previous(); - } - }; - - const handleRoomClick: MouseEventHandler = (evt) => { - const target = evt.currentTarget; - const roomId = target.getAttribute('data-room-id'); - const isSpace = target.getAttribute('data-space') === 'true'; - if (!roomId) return; - handleActivateRoom(roomId, isSpace); - }; - - useEffect(() => { - const scrollView = scrollRef.current; - const focusedItem = scrollView?.querySelector(`[data-focus-index="${listFocus.index}"]`); - - if (focusedItem && scrollView) { - focusedItem.scrollIntoView({ - block: 'center', - }); - } - }, [listFocus.index]); - - return ( - - - inputRef.current, - returnFocusOnDeactivate: true, - allowOutsideClick: true, - clickOutsideDeactivates: true, - onDeactivate: requestClose, - escapeDeactivates: (evt: KeyboardEvent) => { - evt.stopPropagation(); - return true; - }, - }} - > - - {pickRoom && ( - - {pickRoom.title} - - {composerIcon(X)} - - - )} - {pickRoom?.errorMessage ? ( - - - {pickRoom.errorMessage} - - - ) : null} - - - - - {roomsToRender.length === 0 && ( - - - {pickRoom - ? result - ? 'No Match Found' - : pickRoom.eligibleRoomIds.length === 0 - ? 'No rooms to forward to' - : 'No rooms match this filter' - : result - ? 'No Match Found' - : 'No Rooms'} - - - {pickRoom - ? result - ? `No match found for "${result.query}".` - : pickRoom.eligibleRoomIds.length === 0 - ? 'You cannot send messages in any joined room yet.' - : 'Try another search, or use # for group rooms and @ for direct messages.' - : result - ? `No match found for "${result.query}".` - : 'You do not have any Rooms to display yet.'} - - - )} - {roomsToRender.length > 0 && ( - -
- {roomsToRender.map((roomId, index) => { - const room = getRoom(roomId); - if (!room) return null; - - const dm = mDirects.has(roomId); - const dmUserId = dm && getDmUserId(roomId, getRoom, mx.getSafeUserId()); - const dmUsername = dmUserId && getMxIdLocalPart(dmUserId); - const dmUserServer = dmUserId && getMxIdServer(dmUserId); - - const allParents = getAllParents(roomToParents, roomId); - const orphanParents = - allParents && orphanSpaces.filter((o) => allParents.has(o)); - const perfectOrphanParent = - orphanParents && guessPerfectParent(mx, roomId, orphanParents); - - const exactParents = roomToParents.get(roomId); - const perfectParent = - exactParents && guessPerfectParent(mx, roomId, Array.from(exactParents)); - - const unread = roomToUnread.get(roomId); - - return ( - - {dmUserServer && ( - - {dmUserServer} - - )} - {!dm && perfectOrphanParent && ( - - {getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent} - - )} - {unread && ( - - 0} - count={unread.highlight > 0 ? unread.highlight : unread.total} - /> - - )} - - } - before={ - - {dm || room.isSpaceRoom() ? ( - ( - - {nameInitials(room.name)} - - )} - /> - ) : ( - - )} - - } - > - - - {queryHighlighRegex - ? highlightText(queryHighlighRegex, [room.name]) - : room.name} - - {dmUsername && ( - - @ - {queryHighlighRegex - ? highlightText(queryHighlighRegex, [dmUsername]) - : dmUsername} - - )} - {!dm && perfectParent && perfectParent !== perfectOrphanParent && ( - - — {getRoom(perfectParent)?.name ?? perfectParent} - - )} - - - ); - })} -
-
- )} -
- - - - {pickRoom ? ( - <> - Type # for rooms and @ for direct messages. Choose a room to - forward this message. - - ) : ( - <> - Type # for rooms, @ for DMs and * for spaces. Hotkey:{' '} - {isMacOS() ? KeySymbol.Command : 'Ctrl'} + k - {' / '} - {isMacOS() ? KeySymbol.Command : 'Ctrl'} + f - - )} - - -
-
-
-
- ); -} - -export function SearchModalRenderer() { - const [opened, setOpen] = useAtom(searchModalAtom); - - useKeyDown( - window, - useCallback( - (event) => { - if (isKeyHotkey('mod+k', event)) { - event.preventDefault(); - if (opened) { - setOpen(false); - return; - } - - const portalContainer = document.getElementById('portalContainer'); - if (portalContainer && portalContainer.children.length > 0) { - return; - } - setOpen(true); - } - }, - [opened, setOpen] - ) - ); - - return opened && setOpen(false)} />; -} - -export function Search(props: { requestClose: () => void }) { - return ; -} diff --git a/src/app/features/search/index.ts b/src/app/features/search/index.ts deleted file mode 100644 index addd53308b..0000000000 --- a/src/app/features/search/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Search'; diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index bf22676a06..90654dc3e2 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -78,14 +78,14 @@ export enum SettingsPages { type PhosphorIcon = ComponentType; -type SettingsMenuItem = { +export type SettingsMenuItem = { id: SettingsSectionId; name: string; icon: PhosphorIcon; activeIcon?: PhosphorIcon; }; -const settingsMenuIcons: Record< +export const settingsMenuIcons: Record< SettingsSectionId, Pick > = { diff --git a/src/app/hooks/router/useNavigateSelected.ts b/src/app/hooks/router/useNavigateSelected.ts new file mode 100644 index 0000000000..81e2168b9c --- /dev/null +++ b/src/app/hooks/router/useNavigateSelected.ts @@ -0,0 +1,12 @@ +import { useMatch } from 'react-router-dom'; +import { getNavigatePath } from '$pages/pathUtils'; + +export const useNavigateSelected = (): boolean => { + const match = useMatch({ + path: getNavigatePath(), + caseSensitive: true, + end: false, + }); + + return !!match; +}; diff --git a/src/app/hooks/router/useProfileSelected.ts b/src/app/hooks/router/useProfileSelected.ts new file mode 100644 index 0000000000..9a7abdde21 --- /dev/null +++ b/src/app/hooks/router/useProfileSelected.ts @@ -0,0 +1,12 @@ +import { useMatch } from 'react-router-dom'; +import { getProfilePath } from '$pages/pathUtils'; + +export const useProfileSelected = (): boolean => { + const match = useMatch({ + path: getProfilePath(), + caseSensitive: true, + end: false, + }); + + return !!match; +}; diff --git a/src/app/pages/MobileFriendly.tsx b/src/app/pages/MobileFriendly.tsx index 83009cda5b..b791bf9066 100644 --- a/src/app/pages/MobileFriendly.tsx +++ b/src/app/pages/MobileFriendly.tsx @@ -1,22 +1,34 @@ import type { ReactNode } from 'react'; import { useMatch } from 'react-router-dom'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; -import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths'; +import { + DIRECT_PATH, + EXPLORE_PATH, + HOME_PATH, + INBOX_PATH, + NAVIGATE_PATH, + PROFILE_PATH, + SPACE_PATH, +} from './paths'; type MobileFriendlyClientNavProps = { children: ReactNode; }; -export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) { +export function MobileFriendlySidebarNav({ children }: MobileFriendlyClientNavProps) { const screenSize = useScreenSizeContext(); const homeMatch = useMatch({ path: HOME_PATH, caseSensitive: true, end: true }); const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true }); const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true }); const exploreMatch = useMatch({ path: EXPLORE_PATH, caseSensitive: true, end: true }); const inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: true }); - + const profileMatch = useMatch({ path: PROFILE_PATH, caseSensitive: true, end: true }); + const navigateMatch = useMatch({ path: NAVIGATE_PATH, caseSensitive: true, end: true }); if ( screenSize === ScreenSize.Mobile && - !(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch) + (!(homeMatch || directMatch || spaceMatch || exploreMatch) || + profileMatch || + inboxMatch || + navigateMatch) ) { return null; } @@ -24,6 +36,22 @@ export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavPro return children; } +export function MobileFriendlyBottomNav({ children }: MobileFriendlyClientNavProps) { + const screenSize = useScreenSizeContext(); + const homeMatch = useMatch({ path: HOME_PATH, caseSensitive: true, end: true }); + const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true }); + const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true }); + const settingsMatch = useMatch({ path: '/settings/', caseSensitive: true, end: true }); + if ( + screenSize !== ScreenSize.Mobile || + (!homeMatch && !directMatch && !spaceMatch) || + settingsMatch + ) { + return null; + } + + return children; +} type MobileFriendlyPageNavProps = { path: string; children: ReactNode; diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 2a01c772c6..e52b07647f 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -28,7 +28,7 @@ import type { Sessions } from '$state/sessions'; import { getFallbackSession, MATRIX_SESSIONS_KEY } from '$state/sessions'; import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage'; import { NotificationJumper } from '$hooks/useNotificationJumper'; -import { SearchModalRenderer } from '$features/search'; +import { SearchModalRenderer } from '$features/navigate'; import { GlobalKeyboardShortcuts } from '$components/GlobalKeyboardShortcuts'; import { CallEmbedProvider } from '$components/CallEmbedProvider'; import { AuthLayout, Login, Register, ResetPassword } from './auth'; @@ -53,6 +53,8 @@ import { CREATE_PATH, TO_ROOM_EVENT_PATH, SETTINGS_PATH, + NAVIGATE_PATH, + PROFILE_PATH, BOOKMARKS_PATH_SEGMENT, } from './paths'; import { @@ -74,7 +76,11 @@ import { Notifications, Inbox, Invites, Bookmarks } from './client/inbox'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; import { WelcomePage } from './client/WelcomePage'; import { SidebarNav } from './client/SidebarNav'; -import { MobileFriendlyPageNav, MobileFriendlyClientNav } from './MobileFriendly'; +import { + MobileFriendlyPageNav, + MobileFriendlySidebarNav, + MobileFriendlyBottomNav, +} from './MobileFriendly'; import { ClientInitStorageAtom } from './client/ClientInitStorageAtom'; import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager'; import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences'; @@ -82,6 +88,9 @@ import { HomeCreateRoom } from './client/home/CreateRoom'; import { Create } from './client/create'; import { ToRoomEvent } from './client/ToRoomEvent'; import { CallStatusRenderer } from './CallStatusRenderer'; +import { UserQuickToolsProvider } from '$components/UserQuickToolsProvider'; +import { Navigate } from './client/navigate'; +import { ProfileMobile } from './client/profile'; /** * Returns true if there is at least one stored session. @@ -183,15 +192,18 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) + - + } > + + + @@ -347,6 +359,8 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } /> } /> + } /> + } /> } /> + diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx index 2dfb8cbc0b..680070786c 100644 --- a/src/app/pages/client/SidebarNav.tsx +++ b/src/app/pages/client/SidebarNav.tsx @@ -6,11 +6,10 @@ 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 } from './sidebar'; +import { DirectTab, DirectDMsList, HomeTab, SpaceTabs, InboxTab } from './sidebar'; import { CreateTab } from './sidebar/CreateTab'; -import { SearchTab } from './sidebar/SearchTab'; +import { NavigateTab } from './sidebar/NavigateTab'; import { SettingsTab } from './sidebar/SettingsTab'; -import { UserQuickTools } from './sidebar/UserQuickTools'; import { useScreenSizeContext, ScreenSize } from '$hooks/useScreenSize'; import { UserMenuTab } from './sidebar/UserMenuTab'; @@ -22,11 +21,9 @@ export function SidebarNav() { const [showUnreadCounts, setShowUnreadCounts] = useSetting(settingsAtom, 'showUnreadCounts'); const [badgeCountDMsOnly, setBadgeCountDMsOnly] = useSetting(settingsAtom, 'badgeCountDMsOnly'); const [showPingCounts, setShowPingCounts] = useSetting(settingsAtom, 'showPingCounts'); - - const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar'); - const [roomSidebarWidth] = useSetting(settingsAtom, 'roomSidebarWidth'); + const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar'); const screenSize = useScreenSizeContext(); const compact = screenSize === ScreenSize.Mobile; @@ -137,38 +134,37 @@ export function SidebarNav() {
} sticky={ - - {oldSidebar ? ( - <> - - - -
- {/*PROBS ADD SETTINGSTAB HERE WHEN ADDING THE STATUSES*/} - -
- - ) : ( - <> - {isCollapsed && ( + <> + {(oldSidebar || isCollapsed) && ( + + {oldSidebar ? ( + <> + + + + ) : ( <> - - + )} - - - - - + + )} + {!compact && ( +
+ +
)} -
+ } /> - {!oldSidebar && } ); } diff --git a/src/app/pages/client/create/Create.tsx b/src/app/pages/client/create/Create.tsx index d22ba50c87..199ff9a0ea 100644 --- a/src/app/pages/client/create/Create.tsx +++ b/src/app/pages/client/create/Create.tsx @@ -18,6 +18,7 @@ import { settingsAtom } from '$state/settings'; import { SidebarResizer } from '../sidebar/SidebarResizer'; import { useSetAtom } from 'jotai'; import { isResizingSidebarAtom } from '$state/isResizingSidebar'; +import { UserQuickTools } from '../sidebar/UserQuickTools'; export function Create() { const { navigateSpace } = useRoomNavigate(); @@ -32,6 +33,7 @@ export function Create() { const screenSize = useScreenSizeContext(); const isMobile = screenSize === ScreenSize.Mobile; const hideText = curWidth <= 80 && !isMobile; + const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar'); return ( <> @@ -64,13 +66,14 @@ export function Create() { setCurWidth={setCurWidth} sidebarWidth={roomSidebarWidth} setSidebarWidth={setRoomSidebarWidth} - instep={80} + instep={50} outstep={190} minValue={50} maxValue={500} setAnnouncement={setIsResizingSidebar} /> + {!oldSidebar && !isMobile && }
)} diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index c9a3bffdfe..8ac9ba96f3 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -65,6 +65,7 @@ import { useDirectRooms } from './useDirectRooms'; import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer'; import { useScreenSizeContext, ScreenSize } from '$hooks/useScreenSize'; import { isResizingSidebarAtom } from '$state/isResizingSidebar'; +import { UserQuickTools } from '../sidebar/UserQuickTools'; type DirectMenuProps = { requestClose: () => void; @@ -270,6 +271,7 @@ export function Direct() { const screenSize = useScreenSizeContext(); const isMobile = screenSize === ScreenSize.Mobile; const hideText = curWidth <= 80 && !isMobile; + const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar'); return ( )} + {!oldSidebar && !isMobile && } ); } diff --git a/src/app/pages/client/explore/Explore.tsx b/src/app/pages/client/explore/Explore.tsx index 60796783a9..184ede69fd 100644 --- a/src/app/pages/client/explore/Explore.tsx +++ b/src/app/pages/client/explore/Explore.tsx @@ -54,6 +54,7 @@ import { isServerName } from '$utils/matrix'; import { useScreenSizeContext, ScreenSize } from '$hooks/useScreenSize'; import { isResizingSidebarAtom } from '$state/isResizingSidebar'; import { useSetAtom } from 'jotai'; +import { UserQuickTools } from '../sidebar/UserQuickTools'; type AddServerProps = { hideText?: boolean; @@ -267,6 +268,7 @@ export function Explore() { const screenSize = useScreenSizeContext(); const isMobile = screenSize === ScreenSize.Mobile; const hideText = curWidth <= 80 && !isMobile; + const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar'); return ( )} + {!oldSidebar && !isMobile && } ); } diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index 3b2da2945c..b398adb3e9 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -81,6 +81,7 @@ import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useClientConfig } from '$hooks/useClientConfig'; import { getMxIdServer } from '$utils/mxIdHelper'; import { isResizingSidebarAtom } from '$state/isResizingSidebar'; +import { UserQuickTools } from '../sidebar/UserQuickTools'; type HomeMenuProps = { requestClose: () => void; @@ -319,6 +320,7 @@ export function Home() { const screenSize = useScreenSizeContext(); const isMobile = screenSize === ScreenSize.Mobile; const hideText = curWidth <= 80 && !isMobile; + const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar'); return ( )} + {!oldSidebar && !isMobile && } ); } diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx index 65d428ea86..0439c5fc70 100644 --- a/src/app/pages/client/inbox/Inbox.tsx +++ b/src/app/pages/client/inbox/Inbox.tsx @@ -23,6 +23,7 @@ import { useInviteCount } from '$hooks/useInviteCount'; import { BookmarkIcon } from '@phosphor-icons/react'; import { isResizingSidebarAtom } from '$state/isResizingSidebar'; import { useSetAtom } from 'jotai'; +import { UserQuickTools } from '../sidebar/UserQuickTools'; function InvitesNavItem({ hideText }: { hideText?: boolean }) { const invitesSelected = useInboxInvitesSelected(); @@ -68,6 +69,7 @@ export function Inbox() { const setIsResizingSidebar = useSetAtom(isResizingSidebarAtom); const [roomSidebarWidth, setRoomSidebarWidth] = useSetting(settingsAtom, 'roomSidebarWidth'); const [curWidth, setCurWidth] = useState(roomSidebarWidth); + const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar'); useEffect(() => { setCurWidth(roomSidebarWidth); @@ -89,7 +91,12 @@ export function Inbox() { {!hideText ? ( - + Inbox @@ -152,13 +159,14 @@ export function Inbox() { setCurWidth={setCurWidth} sidebarWidth={roomSidebarWidth} setSidebarWidth={setRoomSidebarWidth} - instep={80} + instep={50} outstep={190} minValue={50} maxValue={500} setAnnouncement={setIsResizingSidebar} /> )} + {!oldSidebar && !isMobile && } ); } diff --git a/src/app/pages/client/navigate/Navigate.tsx b/src/app/pages/client/navigate/Navigate.tsx new file mode 100644 index 0000000000..ccb95bc544 --- /dev/null +++ b/src/app/pages/client/navigate/Navigate.tsx @@ -0,0 +1,94 @@ +import { Box, Scroll, toRem, Text, color, config } from 'folds'; +import { SquaresFour, sizedIcon } from '$components/icons/phosphor'; +import { Page, PageContent, PageContentCenter, PageNav, PageNavHeader } from '$components/page'; +import { useEffect, useState } from 'react'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { SidebarResizer } from '../sidebar/SidebarResizer'; +import { useSetAtom } from 'jotai'; +import { isResizingSidebarAtom } from '$state/isResizingSidebar'; +import { RoomSearchModal } from '$features/navigate'; +import { UserQuickTools } from '../sidebar/UserQuickTools'; + +export function Navigate() { + const setIsResizingSidebar = useSetAtom(isResizingSidebarAtom); + const [roomSidebarWidth, setRoomSidebarWidth] = useSetting(settingsAtom, 'roomSidebarWidth'); + const [curWidth, setCurWidth] = useState(roomSidebarWidth); + const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar'); + + useEffect(() => { + setCurWidth(roomSidebarWidth); + }, [roomSidebarWidth]); + const screenSize = useScreenSizeContext(); + const isMobile = screenSize === ScreenSize.Mobile; + const hideText = curWidth <= 80 && !isMobile; + + return ( + <> + {!isMobile && ( + + + + + {!hideText ? ( + + + Inbox + + + ) : ( + sizedIcon(SquaresFour, '200', { filled: true }) + )} + + + + + + {!oldSidebar && !isMobile && } + + )} + + + + + + + Navigate + + + + + + + + + + + + + + + + + ); +} diff --git a/src/app/pages/client/navigate/index.ts b/src/app/pages/client/navigate/index.ts new file mode 100644 index 0000000000..7571cfdeca --- /dev/null +++ b/src/app/pages/client/navigate/index.ts @@ -0,0 +1 @@ +export * from './Navigate'; diff --git a/src/app/pages/client/profile/Profile.tsx b/src/app/pages/client/profile/Profile.tsx new file mode 100644 index 0000000000..300afc1ff0 --- /dev/null +++ b/src/app/pages/client/profile/Profile.tsx @@ -0,0 +1,274 @@ +import { + Box, + toRem, + Text, + color, + config, + Menu, + Line, + MenuItem, + OverlayBackdrop, + Overlay, + OverlayCenter, +} from 'folds'; +import { GearSix, SquaresFour, menuIcon, sizedIcon } from '$components/icons/phosphor'; +import { PageNav, PageNavHeader } from '$components/page'; +import { useEffect, useMemo, useState } from 'react'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { SidebarResizer } from '../sidebar/SidebarResizer'; +import { useSetAtom } from 'jotai'; +import { isResizingSidebarAtom } from '$state/isResizingSidebar'; +import { + AccountMenuOption, + PresenceMenuOption, + UnverifiedMenuOption, +} from '../sidebar/UserMenuTab'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { GlobalUserHeroName, UserHero } from '$components/user-profile/UserHero'; +import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useUserPresence } from '$hooks/useUserPresence'; +import { useUserProfile } from '$hooks/useUserProfile'; +import type { SettingsMenuItem } from '$features/settings'; +import { settingsMenuIcons, settingsSections, useOpenSettings } from '$features/settings'; +import { UserQuickTools } from '../sidebar/UserQuickTools'; +import { + CaretDownIcon, + CaretRightIcon, + PencilSimpleIcon, + SignOutIcon, +} from '@phosphor-icons/react'; +import { UseStateProvider } from '$components/UseStateProvider'; +import { FocusTrap } from 'focus-trap-react'; +import { LogoutDialog } from '$components/LogoutDialog'; +import { stopPropagation } from '$utils/keyboard'; + +export function ProfileMobile() { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const openSettings = useOpenSettings(); + const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar'); + + const userId = mx.getUserId() ?? ''; + const profile = useUserProfile(userId); + const presence = useUserPresence(userId); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + + const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; + 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 setIsResizingSidebar = useSetAtom(isResizingSidebarAtom); + const [roomSidebarWidth, setRoomSidebarWidth] = useSetting(settingsAtom, 'roomSidebarWidth'); + const [curWidth, setCurWidth] = useState(roomSidebarWidth); + + useEffect(() => { + setCurWidth(roomSidebarWidth); + }, [roomSidebarWidth]); + const screenSize = useScreenSizeContext(); + const isMobile = screenSize === ScreenSize.Mobile; + const hideText = curWidth <= 80 && !isMobile; + + const [showPersona] = useSetting(settingsAtom, 'showPersonaSetting'); + const menuItems = useMemo( + () => + settingsSections + .filter((section) => showPersona || section.id !== 'persona') + .map((section) => { + const icon = settingsMenuIcons[section.id]; + return { id: section.id, name: section.label, ...icon }; + }), + [showPersona] + ); + + return ( + <> + {!isMobile && ( + + + + + {!hideText ? ( + + + Profile + + + ) : ( + sizedIcon(SquaresFour, '200', { filled: true }) + )} + + + + + {!oldSidebar && !isMobile && } + + )} + + + + + + Account + + + + + + + + + + + + + + + + openSettings('account')} + > + + Edit profile + + + + + + + + + + + + + + setIsSettingsOpen(!isSettingsOpen)} + > + + Settings + + + {isSettingsOpen && ( + + {menuItems.map((item) => { + const IconComponent = item.icon; + + return ( + openSettings(item.id)} + > + + {item.name} + + + ); + })} + + )} + + + {(logout, setLogout) => ( + <> + + setLogout(true)} + > + Logout + + {logout && ( + }> + + setLogout(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + setLogout(false)} /> + + + + )} + + )} + +
+ +
+
+ + ); +} diff --git a/src/app/pages/client/profile/index.ts b/src/app/pages/client/profile/index.ts new file mode 100644 index 0000000000..963df00a60 --- /dev/null +++ b/src/app/pages/client/profile/index.ts @@ -0,0 +1 @@ +export * from './Profile'; diff --git a/src/app/pages/client/sidebar/InboxTab.tsx b/src/app/pages/client/sidebar/InboxTab.tsx index 28dc3db9c5..67851b5d0e 100644 --- a/src/app/pages/client/sidebar/InboxTab.tsx +++ b/src/app/pages/client/sidebar/InboxTab.tsx @@ -21,15 +21,19 @@ import { import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useNavToActivePathAtom } from '$state/hooks/navToActivePath'; import { useInviteCount } from '$hooks/useInviteCount'; +import { Text, Box, color } from 'folds'; +import { searchModalAtom } from '$state/searchModal'; import { EnvelopeSimple, getPhosphorIconSize, Tray } from '$components/icons/phosphor'; import { BookmarkIcon, ChatCircleDotsIcon } from '@phosphor-icons/react'; -export function InboxTab({ isBottom }: { isBottom?: boolean }) { +export function InboxTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile?: boolean }) { const screenSize = useScreenSizeContext(); const navigate = useNavigate(); const navToActivePath = useAtomValue(useNavToActivePathAtom()); const inboxSelected = useInboxSelected(); const inviteCount = useInviteCount(); + const isSearch = useAtomValue(searchModalAtom); + const opened = inboxSelected && !isSearch; const InboxIconSize = getPhosphorIconSize(isBottom ? 'inline' : 'toolbar'); const notificationsSelected = useInboxNotificationsSelected(); @@ -52,22 +56,51 @@ export function InboxTab({ isBottom }: { isBottom?: boolean }) { }; return ( - + {(triggerRef) => ( - - {(notificationsSelected && ) || - (bookmarksSelected && ) || - (invitesSelected && ) || ( - - )} - + + + {(notificationsSelected && ( + + )) || + (bookmarksSelected && ( + + )) || + (invitesSelected && ( + + )) || ( + + )} + + {isMobile && ( + + Inbox + + )} + )} {inviteCount > 0 && } diff --git a/src/app/pages/client/sidebar/MessageTab.tsx b/src/app/pages/client/sidebar/MessageTab.tsx new file mode 100644 index 0000000000..7e5d7473ed --- /dev/null +++ b/src/app/pages/client/sidebar/MessageTab.tsx @@ -0,0 +1,108 @@ +import { + SidebarAvatar, + SidebarItem, + SidebarItemTooltip, + SidebarUnreadBadge, +} from '$components/sidebar'; +import { getPhosphorIconSize } from '$components/icons/phosphor'; +import { matchPath, useNavigate } from 'react-router-dom'; +import { HOME_PATH, SETTINGS_PATH } from '$pages/paths'; +import { ChatTextIcon } from '@phosphor-icons/react'; +import { useAtom, useAtomValue } from 'jotai'; +import { searchModalAtom } from '$state/searchModal'; +import { useInboxSelected } from '$hooks/router/useInbox'; +import { Box, color, Text, toRem } from 'folds'; +import { useNavigateSelected } from '$hooks/router/useNavigateSelected'; +import { useProfileSelected } from '$hooks/router/useProfileSelected'; +import { getSpacePath } from '$pages/pathUtils'; +import { lastVisitedSpaceIdAtom } from '$state/room/lastSpace'; +import { allRoomsAtom } from '$state/room-list/roomList'; +import { useRoomsUnread } from '$state/hooks/unread'; +import { roomToUnreadAtom } from '$state/room/roomToUnread'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { resolveUnreadBadgeMode } from '$components/unread-badge'; + +export function MessageTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile?: boolean }) { + const rooms = useAtomValue(allRoomsAtom); + const unread = useRoomsUnread(rooms, roomToUnreadAtom); + + const navigate = useNavigate(); + const lastSpaceId = useAtomValue(lastVisitedSpaceIdAtom); + const [searchSelected] = useAtom(searchModalAtom); + const navigateRouteActive = useNavigateSelected(); + const profileRouteActive = useProfileSelected(); + const inboxSelected = useInboxSelected(); + const opened = !( + matchPath(SETTINGS_PATH, location.pathname) || + searchSelected || + navigateRouteActive || + profileRouteActive || + inboxSelected + ); + const onBack = () => { + if (!lastSpaceId) { + navigate(HOME_PATH); + return; + } + + navigate(getSpacePath(lastSpaceId)); + }; + + const [showUnreadCounts] = useSetting(settingsAtom, 'showUnreadCounts'); + const [badgeCountDMsOnly] = useSetting(settingsAtom, 'badgeCountDMsOnly'); + const [showPingCounts] = useSetting(settingsAtom, 'showPingCounts'); + const resolvedMode = unread + ? resolveUnreadBadgeMode({ + highlight: !!unread.highlight, + count: unread.total, + showUnreadCounts, + badgeCountDMsOnly, + showPingCounts, + }) + : undefined; + + return ( + + + {(triggerRef) => ( + + + + + {unread && ( + + 0} + count={unread.highlight > 0 ? unread.highlight : unread.total} + /> + + )} + {isMobile && ( + + Messages + + )} + + )} + + + ); +} diff --git a/src/app/pages/client/sidebar/NavigateTab.tsx b/src/app/pages/client/sidebar/NavigateTab.tsx new file mode 100644 index 0000000000..47e5f38198 --- /dev/null +++ b/src/app/pages/client/sidebar/NavigateTab.tsx @@ -0,0 +1,49 @@ +import { useAtom } from 'jotai'; +import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '$components/sidebar'; +import { searchModalAtom } from '$state/searchModal'; +import { ListMagnifyingGlassIcon } from '@phosphor-icons/react'; +import { getPhosphorIconSize } from '$components/icons/phosphor'; +import { Text, Box, color } from 'folds'; +import { useNavigate } from 'react-router-dom'; +import { getNavigatePath } from '$pages/pathUtils'; +import { useNavigateSelected } from '$hooks/router/useNavigateSelected'; + +export function NavigateTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile?: boolean }) { + const [opened, setOpen] = useAtom(searchModalAtom); + const navigateRouteActive = useNavigateSelected(); + const isNavigate = opened || navigateRouteActive; + const navigate = useNavigate(); + const open = () => { + if (isMobile) navigate(getNavigatePath()); + else setOpen(true); + }; + + return ( + + + {(triggerRef) => ( + + + + + {isMobile && ( + + Navigate + + )} + + )} + + + ); +} diff --git a/src/app/pages/client/sidebar/SearchTab.tsx b/src/app/pages/client/sidebar/SearchTab.tsx deleted file mode 100644 index 0a8045860d..0000000000 --- a/src/app/pages/client/sidebar/SearchTab.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useAtom } from 'jotai'; -import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '$components/sidebar'; -import { searchModalAtom } from '$state/searchModal'; -import { ListMagnifyingGlassIcon } from '@phosphor-icons/react'; -import { getPhosphorIconSize } from '$components/icons/phosphor'; - -export function SearchTab({ isBottom }: { isBottom?: boolean }) { - const [opened, setOpen] = useAtom(searchModalAtom); - - const open = () => setOpen(true); - - return ( - - - {(triggerRef) => ( - - - - )} - - - ); -} diff --git a/src/app/pages/client/sidebar/SettingsTab.tsx b/src/app/pages/client/sidebar/SettingsTab.tsx index 37372e2ebe..7ca47d18aa 100644 --- a/src/app/pages/client/sidebar/SettingsTab.tsx +++ b/src/app/pages/client/sidebar/SettingsTab.tsx @@ -3,8 +3,9 @@ import { GearSix, getPhosphorIconSize } from '$components/icons/phosphor'; import { useOpenSettings } from '$features/settings'; import { matchPath } from 'react-router-dom'; import { SETTINGS_PATH } from '$pages/paths'; +import { color } from 'folds'; -export function SettingsTab({ isBottom }: { isBottom?: boolean }) { +export function SettingsTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile?: boolean }) { const opened = !!matchPath(SETTINGS_PATH, location.pathname); const openSettings = useOpenSettings(); @@ -16,6 +17,7 @@ export function SettingsTab({ isBottom }: { isBottom?: boolean }) { )} diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index 80d7e19cb1..b88f9db797 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -1,6 +1,6 @@ import type { FormEventHandler, MouseEventHandler, ReactNode, RefObject, ChangeEvent } from 'react'; import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useAtom, useAtomValue } from 'jotai'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useNavigate } from 'react-router-dom'; import type { RectCords } from 'folds'; import { @@ -100,6 +100,7 @@ import { useRoomCreators } from '$hooks/useRoomCreators'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { InviteUserPrompt } from '$components/invite-user-prompt'; import { CustomAccountDataEvent } from '$types/matrix/accountData'; +import { lastVisitedSpaceIdAtom } from '$state/room/lastSpace'; type SpaceMenuProps = { room: Room; @@ -742,6 +743,7 @@ export function SpaceTabs({ scrollRef }: Readonly) { const [sidebarItems, localEchoSidebarItem] = useSidebarItems(orphanSpaces); const navToActivePath = useAtomValue(useNavToActivePathAtom()); const [openedFolder, setOpenedFolder] = useAtom(useOpenedSidebarFolderAtom()); + const setLastSpaceId = useSetAtom(lastVisitedSpaceIdAtom); const [draggingItem, setDraggingItem] = useState(); const [folderMenuState, setFolderMenuState] = useState<{ folder: ISidebarFolder; @@ -914,6 +916,7 @@ export function SpaceTabs({ scrollRef }: Readonly) { const targetSpaceId = target.getAttribute('data-id'); if (!targetSpaceId) return; + setLastSpaceId(targetSpaceId); const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId)); if (screenSize === ScreenSize.Mobile) { navigate(spacePath); diff --git a/src/app/pages/client/sidebar/UnverifiedTab.css.ts b/src/app/pages/client/sidebar/UnverifiedTab.css.ts deleted file mode 100644 index cce17ccb92..0000000000 --- a/src/app/pages/client/sidebar/UnverifiedTab.css.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { keyframes, style } from '@vanilla-extract/css'; -import { color, toRem } from 'folds'; - -const pushRight = keyframes({ - from: { - transform: `translateX(${toRem(2)}) scale(1)`, - }, - to: { - transform: 'translateX(0) scale(1)', - }, -}); - -export const UnverifiedTab = style({ - animationName: pushRight, - animationDuration: '400ms', - animationIterationCount: 30, - animationDirection: 'alternate', -}); - -export const UnverifiedAvatar = style({ - backgroundColor: color.Critical.Container, - color: color.Critical.OnContainer, - borderColor: color.Critical.ContainerLine, -}); - -export const UnverifiedOtherAvatar = style({ - backgroundColor: color.Warning.Container, - color: color.Warning.OnContainer, - borderColor: color.Warning.ContainerLine, -}); diff --git a/src/app/pages/client/sidebar/UnverifiedTab.tsx b/src/app/pages/client/sidebar/UnverifiedTab.tsx deleted file mode 100644 index 74070a0119..0000000000 --- a/src/app/pages/client/sidebar/UnverifiedTab.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { Badge, color, Text } from 'folds'; -import { - SidebarAvatar, - SidebarItem, - SidebarItemBadge, - SidebarItemTooltip, -} from '$components/sidebar'; -import { useDeviceIds, useDeviceList, useSplitCurrentDevice } from '$hooks/useDeviceList'; -import { useMatrixClient } from '$hooks/useMatrixClient'; -import { - useDeviceVerificationStatus, - useUnverifiedDeviceCount, - VerificationStatus, -} from '$hooks/useDeviceVerificationStatus'; -import { useCrossSigningActive } from '$hooks/useCrossSigning'; -import { useOpenSettings } from '$features/settings'; -import { getPhosphorIconSize, ShieldWarning } from '$components/icons/phosphor'; -import * as css from './UnverifiedTab.css'; - -function UnverifiedIndicator({ isBottom }: { isBottom?: boolean }) { - const mx = useMatrixClient(); - const openSettings = useOpenSettings(); - - const crypto = mx.getCrypto(); - const [devices] = useDeviceList(); - - const [currentDevice, otherDevices] = useSplitCurrentDevice(devices); - - const verificationStatus = useDeviceVerificationStatus( - crypto, - mx.getSafeUserId(), - currentDevice?.device_id - ); - const unverified = verificationStatus === VerificationStatus.Unverified; - - const otherDevicesId = useDeviceIds(otherDevices); - const unverifiedDeviceCount = useUnverifiedDeviceCount( - crypto, - mx.getSafeUserId(), - otherDevicesId - ); - - const hasUnverified = - unverified || (unverifiedDeviceCount !== undefined && unverifiedDeviceCount > 0); - return ( - <> - {hasUnverified && ( - - - {(triggerRef) => ( - openSettings('devices')} - > - - - )} - - {!unverified && unverifiedDeviceCount && unverifiedDeviceCount > 0 && ( - - - - {unverifiedDeviceCount} - - - - )} - - )} - - ); -} - -export function UnverifiedTab({ isBottom }: { isBottom?: boolean }) { - const crossSigningActive = useCrossSigningActive(); - - if (!crossSigningActive) return null; - - return ; -} diff --git a/src/app/pages/client/sidebar/UserMenuTab.css.ts b/src/app/pages/client/sidebar/UserMenuTab.css.ts new file mode 100644 index 0000000000..b466fe526d --- /dev/null +++ b/src/app/pages/client/sidebar/UserMenuTab.css.ts @@ -0,0 +1,52 @@ +import { keyframes } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; +import { color } from 'folds'; + +const PulseCritical = keyframes({ + '0%, 100%': { + color: color.Critical.OnContainer, + }, + '50%': { + color: color.Surface.OnContainer, + }, +}); + +const PulseWarning = keyframes({ + '0%, 100%': { + color: color.Warning.OnContainer, + }, + '50%': { + color: color.Surface.OnContainer, + }, +}); + +export const UnverifiedDevices = recipe({ + base: { + position: 'relative', + background: color.Surface.Container, + }, + variants: { + critical: { + true: { + animation: `${PulseCritical} 2s infinite`, + }, + false: { + animation: `${PulseWarning} 2s infinite`, + }, + }, + }, +}); + +export const UnverifiedDevicesReduced = recipe({ + base: {}, + variants: { + critical: { + true: { + color: color.Critical.OnContainer, + }, + false: { + color: color.Warning.OnContainer, + }, + }, + }, +}); diff --git a/src/app/pages/client/sidebar/UserMenuTab.tsx b/src/app/pages/client/sidebar/UserMenuTab.tsx index efbb30283c..72e66f4825 100644 --- a/src/app/pages/client/sidebar/UserMenuTab.tsx +++ b/src/app/pages/client/sidebar/UserMenuTab.tsx @@ -2,24 +2,24 @@ 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, + color, config, toRem, } from 'folds'; -import FocusTrap from 'focus-trap-react'; -import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; +import { FocusTrap } from 'focus-trap-react'; +import { SidebarAvatar, SidebarItem, SidebarItemBadge } from '../../../components/sidebar'; import { UserAvatar } from '../../../components/user-avatar'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; @@ -36,18 +36,33 @@ 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 { Check, chipIcon, menuIcon, Plus } from '$components/icons/phosphor'; import { useSessionProfiles } from '$hooks/useSessionProfiles'; import { useClientConfig } from '$hooks/useClientConfig'; -import { getHomePath, getLoginPath, withSearchParam } from '$pages/pathUtils'; +import { getHomePath, getLoginPath, getProfilePath, 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'; +import { useProfileSelected } from '$hooks/router/useProfileSelected'; +import { useDeviceIds, useDeviceList, useSplitCurrentDevice } from '$hooks/useDeviceList'; +import { + useDeviceVerificationStatus, + useUnverifiedDeviceCount, + VerificationStatus, +} from '$hooks/useDeviceVerificationStatus'; +import { + CaretDownIcon, + CaretRightIcon, + GearSixIcon, + PencilSimpleIcon, + ShieldWarningIcon, + UserIcon, +} from '@phosphor-icons/react'; +import * as css from './UserMenuTab.css'; const log = createLogger('AccountSwitcherTab'); @@ -57,6 +72,7 @@ function AccountRow({ displayName, avatarUrl, isBusy, + isMobile, unread, onSwitch, onSignOut, @@ -66,6 +82,7 @@ function AccountRow({ displayName?: string; avatarUrl?: string; isBusy?: boolean; + isMobile?: boolean; unread?: { total: number; highlight: number }; onSwitch: (session: Session) => void; onSignOut: (session: Session) => void; @@ -81,6 +98,7 @@ function AccountRow({ style={{ opacity: isBusy ? 0.6 : undefined, height: 'auto', + background: isMobile ? color.Background.Container : undefined, }} before={ @@ -140,7 +158,7 @@ function AccountRow({ ); } -export function AccountMenuOption() { +export function AccountMenuOption({ isMobile, isRight }: { isMobile: boolean; isRight?: boolean }) { const mx = useMatrixClient(); const navigate = useNavigate(); const sessions = useAtomValue(sessionsAtom); @@ -149,8 +167,6 @@ export function AccountMenuOption() { 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({ @@ -251,12 +267,15 @@ export function AccountMenuOption() { } - after={ - - } + before={menuIcon(UserIcon)} + after={isOpen && isMobile ? menuIcon(CaretDownIcon) : menuIcon(CaretRightIcon)} style={{ position: 'relative', + background: isMobile + ? color.Background.Container + : isOpen + ? color.Secondary.Container + : color.Surface.Container, }} onClick={() => isMobile && setIsOpen(!isOpen)} {...hoverProps} @@ -277,9 +296,10 @@ export function AccountMenuOption() { : { minWidth: toRem(240), position: 'absolute', - left: '98%', + left: isRight ? undefined : '98%', + right: isRight ? '98%' : undefined, padding: toRem(15), - bottom: toRem(25), + bottom: toRem(-15), } } > @@ -290,6 +310,7 @@ export function AccountMenuOption() { border: 0, boxShadow: 'none', gap: 0, + background: color.Background.Container, } : {} } @@ -320,10 +341,17 @@ export function AccountMenuOption() { onSignOut={(pendingSession) => { setConfirmSignOutSession(pendingSession); }} + isMobile={isMobile} /> ); })} - + Add Account
@@ -377,17 +405,22 @@ const PresenceOptions: Array<{ value: Presence; label: string }> = [ { value: Presence.Offline, label: 'Offline' }, ]; -export function PresenceMenuOption() { +export function PresenceMenuOption({ + isMobile, + isRight, +}: { + isMobile: boolean; + isRight?: boolean; +}) { 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 [isOpen, setIsOpen] = useState(isMobile); + const { hoverProps } = useHover({ onHoverChange: (h) => { if (!isMobile) setIsOpen(h); @@ -435,7 +468,7 @@ export function PresenceMenuOption() { display: 'flex', justifyContent: 'center', alignContent: 'center', - width: 18, + width: toRem(20), }} > {savingStatus ? ( @@ -449,11 +482,14 @@ export function PresenceMenuOption() { )} } - after={ - - } + after={isOpen && isMobile ? menuIcon(CaretDownIcon) : menuIcon(CaretRightIcon)} style={{ position: 'relative', + background: isMobile + ? color.Background.Container + : isOpen + ? color.Secondary.Container + : color.Surface.Container, }} onClick={() => isMobile && setIsOpen(!isOpen)} {...hoverProps} @@ -473,9 +509,10 @@ export function PresenceMenuOption() { : { minWidth: toRem(240), position: 'absolute', - left: '98%', + left: isRight ? undefined : '98%', + right: isRight ? '98%' : undefined, padding: toRem(15), - bottom: toRem(65), + bottom: toRem(25), } } > @@ -486,6 +523,7 @@ export function PresenceMenuOption() { border: 0, boxShadow: 'none', gap: 0, + background: color.Background.Container, } : {} } @@ -525,19 +563,92 @@ export function PresenceMenuOption() { ); } -export function UserMenuTab({ isBottom }: { isBottom?: boolean }) { +export function useIsUnverified() { const mx = useMatrixClient(); + + const crypto = mx.getCrypto(); + const [devices] = useDeviceList(); + + const [currentDevice] = useSplitCurrentDevice(devices); + + const verificationStatus = useDeviceVerificationStatus( + crypto, + mx.getSafeUserId(), + currentDevice?.device_id + ); + const unverified = verificationStatus === VerificationStatus.Unverified; + return unverified; +} + +export function useUnverifiedDevices() { + const mx = useMatrixClient(); + + const crypto = mx.getCrypto(); + const [devices] = useDeviceList(); + + const [, otherDevices] = useSplitCurrentDevice(devices); + + const otherDevicesId = useDeviceIds(otherDevices); + const unverifiedDeviceCount = useUnverifiedDeviceCount( + crypto, + mx.getSafeUserId(), + otherDevicesId + ); + return unverifiedDeviceCount; +} + +export function UnverifiedMenuOption() { + const openSettings = useOpenSettings(); + const [reducedMotion] = useSetting(settingsAtom, 'reducedMotion'); + + const unverifiedDeviceCount = useUnverifiedDevices(); + const unverified = useIsUnverified(); + + const hasUnverified = + unverified || (unverifiedDeviceCount !== undefined && unverifiedDeviceCount > 0); + + return ( + <> + {hasUnverified && ( + } + onClick={() => openSettings('devices')} + > + + {`Verify ${unverified ? 'this' : 'your'} device${!unverified && (unverifiedDeviceCount ?? 0) > 1 ? 's' : ''}`} + + + )} + + ); +} + +export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile?: boolean }) { + const mx = useMatrixClient(); + const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar'); const useAuthentication = useMediaAuthentication(); + const profileSelected = useProfileSelected(); + const navigate = useNavigate(); 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 unverifiedDeviceCount = useUnverifiedDevices(); + const unverified = useIsUnverified(); + const hasUnverified = unverified || (unverifiedDeviceCount ?? 0) > 0; + const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; const avatarUrl = profile.avatarUrl ? (mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined) @@ -552,32 +663,74 @@ export function UserMenuTab({ isBottom }: { isBottom?: boolean }) { ? (mxcUrlToHttp(mx, parsedBanner, useAuthentication, 640, 192, 'scale') ?? undefined) : undefined; - const handleToggle: MouseEventHandler = (evt) => { + const handleToggle: MouseEventHandler = (evt) => { + if (isMobile) { + navigate(getProfilePath()); + return; + } + const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((cur) => (cur ? undefined : cords)); + setMenuAnchor(() => cords); }; const handleCloseMenu = () => setMenuAnchor(undefined); + const isActive = (!!menuAnchor || profileSelected) && !isMobile; + return ( - - - {(triggerRef) => ( - } + + + }> + - - {nameInitials(displayName)}} - /> - - + {nameInitials(displayName)}} + /> + + + {isMobile && ( + + Account + )} - +
+ + {hasUnverified && ( + + + + {unverifiedDeviceCount} + + + + )} - + + + + @@ -619,32 +775,40 @@ export function UserMenuTab({ isBottom }: { isBottom?: boolean }) { onClick={() => openSettings('account')} size="300" radii="300" - before={} + before={menuIcon(PencilSimpleIcon)} > Edit Profile - + window.innerWidth / 2} + /> - - - - - - } - onClick={() => openSettings()} - > - - Settings - - - + window.innerWidth / 2} + /> + {(oldSidebar || isMobile) && ( + <> + + + openSettings()} + > + + Settings + + + + + )} diff --git a/src/app/pages/client/sidebar/UserQuickTools.css.ts b/src/app/pages/client/sidebar/UserQuickTools.css.ts index 8cd7330381..f085e5fc81 100644 --- a/src/app/pages/client/sidebar/UserQuickTools.css.ts +++ b/src/app/pages/client/sidebar/UserQuickTools.css.ts @@ -2,13 +2,11 @@ import { style } from '@vanilla-extract/css'; import { color, config, toRem } from 'folds'; export const UserQuickTools = style({ - backgroundColor: color.SurfaceVariant.Container, - color: color.SurfaceVariant.OnContainer, - position: 'absolute', + backgroundColor: color.Surface.Container, + color: color.Surface.OnContainer, zIndex: '1000', height: toRem(74), bottom: '0', - left: toRem(-66), - padding: config.space.S300, + padding: config.space.S0, borderTop: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`, }); diff --git a/src/app/pages/client/sidebar/UserQuickTools.tsx b/src/app/pages/client/sidebar/UserQuickTools.tsx index 1004f38cd8..b9357e8e40 100644 --- a/src/app/pages/client/sidebar/UserQuickTools.tsx +++ b/src/app/pages/client/sidebar/UserQuickTools.tsx @@ -1,26 +1,21 @@ import { Box, config, toRem } from 'folds'; import { InboxTab } from './InboxTab'; -import { SearchTab } from './SearchTab'; +import { NavigateTab } from './NavigateTab'; import { SettingsTab } from './SettingsTab'; -import { UnverifiedTab } from './UnverifiedTab'; -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'; +import { MessageTab } from './MessageTab'; export function UserQuickTools({ width, + compact, }: { isCollapsed?: boolean; underOutstep?: boolean; - width: number; + width?: number; + compact: boolean; }) { - const screenSize = useScreenSizeContext(); - const compact = screenSize === ScreenSize.Mobile; - - const [isResizingSidebar] = useAtom(isResizingSidebarAtom); - const isCollapsed = compact ? false : width < 190 + 66; + const isCollapsed = compact ? false : (width ?? 0) < 190 + 66; return ( <> @@ -29,31 +24,51 @@ export function UserQuickTools({
- - - {!isCollapsed && ( - <> - - - - - - )} - + {compact ? ( + <> + + + + + + + + ) : ( + <> + + + {!isCollapsed && ( + <> + + + + + )} + + + )}
)} diff --git a/src/app/pages/client/sidebar/index.ts b/src/app/pages/client/sidebar/index.ts index 6160587640..7f9e7adca4 100644 --- a/src/app/pages/client/sidebar/index.ts +++ b/src/app/pages/client/sidebar/index.ts @@ -3,4 +3,3 @@ export * from './DirectTab'; export * from './DirectDMsList'; export * from './SpaceTabs'; export * from './InboxTab'; -export * from './UnverifiedTab'; diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 48698d921e..91188ccdef 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -109,6 +109,7 @@ import { ModalWide } from '$styles/Modal.css'; import { ImageViewer } from '$components/image-viewer'; import * as css from './styles.css'; import { isResizingSidebarAtom } from '$state/isResizingSidebar'; +import { UserQuickTools } from '../sidebar/UserQuickTools'; const debugLog = createDebugLogger('Space'); @@ -859,6 +860,8 @@ export function Space() { const screenSize = useScreenSizeContext(); const isMobile = screenSize === ScreenSize.Mobile; const hideText = curWidth <= 80 && !isMobile; + const [oldSidebar] = useSetting(settingsAtom, 'oldSidebar'); + return ( -
+ {!isMobile &&
} @@ -1057,13 +1060,14 @@ export function Space() { setCurWidth={setCurWidth} sidebarWidth={roomSidebarWidth} setSidebarWidth={setRoomSidebarWidth} - instep={80} + instep={50} outstep={190} minValue={50} maxValue={500} setAnnouncement={setIsResizingSidebar} /> )} + {!oldSidebar && !isMobile && } ); } diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index 8df6b14ace..d01bff279e 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -28,6 +28,8 @@ import { SPACE_ROOM_PATH, SPACE_SEARCH_PATH, CREATE_PATH, + NAVIGATE_PATH, + PROFILE_PATH, INBOX_BOOKMARKS_PATH, } from './paths'; @@ -155,6 +157,8 @@ export const getExploreServerPath = (server: string): string => { }; export const getCreatePath = (): string => CREATE_PATH; +export const getNavigatePath = (): string => NAVIGATE_PATH; +export const getProfilePath = (): string => PROFILE_PATH; export const getInboxPath = (): string => INBOX_PATH; export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH; diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 2770cd3ee5..05917aa2c7 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -80,6 +80,8 @@ export type ExploreServerPathSearchParams = { export const EXPLORE_SERVER_PATH = `/explore/${SERVER_PATH_SEGMENT}`; export const CREATE_PATH = '/create'; +export const NAVIGATE_PATH = '/navigate'; +export const PROFILE_PATH = '/profile/'; export const NOTIFICATIONS_PATH_SEGMENT = 'notifications/'; export const INVITES_PATH_SEGMENT = 'invites/'; diff --git a/src/app/state/room/lastSpace.ts b/src/app/state/room/lastSpace.ts new file mode 100644 index 0000000000..daefc63bce --- /dev/null +++ b/src/app/state/room/lastSpace.ts @@ -0,0 +1,5 @@ +import { atom } from 'jotai'; + +// It is not particularly accurate and shouldn't be used for much else +// unless you plan major refractors +export const lastVisitedSpaceIdAtom = atom(undefined); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index f3ad673157..88ee2ed0d1 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -359,7 +359,7 @@ export const defaultSettings: Settings = { saveStickerEmojiBandwidth: false, subspaceHierarchyLimit: 3, alwaysShowCallButton: false, - joinCallOnSingleClick: true, + joinCallOnSingleClick: false, incomingCallSoundEnabled: true, incomingVoiceRoomCallSoundEnabled: false, outgoingRingbackEnabled: true,