From ed68922d2cde6572961fe71c6896ffff33053d42 Mon Sep 17 00:00:00 2001 From: niki <295591807+nikiwastaken@users.noreply.github.com> Date: Wed, 24 Jun 2026 06:30:19 +0000 Subject: [PATCH 01/16] Redesign the user menu tab --- src/app/components/user-profile/UserHero.tsx | 3 +++ src/app/pages/client/sidebar/UserMenuTab.tsx | 1 + 2 files changed, 4 insertions(+) diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index da6f57fab..29c8de992 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -238,7 +238,10 @@ export function UserHero({ WebkitBoxOrient: 'vertical', overflow: 'hidden', fontStyle: allowEditing && !status ? 'italic' : 'normal', +<<<<<<< HEAD opacity: allowEditing && !status ? config.opacity.Placeholder : 1, +======= +>>>>>>> f873d334 (Redesign the user menu tab) }} > {status || (allowEditing && "What's on your mind?")} diff --git a/src/app/pages/client/sidebar/UserMenuTab.tsx b/src/app/pages/client/sidebar/UserMenuTab.tsx index efbb30283..39a3ab102 100644 --- a/src/app/pages/client/sidebar/UserMenuTab.tsx +++ b/src/app/pages/client/sidebar/UserMenuTab.tsx @@ -2,6 +2,7 @@ import type { MouseEventHandler } from 'react'; import { useCallback, useEffect, useState } from 'react'; import type { RectCords } from 'folds'; import { + Badge, Box, Button, Chip, From 2ff14d8f38879627f7cf78bad7e21b79fae91492 Mon Sep 17 00:00:00 2001 From: Shea Date: Fri, 26 Jun 2026 10:33:10 +0300 Subject: [PATCH 02/16] remove automatic status mode Signed-off-by: Shea --- src/app/pages/client/sidebar/UserMenuTab.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/pages/client/sidebar/UserMenuTab.tsx b/src/app/pages/client/sidebar/UserMenuTab.tsx index 39a3ab102..efbb30283 100644 --- a/src/app/pages/client/sidebar/UserMenuTab.tsx +++ b/src/app/pages/client/sidebar/UserMenuTab.tsx @@ -2,7 +2,6 @@ import type { MouseEventHandler } from 'react'; import { useCallback, useEffect, useState } from 'react'; import type { RectCords } from 'folds'; import { - Badge, Box, Button, Chip, From 34869f1c153870dcd6b62392da0df17e29423277 Mon Sep 17 00:00:00 2001 From: Shea Date: Sat, 27 Jun 2026 18:18:22 +0300 Subject: [PATCH 03/16] change modal styling and bar Signed-off-by: Shea --- .../message/modals/GlobalModalManager.tsx | 6 ++- src/app/features/search/Search.tsx | 54 +++++++++++-------- src/app/pages/client/SidebarNav.tsx | 1 - src/app/pages/client/sidebar/MessageTab.tsx | 31 +++++++++++ .../pages/client/sidebar/UserQuickTools.tsx | 42 +++++++++------ src/app/state/settings.ts | 2 +- 6 files changed, 97 insertions(+), 39 deletions(-) create mode 100644 src/app/pages/client/sidebar/MessageTab.tsx diff --git a/src/app/components/message/modals/GlobalModalManager.tsx b/src/app/components/message/modals/GlobalModalManager.tsx index be48d49be..a78506cad 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) => { + e.preventDefault(); + close(); + return false; + }, escapeDeactivates: stopPropagation, }} > diff --git a/src/app/features/search/Search.tsx b/src/app/features/search/Search.tsx index c6acf0d2a..51b05f186 100644 --- a/src/app/features/search/Search.tsx +++ b/src/app/features/search/Search.tsx @@ -3,6 +3,7 @@ import { Avatar, Box, config, + Header, IconButton, Input, Line, @@ -50,6 +51,7 @@ import { KeySymbol } from '$utils/key-symbol'; import { isMacOS } from '$utils/user-agent'; import { useSelectedSpace } from '$hooks/router/useSelectedSpace'; import { getMxIdServer } from '$utils/mxIdHelper'; +import { useScreenSizeContext, ScreenSize } from '$hooks/useScreenSize'; enum SearchRoomType { Rooms = '#', @@ -133,6 +135,7 @@ export type RoomSearchPickRoomConfig = { export type RoomSearchModalProps = { requestClose: () => void; pickRoom?: RoomSearchPickRoomConfig; + headerText?: string; }; export function RoomSearchModal({ requestClose, pickRoom }: RoomSearchModalProps) { @@ -274,6 +277,9 @@ export function RoomSearchModal({ requestClose, pickRoom }: RoomSearchModalProps } }, [listFocus.index]); + const screenSize = useScreenSizeContext(); + const isMobile = screenSize === ScreenSize.Mobile; + return ( @@ -285,34 +291,40 @@ export function RoomSearchModal({ requestClose, pickRoom }: RoomSearchModalProps clickOutsideDeactivates: true, onDeactivate: requestClose, escapeDeactivates: (evt: KeyboardEvent) => { - evt.stopPropagation(); + if (!isMobile) evt.stopPropagation(); return true; }, }} > - - {pickRoom && ( - - {pickRoom.title} - + +
+ + {pickRoom ? pickRoom.title : 'Search Message'} + + {composerIcon(X)} - )} +
{pickRoom?.errorMessage ? ( diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx index d92ff4690..b82db3930 100644 --- a/src/app/pages/client/SidebarNav.tsx +++ b/src/app/pages/client/SidebarNav.tsx @@ -144,7 +144,6 @@ export function SidebarNav() {
- {/*PROBS ADD SETTINGSTAB HERE WHEN ADDING THE STATUSES*/}
diff --git a/src/app/pages/client/sidebar/MessageTab.tsx b/src/app/pages/client/sidebar/MessageTab.tsx new file mode 100644 index 000000000..7484187ee --- /dev/null +++ b/src/app/pages/client/sidebar/MessageTab.tsx @@ -0,0 +1,31 @@ +import { SidebarAvatar, SidebarItem, SidebarItemTooltip } 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 } from 'jotai'; +import { searchModalAtom } from '$state/searchModal'; +import { useInboxSelected } from '$hooks/router/useInbox'; + +export function MessageTab({ isBottom }: { isBottom?: boolean }) { + const navigate = useNavigate(); + const [searchSelected] = useAtom(searchModalAtom); + const inboxSelected = useInboxSelected(); + const opened = !(matchPath(SETTINGS_PATH, location.pathname) || searchSelected || inboxSelected); + const openSettings = () => navigate(HOME_PATH); + + return ( + + + {(triggerRef) => ( + + + + )} + + + ); +} diff --git a/src/app/pages/client/sidebar/UserQuickTools.tsx b/src/app/pages/client/sidebar/UserQuickTools.tsx index a6f96dcfc..bebbc3cab 100644 --- a/src/app/pages/client/sidebar/UserQuickTools.tsx +++ b/src/app/pages/client/sidebar/UserQuickTools.tsx @@ -7,6 +7,7 @@ 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, @@ -28,7 +29,7 @@ export function UserQuickTools({
- - - {!isCollapsed && ( - <> - - - - - )} - + {compact ? ( + <> + + + + + + ) : ( + <> + + + {!isCollapsed && ( + <> + + + + + )} + + + )}
)} diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 22445a35b..d1294b49d 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -344,7 +344,7 @@ export const defaultSettings: Settings = { saveStickerEmojiBandwidth: false, subspaceHierarchyLimit: 3, alwaysShowCallButton: false, - joinCallOnSingleClick: true, + joinCallOnSingleClick: false, faviconForMentionsOnly: false, highlightMentions: true, pkCompat: false, From e4be6ce343b4fb594f3cde43abfb96cbee383f9e Mon Sep 17 00:00:00 2001 From: Shea Date: Sun, 28 Jun 2026 16:59:05 +0300 Subject: [PATCH 04/16] style bottom bar and add navigation route Signed-off-by: Shea --- src/app/components/UserQuickToolsProvider.tsx | 9 + .../message/modals/MessageForward.tsx | 4 +- src/app/components/sidebar/Sidebar.css.ts | 2 +- src/app/components/user-profile/styles.css.ts | 1 - src/app/features/room/RoomTimeline.css.ts | 2 + src/app/features/search/Search.tsx | 486 +++++++++--------- src/app/hooks/router/useNavigateSelected.ts | 12 + src/app/pages/Router.tsx | 7 + src/app/pages/client/SidebarNav.tsx | 47 +- src/app/pages/client/navigate/Navigate.tsx | 91 ++++ src/app/pages/client/navigate/index.ts | 1 + src/app/pages/client/sidebar/InboxTab.tsx | 40 +- src/app/pages/client/sidebar/MessageTab.tsx | 39 +- src/app/pages/client/sidebar/NavigateTab.tsx | 49 ++ src/app/pages/client/sidebar/SearchTab.tsx | 26 - src/app/pages/client/sidebar/SettingsTab.tsx | 4 +- src/app/pages/client/sidebar/UserMenuTab.tsx | 47 +- .../client/sidebar/UserQuickTools.css.ts | 6 +- .../pages/client/sidebar/UserQuickTools.tsx | 43 +- src/app/pages/pathUtils.ts | 2 + src/app/pages/paths.ts | 1 + 21 files changed, 562 insertions(+), 357 deletions(-) create mode 100644 src/app/components/UserQuickToolsProvider.tsx create mode 100644 src/app/hooks/router/useNavigateSelected.ts create mode 100644 src/app/pages/client/navigate/Navigate.tsx create mode 100644 src/app/pages/client/navigate/index.ts create mode 100644 src/app/pages/client/sidebar/NavigateTab.tsx delete mode 100644 src/app/pages/client/sidebar/SearchTab.tsx diff --git a/src/app/components/UserQuickToolsProvider.tsx b/src/app/components/UserQuickToolsProvider.tsx new file mode 100644 index 000000000..23cf8c71d --- /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/MessageForward.tsx b/src/app/components/message/modals/MessageForward.tsx index 946f764a0..1bdcdebdd 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/search'; 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/sidebar/Sidebar.css.ts b/src/app/components/sidebar/Sidebar.css.ts index f97c0f9fc..db345edaa 100644 --- a/src/app/components/sidebar/Sidebar.css.ts +++ b/src/app/components/sidebar/Sidebar.css.ts @@ -132,7 +132,7 @@ export const SidebarItemBottom = recipe({ selectors: { '&:hover': { - transform: `translateY(${toRem(PUSH_Y)})`, + transform: `translateY(${toRem(-PUSH_Y)})`, }, '&::before': { content: '', diff --git a/src/app/components/user-profile/styles.css.ts b/src/app/components/user-profile/styles.css.ts index e7283d43b..133f0b7f8 100644 --- a/src/app/components/user-profile/styles.css.ts +++ b/src/app/components/user-profile/styles.css.ts @@ -44,7 +44,6 @@ export const UserAvatarContainer = style({ position: 'relative', top: 0, transform: 'translateY(-50%)', - backgroundColor: color.Surface.Container, }); export const UserHeroStatusContainer = style({ position: 'relative', diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts index 9903033dc..4236685e9 100644 --- a/src/app/features/room/RoomTimeline.css.ts +++ b/src/app/features/room/RoomTimeline.css.ts @@ -12,6 +12,8 @@ export const TimelineFloat = recipe({ transform: 'translateX(-50%)', zIndex: 10, minWidth: 'max-content', + overflow: 'hidden', + borderRadius: config.radii.Pill, }, ], variants: { diff --git a/src/app/features/search/Search.tsx b/src/app/features/search/Search.tsx index 51b05f186..ccc083047 100644 --- a/src/app/features/search/Search.tsx +++ b/src/app/features/search/Search.tsx @@ -3,7 +3,6 @@ import { Avatar, Box, config, - Header, IconButton, Input, Line, @@ -51,7 +50,6 @@ import { KeySymbol } from '$utils/key-symbol'; import { isMacOS } from '$utils/user-agent'; import { useSelectedSpace } from '$hooks/router/useSelectedSpace'; import { getMxIdServer } from '$utils/mxIdHelper'; -import { useScreenSizeContext, ScreenSize } from '$hooks/useScreenSize'; enum SearchRoomType { Rooms = '#', @@ -133,16 +131,14 @@ export type RoomSearchPickRoomConfig = { }; export type RoomSearchModalProps = { - requestClose: () => void; + requestClose?: () => void; pickRoom?: RoomSearchPickRoomConfig; - headerText?: string; }; 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); @@ -217,7 +213,7 @@ export function RoomSearchModal({ requestClose, pickRoom }: RoomSearchModalProps } if (isSpace) navigateSpace(roomId); else navigateRoom(roomId); - requestClose(); + requestClose?.(); }; const handleInputChange: ChangeEventHandler = (evt) => { @@ -277,252 +273,227 @@ export function RoomSearchModal({ requestClose, pickRoom }: RoomSearchModalProps } }, [listFocus.index]); - const screenSize = useScreenSizeContext(); - const isMobile = screenSize === ScreenSize.Mobile; - return ( - - - inputRef.current, - returnFocusOnDeactivate: true, - allowOutsideClick: true, - clickOutsideDeactivates: true, - onDeactivate: requestClose, - escapeDeactivates: (evt: KeyboardEvent) => { - if (!isMobile) 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 && ( + +
- - {pickRoom ? pickRoom.title : 'Search Message'} - - - {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} + {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)} )} - - - ); - })} -
-
- )} -
- - - - {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 - - )} - - -
- -
-
+ /> + ) : ( + + )} + + } + > + + + {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 + + )} + + + ); } @@ -551,9 +522,34 @@ export function SearchModalRenderer() { ) ); - return opened && setOpen(false)} />; + return opened && setOpen(false)} />; +} + +export function SearchWrapper({ requestClose, pickRoom }: RoomSearchModalProps) { + return ( + + + { + evt.stopPropagation(); + return true; + }, + }} + > + + + + + + + ); } export function Search(props: { requestClose: () => void }) { - return ; + return ; } diff --git a/src/app/hooks/router/useNavigateSelected.ts b/src/app/hooks/router/useNavigateSelected.ts new file mode 100644 index 000000000..81e2168b9 --- /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/pages/Router.tsx b/src/app/pages/Router.tsx index 56c24a899..33b55ee56 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -53,6 +53,7 @@ import { CREATE_PATH, TO_ROOM_EVENT_PATH, SETTINGS_PATH, + NAVIGATE_PATH, } from './paths'; import { getAppPathFromHref, @@ -81,6 +82,8 @@ 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'; /** * Returns true if there is at least one stored session. @@ -191,6 +194,9 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) + + + @@ -346,6 +352,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } /> } /> + } /> } /> } sticky={ - - - {oldSidebar ? ( - <> - - -
- -
- - ) : ( - <> - {isCollapsed && ( + <> + {(oldSidebar || isCollapsed) && ( + + + {oldSidebar ? ( <> - + + + + ) : ( + <> + )} - - - - - + + )} + {!compact && ( +
+ +
)} -
+ } /> - {!oldSidebar && } + {!oldSidebar && !compact && } ); } diff --git a/src/app/pages/client/navigate/Navigate.tsx b/src/app/pages/client/navigate/Navigate.tsx new file mode 100644 index 000000000..39572b51b --- /dev/null +++ b/src/app/pages/client/navigate/Navigate.tsx @@ -0,0 +1,91 @@ +import { Box, Scroll, toRem, Text, color, config } from 'folds'; +import { SquaresFour, sizedIcon } from '$components/icons/phosphor'; +import { + Page, + PageContent, + PageContentCenter, + PageHeroSection, + 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 { ListMagnifyingGlassIcon } from '@phosphor-icons/react'; +import { RoomSearchModal } from '$features/search'; + +export function Navigate() { + 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; + + return ( + <> + {!isMobile && ( + + + + + {!hideText ? ( + + + Navigate + + + ) : ( + sizedIcon(SquaresFour, '200', { filled: true }) + )} + + + + + + )} + + + + + + + + {sizedIcon(ListMagnifyingGlassIcon, '600')} + + + + + + + + + + ); +} diff --git a/src/app/pages/client/navigate/index.ts b/src/app/pages/client/navigate/index.ts new file mode 100644 index 000000000..7571cfdec --- /dev/null +++ b/src/app/pages/client/navigate/index.ts @@ -0,0 +1 @@ +export * from './Navigate'; diff --git a/src/app/pages/client/sidebar/InboxTab.tsx b/src/app/pages/client/sidebar/InboxTab.tsx index fe3f636e9..8141fb759 100644 --- a/src/app/pages/client/sidebar/InboxTab.tsx +++ b/src/app/pages/client/sidebar/InboxTab.tsx @@ -17,13 +17,17 @@ import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useNavToActivePathAtom } from '$state/hooks/navToActivePath'; import { useInviteCount } from '$hooks/useInviteCount'; import { getPhosphorIconSize, Tray } from '$components/icons/phosphor'; +import { Text, Box, color } from 'folds'; +import { searchModalAtom } from '$state/searchModal'; -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 handleInboxClick = () => { if (screenSize === ScreenSize.Mobile) { @@ -41,21 +45,29 @@ export function InboxTab({ isBottom }: { isBottom?: boolean }) { }; return ( - + {(triggerRef) => ( - - - + + + + + {isMobile && ( + + Inbox + + )} + )} {inviteCount > 0 && } diff --git a/src/app/pages/client/sidebar/MessageTab.tsx b/src/app/pages/client/sidebar/MessageTab.tsx index 7484187ee..f743ded32 100644 --- a/src/app/pages/client/sidebar/MessageTab.tsx +++ b/src/app/pages/client/sidebar/MessageTab.tsx @@ -6,24 +6,47 @@ import { ChatTextIcon } from '@phosphor-icons/react'; import { useAtom } from 'jotai'; import { searchModalAtom } from '$state/searchModal'; import { useInboxSelected } from '$hooks/router/useInbox'; +import { Box, color, Text } from 'folds'; +import { useNavigateSelected } from '$hooks/router/useNavigateSelected'; -export function MessageTab({ isBottom }: { isBottom?: boolean }) { +export function MessageTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile?: boolean }) { const navigate = useNavigate(); const [searchSelected] = useAtom(searchModalAtom); + const navigateRouteActive = useNavigateSelected(); const inboxSelected = useInboxSelected(); - const opened = !(matchPath(SETTINGS_PATH, location.pathname) || searchSelected || inboxSelected); + const opened = !( + matchPath(SETTINGS_PATH, location.pathname) || + searchSelected || + navigateRouteActive || + inboxSelected + ); const openSettings = () => navigate(HOME_PATH); return ( {(triggerRef) => ( - - - + + + + + {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 000000000..e6e717958 --- /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 0a8045860..000000000 --- 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 37372e2eb..7ca47d18a 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/UserMenuTab.tsx b/src/app/pages/client/sidebar/UserMenuTab.tsx index efbb30283..a94cdded0 100644 --- a/src/app/pages/client/sidebar/UserMenuTab.tsx +++ b/src/app/pages/client/sidebar/UserMenuTab.tsx @@ -525,7 +525,7 @@ export function PresenceMenuOption() { ); } -export function UserMenuTab({ isBottom }: { isBottom?: boolean }) { +export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile?: boolean }) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); @@ -552,7 +552,7 @@ export function UserMenuTab({ isBottom }: { isBottom?: boolean }) { ? (mxcUrlToHttp(mx, parsedBanner, useAuthentication, 640, 192, 'scale') ?? undefined) : undefined; - const handleToggle: MouseEventHandler = (evt) => { + const handleToggle: MouseEventHandler = (evt) => { const cords = evt.currentTarget.getBoundingClientRect(); setMenuAnchor((cur) => (cur ? undefined : cords)); }; @@ -561,21 +561,38 @@ export function UserMenuTab({ isBottom }: { isBottom?: boolean }) { return ( - + {(triggerRef) => ( - } - > - - {nameInitials(displayName)}} - /> + + + } + > + + {nameInitials(displayName)}} + /> + + - + {isMobile && ( + + Account + + )} + )} diff --git a/src/app/pages/client/sidebar/UserQuickTools.css.ts b/src/app/pages/client/sidebar/UserQuickTools.css.ts index 8cd733038..0e194fd8d 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, 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 bebbc3cab..0792f0dd4 100644 --- a/src/app/pages/client/sidebar/UserQuickTools.tsx +++ b/src/app/pages/client/sidebar/UserQuickTools.tsx @@ -1,26 +1,24 @@ import { Box, config, toRem } from 'folds'; import { InboxTab } from './InboxTab'; -import { SearchTab } from './SearchTab'; +import { NavigateTab } from './NavigateTab'; import { SettingsTab } from './SettingsTab'; 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 ( <> @@ -32,19 +30,28 @@ export function UserQuickTools({ justifyContent={compact ? 'SpaceAround' : 'SpaceBetween'} alignItems="Center" className={css.UserQuickTools} - style={{ - opacity: isResizingSidebar ? '0%' : '100%', - transition: isResizingSidebar ? 'opacity 0.2s ease' : 'opacity 0.5s ease', - width: compact ? '100vw' : toRem(width), - paddingRight: config.space.S300, - }} + style={ + compact + ? { + borderTopLeftRadius: config.radii.R500, + borderTopRightRadius: config.radii.R500, + width: '100vw', + } + : { + opacity: isResizingSidebar ? '0%' : '100%', + transition: isResizingSidebar ? 'opacity 0.2s ease' : 'opacity 0.5s ease', + width: toRem(width ?? 100), + position: 'absolute', + left: toRem(-66), + } + } > {compact ? ( <> - - - - + + + + ) : ( <> @@ -57,7 +64,7 @@ export function UserQuickTools({ {!isCollapsed && ( <> - + )} diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index 4a95f47fc..35a248c2e 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -28,6 +28,7 @@ import { SPACE_ROOM_PATH, SPACE_SEARCH_PATH, CREATE_PATH, + NAVIGATE_PATH, } from './paths'; export const joinPathComponent = (path: Path): string => path.pathname + path.search + path.hash; @@ -154,6 +155,7 @@ export const getExploreServerPath = (server: string): string => { }; export const getCreatePath = (): string => CREATE_PATH; +export const getNavigatePath = (): string => NAVIGATE_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 1ac57b756..43921ea4a 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -79,6 +79,7 @@ export type ExploreServerPathSearchParams = { export const EXPLORE_SERVER_PATH = `/explore/${SERVER_PATH_SEGMENT}`; export const CREATE_PATH = '/create'; +export const NAVIGATE_PATH = '/navigate'; export const NOTIFICATIONS_PATH_SEGMENT = 'notifications/'; export const INVITES_PATH_SEGMENT = 'invites/'; From ba96581826b6ba256a928d5790cdeeb72e94a781 Mon Sep 17 00:00:00 2001 From: Shea Date: Mon, 29 Jun 2026 18:17:22 +0300 Subject: [PATCH 05/16] base settings Signed-off-by: Shea --- .../message/modals/MessageForward.tsx | 2 +- src/app/components/user-profile/styles.css.ts | 1 + .../Search.tsx => navigate/NavigateModal.tsx} | 17 +--- src/app/features/navigate/index.ts | 1 + src/app/features/room/RoomTimeline.css.ts | 2 - src/app/features/search/index.ts | 1 - src/app/hooks/router/useProfileSelected.ts | 12 +++ src/app/pages/Router.tsx | 5 +- src/app/pages/client/navigate/Navigate.tsx | 2 +- src/app/pages/client/profile/Profile.tsx | 96 +++++++++++++++++++ src/app/pages/client/profile/index.ts | 1 + src/app/pages/client/sidebar/MessageTab.tsx | 3 + src/app/pages/client/sidebar/UserMenuTab.tsx | 12 ++- src/app/pages/pathUtils.ts | 2 + src/app/pages/paths.ts | 1 + 15 files changed, 138 insertions(+), 20 deletions(-) rename src/app/features/{search/Search.tsx => navigate/NavigateModal.tsx} (98%) create mode 100644 src/app/features/navigate/index.ts delete mode 100644 src/app/features/search/index.ts create mode 100644 src/app/hooks/router/useProfileSelected.ts create mode 100644 src/app/pages/client/profile/Profile.tsx create mode 100644 src/app/pages/client/profile/index.ts diff --git a/src/app/components/message/modals/MessageForward.tsx b/src/app/components/message/modals/MessageForward.tsx index 1bdcdebdd..aef10a95c 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 { SearchWrapper } from '$features/search'; +import { SearchWrapper } from '$features/navigate'; const debugLog = createDebugLogger('MessageForward'); // Message forwarding component diff --git a/src/app/components/user-profile/styles.css.ts b/src/app/components/user-profile/styles.css.ts index 133f0b7f8..e7283d43b 100644 --- a/src/app/components/user-profile/styles.css.ts +++ b/src/app/components/user-profile/styles.css.ts @@ -44,6 +44,7 @@ export const UserAvatarContainer = style({ position: 'relative', top: 0, transform: 'translateY(-50%)', + backgroundColor: color.Surface.Container, }); export const UserHeroStatusContainer = style({ position: 'relative', diff --git a/src/app/features/search/Search.tsx b/src/app/features/navigate/NavigateModal.tsx similarity index 98% rename from src/app/features/search/Search.tsx rename to src/app/features/navigate/NavigateModal.tsx index ccc083047..8da2f7a3d 100644 --- a/src/app/features/search/Search.tsx +++ b/src/app/features/navigate/NavigateModal.tsx @@ -274,7 +274,7 @@ export function RoomSearchModal({ requestClose, pickRoom }: RoomSearchModalProps }, [listFocus.index]); return ( - + {pickRoom && ( {roomsToRender.length === 0 && ( { 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); + setOpen(!opened); + return; } }, [opened, setOpen] diff --git a/src/app/features/navigate/index.ts b/src/app/features/navigate/index.ts new file mode 100644 index 000000000..d72d0d057 --- /dev/null +++ b/src/app/features/navigate/index.ts @@ -0,0 +1 @@ +export * from './NavigateModal'; diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts index 4236685e9..9903033dc 100644 --- a/src/app/features/room/RoomTimeline.css.ts +++ b/src/app/features/room/RoomTimeline.css.ts @@ -12,8 +12,6 @@ export const TimelineFloat = recipe({ transform: 'translateX(-50%)', zIndex: 10, minWidth: 'max-content', - overflow: 'hidden', - borderRadius: config.radii.Pill, }, ], variants: { diff --git a/src/app/features/search/index.ts b/src/app/features/search/index.ts deleted file mode 100644 index addd53308..000000000 --- a/src/app/features/search/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Search'; diff --git a/src/app/hooks/router/useProfileSelected.ts b/src/app/hooks/router/useProfileSelected.ts new file mode 100644 index 000000000..9a7abdde2 --- /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/Router.tsx b/src/app/pages/Router.tsx index 33b55ee56..8cf657b9e 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'; @@ -54,6 +54,7 @@ import { TO_ROOM_EVENT_PATH, SETTINGS_PATH, NAVIGATE_PATH, + PROFILE_PATH, } from './paths'; import { getAppPathFromHref, @@ -84,6 +85,7 @@ 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. @@ -353,6 +355,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
} /> } /> + } /> } /> { + setCurWidth(roomSidebarWidth); + }, [roomSidebarWidth]); + const screenSize = useScreenSizeContext(); + const isMobile = screenSize === ScreenSize.Mobile; + const hideText = curWidth <= 80 && !isMobile; + + return ( + <> + {!isMobile && ( + + + + + {!hideText ? ( + + + Navigate + + + ) : ( + sizedIcon(SquaresFour, '200', { filled: true }) + )} + + + + + + )} + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/app/pages/client/profile/index.ts b/src/app/pages/client/profile/index.ts new file mode 100644 index 000000000..963df00a6 --- /dev/null +++ b/src/app/pages/client/profile/index.ts @@ -0,0 +1 @@ +export * from './Profile'; diff --git a/src/app/pages/client/sidebar/MessageTab.tsx b/src/app/pages/client/sidebar/MessageTab.tsx index f743ded32..212d8b48a 100644 --- a/src/app/pages/client/sidebar/MessageTab.tsx +++ b/src/app/pages/client/sidebar/MessageTab.tsx @@ -8,16 +8,19 @@ import { searchModalAtom } from '$state/searchModal'; import { useInboxSelected } from '$hooks/router/useInbox'; import { Box, color, Text } from 'folds'; import { useNavigateSelected } from '$hooks/router/useNavigateSelected'; +import { useProfileSelected } from '$hooks/router/useProfileSelected'; export function MessageTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile?: boolean }) { const navigate = useNavigate(); 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 openSettings = () => navigate(HOME_PATH); diff --git a/src/app/pages/client/sidebar/UserMenuTab.tsx b/src/app/pages/client/sidebar/UserMenuTab.tsx index a94cdded0..f926340aa 100644 --- a/src/app/pages/client/sidebar/UserMenuTab.tsx +++ b/src/app/pages/client/sidebar/UserMenuTab.tsx @@ -39,7 +39,7 @@ 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 { 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'; @@ -48,6 +48,7 @@ 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'; const log = createLogger('AccountSwitcherTab'); @@ -528,6 +529,8 @@ export function PresenceMenuOption() { export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile?: boolean }) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); + const profileSelected = useProfileSelected(); + const navigate = useNavigate(); const userId = mx.getUserId() ?? ''; const profile = useUserProfile(userId); @@ -553,6 +556,11 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi : undefined; const handleToggle: MouseEventHandler = (evt) => { + if(isMobile){ + navigate(getProfilePath()); + return; + } + const cords = evt.currentTarget.getBoundingClientRect(); setMenuAnchor((cur) => (cur ? undefined : cords)); }; @@ -560,7 +568,7 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi const handleCloseMenu = () => setMenuAnchor(undefined); return ( - + path.pathname + path.search + path.hash; @@ -156,6 +157,7 @@ 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 43921ea4a..2fd16b54f 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -80,6 +80,7 @@ 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/'; From 163575d78f390e79679844854b805fb36eced2ce Mon Sep 17 00:00:00 2001 From: Shea Date: Tue, 30 Jun 2026 22:45:26 +0300 Subject: [PATCH 06/16] sth Signed-off-by: Shea --- src/app/components/user-profile/UserHero.tsx | 3 - src/app/pages/MobileFriendly.tsx | 36 +++++++- src/app/pages/Router.tsx | 14 ++-- src/app/pages/client/SidebarNav.tsx | 6 +- src/app/pages/client/create/Create.tsx | 3 + src/app/pages/client/direct/Direct.tsx | 3 + src/app/pages/client/explore/Explore.tsx | 3 + src/app/pages/client/home/Home.tsx | 3 + src/app/pages/client/inbox/Inbox.tsx | 3 + src/app/pages/client/profile/Profile.tsx | 83 ++++++++++++++----- src/app/pages/client/sidebar/UserMenuTab.tsx | 10 +-- .../pages/client/sidebar/UserQuickTools.tsx | 7 +- src/app/pages/client/space/Space.tsx | 4 + src/app/pages/paths.ts | 2 +- 14 files changed, 128 insertions(+), 52 deletions(-) diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 29c8de992..da6f57fab 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -238,10 +238,7 @@ export function UserHero({ WebkitBoxOrient: 'vertical', overflow: 'hidden', fontStyle: allowEditing && !status ? 'italic' : 'normal', -<<<<<<< HEAD opacity: allowEditing && !status ? config.opacity.Placeholder : 1, -======= ->>>>>>> f873d334 (Redesign the user menu tab) }} > {status || (allowEditing && "What's on your mind?")} diff --git a/src/app/pages/MobileFriendly.tsx b/src/app/pages/MobileFriendly.tsx index 83009cda5..b791bf906 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 8cf657b9e..1605b24ec 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -75,7 +75,11 @@ import { Notifications, Inbox, Invites } 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'; @@ -187,18 +191,18 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) + - + } > - + - + diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx index 846499e5e..de4ea1628 100644 --- a/src/app/pages/client/SidebarNav.tsx +++ b/src/app/pages/client/SidebarNav.tsx @@ -10,7 +10,6 @@ import { DirectTab, DirectDMsList, HomeTab, SpaceTabs, InboxTab, UnverifiedTab } import { CreateTab } from './sidebar/CreateTab'; 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; @@ -169,7 +166,6 @@ export function SidebarNav() { } /> - {!oldSidebar && !compact && } ); } diff --git a/src/app/pages/client/create/Create.tsx b/src/app/pages/client/create/Create.tsx index d22ba50c8..c9a77e7f9 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 ( <> @@ -71,6 +73,7 @@ export function Create() { setAnnouncement={setIsResizingSidebar} /> + {!oldSidebar && !isMobile && } )} diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index c9a3bffdf..bc3bf0ed3 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 60796783a..c78aec24f 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 3b2da2945..c32f6766d 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 7dca68e16..e0d809eda 100644 --- a/src/app/pages/client/inbox/Inbox.tsx +++ b/src/app/pages/client/inbox/Inbox.tsx @@ -14,6 +14,7 @@ import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useInviteCount } from '$hooks/useInviteCount'; import { isResizingSidebarAtom } from '$state/isResizingSidebar'; import { useSetAtom } from 'jotai'; +import { UserQuickTools } from '../sidebar/UserQuickTools'; function InvitesNavItem({ hideText }: { hideText?: boolean }) { const invitesSelected = useInboxInvitesSelected(); @@ -58,6 +59,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); @@ -131,6 +133,7 @@ export function Inbox() { setAnnouncement={setIsResizingSidebar} /> )} + {!oldSidebar && !isMobile && } ); } diff --git a/src/app/pages/client/profile/Profile.tsx b/src/app/pages/client/profile/Profile.tsx index 200195fe4..11f7b8a49 100644 --- a/src/app/pages/client/profile/Profile.tsx +++ b/src/app/pages/client/profile/Profile.tsx @@ -1,13 +1,6 @@ -import { Box, Scroll, toRem, Text, color, config } from 'folds'; +import { Box, Scroll, toRem, Text, color, config, Menu, Icon, Icons, Line, MenuItem } from 'folds'; import { SquaresFour, sizedIcon } from '$components/icons/phosphor'; -import { - Page, - PageContent, - PageContentCenter, - PageHeroSection, - PageNav, - PageNavHeader, -} from '$components/page'; +import { Page, PageHeroSection, PageNav, PageNavHeader } from '$components/page'; import { useEffect, useState } from 'react'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useSetting } from '$state/hooks/settings'; @@ -16,13 +9,33 @@ import { SidebarResizer } from '../sidebar/SidebarResizer'; import { useSetAtom } from 'jotai'; import { isResizingSidebarAtom } from '$state/isResizingSidebar'; import { AccountMenuOption, PresenceMenuOption } from '../sidebar/UserMenuTab'; -import { UserRoomProfile } from '$components/user-profile'; import { useMatrixClient } from '$hooks/useMatrixClient'; -import { UserRoomProfileRenderer } from '$components/UserRoomProfileRenderer'; +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 { useOpenSettings } from '$features/settings'; export function ProfileMobile() { const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const openSettings = useOpenSettings(); + const userId = mx.getUserId() ?? ''; + const profile = useUserProfile(userId); + const presence = useUserPresence(userId); + + 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'); @@ -54,7 +67,7 @@ export function ProfileMobile() { {!hideText ? ( - Navigate + Profile ) : ( @@ -76,18 +89,42 @@ export function ProfileMobile() { )} - - - - - - - - + + + + + + + + + - - - + + + + + + + } + onClick={() => openSettings()} + > + + Settings + + + + + diff --git a/src/app/pages/client/sidebar/UserMenuTab.tsx b/src/app/pages/client/sidebar/UserMenuTab.tsx index f926340aa..4b656b9c7 100644 --- a/src/app/pages/client/sidebar/UserMenuTab.tsx +++ b/src/app/pages/client/sidebar/UserMenuTab.tsx @@ -378,7 +378,7 @@ const PresenceOptions: Array<{ value: Presence; label: string }> = [ { value: Presence.Offline, label: 'Offline' }, ]; -export function PresenceMenuOption() { +export function PresenceMenuOption({ initialOpen }: { initialOpen: boolean }) { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); @@ -388,7 +388,7 @@ export function PresenceMenuOption() { const isMobile = screenSize === ScreenSize.Mobile; const currentPresence = presence?.presence ?? Presence.Online; - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(initialOpen); const { hoverProps } = useHover({ onHoverChange: (h) => { if (!isMobile) setIsOpen(h); @@ -556,11 +556,11 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi : undefined; const handleToggle: MouseEventHandler = (evt) => { - if(isMobile){ + if (isMobile) { navigate(getProfilePath()); return; } - + const cords = evt.currentTarget.getBoundingClientRect(); setMenuAnchor((cur) => (cur ? undefined : cords)); }; @@ -651,7 +651,7 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi - + diff --git a/src/app/pages/client/sidebar/UserQuickTools.tsx b/src/app/pages/client/sidebar/UserQuickTools.tsx index 0792f0dd4..e65799d61 100644 --- a/src/app/pages/client/sidebar/UserQuickTools.tsx +++ b/src/app/pages/client/sidebar/UserQuickTools.tsx @@ -2,8 +2,6 @@ import { Box, config, toRem } from 'folds'; import { InboxTab } from './InboxTab'; import { NavigateTab } from './NavigateTab'; import { SettingsTab } from './SettingsTab'; -import { useAtom } from 'jotai'; -import { isResizingSidebarAtom } from '$state/isResizingSidebar'; import * as css from './UserQuickTools.css'; import { UserMenuTab } from './UserMenuTab'; import { MessageTab } from './MessageTab'; @@ -17,7 +15,6 @@ export function UserQuickTools({ width?: number; compact: boolean; }) { - const [isResizingSidebar] = useAtom(isResizingSidebarAtom); const isCollapsed = compact ? false : (width ?? 0) < 190 + 66; return ( @@ -38,11 +35,9 @@ export function UserQuickTools({ width: '100vw', } : { - opacity: isResizingSidebar ? '0%' : '100%', - transition: isResizingSidebar ? 'opacity 0.2s ease' : 'opacity 0.5s ease', width: toRem(width ?? 100), position: 'absolute', - left: toRem(-66), + right: '0', } } > diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 60aefa740..1a6fd24ab 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 ( )} + {!oldSidebar && !isMobile && } ); } diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 2fd16b54f..f465dd25e 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -80,7 +80,7 @@ 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 PROFILE_PATH = '/profile/'; export const NOTIFICATIONS_PATH_SEGMENT = 'notifications/'; export const INVITES_PATH_SEGMENT = 'invites/'; From 9144f96ed1c00129a3d9e35d954dd0e1ea2327a9 Mon Sep 17 00:00:00 2001 From: Shea Date: Wed, 1 Jul 2026 13:16:44 +0300 Subject: [PATCH 07/16] add better message button Signed-off-by: Shea --- src/app/components/BackRouteHandler.tsx | 7 ++++++- src/app/features/room/MembersDrawer.tsx | 2 +- src/app/pages/client/create/Create.tsx | 2 +- src/app/pages/client/direct/Direct.tsx | 2 +- src/app/pages/client/explore/Explore.tsx | 2 +- src/app/pages/client/home/Home.tsx | 2 +- src/app/pages/client/inbox/Inbox.tsx | 2 +- src/app/pages/client/navigate/Navigate.tsx | 2 +- src/app/pages/client/profile/Profile.tsx | 2 +- src/app/pages/client/sidebar/MessageTab.tsx | 16 +++++++++++++--- src/app/pages/client/space/Space.tsx | 2 +- src/app/state/room/lastSpace.ts | 5 +++++ 12 files changed, 33 insertions(+), 13 deletions(-) create mode 100644 src/app/state/room/lastSpace.ts diff --git a/src/app/components/BackRouteHandler.tsx b/src/app/components/BackRouteHandler.tsx index 1af535ab6..deb3571e5 100644 --- a/src/app/components/BackRouteHandler.tsx +++ b/src/app/components/BackRouteHandler.tsx @@ -20,6 +20,8 @@ import { SPACE_ROOM_PATH, } from '$pages/paths'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; +import { lastVisitedSpaceIdAtom } from '$state/room/lastSpace'; +import { useSpaceOptionally } from '$hooks/useSpace'; type BackRouteHandlerProps = { children: (onBack: () => void) => ReactNode; @@ -28,6 +30,8 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) { const navigate = useNavigate(); const location = useLocation(); const setLastRoomId = useSetAtom(lastVisitedRoomIdAtom); + const setLastSpaceId = useSetAtom(lastVisitedSpaceIdAtom); + const space = useSpaceOptionally(); const goBack = useCallback(() => { const roomPaths = [HOME_ROOM_PATH, DIRECT_ROOM_PATH, SPACE_ROOM_PATH]; @@ -39,6 +43,7 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) { const currentRoomIdOrAlias = roomMatch?.params.roomIdOrAlias; if (currentRoomIdOrAlias) { setLastRoomId(decodeURIComponent(currentRoomIdOrAlias)); + if (space) setLastSpaceId(space.roomId); } if ( @@ -108,7 +113,7 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) { ) { navigate(getInboxPath()); } - }, [navigate, location, setLastRoomId]); + }, [navigate, location, setLastRoomId, setLastSpaceId, space]); return children(goBack); } diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index 0e930730c..1e0ab5f5f 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/pages/client/create/Create.tsx b/src/app/pages/client/create/Create.tsx index c9a77e7f9..199ff9a0e 100644 --- a/src/app/pages/client/create/Create.tsx +++ b/src/app/pages/client/create/Create.tsx @@ -66,7 +66,7 @@ export function Create() { setCurWidth={setCurWidth} sidebarWidth={roomSidebarWidth} setSidebarWidth={setRoomSidebarWidth} - instep={80} + instep={50} outstep={190} minValue={50} maxValue={500} diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index bc3bf0ed3..8ac9ba96f 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -387,7 +387,7 @@ export function Direct() { setCurWidth={setCurWidth} sidebarWidth={roomSidebarWidth} setSidebarWidth={setRoomSidebarWidth} - instep={80} + instep={50} outstep={190} minValue={50} maxValue={500} diff --git a/src/app/pages/client/explore/Explore.tsx b/src/app/pages/client/explore/Explore.tsx index c78aec24f..184ede69f 100644 --- a/src/app/pages/client/explore/Explore.tsx +++ b/src/app/pages/client/explore/Explore.tsx @@ -412,7 +412,7 @@ export function Explore() { setCurWidth={setCurWidth} sidebarWidth={roomSidebarWidth} setSidebarWidth={setRoomSidebarWidth} - instep={80} + instep={50} outstep={190} minValue={50} maxValue={500} diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index c32f6766d..b398adb3e 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -548,7 +548,7 @@ export function Home() { setCurWidth={setCurWidth} sidebarWidth={roomSidebarWidth} setSidebarWidth={setRoomSidebarWidth} - instep={80} + instep={50} outstep={190} minValue={50} maxValue={500} diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx index e0d809eda..e1e98f3d5 100644 --- a/src/app/pages/client/inbox/Inbox.tsx +++ b/src/app/pages/client/inbox/Inbox.tsx @@ -126,7 +126,7 @@ export function Inbox() { setCurWidth={setCurWidth} sidebarWidth={roomSidebarWidth} setSidebarWidth={setRoomSidebarWidth} - instep={80} + instep={50} outstep={190} minValue={50} maxValue={500} diff --git a/src/app/pages/client/navigate/Navigate.tsx b/src/app/pages/client/navigate/Navigate.tsx index 1e63ee51a..1f1604f62 100644 --- a/src/app/pages/client/navigate/Navigate.tsx +++ b/src/app/pages/client/navigate/Navigate.tsx @@ -61,7 +61,7 @@ export function Navigate() { setCurWidth={setCurWidth} sidebarWidth={roomSidebarWidth} setSidebarWidth={setRoomSidebarWidth} - instep={80} + instep={50} outstep={190} minValue={50} maxValue={500} diff --git a/src/app/pages/client/profile/Profile.tsx b/src/app/pages/client/profile/Profile.tsx index 11f7b8a49..f597d8515 100644 --- a/src/app/pages/client/profile/Profile.tsx +++ b/src/app/pages/client/profile/Profile.tsx @@ -79,7 +79,7 @@ export function ProfileMobile() { setCurWidth={setCurWidth} sidebarWidth={roomSidebarWidth} setSidebarWidth={setRoomSidebarWidth} - instep={80} + instep={50} outstep={190} minValue={50} maxValue={500} diff --git a/src/app/pages/client/sidebar/MessageTab.tsx b/src/app/pages/client/sidebar/MessageTab.tsx index 212d8b48a..88743eae3 100644 --- a/src/app/pages/client/sidebar/MessageTab.tsx +++ b/src/app/pages/client/sidebar/MessageTab.tsx @@ -3,15 +3,18 @@ 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 } from 'jotai'; +import { useAtom, useAtomValue } from 'jotai'; import { searchModalAtom } from '$state/searchModal'; import { useInboxSelected } from '$hooks/router/useInbox'; import { Box, color, Text } 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'; export function MessageTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile?: boolean }) { const navigate = useNavigate(); + const lastSpaceId = useAtomValue(lastVisitedSpaceIdAtom); const [searchSelected] = useAtom(searchModalAtom); const navigateRouteActive = useNavigateSelected(); const profileRouteActive = useProfileSelected(); @@ -23,7 +26,14 @@ export function MessageTab({ isBottom, isMobile }: { isBottom?: boolean; isMobil profileRouteActive || inboxSelected ); - const openSettings = () => navigate(HOME_PATH); + const onBack = () => { + if (!lastSpaceId) { + navigate(HOME_PATH); + return; + } + + navigate(getSpacePath(lastSpaceId)); + }; return ( @@ -34,7 +44,7 @@ export function MessageTab({ isBottom, isMobile }: { isBottom?: boolean; isMobil as="button" ref={triggerRef} outlined={!isMobile} - onClick={openSettings} + onClick={onBack} size={'400'} > (undefined); From 195284d99271c47a927910786e169659488e513d Mon Sep 17 00:00:00 2001 From: Shea Date: Thu, 2 Jul 2026 01:25:46 +0300 Subject: [PATCH 08/16] add more logical flow Signed-off-by: Shea --- src/app/components/BackRouteHandler.tsx | 7 +- src/app/pages/client/navigate/Navigate.tsx | 6 +- src/app/pages/client/profile/Profile.tsx | 78 ++++++++++---------- src/app/pages/client/sidebar/SpaceTabs.tsx | 5 +- src/app/pages/client/sidebar/UserMenuTab.tsx | 19 ++--- 5 files changed, 59 insertions(+), 56 deletions(-) diff --git a/src/app/components/BackRouteHandler.tsx b/src/app/components/BackRouteHandler.tsx index deb3571e5..1af535ab6 100644 --- a/src/app/components/BackRouteHandler.tsx +++ b/src/app/components/BackRouteHandler.tsx @@ -20,8 +20,6 @@ import { SPACE_ROOM_PATH, } from '$pages/paths'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; -import { lastVisitedSpaceIdAtom } from '$state/room/lastSpace'; -import { useSpaceOptionally } from '$hooks/useSpace'; type BackRouteHandlerProps = { children: (onBack: () => void) => ReactNode; @@ -30,8 +28,6 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) { const navigate = useNavigate(); const location = useLocation(); const setLastRoomId = useSetAtom(lastVisitedRoomIdAtom); - const setLastSpaceId = useSetAtom(lastVisitedSpaceIdAtom); - const space = useSpaceOptionally(); const goBack = useCallback(() => { const roomPaths = [HOME_ROOM_PATH, DIRECT_ROOM_PATH, SPACE_ROOM_PATH]; @@ -43,7 +39,6 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) { const currentRoomIdOrAlias = roomMatch?.params.roomIdOrAlias; if (currentRoomIdOrAlias) { setLastRoomId(decodeURIComponent(currentRoomIdOrAlias)); - if (space) setLastSpaceId(space.roomId); } if ( @@ -113,7 +108,7 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) { ) { navigate(getInboxPath()); } - }, [navigate, location, setLastRoomId, setLastSpaceId, space]); + }, [navigate, location, setLastRoomId]); return children(goBack); } diff --git a/src/app/pages/client/navigate/Navigate.tsx b/src/app/pages/client/navigate/Navigate.tsx index 1f1604f62..bd238946e 100644 --- a/src/app/pages/client/navigate/Navigate.tsx +++ b/src/app/pages/client/navigate/Navigate.tsx @@ -17,11 +17,13 @@ import { useSetAtom } from 'jotai'; import { isResizingSidebarAtom } from '$state/isResizingSidebar'; import { ListMagnifyingGlassIcon } from '@phosphor-icons/react'; 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); @@ -68,6 +70,7 @@ export function Navigate() { setAnnouncement={setIsResizingSidebar} /> + {!oldSidebar && !isMobile && } )} @@ -76,8 +79,9 @@ export function Navigate() { - + {sizedIcon(ListMagnifyingGlassIcon, '600')} + Navigate diff --git a/src/app/pages/client/profile/Profile.tsx b/src/app/pages/client/profile/Profile.tsx index f597d8515..83b0303fd 100644 --- a/src/app/pages/client/profile/Profile.tsx +++ b/src/app/pages/client/profile/Profile.tsx @@ -1,6 +1,6 @@ -import { Box, Scroll, toRem, Text, color, config, Menu, Icon, Icons, Line, MenuItem } from 'folds'; +import { Box, toRem, Text, color, config, Menu, Icon, Icons, Line, MenuItem } from 'folds'; import { SquaresFour, sizedIcon } from '$components/icons/phosphor'; -import { Page, PageHeroSection, PageNav, PageNavHeader } from '$components/page'; +import { PageNav, PageNavHeader } from '$components/page'; import { useEffect, useState } from 'react'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useSetting } from '$state/hooks/settings'; @@ -16,11 +16,13 @@ import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useUserPresence } from '$hooks/useUserPresence'; import { useUserProfile } from '$hooks/useUserProfile'; import { useOpenSettings } from '$features/settings'; +import { UserQuickTools } from '../sidebar/UserQuickTools'; 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); @@ -86,48 +88,46 @@ export function ProfileMobile() { setAnnouncement={setIsResizingSidebar} /> + {!oldSidebar && !isMobile && } )} - - - - - - - + + + - - - - - - + + + + + + - + - } - onClick={() => openSettings()} - > - - Settings - - - - - - - - + } + onClick={() => openSettings()} + > + + Settings + + + + ); } diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index 80d7e19cb..b88f9db79 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/UserMenuTab.tsx b/src/app/pages/client/sidebar/UserMenuTab.tsx index 4b656b9c7..b292b5423 100644 --- a/src/app/pages/client/sidebar/UserMenuTab.tsx +++ b/src/app/pages/client/sidebar/UserMenuTab.tsx @@ -44,7 +44,6 @@ 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'; @@ -141,7 +140,7 @@ function AccountRow({ ); } -export function AccountMenuOption() { +export function AccountMenuOption({ isMobile }: { isMobile: boolean }) { const mx = useMatrixClient(); const navigate = useNavigate(); const sessions = useAtomValue(sessionsAtom); @@ -150,8 +149,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({ @@ -378,14 +375,18 @@ const PresenceOptions: Array<{ value: Presence; label: string }> = [ { value: Presence.Offline, label: 'Offline' }, ]; -export function PresenceMenuOption({ initialOpen }: { initialOpen: boolean }) { +export function PresenceMenuOption({ + initialOpen, + isMobile, +}: { + initialOpen: boolean; + isMobile: 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(initialOpen); @@ -651,10 +652,10 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi - + - + From 66519a8bf0e0046b8f28b1110e6af0f626d09f13 Mon Sep 17 00:00:00 2001 From: Shea Date: Sun, 5 Jul 2026 05:10:09 +0300 Subject: [PATCH 09/16] add unverified banner Signed-off-by: Shea --- .../UnverifiedNoticeBanner.css.ts | 82 ++++++++ .../UnverifiedNoticeBanner.tsx | 73 +++++++ src/app/components/unverified-notice/index.ts | 1 + src/app/components/user-profile/UserHero.tsx | 10 +- src/app/pages/client/ClientNonUIFeatures.tsx | 2 + src/app/pages/client/SidebarNav.tsx | 3 +- src/app/pages/client/profile/Profile.tsx | 32 ++- .../pages/client/sidebar/UnverifiedTab.css.ts | 30 --- .../pages/client/sidebar/UnverifiedTab.tsx | 92 -------- .../pages/client/sidebar/UserMenuTab.css.ts | 52 +++++ src/app/pages/client/sidebar/UserMenuTab.tsx | 197 +++++++++++++----- src/app/pages/client/sidebar/index.ts | 1 - 12 files changed, 389 insertions(+), 186 deletions(-) create mode 100644 src/app/components/unverified-notice/UnverifiedNoticeBanner.css.ts create mode 100644 src/app/components/unverified-notice/UnverifiedNoticeBanner.tsx create mode 100644 src/app/components/unverified-notice/index.ts delete mode 100644 src/app/pages/client/sidebar/UnverifiedTab.css.ts delete mode 100644 src/app/pages/client/sidebar/UnverifiedTab.tsx create mode 100644 src/app/pages/client/sidebar/UserMenuTab.css.ts diff --git a/src/app/components/unverified-notice/UnverifiedNoticeBanner.css.ts b/src/app/components/unverified-notice/UnverifiedNoticeBanner.css.ts new file mode 100644 index 000000000..868ef19d0 --- /dev/null +++ b/src/app/components/unverified-notice/UnverifiedNoticeBanner.css.ts @@ -0,0 +1,82 @@ +import { keyframes, style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +const slideUp = keyframes({ + from: { + opacity: 0, + transform: 'translateY(-100%)', + }, + to: { + opacity: 1, + transform: 'translateY(0)', + }, +}); + +const slideDown = keyframes({ + from: { + opacity: 1, + transform: 'translateY(0)', + }, + to: { + opacity: 0, + transform: 'translateY(100%)', + }, +}); + +export const Container = style({ + position: 'fixed', + top: 'env(safe-area-inset-top, 0)', + left: '50%', + transform: 'translateX(-50%)', + zIndex: 9998, + width: `min(100%, ${toRem(520)})`, + padding: config.space.S400, + pointerEvents: 'none', +}); + +export const Banner = style({ + pointerEvents: 'all', + display: 'flex', + flexDirection: 'column', + gap: config.space.S300, + backgroundColor: color.Surface.Container, + color: color.Surface.OnContainer, + border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + borderRadius: toRem(16), + padding: config.space.S400, + boxShadow: `0 ${toRem(8)} ${toRem(32)} rgba(0, 0, 0, 0.45), 0 ${toRem(2)} ${toRem(8)} rgba(0, 0, 0, 0.3)`, + animationName: slideUp, + animationDuration: '300ms', + animationTimingFunction: 'cubic-bezier(0.22, 0.8, 0.6, 1)', + animationFillMode: 'both', + + selectors: { + '&[data-dismissing=true]': { + animationName: slideDown, + animationDuration: '220ms', + animationTimingFunction: 'cubic-bezier(0.4, 0, 1, 1)', + animationFillMode: 'both', + }, + }, +}); + +export const Header = style({ + display: 'flex', + alignItems: 'flex-start', + gap: config.space.S300, +}); + +export const HeaderText = style({ + flex: 1, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + gap: toRem(4), +}); + +export const Actions = style({ + display: 'flex', + gap: config.space.S200, + justifyContent: 'flex-end', + flexWrap: 'wrap', +}); diff --git a/src/app/components/unverified-notice/UnverifiedNoticeBanner.tsx b/src/app/components/unverified-notice/UnverifiedNoticeBanner.tsx new file mode 100644 index 000000000..df39f71b7 --- /dev/null +++ b/src/app/components/unverified-notice/UnverifiedNoticeBanner.tsx @@ -0,0 +1,73 @@ +import { Box, Button, color, Text } from 'folds'; +import { sizedIcon } from '$components/icons/phosphor'; +import * as css from './UnverifiedNoticeBanner.css'; +import { useOpenSettings } from '$features/settings'; +import { ShieldWarningIcon } from '@phosphor-icons/react'; +import { useIsUnverified, useUnverifiedDevices } from '$pages/client/sidebar/UserMenuTab'; +import { atom, useAtom } from 'jotai'; +import { useEffect, useState } from 'react'; + +const dismissedNotice = atom(false); + +export function UnverifiedNoticeBanner() { + const openSettings = useOpenSettings(); + const [isDismissedNotice, setDismissedNotice] = useAtom(dismissedNotice); + + const isUnverified = useIsUnverified(); + const unverifiedDeviceCount = useUnverifiedDevices() ?? 0; + const hasUnverified = + isUnverified || (unverifiedDeviceCount !== undefined && unverifiedDeviceCount > 0); + const [dismissCount, setDismissCount] = useState(unverifiedDeviceCount); + + useEffect(() => { + if (unverifiedDeviceCount > 0 && dismissCount < unverifiedDeviceCount) + setDismissedNotice(false); + setDismissCount(unverifiedDeviceCount); + }, [unverifiedDeviceCount, setDismissedNotice, dismissCount]); + + if (!hasUnverified || isDismissedNotice) return null; + + const handleVerify = () => { + openSettings('devices'); + }; + + const handleDismiss = () => { + setDismissedNotice(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 000000000..a7f06aeea --- /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 da6f57fab..3e739303c 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 && ( + diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx index de4ea1628..680070786 100644 --- a/src/app/pages/client/SidebarNav.tsx +++ b/src/app/pages/client/SidebarNav.tsx @@ -6,7 +6,7 @@ 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 { NavigateTab } from './sidebar/NavigateTab'; import { SettingsTab } from './sidebar/SettingsTab'; @@ -137,7 +137,6 @@ export function SidebarNav() { <> {(oldSidebar || isCollapsed) && ( - {oldSidebar ? ( <> diff --git a/src/app/pages/client/profile/Profile.tsx b/src/app/pages/client/profile/Profile.tsx index 83b0303fd..9fbf79ab4 100644 --- a/src/app/pages/client/profile/Profile.tsx +++ b/src/app/pages/client/profile/Profile.tsx @@ -8,7 +8,11 @@ import { settingsAtom } from '$state/settings'; import { SidebarResizer } from '../sidebar/SidebarResizer'; import { useSetAtom } from 'jotai'; import { isResizingSidebarAtom } from '$state/isResizingSidebar'; -import { AccountMenuOption, PresenceMenuOption } from '../sidebar/UserMenuTab'; +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'; @@ -110,22 +114,28 @@ export function ProfileMobile() { + + + + - } - onClick={() => openSettings()} - > - - Settings - - + + } + onClick={() => openSettings()} + > + + Settings + + + 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 cce17ccb9..000000000 --- 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 74070a011..000000000 --- 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 000000000..b466fe526 --- /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 b292b5423..9d16a3a91 100644 --- a/src/app/pages/client/sidebar/UserMenuTab.tsx +++ b/src/app/pages/client/sidebar/UserMenuTab.tsx @@ -2,6 +2,7 @@ import type { MouseEventHandler } from 'react'; import { useCallback, useEffect, useState } from 'react'; import type { RectCords } from 'folds'; import { + Badge, Box, Button, Chip, @@ -15,11 +16,12 @@ import { PopOut, Spinner, Text, + color, config, toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; +import { SidebarAvatar, SidebarItem, SidebarItemBadge } from '../../../components/sidebar'; import { UserAvatar } from '../../../components/user-avatar'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; @@ -48,6 +50,14 @@ 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 { ShieldWarningIcon } from '@phosphor-icons/react'; +import * as css from './UserMenuTab.css'; const log = createLogger('AccountSwitcherTab'); @@ -255,6 +265,7 @@ export function AccountMenuOption({ isMobile }: { isMobile: boolean }) { } style={{ position: 'relative', + background: isOpen && !isMobile ? color.Secondary.Container : color.Surface.Container, }} onClick={() => isMobile && setIsOpen(!isOpen)} {...hoverProps} @@ -277,7 +288,7 @@ export function AccountMenuOption({ isMobile }: { isMobile: boolean }) { position: 'absolute', left: '98%', padding: toRem(15), - bottom: toRem(25), + bottom: toRem(-15), } } > @@ -390,6 +401,7 @@ export function PresenceMenuOption({ const currentPresence = presence?.presence ?? Presence.Online; const [isOpen, setIsOpen] = useState(initialOpen); + const { hoverProps } = useHover({ onHoverChange: (h) => { if (!isMobile) setIsOpen(h); @@ -456,6 +468,7 @@ export function PresenceMenuOption({ } style={{ position: 'relative', + background: isOpen && !isMobile ? color.Secondary.Container : color.Surface.Container, }} onClick={() => isMobile && setIsOpen(!isOpen)} {...hoverProps} @@ -477,7 +490,7 @@ export function PresenceMenuOption({ position: 'absolute', left: '98%', padding: toRem(15), - bottom: toRem(65), + bottom: toRem(25), } } > @@ -527,8 +540,76 @@ export function PresenceMenuOption({ ); } +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' : 'another'} 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(); @@ -536,12 +617,15 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi 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) @@ -570,40 +654,52 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi return ( - - {(triggerRef) => ( - + + + }> - } - > - - {nameInitials(displayName)}} - /> - - + {nameInitials(displayName)}} + /> - {isMobile && ( - - Account - - )} - + + + {isMobile && ( + + Account + )} - + + + {hasUnverified && ( + + + + {unverifiedDeviceCount} + + + + )} - + + + + @@ -656,21 +755,23 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi - - - - - } - onClick={() => openSettings()} - > - - Settings - - - + {(oldSidebar || isMobile) && ( + <> + + + } + onClick={() => openSettings()} + > + + Settings + + + + + )} diff --git a/src/app/pages/client/sidebar/index.ts b/src/app/pages/client/sidebar/index.ts index 616058764..7f9e7adca 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'; From a1406ec069d05e2986d847d91d4c75369aa5cd3e Mon Sep 17 00:00:00 2001 From: Shea Date: Sun, 5 Jul 2026 06:02:56 +0300 Subject: [PATCH 10/16] add message unread bubble Signed-off-by: Shea --- .changeset/feat-change-unverified-view.md | 5 +++ .changeset/feat_rework_mobile_view.md | 5 +++ src/app/pages/client/sidebar/MessageTab.tsx | 45 +++++++++++++++++++- src/app/pages/client/sidebar/UserMenuTab.tsx | 2 +- 4 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 .changeset/feat-change-unverified-view.md create mode 100644 .changeset/feat_rework_mobile_view.md diff --git a/.changeset/feat-change-unverified-view.md b/.changeset/feat-change-unverified-view.md new file mode 100644 index 000000000..c089ceeaf --- /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 000000000..8ebeeb467 --- /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/pages/client/sidebar/MessageTab.tsx b/src/app/pages/client/sidebar/MessageTab.tsx index 88743eae3..ac1f86b87 100644 --- a/src/app/pages/client/sidebar/MessageTab.tsx +++ b/src/app/pages/client/sidebar/MessageTab.tsx @@ -1,4 +1,9 @@ -import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '$components/sidebar'; +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'; @@ -6,13 +11,22 @@ 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 } from 'folds'; +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); @@ -35,6 +49,19 @@ export function MessageTab({ isBottom, isMobile }: { isBottom?: boolean; isMobil 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 ( @@ -54,6 +81,20 @@ export function MessageTab({ isBottom, isMobile }: { isBottom?: boolean; isMobil color={opened && isMobile ? color.Primary.Main : color.Background.OnContainer} /> + {unread && ( + + 0} + count={unread.highlight > 0 ? unread.highlight : unread.total} + /> + + )} {isMobile && ( Messages diff --git a/src/app/pages/client/sidebar/UserMenuTab.tsx b/src/app/pages/client/sidebar/UserMenuTab.tsx index 9d16a3a91..415f8c8b9 100644 --- a/src/app/pages/client/sidebar/UserMenuTab.tsx +++ b/src/app/pages/client/sidebar/UserMenuTab.tsx @@ -599,7 +599,7 @@ export function UnverifiedMenuOption() { onClick={() => openSettings('devices')} > - {`Verify ${unverified ? 'this' : 'another'} device${!unverified && (unverifiedDeviceCount ?? 0) > 1 ? 's' : ''}`} + {`Verify ${unverified ? 'this' : 'your'} device${!unverified && (unverifiedDeviceCount ?? 0) > 1 ? 's' : ''}`} )} From b1b018e1903da3be3037dae0366bf427e4c3540e Mon Sep 17 00:00:00 2001 From: Shea Date: Sun, 5 Jul 2026 06:10:16 +0300 Subject: [PATCH 11/16] merge upstream Signed-off-by: Shea --- .changeset/call-experience-revamp.md | 11 + .changeset/feat_add_bookmarks.md | 5 + .../feat_sticker_add_link_to_settings.md | 5 + .changeset/fix-gradient-themes.md | 5 + .changeset/fix-sending-firefox-apple.md | 5 + .changeset/fix-sliding-sync.md | 5 + flake.nix | 2 +- knip.json | 2 +- oxlint.config.ts | 3 + package.json | 67 +- pnpm-lock.yaml | 2088 ++++++++--------- src/app/components/CallEmbedProvider.tsx | 24 +- .../components/GlobalKeyboardShortcuts.tsx | 13 + src/app/components/IncomingCallModal.test.tsx | 167 ++ src/app/components/IncomingCallModal.tsx | 296 ++- src/app/components/editor/Editor.tsx | 1 + src/app/components/editor/output.ts | 18 +- src/app/components/emoji-board/EmojiBoard.tsx | 4 +- .../emoji-board/components/NoStickerPacks.tsx | 51 +- .../emoji-board/components/styles.css.ts | 8 + src/app/components/message/modals/Options.tsx | 132 +- src/app/components/user-profile/UserHero.tsx | 2 +- src/app/components/user-profile/styles.css.ts | 1 + src/app/features/bookmarks/bookmarkDomain.ts | 90 + .../features/bookmarks/bookmarkRepository.ts | 91 + src/app/features/bookmarks/index.ts | 3 + src/app/features/call/CallControls.tsx | 235 -- src/app/features/call/CallRingtoneManager.ts | 120 + src/app/features/call/CallView.tsx | 162 +- src/app/features/call/callIncomingIngress.ts | 11 + src/app/features/call/callIntent.test.ts | 45 + src/app/features/call/callIntent.ts | 39 + .../features/call/callIntentCrossPath.test.ts | 103 + .../features/call/callMembershipState.test.ts | 53 + src/app/features/call/callMembershipState.ts | 85 + .../call/callNotificationBridge.test.ts | 100 + .../features/call/callNotificationBridge.ts | 133 ++ src/app/features/call/callRingtone.test.ts | 124 + src/app/features/call/callRingtone.ts | 142 ++ src/app/features/call/callRingtoneStorage.ts | 96 + src/app/features/call/callSignalingDecrypt.ts | 56 + .../call/callSignalingFallback.test.ts | 57 + .../features/call/callSignalingFallback.ts | 41 + src/app/features/call/callSignalingPolicy.ts | 9 + .../features/call/callStartCapabilities.ts | 59 + src/app/features/call/callToneSources.test.ts | 68 + src/app/features/call/callToneSources.ts | 70 + .../call/getIncomingCallBlockers.test.ts | 31 + .../features/call/getIncomingCallBlockers.ts | 52 + .../call/outgoingDeclineHandler.test.ts | 52 + .../features/call/outgoingDeclineHandler.ts | 55 + .../call/rtcNotificationParser.test.ts | 258 ++ .../features/call/rtcNotificationParser.ts | 146 ++ src/app/features/call/rtcTimelineDecline.ts | 58 + .../developer-tools/DevelopTools.tsx | 90 +- .../developer-tools/StateEventEditor.tsx | 23 +- .../permissions/PermissionGroups.tsx | 26 +- .../common-settings/permissions/Powers.tsx | 7 +- .../permissions/callPermissions.ts | 23 + .../common-settings/permissions/index.ts | 1 + .../common-settings/permissions/types.ts | 2 +- .../room-settings/permissions/Permissions.tsx | 2 +- .../permissions/usePermissionItems.ts | 20 +- src/app/features/room/Room.tsx | 5 +- src/app/features/room/RoomCallButton.test.tsx | 88 + src/app/features/room/RoomCallButton.tsx | 80 +- src/app/features/room/RoomInput.tsx | 6 + src/app/features/room/RoomTimeline.css.ts | 2 + src/app/features/room/RoomView.tsx | 2 +- .../features/room/RoomViewFollowing.css.ts | 2 +- src/app/features/room/RoomViewHeader.tsx | 30 +- src/app/features/room/message/Message.tsx | 2 - .../developer-tools/SyncDiagnostics.tsx | 24 +- .../general/CallSoundSettings.test.tsx | 79 + .../settings/general/CallSoundSettings.tsx | 399 ++++ .../general/CallSoundSettingsCards.tsx | 149 ++ src/app/features/settings/general/General.tsx | 2 + src/app/features/settings/settingsLink.ts | 9 + .../permissions/usePermissionItems.ts | 2 + src/app/hooks/router/useInbox.ts | 17 +- src/app/hooks/useAutoJoinCall.ts | 30 +- src/app/hooks/useBookmarks.ts | 81 + src/app/hooks/useCallEmbed.ts | 26 +- src/app/hooks/useCallSignaling.ts | 732 ++++-- .../hooks/useCallStartCapabilities.test.ts | 82 + src/app/hooks/useCallStartCapabilities.ts | 30 + src/app/hooks/usePowerLevels.ts | 16 +- src/app/hooks/useRoom.ts | 4 + src/app/hooks/useSlidingSyncActiveRoom.ts | 16 +- src/app/pages/Router.tsx | 4 +- src/app/pages/client/ClientNonUIFeatures.tsx | 42 +- .../client/HandleNotificationClick.test.tsx | 112 + src/app/pages/client/ToRoomEvent.tsx | 45 +- src/app/pages/client/inbox/Bookmarks.tsx | 911 +++++++ src/app/pages/client/inbox/Inbox.tsx | 32 +- src/app/pages/client/inbox/index.ts | 1 + src/app/pages/client/sidebar/InboxTab.tsx | 29 +- src/app/pages/client/space/Space.tsx | 2 +- src/app/pages/client/space/styles.css.ts | 2 +- src/app/pages/pathUtils.ts | 2 + src/app/pages/paths.ts | 2 + src/app/plugins/call/CallControl.ts | 109 +- src/app/plugins/call/CallControlState.ts | 11 +- src/app/plugins/call/CallEmbed.intent.test.ts | 160 ++ src/app/plugins/call/CallEmbed.ts | 447 +++- src/app/plugins/call/CallWidgetDriver.ts | 12 +- src/app/plugins/call/callEmbedError.ts | 28 + .../call/elementCallDomAdapter.test.ts | 51 + src/app/plugins/call/elementCallDomAdapter.ts | 50 + src/app/plugins/call/types.ts | 3 + src/app/plugins/call/utils.test.ts | 78 + src/app/plugins/call/utils.ts | 2 + src/app/plugins/pdfjs-dist.ts | 2 +- src/app/plugins/react-custom-html-parser.tsx | 20 +- src/app/state/bookmarks.ts | 58 + src/app/state/callEmbed.test.ts | 45 + src/app/state/callEmbed.ts | 44 +- src/app/state/hooks/useBindAtoms.ts | 2 + src/app/state/settings.defaults.test.ts | 69 + src/app/state/settings.ts | 65 + src/app/utils/debugLogger.ts | 16 +- src/app/utils/rtc.ts | 10 + src/app/utils/sentryScrubbers.test.ts | 37 +- src/app/utils/sentryScrubbers.ts | 37 + src/app/utils/settingsSync.test.ts | 9 + src/app/utils/settingsSync.ts | 7 + src/app/utils/user-agent.ts | 2 +- src/client/initMatrix.ts | 460 +--- src/client/presenceSync.ts | 112 + src/client/slidingSync.test.ts | 21 +- src/client/slidingSync.ts | 452 +--- src/instrument.ts | 10 +- src/sw.ts | 111 +- src/sw/pushCallNotificationCopy.test.ts | 32 + src/sw/pushCallNotificationCopy.ts | 112 + src/sw/pushNotification.ts | 94 +- .../call/matrixRtcMemberships.test.ts | 12 + .../fixtures/call/matrixRtcMemberships.ts | 50 + src/types/matrix-sdk-events.d.ts | 25 + src/unstable/prefixes/sable/accountdata.ts | 2 + 140 files changed, 8811 insertions(+), 2908 deletions(-) create mode 100644 .changeset/call-experience-revamp.md create mode 100644 .changeset/feat_add_bookmarks.md create mode 100644 .changeset/feat_sticker_add_link_to_settings.md create mode 100644 .changeset/fix-gradient-themes.md create mode 100644 .changeset/fix-sending-firefox-apple.md create mode 100644 .changeset/fix-sliding-sync.md create mode 100644 src/app/components/IncomingCallModal.test.tsx create mode 100644 src/app/features/bookmarks/bookmarkDomain.ts create mode 100644 src/app/features/bookmarks/bookmarkRepository.ts create mode 100644 src/app/features/bookmarks/index.ts delete mode 100644 src/app/features/call/CallControls.tsx create mode 100644 src/app/features/call/CallRingtoneManager.ts create mode 100644 src/app/features/call/callIncomingIngress.ts create mode 100644 src/app/features/call/callIntent.test.ts create mode 100644 src/app/features/call/callIntent.ts create mode 100644 src/app/features/call/callIntentCrossPath.test.ts create mode 100644 src/app/features/call/callMembershipState.test.ts create mode 100644 src/app/features/call/callMembershipState.ts create mode 100644 src/app/features/call/callNotificationBridge.test.ts create mode 100644 src/app/features/call/callNotificationBridge.ts create mode 100644 src/app/features/call/callRingtone.test.ts create mode 100644 src/app/features/call/callRingtone.ts create mode 100644 src/app/features/call/callRingtoneStorage.ts create mode 100644 src/app/features/call/callSignalingDecrypt.ts create mode 100644 src/app/features/call/callSignalingFallback.test.ts create mode 100644 src/app/features/call/callSignalingFallback.ts create mode 100644 src/app/features/call/callSignalingPolicy.ts create mode 100644 src/app/features/call/callStartCapabilities.ts create mode 100644 src/app/features/call/callToneSources.test.ts create mode 100644 src/app/features/call/callToneSources.ts create mode 100644 src/app/features/call/getIncomingCallBlockers.test.ts create mode 100644 src/app/features/call/getIncomingCallBlockers.ts create mode 100644 src/app/features/call/outgoingDeclineHandler.test.ts create mode 100644 src/app/features/call/outgoingDeclineHandler.ts create mode 100644 src/app/features/call/rtcNotificationParser.test.ts create mode 100644 src/app/features/call/rtcNotificationParser.ts create mode 100644 src/app/features/call/rtcTimelineDecline.ts create mode 100644 src/app/features/common-settings/permissions/callPermissions.ts create mode 100644 src/app/features/room/RoomCallButton.test.tsx create mode 100644 src/app/features/settings/general/CallSoundSettings.test.tsx create mode 100644 src/app/features/settings/general/CallSoundSettings.tsx create mode 100644 src/app/features/settings/general/CallSoundSettingsCards.tsx create mode 100644 src/app/hooks/useBookmarks.ts create mode 100644 src/app/hooks/useCallStartCapabilities.test.ts create mode 100644 src/app/hooks/useCallStartCapabilities.ts create mode 100644 src/app/pages/client/HandleNotificationClick.test.tsx create mode 100644 src/app/pages/client/inbox/Bookmarks.tsx create mode 100644 src/app/plugins/call/CallEmbed.intent.test.ts create mode 100644 src/app/plugins/call/callEmbedError.ts create mode 100644 src/app/plugins/call/elementCallDomAdapter.test.ts create mode 100644 src/app/plugins/call/elementCallDomAdapter.ts create mode 100644 src/app/plugins/call/utils.test.ts create mode 100644 src/app/state/bookmarks.ts create mode 100644 src/app/state/callEmbed.test.ts create mode 100644 src/app/utils/rtc.ts create mode 100644 src/client/presenceSync.ts create mode 100644 src/sw/pushCallNotificationCopy.test.ts create mode 100644 src/sw/pushCallNotificationCopy.ts create mode 100644 src/test/fixtures/call/matrixRtcMemberships.test.ts create mode 100644 src/test/fixtures/call/matrixRtcMemberships.ts diff --git a/.changeset/call-experience-revamp.md b/.changeset/call-experience-revamp.md new file mode 100644 index 000000000..875640c5a --- /dev/null +++ b/.changeset/call-experience-revamp.md @@ -0,0 +1,11 @@ +--- +default: minor +--- + +Revamped the calling experience in Sable! + +- Pulled the latest Element Call updates. +- Style improvements to the call widget so it uses the Sable theme and fonts. +- Added support for custom ringtones and ringbacks. +- Added call permissions. +- Fixed various bugs around call ending, declining, and joining. diff --git a/.changeset/feat_add_bookmarks.md b/.changeset/feat_add_bookmarks.md new file mode 100644 index 000000000..aec0f5fe7 --- /dev/null +++ b/.changeset/feat_add_bookmarks.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add bookmark functionality using account data diff --git a/.changeset/feat_sticker_add_link_to_settings.md b/.changeset/feat_sticker_add_link_to_settings.md new file mode 100644 index 000000000..52f082aef --- /dev/null +++ b/.changeset/feat_sticker_add_link_to_settings.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Add a direct link to room/space settings when the current sticker list is empty diff --git a/.changeset/fix-gradient-themes.md b/.changeset/fix-gradient-themes.md new file mode 100644 index 000000000..0b5dd87bd --- /dev/null +++ b/.changeset/fix-gradient-themes.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix codebase issues with gadient themes diff --git a/.changeset/fix-sending-firefox-apple.md b/.changeset/fix-sending-firefox-apple.md new file mode 100644 index 000000000..042ede52b --- /dev/null +++ b/.changeset/fix-sending-firefox-apple.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fixed outdated Firefox versions and some Safari versions not being able to send messages. diff --git a/.changeset/fix-sliding-sync.md b/.changeset/fix-sliding-sync.md new file mode 100644 index 000000000..bfcf936b5 --- /dev/null +++ b/.changeset/fix-sliding-sync.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Improved sliding sync (only requests specific room state data), cleaned up and fixed most flickering issues, and added buttons in developer tools to request full state. diff --git a/flake.nix b/flake.nix index c15d2ba93..1d5d2a6bd 100644 --- a/flake.nix +++ b/flake.nix @@ -61,7 +61,7 @@ ; pname = "sable"; fetcherVersion = 3; - hash = "sha256-LdNWHVhUur064v6Nd+c7qENg7f7k0067Ty8tqBvDMys="; + hash = "sha256-b6+j893Ltg2880YVOIwPnSH3diJdNZdmhZSwsMnJKtU="; }; mkPnpmCheck = diff --git a/knip.json b/knip.json index fb79b4618..1912dfad9 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,5 @@ { - "$schema": "https://unpkg.com/knip@5/schema.json", + "$schema": "https://unpkg.com/knip@6/schema.json", "entry": ["src/sw.ts", "scripts/normalize-imports.js"], "ignore": ["oxlint.config.ts", "oxfmt.config.ts"], "ignoreExportsUsedInFile": { diff --git a/oxlint.config.ts b/oxlint.config.ts index 00fa5d896..d52bbe330 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -33,6 +33,9 @@ export default defineConfig({ 'typescript/no-unsafe-type-assertion': 'off', 'typescript/no-floating-promises': 'off', 'typescript/no-unnecessary-type-arguments': 'off', + // Maybe reconsider this in the future, but it causes a lot of cascading issues + // because of types we use that don't exist in the matrix-js-sdk + 'typescript/no-unnecessary-type-assertion': 'off', 'oxc/no-map-spread': 'off', 'promise/always-return': 'off', 'no-underscore-dangle': [ diff --git a/package.json b/package.json index 452bf7f96..022b97b4a 100644 --- a/package.json +++ b/package.json @@ -26,22 +26,22 @@ "postinstall": "node scripts/install-knope.js" }, "dependencies": { - "@arborium/arborium": "^2.18.0", - "@atlaskit/pragmatic-drag-and-drop": "^1.8.1", - "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^1.4.0", - "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.2.0", + "@arborium/arborium": "^2.18.1", + "@atlaskit/pragmatic-drag-and-drop": "^2.0.1", + "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^3.0.0", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.0.0", "@fontsource-variable/nunito": "5.2.7", "@fontsource/space-mono": "5.2.9", "@phosphor-icons/react": "^2.1.10", "@sableclient/twemoji-font": "^1.0.4", - "@sentry/react": "^10.58.0", - "@tanstack/react-query": "^5.101.0", - "@tanstack/react-query-devtools": "^5.101.0", - "@tanstack/react-virtual": "^3.14.3", + "@sentry/react": "^10.63.0", + "@tanstack/react-query": "^5.101.2", + "@tanstack/react-query-devtools": "^5.101.2", + "@tanstack/react-virtual": "^3.14.5", "@use-gesture/react": "10.3.1", - "@vanilla-extract/css": "^1.20.1", + "@vanilla-extract/css": "^1.21.1", "@vanilla-extract/recipes": "^0.5.7", - "@vanilla-extract/vite-plugin": "^5.2.2", + "@vanilla-extract/vite-plugin": "^5.2.3", "await-to-js": "^3.0.0", "badwords-list": "2.0.1-4", "blurhash": "^2.0.5", @@ -49,37 +49,37 @@ "chroma-js": "^3.2.0", "classnames": "^2.5.1", "dayjs": "^1.11.21", - "domhandler": "^5.0.3", - "dompurify": "^3.4.10", + "domhandler": "^6.0.1", + "dompurify": "^3.4.11", "emojibase": "^17.0.0", "emojibase-data": "^17.0.0", "eventemitter3": "^5.0.4", "file-saver": "^2.0.5", - "focus-trap-react": "^10.3.1", + "focus-trap-react": "^12.0.3", "folds": "^2.6.2", - "framer-motion": "^12.40.0", - "html-dom-parser": "^5.1.8", - "html-react-parser": "^4.2.10", + "framer-motion": "^12.42.2", + "html-dom-parser": "^8.0.0", + "html-react-parser": "^6.1.3", "i18next": "^25.10.10", "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^2.7.3", - "immer": "^9.0.21", + "immer": "^11.1.9", "is-hotkey": "^0.2.0", "jotai": "^2.20.1", - "katex": "^0.16.47", + "katex": "^0.17.0", "leaflet": "1.9.4", "linkify-react": "^4.3.3", "linkifyjs": "^4.3.3", "marked": "^18.0.5", "matrix-js-sdk": "^38.4.0", "matrix-widget-api": "^1.17.0", - "pdfjs-dist": "^5.7.284", + "pdfjs-dist": "^6.1.200", "react": "^18.3.1", - "react-aria": "^3.49.0", + "react-aria": "^3.50.0", "react-blurhash": "^0.3.0", "react-colorful": "^5.7.0", "react-dom": "^18.3.1", - "react-google-recaptcha": "^2.1.0", + "react-google-recaptcha": "^3.1.0", "react-i18next": "^16.6.6", "react-leaflet": "^4.2.1", "react-range": "^1.10.0", @@ -87,17 +87,17 @@ "slate": "^0.124.1", "slate-dom": "^0.124.1", "slate-history": "^0.113.1", - "slate-react": "^0.124.2", - "ua-parser-js": "^1.0.41", - "virtua": "^0.49.1", + "slate-react": "^0.125.1", + "ua-parser-js": "^2.0.10", + "virtua": "^0.49.2", "workbox-precaching": "^7.4.1" }, "devDependencies": { - "@cloudflare/vite-plugin": "^1.41.0", + "@cloudflare/vite-plugin": "^1.42.4", "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-wasm": "^6.2.2", - "@sableclient/sable-call-embedded": "1.1.4", + "@sableclient/sable-call-embedded": "1.1.7", "@sentry/vite-plugin": "^5.3.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -106,21 +106,20 @@ "@types/file-saver": "^2.0.7", "@types/is-hotkey": "^0.1.10", "@types/leaflet": "^1.9.21", - "@types/node": "24.10.13", + "@types/node": "24.13.2", "@types/react": "^18.3.31", "@types/react-dom": "^18.3.7", "@types/react-google-recaptcha": "^2.1.9", - "@types/ua-parser-js": "^0.7.39", "@vitejs/plugin-react": "^5.2.0", "@vitest/coverage-v8": "^4.1.9", "@vitest/ui": "^4.1.9", "buffer": "^6.0.3", "cloudflared": "^0.7.1", "jsdom": "^29.1.1", - "knip": "5.85.0", - "oxfmt": "^0.45.0", - "oxlint": "^1.70.0", - "oxlint-tsgolint": "^0.23.0", + "knip": "6.23.0", + "oxfmt": "^0.57.0", + "oxlint": "^1.72.0", + "oxlint-tsgolint": "^0.24.0", "typescript": "^5.9.3", "vite": "^7.3.5", "vite-plugin-compression2": "^2.5.3", @@ -129,7 +128,7 @@ "vite-plugin-svgr": "4.5.0", "vite-plugin-top-level-await": "^1.6.0", "vitest": "^4.1.9", - "wrangler": "^4.101.0" + "wrangler": "^4.106.0" }, "engines": { "node": "24.x", @@ -141,4 +140,4 @@ "jsdom>undici": "^7.28.0" } } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d39187a3c..e23aea298 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,17 +12,17 @@ importers: .: dependencies: '@arborium/arborium': - specifier: ^2.18.0 - version: 2.18.0 + specifier: ^2.18.1 + version: 2.18.1 '@atlaskit/pragmatic-drag-and-drop': - specifier: ^1.8.1 - version: 1.8.1 + specifier: ^2.0.1 + version: 2.0.1 '@atlaskit/pragmatic-drag-and-drop-auto-scroll': - specifier: ^1.4.0 - version: 1.4.0 + specifier: ^3.0.0 + version: 3.0.0 '@atlaskit/pragmatic-drag-and-drop-hitbox': - specifier: ^1.2.0 - version: 1.2.0 + specifier: ^2.0.0 + version: 2.0.0 '@fontsource-variable/nunito': specifier: 5.2.7 version: 5.2.7 @@ -36,29 +36,29 @@ importers: specifier: ^1.0.4 version: 1.0.4 '@sentry/react': - specifier: ^10.58.0 - version: 10.58.0(react@18.3.1) + specifier: ^10.63.0 + version: 10.63.0(react@18.3.1) '@tanstack/react-query': - specifier: ^5.101.0 - version: 5.101.0(react@18.3.1) + specifier: ^5.101.2 + version: 5.101.2(react@18.3.1) '@tanstack/react-query-devtools': - specifier: ^5.101.0 - version: 5.101.0(@tanstack/react-query@5.101.0(react@18.3.1))(react@18.3.1) + specifier: ^5.101.2 + version: 5.101.2(@tanstack/react-query@5.101.2(react@18.3.1))(react@18.3.1) '@tanstack/react-virtual': - specifier: ^3.14.3 - version: 3.14.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^3.14.5 + version: 3.14.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@use-gesture/react': specifier: 10.3.1 version: 10.3.1(react@18.3.1) '@vanilla-extract/css': - specifier: ^1.20.1 - version: 1.20.1 + specifier: ^1.21.1 + version: 1.21.1 '@vanilla-extract/recipes': specifier: ^0.5.7 - version: 0.5.7(@vanilla-extract/css@1.20.1) + version: 0.5.7(@vanilla-extract/css@1.21.1) '@vanilla-extract/vite-plugin': - specifier: ^5.2.2 - version: 5.2.2(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) + specifier: ^5.2.3 + version: 5.2.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0) await-to-js: specifier: ^3.0.0 version: 3.0.0 @@ -81,11 +81,11 @@ importers: specifier: ^1.11.21 version: 1.11.21 domhandler: - specifier: ^5.0.3 - version: 5.0.3 + specifier: ^6.0.1 + version: 6.0.1 dompurify: - specifier: ^3.4.10 - version: 3.4.10 + specifier: ^3.4.11 + version: 3.4.11 emojibase: specifier: ^17.0.0 version: 17.0.0 @@ -99,20 +99,20 @@ importers: specifier: ^2.0.5 version: 2.0.5 focus-trap-react: - specifier: ^10.3.1 - version: 10.3.1(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^12.0.3 + version: 12.0.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) folds: specifier: ^2.6.2 - version: 2.6.2(@vanilla-extract/css@1.20.1)(@vanilla-extract/recipes@0.5.7(@vanilla-extract/css@1.20.1))(classnames@2.5.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 2.6.2(@vanilla-extract/css@1.21.1)(@vanilla-extract/recipes@0.5.7(@vanilla-extract/css@1.21.1))(classnames@2.5.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) framer-motion: - specifier: ^12.40.0 - version: 12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^12.42.2 + version: 12.42.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) html-dom-parser: - specifier: ^5.1.8 - version: 5.1.8 + specifier: ^8.0.0 + version: 8.0.0 html-react-parser: - specifier: ^4.2.10 - version: 4.2.10(react@18.3.1) + specifier: ^6.1.3 + version: 6.1.3(@types/react@18.3.31)(react@18.3.1) i18next: specifier: ^25.10.10 version: 25.10.10(typescript@5.9.3) @@ -123,8 +123,8 @@ importers: specifier: ^2.7.3 version: 2.7.3 immer: - specifier: ^9.0.21 - version: 9.0.21 + specifier: ^11.1.9 + version: 11.1.9 is-hotkey: specifier: ^0.2.0 version: 0.2.0 @@ -132,8 +132,8 @@ importers: specifier: ^2.20.1 version: 2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@18.3.31)(react@18.3.1) katex: - specifier: ^0.16.47 - version: 0.16.47 + specifier: ^0.17.0 + version: 0.17.0 leaflet: specifier: 1.9.4 version: 1.9.4 @@ -153,14 +153,14 @@ importers: specifier: ^1.17.0 version: 1.17.0 pdfjs-dist: - specifier: ^5.7.284 - version: 5.7.284 + specifier: ^6.1.200 + version: 6.1.200 react: specifier: ^18.3.1 version: 18.3.1 react-aria: - specifier: ^3.49.0 - version: 3.49.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^3.50.0 + version: 3.50.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-blurhash: specifier: ^0.3.0 version: 0.3.0(blurhash@2.0.5)(react@18.3.1) @@ -171,8 +171,8 @@ importers: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) react-google-recaptcha: - specifier: ^2.1.0 - version: 2.1.0(react@18.3.1) + specifier: ^3.1.0 + version: 3.1.0(react@18.3.1) react-i18next: specifier: ^16.6.6 version: 16.6.6(i18next@25.10.10(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) @@ -195,21 +195,21 @@ importers: specifier: ^0.113.1 version: 0.113.1(slate@0.124.1) slate-react: - specifier: ^0.124.2 - version: 0.124.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(slate-dom@0.124.1(slate@0.124.1))(slate@0.124.1) + specifier: ^0.125.1 + version: 0.125.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(slate-dom@0.124.1(slate@0.124.1))(slate@0.124.1) ua-parser-js: - specifier: ^1.0.41 - version: 1.0.41 + specifier: ^2.0.10 + version: 2.0.10 virtua: - specifier: ^0.49.1 - version: 0.49.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^0.49.2 + version: 0.49.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) workbox-precaching: specifier: ^7.4.1 version: 7.4.1 devDependencies: '@cloudflare/vite-plugin': - specifier: ^1.41.0 - version: 1.41.0(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workerd@1.20260616.1)(wrangler@4.101.0) + specifier: ^1.42.4 + version: 1.42.4(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(workerd@1.20260630.1)(wrangler@4.106.0) '@esbuild-plugins/node-globals-polyfill': specifier: ^0.2.3 version: 0.2.3(esbuild@0.28.1) @@ -220,8 +220,8 @@ importers: specifier: ^6.2.2 version: 6.2.2(rollup@2.80.0) '@sableclient/sable-call-embedded': - specifier: 1.1.4 - version: 1.1.4 + specifier: 1.1.7 + version: 1.1.7 '@sentry/vite-plugin': specifier: ^5.3.0 version: 5.3.0(rollup@2.80.0) @@ -247,8 +247,8 @@ importers: specifier: ^1.9.21 version: 1.9.21 '@types/node': - specifier: 24.10.13 - version: 24.10.13 + specifier: 24.13.2 + version: 24.13.2 '@types/react': specifier: ^18.3.31 version: 18.3.31 @@ -258,12 +258,9 @@ importers: '@types/react-google-recaptcha': specifier: ^2.1.9 version: 2.1.9 - '@types/ua-parser-js': - specifier: ^0.7.39 - version: 0.7.39 '@vitejs/plugin-react': specifier: ^5.2.0 - version: 5.2.0(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) + version: 5.2.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: ^4.1.9 version: 4.1.9(vitest@4.1.9) @@ -280,44 +277,44 @@ importers: specifier: ^29.1.1 version: 29.1.1 knip: - specifier: 5.85.0 - version: 5.85.0(@types/node@24.10.13)(typescript@5.9.3) + specifier: 6.23.0 + version: 6.23.0 oxfmt: - specifier: ^0.45.0 - version: 0.45.0 + specifier: ^0.57.0 + version: 0.57.0 oxlint: - specifier: ^1.70.0 - version: 1.70.0(oxlint-tsgolint@0.23.0) + specifier: ^1.72.0 + version: 1.72.0(oxlint-tsgolint@0.24.0) oxlint-tsgolint: - specifier: ^0.23.0 - version: 0.23.0 + specifier: ^0.24.0 + version: 0.24.0 typescript: specifier: ^5.9.3 version: 5.9.3 vite: specifier: ^7.3.5 - version: 7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) + version: 7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) vite-plugin-compression2: specifier: ^2.5.3 version: 2.5.3(rollup@2.80.0) vite-plugin-pwa: specifier: ^1.3.0 - version: 1.3.0(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0) + version: 1.3.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0) vite-plugin-static-copy: specifier: ^3.4.0 - version: 3.4.0(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) + version: 3.4.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) vite-plugin-svgr: specifier: 4.5.0 - version: 4.5.0(rollup@2.80.0)(typescript@5.9.3)(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) + version: 4.5.0(rollup@2.80.0)(typescript@5.9.3)(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) vite-plugin-top-level-await: specifier: ^1.6.0 - version: 1.6.0(@swc/helpers@0.5.23)(rollup@2.80.0)(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) + version: 1.6.0(@swc/helpers@0.5.23)(rollup@2.80.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@24.10.13)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) + version: 4.1.9(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) wrangler: - specifier: ^4.101.0 - version: 4.101.0 + specifier: ^4.106.0 + version: 4.106.0 packages: @@ -330,8 +327,8 @@ packages: peerDependencies: ajv: '>=8' - '@arborium/arborium@2.18.0': - resolution: {integrity: sha512-Qtwxr9wkrPbU8BfeXKQxCkSLbWsNdDvZRzncJ/ZJS6BaU5MxdyGuI2STBUrKxsaibRkmB+ilsmGBwQbSZUegNw==} + '@arborium/arborium@2.18.1': + resolution: {integrity: sha512-v6WuWy2yd/+R5OFT7g+asDXKImOrUdnTmEvGZwaBgOE2DSFKcksa2Us4QxxIr3C2ohVguRJf/rtbnZska6/UKg==} '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} @@ -348,14 +345,14 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - '@atlaskit/pragmatic-drag-and-drop-auto-scroll@1.4.0': - resolution: {integrity: sha512-5GoikoTSW13UX76F9TDeWB8x3jbbGlp/Y+3aRkHe1MOBMkrWkwNpJ42MIVhhX/6NSeaZiPumP0KbGJVs2tOWSQ==} + '@atlaskit/pragmatic-drag-and-drop-auto-scroll@3.0.0': + resolution: {integrity: sha512-8TlkMi417zIPrTnCpbNV0Ce63JTKBn3dGkqiadkGQROvzDAox7Y9TYRHpvbQiEdtPoHWnmyRBi+RsGDxXsQxgQ==} - '@atlaskit/pragmatic-drag-and-drop-hitbox@1.2.0': - resolution: {integrity: sha512-eWJvvuHZOC4Yk+Li7QpS+JM2F/I50/3PhMvEcyvcHbXI0FP0kCDD1MiF8Hv7uSOxpk5JNqKoOmK8e1ncOzTgqA==} + '@atlaskit/pragmatic-drag-and-drop-hitbox@2.0.0': + resolution: {integrity: sha512-vkXCB/23pT8YgYU8biGmR9uu0fr9oWoaq2GMSKyiNPk3reA12XVrCdgB2SLBoB24Ic1FOm9PdaQJ9ZSUQdCVpw==} - '@atlaskit/pragmatic-drag-and-drop@1.8.1': - resolution: {integrity: sha512-uXWNPpL8n4OmTVbduH7nq8pk8htqGo/prR5cYEE8sVCPJGAUMWn6lzvWTfI+4VCeQvHiDRODVz4YzH06OVAxhw==} + '@atlaskit/pragmatic-drag-and-drop@2.0.1': + resolution: {integrity: sha512-5iSbCveKuWYh0HijDxfSsYKD2VE0UPObI9jO1pqq+RBk9LzaoOYeKyW0Fobv/6db8HAPcyT3vhRy8bmk5TMcMw==} '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} @@ -897,39 +894,39 @@ packages: workerd: optional: true - '@cloudflare/vite-plugin@1.41.0': - resolution: {integrity: sha512-87OUqum/sEAp0eJpuY2GxREPOL6sgjxL7JuLO2COiM/OBWcxtwz25EETy05WK98hD5fdm5s4WcxDljzkgXOcoA==} + '@cloudflare/vite-plugin@1.42.4': + resolution: {integrity: sha512-31Sfu4NpE508YIU4oTSWGFvf31h01BjsDwPS15ufLb3whk9XscRX+rdl9+jOOAcHQu4F6lo7QVkj84IhBKlv6Q==} hasBin: true peerDependencies: vite: ^6.1.0 || ^7.0.0 || ^8.0.0 - wrangler: ^4.101.0 + wrangler: ^4.106.0 - '@cloudflare/workerd-darwin-64@1.20260616.1': - resolution: {integrity: sha512-8QaDRQABkwkwoeviNiyScol7EQgXfGsPNSyUn52GiXObthY4XPiokoJsgDSDNcAelHjEvDLmdvQBHPK8YvGn4A==} + '@cloudflare/workerd-darwin-64@1.20260630.1': + resolution: {integrity: sha512-oEVsD2NZtPAMaEvFeH2Y6N63yiFuOnPDKeAM+l8AkRbLAbFk462uWOq6/ZLn8ouY4P4coMkgsOPqcT1mkuzvzg==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20260616.1': - resolution: {integrity: sha512-xEhiZQ62CBJ+vyKSmM13rkK/wB1kLP5sKFkF3+P+3R/c2bmnSG3Vcd5FfXUu9V0PdC+KlR02nByvZjqEw2N6Ag==} + '@cloudflare/workerd-darwin-arm64@1.20260630.1': + resolution: {integrity: sha512-tar1vcQSzM+27Agrlv28BhtN1tIFKw2YHrzldEMyQJOJB/885TU8Z3oO1c/a9YOmsKABhD6I4dGFhsmXyrbK1g==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20260616.1': - resolution: {integrity: sha512-p5laSYPiRUMHaLkneaZ9ZfIkNpmEnGFwgYmXtfcHJutTfEd8o3IBnsUVRSbPL+phcshKqmapLsQSxDEX6WSFfA==} + '@cloudflare/workerd-linux-64@1.20260630.1': + resolution: {integrity: sha512-mhjIg91+ikWw5v9tY4BYO7N9vLOZBhn7EnVFvxCdxcpuUUFBKATxUYHUy1kkgYxnmiI6s93PRNbzBz1NpYQ3IQ==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20260616.1': - resolution: {integrity: sha512-XQ7GonEl8ORvbz5fhe8Eyw2t/j09Li0KbXJxaldA318E+syF+PPTc4IRQudgqPWzzdzkH5nF7PuMOGySLSjFFw==} + '@cloudflare/workerd-linux-arm64@1.20260630.1': + resolution: {integrity: sha512-7g0iGvMCwGct+vE3FOKXtFWMAIGHzK2Ei9oALp44gXuL4lBcs3PPJISeTp5itquW2JwS1fw4Hnq7zrT7N/dgPw==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@cloudflare/workerd-windows-64@1.20260616.1': - resolution: {integrity: sha512-RaDVF9bSbPiPTq6vHYrgnv1TcQEcYnOr0WB3hWJ4yg2fBfpi2ygU6cYPuFeDwyFE9aPW5S6FBAkNmpKYueK4DQ==} + '@cloudflare/workerd-windows-64@1.20260630.1': + resolution: {integrity: sha512-J5KF9VF8yRpRBib/cPSuEp6iR9q3/cKgeDVhg1ZtuwpkzwnmCb+rxMF5WFLxAN8bI2x2FMG1v6o4vVFOGZ0fOQ==} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -977,15 +974,27 @@ packages: '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.11.0': + resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.0': + resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==} + '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@emotion/hash@0.9.2': resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} @@ -994,12 +1003,6 @@ packages: peerDependencies: esbuild: '*' - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -1012,12 +1015,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.27.7': resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} @@ -1030,12 +1027,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.27.7': resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} @@ -1048,12 +1039,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.27.7': resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} @@ -1066,12 +1051,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.27.7': resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} @@ -1084,12 +1063,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.27.7': resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} @@ -1102,12 +1075,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.27.7': resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} @@ -1120,12 +1087,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.27.7': resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} @@ -1138,12 +1099,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.27.7': resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} @@ -1156,12 +1111,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.27.7': resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} @@ -1174,12 +1123,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.27.7': resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} @@ -1192,12 +1135,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.27.7': resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} @@ -1210,12 +1147,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.27.7': resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} @@ -1228,12 +1159,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.27.7': resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} @@ -1246,12 +1171,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.27.7': resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} @@ -1264,12 +1183,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.27.7': resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} @@ -1282,12 +1195,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.27.7': resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} @@ -1300,12 +1207,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.27.7': resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} @@ -1318,12 +1219,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.27.7': resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} @@ -1336,12 +1231,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.27.7': resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} @@ -1354,12 +1243,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.27.7': resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} @@ -1372,12 +1255,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.27.7': resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} @@ -1390,12 +1267,6 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.27.7': resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} @@ -1408,12 +1279,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.27.7': resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} @@ -1426,12 +1291,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.27.7': resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} @@ -1444,12 +1303,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.27.7': resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} @@ -1672,79 +1525,79 @@ packages: resolution: {integrity: sha512-QyxHvncvkl7nf+tnn92PjQ54gMNV8hMSpiukiDgNrqF6IYwgySTlcSdkPYdw8QjZJ0NR6fnVrNzMec0OohM3wA==} engines: {node: '>= 18'} - '@napi-rs/canvas-android-arm64@0.1.100': - resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} + '@napi-rs/canvas-android-arm64@1.0.2': + resolution: {integrity: sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==} engines: {node: '>= 10'} cpu: [arm64] os: [android] - '@napi-rs/canvas-darwin-arm64@0.1.100': - resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} + '@napi-rs/canvas-darwin-arm64@1.0.2': + resolution: {integrity: sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@napi-rs/canvas-darwin-x64@0.1.100': - resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} + '@napi-rs/canvas-darwin-x64@1.0.2': + resolution: {integrity: sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': - resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.2': + resolution: {integrity: sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@napi-rs/canvas-linux-arm64-gnu@0.1.100': - resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} + '@napi-rs/canvas-linux-arm64-gnu@1.0.2': + resolution: {integrity: sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-arm64-musl@0.1.100': - resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} + '@napi-rs/canvas-linux-arm64-musl@1.0.2': + resolution: {integrity: sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': - resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} + '@napi-rs/canvas-linux-riscv64-gnu@1.0.2': + resolution: {integrity: sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-x64-gnu@0.1.100': - resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} + '@napi-rs/canvas-linux-x64-gnu@1.0.2': + resolution: {integrity: sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-x64-musl@0.1.100': - resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} + '@napi-rs/canvas-linux-x64-musl@1.0.2': + resolution: {integrity: sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@napi-rs/canvas-win32-arm64-msvc@0.1.100': - resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} + '@napi-rs/canvas-win32-arm64-msvc@1.0.2': + resolution: {integrity: sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@napi-rs/canvas-win32-x64-msvc@0.1.100': - resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} + '@napi-rs/canvas-win32-x64-msvc@1.0.2': + resolution: {integrity: sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@napi-rs/canvas@0.1.100': - resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} + '@napi-rs/canvas@1.0.2': + resolution: {integrity: sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==} engines: {node: '>= 10'} '@napi-rs/wasm-runtime@1.1.5': @@ -1753,394 +1606,512 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} + '@oxc-parser/binding-android-arm-eabi@0.137.0': + resolution: {integrity: sha512-KDs+0VPdEmasOkpuJHW9V5WCF+cvYdMQv2Jd+aJXt+cxIx12NToRQRbXaRwUEDsZw+/jMk81Ve8ZFbjUkJTOwA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} + '@oxc-parser/binding-android-arm64@0.137.0': + resolution: {integrity: sha512-WhALNzfy3x/RfC6bsqX+csavuUY0yHHE7XfgPE5M542uhoBZUUoGTPG+nkMbGoG4+gcfss5s7urMyn5QBHu0sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} + '@oxc-parser/binding-darwin-arm64@0.137.0': + resolution: {integrity: sha512-bFPr5hgmNMOMoyPTGtdsK4Ug21RovIPojRMgDDhSp1LtCnc/DkLwGONKjgRjszg677RlGnkYSviQ8hHaUPOVYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.137.0': + resolution: {integrity: sha512-CL5dMm1asqXIDZHg14FLxj3Mc36w8PI7xCWh1uA4is6z8g2XrIILoTcQYOxDbwzuk34RDPX5IAGUxZr6LA9KAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.137.0': + resolution: {integrity: sha512-79h8rYGnSlKPGWo7mHr2ixO6ea7aW8B0CT965SZ8SLbNnCOH5aOYBTeVXUY6eMvEaiLyWr8Skuiugr5pDYgLGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.137.0': + resolution: {integrity: sha512-ASgmlSimhGyr0lksgVIo6hibz1obnDq4qJbiMX/AzltfgPnanRrzG1Q+23g8ljOHOjv6dsznkUuCYL3gg0sY1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.137.0': + resolution: {integrity: sha512-AU2J9aa22Sx32wRGnDjybOU9TQXXQUud5sdUi+ZB0XxwM8aToWLweV+yA0wlQm0yIUVqljquqoHCYEq9II8gJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.137.0': + resolution: {integrity: sha512-GdEtiG89yMr7XkUGxifgodXEEm2f+xW2f9CpDjlgAnBOwhTmrpQMvhOGobLVKUyzf/qHBXW16smk5zbF3nZU6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.137.0': + resolution: {integrity: sha512-EGJ+Bs8iXx8KBH8DQ5BLoEm5lnHaYjlh4/8j8vFhrr/6z4tqONy5BZDzLpKmmNWlN6Hlc5r8YOuBVHqZ9vRFEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.137.0': + resolution: {integrity: sha512-vzFUQENy/fnbSe5DZWovq6tIBc1uhuMztanSW6rz1e9WdQE4gHwYuD7ZII6JnrJifd1R3RSoqiZbgRFlVL2tYQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.137.0': + resolution: {integrity: sha512-SfVI14HBQs9gtLcUD5hTt5hsNbdrqSUNg9S8muN+LhVQ5nf1WwH3hAoK6B9NKgdYgWAQSXFXGiiBedQ4r/BKuw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.137.0': + resolution: {integrity: sha512-e7Ppy4FCIFNQxT/ikSeIWFoQ0l+N9vgtRBtLcyZXeolTzApyVoPqEXsYPrcdM/9i0Bwk8knvYd37vaEMxHyi6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.137.0': + resolution: {integrity: sha512-Bho5qFwdhqsIFR7gipYEUlqvi3SRrY8sugxXig380MIaakBB1PyU9+7dBiBVScfImTNWhijUxdBwqrprGdq5WA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.137.0': + resolution: {integrity: sha512-36mGWtg7PyFzjJwGDkH6/F4o2nIDEoKXLPr/X/lwqklkomQwJJt1I5GJVmGhovUEmgPK5WAeAZMqlFCehwiy9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.137.0': + resolution: {integrity: sha512-/Jqx6+N7A44n2BdvUr7pXhVr2vFjs6WGH3unZRczwrfiH0H1zY0QwKQMG/dtRiTlKGDKGukznPT8lx84/oEsZg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.137.0': + resolution: {integrity: sha512-9Uj0qHNNl+OgT1UTGwF7ixIXU6T1u2SbMidmgPy/h1h/fl2gRS6YpAxxY1gwHofcWjoTwkoMFd8xs5Vuj6GOFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.137.0': + resolution: {integrity: sha512-gW2vfkytNGgMVADiuzdvOfw0mWG9za20F/1fCJsif5aBMAvWJTSbpIXbIe0XkOe0VENk+PadpQ7cZgUy2sUJcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.137.0': + resolution: {integrity: sha512-x+pFANF0yL5uK/6T7lu6SlR5qid6sp//eZXKLq5iNsIE+EQg6EaS8/wsW7E91nXXjpnPhSoMOHXShSVhGRdn8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.137.0': + resolution: {integrity: sha512-sQUqym80PFi6McRsIqfJrSu2JrSClEZIXXD+/FjAFoULEKzOPsldIdFBG96xdX8aVMzCNQ9792FPx3MfkEIrFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.137.0': + resolution: {integrity: sha512-2AsevxlvNN4WKxpEn3RtqD5zbqMaXF+T7JXblsP4gVuY+vC9dXS4ED/PwfRCliFqoeisYS3Iro4DHzxr0TEvVA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} - '@oxc-resolver/binding-android-arm-eabi@11.20.0': - resolution: {integrity: sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==} + '@oxc-project/types@0.137.0': + resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + + '@oxc-resolver/binding-android-arm-eabi@11.21.3': + resolution: {integrity: sha512-eNU11A2WNizh04v3uyaJCootrHIaS0B9aHYXvAvVnPNk4xYSjMUjHnhQ6dewPN2MRYDskV85d1N0Aw0WNWhcyg==} cpu: [arm] os: [android] - '@oxc-resolver/binding-android-arm64@11.20.0': - resolution: {integrity: sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q==} + '@oxc-resolver/binding-android-arm64@11.21.3': + resolution: {integrity: sha512-8Q+ZjTLvn2dIcWsrmhdrEihm7q+ag/k+mkry7Z+t0QbbHaVxXQfvH9AewyVMh/WrpEKhQ3DDgx9fYbqeCpeOEw==} cpu: [arm64] os: [android] - '@oxc-resolver/binding-darwin-arm64@11.20.0': - resolution: {integrity: sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ==} + '@oxc-resolver/binding-darwin-arm64@11.21.3': + resolution: {integrity: sha512-wkh0qKZGHXVUDxFw3oA1TXnU2BDYY/r775oJflGeIr8uDPPoN2pk8gijQIzYRT6hoql/lg3+Tx/SaTn9e2/aGg==} cpu: [arm64] os: [darwin] - '@oxc-resolver/binding-darwin-x64@11.20.0': - resolution: {integrity: sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg==} + '@oxc-resolver/binding-darwin-x64@11.21.3': + resolution: {integrity: sha512-HbNc23FAQYbuyDV2vBWMez4u4mrsm5RAkniGZAWqr6lYZ3N4beeqIb776jzwRl8qL2zRhHVXpUj97X0QgogVzg==} cpu: [x64] os: [darwin] - '@oxc-resolver/binding-freebsd-x64@11.20.0': - resolution: {integrity: sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ==} + '@oxc-resolver/binding-freebsd-x64@11.21.3': + resolution: {integrity: sha512-K6xNsTUPEUdfrn0+kbMq5nOUB5w1C5pavPQngt4TM2FpN91lP0PBe2srSpamb4d69O7h86oAi/qWX/kZNRSjkw==} cpu: [x64] os: [freebsd] - '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0': - resolution: {integrity: sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg==} + '@oxc-resolver/binding-linux-arm-gnueabihf@11.21.3': + resolution: {integrity: sha512-VcFmOpcpWX1zoEy8M58tR2M9YxM+Z9RuQhqAx5q0CTmrruaP7Gveejg75hzd/5sg5nk9G3aLALEa3hE2FsmmTQ==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0': - resolution: {integrity: sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg==} + '@oxc-resolver/binding-linux-arm-musleabihf@11.21.3': + resolution: {integrity: sha512-quVoxFLBy43hWaQbbDtQNRwAX5vX76mv7n64icAtQcJ3eNgVeblqmkupF/hAneNthdqSlnd1sTjb3aQSaDPaCQ==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm64-gnu@11.20.0': - resolution: {integrity: sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg==} + '@oxc-resolver/binding-linux-arm64-gnu@11.21.3': + resolution: {integrity: sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ==} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-arm64-musl@11.20.0': - resolution: {integrity: sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw==} + '@oxc-resolver/binding-linux-arm64-musl@11.21.3': + resolution: {integrity: sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw==} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': - resolution: {integrity: sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ==} + '@oxc-resolver/binding-linux-ppc64-gnu@11.21.3': + resolution: {integrity: sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A==} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': - resolution: {integrity: sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw==} + '@oxc-resolver/binding-linux-riscv64-gnu@11.21.3': + resolution: {integrity: sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og==} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': - resolution: {integrity: sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg==} + '@oxc-resolver/binding-linux-riscv64-musl@11.21.3': + resolution: {integrity: sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw==} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': - resolution: {integrity: sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g==} + '@oxc-resolver/binding-linux-s390x-gnu@11.21.3': + resolution: {integrity: sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q==} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-x64-gnu@11.20.0': - resolution: {integrity: sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g==} + '@oxc-resolver/binding-linux-x64-gnu@11.21.3': + resolution: {integrity: sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q==} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-x64-musl@11.20.0': - resolution: {integrity: sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ==} + '@oxc-resolver/binding-linux-x64-musl@11.21.3': + resolution: {integrity: sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA==} cpu: [x64] os: [linux] libc: [musl] - '@oxc-resolver/binding-openharmony-arm64@11.20.0': - resolution: {integrity: sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ==} + '@oxc-resolver/binding-openharmony-arm64@11.21.3': + resolution: {integrity: sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg==} cpu: [arm64] os: [openharmony] - '@oxc-resolver/binding-wasm32-wasi@11.20.0': - resolution: {integrity: sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg==} + '@oxc-resolver/binding-wasm32-wasi@11.21.3': + resolution: {integrity: sha512-i8oluoel5kru/j1WNrjmQSiA3GQ7wvIYVR1IwIoZtKogAhya2iub+ZKIeSIkcJOrnzQ18Tzl/F+kL3fYOxZLvA==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@oxc-resolver/binding-win32-arm64-msvc@11.20.0': - resolution: {integrity: sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA==} + '@oxc-resolver/binding-win32-arm64-msvc@11.21.3': + resolution: {integrity: sha512-M/8dw8dD6aOs+NlPJax401CZB9I7Aut84isQLgALGGwke4Afvw+/7yYhZb94yXf6t2sPLhQLmSmtSV+2FhsOWg==} cpu: [arm64] os: [win32] - '@oxc-resolver/binding-win32-x64-msvc@11.20.0': - resolution: {integrity: sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw==} + '@oxc-resolver/binding-win32-x64-msvc@11.21.3': + resolution: {integrity: sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q==} cpu: [x64] os: [win32] - '@oxfmt/binding-android-arm-eabi@0.45.0': - resolution: {integrity: sha512-A/UMxFob1fefCuMeGxQBulGfFE38g2Gm23ynr3u6b+b7fY7/ajGbNsa3ikMIkGMLJW/TRoQaMoP1kME7S+815w==} + '@oxfmt/binding-android-arm-eabi@0.57.0': + resolution: {integrity: sha512-qVBsEO+KugOsCmUHcO8iqNnqc65p7PCKpCs8M66mPZ+Ri+CWbcpoQOEJBg2OTu03+0qu++NK1jj6IzvQVs0Sig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.45.0': - resolution: {integrity: sha512-L63z4uZmHjgvvqvMJD7mwff8aSBkM0+X4uFr6l6U5t6+Qc9DCLVZWIunJ7Gm4fn4zHPdSq6FFQnhu9yqqobxIg==} + '@oxfmt/binding-android-arm64@0.57.0': + resolution: {integrity: sha512-mp6PibWbao3aizijcheOeHQaYEhcUAt8pwLniYbtLfHxL/psFF0BykAwCj+s3c6qIpa8yN8keZICWrqtZ70w8g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.45.0': - resolution: {integrity: sha512-UV34dd623FzqT+outIGndsCA/RBB+qgB3XVQhgmmJ9PJwa37NzPC9qzgKeOhPKxVk2HW+JKldQrVL54zs4Noww==} + '@oxfmt/binding-darwin-arm64@0.57.0': + resolution: {integrity: sha512-T+0stuCBqmUVY+aMIvrgXhzGhHO3sD5tNiiEcYqgSdPsnukskQqn2u5qOVD0sv1l7RLdFS5Z/f5Wi9Ktyjr3Eg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.45.0': - resolution: {integrity: sha512-pMNJv0CMa1pDefVPeNbuQxibh8ITpWDFEhMC/IBB9Zlu76EbgzYwrzI4Cb11mqX2+rIYN70UTrh3z06TM59ptQ==} + '@oxfmt/binding-darwin-x64@0.57.0': + resolution: {integrity: sha512-O+3JbqWs/mCI2oi4xfhRO2IVPFJNDDEBV8Odo+ZpmsUOeKJfjXoNH7nDmBEQcDgK7NfjDIyE7kRgYSZcTLDO0A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.45.0': - resolution: {integrity: sha512-xTcRoxbbo61sW2+ZRPeH+vp/o9G8gkdhiVumFU+TpneiPm14c79l6GFlxPXlCE9bNWikigbsrvJw46zCVAQFfg==} + '@oxfmt/binding-freebsd-x64@0.57.0': + resolution: {integrity: sha512-pxwhxVC+JkLX9twOQ/8C/vbuOQcMZyKIDmiRDZfO7yITuVcIdZCiLRqqf4QOxb2+8FWrRXzQpm+1DBKcMpHSSQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.45.0': - resolution: {integrity: sha512-hWL8Hdni+3U1mPFx1UtWeGp3tNb6EhBAUHRMbKUxVkOp3WwoJbpVO2bfUVbS4PfpledviXXNHSTl1veTa6FhkQ==} + '@oxfmt/binding-linux-arm-gnueabihf@0.57.0': + resolution: {integrity: sha512-pxBU4zH2imB/MDBfth2rOMeVxXUMjRQLCazagwLARIFH3hVlxZJBlM4nSnHXaIHJK4/qezoFCIORN6AY8Mra4A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.45.0': - resolution: {integrity: sha512-6Blt/0OBT7vvfQpqYuYbpbFLPqSiaYpEJzUUWhinPEuADypDbtV1+LdjM0vYBNGPvnj85ex7lTerEX6JGcPt9w==} + '@oxfmt/binding-linux-arm-musleabihf@0.57.0': + resolution: {integrity: sha512-JAprOzt8tycYou36ZgEw14DlRHTiN8qdtKANdV3VZIRIvTI/lh/cX13c9pJ/EnDk2GT3FASH7KvCgQ2AufAifQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.45.0': - resolution: {integrity: sha512-jLjoLfe+hGfjhA8hNBSdw85yCA8ePKq7ME4T+g6P9caQXvmt6IhE2X7iVjnVdkmYUWEzZrxlh4p6RkDmAMJY/A==} + '@oxfmt/binding-linux-arm64-gnu@0.57.0': + resolution: {integrity: sha512-ajtjaxSaj9xl4BW7REt+Cef/ttzbAq00Bq4z7JUDZEfgFXdwSjH8K9bF+IcIJzZB9lKqMfQ4eHuSFOvvlvtqOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.45.0': - resolution: {integrity: sha512-XQKXZIKYJC3GQJ8FnD3iMntpw69Wd9kDDK/Xt79p6xnFYlGGxSNv2vIBvRTDg5CKByWFWWZLCRDOXoP/m6YN4g==} + '@oxfmt/binding-linux-arm64-musl@0.57.0': + resolution: {integrity: sha512-p4Y/+RYk9Bk5WO+zHSUXAClRmZ2fbJCejMuCAsU2HhyME4jqf6Ftt/mJYEwIah1wGCBDYOB7wEGV1x5bCEZ6hA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.45.0': - resolution: {integrity: sha512-+g5RiG+xOkdrCWkKodv407nTvMq4vYM18Uox2MhZBm/YoqFxxJpWKsloskFFG5NU13HGPw1wzYjjOVcyd9moCA==} + '@oxfmt/binding-linux-ppc64-gnu@0.57.0': + resolution: {integrity: sha512-By6tRALAZsno0F4zedmtG+wdMvJiJmJoXM4d3+A9zHE4HRXLqXITwRH8mgrlcXc5yJM2g2W3riRPwTYdgemZLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.45.0': - resolution: {integrity: sha512-V7dXKoSyEbWAkkSF4JJNtF+NJZDmJoSarSoP30WCsB3X636Rehd3CvxBj49FIJxEBFWhvcUjGSHVeU8Erck1bQ==} + '@oxfmt/binding-linux-riscv64-gnu@0.57.0': + resolution: {integrity: sha512-skYeG+RgvyzspqVEBsEprL90OYYZfoVNqB3HcCNR6QDJyXKOzfDRT3zncnHmUaFluIlBHuY23mU1b5WGgR98hA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.45.0': - resolution: {integrity: sha512-Vdelft1sAEYojVGgcODEFXSWYQYlIvoyIGWebKCuUibd1tvS1TjTx413xG2ZLuHpYj45CkN/ztMLMX6jrgqpgg==} + '@oxfmt/binding-linux-riscv64-musl@0.57.0': + resolution: {integrity: sha512-FFgACrZOXAXUh5KQh2mt1CDOVOZmn+QzHP71wM9QobNwyQvoFfyAeefVUltW83g3sm7LTiH3yfFqLLVUpA5ZFQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.45.0': - resolution: {integrity: sha512-RR7xKgNpqwENnK0aYCGYg0JycY2n93J0reNjHyes+I9Gq52dH95x+CBlnlAQHCPfz6FGnKA9HirgUl14WO6o7w==} + '@oxfmt/binding-linux-s390x-gnu@0.57.0': + resolution: {integrity: sha512-Nm/BAOfQeFiiKd502mZn/GAVKJwtd0RdCg17G3Wz/WSOIQmDi3+7/SZH4BHn1Ye5KvTVH3ua8WvfwLLycNIuvA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.45.0': - resolution: {integrity: sha512-U/QQ0+BQNSHxjuXR/utvXnQ50Vu5kUuqEomZvQ1/3mhgbBiMc2WU9q5kZ5WwLp3gnFIx9ibkveoRSe2EZubkqg==} + '@oxfmt/binding-linux-x64-gnu@0.57.0': + resolution: {integrity: sha512-BiSy5Ku3mQqyxS6YIqAJgd403wEUWvI7kerfzPxc2l/txZVmZM0pSj7oDM+4bGBExowxOi7o73jEam1W0EDTZg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.45.0': - resolution: {integrity: sha512-o5TLOUCF0RWQjsIS06yVC+kFgp092/yLe6qBGSUvtnmTVw9gxjpdQSXc3VN5Cnive4K11HNstEZF8ROKHfDFSw==} + '@oxfmt/binding-linux-x64-musl@0.57.0': + resolution: {integrity: sha512-BCRkJiotz5s9afLYD2LuMvzAoDYx9H17E/YbDyu4xK7l4zHDPeny9ErSXL//i/nJyaOwRk08x4b8cgJC00+JDg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.45.0': - resolution: {integrity: sha512-RnGcV3HgPuOjsGx/k9oyRNKmOp+NBLGzZTdPDYbc19r7NGeYPplnUU/BfU35bX2Y/O4ejvHxcfkvW2WoYL/gsg==} + '@oxfmt/binding-openharmony-arm64@0.57.0': + resolution: {integrity: sha512-4Oaxe1qrGgXfpCJ1C/ERJ2iCtV2rN1R79ga9fsfyVHfSQRu/hVW780u2KDqZWFZ/iGTHODJji0JemxqFZ63eIQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.45.0': - resolution: {integrity: sha512-v3Vj7iKKsUFwt9w5hsqIIoErKVoENC6LoqfDlteOQ5QMDCXihlqLoxpmviUhXnNncg4zV6U9BPwlBbwa+qm4wg==} + '@oxfmt/binding-win32-arm64-msvc@0.57.0': + resolution: {integrity: sha512-MYLAsDnhdNsSGheLYhWgbk0vfIrlS84iQYun/y21fX6u0jj8iBtYtbpZMdiqYeuf8U12eVPUjVY2xE2NrCfJ0g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.45.0': - resolution: {integrity: sha512-N8yotPBX6ph0H3toF4AEpdCeVPrdcSetj+8eGiZGsrLsng3bs/Q5HPu4bbSxip5GBPx5hGbGHrZwH4+rcrjhHA==} + '@oxfmt/binding-win32-ia32-msvc@0.57.0': + resolution: {integrity: sha512-PBwdzZALJY/jcCx2E6is0yu+cuVXeySTDmwuseD+9j0mHqlRNxwlKgsyRTBed/woPeqfVfuXfWjoq4Cx2Zt3Eg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.45.0': - resolution: {integrity: sha512-w5MMTRCK1dpQeRA+HHqXQXyN33DlG/N2LOYxJmaT4fJjcmZrbNnqw7SmIk7I2/a2493PPLZ+2E/Ar6t2iKVMug==} + '@oxfmt/binding-win32-x64-msvc@0.57.0': + resolution: {integrity: sha512-bQJdH9i4RRfw55jm7+8/xS7GzHLLTbHx4huhrrDxQJaJtbSDbsyOnODvP1ftT7EG0KFKAYO2S+q6AcioXODx8w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint-tsgolint/darwin-arm64@0.23.0': - resolution: {integrity: sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw==} + '@oxlint-tsgolint/darwin-arm64@0.24.0': + resolution: {integrity: sha512-C2uMmwK5Bc4ri4ysZ6sA8Rcu+A5zBQTp6ml2u0CLLbRZp4kMFPV3yWk8B5DK9Aw7y9bbjogIm75tUwGLFzlsYQ==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.23.0': - resolution: {integrity: sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA==} + '@oxlint-tsgolint/darwin-x64@0.24.0': + resolution: {integrity: sha512-Wgvt/1lRbDxmoNqWQKKcL+UIiqLmdJ+EWLpQa1qzoNVAfNB0PJpa82/8dH1twT/3rSs4zrP5TXPWl4juB71WuQ==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.23.0': - resolution: {integrity: sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw==} + '@oxlint-tsgolint/linux-arm64@0.24.0': + resolution: {integrity: sha512-PB1rxII7KV83+ASY4sSkXtqvpij6ME66+QCRL49uksi/ofs2Rf/UVboYr095n0Rkbl2wgvlsHGl6DHC361jQUQ==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.23.0': - resolution: {integrity: sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA==} + '@oxlint-tsgolint/linux-x64@0.24.0': + resolution: {integrity: sha512-xcz3CxKmjTQLREtE/UShh+ruWmm9nAb7UM9zKcD65BStiuYgOakAKkPHl4YS5DztpVcDrE0+HqbOolTlRKYWmw==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.23.0': - resolution: {integrity: sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw==} + '@oxlint-tsgolint/win32-arm64@0.24.0': + resolution: {integrity: sha512-A2i6ZGBec3i20S7RaxkgHc6r3HYtD5Mn7j/mb22NkTz14u0JuudvTu6JggAnbGMcv8+dBKQI//EasxSPJLD8pw==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.23.0': - resolution: {integrity: sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ==} + '@oxlint-tsgolint/win32-x64@0.24.0': + resolution: {integrity: sha512-0ZbGd9qRB6zs82moekaKdEvncRANq49EAwfNX62JpTS46feXUhKAuoyVDvZMj6Rywejylrmmu79Wo6faYCo4Ew==} cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.70.0': - resolution: {integrity: sha512-zFh0P4cswmRvw6nkyb89dr18rRanuaCPAsEXsFDoQY8WdaquI8Pt4NWFjaMJg6L23cy5NeN8J9cBnREbWzZhaw==} + '@oxlint/binding-android-arm-eabi@1.72.0': + resolution: {integrity: sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.70.0': - resolution: {integrity: sha512-qI8o4HZjeGiBrWv+pJv4lH0Yi2Gl/JSp/EumBUApezJprIKa5PS4nU0lQsQngtky8k+SplQIOjv6hwu0SSxeyg==} + '@oxlint/binding-android-arm64@1.72.0': + resolution: {integrity: sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.70.0': - resolution: {integrity: sha512-8KjgVVHI5F9nVwHCRwwA78Ty7zNKP4Wd9OeN5PSv3iu/F/u1RVXoOCgLhWqust6HmwQG6xc8c+RCyaWENy24+w==} + '@oxlint/binding-darwin-arm64@1.72.0': + resolution: {integrity: sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.70.0': - resolution: {integrity: sha512-WVydssv5PSUBXFJTdNBWlmGkbNmvPGaFt/2SUT/EZRB6bq6bEOHmMlbnupZD5jmlEvi9+mZJHi8TCw15lyfSfQ==} + '@oxlint/binding-darwin-x64@1.72.0': + resolution: {integrity: sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.70.0': - resolution: {integrity: sha512-hJucmUf8OlinHNb1R7fI4Fw6WsAstOz7i8nmkWQfiHoZXtbufNm+MxiDTIMk1ggh2Ro4vLzgQ+bKvRY54MZoRA==} + '@oxlint/binding-freebsd-x64@1.72.0': + resolution: {integrity: sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.70.0': - resolution: {integrity: sha512-1BnS7wbCYDSXwWzJJ+mc3NURoha6m6m6RT5c6vgAY3oz7C3OVXP+S0awo2mRq97arrJkVvO3qRQfyAHL+76xtQ==} + '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + resolution: {integrity: sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.70.0': - resolution: {integrity: sha512-yKy/UdbR55+M2yEcuiV5DCNC/gdQAjr/GioUy50QwBzSrKm8ueWADqyRLS9Xk+qjNeCYGg6A8FvUBds56ttfqg==} + '@oxlint/binding-linux-arm-musleabihf@1.72.0': + resolution: {integrity: sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.70.0': - resolution: {integrity: sha512-0A5XJ4alvmqFUFP/4oYSyaO+qLto/HrKEWTSaegiVl+HOufFngK2BjYw9x4RbwBt/du5QG6l5q1zeWiJYYG5yg==} + '@oxlint/binding-linux-arm64-gnu@1.72.0': + resolution: {integrity: sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.70.0': - resolution: {integrity: sha512-JiylyurlB0CLSedNtx1gzv3FvfWPF1h/2Y3BJszPLNt5XQFlBsH5ke0Jle3iJb3uqu5m2e7A/DwzpuCAHdiU+A==} + '@oxlint/binding-linux-arm64-musl@1.72.0': + resolution: {integrity: sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.70.0': - resolution: {integrity: sha512-J8VPG7I3/HmgaU4u8pNU2kFx2+0U+vPLS1dXFxXOaR/2TQ0f8AC7DRz0SRGRI1bfphnX2hVYTTtLuhL4nYKL+Q==} + '@oxlint/binding-linux-ppc64-gnu@1.72.0': + resolution: {integrity: sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.70.0': - resolution: {integrity: sha512-N2+4lV2KLN+oXTIIIwmWDhwkrnvqf5oX7Hw0zPjk+RuIVgiBQSOlJWF7uQoFx2siEYX0ZQ5cfSbEAHm+J3t7Wg==} + '@oxlint/binding-linux-riscv64-gnu@1.72.0': + resolution: {integrity: sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.70.0': - resolution: {integrity: sha512-1e2L7cFCvx9QDzq6NPP+0tABKb5z6nWHyddWTNKprEsjO9xNrAtPowuCGpjNXxkTdsMiZ4jc8YQ5SstZd4XK6g==} + '@oxlint/binding-linux-riscv64-musl@1.72.0': + resolution: {integrity: sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.70.0': - resolution: {integrity: sha512-Kwu/l/8GcYibCWA9m9N5pRXMIKVSsL/YbgpLzYkqDhWTiqdRfnNJ/+nqIKRKQiFbHWsdlHEhzMwruJK+qcEruA==} + '@oxlint/binding-linux-s390x-gnu@1.72.0': + resolution: {integrity: sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.70.0': - resolution: {integrity: sha512-tap04CsHYOl0nSAQJfPNIuBxqEPB2HnhQqwaOXLg1jnp2XfRo8Fa814dA4QC4zpvTWXCjAAaCY1W5LOORkEQuQ==} + '@oxlint/binding-linux-x64-gnu@1.72.0': + resolution: {integrity: sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.70.0': - resolution: {integrity: sha512-hzJa/WgvtJpbBD9rgfy0qe+MjbxOXNUT0bfR1S6EQQzfTtBFA9xg5q8KSwRrQ2QfSS+TaP4j+4mVPQrfNc6UNg==} + '@oxlint/binding-linux-x64-musl@1.72.0': + resolution: {integrity: sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.70.0': - resolution: {integrity: sha512-xbsaNSNzVSnaJACCUYr1HQMyY/Q/Q1LkePmHG3UvZPvGCYGNxrsZp9OmtA6ick8xH47ltRRbRrPCM1YXYcyC+A==} + '@oxlint/binding-openharmony-arm64@1.72.0': + resolution: {integrity: sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.70.0': - resolution: {integrity: sha512-icAEsUI7JbW1TMRdEXV83mVAInhRVQYuuAlPpxdGwJ95chNdnCzjloRW8GglT0WvzOEZSio6fnYSk2DJ2Hv7LQ==} + '@oxlint/binding-win32-arm64-msvc@1.72.0': + resolution: {integrity: sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.70.0': - resolution: {integrity: sha512-FHMSWbVsPVs/f+Jcl04ws4JJ2wUnauyTzlpxWRG/lSO/8GpX08Fo2gQZqdA6CrRFI+zvkxl+N/KwJGWfUwYVZA==} + '@oxlint/binding-win32-ia32-msvc@1.72.0': + resolution: {integrity: sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.70.0': - resolution: {integrity: sha512-ptOlKwCz7n4AKs5VweMqG6DAg677FmKOK+vBkkL9DMNgFATIQ+upqUYBTOEwRQyRAx1ncGlPlXleV2hIcm3z4g==} + '@oxlint/binding-win32-x64-msvc@1.72.0': + resolution: {integrity: sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2171,8 +2142,8 @@ packages: react: ^18.0.0 react-dom: ^18.0.0 - '@react-types/shared@3.35.0': - resolution: {integrity: sha512-iNWvuzEwANttpQpdlu8nPBtdHb0mcCMj1ZTH//iRB5E/14IAnyRlR25rxH7pNLyzHINsPGEKnWvpwDMCT6vziQ==} + '@react-types/shared@3.36.0': + resolution: {integrity: sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -2495,8 +2466,8 @@ packages: cpu: [x64] os: [win32] - '@sableclient/sable-call-embedded@1.1.4': - resolution: {integrity: sha512-XLRcbUPcn7i3QKZAPjIfUkUEXP0E4DOr0dyRoVCWMjHWj28kq+T7jeB2fRr5lB77olBwNHMjIuoTwrv02xiepQ==} + '@sableclient/sable-call-embedded@1.1.7': + resolution: {integrity: sha512-I2GSgGSUjY7ytsurTQ7JfBAsO8ZiytniknbNVzJfbN6MQjr69YDmj/UHtB2QUqwPqUmxHVxhtqVTiUgFx0k8mA==} '@sableclient/twemoji-font@1.0.4': resolution: {integrity: sha512-LzvQB/VZtv5KEq1VYq2azr7v7cYT8oklOVRGkAN2RNwa3sF0XcnqM3lBHSYRFXQleGLSUgs3gA0slbwkD2+sJw==} @@ -2505,12 +2476,12 @@ packages: resolution: {integrity: sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA==} engines: {node: '>= 18'} - '@sentry/browser-utils@10.58.0': - resolution: {integrity: sha512-TzXrhZq3Llj6qPSv0ZVG5N5c7C6qNN/aRKJXhq2LombJrLwiQrWdgizp7zdHA0FGlZ7F5YpyRA2JIpkhvrFqYA==} + '@sentry/browser-utils@10.63.0': + resolution: {integrity: sha512-DhUGNN+CH8fzAs6qAsueKPU70qShyTX3NxLhIP+l5DbGXDSXpYXBT6s8ubZus0/LhxpLvI0iSyNIDvZRD/gZaA==} engines: {node: '>=18'} - '@sentry/browser@10.58.0': - resolution: {integrity: sha512-Syf6h6HDwC15fk86+/0ql8ebwwJFw2wp+lvOX9GfLTJVOqrSILCrtLKNI+f4v/3w8mzImsv9ttJGGbUugLNvcA==} + '@sentry/browser@10.63.0': + resolution: {integrity: sha512-0mi56YOkwgyjdLOcN5cB1//EcYzEOt3NZ2GLygE92B3zAAwVM1WgbmibZCXToKFClH7z1uH3VWVfBffmkwIMYw==} engines: {node: '>=18'} '@sentry/bundler-plugin-core@5.3.0': @@ -2569,26 +2540,26 @@ packages: engines: {node: '>= 10'} hasBin: true - '@sentry/core@10.58.0': - resolution: {integrity: sha512-bkIbh2c6dzwhrWn/FGWu7j8hf6TAat2XxpkGM91LiN09fLYUXIMwcohVsXqze5l2cq35TnvqmSROAbRNr27GVw==} + '@sentry/core@10.63.0': + resolution: {integrity: sha512-OtUbsrnbEHffOF2S2+M5zXa3HIM0U2b4CDVLKMY1dgS0J3ivRF8XvkjvyIcEG/y8JXnwXbnprLyjhG+AqMdUZQ==} engines: {node: '>=18'} - '@sentry/feedback@10.58.0': - resolution: {integrity: sha512-VmIlR/0O0GXITbvgjPkQqd6yM0JDEk52WXv6Rs1kTdaIDU5h3Y64VDVN4MAbYVRHqSz7F1arjRRk2FkaKC7ZOQ==} + '@sentry/feedback@10.63.0': + resolution: {integrity: sha512-If/+72xFg9ylz4twUo3U9gUpZ+Ys+T/3Y09WH7r2gGhWEOF9bp+ta94+Pg7Lb0M2nVD7waz4OxIvB49GEvtLDA==} engines: {node: '>=18'} - '@sentry/react@10.58.0': - resolution: {integrity: sha512-3FLRtnXrue30UZALrQ9wQZeuvVmZl/pTCA+RyPlaZ5GxcYTapN9CVbm1IvbQpK4w1bt80+JxaKM4HikvII6KpA==} + '@sentry/react@10.63.0': + resolution: {integrity: sha512-+/Y0dd4EMqyqYBJ1D3bAYYuG+ccIx5+IFcbTZ9p+XWnW6nNIVjy5zttVftYo6xOmtTQbzRuPT/vO4dqDHKKmfw==} engines: {node: '>=18'} peerDependencies: react: ^16.14.0 || 17.x || 18.x || 19.x - '@sentry/replay-canvas@10.58.0': - resolution: {integrity: sha512-ufFmaJ968DXGe6u9W/UqNVjCCTtAqT2bNtSu1jHAjIFFpWIuM80lcR33grK6SuGnFxceu5iJFaIW6JRyfbM0Sw==} + '@sentry/replay-canvas@10.63.0': + resolution: {integrity: sha512-1Dg6yo+KDNZcE9M6V2EP4DGgTDJMcUgg5ui69w/E96ZZPWErS/bibK2bGj20H3qwpJXlnEwXB5YAJ2fZ620T1A==} engines: {node: '>=18'} - '@sentry/replay@10.58.0': - resolution: {integrity: sha512-EVasQNUenpwJksK9DI1TQ78YytpjLAhxn0UTiHqA3sU/s1fHK5XZdZJPr/9uEGedRoInIP0UIWmbOtOEX8HHDg==} + '@sentry/replay@10.63.0': + resolution: {integrity: sha512-u4fDaLbd4QmJbU0qGzV5g2B2hjw5utdeZzpTrmq565AS5o6mfaZdCz30zF9R2Unkn0g9SJr90piTN2RMwvDrkw==} engines: {node: '>=18'} '@sentry/rollup-plugin@5.3.0': @@ -2784,31 +2755,31 @@ packages: '@swc/wasm@1.15.41': resolution: {integrity: sha512-wFocmIRY5pSvqxzvJbsKiHyP6oNrSPqBbicF1TV3n0E1ZekSQm/hG9cbRYwxd2pVGL4Nmg8JNpIeLSwRS97Gdg==} - '@tanstack/query-core@5.101.0': - resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} + '@tanstack/query-core@5.101.2': + resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} - '@tanstack/query-devtools@5.101.0': - resolution: {integrity: sha512-MVqw17k08RQtGGLEL654+dX/btbX9p/8WjkznO//zusLTMaObxi3Q+MoFwGVkC9K3tqjn8qrrNhJevXx4fJTeQ==} + '@tanstack/query-devtools@5.101.2': + resolution: {integrity: sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w==} - '@tanstack/react-query-devtools@5.101.0': - resolution: {integrity: sha512-cpZA0+WqKXwrwMfiWZEGGF6QrIWVQFbhBtxqDF5sQsAfrFf47HIE6fiPbQU3wyAUEN2+7UNqLCQe7oG6m3f93w==} + '@tanstack/react-query-devtools@5.101.2': + resolution: {integrity: sha512-eU7HctdA9gDjqoERoEdzLbw9DiqnBDfh5+Hu0u26gjqoHJezOpQAuiesDL2VvkU+2cPV76zgv0tMZsOrI4LjnQ==} peerDependencies: - '@tanstack/react-query': ^5.101.0 + '@tanstack/react-query': ^5.101.2 react: ^18 || ^19 - '@tanstack/react-query@5.101.0': - resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==} + '@tanstack/react-query@5.101.2': + resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==} peerDependencies: react: ^18 || ^19 - '@tanstack/react-virtual@3.14.3': - resolution: {integrity: sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==} + '@tanstack/react-virtual@3.14.5': + resolution: {integrity: sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/virtual-core@3.17.1': - resolution: {integrity: sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==} + '@tanstack/virtual-core@3.17.3': + resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==} '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} @@ -2887,8 +2858,8 @@ packages: '@types/leaflet@1.9.21': resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} - '@types/node@24.10.13': - resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} + '@types/node@24.13.2': + resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -2910,9 +2881,6 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@types/ua-parser-js@0.7.39': - resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==} - '@use-gesture/core@10.3.1': resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} @@ -2927,8 +2895,8 @@ packages: '@vanilla-extract/compiler@0.7.0': resolution: {integrity: sha512-rZQ40HVmsxfGLjoflwwsaUBLfpbpKDoZC19oiDA0FHq4LdrYtyVbFkc0MfqkNo/qBCvaZfsRezCqk0QQxCqZ8w==} - '@vanilla-extract/css@1.20.1': - resolution: {integrity: sha512-5I9RNo5uZW9tsBnqrWzJqELegOqTHBrZyDFnES0gR9gJJHBB9dom1N0bwITM9tKwBcfKrTX4a6DHVeQdJ2ubQA==} + '@vanilla-extract/css@1.21.1': + resolution: {integrity: sha512-T6z6oeT+o0OwqukE6kLs9w5ooB75b8NPRadyU9pPTml83l40TPN+mscPt2OQ9P9cYfwEBMfsfivwiJujs2mzmg==} '@vanilla-extract/integration@8.0.10': resolution: {integrity: sha512-01IB5gbrgTe8IIrtfRXXTmACl5D8Enzqp2cKbCWaMKXmnoilXXVCPbJoA96q88PXkNDXsXepCxUugMvEmL3c7A==} @@ -2941,8 +2909,8 @@ packages: peerDependencies: '@vanilla-extract/css': ^1.0.0 - '@vanilla-extract/vite-plugin@5.2.2': - resolution: {integrity: sha512-AUyB4fDR2b/Mo0lcXhhlf6RxnDPYwFMyKKopalJ4BwQNKYzZSoTwHJ1PLPO9SKhpz7lzXc0Z18GHQZOewzl3YA==} + '@vanilla-extract/vite-plugin@5.2.3': + resolution: {integrity: sha512-OwkW0DVNrDBIOpxexE83Y7lpjRsTsu+87xGzo2JXy6qUEJuM+b8/iEqt+ZUbvCNLs6bnC9+QNISZOG33rPMBlg==} peerDependencies: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -3322,6 +3290,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-europe-js@0.1.2: + resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -3336,21 +3307,24 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - dom-serializer@2.0.0: - resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} - domelementtype@2.3.0: - resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} - domhandler@5.0.3: - resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} - engines: {node: '>= 4'} + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} - dompurify@3.4.10: - resolution: {integrity: sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==} + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} - domutils@3.2.2: - resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -3384,10 +3358,6 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - entities@7.0.1: - resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} - engines: {node: '>=0.12'} - entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -3429,11 +3399,6 @@ packages: resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==} engines: {node: '>= 0.4'} - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -3479,19 +3444,12 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - fd-package-json@2.0.0: resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} @@ -3524,15 +3482,16 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - focus-trap-react@10.3.1: - resolution: {integrity: sha512-PN4Ya9xf9nyj/Nd9VxBNMuD7IrlRbmaG6POAQ8VLqgtc6IY/Ln1tYakow+UIq4fihYYYFM70/2oyidE6bbiPgw==} + focus-trap-react@12.0.3: + resolution: {integrity: sha512-4eXtzhRTtFrxle9Tkl0Wkj8SFJnWz3i3yb5e53Mdn4E8XZQ/Lzqom9U4uUAJ8wd7yDmyGVP96dxwQZXLXd4sbg==} peerDependencies: - prop-types: ^15.8.1 - react: '>=16.3.0' - react-dom: '>=16.3.0' + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 - focus-trap@7.8.0: - resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + focus-trap@8.2.2: + resolution: {integrity: sha512-qV0g8hRYBqgACcFOH3f9wXc4zPKhr/0z9RI2a6ZijZ72EeBi4g8oBy8zAWuUR1TsMpOzwpUMFvjdasrC41Joug==} folds@2.6.2: resolution: {integrity: sha512-1HemxxSnBm8/U5kq1pDQrFkpltWgQN90DmWCZWkZb7D2pe8BhOJSwIRLjk9WxHcw6nn69oz2XNYIXtSw0LvX1w==} @@ -3556,8 +3515,8 @@ packages: engines: {node: '>=18.3.0'} hasBin: true - framer-motion@12.40.0: - resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} + framer-motion@12.42.2: + resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -3612,6 +3571,9 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3667,11 +3629,8 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} - html-dom-parser@5.0.3: - resolution: {integrity: sha512-slsc6ipw88OUZjAayRs5NTmfOQCwcUa3hNyk6AdsbQxY09H5Lr1Y3CZ4ZlconMKql3Ga6sWg3HMoUzo7KSItaQ==} - - html-dom-parser@5.1.8: - resolution: {integrity: sha512-MCIUng//mF2qTtGHXJWr6OLfHWmg3Pm8ezpfiltF83tizPWY17JxT4dRLE8lykJ5bChJELoY3onQKPbufJHxYA==} + html-dom-parser@8.0.0: + resolution: {integrity: sha512-cWLJ8mt930VceOzVfY/J+T1ou9v3ENT0BgWqy5aEZjdFp37TYLXULjX8u0vD2rBpFAkK5IofOPl0y4Gq0QLIyA==} html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} @@ -3683,16 +3642,18 @@ packages: html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} - html-react-parser@4.2.10: - resolution: {integrity: sha512-JyKZVQ+kQ8PdycISwkuLbEEvV/k4hWhU6cb6TT7yGaYwdqA7cPt4VRYXkCZcix2vlQtgDBSMJUmPI2jpNjPGvg==} + html-react-parser@6.1.3: + resolution: {integrity: sha512-CTsKtLJA23cgTbcJYT8vgHAPSqGY7NM+/mv+Tv6I0DqkLCa3FlceK2htH8AwlGa03BopqnVvK/XHqQq7HAw/sg==} peerDependencies: - react: 0.14 || 15 || 16 || 17 || 18 - - htmlparser2@10.1.0: - resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + '@types/react': 0.14 || 15 || 16 || 17 || 18 || 19 + react: 0.14 || 15 || 16 || 17 || 18 || 19 + peerDependenciesMeta: + '@types/react': + optional: true - htmlparser2@9.0.0: - resolution: {integrity: sha512-uxbSI98wmFT/G4P2zXx4OVx04qWUmyFPrD2/CNepa2Zo3GPNaCaaxElDgwUrwYWkK1nr9fft0Ya8dws8coDLLQ==} + htmlparser2@12.0.0: + resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==} + engines: {node: '>=20.19.0'} https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} @@ -3718,8 +3679,8 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - immer@9.0.21: - resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==} + immer@11.1.9: + resolution: {integrity: sha512-sc/z0Cyti70bZa0ZU4sWfAElfovFb9Ni8tArJZLuklYWxegPiK3pDOql1Rq5H0FIRAW9LSQRG6OX4KqBldbhBA==} import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} @@ -3729,8 +3690,8 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} - inline-style-parser@0.2.2: - resolution: {integrity: sha512-EcKzdTHVe8wFVOGEYXiW9WmJXPjqi1T+234YpJr98RiFYKHV3cdy1+3mkTE+KHTHxFFLH51SfaGOoUdW+v7ViQ==} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} @@ -3848,6 +3809,9 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-standalone-pwa@0.1.1: + resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -3974,21 +3938,18 @@ packages: resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} engines: {node: '>=18'} - katex@0.16.47: - resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + katex@0.17.0: + resolution: {integrity: sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==} hasBin: true kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - knip@5.85.0: - resolution: {integrity: sha512-V2kyON+DZiYdNNdY6GALseiNCwX7dYdpz9Pv85AUn69Gk0UKCts+glOKWfe5KmaMByRjM9q17Mzj/KinTVOyxg==} - engines: {node: '>=18.18.0'} + knip@6.23.0: + resolution: {integrity: sha512-2DvAOX2pZWiG4SLvRRxOAU0aWGEn1ZoVblI541xIoXFdHqq2THMZXy66/qcY5WGuW3TXhb9T1x1zd/Hd1u+yqg==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - peerDependencies: - '@types/node': '>=18' - typescript: '>=5.0.4 <7' leaflet@1.9.4: resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} @@ -4159,20 +4120,12 @@ packages: media-query-parser@2.0.2: resolution: {integrity: sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==} - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - miniflare@4.20260616.0: - resolution: {integrity: sha512-cEpzoNgSWjedzYmhJvttUPmL4Jk6nSzzeNNi118T5zwnmYP9fnM8UXwFU/Qa/1qoQ4SzGqtM1Q7tinHvHvIGtw==} + miniflare@4.20260630.0: + resolution: {integrity: sha512-lyRplDrSJJWVpzSSQPBSQtNmUuxScCZyOOkXFs37uSbdTfWRDDmw6DyFKVS2s1eYtA/i4u2xR/0FyPIsTl/HJw==} engines: {node: '>=22.0.0'} hasBin: true @@ -4184,9 +4137,6 @@ packages: resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -4197,8 +4147,8 @@ packages: modern-ahocorasick@1.1.0: resolution: {integrity: sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==} - motion-dom@12.40.0: - resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} + motion-dom@12.42.2: + resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==} motion-utils@12.39.0: resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} @@ -4263,20 +4213,32 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} - oxc-resolver@11.20.0: - resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==} + oxc-parser@0.137.0: + resolution: {integrity: sha512-yFImD+WLElJpLKy8llG1qe4DCmMsL18peRp8XP1JKfig/gISbJkglnpDtX2aTmAn10kZF7164HbN2H8QPsXxGg==} + engines: {node: ^20.19.0 || >=22.12.0} - oxfmt@0.45.0: - resolution: {integrity: sha512-0o/COoN9fY50bjVeM7PQsNgbhndKurBIeTIcspW033OumksjJJmIVDKjAk5HMwU/GHTxSOdGDdhJ6BRzGPmsHg==} + oxc-resolver@11.21.3: + resolution: {integrity: sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA==} + + oxfmt@0.57.0: + resolution: {integrity: sha512-ZB7Bi+rGDSqmVIo9jwcLyFgjxXvQhDdU+jx+ZrVy6VRiVXK2+CHc4hO3J4dUQjHe7V0ymHB+MDuv5z+NhK07HA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true - oxlint-tsgolint@0.23.0: - resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==} + oxlint-tsgolint@0.24.0: + resolution: {integrity: sha512-giCk5sEvG02d5tzPmFMX3hem8ndzEEu1xvGYS5OwNfO2WGl6ZVxt5LjE0yiMDoz94INI7XkXwgFAQiydPvVHDw==} hasBin: true - oxlint@1.70.0: - resolution: {integrity: sha512-D6JgHtzkhRwvEC+A0Nw5AEc5bk8x5i1pHzvZIEf/a0C4hOzmAACNGtkDGPyFaxxX3ZVGxCPeig3P3rMM8XU3/g==} + oxlint@1.72.0: + resolution: {integrity: sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -4343,8 +4305,8 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pdfjs-dist@5.7.284: - resolution: {integrity: sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==} + pdfjs-dist@6.1.200: + resolution: {integrity: sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==} engines: {node: '>=22.13.0 || >=24'} picocolors@1.1.1: @@ -4395,17 +4357,14 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - raf-schd@4.0.3: resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} - react-aria@3.49.0: - resolution: {integrity: sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==} + react-aria@3.50.0: + resolution: {integrity: sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -4432,8 +4391,8 @@ packages: peerDependencies: react: ^18.3.1 - react-google-recaptcha@2.1.0: - resolution: {integrity: sha512-K9jr7e0CWFigi8KxC3WPvNqZZ47df2RrMAta6KmRoE4RUi7Ys6NmNjytpXpg4HI/svmQJLKR+PncEPaNJ98DqQ==} + react-google-recaptcha@3.1.0: + resolution: {integrity: sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==} peerDependencies: react: '>=16.4.1' @@ -4492,8 +4451,8 @@ packages: peerDependencies: react: '>=16.8' - react-stately@3.47.0: - resolution: {integrity: sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==} + react-stately@3.48.0: + resolution: {integrity: sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -4546,15 +4505,14 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} hasBin: true - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rolldown@1.0.3: resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4570,9 +4528,6 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - safe-array-concat@1.1.4: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} @@ -4675,8 +4630,8 @@ packages: peerDependencies: slate: '>=0.65.3' - slate-react@0.124.2: - resolution: {integrity: sha512-2B6ZZX5qKYeISNaQ9cDuvvp0tWydnHUPuGxyVJZfjD+W00X34dGaAF/5ZyVMZt/O//sf0Glbgd7+NBHRWjiA7Q==} + slate-react@0.125.1: + resolution: {integrity: sha512-Cb9CROi4zp/p+a+f94srWPRhVCxTqQ+78ExSN7EBN/XoYf6GjGwafKtyTjpZCbr6OLY+raeDC+s3Rgxf1MFiEw==} peerDependencies: react: '>=18.2.0' react-dom: '>=18.2.0' @@ -4759,11 +4714,11 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} - style-to-js@1.1.8: - resolution: {integrity: sha512-bPSspCXkkhETLXnEgDbaoWRWyv3lF2bj32YIc8IElok2IIMHUlZtQUrxYmAkKUNxpluhH0qnKWrmuoXUyTY12g==} + style-to-js@2.0.0: + resolution: {integrity: sha512-amkl/SwHF/Gb430+eOiN+XToZ6VsD2nota1kCXps4k1xagZOniEVNm8zKYm7RrGm2Q6d6hjnZdqebFIunoSJng==} - style-to-object@1.0.3: - resolution: {integrity: sha512-xOpx7S53E0V3DpVsvt7ySvoiumRpfXiC99PUXLqGB3wiAnN9ybEIpuzlZ8LAZg+h1sl9JkEUwtSQXxcCgFqbbg==} + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} @@ -4783,8 +4738,8 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - tabbable@6.4.0: - resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} tar-mini@0.2.0: resolution: {integrity: sha512-+qfUHz700DWnRutdUsxRRVZ38G1Qr27OetwaMYTdg8hcPxf46U0S1Zf76dQMWRBmusOt2ZCK5kbIaiLkoGO7WQ==} @@ -4881,23 +4836,26 @@ packages: engines: {node: '>=14.17'} hasBin: true - ua-parser-js@1.0.41: - resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} + ua-is-frozen@0.1.2: + resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==} + + ua-parser-js@2.0.10: + resolution: {integrity: sha512-t+3Ktbq0Ies2vaSezfOaWiolH4OigQIO1dk+1xDpOydB1COVPocVYOrEV5rqZ0kFY9XYG1v9LutCyMgYBpABcw==} hasBin: true ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + unbash@4.0.2: + resolution: {integrity: sha512-8gwNZ29+0/3zmXw7ToIHZtg6wK37xnniRUdBt7B27xZxaxfgR5tGMaGHT0t0dLtBV9fXE7zurh0s6Z1DHVjfWg==} + engines: {node: '>=14'} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - - undici@7.24.8: - resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} - engines: {node: '>=20.18.1'} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} undici@7.28.0: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} @@ -4957,8 +4915,8 @@ packages: resolution: {integrity: sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==} hasBin: true - virtua@0.49.1: - resolution: {integrity: sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg==} + virtua@0.49.2: + resolution: {integrity: sha512-aEp3+6cmIRjHUlQnWdgGXYMYtrIG26QnN9jJDZEE5LRhvo1Z9HzoJwLDgyVULUPWcSdCnZAroQm7raXJyTG0AA==} peerDependencies: react: '>=16.14.0' react-dom: '>=16.14.0' @@ -5260,23 +5218,23 @@ packages: workbox-window@7.4.0: resolution: {integrity: sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==} - workerd@1.20260616.1: - resolution: {integrity: sha512-aRGWYxviSjYZwyu97pCr5GyJ9ObpgmNcfZZs3/o+kG7Wz3SBTqA8d8uhNueY5u7ADeUp2ibJvK6mXkFLrUmPgg==} + workerd@1.20260630.1: + resolution: {integrity: sha512-7M0AA4l14hmPGtzQ5YPHyXosIKI/uz3TdcPHeiFDbgb7/0c8ECVMzIaodSV5bZIVhDHL0OlzqITAdPiwAr+dTg==} engines: {node: '>=16'} hasBin: true - wrangler@4.101.0: - resolution: {integrity: sha512-dZDDiRcT7MiA09lBDxWKmiL/iybEZ+SZe3IZmnVx1m1n1DOo730vOY5SeO7z9xFK8a/+vhGKDYB8mDXrvzEr5g==} + wrangler@4.106.0: + resolution: {integrity: sha512-b6EVbsvbmAUY4bUQXT3+f8oFP8x+J5rEa5z3Akeh+6vyKiN4x8+PyZ53DPpnqdxhIihhq/a00Yq5chGJ19QXBQ==} engines: {node: '>=22.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20260616.1 + '@cloudflare/workers-types': ^4.20260630.1 peerDependenciesMeta: '@cloudflare/workers-types': optional: true - ws@8.20.1: - resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -5297,6 +5255,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -5320,7 +5283,7 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 - '@arborium/arborium@2.18.0': {} + '@arborium/arborium@2.18.1': {} '@asamuzakjp/css-color@5.1.11': dependencies: @@ -5342,17 +5305,17 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} - '@atlaskit/pragmatic-drag-and-drop-auto-scroll@1.4.0': + '@atlaskit/pragmatic-drag-and-drop-auto-scroll@3.0.0': dependencies: - '@atlaskit/pragmatic-drag-and-drop': 1.8.1 + '@atlaskit/pragmatic-drag-and-drop': 2.0.1 '@babel/runtime': 7.29.7 - '@atlaskit/pragmatic-drag-and-drop-hitbox@1.2.0': + '@atlaskit/pragmatic-drag-and-drop-hitbox@2.0.0': dependencies: - '@atlaskit/pragmatic-drag-and-drop': 1.8.1 + '@atlaskit/pragmatic-drag-and-drop': 2.0.1 '@babel/runtime': 7.29.7 - '@atlaskit/pragmatic-drag-and-drop@1.8.1': + '@atlaskit/pragmatic-drag-and-drop@2.0.1': dependencies: '@babel/runtime': 7.29.7 bind-event-listener: 3.0.0 @@ -6044,38 +6007,38 @@ snapshots: '@cloudflare/kv-asset-handler@0.5.0': {} - '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260616.1)': + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260630.1)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: - workerd: 1.20260616.1 + workerd: 1.20260630.1 - '@cloudflare/vite-plugin@1.41.0(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workerd@1.20260616.1)(wrangler@4.101.0)': + '@cloudflare/vite-plugin@1.42.4(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(workerd@1.20260630.1)(wrangler@4.106.0)': dependencies: - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260616.1) - miniflare: 4.20260616.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260630.1) + miniflare: 4.20260630.0 unenv: 2.0.0-rc.24 - vite: 7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) - wrangler: 4.101.0 - ws: 8.20.1 + vite: 7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + wrangler: 4.106.0 + ws: 8.21.0 transitivePeerDependencies: - bufferutil - utf-8-validate - workerd - '@cloudflare/workerd-darwin-64@1.20260616.1': + '@cloudflare/workerd-darwin-64@1.20260630.1': optional: true - '@cloudflare/workerd-darwin-arm64@1.20260616.1': + '@cloudflare/workerd-darwin-arm64@1.20260630.1': optional: true - '@cloudflare/workerd-linux-64@1.20260616.1': + '@cloudflare/workerd-linux-64@1.20260630.1': optional: true - '@cloudflare/workerd-linux-arm64@1.20260616.1': + '@cloudflare/workerd-linux-arm64@1.20260630.1': optional: true - '@cloudflare/workerd-windows-64@1.20260616.1': + '@cloudflare/workerd-windows-64@1.20260630.1': optional: true '@cspotcode/source-map-support@0.8.1': @@ -6112,11 +6075,28 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.0': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.0': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.11.1': dependencies: tslib: 2.8.1 @@ -6127,240 +6107,167 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emotion/hash@0.9.2': {} '@esbuild-plugins/node-globals-polyfill@0.2.3(esbuild@0.28.1)': dependencies: esbuild: 0.28.1 - '@esbuild/aix-ppc64@0.27.3': - optional: true - '@esbuild/aix-ppc64@0.27.7': optional: true '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.27.3': - optional: true - '@esbuild/android-arm64@0.27.7': optional: true '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.27.3': - optional: true - '@esbuild/android-arm@0.27.7': optional: true '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.27.3': - optional: true - '@esbuild/android-x64@0.27.7': optional: true '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.27.3': - optional: true - '@esbuild/darwin-arm64@0.27.7': optional: true '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.27.3': - optional: true - '@esbuild/darwin-x64@0.27.7': optional: true '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.27.3': - optional: true - '@esbuild/freebsd-arm64@0.27.7': optional: true '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.27.3': - optional: true - '@esbuild/freebsd-x64@0.27.7': optional: true '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.27.3': - optional: true - '@esbuild/linux-arm64@0.27.7': optional: true '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.27.3': - optional: true - '@esbuild/linux-arm@0.27.7': optional: true '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.27.3': - optional: true - '@esbuild/linux-ia32@0.27.7': optional: true '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.27.3': - optional: true - '@esbuild/linux-loong64@0.27.7': optional: true '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.27.3': - optional: true - '@esbuild/linux-mips64el@0.27.7': optional: true '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.27.3': - optional: true - '@esbuild/linux-ppc64@0.27.7': optional: true '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.27.3': - optional: true - '@esbuild/linux-riscv64@0.27.7': optional: true '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.27.3': - optional: true - '@esbuild/linux-s390x@0.27.7': optional: true '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.27.3': - optional: true - '@esbuild/linux-x64@0.27.7': optional: true '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.27.3': - optional: true - '@esbuild/netbsd-arm64@0.27.7': optional: true '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.27.3': - optional: true - '@esbuild/netbsd-x64@0.27.7': optional: true '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.27.3': - optional: true - '@esbuild/openbsd-arm64@0.27.7': optional: true '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.27.3': - optional: true - '@esbuild/openbsd-x64@0.27.7': optional: true '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.27.3': - optional: true - '@esbuild/openharmony-arm64@0.27.7': optional: true '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.27.3': - optional: true - '@esbuild/sunos-x64@0.27.7': optional: true '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.27.3': - optional: true - '@esbuild/win32-arm64@0.27.7': optional: true '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.27.3': - optional: true - '@esbuild/win32-ia32@0.27.7': optional: true '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.27.3': - optional: true - '@esbuild/win32-x64@0.27.7': optional: true @@ -6516,52 +6423,52 @@ snapshots: '@matrix-org/matrix-sdk-crypto-wasm@15.3.0': {} - '@napi-rs/canvas-android-arm64@0.1.100': + '@napi-rs/canvas-android-arm64@1.0.2': optional: true - '@napi-rs/canvas-darwin-arm64@0.1.100': + '@napi-rs/canvas-darwin-arm64@1.0.2': optional: true - '@napi-rs/canvas-darwin-x64@0.1.100': + '@napi-rs/canvas-darwin-x64@1.0.2': optional: true - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.2': optional: true - '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + '@napi-rs/canvas-linux-arm64-gnu@1.0.2': optional: true - '@napi-rs/canvas-linux-arm64-musl@0.1.100': + '@napi-rs/canvas-linux-arm64-musl@1.0.2': optional: true - '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + '@napi-rs/canvas-linux-riscv64-gnu@1.0.2': optional: true - '@napi-rs/canvas-linux-x64-gnu@0.1.100': + '@napi-rs/canvas-linux-x64-gnu@1.0.2': optional: true - '@napi-rs/canvas-linux-x64-musl@0.1.100': + '@napi-rs/canvas-linux-x64-musl@1.0.2': optional: true - '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + '@napi-rs/canvas-win32-arm64-msvc@1.0.2': optional: true - '@napi-rs/canvas-win32-x64-msvc@0.1.100': + '@napi-rs/canvas-win32-x64-msvc@1.0.2': optional: true - '@napi-rs/canvas@0.1.100': + '@napi-rs/canvas@1.0.2': optionalDependencies: - '@napi-rs/canvas-android-arm64': 0.1.100 - '@napi-rs/canvas-darwin-arm64': 0.1.100 - '@napi-rs/canvas-darwin-x64': 0.1.100 - '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100 - '@napi-rs/canvas-linux-arm64-gnu': 0.1.100 - '@napi-rs/canvas-linux-arm64-musl': 0.1.100 - '@napi-rs/canvas-linux-riscv64-gnu': 0.1.100 - '@napi-rs/canvas-linux-x64-gnu': 0.1.100 - '@napi-rs/canvas-linux-x64-musl': 0.1.100 - '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 - '@napi-rs/canvas-win32-x64-msvc': 0.1.100 + '@napi-rs/canvas-android-arm64': 1.0.2 + '@napi-rs/canvas-darwin-arm64': 1.0.2 + '@napi-rs/canvas-darwin-x64': 1.0.2 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.2 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.2 + '@napi-rs/canvas-linux-arm64-musl': 1.0.2 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.2 + '@napi-rs/canvas-linux-x64-gnu': 1.0.2 + '@napi-rs/canvas-linux-x64-musl': 1.0.2 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.2 + '@napi-rs/canvas-win32-x64-msvc': 1.0.2 optional: true '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': @@ -6571,211 +6478,279 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true - '@nodelib/fs.scandir@2.1.5': + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': + dependencies: + '@emnapi/core': 1.11.0 + '@emnapi/runtime': 1.11.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@oxc-parser/binding-android-arm-eabi@0.137.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.137.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.137.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.137.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.137.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.137.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.137.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.137.0': + optional: true - '@nodelib/fs.stat@2.0.5': {} + '@oxc-parser/binding-linux-arm64-musl@0.137.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.137.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.137.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.137.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.137.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.137.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.137.0': + optional: true - '@nodelib/fs.walk@1.2.8': + '@oxc-parser/binding-openharmony-arm64@0.137.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.137.0': dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.137.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.137.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.137.0': + optional: true '@oxc-project/types@0.133.0': {} - '@oxc-resolver/binding-android-arm-eabi@11.20.0': + '@oxc-project/types@0.137.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.21.3': optional: true - '@oxc-resolver/binding-android-arm64@11.20.0': + '@oxc-resolver/binding-android-arm64@11.21.3': optional: true - '@oxc-resolver/binding-darwin-arm64@11.20.0': + '@oxc-resolver/binding-darwin-arm64@11.21.3': optional: true - '@oxc-resolver/binding-darwin-x64@11.20.0': + '@oxc-resolver/binding-darwin-x64@11.21.3': optional: true - '@oxc-resolver/binding-freebsd-x64@11.20.0': + '@oxc-resolver/binding-freebsd-x64@11.21.3': optional: true - '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0': + '@oxc-resolver/binding-linux-arm-gnueabihf@11.21.3': optional: true - '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0': + '@oxc-resolver/binding-linux-arm-musleabihf@11.21.3': optional: true - '@oxc-resolver/binding-linux-arm64-gnu@11.20.0': + '@oxc-resolver/binding-linux-arm64-gnu@11.21.3': optional: true - '@oxc-resolver/binding-linux-arm64-musl@11.20.0': + '@oxc-resolver/binding-linux-arm64-musl@11.21.3': optional: true - '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': + '@oxc-resolver/binding-linux-ppc64-gnu@11.21.3': optional: true - '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': + '@oxc-resolver/binding-linux-riscv64-gnu@11.21.3': optional: true - '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': + '@oxc-resolver/binding-linux-riscv64-musl@11.21.3': optional: true - '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': + '@oxc-resolver/binding-linux-s390x-gnu@11.21.3': optional: true - '@oxc-resolver/binding-linux-x64-gnu@11.20.0': + '@oxc-resolver/binding-linux-x64-gnu@11.21.3': optional: true - '@oxc-resolver/binding-linux-x64-musl@11.20.0': + '@oxc-resolver/binding-linux-x64-musl@11.21.3': optional: true - '@oxc-resolver/binding-openharmony-arm64@11.20.0': + '@oxc-resolver/binding-openharmony-arm64@11.21.3': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.20.0': + '@oxc-resolver/binding-wasm32-wasi@11.21.3': dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@emnapi/core': 1.11.0 + '@emnapi/runtime': 1.11.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0) optional: true - '@oxc-resolver/binding-win32-arm64-msvc@11.20.0': + '@oxc-resolver/binding-win32-arm64-msvc@11.21.3': optional: true - '@oxc-resolver/binding-win32-x64-msvc@11.20.0': + '@oxc-resolver/binding-win32-x64-msvc@11.21.3': optional: true - '@oxfmt/binding-android-arm-eabi@0.45.0': + '@oxfmt/binding-android-arm-eabi@0.57.0': optional: true - '@oxfmt/binding-android-arm64@0.45.0': + '@oxfmt/binding-android-arm64@0.57.0': optional: true - '@oxfmt/binding-darwin-arm64@0.45.0': + '@oxfmt/binding-darwin-arm64@0.57.0': optional: true - '@oxfmt/binding-darwin-x64@0.45.0': + '@oxfmt/binding-darwin-x64@0.57.0': optional: true - '@oxfmt/binding-freebsd-x64@0.45.0': + '@oxfmt/binding-freebsd-x64@0.57.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.45.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.57.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.45.0': + '@oxfmt/binding-linux-arm-musleabihf@0.57.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.45.0': + '@oxfmt/binding-linux-arm64-gnu@0.57.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.45.0': + '@oxfmt/binding-linux-arm64-musl@0.57.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.45.0': + '@oxfmt/binding-linux-ppc64-gnu@0.57.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.45.0': + '@oxfmt/binding-linux-riscv64-gnu@0.57.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.45.0': + '@oxfmt/binding-linux-riscv64-musl@0.57.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.45.0': + '@oxfmt/binding-linux-s390x-gnu@0.57.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.45.0': + '@oxfmt/binding-linux-x64-gnu@0.57.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.45.0': + '@oxfmt/binding-linux-x64-musl@0.57.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.45.0': + '@oxfmt/binding-openharmony-arm64@0.57.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.45.0': + '@oxfmt/binding-win32-arm64-msvc@0.57.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.45.0': + '@oxfmt/binding-win32-ia32-msvc@0.57.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.45.0': + '@oxfmt/binding-win32-x64-msvc@0.57.0': optional: true - '@oxlint-tsgolint/darwin-arm64@0.23.0': + '@oxlint-tsgolint/darwin-arm64@0.24.0': optional: true - '@oxlint-tsgolint/darwin-x64@0.23.0': + '@oxlint-tsgolint/darwin-x64@0.24.0': optional: true - '@oxlint-tsgolint/linux-arm64@0.23.0': + '@oxlint-tsgolint/linux-arm64@0.24.0': optional: true - '@oxlint-tsgolint/linux-x64@0.23.0': + '@oxlint-tsgolint/linux-x64@0.24.0': optional: true - '@oxlint-tsgolint/win32-arm64@0.23.0': + '@oxlint-tsgolint/win32-arm64@0.24.0': optional: true - '@oxlint-tsgolint/win32-x64@0.23.0': + '@oxlint-tsgolint/win32-x64@0.24.0': optional: true - '@oxlint/binding-android-arm-eabi@1.70.0': + '@oxlint/binding-android-arm-eabi@1.72.0': optional: true - '@oxlint/binding-android-arm64@1.70.0': + '@oxlint/binding-android-arm64@1.72.0': optional: true - '@oxlint/binding-darwin-arm64@1.70.0': + '@oxlint/binding-darwin-arm64@1.72.0': optional: true - '@oxlint/binding-darwin-x64@1.70.0': + '@oxlint/binding-darwin-x64@1.72.0': optional: true - '@oxlint/binding-freebsd-x64@1.70.0': + '@oxlint/binding-freebsd-x64@1.72.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.70.0': + '@oxlint/binding-linux-arm-gnueabihf@1.72.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.70.0': + '@oxlint/binding-linux-arm-musleabihf@1.72.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.70.0': + '@oxlint/binding-linux-arm64-gnu@1.72.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.70.0': + '@oxlint/binding-linux-arm64-musl@1.72.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.70.0': + '@oxlint/binding-linux-ppc64-gnu@1.72.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.70.0': + '@oxlint/binding-linux-riscv64-gnu@1.72.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.70.0': + '@oxlint/binding-linux-riscv64-musl@1.72.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.70.0': + '@oxlint/binding-linux-s390x-gnu@1.72.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.70.0': + '@oxlint/binding-linux-x64-gnu@1.72.0': optional: true - '@oxlint/binding-linux-x64-musl@1.70.0': + '@oxlint/binding-linux-x64-musl@1.72.0': optional: true - '@oxlint/binding-openharmony-arm64@1.70.0': + '@oxlint/binding-openharmony-arm64@1.72.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.70.0': + '@oxlint/binding-win32-arm64-msvc@1.72.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.70.0': + '@oxlint/binding-win32-ia32-msvc@1.72.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.70.0': + '@oxlint/binding-win32-x64-msvc@1.72.0': optional: true '@phosphor-icons/react@2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': @@ -6803,7 +6778,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@react-types/shared@3.35.0(react@18.3.1)': + '@react-types/shared@3.36.0(react@18.3.1)': dependencies: react: 18.3.1 @@ -7005,23 +6980,23 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.0': optional: true - '@sableclient/sable-call-embedded@1.1.4': {} + '@sableclient/sable-call-embedded@1.1.7': {} '@sableclient/twemoji-font@1.0.4': {} '@sentry/babel-plugin-component-annotate@5.3.0': {} - '@sentry/browser-utils@10.58.0': + '@sentry/browser-utils@10.63.0': dependencies: - '@sentry/core': 10.58.0 + '@sentry/core': 10.63.0 - '@sentry/browser@10.58.0': + '@sentry/browser@10.63.0': dependencies: - '@sentry/browser-utils': 10.58.0 - '@sentry/core': 10.58.0 - '@sentry/feedback': 10.58.0 - '@sentry/replay': 10.58.0 - '@sentry/replay-canvas': 10.58.0 + '@sentry/browser-utils': 10.63.0 + '@sentry/core': 10.63.0 + '@sentry/feedback': 10.63.0 + '@sentry/replay': 10.63.0 + '@sentry/replay-canvas': 10.63.0 '@sentry/bundler-plugin-core@5.3.0': dependencies: @@ -7080,27 +7055,27 @@ snapshots: - encoding - supports-color - '@sentry/core@10.58.0': {} + '@sentry/core@10.63.0': {} - '@sentry/feedback@10.58.0': + '@sentry/feedback@10.63.0': dependencies: - '@sentry/core': 10.58.0 + '@sentry/core': 10.63.0 - '@sentry/react@10.58.0(react@18.3.1)': + '@sentry/react@10.63.0(react@18.3.1)': dependencies: - '@sentry/browser': 10.58.0 - '@sentry/core': 10.58.0 + '@sentry/browser': 10.63.0 + '@sentry/core': 10.63.0 react: 18.3.1 - '@sentry/replay-canvas@10.58.0': + '@sentry/replay-canvas@10.63.0': dependencies: - '@sentry/core': 10.58.0 - '@sentry/replay': 10.58.0 + '@sentry/core': 10.63.0 + '@sentry/replay': 10.63.0 - '@sentry/replay@10.58.0': + '@sentry/replay@10.63.0': dependencies: - '@sentry/browser-utils': 10.58.0 - '@sentry/core': 10.58.0 + '@sentry/browser-utils': 10.63.0 + '@sentry/core': 10.63.0 '@sentry/rollup-plugin@5.3.0(rollup@2.80.0)': dependencies: @@ -7271,28 +7246,28 @@ snapshots: '@swc/wasm@1.15.41': {} - '@tanstack/query-core@5.101.0': {} + '@tanstack/query-core@5.101.2': {} - '@tanstack/query-devtools@5.101.0': {} + '@tanstack/query-devtools@5.101.2': {} - '@tanstack/react-query-devtools@5.101.0(@tanstack/react-query@5.101.0(react@18.3.1))(react@18.3.1)': + '@tanstack/react-query-devtools@5.101.2(@tanstack/react-query@5.101.2(react@18.3.1))(react@18.3.1)': dependencies: - '@tanstack/query-devtools': 5.101.0 - '@tanstack/react-query': 5.101.0(react@18.3.1) + '@tanstack/query-devtools': 5.101.2 + '@tanstack/react-query': 5.101.2(react@18.3.1) react: 18.3.1 - '@tanstack/react-query@5.101.0(react@18.3.1)': + '@tanstack/react-query@5.101.2(react@18.3.1)': dependencies: - '@tanstack/query-core': 5.101.0 + '@tanstack/query-core': 5.101.2 react: 18.3.1 - '@tanstack/react-virtual@3.14.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tanstack/react-virtual@3.14.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@tanstack/virtual-core': 3.17.1 + '@tanstack/virtual-core': 3.17.3 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@tanstack/virtual-core@3.17.1': {} + '@tanstack/virtual-core@3.17.3': {} '@testing-library/dom@10.4.1': dependencies: @@ -7381,9 +7356,9 @@ snapshots: dependencies: '@types/geojson': 7946.0.16 - '@types/node@24.10.13': + '@types/node@24.13.2': dependencies: - undici-types: 7.16.0 + undici-types: 7.18.2 '@types/prop-types@15.7.15': {} @@ -7404,8 +7379,6 @@ snapshots: '@types/trusted-types@2.0.7': {} - '@types/ua-parser-js@0.7.39': {} - '@use-gesture/core@10.3.1': {} '@use-gesture/react@10.3.1(react@18.3.1)': @@ -7419,12 +7392,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@vanilla-extract/compiler@0.7.0(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)': + '@vanilla-extract/compiler@0.7.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)': dependencies: - '@vanilla-extract/css': 1.20.1 + '@vanilla-extract/css': 1.21.1 '@vanilla-extract/integration': 8.0.10 - vite: 7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) - vite-node: 6.0.0(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: 6.0.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -7442,7 +7415,7 @@ snapshots: - tsx - yaml - '@vanilla-extract/css@1.20.1': + '@vanilla-extract/css@1.21.1': dependencies: '@emotion/hash': 0.9.2 '@vanilla-extract/private': 1.0.9 @@ -7463,7 +7436,7 @@ snapshots: '@babel/core': 7.29.7 '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) '@vanilla-extract/babel-plugin-debug-ids': 1.2.2 - '@vanilla-extract/css': 1.20.1 + '@vanilla-extract/css': 1.21.1 dedent: 1.7.2 esbuild: 0.28.1 eval: 0.1.8 @@ -7476,15 +7449,15 @@ snapshots: '@vanilla-extract/private@1.0.9': {} - '@vanilla-extract/recipes@0.5.7(@vanilla-extract/css@1.20.1)': + '@vanilla-extract/recipes@0.5.7(@vanilla-extract/css@1.21.1)': dependencies: - '@vanilla-extract/css': 1.20.1 + '@vanilla-extract/css': 1.21.1 - '@vanilla-extract/vite-plugin@5.2.2(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))': + '@vanilla-extract/vite-plugin@5.2.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0)': dependencies: - '@vanilla-extract/compiler': 0.7.0(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) + '@vanilla-extract/compiler': 0.7.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) '@vanilla-extract/integration': 8.0.10 - vite: 7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -7502,7 +7475,7 @@ snapshots: - tsx - yaml - '@vitejs/plugin-react@5.2.0(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))': + '@vitejs/plugin-react@5.2.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) @@ -7510,7 +7483,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -7526,7 +7499,7 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@24.10.13)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) + vitest: 4.1.9(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) '@vitest/expect@4.1.9': dependencies: @@ -7537,13 +7510,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))': + '@vitest/mocker@4.1.9(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.9': dependencies: @@ -7572,7 +7545,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@24.10.13)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) + vitest: 4.1.9(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) '@vitest/utils@4.1.9': dependencies: @@ -7888,6 +7861,8 @@ snapshots: dequal@2.0.3: {} + detect-europe-js@0.1.2: {} + detect-libc@2.1.2: {} direction@1.0.4: {} @@ -7896,27 +7871,27 @@ snapshots: dom-accessibility-api@0.6.3: {} - dom-serializer@2.0.0: + dom-serializer@3.1.1: dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - entities: 4.5.0 + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.0.0 - domelementtype@2.3.0: {} + domelementtype@3.0.0: {} - domhandler@5.0.3: + domhandler@6.0.1: dependencies: - domelementtype: 2.3.0 + domelementtype: 3.0.0 - dompurify@3.4.10: + dompurify@3.4.11: optionalDependencies: '@types/trusted-types': 2.0.7 - domutils@3.2.2: + domutils@4.0.2: dependencies: - dom-serializer: 2.0.0 - domelementtype: 2.3.0 - domhandler: 5.0.3 + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 dot-case@3.0.4: dependencies: @@ -7945,8 +7920,6 @@ snapshots: entities@4.5.0: {} - entities@7.0.1: {} - entities@8.0.0: {} error-ex@1.3.4: @@ -8044,35 +8017,6 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - esbuild@0.27.3: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.3 - '@esbuild/android-arm': 0.27.3 - '@esbuild/android-arm64': 0.27.3 - '@esbuild/android-x64': 0.27.3 - '@esbuild/darwin-arm64': 0.27.3 - '@esbuild/darwin-x64': 0.27.3 - '@esbuild/freebsd-arm64': 0.27.3 - '@esbuild/freebsd-x64': 0.27.3 - '@esbuild/linux-arm': 0.27.3 - '@esbuild/linux-arm64': 0.27.3 - '@esbuild/linux-ia32': 0.27.3 - '@esbuild/linux-loong64': 0.27.3 - '@esbuild/linux-mips64el': 0.27.3 - '@esbuild/linux-ppc64': 0.27.3 - '@esbuild/linux-riscv64': 0.27.3 - '@esbuild/linux-s390x': 0.27.3 - '@esbuild/linux-x64': 0.27.3 - '@esbuild/netbsd-arm64': 0.27.3 - '@esbuild/netbsd-x64': 0.27.3 - '@esbuild/openbsd-arm64': 0.27.3 - '@esbuild/openbsd-x64': 0.27.3 - '@esbuild/openharmony-arm64': 0.27.3 - '@esbuild/sunos-x64': 0.27.3 - '@esbuild/win32-arm64': 0.27.3 - '@esbuild/win32-ia32': 0.27.3 - '@esbuild/win32-x64': 0.27.3 - esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -8145,7 +8089,7 @@ snapshots: eval@0.1.8: dependencies: - '@types/node': 24.10.13 + '@types/node': 24.13.2 require-like: 0.1.2 eventemitter3@5.0.4: {} @@ -8156,22 +8100,10 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} fast-uri@3.1.2: {} - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - fd-package-json@2.0.0: dependencies: walk-up-path: 4.0.0 @@ -8199,22 +8131,23 @@ snapshots: flatted@3.4.2: {} - focus-trap-react@10.3.1(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + focus-trap-react@12.0.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - focus-trap: 7.8.0 - prop-types: 15.8.1 + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + focus-trap: 8.2.2 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - tabbable: 6.4.0 + tabbable: 6.5.0 - focus-trap@7.8.0: + focus-trap@8.2.2: dependencies: - tabbable: 6.4.0 + tabbable: 6.5.0 - folds@2.6.2(@vanilla-extract/css@1.20.1)(@vanilla-extract/recipes@0.5.7(@vanilla-extract/css@1.20.1))(classnames@2.5.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + folds@2.6.2(@vanilla-extract/css@1.21.1)(@vanilla-extract/recipes@0.5.7(@vanilla-extract/css@1.21.1))(classnames@2.5.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@vanilla-extract/css': 1.20.1 - '@vanilla-extract/recipes': 0.5.7(@vanilla-extract/css@1.20.1) + '@vanilla-extract/css': 1.21.1 + '@vanilla-extract/recipes': 0.5.7(@vanilla-extract/css@1.21.1) classnames: 2.5.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -8232,9 +8165,9 @@ snapshots: dependencies: fd-package-json: 2.0.0 - framer-motion@12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + framer-motion@12.42.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - motion-dom: 12.40.0 + motion-dom: 12.42.2 motion-utils: 12.39.0 tslib: 2.8.1 optionalDependencies: @@ -8297,6 +8230,10 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -8351,15 +8288,10 @@ snapshots: dependencies: react-is: 16.13.1 - html-dom-parser@5.0.3: - dependencies: - domhandler: 5.0.3 - htmlparser2: 9.0.0 - - html-dom-parser@5.1.8: + html-dom-parser@8.0.0: dependencies: - domhandler: 5.0.3 - htmlparser2: 10.1.0 + domhandler: 6.0.1 + htmlparser2: 12.0.0 html-encoding-sniffer@6.0.0: dependencies: @@ -8373,27 +8305,22 @@ snapshots: dependencies: void-elements: 3.1.0 - html-react-parser@4.2.10(react@18.3.1): + html-react-parser@6.1.3(@types/react@18.3.31)(react@18.3.1): dependencies: - domhandler: 5.0.3 - html-dom-parser: 5.0.3 + domhandler: 6.0.1 + html-dom-parser: 8.0.0 react: 18.3.1 react-property: 2.0.2 - style-to-js: 1.1.8 - - htmlparser2@10.1.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - entities: 7.0.1 + style-to-js: 2.0.0 + optionalDependencies: + '@types/react': 18.3.31 - htmlparser2@9.0.0: + htmlparser2@12.0.0: dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - entities: 4.5.0 + domelementtype: 3.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + entities: 8.0.0 https-proxy-agent@5.0.1: dependencies: @@ -8422,7 +8349,7 @@ snapshots: ieee754@1.2.1: {} - immer@9.0.21: {} + immer@11.1.9: {} import-fresh@3.3.1: dependencies: @@ -8431,7 +8358,7 @@ snapshots: indent-string@4.0.0: {} - inline-style-parser@0.2.2: {} + inline-style-parser@0.2.7: {} internal-slot@1.1.0: dependencies: @@ -8545,6 +8472,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-standalone-pwa@0.1.1: {} + is-stream@2.0.1: {} is-string@1.1.1: @@ -8663,27 +8592,26 @@ snapshots: jwt-decode@4.0.0: {} - katex@0.16.47: + katex@0.17.0: dependencies: commander: 8.3.0 kleur@4.1.5: {} - knip@5.85.0(@types/node@24.10.13)(typescript@5.9.3): + knip@6.23.0: dependencies: - '@nodelib/fs.walk': 1.2.8 - '@types/node': 24.10.13 - fast-glob: 3.3.3 + fdir: 6.5.0(picomatch@4.0.4) formatly: 0.3.0 + get-tsconfig: 4.14.0 jiti: 2.7.0 - js-yaml: 4.2.0 - minimist: 1.2.8 - oxc-resolver: 11.20.0 - picocolors: 1.1.1 + oxc-parser: 0.137.0 + oxc-resolver: 11.21.3 picomatch: 4.0.4 smol-toml: 1.6.1 strip-json-comments: 5.0.3 - typescript: 5.9.3 + tinyglobby: 0.2.17 + unbash: 4.0.2 + yaml: 2.9.0 zod: 4.4.3 leaflet@1.9.4: {} @@ -8830,22 +8758,15 @@ snapshots: dependencies: '@babel/runtime': 7.29.7 - merge2@1.4.1: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - min-indent@1.0.1: {} - miniflare@4.20260616.0: + miniflare@4.20260630.0: dependencies: '@cspotcode/source-map-support': 0.8.1 sharp: 0.34.5 - undici: 7.24.8 - workerd: 1.20260616.1 - ws: 8.20.1 + undici: 7.28.0 + workerd: 1.20260630.1 + ws: 8.21.0 youch: 4.1.0-beta.10 transitivePeerDependencies: - bufferutil @@ -8859,8 +8780,6 @@ snapshots: dependencies: brace-expansion: 2.1.1 - minimist@1.2.8: {} - minipass@7.1.3: {} mlly@1.8.2: @@ -8872,7 +8791,7 @@ snapshots: modern-ahocorasick@1.1.0: {} - motion-dom@12.40.0: + motion-dom@12.42.2: dependencies: motion-utils: 12.39.0 @@ -8924,83 +8843,108 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxc-resolver@11.20.0: + oxc-parser@0.137.0: + dependencies: + '@oxc-project/types': 0.137.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.137.0 + '@oxc-parser/binding-android-arm64': 0.137.0 + '@oxc-parser/binding-darwin-arm64': 0.137.0 + '@oxc-parser/binding-darwin-x64': 0.137.0 + '@oxc-parser/binding-freebsd-x64': 0.137.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.137.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.137.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.137.0 + '@oxc-parser/binding-linux-arm64-musl': 0.137.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.137.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.137.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.137.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.137.0 + '@oxc-parser/binding-linux-x64-gnu': 0.137.0 + '@oxc-parser/binding-linux-x64-musl': 0.137.0 + '@oxc-parser/binding-openharmony-arm64': 0.137.0 + '@oxc-parser/binding-wasm32-wasi': 0.137.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.137.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.137.0 + '@oxc-parser/binding-win32-x64-msvc': 0.137.0 + + oxc-resolver@11.21.3: optionalDependencies: - '@oxc-resolver/binding-android-arm-eabi': 11.20.0 - '@oxc-resolver/binding-android-arm64': 11.20.0 - '@oxc-resolver/binding-darwin-arm64': 11.20.0 - '@oxc-resolver/binding-darwin-x64': 11.20.0 - '@oxc-resolver/binding-freebsd-x64': 11.20.0 - '@oxc-resolver/binding-linux-arm-gnueabihf': 11.20.0 - '@oxc-resolver/binding-linux-arm-musleabihf': 11.20.0 - '@oxc-resolver/binding-linux-arm64-gnu': 11.20.0 - '@oxc-resolver/binding-linux-arm64-musl': 11.20.0 - '@oxc-resolver/binding-linux-ppc64-gnu': 11.20.0 - '@oxc-resolver/binding-linux-riscv64-gnu': 11.20.0 - '@oxc-resolver/binding-linux-riscv64-musl': 11.20.0 - '@oxc-resolver/binding-linux-s390x-gnu': 11.20.0 - '@oxc-resolver/binding-linux-x64-gnu': 11.20.0 - '@oxc-resolver/binding-linux-x64-musl': 11.20.0 - '@oxc-resolver/binding-openharmony-arm64': 11.20.0 - '@oxc-resolver/binding-wasm32-wasi': 11.20.0 - '@oxc-resolver/binding-win32-arm64-msvc': 11.20.0 - '@oxc-resolver/binding-win32-x64-msvc': 11.20.0 - - oxfmt@0.45.0: + '@oxc-resolver/binding-android-arm-eabi': 11.21.3 + '@oxc-resolver/binding-android-arm64': 11.21.3 + '@oxc-resolver/binding-darwin-arm64': 11.21.3 + '@oxc-resolver/binding-darwin-x64': 11.21.3 + '@oxc-resolver/binding-freebsd-x64': 11.21.3 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.21.3 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.21.3 + '@oxc-resolver/binding-linux-arm64-gnu': 11.21.3 + '@oxc-resolver/binding-linux-arm64-musl': 11.21.3 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.21.3 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.21.3 + '@oxc-resolver/binding-linux-riscv64-musl': 11.21.3 + '@oxc-resolver/binding-linux-s390x-gnu': 11.21.3 + '@oxc-resolver/binding-linux-x64-gnu': 11.21.3 + '@oxc-resolver/binding-linux-x64-musl': 11.21.3 + '@oxc-resolver/binding-openharmony-arm64': 11.21.3 + '@oxc-resolver/binding-wasm32-wasi': 11.21.3 + '@oxc-resolver/binding-win32-arm64-msvc': 11.21.3 + '@oxc-resolver/binding-win32-x64-msvc': 11.21.3 + + oxfmt@0.57.0: dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.45.0 - '@oxfmt/binding-android-arm64': 0.45.0 - '@oxfmt/binding-darwin-arm64': 0.45.0 - '@oxfmt/binding-darwin-x64': 0.45.0 - '@oxfmt/binding-freebsd-x64': 0.45.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.45.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.45.0 - '@oxfmt/binding-linux-arm64-gnu': 0.45.0 - '@oxfmt/binding-linux-arm64-musl': 0.45.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.45.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.45.0 - '@oxfmt/binding-linux-riscv64-musl': 0.45.0 - '@oxfmt/binding-linux-s390x-gnu': 0.45.0 - '@oxfmt/binding-linux-x64-gnu': 0.45.0 - '@oxfmt/binding-linux-x64-musl': 0.45.0 - '@oxfmt/binding-openharmony-arm64': 0.45.0 - '@oxfmt/binding-win32-arm64-msvc': 0.45.0 - '@oxfmt/binding-win32-ia32-msvc': 0.45.0 - '@oxfmt/binding-win32-x64-msvc': 0.45.0 - - oxlint-tsgolint@0.23.0: + '@oxfmt/binding-android-arm-eabi': 0.57.0 + '@oxfmt/binding-android-arm64': 0.57.0 + '@oxfmt/binding-darwin-arm64': 0.57.0 + '@oxfmt/binding-darwin-x64': 0.57.0 + '@oxfmt/binding-freebsd-x64': 0.57.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.57.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.57.0 + '@oxfmt/binding-linux-arm64-gnu': 0.57.0 + '@oxfmt/binding-linux-arm64-musl': 0.57.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.57.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.57.0 + '@oxfmt/binding-linux-riscv64-musl': 0.57.0 + '@oxfmt/binding-linux-s390x-gnu': 0.57.0 + '@oxfmt/binding-linux-x64-gnu': 0.57.0 + '@oxfmt/binding-linux-x64-musl': 0.57.0 + '@oxfmt/binding-openharmony-arm64': 0.57.0 + '@oxfmt/binding-win32-arm64-msvc': 0.57.0 + '@oxfmt/binding-win32-ia32-msvc': 0.57.0 + '@oxfmt/binding-win32-x64-msvc': 0.57.0 + + oxlint-tsgolint@0.24.0: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.23.0 - '@oxlint-tsgolint/darwin-x64': 0.23.0 - '@oxlint-tsgolint/linux-arm64': 0.23.0 - '@oxlint-tsgolint/linux-x64': 0.23.0 - '@oxlint-tsgolint/win32-arm64': 0.23.0 - '@oxlint-tsgolint/win32-x64': 0.23.0 - - oxlint@1.70.0(oxlint-tsgolint@0.23.0): + '@oxlint-tsgolint/darwin-arm64': 0.24.0 + '@oxlint-tsgolint/darwin-x64': 0.24.0 + '@oxlint-tsgolint/linux-arm64': 0.24.0 + '@oxlint-tsgolint/linux-x64': 0.24.0 + '@oxlint-tsgolint/win32-arm64': 0.24.0 + '@oxlint-tsgolint/win32-x64': 0.24.0 + + oxlint@1.72.0(oxlint-tsgolint@0.24.0): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.70.0 - '@oxlint/binding-android-arm64': 1.70.0 - '@oxlint/binding-darwin-arm64': 1.70.0 - '@oxlint/binding-darwin-x64': 1.70.0 - '@oxlint/binding-freebsd-x64': 1.70.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.70.0 - '@oxlint/binding-linux-arm-musleabihf': 1.70.0 - '@oxlint/binding-linux-arm64-gnu': 1.70.0 - '@oxlint/binding-linux-arm64-musl': 1.70.0 - '@oxlint/binding-linux-ppc64-gnu': 1.70.0 - '@oxlint/binding-linux-riscv64-gnu': 1.70.0 - '@oxlint/binding-linux-riscv64-musl': 1.70.0 - '@oxlint/binding-linux-s390x-gnu': 1.70.0 - '@oxlint/binding-linux-x64-gnu': 1.70.0 - '@oxlint/binding-linux-x64-musl': 1.70.0 - '@oxlint/binding-openharmony-arm64': 1.70.0 - '@oxlint/binding-win32-arm64-msvc': 1.70.0 - '@oxlint/binding-win32-ia32-msvc': 1.70.0 - '@oxlint/binding-win32-x64-msvc': 1.70.0 - oxlint-tsgolint: 0.23.0 + '@oxlint/binding-android-arm-eabi': 1.72.0 + '@oxlint/binding-android-arm64': 1.72.0 + '@oxlint/binding-darwin-arm64': 1.72.0 + '@oxlint/binding-darwin-x64': 1.72.0 + '@oxlint/binding-freebsd-x64': 1.72.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.72.0 + '@oxlint/binding-linux-arm-musleabihf': 1.72.0 + '@oxlint/binding-linux-arm64-gnu': 1.72.0 + '@oxlint/binding-linux-arm64-musl': 1.72.0 + '@oxlint/binding-linux-ppc64-gnu': 1.72.0 + '@oxlint/binding-linux-riscv64-gnu': 1.72.0 + '@oxlint/binding-linux-riscv64-musl': 1.72.0 + '@oxlint/binding-linux-s390x-gnu': 1.72.0 + '@oxlint/binding-linux-x64-gnu': 1.72.0 + '@oxlint/binding-linux-x64-musl': 1.72.0 + '@oxlint/binding-openharmony-arm64': 1.72.0 + '@oxlint/binding-win32-arm64-msvc': 1.72.0 + '@oxlint/binding-win32-ia32-msvc': 1.72.0 + '@oxlint/binding-win32-x64-msvc': 1.72.0 + oxlint-tsgolint: 0.24.0 p-limit@3.1.0: dependencies: @@ -9050,9 +8994,9 @@ snapshots: pathe@2.0.3: {} - pdfjs-dist@5.7.284: + pdfjs-dist@6.1.200: optionalDependencies: - '@napi-rs/canvas': 0.1.100 + '@napi-rs/canvas': 1.0.2 picocolors@1.1.1: {} @@ -9096,26 +9040,24 @@ snapshots: punycode@2.3.1: {} - queue-microtask@1.2.3: {} - raf-schd@4.0.3: {} randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 - react-aria@3.49.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-aria@3.50.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@internationalized/date': 3.12.2 '@internationalized/number': 3.6.7 '@internationalized/string': 3.2.9 - '@react-types/shared': 3.35.0(react@18.3.1) + '@react-types/shared': 3.36.0(react@18.3.1) '@swc/helpers': 0.5.23 aria-hidden: 1.2.6 clsx: 2.1.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-stately: 3.47.0(react@18.3.1) + react-stately: 3.48.0(react@18.3.1) use-sync-external-store: 1.6.0(react@18.3.1) react-async-script@1.2.0(react@18.3.1): @@ -9140,7 +9082,7 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-google-recaptcha@2.1.0(react@18.3.1): + react-google-recaptcha@3.1.0(react@18.3.1): dependencies: prop-types: 15.8.1 react: 18.3.1 @@ -9189,12 +9131,12 @@ snapshots: '@remix-run/router': 1.23.3 react: 18.3.1 - react-stately@3.47.0(react@18.3.1): + react-stately@3.48.0(react@18.3.1): dependencies: '@internationalized/date': 3.12.2 '@internationalized/number': 3.6.7 '@internationalized/string': 3.2.9 - '@react-types/shared': 3.35.0(react@18.3.1) + '@react-types/shared': 3.36.0(react@18.3.1) '@swc/helpers': 0.5.23 react: 18.3.1 use-sync-external-store: 1.6.0(react@18.3.1) @@ -9259,6 +9201,8 @@ snapshots: resolve-from@4.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve@1.22.12: dependencies: es-errors: 1.3.0 @@ -9266,8 +9210,6 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - reusify@1.1.0: {} - rolldown@1.0.3: dependencies: '@oxc-project/types': 0.133.0 @@ -9324,10 +9266,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.0 fsevents: 2.3.3 - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - safe-array-concat@1.1.4: dependencies: call-bind: 1.0.9 @@ -9484,7 +9422,7 @@ snapshots: is-plain-object: 5.0.0 slate: 0.124.1 - slate-react@0.124.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(slate-dom@0.124.1(slate@0.124.1))(slate@0.124.1): + slate-react@0.125.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(slate-dom@0.124.1(slate@0.124.1))(slate@0.124.1): dependencies: '@juggle/resize-observer': 3.4.0 direction: 1.0.4 @@ -9586,13 +9524,13 @@ snapshots: strip-json-comments@5.0.3: {} - style-to-js@1.1.8: + style-to-js@2.0.0: dependencies: - style-to-object: 1.0.3 + style-to-object: 1.0.14 - style-to-object@1.0.3: + style-to-object@1.0.14: dependencies: - inline-style-parser: 0.2.2 + inline-style-parser: 0.2.7 supports-color@10.2.2: {} @@ -9606,7 +9544,7 @@ snapshots: symbol-tree@3.2.4: {} - tabbable@6.4.0: {} + tabbable@6.5.0: {} tar-mini@0.2.0: {} @@ -9706,10 +9644,18 @@ snapshots: typescript@5.9.3: {} - ua-parser-js@1.0.41: {} + ua-is-frozen@0.1.2: {} + + ua-parser-js@2.0.10: + dependencies: + detect-europe-js: 0.1.2 + is-standalone-pwa: 0.1.1 + ua-is-frozen: 0.1.2 ufo@1.6.4: {} + unbash@4.0.2: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -9717,9 +9663,7 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - undici-types@7.16.0: {} - - undici@7.24.8: {} + undici-types@7.18.2: {} undici@7.28.0: {} @@ -9762,18 +9706,18 @@ snapshots: uuid@13.0.2: {} - virtua@0.49.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + virtua@0.49.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): optionalDependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - vite-node@6.0.0(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0): + vite-node@6.0.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0): dependencies: cac: 7.0.0 es-module-lexer: 2.1.0 obug: 2.1.3 pathe: 2.0.3 - vite: 8.0.16(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0) + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -9795,48 +9739,48 @@ snapshots: transitivePeerDependencies: - rollup - vite-plugin-pwa@1.3.0(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0): + vite-plugin-pwa@1.3.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0): dependencies: debug: 4.4.3 pretty-bytes: 6.1.1 tinyglobby: 0.2.17 - vite: 7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) workbox-build: 7.4.0(@types/babel__core@7.20.5) workbox-window: 7.4.0 transitivePeerDependencies: - supports-color - vite-plugin-static-copy@3.4.0(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)): + vite-plugin-static-copy@3.4.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: chokidar: 3.6.0 p-map: 7.0.4 picocolors: 1.1.1 tinyglobby: 0.2.17 - vite: 7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) - vite-plugin-svgr@4.5.0(rollup@2.80.0)(typescript@5.9.3)(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)): + vite-plugin-svgr@4.5.0(rollup@2.80.0)(typescript@5.9.3)(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@rollup/pluginutils': 5.4.0(rollup@2.80.0) '@svgr/core': 8.1.0(typescript@5.9.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) - vite: 7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) transitivePeerDependencies: - rollup - supports-color - typescript - vite-plugin-top-level-await@1.6.0(@swc/helpers@0.5.23)(rollup@2.80.0)(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)): + vite-plugin-top-level-await@1.6.0(@swc/helpers@0.5.23)(rollup@2.80.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@rollup/plugin-virtual': 3.0.2(rollup@2.80.0) '@swc/core': 1.15.41(@swc/helpers@0.5.23) '@swc/wasm': 1.15.41 uuid: 10.0.0 - vite: 7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) transitivePeerDependencies: - '@swc/helpers' - rollup - vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0): + vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -9845,13 +9789,14 @@ snapshots: rollup: 4.62.0 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.13.2 fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 terser: 5.48.0 + yaml: 2.9.0 - vite@8.0.16(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0): + vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -9859,16 +9804,17 @@ snapshots: rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.13.2 esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 terser: 5.48.0 + yaml: 2.9.0 - vitest@4.1.9(@types/node@24.10.13)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)): + vitest@4.1.9(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) + '@vitest/mocker': 4.1.9(vite@7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -9885,10 +9831,10 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 7.3.5(@types/node@24.10.13)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) + vite: 7.3.5(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.13.2 '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) '@vitest/ui': 4.1.9(vitest@4.1.9) jsdom: 29.1.1 @@ -10109,31 +10055,31 @@ snapshots: '@types/trusted-types': 2.0.7 workbox-core: 7.4.0 - workerd@1.20260616.1: + workerd@1.20260630.1: optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260616.1 - '@cloudflare/workerd-darwin-arm64': 1.20260616.1 - '@cloudflare/workerd-linux-64': 1.20260616.1 - '@cloudflare/workerd-linux-arm64': 1.20260616.1 - '@cloudflare/workerd-windows-64': 1.20260616.1 + '@cloudflare/workerd-darwin-64': 1.20260630.1 + '@cloudflare/workerd-darwin-arm64': 1.20260630.1 + '@cloudflare/workerd-linux-64': 1.20260630.1 + '@cloudflare/workerd-linux-arm64': 1.20260630.1 + '@cloudflare/workerd-windows-64': 1.20260630.1 - wrangler@4.101.0: + wrangler@4.106.0: dependencies: '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260616.1) + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260630.1) blake3-wasm: 2.1.5 - esbuild: 0.27.3 - miniflare: 4.20260616.0 + esbuild: 0.28.1 + miniflare: 4.20260630.0 path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 - workerd: 1.20260616.1 + workerd: 1.20260630.1 optionalDependencies: fsevents: 2.3.3 transitivePeerDependencies: - bufferutil - utf-8-validate - ws@8.20.1: {} + ws@8.21.0: {} xml-name-validator@5.0.0: {} @@ -10141,6 +10087,8 @@ snapshots: yallist@3.1.1: {} + yaml@2.9.0: {} + yocto-queue@0.1.0: {} youch-core@0.3.3: diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 611b27566..0ddd51afd 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { useCallback, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; import { useAutoJoinCall } from '$hooks/useAutoJoinCall'; import { @@ -12,13 +12,15 @@ import { } from '$hooks/useCallEmbed'; import type { CallEmbed } from '$plugins/call'; import { useClientWidgetApiEvent, ElementWidgetActions } from '$plugins/call'; -import { callChatAtom, callEmbedAtom } from '$state/callEmbed'; +import { callChatAtom, callEmbedAtom, callEmbedStartErrorAtom } from '$state/callEmbed'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { IncomingCallModal } from './IncomingCallModal'; +import { toCallEmbedStartError } from '$plugins/call/callEmbedError'; function CallUtils({ embed }: { embed: CallEmbed }) { const setCallEmbed = useSetAtom(callEmbedAtom); + const setCallEmbedStartError = useSetAtom(callEmbedStartErrorAtom); useCallMemberSoundSync(embed); useCallThemeSync(embed); @@ -30,6 +32,24 @@ function CallUtils({ embed }: { embed: CallEmbed }) { useCallHangupEvent(embed, handleCallEnd); useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, handleCallEnd); + useEffect(() => { + const disposeOnReady = embed.onReady(() => { + setCallEmbedStartError(null); + }); + const disposeOnCapabilitiesNotified = embed.onCapabilitiesNotified(() => { + setCallEmbedStartError(null); + }); + const disposeOnPreparingError = embed.onPreparingError((error) => { + setCallEmbedStartError(toCallEmbedStartError(error)); + }); + + return () => { + disposeOnReady(); + disposeOnCapabilitiesNotified(); + disposeOnPreparingError(); + }; + }, [embed, setCallEmbedStartError]); + return null; } diff --git a/src/app/components/GlobalKeyboardShortcuts.tsx b/src/app/components/GlobalKeyboardShortcuts.tsx index 4f2e4cf49..e3b577ffc 100644 --- a/src/app/components/GlobalKeyboardShortcuts.tsx +++ b/src/app/components/GlobalKeyboardShortcuts.tsx @@ -20,6 +20,7 @@ import { getDirectRoomPath, getHomeRoomPath, getHomeSearchPath, + getInboxBookmarksPath, getSpaceRoomPath, getSpaceSearchPath, withSearchParam, @@ -162,6 +163,17 @@ export function GlobalKeyboardShortcuts() { [currentRoom, replyDraft, setReplyDraft] ); + const handleBookmarkKeyDown = useCallback( + (evt: KeyboardEvent) => { + if (!isKeyHotkey('mod+b', evt)) return; + evt.preventDefault(); + + navigate(getInboxBookmarksPath()); + announce(`Navigated to bookmarks`); + }, + [navigate] + ); + /** Ctrl+F: Search for messages */ const handleSearchMessageInRoom = useCallback( (evt: KeyboardEvent) => { @@ -184,6 +196,7 @@ export function GlobalKeyboardShortcuts() { useKeyDown(window, handleNextUnreadKeyDown); useKeyDown(window, handleUnreadNavKeyDown); useKeyDown(window, handleReplyKeyDown); + useKeyDown(window, handleBookmarkKeyDown); useKeyDown(window, handleSearchMessageInRoom); return null; diff --git a/src/app/components/IncomingCallModal.test.tsx b/src/app/components/IncomingCallModal.test.tsx new file mode 100644 index 000000000..608902ab3 --- /dev/null +++ b/src/app/components/IncomingCallModal.test.tsx @@ -0,0 +1,167 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { Room } from '$types/matrix-sdk'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { IncomingCallInternal } from './IncomingCallModal'; + +const { navigateRoomMock, sendRtcDeclineMock, webRtcSupportedMock, livekitSupportedMock } = + vi.hoisted(() => ({ + navigateRoomMock: vi.fn<(roomId: string) => void>(), + sendRtcDeclineMock: vi.fn<(roomId: string, eventId: string) => Promise>(), + webRtcSupportedMock: vi.fn<() => boolean>(), + livekitSupportedMock: vi.fn<() => boolean>(), + })); + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => ({ + sendRtcDecline: sendRtcDeclineMock, + getSafeUserId: () => '@me:example.org', + mxcUrlToHttp: () => undefined, + }), +})); + +vi.mock('$hooks/useLivekitSupport', () => ({ + useLivekitSupport: () => livekitSupportedMock(), +})); + +vi.mock('$hooks/useCallEmbed', () => ({ + useCallEmbed: () => undefined, +})); + +vi.mock('$hooks/useScreenSize', () => ({ + ScreenSize: { Desktop: 'Desktop', Tablet: 'Tablet', Mobile: 'Mobile' }, + useScreenSizeContext: () => 'Desktop', +})); + +vi.mock('$hooks/useRoomMeta', () => ({ + useRoomName: () => 'Direct Message', +})); + +vi.mock('$utils/room', () => ({ + getRoomAvatarUrl: () => null, + getMemberDisplayName: () => 'Alice', +})); + +vi.mock('$hooks/useRoomNavigate', () => ({ + useRoomNavigate: () => ({ + navigateRoom: navigateRoomMock, + }), +})); + +vi.mock('$utils/rtc', () => ({ + webRTCSupported: () => webRtcSupportedMock(), +})); + +vi.mock('./room-avatar', () => ({ + RoomAvatar: ({ alt }: { alt: string }) =>
{alt}
, +})); + +vi.mock('./user-avatar', () => ({ + UserAvatar: ({ alt }: { alt?: string }) =>
{alt}
, +})); + +vi.mock('@sentry/react', () => ({ + addBreadcrumb: vi.fn<(...args: unknown[]) => void>(), + metrics: { + count: vi.fn<(...args: unknown[]) => void>(), + }, +})); + +vi.mock('$utils/debugLogger', () => ({ + createDebugLogger: () => ({ + info: vi.fn<(...args: unknown[]) => void>(), + warn: vi.fn<(...args: unknown[]) => void>(), + error: vi.fn<(...args: unknown[]) => void>(), + }), +})); + +describe('IncomingCallInternal', () => { + const room = { + roomId: '!room:example.org', + getMember: () => ({ + getMxcAvatarUrl: () => undefined, + rawDisplayName: 'Alice', + }), + currentState: { + maySendStateEvent: () => true, + }, + } as unknown as Room; + const incomingCall = { + roomId: room.roomId, + notificationEventId: '$notif', + refEventId: '$ref', + senderId: '@alice:example.org', + senderTs: Date.now(), + expiresAt: Date.now() + 60_000, + notificationType: 'ring' as const, + intentKind: 'audio' as const, + isDirect: true, + }; + + beforeEach(() => { + navigateRoomMock.mockReset(); + sendRtcDeclineMock.mockReset().mockResolvedValue(undefined); + webRtcSupportedMock.mockReset().mockReturnValue(true); + livekitSupportedMock.mockReset().mockReturnValue(true); + }); + + it('closes the modal when decline is pressed', async () => { + const onClose = vi.fn<() => void>(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Decline call' })); + + await waitFor(() => { + expect(onClose).toHaveBeenCalledTimes(1); + }); + expect(navigateRoomMock).not.toHaveBeenCalled(); + expect(sendRtcDeclineMock).toHaveBeenCalledWith('!room:example.org', '$notif'); + }); + + it('navigates and closes when answer is pressed', () => { + const onClose = vi.fn<() => void>(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /answer/i })); + + expect(navigateRoomMock).toHaveBeenCalledWith('!room:example.org'); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('disables answer when WebRTC is unavailable', () => { + webRtcSupportedMock.mockReturnValue(false); + const onClose = vi.fn<() => void>(); + render(); + + expect(screen.getByRole('button', { name: /answer voice call/i })).toBeDisabled(); + }); + + it('ignores room call notifications without sending RTC decline', async () => { + const onClose = vi.fn<() => void>(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Ignore call notification' })); + + await waitFor(() => { + expect(onClose).toHaveBeenCalledTimes(1); + }); + expect(sendRtcDeclineMock).not.toHaveBeenCalled(); + }); + + it('shows homeserver capability issues and blocks answer when LiveKit is unavailable', () => { + livekitSupportedMock.mockReturnValue(false); + const onClose = vi.fn<() => void>(); + render(); + + expect( + screen.getByText(/homeserver does not expose a livekit call focus/i) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /answer voice call/i })).toBeDisabled(); + expect(screen.getByText(/homeserver call focus is unavailable/i)).toBeInTheDocument(); + }); +}); diff --git a/src/app/components/IncomingCallModal.tsx b/src/app/components/IncomingCallModal.tsx index c61cbe023..ecb674cb8 100644 --- a/src/app/components/IncomingCallModal.tsx +++ b/src/app/components/IncomingCallModal.tsx @@ -1,77 +1,218 @@ import { + Avatar, Box, + Button, + color, Dialog, Header, IconButton, - Text, - Button, - Avatar, - config, Overlay, - OverlayCenter, OverlayBackdrop, + OverlayCenter, + Text, + config, + toRem, } from 'folds'; +import { useMemo, type KeyboardEvent as ReactKeyboardEvent } from 'react'; import type { Room } from '$types/matrix-sdk'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useLivekitSupport } from '$hooks/useLivekitSupport'; import { useRoomName } from '$hooks/useRoomMeta'; -import { getRoomAvatarUrl } from '$utils/room'; +import { useCallEmbed } from '$hooks/useCallEmbed'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { getMxIdLocalPart } from '$utils/matrix'; +import { getMemberDisplayName, getRoomAvatarUrl } from '$utils/room'; +import { webRTCSupported } from '$utils/rtc'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; import FocusTrap from 'focus-trap-react'; -import { stopPropagation } from '$utils/keyboard'; import * as Sentry from '@sentry/react'; -import { useAtom, useSetAtom } from 'jotai'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { autoJoinCallIntentAtom, - incomingCallRoomIdAtom, + callSoundBlockedAtom, + incomingCallAtom, mutedCallRoomIdAtom, + type IncomingCall, } from '$state/callEmbed'; import { createDebugLogger } from '$utils/debugLogger'; +import { dismissSystemCallNotifications } from '$features/call/callNotificationBridge'; +import { getIncomingCallBlockers } from '$features/call/getIncomingCallBlockers'; import { RoomAvatar } from './room-avatar'; -import { composerIcon, menuIcon, Phone, userFallbackIcon, X } from '$components/icons/phosphor'; +import { UserAvatar } from './user-avatar'; +import { + composerIcon, + menuIcon, + Phone, + X, + Hash, + VideoCamera, + User, + sizedIcon, +} from '$components/icons/phosphor'; const debugLog = createDebugLogger('IncomingCall'); type IncomingCallInternalProps = { room: Room; + incomingCall: IncomingCall; onClose: () => void; }; -export function IncomingCallInternal({ room, onClose }: IncomingCallInternalProps) { +export function IncomingCallInternal({ room, incomingCall, onClose }: IncomingCallInternalProps) { const mx = useMatrixClient(); + const screenSize = useScreenSizeContext(); + const compact = screenSize === ScreenSize.Mobile; const roomName = useRoomName(room); + const livekitSupported = useLivekitSupport(); + const callEmbed = useCallEmbed(); const { navigateRoom } = useRoomNavigate(); - const avatarUrl = getRoomAvatarUrl(mx, room, 96); + const roomAvatarUrl = getRoomAvatarUrl(mx, room, 96); const setAutoJoinIntent = useSetAtom(autoJoinCallIntentAtom); const setMutedRoomId = useSetAtom(mutedCallRoomIdAtom); + const setCallSoundBlocked = useSetAtom(callSoundBlockedAtom); + const callSoundBlocked = useAtomValue(callSoundBlockedAtom); + const callerDisplayName = + getMemberDisplayName(room, incomingCall.senderId) ?? + getMxIdLocalPart(incomingCall.senderId) ?? + incomingCall.senderId; + const callerAvatarMxc = room.getMember(incomingCall.senderId)?.getMxcAvatarUrl(); + const callerAvatarUrl = callerAvatarMxc + ? (mx.mxcUrlToHttp(callerAvatarMxc, 96, 96, 'crop') ?? undefined) + : undefined; + + const isRingNotification = incomingCall.notificationType === 'ring'; + const isDirectRing = incomingCall.isDirect && incomingCall.notificationType === 'ring'; + const isVideoIntent = incomingCall.intentKind === 'video'; + const inAnotherCall = Boolean(callEmbed && callEmbed.roomId !== room.roomId); + const canUseWebRTC = webRTCSupported(); + const myUserId = mx.getSafeUserId(); + const hasCallMemberPermission = + room.currentState?.maySendStateEvent('org.matrix.msc3401.call.member', myUserId) ?? false; + + const capabilityIssues = useMemo( + () => + getIncomingCallBlockers({ + canUseWebRTC, + livekitSupported, + hasCallMemberPermission, + inAnotherCall, + }), + [canUseWebRTC, livekitSupported, hasCallMemberPermission, inAnotherCall] + ); + + const canAnswer = capabilityIssues.length === 0; + const primaryBlockedReason = capabilityIssues[0]?.shortReason; + + const incomingLabel = isRingNotification + ? isVideoIntent + ? 'Incoming video call' + : 'Incoming voice call' + : 'Incoming room call notification'; + const dismissLabel = isDirectRing ? 'Decline' : 'Ignore'; + const closeLabel = 'Close'; + const showCallerAvatar = incomingCall.isDirect; + const title = showCallerAvatar ? callerDisplayName : roomName; + const subtitle = showCallerAvatar ? roomName : callerDisplayName; const handleAnswer = () => { - debugLog.info('call', 'Incoming call answered', { roomId: room.roomId }); - Sentry.addBreadcrumb({ - category: 'call.signal', - message: 'Incoming call answered', - data: { roomId: room.roomId }, + if (!canAnswer) return; + setCallSoundBlocked(false); + + debugLog.info('call', 'Incoming call answered', { + roomId: room.roomId, + notificationEventId: incomingCall.notificationEventId, + notificationType: incomingCall.notificationType, + intent: incomingCall.intentRaw, + }); + Sentry.metrics.count('sable.call.answered', 1, { + attributes: { + type: incomingCall.notificationType, + dm: String(incomingCall.isDirect), + intent: incomingCall.intentKind, + }, }); - Sentry.metrics.count('sable.call.answered', 1); + setMutedRoomId(room.roomId); - setAutoJoinIntent(room.roomId); + setAutoJoinIntent({ roomId: room.roomId, video: isVideoIntent }); + void dismissSystemCallNotifications(room.roomId); onClose(); navigateRoom(room.roomId); }; - const handleDecline = async () => { - debugLog.info('call', 'Incoming call declined', { roomId: room.roomId }); - Sentry.addBreadcrumb({ - category: 'call.signal', - message: 'Incoming call declined', - data: { roomId: room.roomId }, + const handleDeclineOrIgnore = () => { + setCallSoundBlocked(false); + const action = isDirectRing ? 'decline' : 'ignore'; + debugLog.info('call', 'Incoming call dismissed', { + roomId: room.roomId, + action, + notificationEventId: incomingCall.notificationEventId, + notificationType: incomingCall.notificationType, + }); + Sentry.metrics.count(`sable.call.${action}d`, 1, { + attributes: { + type: incomingCall.notificationType, + dm: String(incomingCall.isDirect), + }, + }); + + setMutedRoomId(room.roomId); + void dismissSystemCallNotifications(room.roomId); + onClose(); + + if (isDirectRing) { + void mx.sendRtcDecline(room.roomId, incomingCall.notificationEventId).catch((error) => { + debugLog.warn('call', 'Failed to send RTC decline event', { + roomId: room.roomId, + notificationEventId: incomingCall.notificationEventId, + error: error instanceof Error ? error.message : String(error), + }); + Sentry.metrics.count('sable.call.decline.error', 1); + }); + } + }; + + const handleClose = () => { + setCallSoundBlocked(false); + const action = 'ignore'; + debugLog.info('call', 'Incoming call dismissed', { + roomId: room.roomId, + action, + notificationEventId: incomingCall.notificationEventId, + notificationType: incomingCall.notificationType, + }); + Sentry.metrics.count(`sable.call.${action}d`, 1, { + attributes: { + type: incomingCall.notificationType, + dm: String(incomingCall.isDirect), + }, }); - Sentry.metrics.count('sable.call.declined', 1); + setMutedRoomId(room.roomId); + void dismissSystemCallNotifications(room.roomId); onClose(); }; + const handleModalKeyDown = (evt: ReactKeyboardEvent) => { + if (evt.key === 'Escape') { + evt.preventDefault(); + evt.stopPropagation(); + handleClose(); + return; + } + if (evt.key === 'Enter' && canAnswer) { + evt.preventDefault(); + evt.stopPropagation(); + handleAnswer(); + } + }; + return ( - +
Incoming Call - + {composerIcon(X)}
- + - userFallbackIcon('lg')} - /> + {showCallerAvatar ? ( + sizedIcon(User, '200', { filled: true })} + /> + ) : ( + sizedIcon(Hash, '200', { filled: true })} + /> + )} - {roomName} + {title} - Incoming voice chat request + {incomingLabel} + + + {showCallerAvatar ? `Room: ${subtitle}` : `Caller: ${subtitle}`} + {capabilityIssues.length > 0 && ( + + {capabilityIssues.map((issue) => ( + + {issue.message} + + ))} + + )} + + {!canAnswer && primaryBlockedReason && ( + + {primaryBlockedReason} + + )} + {callSoundBlocked && ( + + Call sound was blocked by your browser. Click any call action to re-enable sound. + + )}
); } export function IncomingCallModal() { - const [ringingRoomId, setRingingRoomId] = useAtom(incomingCallRoomIdAtom); + const [incomingCall, setIncomingCall] = useAtom(incomingCallAtom); const mx = useMatrixClient(); - const room = ringingRoomId ? mx.getRoom(ringingRoomId) : null; + const room = incomingCall ? mx.getRoom(incomingCall.roomId) : null; - if (!ringingRoomId || !room) return null; + if (!incomingCall || !room) return null; - const close = () => setRingingRoomId(null); + const close = () => setIncomingCall(null); return ( }> @@ -146,13 +347,12 @@ export function IncomingCallModal() {
- +
diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index e76cce520..0a61c2cad 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -444,6 +444,7 @@ export const CustomEditor = forwardRef( onBlur={() => { if (mobileOrTablet()) ReactEditor.focus(editor); }} + style={{ boxShadow: 'none' }} /> {(hasAfter || showResponsiveAfterInline) && ( diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index d8be87e30..6c3665925 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -74,11 +74,11 @@ const elementToCustomHtml = ( return sanitizeText(matrixTo); } case BlockType.Emoticon: - return node.key.startsWith('mxc://') + return node.key?.startsWith('mxc://') ? `${sanitizeText(
             node.shortcode
           )}` - : sanitizeText(node.key); + : sanitizeText(node.key ?? ''); case BlockType.Link: return testMatrixTo(node.href) ? sanitizeText(node.href) @@ -111,10 +111,9 @@ export const toMatrixCustomHTML = ( // strip nicknames if needed if (opts.stripNickname && opts.nickNameReplacement) { - opts.nickNameReplacement?.keys().forEach((key) => { - const replacement = opts.nickNameReplacement!.get(key) ?? ''; + for (const [key, replacement] of opts.nickNameReplacement) { line = line.replaceAll(key, replacement); - }); + } } markdownLines += line; if (index === targetNodes.length - 1) { @@ -145,7 +144,7 @@ const elementToPlainText = (node: CustomElement, children: string): string => { case BlockType.Mention: return node.name === '@room' ? node.name : node.id; case BlockType.Emoticon: - return node.key.startsWith('mxc://') ? `:${node.shortcode}:` : node.key; + return node.key?.startsWith('mxc://') ? `:${node.shortcode}:` : (node.key ?? ''); case BlockType.Link: return `[${children}](${node.href})`; case BlockType.Command: @@ -182,10 +181,9 @@ export const toPlainText = ( text = text.replaceAll(SPOILEREDLINKINPUTREGEX, '$1'); if (stripNickname && nickNameReplacement) { - nickNameReplacement?.keys().forEach((key) => { - const replacement = nickNameReplacement.get(key) ?? ''; + for (const [key, replacement] of nickNameReplacement) { text = text.replaceAll(key, replacement); - }); + } } return text; } @@ -211,7 +209,7 @@ export const toRawText = (node: Descendant | Descendant[]): string => { case BlockType.Link: return `[${children}](${node.href})`; case BlockType.Emoticon: - return node.key.startsWith('mxc://') ? `:${node.shortcode}:` : node.key; + return node.key?.startsWith('mxc://') ? `:${node.shortcode}:` : (node.key ?? ''); case BlockType.Mention: return node.name === '@room' ? node.name : node.id; case BlockType.Command: diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index a55ab44ec..485283dca 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -507,7 +507,7 @@ export function EmojiBoard({ ); const searchedItems = emojiResult?.items.slice(0, 100); - const searchedGifItems = gifResult?.items.slice(0, 100) ?? favoriteGifs; + const searchedGifItems = gifResult?.items.slice(0, 100) ?? favoriteGifs.toReversed(); function useGifSearch() { const [gifs, setGifs] = useState<{ @@ -764,7 +764,7 @@ export function EmojiBoard({ initialFocus: false, onDeactivate: requestClose, - allowOutsideClick: (e) => { + allowOutsideClick: (e: MouseEvent | TouchEvent) => { e.preventDefault(); requestClose(); return false; diff --git a/src/app/components/emoji-board/components/NoStickerPacks.tsx b/src/app/components/emoji-board/components/NoStickerPacks.tsx index c8372b241..2335fc9e9 100644 --- a/src/app/components/emoji-board/components/NoStickerPacks.tsx +++ b/src/app/components/emoji-board/components/NoStickerPacks.tsx @@ -1,7 +1,30 @@ -import { Box, toRem, config, Text } from 'folds'; +import { Box, config, Text, toRem } from 'folds'; import { dropzoneIcon, Sticker } from '$components/icons/phosphor'; +import { useOpenRoomSettings } from '$state/hooks/roomSettings.ts'; +import { useOpenSpaceSettings } from '$state/hooks/spaceSettings.ts'; +import { useRoomOptionally } from '$hooks/useRoom.ts'; +import { useSpaceOptionally } from '$hooks/useSpace.ts'; +import * as css from './styles.css'; +import { RoomSettingsPage } from '$state/roomSettings.ts'; +import { SpaceSettingsPage } from '$state/spaceSettings.ts'; + +function OptionallyLinkedText(props: { text: string; isLink: boolean; onClick: () => void }) { + return props.isLink ? ( + + {props.text} + + ) : ( + <>{props.text} + ); +} export function NoStickerPacks() { + const openRoomSettings = useOpenRoomSettings(); + const openSpaceSettings = useOpenSpaceSettings(); + + const room = useRoomOptionally(); + const space = useSpaceOptionally(); + return ( No Sticker Packs! - Add stickers from user, room or space settings. + Add stickers from user,{' '} + + openRoomSettings( + room?.roomId as string, + space?.roomId, + RoomSettingsPage.EmojisStickersPage + ) + } + /> + {', '}or{' '} + + openSpaceSettings( + room?.roomId as string, + space?.roomId, + SpaceSettingsPage.EmojisStickersPage + ) + } + />{' '} + settings. diff --git a/src/app/components/emoji-board/components/styles.css.ts b/src/app/components/emoji-board/components/styles.css.ts index 13de5f2ec..7f8470cd7 100644 --- a/src/app/components/emoji-board/components/styles.css.ts +++ b/src/app/components/emoji-board/components/styles.css.ts @@ -228,3 +228,11 @@ export const GifImg = style({ objectFit: 'cover', borderRadius: config.radii.R400, }); + +export const TextLink = style({ + color: 'var(--tc-link)', + cursor: 'pointer', + ':hover': { + textDecoration: 'underline', + }, +}); diff --git a/src/app/components/message/modals/Options.tsx b/src/app/components/message/modals/Options.tsx index 7a93733b7..7ad72bfae 100644 --- a/src/app/components/message/modals/Options.tsx +++ b/src/app/components/message/modals/Options.tsx @@ -28,8 +28,7 @@ import { MessageSourceCodeItem } from './MessageSource'; import { MessageForwardItem } from './MessageForward'; import * as css from '$features/room/message/styles.css'; -import { useAtom, useAtomValue, useSetAtom, useStore } from 'jotai'; -import { nicknamesAtom, setNicknameAtom } from '$state/nicknames'; +import { useAtom, useSetAtom, useStore } from 'jotai'; import type { Dispatch, MouseEventHandler, ReactNode, SetStateAction } from 'react'; import { useCallback, useEffect, useState, useRef } from 'react'; import { MessageDeleteItem } from './MessageDelete'; @@ -43,6 +42,13 @@ import { useRoomPinnedEvents } from '$hooks/useRoomPinnedEvents'; import { EmojiBoard } from '$components/emoji-board'; import { MemoizedBody, type ReactionHandler } from '$features/room/message'; import { useRecentEmoji } from '$hooks/useRecentEmoji'; +import { BookmarkIcon } from '@phosphor-icons/react'; +import { + computeBookmarkId, + createBookmarkItem, + useBookmarkActions, + useIsBookmarked, +} from '$features/bookmarks'; import { CopyIcon } from '@phosphor-icons/react'; function WrappedMessage({ @@ -207,6 +213,44 @@ export const MessagePinItem = as< ); }); +export const MessageBookmarkItem = as< + 'button', + { + room: Room; + mEvent: MatrixEvent; + onClose?: () => void; + } +>(({ room, mEvent, onClose, ...props }, ref) => { + const eventId = mEvent.getId() ?? ''; + const bookmarked = useIsBookmarked(room.roomId, eventId); + const { add, remove } = useBookmarkActions(); + + const handleClick = async () => { + onClose?.(); + if (bookmarked) { + await remove(computeBookmarkId(room.roomId, eventId)); + } else { + const item = createBookmarkItem(room, mEvent); + if (item) await add(item); + } + }; + + return ( + + + {bookmarked ? 'Remove Bookmark' : 'Bookmark Message'} + + + ); +}); + export type OptionEmojiMenuProps = { mEvent: MatrixEvent; closeMenu: () => void; @@ -284,7 +328,6 @@ export function OptionQuickMenu({ hideReadReceipts, showDeveloperTools, canPinEvent, - cleanedDisplayName, canDelete, handleOpenMenu, menuAnchor, @@ -386,7 +429,6 @@ export function OptionQuickMenu({ hideReadReceipts={hideReadReceipts} showDeveloperTools={showDeveloperTools} canPinEvent={canPinEvent} - cleanedDisplayName={cleanedDisplayName} canDelete={canDelete} setIsEmoji={setIsEmoji} emojiBoardAnchor={menuAnchor} @@ -433,7 +475,6 @@ export type OptionMenuProps = { hideReadReceipts?: boolean; showDeveloperTools?: boolean; canPinEvent?: boolean; - cleanedDisplayName?: string; canDelete?: boolean; handleOpenMenu?: MouseEventHandler; menuAnchor?: RectCords | undefined; @@ -458,7 +499,6 @@ export function OptionMenu({ hideReadReceipts, showDeveloperTools, canPinEvent, - cleanedDisplayName, canDelete, imagePackRooms, setIsEmoji, @@ -478,12 +518,6 @@ export function OptionMenu({ getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations(); const isEdited = !!edits?.length; - const [nickEditOpen, setNickEditOpen] = useState(false); - const [nickDraft, setNickDraft] = useState(''); - const nicknames = useAtomValue(nicknamesAtom); - const setNickname = useSetAtom(setNicknameAtom); - const senderId = mEvent.getSender() ?? ''; - const onTotalClose = () => { setModal(null); closeMenu(); @@ -530,7 +564,7 @@ export function OptionMenu({ initialFocus: false, onDeactivate: closeMenu, onPostDeactivate: handlePostDeactivate, - allowOutsideClick: (e) => { + allowOutsideClick: (e: MouseEvent | TouchEvent) => { e.preventDefault(); closeMenu(); return false; @@ -666,80 +700,13 @@ export function OptionMenu({ )} + {canForwardEvent(mEvent) && ( )} + {canPinEvent && } - {cleanedDisplayName && - senderId !== mx.getUserId() && - (nickEditOpen ? ( - - Nickname - setNickDraft(e.target.value)} - placeholder={cleanedDisplayName} - onKeyDown={(e) => { - if (e.key === 'Enter') { - setNickname(senderId, nickDraft || undefined, mx); - closeMenu(); - } - if (e.key === 'Escape') closeMenu(); - }} - className={css.MessageNickEditor} - /> - - { - setNickname(senderId, nickDraft || undefined, mx); - closeMenu(); - }} - > - Save - - {nicknames[senderId] && ( - { - setNickname(senderId, undefined, mx); - onTotalClose(); - }} - > - Clear - - )} - - - ) : ( - { - setNickDraft(nicknames[senderId] ?? ''); - setNickEditOpen(true); - }} - > - - {nicknames[senderId] ? 'Edit Nickname' : 'Set Nickname'} - - - ))} {((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && ( <> @@ -854,7 +821,6 @@ export function MobileOptionsInternal({ options }: { options: OptionMenuProps }) hideReadReceipts={options.hideReadReceipts} showDeveloperTools={options.showDeveloperTools} canPinEvent={options.canPinEvent} - cleanedDisplayName={options.cleanedDisplayName} canDelete={options.canDelete} setIsEmoji={options.setIsEmoji} ActualMessage={options.ActualMessage} diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 3e739303c..3e14b49d9 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -328,7 +328,7 @@ function UserHeroNameInner({ } else isSuccess.current = false; setCopied(); }} - style={{ backgroundColor: '#0000', padding: '0' }} + style={{ backgroundColor: 'transparent', padding: '0' }} onPointerEnter={() => setIsHovered(true)} onPointerLeave={() => setIsHovered(false)} before={`@${username}`} diff --git a/src/app/components/user-profile/styles.css.ts b/src/app/components/user-profile/styles.css.ts index e7283d43b..9d47b7fbe 100644 --- a/src/app/components/user-profile/styles.css.ts +++ b/src/app/components/user-profile/styles.css.ts @@ -45,6 +45,7 @@ export const UserAvatarContainer = style({ top: 0, transform: 'translateY(-50%)', backgroundColor: color.Surface.Container, + borderRadius: config.borderWidth.B400, }); export const UserHeroStatusContainer = style({ position: 'relative', diff --git a/src/app/features/bookmarks/bookmarkDomain.ts b/src/app/features/bookmarks/bookmarkDomain.ts new file mode 100644 index 000000000..393ad9653 --- /dev/null +++ b/src/app/features/bookmarks/bookmarkDomain.ts @@ -0,0 +1,90 @@ +import { MATRIX_SABLE_UNSTABLE_BOOKMARK_ITEM_EVENT_PREFIX } from '$unstable/prefixes'; +import type { MatrixEvent, Room } from 'matrix-js-sdk'; +import type { BookmarkIndexContent, BookmarkItemContent } from '$types/matrix-sdk-events'; + +export function computeBookmarkId(roomId: string, eventId: string): string { + const input = `${roomId}|${eventId}`; + let hash = 0; + for (let i = 0; i < input.length; i++) { + const ch = input.charCodeAt(i); + hash = ((hash << 5) - hash + ch) | 0; + } + const hex = (hash >>> 0).toString(16).padStart(8, '0'); + return `bmk_${hex}`; +} + +export function bookmarkItemEventType(bookmarkId: string): string { + return `${MATRIX_SABLE_UNSTABLE_BOOKMARK_ITEM_EVENT_PREFIX}${bookmarkId}`; +} + +export function buildMatrixURI(roomId: string, eventId: string): string { + return `matrix:roomid/${encodeURIComponent(roomId)}/e/${encodeURIComponent(eventId)}`; +} + +export function extractBodyPreview(mEvent: MatrixEvent, maxLength = 120): string { + const content = mEvent.getContent(); + const body = content?.body; + if (typeof body !== 'string' || body.length === 0) return ''; + if (body.length <= maxLength) return body; + return `${body.slice(0, maxLength)}…`; +} + +export function createBookmarkItem( + room: Room, + mEvent: MatrixEvent +): BookmarkItemContent | undefined { + const eventId = mEvent.getId(); + const { roomId } = room; + if (!eventId) return undefined; + + const bookmarkId = computeBookmarkId(roomId, eventId); + + return { + version: 1, + bookmark_id: bookmarkId, + uri: buildMatrixURI(roomId, eventId), + room_id: roomId, + event_id: eventId, + event_ts: mEvent.getTs(), + bookmarked_ts: Date.now(), + sender: mEvent.getSender(), + room_name: room.name, + body_preview: mEvent.isEncrypted() ? undefined : extractBodyPreview(mEvent), + msgtype: mEvent.getContent()?.msgtype, + }; +} + +export function isValidIndexContent(content: unknown): content is BookmarkIndexContent { + if (typeof content !== 'object' || content === null) return false; + const c = content as Record; + return ( + c.version === 1 && + typeof c.revision === 'number' && + typeof c.updated_ts === 'number' && + Array.isArray(c.bookmark_ids) && + c.bookmark_ids.every((id: unknown) => typeof id === 'string') + ); +} + +export function isValidBookmarkItem(content: unknown): content is BookmarkItemContent { + if (typeof content !== 'object' || content === null) return false; + const c = content as Record; + return ( + c.version === 1 && + typeof c.bookmark_id === 'string' && + typeof c.uri === 'string' && + typeof c.room_id === 'string' && + typeof c.event_id === 'string' && + typeof c.event_ts === 'number' && + typeof c.bookmarked_ts === 'number' + ); +} + +export function emptyIndex(): BookmarkIndexContent { + return { + version: 1, + revision: 0, + updated_ts: Date.now(), + bookmark_ids: [], + }; +} diff --git a/src/app/features/bookmarks/bookmarkRepository.ts b/src/app/features/bookmarks/bookmarkRepository.ts new file mode 100644 index 000000000..66f3ff152 --- /dev/null +++ b/src/app/features/bookmarks/bookmarkRepository.ts @@ -0,0 +1,91 @@ +import type { AccountDataEvents, MatrixClient } from 'matrix-js-sdk'; +import { + bookmarkItemEventType, + emptyIndex, + isValidBookmarkItem, + isValidIndexContent, +} from './bookmarkDomain'; +import type { BookmarkIndexContent, BookmarkItemContent } from '$types/matrix-sdk-events'; +import { MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT } from '$unstable/prefixes'; + +function readIndex(mx: MatrixClient): BookmarkIndexContent { + const evt = mx.getAccountData(MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT); + const content = evt?.getContent(); + if (isValidIndexContent(content)) return content; + return emptyIndex(); +} + +async function readIndexFromServer(mx: MatrixClient): Promise { + const content = await mx.getAccountDataFromServer(MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT); + if (isValidIndexContent(content)) return content; + return emptyIndex(); +} + +async function readItemFromServer( + mx: MatrixClient, + bookmarkId: string +): Promise { + const content = await mx.getAccountDataFromServer( + bookmarkItemEventType(bookmarkId) as keyof AccountDataEvents + ); + if (isValidBookmarkItem(content) && !content.deleted) return content; + return undefined; +} + +async function writeIndex(mx: MatrixClient, index: BookmarkIndexContent): Promise { + await mx.setAccountData(MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT, index); +} + +async function writeItem(mx: MatrixClient, item: BookmarkItemContent): Promise { + await mx.setAccountData(bookmarkItemEventType(item.bookmark_id) as keyof AccountDataEvents, item); +} + +type IndexMutator = (index: BookmarkIndexContent) => BookmarkIndexContent; + +async function mutateIndex(mx: MatrixClient, mutate: IndexMutator): Promise { + const currentIndex = await readIndexFromServer(mx); + const nextIndex = mutate(currentIndex); + await writeIndex(mx, nextIndex); +} + +export async function addBookmark(mx: MatrixClient, item: BookmarkItemContent): Promise { + await writeItem(mx, item); + + await mutateIndex(mx, (index) => { + const ids = index.bookmark_ids.includes(item.bookmark_id) + ? index.bookmark_ids + : [item.bookmark_id, ...index.bookmark_ids]; + + return { + ...index, + bookmark_ids: ids, + revision: index.revision + 1, + updated_ts: Date.now(), + }; + }); +} + +export async function removeBookmark(mx: MatrixClient, bookmarkId: string): Promise { + await mutateIndex(mx, (index) => ({ + ...index, + bookmark_ids: index.bookmark_ids.filter((id) => id !== bookmarkId), + revision: index.revision + 1, + updated_ts: Date.now(), + })); + + const existing = await readItemFromServer(mx, bookmarkId); + if (existing) { + await writeItem(mx, { ...existing, deleted: true }); + } +} + +export async function listBookmarks(mx: MatrixClient): Promise { + const index = await readIndexFromServer(mx); + const items = await Promise.all(index.bookmark_ids.map((id) => readItemFromServer(mx, id))); + return items.filter((item): item is BookmarkItemContent => item != null); +} + +export function isBookmarked(mx: MatrixClient, bookmarkId: string): boolean { + const index = readIndex(mx); + return index.bookmark_ids.includes(bookmarkId); +} diff --git a/src/app/features/bookmarks/index.ts b/src/app/features/bookmarks/index.ts new file mode 100644 index 000000000..015c9c22b --- /dev/null +++ b/src/app/features/bookmarks/index.ts @@ -0,0 +1,3 @@ +export * from './bookmarkDomain'; +export * from './bookmarkRepository'; +export * from '../../hooks/useBookmarks'; diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx deleted file mode 100644 index cfb8df8a9..000000000 --- a/src/app/features/call/CallControls.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import type { MouseEventHandler } from 'react'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import type { RectCords } from 'folds'; -import { - Box, - Button, - config, - IconButton, - Menu, - MenuItem, - PopOut, - Spinner, - Text, - toRem, -} from 'folds'; -import { - DotsThreeOutlineVerticalIcon, - sizedIcon, - PhoneDisconnect, -} from '$components/icons/phosphor'; -import FocusTrap from 'focus-trap-react'; -import { SequenceCard } from '$components/sequence-card'; -import type { CallEmbed } from '$plugins/call'; -import { useCallControlState } from '$plugins/call'; -import { stopPropagation } from '$utils/keyboard'; -import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; -import { useRoom } from '$hooks/useRoom'; -import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; -import { useCallPreferences } from '$state/hooks/callPreferences'; -import * as css from './styles.css'; -import { - ChatButton, - ControlDivider, - MicrophoneButton, - ScreenShareButton, - SoundButton, - VideoButton, -} from './Controls'; - -type CallControlsProps = { - callEmbed: CallEmbed; -}; -export function CallControls({ callEmbed }: CallControlsProps) { - const room = useRoom(); - const controlRef = useRef(null); - - const screenSize = useScreenSizeContext(); - const compact = screenSize === ScreenSize.Mobile; - - const { microphone, video, sound, screenshare, spotlight } = useCallControlState( - callEmbed.control - ); - - const { setPreferences } = useCallPreferences(); - - useEffect(() => { - setPreferences({ microphone, video, sound }); - }, [microphone, video, sound, setPreferences]); - - const [cords, setCords] = useState(); - - const handleOpenMenu: MouseEventHandler = (evt) => { - setCords(evt.currentTarget.getBoundingClientRect()); - }; - - const handleSpotlightClick = () => { - callEmbed.control.toggleSpotlight(); - setCords(undefined); - }; - - const handleReactionsClick = () => { - callEmbed.control.toggleReactions(); - setCords(undefined); - }; - - const handleSettingsClick = () => { - callEmbed.control.toggleSettings(); - setCords(undefined); - }; - - const [hangupState, hangup] = useAsyncCallback( - useCallback(() => callEmbed.hangup(), [callEmbed]) - ); - const exiting = - hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success; - - return ( - - - - callEmbed.control.toggleMicrophone()} - /> - callEmbed.control.toggleSound()} /> - - - {!compact && } - - - callEmbed.control.toggleVideo()} /> - callEmbed.control.toggleScreenshare()} - /> - - {!compact && } - - {room?.isCallRoom() && } - - setCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - - - - - {spotlight ? 'Grid View' : 'Spotlight View'} - - - - - Reactions - - - - - Settings - - - - - - } - > - - {sizedIcon(DotsThreeOutlineVerticalIcon, '300', { filled: !!cords })} - - - - - - - - ); -} diff --git a/src/app/features/call/CallRingtoneManager.ts b/src/app/features/call/CallRingtoneManager.ts new file mode 100644 index 000000000..084a7d065 --- /dev/null +++ b/src/app/features/call/CallRingtoneManager.ts @@ -0,0 +1,120 @@ +import * as Sentry from '@sentry/react'; +import { callRingtoneVolumeToGain } from './callRingtone'; +import { resolveCallToneSources } from './callToneSources'; +import type { Settings, CallRingtoneId } from '$state/settings'; + +export type PreviewTone = 'incoming' | 'outgoing'; + +class CallRingtoneManager { + private incomingAudio: HTMLAudioElement; + private outgoingAudio: HTMLAudioElement; + private previewAudio: HTMLAudioElement; + private revokeToneUrls: (() => void) | undefined; + private currentPreviewUrl: string | null = null; + + constructor() { + this.incomingAudio = new Audio(); + this.incomingAudio.loop = true; + + this.outgoingAudio = new Audio(); + this.outgoingAudio.loop = true; + + this.previewAudio = new Audio(); + this.previewAudio.loop = true; + } + + public async syncSources( + callRingtoneId: CallRingtoneId, + callRingbackTone: CallRingtoneId, + callRingtoneVolume: number + ) { + const resolved = await resolveCallToneSources({ callRingtoneId, callRingbackTone }); + + this.revokeToneUrls?.(); + this.revokeToneUrls = resolved.revoke; + + const gain = callRingtoneVolumeToGain(callRingtoneVolume); + + if (resolved.incomingUrl) { + this.incomingAudio.src = resolved.incomingUrl; + } else { + this.incomingAudio.removeAttribute('src'); + } + + if (resolved.outgoingUrl) { + this.outgoingAudio.src = resolved.outgoingUrl; + } else { + this.outgoingAudio.removeAttribute('src'); + } + + this.incomingAudio.volume = gain; + this.outgoingAudio.volume = gain; + } + + public playIncoming(): Promise { + if (!this.incomingAudio.src) return Promise.resolve(); + return this.incomingAudio.play().catch((err) => { + if (err.name === 'AbortError') return; + Sentry.metrics.count('sable.call.ringtone.blocked', 1); + throw err; + }); + } + + public stopIncoming() { + this.incomingAudio.pause(); + this.incomingAudio.currentTime = 0; + } + + public playOutgoing() { + if (!this.outgoingAudio.src) return; + this.outgoingAudio.play().catch((err) => { + if (err.name === 'AbortError') return; + Sentry.metrics.count('sable.call.ringback.blocked', 1); + }); + } + + public stopOutgoing() { + this.outgoingAudio.pause(); + this.outgoingAudio.currentTime = 0; + } + + public async playPreview( + tone: PreviewTone, + settings: Pick + ) { + this.stopPreview(); + + const resolved = await resolveCallToneSources({ + callRingtoneId: settings.callRingtoneId, + callRingbackTone: settings.callRingbackTone, + }); + const source = tone === 'incoming' ? resolved.incomingUrl : resolved.outgoingUrl; + + if (tone === 'incoming' && resolved.outgoingUrl?.startsWith('blob:')) { + URL.revokeObjectURL(resolved.outgoingUrl); + } else if (tone === 'outgoing' && resolved.incomingUrl?.startsWith('blob:')) { + URL.revokeObjectURL(resolved.incomingUrl); + } + + if (!source) return; + + this.currentPreviewUrl = source; + this.previewAudio.src = source; + this.previewAudio.volume = callRingtoneVolumeToGain(settings.callRingtoneVolume); + + await this.previewAudio.play(); + } + + public stopPreview() { + this.previewAudio.pause(); + this.previewAudio.currentTime = 0; + this.previewAudio.removeAttribute('src'); + + if (this.currentPreviewUrl?.startsWith('blob:')) { + URL.revokeObjectURL(this.currentPreviewUrl); + } + this.currentPreviewUrl = null; + } +} + +export const ringtoneManager = new CallRingtoneManager(); diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 861af9175..36a5108b9 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -1,12 +1,9 @@ -import { useCallback, useRef, useState, type RefObject } from 'react'; +import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'; import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds'; +import { useAtomValue } from 'jotai'; import { ContainerColor } from '$styles/ContainerColor.css'; -import { usePowerLevelsContext } from '$hooks/usePowerLevels'; -import { useRoomCreators } from '$hooks/useRoomCreators'; -import { useRoomPermissions } from '$hooks/useRoomPermissions'; -import { useMatrixClient } from '$hooks/useMatrixClient'; import { useRoom } from '$hooks/useRoom'; -import { useLivekitSupport } from '$hooks/useLivekitSupport'; +import { useCallStartCapabilities } from '$hooks/useCallStartCapabilities'; import { useCallMembers, useCallSession } from '$hooks/useCall'; import { useCallEmbed, useCallEmbedPlacementSync, useCallJoined } from '$hooks/useCallEmbed'; @@ -14,13 +11,20 @@ import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import * as css from './styles.css'; import { CallMemberRenderer } from './CallMemberCard'; import { PrescreenControls } from './PrescreenControls'; -import { CallControls } from './CallControls'; -import { EventType } from '$types/matrix-sdk'; +import { callEmbedAtom, callEmbedStartErrorAtom } from '$state/callEmbed'; function LivekitServerMissingMessage() { return ( - Your homeserver does not support calling. But you can still join call started by others. + Your homeserver does not support calling. You can still join calls started by others. + + ); +} + +function WebRTCMissingError() { + return ( + + Your browser does not support WebRTC, which is required for calling. ); } @@ -28,19 +32,25 @@ function LivekitServerMissingMessage() { function JoinMessage({ hasParticipant, livekitSupported, + rtcSupported, }: { hasParticipant?: boolean; livekitSupported?: boolean; + rtcSupported?: boolean; }) { - if (hasParticipant) return null; + if (rtcSupported === false) { + return ; + } if (livekitSupported === false) { return ; } + if (hasParticipant) return null; + return ( - Voice chat's empty — Be the first to hop in! + Voice chat's empty - be the first to hop in! ); } @@ -56,30 +66,37 @@ function NoPermissionMessage() { function AlreadyInCallMessage() { return ( - Already in another call — End the current call to join! + Already in another call - end the current call to join. + + ); +} + +function WidgetPreparationErrorMessage({ message }: { message: string }) { + return ( + + {message} ); } function CallPrescreen() { - const mx = useMatrixClient(); const room = useRoom(); - const livekitSupported = useLivekitSupport(); - - const powerLevels = usePowerLevelsContext(); - const creators = useRoomCreators(room); - - const permissions = useRoomPermissions(creators, powerLevels); - const hasPermission = permissions.event(EventType.GroupCallMemberPrefix, mx.getSafeUserId()); + const callEmbed = useAtomValue(callEmbedAtom); + const callEmbedStartError = useAtomValue(callEmbedStartErrorAtom); + const callJoined = useCallJoined(callEmbed); + const callStartCapabilities = useCallStartCapabilities(room); const callSession = useCallSession(room); const callMembers = useCallMembers(room, callSession); const hasParticipant = callMembers.length > 0; + const showEmbedError = + callEmbed?.roomId === room.roomId && !callJoined && callEmbedStartError !== null; + const embedErrorMessage = + callEmbedStartError?.kind === 'capability' + ? 'Call setup failed because required call capabilities were rejected.' + : 'Call setup failed while preparing the embedded call app.'; - const callEmbed = useCallEmbed(); - const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId; - - const canJoin = hasPermission && (livekitSupported || hasParticipant); + const canJoin = callStartCapabilities.canStart; return ( @@ -100,13 +117,22 @@ function CallPrescreen() { - {!inOtherCall && - (hasPermission ? ( - + {!callStartCapabilities.inAnotherCall && + (callStartCapabilities.hasCallMemberPermission ? ( + ) : ( ))} - {inOtherCall && } + {callStartCapabilities.inAnotherCall && } + {showEmbedError && ( + + )} @@ -116,34 +142,12 @@ function CallPrescreen() { type CallJoinedProps = { containerRef: RefObject; - joined: boolean; }; -function CallJoined({ joined, containerRef }: CallJoinedProps) { - const callEmbed = useCallEmbed(); - +function CallJoined({ containerRef }: CallJoinedProps) { return ( - - {callEmbed && joined && ( -
-
- -
-
- )}
); } @@ -166,17 +170,40 @@ export function CallView({ resizable }: CallViewProps) { const currentJoined = callEmbed?.roomId === room.roomId && callJoined; - const [height, setHeight] = useState(isMobile ? 240 : 380); + const [heightRatio, setHeightRatio] = useState(isMobile ? 0.3 : 0.72); + const [availableHeight, setAvailableHeight] = useState(0); + + useEffect(() => { + if (!resizable || !callViewRef.current) return undefined; + const container = callViewRef.current.parentElement?.parentElement; + if (!container) return undefined; + + const observer = new ResizeObserver((entries) => { + if (entries[0]) { + setAvailableHeight(entries[0].contentRect.height); + } + }); + observer.observe(container); + setAvailableHeight(container.getBoundingClientRect().height); + + return () => observer.disconnect(); + }, [resizable]); + const [isDragging, setIsDragging] = useState(false); const isResizing = useRef(false); + const previousBodyUserSelect = useRef(null); const handleMove = useCallback( (clientY: number) => { if (!isResizing.current || !callViewRef.current) return; const { top } = callViewRef.current.getBoundingClientRect(); - setHeight(Math.max(isMobile ? 120 : 150, Math.min(clientY - top, window.innerHeight * 0.8))); + const newHeight = clientY - top; + const baseHeight = availableHeight || window.innerHeight; + const ratio = newHeight / baseHeight; + const clampedRatio = Math.max(0.2, Math.min(ratio, 0.8)); + setHeightRatio(clampedRatio); }, - [isMobile] + [availableHeight] ); const handleMouseMove = useCallback((e: MouseEvent) => handleMove(e.clientY), [handleMove]); @@ -195,12 +222,16 @@ export function CallView({ resizable }: CallViewProps) { document.removeEventListener('mouseup', stopResizing); document.removeEventListener('touchmove', handleTouchMove); document.removeEventListener('touchend', stopResizing); - document.body.style.userSelect = 'auto'; + document.body.style.userSelect = previousBodyUserSelect.current ?? ''; + previousBodyUserSelect.current = null; }, [handleMouseMove, handleTouchMove]); const startResizing = useCallback(() => { isResizing.current = true; setIsDragging(true); + if (previousBodyUserSelect.current === null) { + previousBodyUserSelect.current = document.body.style.userSelect; + } document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', stopResizing); document.addEventListener('touchmove', handleTouchMove, { passive: false }); @@ -208,6 +239,19 @@ export function CallView({ resizable }: CallViewProps) { document.body.style.userSelect = 'none'; }, [handleMouseMove, handleTouchMove, stopResizing]); + useEffect( + () => () => { + isResizing.current = false; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', stopResizing); + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', stopResizing); + document.body.style.userSelect = previousBodyUserSelect.current ?? ''; + previousBodyUserSelect.current = null; + }, + [handleMouseMove, handleTouchMove, stopResizing] + ); + return ( 0 + ? `${availableHeight * heightRatio}px` + : `${heightRatio * 100}dvh` + : undefined, borderBottom: `1px solid var(--sable-surface-container-line)`, zIndex: 20, backgroundColor: currentJoined ? 'transparent' : undefined, @@ -236,7 +284,7 @@ export function CallView({ resizable }: CallViewProps) { )} {!currentJoined && } - + {resizable && ( void; }; -export function StateEventEditor({ type, stateKey, requestClose }: StateEventEditorProps) { +export function StateEventEditor({ + type, + stateKey, + rawEvent, + requestClose, +}: StateEventEditorProps) { const mx = useMatrixClient(); const room = useRoom(); const stateEvent = useStateEvent(room, type as keyof StateEvents, stateKey); @@ -252,9 +258,16 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi const canEdit = permissions.stateEvent(type, mx.getSafeUserId()); const eventJSONStr = useMemo(() => { - if (!stateEvent) return ''; - return JSON.stringify(stateEvent.event, null, EDITOR_INTENT_SPACE_COUNT); - }, [stateEvent]); + if (stateEvent) { + return JSON.stringify(stateEvent.event, null, EDITOR_INTENT_SPACE_COUNT); + } + if (rawEvent) { + return JSON.stringify(rawEvent, null, EDITOR_INTENT_SPACE_COUNT); + } + return ''; + }, [stateEvent, rawEvent]); + + const content = stateEvent?.getContent() ?? (rawEvent as { content?: object })?.content ?? {}; const handleCloseEdit = useCallback(() => { setEditContent(undefined); @@ -286,7 +299,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi /> ) : ( diff --git a/src/app/features/common-settings/permissions/PermissionGroups.tsx b/src/app/features/common-settings/permissions/PermissionGroups.tsx index 628f53bad..3205c545b 100644 --- a/src/app/features/common-settings/permissions/PermissionGroups.tsx +++ b/src/app/features/common-settings/permissions/PermissionGroups.tsx @@ -28,7 +28,8 @@ type PermissionGroupsProps = { permissionGroups: PermissionGroup[]; }; -const getPermissionLocationKey = (location: PermissionLocation): string => JSON.stringify(location); +const getPermissionLocationKey = (location: PermissionLocation | PermissionLocation[]): string => + JSON.stringify(location); export function PermissionGroups({ powerLevels, @@ -42,9 +43,7 @@ export function PermissionGroups({ const powerLevelTags = usePowerLevelTags(room, powerLevels); const maxPower = useMemo(() => Math.max(...getPowers(powerLevelTags)), [powerLevelTags]); - const [permissionUpdate, setPermissionUpdate] = useState>( - new Map() - ); + const [permissionUpdate, setPermissionUpdate] = useState>(new Map()); useEffect(() => { // reset permission update if component rerender @@ -53,19 +52,20 @@ export function PermissionGroups({ }, [permissionGroups]); const handleChangePermission = ( - location: PermissionLocation, + location: PermissionLocation | PermissionLocation[], newPower: number, currentPower: number ) => { + const locationKey = getPermissionLocationKey(location); setPermissionUpdate((p) => { const up: typeof p = new Map(); p.forEach((value, key) => { up.set(key, value); }); if (newPower === currentPower) { - up.delete(location); + up.delete(locationKey); } else { - up.set(location, newPower); + up.set(locationKey, newPower); } return up; }); @@ -80,8 +80,12 @@ export function PermissionGroups({ applyPermissionPower(draftPowerLevels, item.location, power); }) ); - permissionUpdate.forEach((power, location) => - applyPermissionPower(draftPowerLevels, location, power) + permissionUpdate.forEach((power, locationKey) => + applyPermissionPower( + draftPowerLevels, + JSON.parse(locationKey) as PermissionLocation | PermissionLocation[], + power + ) ); return draftPowerLevels; @@ -111,7 +115,7 @@ export function PermissionGroups({ const renderUserGroup = () => { const power = getPermissionPower(powerLevels, USER_DEFAULT_LOCATION); - const powerUpdate = permissionUpdate.get(USER_DEFAULT_LOCATION); + const powerUpdate = permissionUpdate.get(getPermissionLocationKey(USER_DEFAULT_LOCATION)); const value = powerUpdate ?? power; const tag = getPowerLevelTag(powerLevelTags, value); @@ -172,7 +176,7 @@ export function PermissionGroups({ {group.name} {group.items.map((item) => { const power = getPermissionPower(powerLevels, item.location); - const powerUpdate = permissionUpdate.get(item.location); + const powerUpdate = permissionUpdate.get(getPermissionLocationKey(item.location)); const value = powerUpdate ?? power; const tag = getPowerLevelTag(powerLevelTags, value); diff --git a/src/app/features/common-settings/permissions/Powers.tsx b/src/app/features/common-settings/permissions/Powers.tsx index 7ad8630c4..0563be7da 100644 --- a/src/app/features/common-settings/permissions/Powers.tsx +++ b/src/app/features/common-settings/permissions/Powers.tsx @@ -6,7 +6,7 @@ import { Box, Button, Chip, Text, PopOut, Menu, Scroll, toRem, config, color } f import { SequenceCard } from '$components/sequence-card'; import { getPowers, usePowerLevelTags } from '$hooks/usePowerLevelTags'; import { SettingTile } from '$components/setting-tile'; -import type { IPowerLevels } from '$hooks/usePowerLevels'; +import type { IPowerLevels, PermissionLocation } from '$hooks/usePowerLevels'; import { getPermissionPower } from '$hooks/usePowerLevels'; import { useRoom } from '$hooks/useRoom'; import { PowerColorBadge, PowerIcon } from '$components/power'; @@ -19,6 +19,9 @@ import { useRoomCreators } from '$hooks/useRoomCreators'; import { SequenceCardStyle } from '$features/common-settings/styles.css'; import type { PermissionGroup } from './types'; +const getPermissionLocationKey = (location: PermissionLocation | PermissionLocation[]): string => + JSON.stringify(location); + type PeekPermissionsProps = { powerLevels: IPowerLevels; power: number; @@ -67,7 +70,7 @@ function PeekPermissions({ powerLevels, power, permissionGroups, children }: Pee return ( { +export const usePermissionGroups = (): PermissionGroup[] => { const groups: PermissionGroup[] = useMemo(() => { const messagesGroup: PermissionGroup = { name: 'Messages', @@ -48,19 +49,6 @@ export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => { ], }; - const callSettingsGroup: PermissionGroup = { - name: 'Calls', - items: [ - { - location: { - state: true, - key: EventType.GroupCallMemberPrefix, - }, - name: 'Join Call', - }, - ], - }; - const moderationGroup: PermissionGroup = { name: 'Moderation', items: [ @@ -218,13 +206,13 @@ export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => { return [ messagesGroup, - ...(isCallRoom ? [callSettingsGroup] : []), + CALL_PERMISSIONS_GROUP, moderationGroup, roomOverviewGroup, roomSettingsGroup, otherSettingsGroup, ]; - }, [isCallRoom]); + }, []); return groups; }; diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index da71e3f5b..e8ba66382 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -12,6 +12,7 @@ import { useKeyDown } from '$hooks/useKeyDown'; import { markAsRead } from '$utils/notifications'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useRoomMembers } from '$hooks/useRoomMembers'; +import { Page } from '$components/page'; import { CallView } from '$features/call/CallView'; import { WidgetsDrawer } from '$features/widgets/WidgetsDrawer'; import { callChatAtom } from '$state/callEmbed'; @@ -113,12 +114,12 @@ export function Room() { {callView && (screenSize === ScreenSize.Desktop || !chat) && ( - + - + )} {!callView && ( diff --git a/src/app/features/room/RoomCallButton.test.tsx b/src/app/features/room/RoomCallButton.test.tsx new file mode 100644 index 000000000..7f4ce6ae6 --- /dev/null +++ b/src/app/features/room/RoomCallButton.test.tsx @@ -0,0 +1,88 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type * as JotaiModule from 'jotai'; +import type { Room } from '$types/matrix-sdk'; +import { RoomCallButton } from './RoomCallButton'; + +const { startCallMock, useCallJoinedMock } = vi.hoisted(() => ({ + startCallMock: vi.fn<(...args: unknown[]) => void>(), + useCallJoinedMock: vi.fn<() => boolean>(), +})); + +vi.mock('$hooks/useCallEmbed', () => ({ + useCallStart: () => startCallMock, + useCallJoined: () => useCallJoinedMock(), +})); + +vi.mock('jotai', async (importOriginal: () => Promise) => { + const actual = await importOriginal(); + return { + ...actual, + useAtomValue: () => undefined, + }; +}); + +describe('RoomCallButton', () => { + const room = { roomId: '!room:example.org' } as Room; + + beforeEach(() => { + startCallMock.mockReset(); + useCallJoinedMock.mockReset().mockReturnValue(false); + }); + + it('starts a voice call from the voice button', async () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /start voice call/i })); + + await waitFor(() => { + expect(startCallMock).toHaveBeenCalledWith(room, { + microphone: true, + video: false, + sound: true, + }); + }); + }); + + it('starts a video call from the video button', async () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /start video call/i })); + + await waitFor(() => { + expect(startCallMock).toHaveBeenCalledWith(room, { + microphone: true, + video: true, + sound: true, + }); + }); + }); + + it('hides video button when video start is disabled', () => { + render( + + ); + + expect(screen.queryByRole('button', { name: /start video call/i })).toBeNull(); + }); +}); diff --git a/src/app/features/room/RoomCallButton.tsx b/src/app/features/room/RoomCallButton.tsx index 3d7b40bfe..b87ecd839 100644 --- a/src/app/features/room/RoomCallButton.tsx +++ b/src/app/features/room/RoomCallButton.tsx @@ -1,63 +1,64 @@ import { IconButton, TooltipProvider, Tooltip, Text } from 'folds'; -import { composerIcon, Phone } from '$components/icons/phosphor'; +import { composerIcon, Phone, VideoCamera } from '$components/icons/phosphor'; import { useAtomValue } from 'jotai'; -import type { Room, TimelineEvents } from '$types/matrix-sdk'; +import type { Room } from '$types/matrix-sdk'; import { useCallStart, useCallJoined } from '$hooks/useCallEmbed'; +import type { CallPreferences } from '$state/callPreferences'; import { callEmbedAtom } from '$state/callEmbed'; -import { useMatrixClient } from '$hooks/useMatrixClient'; -import { useCallPreferences } from '$state/hooks/callPreferences'; interface RoomCallButtonProps { room: Room; + direct: boolean; + defaultPreferences: CallPreferences; + kind: 'voice' | 'video'; + allowVideoStart?: boolean; } -export function RoomCallButton({ room }: RoomCallButtonProps) { - const startCall = useCallStart(); +export function RoomCallButton({ + room, + direct, + defaultPreferences, + kind, + allowVideoStart = true, +}: RoomCallButtonProps) { + const startCall = useCallStart(direct); const callEmbed = useAtomValue(callEmbedAtom); const joined = useCallJoined(callEmbed); - const mx = useMatrixClient(); - const { microphone, video, sound } = useCallPreferences(); const isJoinedInThisRoom = joined && callEmbed?.roomId === room.roomId; + const callStartingInThisRoom = !!callEmbed && callEmbed.roomId === room.roomId && !joined; + const inAnotherCall = !!callEmbed && callEmbed.roomId !== room.roomId; + const startDisabled = inAnotherCall || callStartingInThisRoom; + const startingVideoCall = kind === 'video'; + if (kind === 'video' && !allowVideoStart) return null; if (isJoinedInThisRoom) return null; - const handleStartCall = async () => { - startCall(room, { microphone, video, sound }); - try { - const now = Date.now(); - // TODO not use as any one day someday i swear - await mx.sendEvent( - room.roomId, - 'org.matrix.msc4075.rtc.notification' as keyof TimelineEvents, - { - notification_type: 'ring', - sender_ts: now, - lifetime: 30000, - 'm.mentions': { - room: true, - }, - application: 'm.call', - call_id: room.roomId, - 'm.text': [ - { - body: `Call started by ${mx.getUser(mx.getSafeUserId())?.displayName || 'User'} 🎶`, - }, - ], - } as unknown as TimelineEvents[keyof TimelineEvents] - ); - } catch { - /* skill issue block */ - } + const startSelectedCall = () => { + startCall(room, { + microphone: defaultPreferences.microphone, + video: startingVideoCall, + sound: defaultPreferences.sound, + }); }; + const readyCopy = startingVideoCall ? 'Start Video Call' : 'Start Voice Call'; + const ariaLabel = startingVideoCall ? 'Start Video Call' : 'Start Voice Call'; + const icon = startingVideoCall ? VideoCamera : Phone; + return ( - Start Voice Call + {inAnotherCall ? ( + Already in another call + ) : callStartingInThisRoom ? ( + Call is starting + ) : ( + {readyCopy} + )} } > @@ -65,10 +66,11 @@ export function RoomCallButton({ room }: RoomCallButtonProps) { - {composerIcon(Phone)} + {composerIcon(icon)} )} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index aff2088f9..4dff58cf7 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -1632,6 +1632,7 @@ export const RoomInput = forwardRef( variant="SurfaceVariant" size="300" radii="300" + style={{ backgroundColor: 'transparent' }} title={editorOldAddFile ? 'Upload File' : 'Add'} aria-label={editorOldAddFile ? 'Upload and attach a File' : 'Add new Item'} > @@ -1649,6 +1650,7 @@ export const RoomInput = forwardRef( radii="300" title={showAudioRecorder ? 'Stop recording' : 'Record audio message'} aria-label={showAudioRecorder ? 'Stop recording' : 'Record audio message'} + style={{ backgroundColor: 'transparent' }} aria-pressed={showAudioRecorder} onClick={() => { if (mobileOrTablet() && !showAudioRecorder) return; @@ -1752,6 +1754,7 @@ export const RoomInput = forwardRef( variant="SurfaceVariant" size="300" radii="300" + style={{ backgroundColor: 'transparent' }} title="open sticker picker" aria-label="Open sticker picker" > @@ -1772,6 +1775,7 @@ export const RoomInput = forwardRef( variant="SurfaceVariant" size="300" radii="300" + style={{ backgroundColor: 'transparent' }} title="open emoji picker" aria-label="Open emoji picker" > @@ -1835,6 +1839,7 @@ export const RoomInput = forwardRef( { if (isLongPress.current) { isLongPress.current = false; @@ -1879,6 +1884,7 @@ export const RoomInput = forwardRef( title="Schedule Message" aria-label="Schedule message send" variant={scheduledTime ? 'Primary' : 'SurfaceVariant'} + style={{ backgroundColor: 'transparent' }} size="300" radii="0" className={css.SplitChevronButton} diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts index 9903033dc..4236685e9 100644 --- a/src/app/features/room/RoomTimeline.css.ts +++ b/src/app/features/room/RoomTimeline.css.ts @@ -12,6 +12,8 @@ export const TimelineFloat = recipe({ transform: 'translateX(-50%)', zIndex: 10, minWidth: 'max-content', + overflow: 'hidden', + borderRadius: config.radii.Pill, }, ], variants: { diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index a5329bed4..c22cce06e 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -195,8 +195,8 @@ export function RoomView({ eventId }: { eventId?: string }) { )} )} + {hideReads ? : } - {hideReads ? : } diff --git a/src/app/features/room/RoomViewFollowing.css.ts b/src/app/features/room/RoomViewFollowing.css.ts index 3f7bee353..e8385716a 100644 --- a/src/app/features/room/RoomViewFollowing.css.ts +++ b/src/app/features/room/RoomViewFollowing.css.ts @@ -16,7 +16,6 @@ export const RoomViewFollowing = recipe({ minHeight: toRem(28), padding: `0 ${config.space.S400}`, width: '100%', - backgroundColor: color.Surface.Container, color: color.Surface.OnContainer, outline: 'none', userSelect: 'none', @@ -29,6 +28,7 @@ export const RoomViewFollowing = recipe({ selectors: { '&:hover, &:focus-visible': { color: color.Primary.Main, + transform: 'none', }, '&:active': { color: color.Primary.Main, diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 632331b17..5aec60911 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -26,7 +26,7 @@ import { useNavigate } from 'react-router-dom'; import type { Room, MatrixEvent } from '$types/matrix-sdk'; import { Direction, - EventTimeline, + type EventTimeline, NotificationCountType, ThreadEvent, RoomEvent, @@ -107,6 +107,8 @@ import { callChatAtom } from '$state/callEmbed'; import { RoomSettingsPage } from '$state/roomSettings'; import { roomIdToThreadBrowserAtomFamily } from '$state/room/roomToThreadBrowser'; import { roomIdToOpenThreadAtomFamily } from '$state/room/roomToOpenThread'; +import { useCallPreferences } from '$state/hooks/callPreferences'; +import { useCallStartCapabilities } from '$hooks/useCallStartCapabilities'; import { JumpToTime } from './jump-to-time'; import { RoomPinMenu } from './room-pin-menu'; import * as css from './RoomViewHeader.css'; @@ -361,6 +363,7 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { const [pinMenuAnchor, setPinMenuAnchor] = useState(); const direct = useIsDirectRoom(); const [customDMCards] = useSetting(settingsAtom, 'customDMCards'); + const { microphone, video, sound } = useCallPreferences(); const [chat, setChat] = useAtom(callChatAtom); const [threadBrowserOpen, setThreadBrowserOpen] = useAtom( @@ -368,10 +371,7 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { ); const [openThreadId, setOpenThread] = useAtom(roomIdToOpenThreadAtomFamily(room.roomId)); - const canUseCalls = room - .getLiveTimeline() - .getState(EventTimeline.FORWARDS) - ?.maySendStateEvent('org.matrix.msc3401.call.member', mx.getUserId()!); + const callStartCapabilities = useCallStartCapabilities(room); const [alwaysShowCallButton] = useSetting(settingsAtom, 'alwaysShowCallButton'); const shouldShowCallButton = alwaysShowCallButton || room.getJoinedMemberCount() <= 10; @@ -736,7 +736,25 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { )} - {canUseCalls && shouldShowCallButton && } + {!room.isCallRoom() && + callStartCapabilities.canRenderCallButton && + shouldShowCallButton && ( + <> + + + + )} { return `${loadedCount}/${knownCount}`; }; -const formatSyncReason = (reason: string): string => { - if (reason === 'sliding_active') return 'Sliding Sync active'; - if (reason === 'sliding_disabled_server') return 'Server-side sliding sync disabled'; - if (reason === 'session_opt_out') return 'Session opt-in is off'; - if (reason === 'missing_proxy') return 'Sliding proxy URL missing'; - if (reason === 'cold_cache_bootstrap') return 'Cold-cache bootstrap (classic for this run)'; - if (reason === 'probe_failed_fallback') return 'Sliding probe failed, using fallback'; - return reason; -}; - export function SyncDiagnostics() { const mx = useMatrixClient(); const [, setTick] = useState(0); @@ -148,20 +138,8 @@ export function SyncDiagnostics() { gap="100" > - - Transport: {diagnostics.transport} - {diagnostics.fallbackFromSliding ? ' (fallback)' : ''} - + Transport: {diagnostics.transport} State: {diagnostics.syncState ?? 'null'} - - Sliding configured: {diagnostics.slidingConfigured ? 'yes' : 'no'} - - - Sliding server-enabled: {diagnostics.slidingEnabledOnServer ? 'yes' : 'no'} - - Sliding session opt-in: {diagnostics.sessionOptIn ? 'yes' : 'no'} - Sliding requested: {diagnostics.slidingRequested ? 'yes' : 'no'} - Sync reason: {formatSyncReason(diagnostics.reason)} Room counts: {roomDiagnostics.totalRooms} total, {roomDiagnostics.joinedRooms} joined,{' '} {roomDiagnostics.inviteRooms} invites diff --git a/src/app/features/settings/general/CallSoundSettings.test.tsx b/src/app/features/settings/general/CallSoundSettings.test.tsx new file mode 100644 index 000000000..16bf0ca04 --- /dev/null +++ b/src/app/features/settings/general/CallSoundSettings.test.tsx @@ -0,0 +1,79 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useSetting } from '$state/hooks/settings'; +import { CallSoundSettings } from './CallSoundSettings'; + +vi.mock('$state/settings', () => ({ + CALL_TONE_IDS: ['sable-default', 'classic-soft', 'minimal-ping', 'silent', 'custom'], + settingsAtom: {}, + getSettings: () => ({ + iconCompactSizePx: 16, + iconInlineSizePx: 20, + iconToolbarSizePx: 24, + iconEmptySizePx: 32, + }), +})); + +vi.mock('$state/hooks/settings', () => ({ + useSetting: vi.fn(), +})); + +vi.mock('$features/call/callRingtoneStorage', () => ({ + getCustomCallRingtone: vi.fn<() => Promise>(async () => undefined), + getCustomCallRingback: vi.fn<() => Promise>(async () => undefined), + putCustomCallRingtone: vi.fn<() => Promise>(), + putCustomCallRingback: vi.fn<() => Promise>(), + clearCustomCallRingtone: vi.fn<() => Promise>(), + clearCustomCallRingback: vi.fn<() => Promise>(), +})); + +const defaultSettingValues: Record = { + incomingCallSoundEnabled: true, + outgoingRingbackEnabled: true, + callRingtoneId: 'sable-default', + callRingbackTone: 'sable-default', + callRingtoneVolume: 80, + callSoundOverrideGlobalNotifications: false, +}; + +describe('CallSoundSettings', () => { + beforeEach(() => { + vi.mocked(useSetting).mockImplementation(((_atom: unknown, key: string) => { + return [defaultSettingValues[key], vi.fn<() => void>()] as never; + }) as never); + }); + + it('falls back to default ringtone when custom ringtone is unavailable', async () => { + const setCallRingtoneId = vi.fn<() => void>(); + vi.mocked(useSetting).mockImplementation(((_atom: unknown, key: string) => { + if (key === 'callRingtoneId') { + return ['custom', setCallRingtoneId] as never; + } + return [defaultSettingValues[key], vi.fn<() => void>()] as never; + }) as never); + + render(); + + await waitFor(() => { + expect(setCallRingtoneId).toHaveBeenCalledWith('sable-default'); + }); + }); + + it('renders expected call sound setting controls', async () => { + render(); + + expect(screen.getByText('Incoming Call Sound')).toBeInTheDocument(); + expect(screen.getByText('Outgoing Ringback Sound')).toBeInTheDocument(); + expect(screen.getByText('Ringtone')).toBeInTheDocument(); + expect(screen.getByText('Ringback Tone')).toBeInTheDocument(); + expect(screen.getByText('Ringtone Volume')).toBeInTheDocument(); + expect(screen.getByText('Always Play Call Sound')).toBeInTheDocument(); + expect(screen.getByText('Custom Ringtone')).toBeInTheDocument(); + expect(screen.getByText('Custom Ringback')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText('No custom ringtone imported.')).toBeInTheDocument(); + expect(screen.getByText('No custom ringback imported.')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/app/features/settings/general/CallSoundSettings.tsx b/src/app/features/settings/general/CallSoundSettings.tsx new file mode 100644 index 000000000..ac43c6441 --- /dev/null +++ b/src/app/features/settings/general/CallSoundSettings.tsx @@ -0,0 +1,399 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Box, Icons, Input, Switch, Text, toRem } from 'folds'; +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { SettingMenuSelector } from '$components/setting-menu-selector'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom, type CallRingtoneId } from '$state/settings'; +import { + CALL_RINGBACK_OPTIONS, + CALL_RINGTONE_OPTIONS, + clampCallRingtoneVolume, + readAudioDurationMs, + validateCustomCallRingtone, +} from '$features/call/callRingtone'; +import { ringtoneManager } from '$features/call/CallRingtoneManager'; +import { + clearCustomCallRingback, + clearCustomCallRingtone, + getCustomCallRingback, + getCustomCallRingtone, + putCustomCallRingback, + putCustomCallRingtone, + type StoredCallRingtone, +} from '$features/call/callRingtoneStorage'; +import { SequenceCardStyle } from '$features/settings/styles.css'; +import { + CustomToneSettingsCard, + customToneValidationError, + type CustomToneMetadata, + type PreviewTone, +} from './CallSoundSettingsCards'; + +const toCustomToneMetadata = (stored: StoredCallRingtone): CustomToneMetadata => ({ + fileName: stored.fileName, + sizeBytes: stored.sizeBytes, + durationMs: stored.durationMs, +}); + +export function CallSoundSettings() { + const [incomingCallSoundEnabled, setIncomingCallSoundEnabled] = useSetting( + settingsAtom, + 'incomingCallSoundEnabled' + ); + const [incomingVoiceRoomCallSoundEnabled, setIncomingVoiceRoomCallSoundEnabled] = useSetting( + settingsAtom, + 'incomingVoiceRoomCallSoundEnabled' + ); + const [outgoingRingbackEnabled, setOutgoingRingbackEnabled] = useSetting( + settingsAtom, + 'outgoingRingbackEnabled' + ); + const [callRingtoneId, setCallRingtoneId] = useSetting(settingsAtom, 'callRingtoneId'); + const [callRingbackTone, setCallRingbackTone] = useSetting(settingsAtom, 'callRingbackTone'); + const [callRingtoneVolume, setCallRingtoneVolume] = useSetting( + settingsAtom, + 'callRingtoneVolume' + ); + const [callSoundOverrideGlobalNotifications, setCallSoundOverrideGlobalNotifications] = + useSetting(settingsAtom, 'callSoundOverrideGlobalNotifications'); + + const [previewing, setPreviewing] = useState(false); + const [loadingCustomState, setLoadingCustomState] = useState(true); + const [hasCustomRingtone, setHasCustomRingtone] = useState(false); + const [hasCustomRingback, setHasCustomRingback] = useState(false); + const [customRingtoneMeta, setCustomRingtoneMeta] = useState(null); + const [customRingbackMeta, setCustomRingbackMeta] = useState(null); + const [customError, setCustomError] = useState(null); + useEffect(() => { + let mounted = true; + Promise.all([getCustomCallRingtone(), getCustomCallRingback()]) + .then(([ringtone, ringback]) => { + if (!mounted) return; + setHasCustomRingtone(Boolean(ringtone)); + setHasCustomRingback(Boolean(ringback)); + setCustomRingtoneMeta(ringtone ? toCustomToneMetadata(ringtone) : null); + setCustomRingbackMeta(ringback ? toCustomToneMetadata(ringback) : null); + }) + .finally(() => { + if (!mounted) return; + setLoadingCustomState(false); + }); + + return () => { + mounted = false; + ringtoneManager.stopPreview(); + }; + }, []); + + useEffect(() => { + if (!loadingCustomState && !hasCustomRingtone && callRingtoneId === 'custom') { + setCallRingtoneId('sable-default'); + setCustomError('Custom ringtone is not available on this device. Falling back to default.'); + } + if (!loadingCustomState && !hasCustomRingback && callRingbackTone === 'custom') { + setCallRingbackTone('sable-default'); + setCustomError('Custom ringback is not available on this device. Falling back to default.'); + } + }, [ + callRingtoneId, + callRingbackTone, + hasCustomRingtone, + hasCustomRingback, + loadingCustomState, + setCallRingtoneId, + setCallRingbackTone, + ]); + + const ringtoneOptions = useMemo( + () => + CALL_RINGTONE_OPTIONS.map((option) => + option.value === 'custom' + ? { + ...option, + label: customRingtoneMeta ? 'Custom File (Imported)' : 'Custom File', + disabled: loadingCustomState, + } + : option + ), + [customRingtoneMeta, loadingCustomState] + ); + const ringbackOptions = useMemo( + () => + CALL_RINGBACK_OPTIONS.map((option) => + option.value === 'custom' + ? { + ...option, + label: customRingbackMeta ? 'Custom File (Imported)' : 'Custom File', + disabled: loadingCustomState, + } + : option + ), + [customRingbackMeta, loadingCustomState] + ); + + const playPreviewTone = useCallback( + async (tone: PreviewTone) => { + setCustomError(null); + setPreviewing(true); + try { + await ringtoneManager.playPreview(tone, { + callRingtoneId, + callRingbackTone, + callRingtoneVolume, + }); + + window.setTimeout(() => { + ringtoneManager.stopPreview(); + }, 2500); + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') return; + setCustomError('Unable to preview this ringtone in your browser.'); + } finally { + setPreviewing(false); + } + }, + [callRingtoneId, callRingbackTone, callRingtoneVolume] + ); + + const importCustomTone = useCallback( + ( + label: 'Ringtone' | 'Ringback', + putTone: (file: File, durationMs: number) => Promise, + onImported: (stored: StoredCallRingtone) => void + ) => { + setCustomError(null); + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'audio/*'; + input.addEventListener('change', async () => { + const file = input.files?.[0]; + if (!file) return; + + try { + const durationMs = await readAudioDurationMs(file); + const validation = validateCustomCallRingtone({ + fileName: file.name, + mimeType: file.type, + sizeBytes: file.size, + durationMs, + }); + if (!validation.valid) { + setCustomError(customToneValidationError(validation.reason, label)); + return; + } + + const stored = await putTone(file, durationMs); + onImported(stored); + } catch { + setCustomError('Could not import this file. Try a different audio format.'); + } + }); + + input.click(); + }, + [] + ); + + const handleImportCustomRingtone = useCallback(() => { + importCustomTone('Ringtone', putCustomCallRingtone, (stored) => { + setHasCustomRingtone(true); + setCallRingtoneId('custom'); + setCustomRingtoneMeta(toCustomToneMetadata(stored)); + }); + }, [importCustomTone, setCallRingtoneId]); + + const handleResetCustomRingtone = useCallback(async () => { + setCustomError(null); + await clearCustomCallRingtone(); + setHasCustomRingtone(false); + setCustomRingtoneMeta(null); + if (callRingtoneId === 'custom') { + setCallRingtoneId('sable-default'); + } + }, [callRingtoneId, setCallRingtoneId]); + + const handleImportCustomRingback = useCallback(() => { + importCustomTone('Ringback', putCustomCallRingback, (stored) => { + setHasCustomRingback(true); + setCallRingbackTone('custom'); + setCustomRingbackMeta(toCustomToneMetadata(stored)); + }); + }, [importCustomTone, setCallRingbackTone]); + + const handleResetCustomRingback = useCallback(async () => { + setCustomError(null); + await clearCustomCallRingback(); + setHasCustomRingback(false); + setCustomRingbackMeta(null); + if (callRingbackTone === 'custom') { + setCallRingbackTone('sable-default'); + } + }, [callRingbackTone, setCallRingbackTone]); + + const handleRingtoneSelection = (next: CallRingtoneId) => { + if (next === 'custom' && !hasCustomRingtone) { + setCustomError('Import a custom ringtone file first.'); + return; + } + setCustomError(null); + setCallRingtoneId(next); + }; + + const handleRingbackSelection = (next: CallRingtoneId) => { + if (next === 'custom' && !hasCustomRingback) { + setCustomError('Import a custom ringback file first.'); + return; + } + setCustomError(null); + setCallRingbackTone(next); + }; + + const handleVolumeChange = (value: string) => { + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed)) return; + setCallRingtoneVolume(clampCallRingtoneVolume(parsed)); + }; + + return ( + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + handleVolumeChange(evt.currentTarget.value)} + outlined + /> + } + /> + + + + } + /> + + + + {customError && ( + + {customError} + + )} + + ); +} diff --git a/src/app/features/settings/general/CallSoundSettingsCards.tsx b/src/app/features/settings/general/CallSoundSettingsCards.tsx new file mode 100644 index 000000000..92b849730 --- /dev/null +++ b/src/app/features/settings/general/CallSoundSettingsCards.tsx @@ -0,0 +1,149 @@ +import { Box, Button, Icon, Icons, Spinner, Text } from 'folds'; +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { SequenceCardStyle } from '$features/settings/styles.css'; +import { + CUSTOM_CALL_RINGTONE_MAX_BYTES, + CUSTOM_CALL_RINGTONE_MAX_DURATION_MS, +} from '$features/call/callRingtone'; +import { bytesToSize, millisecondsToMinutesAndSeconds } from '$utils/common'; + +export type PreviewTone = 'incoming' | 'outgoing'; + +export type CustomToneMetadata = { + fileName: string; + sizeBytes: number; + durationMs: number; +}; + +export function CustomToneMeta({ + metadata, + emptyLabel, +}: { + metadata: CustomToneMetadata | null; + emptyLabel: string; +}) { + if (!metadata) { + return ( + + {emptyLabel} + + ); + } + + return ( + + {[ + metadata.fileName, + bytesToSize(metadata.sizeBytes), + millisecondsToMinutesAndSeconds(metadata.durationMs), + ].join(' - ')} + + ); +} + +export function CustomToneSettingsCard({ + title, + focusId, + description, + metadata, + emptyLabel, + hasCustomTone, + previewing, + previewActions, + onImport, + onPreview, + onReset, +}: { + title: string; + focusId: string; + description: string; + metadata: CustomToneMetadata | null; + emptyLabel: string; + hasCustomTone: boolean; + previewing: boolean; + previewActions: { + label: string; + tone: PreviewTone; + icon: (typeof Icons)[keyof typeof Icons]; + }[]; + onImport: () => void; + onPreview: (tone: PreviewTone) => void; + onReset: () => void; +}) { + return ( + + + + + + + {previewActions.map(({ label, tone, icon }) => ( + + ))} + + + + Max file size: {bytesToSize(CUSTOM_CALL_RINGTONE_MAX_BYTES)}. Max duration:{' '} + {millisecondsToMinutesAndSeconds(CUSTOM_CALL_RINGTONE_MAX_DURATION_MS)}. + + + + + ); +} + +export const customToneValidationError = ( + reason: 'type' | 'size' | 'duration', + label: 'Ringtone' | 'Ringback' +): string => { + if (reason === 'type') return 'Only audio files are supported.'; + if (reason === 'size') { + return `File is too large. Max ${bytesToSize(CUSTOM_CALL_RINGTONE_MAX_BYTES)} allowed.`; + } + + return `${label} must be between 1s and ${millisecondsToMinutesAndSeconds( + CUSTOM_CALL_RINGTONE_MAX_DURATION_MS + )}.`; +}; diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 5d5363fdb..89359f913 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -55,6 +55,7 @@ import { isKeyHotkey } from 'is-hotkey'; import { settingsSyncLastSyncedAtom, settingsSyncStatusAtom } from '$hooks/useSettingsSync'; import { exportSettingsAsJson, importSettingsFromJson } from '$utils/settingsSync'; import { SettingsSectionPage } from '../SettingsSectionPage'; +import { CallSoundSettings } from './CallSoundSettings'; type DateHintProps = { hasChanges: boolean; @@ -896,6 +897,7 @@ function Calls() { } /> + ); } diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index 0310e8f40..eb258b1c3 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -64,6 +64,15 @@ const settingsLinkFocusIdsBySection: Record { return [ messagesGroup, + CALL_PERMISSIONS_GROUP, moderationGroup, roomOverviewGroup, roomSettingsGroup, diff --git a/src/app/hooks/router/useInbox.ts b/src/app/hooks/router/useInbox.ts index 639e16dd4..c19c0cc4b 100644 --- a/src/app/hooks/router/useInbox.ts +++ b/src/app/hooks/router/useInbox.ts @@ -1,5 +1,10 @@ import { useMatch } from 'react-router-dom'; -import { getInboxInvitesPath, getInboxNotificationsPath, getInboxPath } from '$pages/pathUtils'; +import { + getInboxBookmarksPath, + getInboxInvitesPath, + getInboxNotificationsPath, + getInboxPath, +} from '$pages/pathUtils'; export const useInboxSelected = (): boolean => { const match = useMatch({ @@ -30,3 +35,13 @@ export const useInboxInvitesSelected = (): boolean => { return !!match; }; + +export const useInboxBookmarksSelected = (): boolean => { + const match = useMatch({ + path: getInboxBookmarksPath(), + caseSensitive: true, + end: false, + }); + + return !!match; +}; diff --git a/src/app/hooks/useAutoJoinCall.ts b/src/app/hooks/useAutoJoinCall.ts index 99b59d298..0f95d9485 100644 --- a/src/app/hooks/useAutoJoinCall.ts +++ b/src/app/hooks/useAutoJoinCall.ts @@ -1,24 +1,44 @@ import { useEffect } from 'react'; -import { useAtom } from 'jotai'; +import { useAtom, useAtomValue } from 'jotai'; import { useCallStart } from '$hooks/useCallEmbed'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { autoJoinCallIntentAtom } from '$state/callEmbed'; +import { mDirectAtom } from '$state/mDirectList'; +import { useCallPreferences } from '$state/hooks/callPreferences'; export function useAutoJoinCall() { const mx = useMatrixClient(); const selectedRoomId = useSelectedRoom(); const [autoJoinIntent, setAutoJoinIntent] = useAtom(autoJoinCallIntentAtom); - const startCall = useCallStart(); + const mDirects = useAtomValue(mDirectAtom); + const callPreferences = useCallPreferences(); + const startDirectCall = useCallStart(true); + const startRoomCall = useCallStart(false); useEffect(() => { - if (selectedRoomId && autoJoinIntent && selectedRoomId === autoJoinIntent) { + if (selectedRoomId && autoJoinIntent && selectedRoomId === autoJoinIntent.roomId) { const room = mx.getRoom(selectedRoomId); if (room) { - startCall(room); + const startCall = mDirects.has(room.roomId) ? startDirectCall : startRoomCall; + startCall(room, { + microphone: callPreferences.microphone, + video: autoJoinIntent.video, + sound: callPreferences.sound, + }); setAutoJoinIntent(null); } } - }, [selectedRoomId, autoJoinIntent, startCall, setAutoJoinIntent, mx]); + }, [ + selectedRoomId, + autoJoinIntent, + setAutoJoinIntent, + mx, + mDirects, + callPreferences.microphone, + callPreferences.sound, + startDirectCall, + startRoomCall, + ]); } diff --git a/src/app/hooks/useBookmarks.ts b/src/app/hooks/useBookmarks.ts new file mode 100644 index 000000000..37a4e0223 --- /dev/null +++ b/src/app/hooks/useBookmarks.ts @@ -0,0 +1,81 @@ +import { useAtomValue, useSetAtom } from 'jotai'; +import { useCallback } from 'react'; +import { computeBookmarkId } from '$features/bookmarks/bookmarkDomain'; +import { + addBookmark as repoAdd, + removeBookmark as repoRemove, + listBookmarks, + isBookmarked as repoIsBookmarked, +} from '$features/bookmarks/bookmarkRepository'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { + bookmarkIdSetAtom, + bookmarkListAtom, + bookmarkLoadingAtom, + bookmarkRefreshErrorAtom, +} from '$state/bookmarks'; +import type { BookmarkItemContent } from '$types/matrix-sdk-events'; + +export function useBookmarkList(): BookmarkItemContent[] { + return useAtomValue(bookmarkListAtom); +} + +export function useBookmarkLoading(): boolean { + return useAtomValue(bookmarkLoadingAtom); +} + +export function useBookmarkRefreshError(): Error | undefined { + return useAtomValue(bookmarkRefreshErrorAtom); +} + +export function useIsBookmarked(roomId: string, eventId: string): boolean { + const idSet = useAtomValue(bookmarkIdSetAtom); + return idSet.has(computeBookmarkId(roomId, eventId)); +} + +export function useBookmarkActions() { + const mx = useMatrixClient(); + const setList = useSetAtom(bookmarkListAtom); + const setLoading = useSetAtom(bookmarkLoadingAtom); + const setRefreshError = useSetAtom(bookmarkRefreshErrorAtom); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const items = await listBookmarks(mx); + setList(items); + setRefreshError(undefined); + } catch (error) { + setRefreshError(error as Error); + } finally { + setLoading(false); + } + }, [mx, setList, setLoading, setRefreshError]); + + const add = useCallback( + async (item: BookmarkItemContent) => { + setList((prev) => { + if (prev.some((b) => b.bookmark_id === item.bookmark_id)) return prev; + return [item, ...prev]; + }); + await repoAdd(mx, item); + }, + [mx, setList] + ); + + const remove = useCallback( + async (bookmarkId: string) => { + setList((prev) => prev.filter((b) => b.bookmark_id !== bookmarkId)); + await repoRemove(mx, bookmarkId); + }, + [mx, setList] + ); + + const checkIsBookmarked = useCallback( + (roomId: string, eventId: string): boolean => + repoIsBookmarked(mx, computeBookmarkId(roomId, eventId)), + [mx] + ); + + return { refresh, add, remove, checkIsBookmarked }; +} diff --git a/src/app/hooks/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts index fef17cdde..e3364f4fa 100644 --- a/src/app/hooks/useCallEmbed.ts +++ b/src/app/hooks/useCallEmbed.ts @@ -14,6 +14,8 @@ import { CallControlState } from '../plugins/call/CallControlState'; import { useCallMembersChange, useCallSession } from './useCall'; import type { CallPreferences } from '../state/callPreferences'; import { createDebugLogger } from '../utils/debugLogger'; +import { useClientConfig } from './useClientConfig'; +import { callEmbedStartErrorAtom } from '$state/callEmbed'; const debugLog = createDebugLogger('useCallEmbed'); @@ -43,14 +45,15 @@ export const createCallEmbed = ( dm: boolean, themeKind: ElementCallThemeKind, container: HTMLElement, - pref?: CallPreferences + pref?: CallPreferences, + elementCallUrl?: string ): CallEmbed => { const rtcSession = mx.matrixRTC.getRoomSession(room); const ongoing = MatrixRTCSession.sessionMembershipsForRoom(room, rtcSession.sessionDescription).length > 0; const intent = CallEmbed.getIntent(dm, ongoing, pref?.video); - const widget = CallEmbed.getWidget(mx, room, intent, themeKind); + const widget = CallEmbed.getWidget(mx, room, intent, themeKind, elementCallUrl); const controlState = pref && new CallControlState(pref.microphone, pref.video, pref.sound); const embed = new CallEmbed(mx, room, widget, container, controlState); @@ -61,7 +64,9 @@ export const createCallEmbed = ( export const useCallStart = (dm = false) => { const mx = useMatrixClient(); const theme = useTheme(); + const clientConfig = useClientConfig(); const setCallEmbed = useSetAtom(callEmbedAtom); + const setCallEmbedStartError = useSetAtom(callEmbedStartErrorAtom); const callEmbedRef = useCallEmbedRef(); const startCall = useCallback( @@ -81,7 +86,16 @@ export const useCallStart = (dm = false) => { Sentry.metrics.count('sable.call.start.attempt', 1, { attributes: { dm: String(dm) }, }); - const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref); + setCallEmbedStartError(null); + const callEmbed = createCallEmbed( + mx, + room, + dm, + theme.kind, + container, + pref, + clientConfig.elementCallUrl + ); setCallEmbed(callEmbed); } catch (err) { debugLog.error('call', 'Call embed creation failed', { @@ -94,7 +108,7 @@ export const useCallStart = (dm = false) => { throw err; } }, - [mx, dm, theme, setCallEmbed, callEmbedRef] + [mx, dm, theme, setCallEmbed, callEmbedRef, clientConfig.elementCallUrl, setCallEmbedStartError] ); return startCall; @@ -112,9 +126,7 @@ export const useCallJoined = (embed?: CallEmbed): boolean => { ); useEffect(() => { - if (!embed) { - setJoined(false); - } + setJoined(embed?.joined ?? false); }, [embed]); return joined; diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index 73cf864e2..14f38ec6f 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -1,247 +1,583 @@ -import { useEffect, useRef, useCallback } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import * as Sentry from '@sentry/react'; -import { RoomStateEvent } from '$types/matrix-sdk'; -import { MatrixRTCSession } from '$types/matrix-sdk'; -import { MatrixRTCSessionManagerEvents } from '$types/matrix-sdk'; -import { useSetAtom, useAtomValue } from 'jotai'; +import { useAtomValue, useSetAtom, useStore } from 'jotai'; +import type { RoomEventHandlerMap, MatrixEvent, Room } from '$types/matrix-sdk'; +import { MatrixRTCSessionManagerEvents, RoomEvent } from '$types/matrix-sdk'; import { mDirectAtom } from '$state/mDirectList'; -import { incomingCallRoomIdAtom, mutedCallRoomIdAtom } from '$state/callEmbed'; -import RingtoneSound from '$public/sound/ringtone.webm'; +import { + callEmbedAtom, + callSoundBlockedAtom, + incomingCallAtom, + mutedCallRoomIdAtom, + type IncomingCall, +} from '$state/callEmbed'; +import { settingsAtom } from '$state/settings'; +import { + parseIncomingRtcNotification, + RTC_DECLINE_EVENT_TYPE, + REFERENCE_REL_TYPE, + RTC_NOTIFICATION_EVENT_TYPE, +} from '$features/call/rtcNotificationParser'; +import { decryptRtcTimelineEvent } from '$features/call/callSignalingDecrypt'; +import { + FALLBACK_INTERVAL_MS, + MAX_NOTIFICATION_LIFETIME_MS, + OUTGOING_DECLINE_EMBED_CLEAR_MS, +} from '$features/call/callSignalingPolicy'; +import { + applyOutgoingDeclineToTracker, + type OutgoingDeclineEvent, +} from '$features/call/outgoingDeclineHandler'; +import { parseRtcDeclineFromTimelineEvent } from '$features/call/rtcTimelineDecline'; +import { evaluateIncomingCallFallback } from '$features/call/callSignalingFallback'; +import { canPlayCallAudio } from '$features/call/callRingtone'; +import { dismissSystemCallNotifications } from '$features/call/callNotificationBridge'; +import { isIncomingCallSuppressed } from '$features/call/callIncomingIngress'; +import { + getRemoteRtcMemberUserIds, + isCallActive, + isOutgoingCallPending, +} from '$features/call/callMembershipState'; +import { ringtoneManager } from '$features/call/CallRingtoneManager'; +import { OUTGOING_RING_TIMEOUT_MS } from '$features/call/callSignalingPolicy'; import { useMatrixClient } from './useMatrixClient'; import { createDebugLogger } from '../utils/debugLogger'; const debugLog = createDebugLogger('CallSignaling'); -type CallPhase = 'IDLE' | 'RINGING_OUT' | 'RINGING_IN' | 'ACTIVE' | 'ENDED'; +const canSenderStartCalls = (room: Room, senderId: string): boolean => + room.currentState?.maySendStateEvent('org.matrix.msc3401.call.member', senderId) ?? false; -interface SignalState { - incoming: string | null; - outgoing: string | null; -} - -export function useCallSignaling() { +export function useIncomingCallSignaling() { const mx = useMatrixClient(); - const setIncomingCall = useSetAtom(incomingCallRoomIdAtom); + const store = useStore(); + const callEmbed = useAtomValue(callEmbedAtom); const mDirects = useAtomValue(mDirectAtom); - - const incomingAudioRef = useRef(null); - const outgoingAudioRef = useRef(null); - const ringingRoomIdRef = useRef(null); - const outgoingStartRef = useRef(null); - const callPhaseRef = useRef>({}); - + const settings = useAtomValue(settingsAtom); + const incomingCall = useAtomValue(incomingCallAtom); const mutedRoomId = useAtomValue(mutedCallRoomIdAtom); + const setIncomingCall = useSetAtom(incomingCallAtom); const setMutedRoomId = useSetAtom(mutedCallRoomIdAtom); - - // Stable refs so volatile values (mutedRoomId, ring callbacks) don't force - // the listener registration effect to re-run — which would cause the - // SessionEnded and RoomState.events listeners to accumulate when muting - // or when call state changes rapidly during a sync retry cycle. - const mutedRoomIdRef = useRef(mutedRoomId); + const setCallSoundBlocked = useSetAtom(callSoundBlockedAtom); + const setCallEmbed = useSetAtom(callEmbedAtom); + + const incomingCallRef = useRef(incomingCall); + const mutedRoomIdRef = useRef(mutedRoomId); + const seenNotificationIdsRef = useRef>(new Set()); + const MAX_SEEN_NOTIFICATION_IDS = 256; + + const rememberNotificationId = (notificationEventId: string) => { + const seen = seenNotificationIdsRef.current; + if (seen.has(notificationEventId)) return false; + seen.add(notificationEventId); + while (seen.size > MAX_SEEN_NOTIFICATION_IDS) { + const oldest = seen.values().next().value; + if (!oldest) break; + seen.delete(oldest); + } + return true; + }; + const outgoingRingRoomIdRef = useRef(null); + const declinedOutgoingRoomIdRef = useRef(null); + const outgoingDeclinesRef = useRef< + Map }> + >(new Map()); + const outgoingStartRef = useRef(null); + const activeOutgoingNotificationIdRef = useRef(null); + const seenDeclineEventIdsRef = useRef>(new Set()); + const hasCallBeenActiveRef = useRef(false); + + type SignalingHandlerRefs = { + callEmbed: typeof callEmbed; + mDirects: typeof mDirects; + outgoingRingbackAllowed: boolean; + handleIncomingCall: (incoming: IncomingCall) => void; + handleOutgoingDecline: (decline: { + roomId: string; + declineEventId: string; + notificationEventId: string; + senderId: string; + }) => void; + clearIncomingCall: () => void; + stopIncomingRing: () => void; + stopOutgoingRing: () => void; + setMutedRoomId: (roomId: string | null) => void; + }; + + const signalingHandlerRefs = useRef(null); + + incomingCallRef.current = incomingCall; mutedRoomIdRef.current = mutedRoomId; useEffect(() => { - const inc = new Audio(RingtoneSound); - inc.loop = true; - incomingAudioRef.current = inc; + declinedOutgoingRoomIdRef.current = null; + outgoingDeclinesRef.current.clear(); + activeOutgoingNotificationIdRef.current = null; + seenDeclineEventIdsRef.current.clear(); + hasCallBeenActiveRef.current = false; + outgoingRingRoomIdRef.current = null; + outgoingStartRef.current = null; + }, [callEmbed]); - const out = new Audio(RingtoneSound); - out.loop = true; - outgoingAudioRef.current = out; - - return () => { - inc.pause(); - out.pause(); - }; + useEffect(() => { + ringtoneManager.syncSources( + settings.callRingtoneId, + settings.callRingbackTone, + settings.callRingtoneVolume + ); + }, [settings.callRingtoneId, settings.callRingbackTone, settings.callRingtoneVolume]); + + const stopIncomingRing = useCallback(() => { + ringtoneManager.stopIncoming(); + setCallSoundBlocked(false); + }, [setCallSoundBlocked]); + + const stopOutgoingRing = useCallback(() => { + ringtoneManager.stopOutgoing(); }, []); - const stopRinging = useCallback(() => { - incomingAudioRef.current?.pause(); - outgoingAudioRef.current?.pause(); - if (incomingAudioRef.current) incomingAudioRef.current.currentTime = 0; - if (outgoingAudioRef.current) outgoingAudioRef.current.currentTime = 0; - - ringingRoomIdRef.current = null; + const clearIncomingCall = useCallback(() => { + const activeIncomingCall = incomingCallRef.current; + stopIncomingRing(); setIncomingCall(null); - }, [setIncomingCall]); - - const playOutgoingRinging = useCallback((roomId: string) => { - if (outgoingAudioRef.current && ringingRoomIdRef.current !== roomId) { - outgoingAudioRef.current.play().catch(() => {}); - ringingRoomIdRef.current = roomId; + if (activeIncomingCall) { + void dismissSystemCallNotifications(activeIncomingCall.roomId); } - }, []); + }, [setIncomingCall, stopIncomingRing]); + + const handleOutgoingDecline = useCallback( + (decline: OutgoingDeclineEvent) => { + if (!callEmbed || callEmbed.roomId !== decline.roomId) { + return; + } + + if (seenDeclineEventIdsRef.current.has(decline.declineEventId)) { + return; + } + seenDeclineEventIdsRef.current.add(decline.declineEventId); + + const activeNotificationId = activeOutgoingNotificationIdRef.current; + if (activeNotificationId && decline.notificationEventId !== activeNotificationId) { + debugLog.info('call', 'Ignoring stale outgoing decline for previous notification', { + roomId: decline.roomId, + declineEventId: decline.declineEventId, + notificationEventId: decline.notificationEventId, + activeNotificationId, + }); + return; + } - const playRinging = useCallback( - (roomId: string) => { - if (incomingAudioRef.current && ringingRoomIdRef.current !== roomId) { - incomingAudioRef.current.play().catch(() => {}); - ringingRoomIdRef.current = roomId; - setIncomingCall(roomId); + const outgoingRoom = mx.getRoom(decline.roomId); + if (!outgoingRoom) { + return; } + + const myUserId = mx.getSafeUserId(); + const sessionDescription = mx.matrixRTC.getRoomSession(outgoingRoom).sessionDescription; + let remoteJoinedIds = getRemoteRtcMemberUserIds(myUserId, outgoingRoom, sessionDescription); + if (remoteJoinedIds.size === 0) { + remoteJoinedIds = new Set([decline.senderId]); + } + + const decision = applyOutgoingDeclineToTracker(outgoingDeclinesRef.current, decline, { + remoteJoinedIds, + isDirectRoom: mDirects.has(decline.roomId), + }); + + if (decision.kind === 'ignore_partial') { + debugLog.info('call', 'Ignoring partial outgoing decline for group call', { + roomId: decline.roomId, + declineEventId: decline.declineEventId, + notificationEventId: decline.notificationEventId, + declinedCount: decision.declinedCount, + targetCount: decision.targetCount, + }); + Sentry.metrics.count('sable.call.outgoing.declined.partial', 1); + return; + } + + declinedOutgoingRoomIdRef.current = decline.roomId; + debugLog.info('call', 'Outgoing call declined and ending call', { + roomId: decline.roomId, + declineEventId: decline.declineEventId, + notificationEventId: decline.notificationEventId, + declinedCount: decision.declinedCount, + targetCount: decision.targetCount, + }); + Sentry.metrics.count('sable.call.outgoing.declined', 1); + stopOutgoingRing(); + + void callEmbed + .hangup() + .catch((error) => { + debugLog.warn('call', 'Failed to hang up after outgoing decline', { + roomId: decline.roomId, + error: error instanceof Error ? error.message : String(error), + }); + Sentry.metrics.count('sable.call.outgoing.decline_hangup_error', 1); + }) + .finally(() => { + window.setTimeout(() => { + const activeEmbed = store.get(callEmbedAtom); + if (activeEmbed !== callEmbed) return; + setCallEmbed(undefined); + }, OUTGOING_DECLINE_EMBED_CLEAR_MS); + }); }, - [setIncomingCall] + [callEmbed, mDirects, mx, setCallEmbed, stopOutgoingRing, store] ); - // Must be declared after the callbacks above so the initial useRef(value) call - // sees their current identity. Updated on every render so the effect closure - // always calls the latest version without needing them in the dep array. - const playRingingRef = useRef(playRinging); - playRingingRef.current = playRinging; - const stopRingingRef = useRef(stopRinging); - stopRingingRef.current = stopRinging; - const playOutgoingRingingRef = useRef(playOutgoingRinging); - playOutgoingRingingRef.current = playOutgoingRinging; + const callAudioAllowed = canPlayCallAudio({ + isNotificationSounds: settings.isNotificationSounds, + callSoundOverrideGlobalNotifications: settings.callSoundOverrideGlobalNotifications, + }); + const incomingRingtoneAllowed = settings.incomingCallSoundEnabled && callAudioAllowed; + const outgoingRingbackAllowed = + settings.outgoingRingbackEnabled && callAudioAllowed && settings.callRingbackTone !== 'silent'; + const incomingToneIsSilent = settings.callRingtoneId === 'silent'; + + const handleIncomingCall = useCallback( + (nextIncomingCall: IncomingCall) => { + if ( + isIncomingCallSuppressed( + nextIncomingCall, + mutedRoomIdRef.current, + settings.incomingVoiceRoomCallSoundEnabled + ) + ) + return; + if (!rememberNotificationId(nextIncomingCall.notificationEventId)) return; + setIncomingCall(nextIncomingCall); + + debugLog.info('call', 'Incoming RTC notification accepted', { + roomId: nextIncomingCall.roomId, + notificationType: nextIncomingCall.notificationType, + intent: nextIncomingCall.intentRaw, + }); + Sentry.metrics.count('sable.call.incoming.shown', 1, { + attributes: { + type: nextIncomingCall.notificationType, + dm: String(nextIncomingCall.isDirect), + }, + }); + }, + [setIncomingCall, settings.incomingVoiceRoomCallSoundEnabled] + ); + + const playIncomingRing = useCallback(() => { + if (!incomingRingtoneAllowed || incomingToneIsSilent) { + stopIncomingRing(); + return; + } + + ringtoneManager + .playIncoming() + ?.then(() => { + setCallSoundBlocked(false); + }) + .catch(() => { + // AbortError is handled in ringtoneManager, any other error comes here + setCallSoundBlocked(true); + }); + }, [incomingRingtoneAllowed, incomingToneIsSilent, setCallSoundBlocked, stopIncomingRing]); + + signalingHandlerRefs.current = { + callEmbed, + mDirects, + outgoingRingbackAllowed, + handleIncomingCall, + handleOutgoingDecline, + clearIncomingCall, + stopIncomingRing, + stopOutgoingRing, + setMutedRoomId, + }; + + useEffect(() => { + if (!incomingRingtoneAllowed) { + stopIncomingRing(); + } + if (!outgoingRingbackAllowed) { + stopOutgoingRing(); + } + }, [incomingRingtoneAllowed, outgoingRingbackAllowed, stopIncomingRing, stopOutgoingRing]); + + useEffect(() => { + if (!incomingCall) { + stopIncomingRing(); + return; + } + if ( + isIncomingCallSuppressed( + incomingCall, + mutedRoomId, + settings.incomingVoiceRoomCallSoundEnabled + ) + ) { + setIncomingCall(null); + return; + } + playIncomingRing(); + }, [ + incomingCall, + mutedRoomId, + playIncomingRing, + setIncomingCall, + settings.incomingVoiceRoomCallSoundEnabled, + stopIncomingRing, + ]); useEffect(() => { if (!mx || !mx.matrixRTC) return undefined; - const checkDMsForActiveCalls = () => { - const myUserId = mx.getUserId(); - const now = Date.now(); - - const signal = Array.from(mDirects).reduce( - (acc, roomId) => { - if (acc.incoming || mutedRoomIdRef.current === roomId) return acc; - - const room = mx.getRoom(roomId); - if (!room) return acc; - - const session = mx.matrixRTC.getRoomSession(room); - const memberships = MatrixRTCSession.sessionMembershipsForRoom( - room, - session.sessionDescription - ); - - const remoteMembers = memberships.filter( - (m: { userId?: string; sender?: string }) => (m.userId || m.sender) !== myUserId - ); - const isSelfInCall = memberships.some( - (m: { userId?: string; sender?: string }) => (m.userId || m.sender) === myUserId - ); - const currentPhase = callPhaseRef.current[roomId] || 'IDLE'; - - // no one here - if (!isSelfInCall && remoteMembers.length === 0) { - callPhaseRef.current[roomId] = 'IDLE'; - return acc; - } - - // being called - if (remoteMembers.length > 0 && !isSelfInCall) { - if (currentPhase !== 'RINGING_IN') { - debugLog.info('call', 'Incoming call detected', { - roomId, - remoteCount: remoteMembers.length, - }); - Sentry.addBreadcrumb({ - category: 'call.signal', - message: 'Incoming call ringing', - data: { roomId }, - }); - } - callPhaseRef.current[roomId] = 'RINGING_IN'; - acc.incoming = roomId; - return acc; - } - - // multiple people no ringtone - if (isSelfInCall && remoteMembers.length > 0) { - if (currentPhase !== 'ACTIVE') { - debugLog.info('call', 'Call became active', { roomId }); - Sentry.addBreadcrumb({ - category: 'call.signal', - message: 'Call active', - data: { roomId }, - }); - Sentry.metrics.count('sable.call.active', 1); - } - callPhaseRef.current[roomId] = 'ACTIVE'; - return acc; - } - - // alone in call - if (isSelfInCall && remoteMembers.length === 0) { - // Check if post call - if (currentPhase === 'ACTIVE' || currentPhase === 'ENDED') { - if (currentPhase !== 'ENDED') { - debugLog.info('call', 'Call ended', { roomId }); - Sentry.addBreadcrumb({ - category: 'call.signal', - message: 'Call ended', - data: { roomId }, - }); - Sentry.metrics.count('sable.call.ended', 1); - } - callPhaseRef.current[roomId] = 'ENDED'; - return acc; - } - - // Check if new call - if (currentPhase === 'IDLE' || currentPhase === 'RINGING_OUT') { - if (!outgoingStartRef.current) outgoingStartRef.current = now; - - if (now - outgoingStartRef.current < 30000) { - if (currentPhase !== 'RINGING_OUT') { - debugLog.info('call', 'Outgoing call ringing', { roomId }); - Sentry.addBreadcrumb({ - category: 'call.signal', - message: 'Outgoing call ringing', - data: { roomId }, - }); - } - callPhaseRef.current[roomId] = 'RINGING_OUT'; - acc.outgoing = roomId; - return acc; - } - - debugLog.info('call', 'Outgoing call timed out (unanswered)', { - roomId, - }); - Sentry.metrics.count('sable.call.timeout', 1); - callPhaseRef.current[roomId] = 'ENDED'; - } - } - - return acc; + const myUserId = mx.getSafeUserId(); + const handlers = () => signalingHandlerRefs.current!; + + const parseEvent = async ( + event: MatrixEvent, + room: Room, + liveEvent: boolean + ): Promise => { + const relation = event.getRelation(); + if (relation?.rel_type !== REFERENCE_REL_TYPE || !relation.event_id) return undefined; + + let eventType = event.getType(); + let content = event.getContent(); + + if (event.isEncrypted()) { + const decrypted = await decryptRtcTimelineEvent(event, mx); + if (!decrypted?.content || !decrypted.type) { + Sentry.metrics.count('sable.call.signal.decrypt_timeout', 1); + return undefined; + } + eventType = decrypted.type; + content = decrypted.content; + } + + const parsed = await parseIncomingRtcNotification( + { + type: eventType, + sender: event.getSender() ?? '', + roomId: room.roomId, + eventId: event.getId() ?? '', + originServerTs: event.getTs(), + content, + relation: { + rel_type: relation.rel_type, + event_id: relation.event_id, + }, + isLiveEvent: liveEvent, + isEncrypted: false, }, - { incoming: null, outgoing: null } + { + myUserId, + now: Date.now(), + maxLifetimeMs: MAX_NOTIFICATION_LIFETIME_MS, + } ); - if (signal.incoming) { - playRingingRef.current(signal.incoming); - } else if (signal.outgoing) { - playOutgoingRingingRef.current(signal.outgoing); - } else { - stopRingingRef.current(); - if (!signal.outgoing) outgoingStartRef.current = null; + if (!parsed) return undefined; + if (!canSenderStartCalls(room, parsed.senderId)) { + debugLog.warn('call', 'Rejected incoming call without call-member permission', { + roomId: room.roomId, + senderId: parsed.senderId, + }); + return undefined; + } + + return { + ...parsed, + isDirect: handlers().mDirects.has(room.roomId), + }; + }; + + let timelineHandlerEpoch = 0; + + const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = async ( + event, + room, + _toStartOfTimeline, + _removed, + data + ) => { + if (!room || !data.liveEvent) return; + + const epochAtStart = timelineHandlerEpoch; + const isStale = () => epochAtStart !== timelineHandlerEpoch; + + const relation = event.getRelation(); + if (relation?.rel_type !== REFERENCE_REL_TYPE && !event.isEncrypted()) return; + + const type = event.getType(); + if ( + type !== RTC_NOTIFICATION_EVENT_TYPE && + type !== RTC_DECLINE_EVENT_TYPE && + !event.isEncrypted() + ) { + return; + } + const senderId = event.getSender(); + const eventId = event.getId(); + if (!senderId || !eventId) return; + + if (senderId === myUserId) { + if (type === RTC_NOTIFICATION_EVENT_TYPE && handlers().callEmbed?.roomId === room.roomId) { + activeOutgoingNotificationIdRef.current = eventId; + } + return; + } + + const incoming = await parseEvent(event, room, data.liveEvent); + if (isStale()) return; + if (incoming) { + handlers().handleIncomingCall(incoming); + return; + } + + // Only inspect declines for the active outgoing call room. Cleartext declines are + // cheap; encrypted events are decrypted only when they might be RTC declines. + const activeEmbed = handlers().callEmbed; + if (!activeEmbed || activeEmbed.roomId !== room.roomId) { + return; + } + if (event.isDecryptionFailure()) { + return; + } + const shouldCheckDecline = + type === RTC_DECLINE_EVENT_TYPE || + (event.isEncrypted() && relation?.rel_type === REFERENCE_REL_TYPE); + if (!shouldCheckDecline) { + return; + } + + const decline = await parseRtcDeclineFromTimelineEvent( + event, + room, + data.liveEvent, + myUserId, + mx + ); + if (isStale()) return; + if (decline) { + handlers().handleOutgoingDecline(decline); } }; - const interval = setInterval(checkDMsForActiveCalls, 1000); + const fallbackContext = { + myUserId, + getRoom: (roomId: string) => mx.getRoom(roomId), + getSessionDescription: (room: Room) => mx.matrixRTC.getRoomSession(room).sessionDescription, + }; - const handleUpdate = () => checkDMsForActiveCalls(); + const evaluateIncomingFallback = () => { + const action = evaluateIncomingCallFallback( + incomingCallRef.current, + Date.now(), + fallbackContext + ); + if (action.kind !== 'clear') return; + + if (action.reason === 'expired') { + const currentIncoming = incomingCallRef.current; + debugLog.info('call', 'Incoming call timed out', { + roomId: currentIncoming?.roomId, + notificationEventId: currentIncoming?.notificationEventId, + }); + Sentry.metrics.count('sable.call.timeout', 1); + } else if (action.reason === 'membership_dropped') { + debugLog.info('call', 'Incoming call cleared after membership drop', { + roomId: incomingCallRef.current?.roomId, + }); + } + + handlers().clearIncomingCall(); + }; + + let outgoingRingTimeoutId: number | undefined; + + const evaluateOutgoingFallback = () => { + const activeCallRoomId = handlers().callEmbed?.roomId; + + const stop = () => { + handlers().stopOutgoingRing(); + window.clearTimeout(outgoingRingTimeoutId); + outgoingRingTimeoutId = undefined; + }; + + if ( + !activeCallRoomId || + !handlers().outgoingRingbackAllowed || + declinedOutgoingRoomIdRef.current === activeCallRoomId + ) { + outgoingRingRoomIdRef.current = null; + outgoingStartRef.current = null; + return stop(); + } + + if (!handlers().mDirects.has(activeCallRoomId)) { + return stop(); + } + + const outgoingRoom = mx.getRoom(activeCallRoomId); + if (!outgoingRoom) { + return stop(); + } + + const session = mx.matrixRTC.getRoomSession(outgoingRoom).sessionDescription; + + if (isCallActive(myUserId, outgoingRoom, session)) { + hasCallBeenActiveRef.current = true; + } + + if (hasCallBeenActiveRef.current) { + return stop(); + } + + const isPending = isOutgoingCallPending(myUserId, outgoingRoom, session); + if (!isPending) { + return stop(); + } + + if (!outgoingStartRef.current || outgoingRingRoomIdRef.current !== activeCallRoomId) { + outgoingStartRef.current = Date.now(); + outgoingRingRoomIdRef.current = activeCallRoomId; + debugLog.info('call', 'Outgoing ringing fallback started', { roomId: activeCallRoomId }); + ringtoneManager.playOutgoing(); + + window.clearTimeout(outgoingRingTimeoutId); + outgoingRingTimeoutId = window.setTimeout(() => { + stop(); + }, OUTGOING_RING_TIMEOUT_MS); + } + }; + + const evaluateFallbackState = () => { + evaluateIncomingFallback(); + evaluateOutgoingFallback(); + }; const handleSessionEnded = (roomId: string) => { - if (mutedRoomIdRef.current === roomId) setMutedRoomId(null); - callPhaseRef.current[roomId] = 'IDLE'; - checkDMsForActiveCalls(); + if (mutedRoomIdRef.current === roomId) handlers().setMutedRoomId(null); + evaluateFallbackState(); }; - mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, handleUpdate); + mx.on(RoomEvent.Timeline, handleTimelineEvent); + mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, evaluateFallbackState); mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded); - mx.on(RoomStateEvent.Events, handleUpdate); - checkDMsForActiveCalls(); + const intervalId = window.setInterval(evaluateFallbackState, FALLBACK_INTERVAL_MS); + evaluateFallbackState(); return () => { - clearInterval(interval); - mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, handleUpdate); + timelineHandlerEpoch += 1; + mx.off(RoomEvent.Timeline, handleTimelineEvent); + mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, evaluateFallbackState); mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded); - mx.off(RoomStateEvent.Events, handleUpdate); - stopRingingRef.current(); + window.clearInterval(intervalId); + handlers().stopIncomingRing(); + handlers().stopOutgoingRing(); }; - }, [mx, mDirects, setMutedRoomId]); // stable: volatile deps accessed via refs above + }, [mx]); return null; } diff --git a/src/app/hooks/useCallStartCapabilities.test.ts b/src/app/hooks/useCallStartCapabilities.test.ts new file mode 100644 index 000000000..0f3c45b28 --- /dev/null +++ b/src/app/hooks/useCallStartCapabilities.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import type { Room } from '$types/matrix-sdk'; +import { evaluateCallStartCapabilities } from '$features/call/callStartCapabilities'; + +const createRoom = (roomId: string, canSend = true): Room => + ({ + roomId, + currentState: { + maySendStateEvent: () => canSend, + }, + }) as unknown as Room; + +describe('evaluateCallStartCapabilities', () => { + it('allows call start when all capabilities are available', () => { + const capabilities = evaluateCallStartCapabilities({ + room: createRoom('!room:example.org'), + myUserId: '@me:example.org', + activeCallRoomId: undefined, + livekitSupported: true, + rtcSupported: true, + }); + + expect(capabilities.canStart).toBe(true); + expect(capabilities.canRenderCallButton).toBe(true); + expect(capabilities.blockers).toHaveLength(0); + }); + + it('blocks and hides button when WebRTC is unsupported', () => { + const capabilities = evaluateCallStartCapabilities({ + room: createRoom('!room:example.org'), + myUserId: '@me:example.org', + activeCallRoomId: undefined, + livekitSupported: true, + rtcSupported: false, + }); + + expect(capabilities.canStart).toBe(false); + expect(capabilities.canRenderCallButton).toBe(false); + expect(capabilities.blockers).toContain('missing_webrtc'); + }); + + it('blocks and hides button when call-member permission is missing', () => { + const capabilities = evaluateCallStartCapabilities({ + room: createRoom('!room:example.org', false), + myUserId: '@me:example.org', + activeCallRoomId: undefined, + livekitSupported: true, + rtcSupported: true, + }); + + expect(capabilities.canStart).toBe(false); + expect(capabilities.canRenderCallButton).toBe(false); + expect(capabilities.blockers).toContain('missing_call_member_permission'); + }); + + it('blocks start but keeps button visible when already in another call', () => { + const capabilities = evaluateCallStartCapabilities({ + room: createRoom('!room:example.org'), + myUserId: '@me:example.org', + activeCallRoomId: '!other:example.org', + livekitSupported: true, + rtcSupported: true, + }); + + expect(capabilities.canStart).toBe(false); + expect(capabilities.canRenderCallButton).toBe(true); + expect(capabilities.blockers).toEqual(['already_in_another_call']); + }); + + it('does not block when active call is in the same room', () => { + const capabilities = evaluateCallStartCapabilities({ + room: createRoom('!room:example.org'), + myUserId: '@me:example.org', + activeCallRoomId: '!room:example.org', + livekitSupported: true, + rtcSupported: true, + }); + + expect(capabilities.canStart).toBe(true); + expect(capabilities.inAnotherCall).toBe(false); + }); +}); diff --git a/src/app/hooks/useCallStartCapabilities.ts b/src/app/hooks/useCallStartCapabilities.ts new file mode 100644 index 000000000..8e2d55813 --- /dev/null +++ b/src/app/hooks/useCallStartCapabilities.ts @@ -0,0 +1,30 @@ +import { useMemo } from 'react'; +import type { Room } from '$types/matrix-sdk'; +import { useCallEmbed } from '$hooks/useCallEmbed'; +import { useLivekitSupport } from '$hooks/useLivekitSupport'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { webRTCSupported } from '$utils/rtc'; +import { + evaluateCallStartCapabilities, + type CallStartCapabilities, +} from '$features/call/callStartCapabilities'; + +export const useCallStartCapabilities = (room: Room): CallStartCapabilities => { + const mx = useMatrixClient(); + const callEmbed = useCallEmbed(); + const livekitSupported = useLivekitSupport(); + const rtcSupported = webRTCSupported(); + const myUserId = mx.getSafeUserId(); + + return useMemo( + () => + evaluateCallStartCapabilities({ + room, + myUserId, + activeCallRoomId: callEmbed?.roomId, + livekitSupported, + rtcSupported, + }), + [room, myUserId, callEmbed?.roomId, livekitSupported, rtcSupported] + ); +}; diff --git a/src/app/hooks/usePowerLevels.ts b/src/app/hooks/usePowerLevels.ts index 01eb781ee..03d145e47 100644 --- a/src/app/hooks/usePowerLevels.ts +++ b/src/app/hooks/usePowerLevels.ts @@ -209,8 +209,12 @@ export type PermissionLocation = export const getPermissionPower = ( powerLevels: IPowerLevels, - location: PermissionLocation + location: PermissionLocation | PermissionLocation[] ): number => { + if (Array.isArray(location)) { + return Math.max(...location.map((loc) => getPermissionPower(powerLevels, loc))); + } + if ('user' in location) { return readPowerLevel.user(powerLevels, location.key); } @@ -229,9 +233,17 @@ export const getPermissionPower = ( export const applyPermissionPower = ( powerLevels: IPowerLevels, - location: PermissionLocation, + location: PermissionLocation | PermissionLocation[], power: number ): IPowerLevels => { + if (Array.isArray(location)) { + let result = powerLevels; + location.forEach((loc) => { + result = applyPermissionPower(result, loc, power); + }); + return result; + } + if ('user' in location) { if (typeof location.key === 'string') { const users = powerLevels.users ?? {}; diff --git a/src/app/hooks/useRoom.ts b/src/app/hooks/useRoom.ts index 4718a59a2..9617414e9 100644 --- a/src/app/hooks/useRoom.ts +++ b/src/app/hooks/useRoom.ts @@ -11,6 +11,10 @@ export function useRoom(): Room { return room; } +export function useRoomOptionally(): Room | null { + return useContext(RoomContext); +} + const IsDirectRoomContext = createContext(false); export const IsDirectRoomProvider = IsDirectRoomContext.Provider; diff --git a/src/app/hooks/useSlidingSyncActiveRoom.ts b/src/app/hooks/useSlidingSyncActiveRoom.ts index c86914d56..42481970f 100644 --- a/src/app/hooks/useSlidingSyncActiveRoom.ts +++ b/src/app/hooks/useSlidingSyncActiveRoom.ts @@ -3,18 +3,6 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { getSlidingSyncManager } from '$client/initMatrix'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; -/** - * Subscribes the currently selected room to the sliding sync "active room" - * custom subscription (higher timeline limit) for the duration the room is open. - * - * Subscriptions are intentionally never removed on navigation — once a room - * has been opened it continues receiving background updates so that returning - * to it is instant. Explicit unsubscription (and timeline pruning) only happens - * when the user actually leaves the room via `unsubscribeFromRoom()`. - * - * Safe to call unconditionally — it is a no-op when classic sync is in use - * (i.e. when there is no SlidingSyncManager for the client). - */ export const useSlidingSyncActiveRoom = (): void => { const mx = useMatrixClient(); const roomId = useSelectedRoom(); @@ -25,6 +13,8 @@ export const useSlidingSyncActiveRoom = (): void => { if (!manager) return undefined; manager.subscribeToRoom(roomId); - return undefined; + return () => { + manager.unsubscribeFromRoom(roomId); + }; }, [mx, roomId]); }; diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 1605b24ec..e52b07647 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -55,6 +55,7 @@ import { SETTINGS_PATH, NAVIGATE_PATH, PROFILE_PATH, + BOOKMARKS_PATH_SEGMENT, } from './paths'; import { getAppPathFromHref, @@ -71,7 +72,7 @@ import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; -import { Notifications, Inbox, Invites } from './client/inbox'; +import { Notifications, Inbox, Invites, Bookmarks } from './client/inbox'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; import { WelcomePage } from './client/WelcomePage'; import { SidebarNav } from './client/SidebarNav'; @@ -384,6 +385,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) )} } /> } /> + } /> } /> diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 61c9acc35..52ac07ef2 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from 'react'; import { useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import type { RoomEventHandlerMap } from '$types/matrix-sdk'; +import { getPresenceSyncManager } from '$client/initMatrix'; import { MatrixEvent, MatrixEventEvent, @@ -53,14 +54,16 @@ import { import { mobileOrTablet } from '$utils/user-agent'; import { createDebugLogger } from '$utils/debugLogger'; import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom'; -import { getSlidingSyncManager } from '$client/initMatrix'; import { NotificationBanner } from '$components/notification-banner'; import { ThemeMigrationBanner } from '$components/theme/ThemeMigrationBanner'; import { TelemetryConsentBanner } from '$components/telemetry-consent'; -import { useCallSignaling } from '$hooks/useCallSignaling'; +import { useIncomingCallSignaling } from '$hooks/useCallSignaling'; import { getBlobCacheStats } from '$hooks/useBlobCache'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; +import { resolveIncomingCallFromNotificationData } from '$features/call/callNotificationBridge'; +import { isIncomingCallSuppressed } from '$features/call/callIncomingIngress'; +import { incomingCallAtom, mutedCallRoomIdAtom } from '$state/callEmbed'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; import { UnverifiedNoticeBanner } from '$components/unverified-notice'; @@ -615,6 +618,13 @@ type ClientNonUIFeaturesProps = { export function HandleNotificationClick() { const setPending = useSetAtom(pendingNotificationAtom); const setActiveSessionId = useSetAtom(activeSessionIdAtom); + const setIncomingCall = useSetAtom(incomingCallAtom); + const mutedRoomId = useAtomValue(mutedCallRoomIdAtom); + const [incomingVoiceRoomCallSoundEnabled] = useSetting( + settingsAtom, + 'incomingVoiceRoomCallSoundEnabled' + ); + const mDirects = useAtomValue(mDirectAtom); const navigate = useNavigate(); useEffect(() => { @@ -640,11 +650,30 @@ export function HandleNotificationClick() { if (!roomId) return; setPending({ roomId, eventId, targetSessionId: userId }); + + const incomingCall = resolveIncomingCallFromNotificationData( + data as Record, + mDirects.has(roomId) + ); + if ( + incomingCall && + !isIncomingCallSuppressed(incomingCall, mutedRoomId, incomingVoiceRoomCallSoundEnabled) + ) { + setIncomingCall(incomingCall); + } }; navigator.serviceWorker.addEventListener('message', handleMessage); return () => navigator.serviceWorker.removeEventListener('message', handleMessage); - }, [setPending, setActiveSessionId, navigate]); + }, [ + mDirects, + mutedRoomId, + navigate, + setActiveSessionId, + setIncomingCall, + setPending, + incomingVoiceRoomCallSoundEnabled, + ]); return null; } @@ -852,11 +881,10 @@ function PresenceFeature() { const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); useEffect(() => { - // Classic sync: set_presence query param on every /sync poll. + // Classic sync / MSC4186 presence: set_presence query param on every /sync poll. // Passing undefined restores the default (online); Offline suppresses broadcasting. mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline); - // Sliding sync: enable/disable the presence extension on the next poll. - getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence); + getPresenceSyncManager(mx)?.setPresenceEnabled(sendPresence); }, [mx, sendPresence]); return null; @@ -868,7 +896,7 @@ function SettingsSyncFeature() { } export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { - useCallSignaling(); + useIncomingCallSignaling(); return ( <> diff --git a/src/app/pages/client/HandleNotificationClick.test.tsx b/src/app/pages/client/HandleNotificationClick.test.tsx new file mode 100644 index 000000000..85eb4f641 --- /dev/null +++ b/src/app/pages/client/HandleNotificationClick.test.tsx @@ -0,0 +1,112 @@ +import { render, waitFor } from '@testing-library/react'; +import { Provider, createStore } from 'jotai'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { HandleNotificationClick } from './ClientNonUIFeatures'; +import { pendingNotificationAtom, activeSessionIdAtom } from '$state/sessions'; +import { incomingCallAtom } from '$state/callEmbed'; +import { mDirectAtom } from '$state/mDirectList'; + +type TestServiceWorkerContainer = EventTarget & Partial; + +describe('HandleNotificationClick', () => { + let swContainer: TestServiceWorkerContainer; + + beforeEach(() => { + swContainer = new EventTarget() as TestServiceWorkerContainer; + Object.defineProperty(window.navigator, 'serviceWorker', { + configurable: true, + value: swContainer, + }); + }); + + it('stores pending notification and restores incoming call state from call click payload', async () => { + const store = createStore(); + store.set(mDirectAtom, { type: 'INITIALIZE', rooms: new Set(['!dm:example.org']) }); + + render( + + + + + + ); + + const now = Date.now(); + swContainer.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'notificationClick', + userId: '@me:example.org', + roomId: '!dm:example.org', + eventId: '$notif', + isCall: true, + callNotificationType: 'ring', + callIntentKind: 'video', + callIntentRaw: 'start_call_dm', + callRefEventId: '$ref', + callSenderId: '@alice:example.org', + callSenderTs: now, + callExpiresAt: now + 60_000, + }, + }) + ); + + await waitFor(() => { + expect(store.get(activeSessionIdAtom)).toBe('@me:example.org'); + expect(store.get(pendingNotificationAtom)).toEqual({ + roomId: '!dm:example.org', + eventId: '$notif', + targetSessionId: '@me:example.org', + }); + expect(store.get(incomingCallAtom)).toEqual( + expect.objectContaining({ + roomId: '!dm:example.org', + notificationEventId: '$notif', + refEventId: '$ref', + senderId: '@alice:example.org', + notificationType: 'ring', + intentKind: 'video', + isDirect: true, + }) + ); + }); + }); + + it('ignores expired call payloads while still navigating to the notification target', async () => { + const store = createStore(); + store.set(mDirectAtom, { type: 'INITIALIZE', rooms: new Set(['!dm:example.org']) }); + + render( + + + + + + ); + + const now = Date.now(); + swContainer.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'notificationClick', + roomId: '!dm:example.org', + eventId: '$notif', + isCall: true, + callNotificationType: 'ring', + callSenderTs: now - 120_000, + callExpiresAt: now - 1, + }, + }) + ); + + await waitFor(() => { + expect(store.get(pendingNotificationAtom)).toEqual({ + roomId: '!dm:example.org', + eventId: '$notif', + targetSessionId: undefined, + }); + }); + expect(store.get(incomingCallAtom)).toBeNull(); + }); +}); diff --git a/src/app/pages/client/ToRoomEvent.tsx b/src/app/pages/client/ToRoomEvent.tsx index 3073b44e9..7c3ea3aff 100644 --- a/src/app/pages/client/ToRoomEvent.tsx +++ b/src/app/pages/client/ToRoomEvent.tsx @@ -1,7 +1,13 @@ import { useEffect } from 'react'; -import { useParams } from 'react-router-dom'; -import { useSetAtom } from 'jotai'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { useAtomValue, useSetAtom } from 'jotai'; import { activeSessionIdAtom, pendingNotificationAtom } from '$state/sessions'; +import { mDirectAtom } from '$state/mDirectList'; +import { incomingCallAtom, mutedCallRoomIdAtom } from '$state/callEmbed'; +import { resolveIncomingCallFromSearchParams } from '$features/call/callNotificationBridge'; +import { isIncomingCallSuppressed } from '$features/call/callIncomingIngress'; +import { settingsAtom } from '$state/settings'; +import { useSetting } from '$state/hooks/settings'; // ToRoomEvent handles /to/:user_id/:room_id/:event_id? — the canonical deep-link // URL used by the service worker's notificationclick handler. @@ -17,8 +23,16 @@ import { activeSessionIdAtom, pendingNotificationAtom } from '$state/sessions'; // setActiveSessionId() triggers an account switch. export function ToRoomEvent() { const { user_id: userId, room_id: roomId, event_id: eventId } = useParams(); + const [searchParams] = useSearchParams(); + const mDirects = useAtomValue(mDirectAtom); + const mutedRoomId = useAtomValue(mutedCallRoomIdAtom); + const [incomingVoiceRoomCallSoundEnabled] = useSetting( + settingsAtom, + 'incomingVoiceRoomCallSoundEnabled' + ); const setActiveSessionId = useSetAtom(activeSessionIdAtom); const setPending = useSetAtom(pendingNotificationAtom); + const setIncomingCall = useSetAtom(incomingCallAtom); useEffect(() => { if (!roomId) return; @@ -26,9 +40,34 @@ export function ToRoomEvent() { // under the correct session. if (userId) setActiveSessionId(userId); setPending({ roomId, eventId, targetSessionId: userId }); + + const incomingCall = resolveIncomingCallFromSearchParams( + searchParams, + roomId, + eventId, + mDirects.has(roomId) + ); + if ( + incomingCall && + !isIncomingCallSuppressed(incomingCall, mutedRoomId, incomingVoiceRoomCallSoundEnabled) + ) { + setIncomingCall(incomingCall); + } + // Replace /to/… in history so the back button doesn't return to this route. window.history.replaceState({}, '', '/'); - }, [userId, roomId, eventId, setActiveSessionId, setPending]); + }, [ + eventId, + mDirects, + mutedRoomId, + roomId, + searchParams, + setActiveSessionId, + setIncomingCall, + setPending, + userId, + incomingVoiceRoomCallSoundEnabled, + ]); return null; } diff --git a/src/app/pages/client/inbox/Bookmarks.tsx b/src/app/pages/client/inbox/Bookmarks.tsx new file mode 100644 index 000000000..56595ff5d --- /dev/null +++ b/src/app/pages/client/inbox/Bookmarks.tsx @@ -0,0 +1,911 @@ +import type { ChangeEventHandler, ComponentProps, MouseEventHandler, ReactNode } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { MatrixEvent, Room } from 'matrix-js-sdk'; +import { ClientEvent, EventType, JoinRule, M_POLL_START } from 'matrix-js-sdk'; +import { + Avatar, + Box, + Button, + Chip, + Dialog, + Header, + Icon, + IconButton, + Icons, + Input, + Line, + Overlay, + OverlayBackdrop, + OverlayCenter, + Scroll, + Spinner, + Text, + color, + config, + toRem, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { useAtomValue } from 'jotai'; +import { + Page, + PageContent, + PageContentCenter, + PageHeader, + PageHero, + PageHeroEmpty, + PageHeroSection, +} from '../../../components/page'; +import { + useBookmarkList, + useBookmarkLoading, + useBookmarkActions, +} from '../../../hooks/useBookmarks'; +import type { BookmarkItemContent } from '$types/matrix-sdk-events'; +import { SequenceCard } from '../../../components/sequence-card'; +import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; +import type { RenderImageContentProps } from '../../../components/message'; +import { + AvatarBase, + ImageContent, + MessageNotDecryptedContent, + MessageUnsupportedContent, + ModernLayout, + MSticker, + RedactedContent, + Time, + Username, + UsernameBold, +} from '../../../components/message'; +import { UserAvatar } from '../../../components/user-avatar'; +import { RoomAvatar, RoomIcon } from '../../../components/room-avatar'; +import { + getEditedEvent, + getMemberAvatarMxc, + getMemberDisplayName, + getRoomAvatarUrl, +} from '../../../utils/room'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { BackRouteHandler } from '../../../components/BackRouteHandler'; +import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { mDirectAtom } from '../../../state/mDirectList'; +import { stopPropagation } from '../../../utils/keyboard'; +import { highlightText, makeHighlightRegex } from '../../../plugins/react-custom-html-parser'; +import colorMXID from '$utils/colorMXID'; +import { RenderMessageContent } from '$components/RenderMessageContent'; +import type { GetContentCallback } from '$types/matrix/room'; +import { useRoomEvent } from '$hooks/useRoomEvent'; +import type { HTMLReactParserOptions } from 'html-react-parser'; +import type { Opts } from 'linkifyjs'; +import { ImageViewer } from '$components/image-viewer'; +import { Image } from '$components/media'; +import { EncryptedContent } from '$features/room/message'; +import * as customHtmlCss from '$styles/CustomHtml.css'; +import type { IImageContent } from '$types/matrix/common'; +import { useMatrixEventRenderer } from '$hooks/useMatrixEventRenderer'; +import { MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT } from '$unstable/prefixes'; +import { useDebounce } from '$hooks/useDebounce'; + +type RemoveBookmarkDialogProps = { + open: boolean; + sender?: string; + displayName?: string; + senderAvatarMxc?: string; + renderMatrixEvent: () => ReactNode; + onConfirm: () => void; + onClose: () => void; +}; +function RemoveBookmarkDialog({ + open, + sender, + displayName, + senderAvatarMxc, + renderMatrixEvent, + onConfirm, + onClose, +}: RemoveBookmarkDialogProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + + return ( + }> + + + +
+ + Remove Bookmark + + + + +
+ + Are you sure you want to remove this bookmark? + {sender && ( + + {sender && ( + + + } + /> + + + {displayName ?? sender} + + + )} + {renderMatrixEvent()} + + )} + + +
+
+
+
+ ); +} + +type BookmarkItemRowProps = { + item: BookmarkItemContent; + room?: Room; + displayName: string; + senderAvatarMxc?: string; + usernameColor?: string; + hour24Clock: boolean; + dateFormatString: string; + onOpen: MouseEventHandler; + onRemove: (bookmarkId: string) => void; + highlightRegex?: RegExp; +}; + +type BookmarkItemRowBodyProps = { + item: BookmarkItemContent; + room: Room; + highlightRegex?: RegExp; + displayName: string; +}; + +type BookmarkItemRowBodyFallbackProps = { + item: BookmarkItemContent; + highlightRegex?: RegExp; +}; + +type bookmarkRendererContext = { + mx: ReturnType; + room?: Room; + mediaAutoLoad: boolean; + urlPreview: boolean; + htmlReactParserOptions: HTMLReactParserOptions; + linkifyOpts: Opts; +}; + +function BookmarkLazyImage(props: ComponentProps) { + return ; +} + +function renderBookmarkStickerImageContent( + mediaAutoLoad: boolean | undefined, + props: RenderImageContentProps +) { + return ( + } + /> + ); +} + +function renderBookmarkEncryptedDecrypted( + ctx: bookmarkRendererContext, + event: MatrixEvent, + displayName: string, + mEvent: MatrixEvent, + evtTimeline: NonNullable> +) { + const eventId = event.getId()!; + const eventType = mEvent.getType(); + const stickerEventType: string = EventType.Sticker; + const roomMessageEventType: string = EventType.RoomMessage; + const encryptedMessageEventType: string = EventType.RoomMessageEncrypted; + + if (mEvent.isRedacted()) return ; + if (eventType === stickerEventType) { + return ( + + ); + } + if (eventType === roomMessageEventType) { + const editedEvent = getEditedEvent(eventId, mEvent, evtTimeline.getTimelineSet()); + const getContent = (() => { + const eventContent = mEvent.getContent(); + const editContent = editedEvent?.getContent(); + return (editContent?.['m.new_content'] ?? eventContent) as Record; + }) as GetContentCallback; + + return ( + + ); + } + if (eventType === encryptedMessageEventType) { + return ( + + + + ); + } + return ( + + + + ); +} + +function renderBookmarkEncrypted( + ctx: bookmarkRendererContext, + event: MatrixEvent, + displayName: string +) { + const eventId = event.getId()!; + const evtTimeline = ctx.room?.getTimelineForEvent(eventId); + const mEvent = evtTimeline?.getEvents().find((e: MatrixEvent) => e.getId() === eventId); + + if (!mEvent || !evtTimeline) { + return ( + + + {event.getType()} + {' event'} + + + ); + } + + return ( + + {renderBookmarkEncryptedDecrypted.bind(null, ctx, event, displayName, mEvent, evtTimeline)} + + ); +} + +function renderBookmarkRoomMessage( + ctx: bookmarkRendererContext, + event: MatrixEvent, + displayName: string, + getContent: GetContentCallback +) { + if (event.isRedacted()) { + const unsigned = event.getUnsigned(); + const redactionContent = unsigned.redacted_because?.content as { reason?: string } | undefined; + return ; + } + + return ( + + ); +} + +function renderBookmarkSticker( + ctx: bookmarkRendererContext, + event: MatrixEvent, + _displayName: string, + getContent: GetContentCallback +) { + if (event.isRedacted()) { + const unsigned = event.getUnsigned(); + const redactionContent = unsigned.redacted_because?.content as + | Record + | undefined; + + return ; + } + return ( + + ); +} + +function renderBookmarkFallback(_ctx: bookmarkRendererContext, event: MatrixEvent) { + if (event.isRedacted()) { + const unsigned = event.getUnsigned(); + const redactionContent = unsigned.redacted_because?.content as + | Record + | undefined; + return ; + } + return ( + + + {event.getType()} + {' event'} + + + ); +} + +function BookmarkItemRowBodyFallback({ item, highlightRegex }: BookmarkItemRowBodyFallbackProps) { + return ( + + {item.body_preview + ? highlightRegex + ? highlightText(highlightRegex, [item.body_preview]) + : item.body_preview + : 'This bookmark has no preview'} + + ); +} + +function BookmarkItemRowBody({ + room, + item, + highlightRegex, + displayName, +}: BookmarkItemRowBodyProps) { + const mx = useMatrixClient(); + const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); + const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); + const event = useRoomEvent(room, item.event_id); // TODO: only fetch when in view (virtualizer?) + + const getContent = (() => event?.getContent()) as GetContentCallback; + + const rendererContext = useMemo( + () => ({ + mx, + room, + mediaAutoLoad, + urlPreview, + htmlReactParserOptions: {}, + linkifyOpts: {}, + }), + [mx, room, mediaAutoLoad, urlPreview] + ); + + // TODO: abstract this (code from pin menu) and reuse in a lot of places + const matrixEventHandlers = useMemo( + () => ({ + [EventType.RoomMessage]: renderBookmarkRoomMessage.bind(null, rendererContext), + [EventType.RoomMessageEncrypted]: renderBookmarkEncrypted.bind(null, rendererContext), + [EventType.Sticker]: renderBookmarkSticker.bind(null, rendererContext), + [M_POLL_START.name]: renderBookmarkRoomMessage.bind(null, rendererContext), + }), + [rendererContext] + ); + + const renderMatrixEvent = useMatrixEventRenderer<[MatrixEvent, string, GetContentCallback]>( + matrixEventHandlers, + undefined, + renderBookmarkFallback.bind(null, rendererContext) + ); + + if (!event) { + return ; + } + + return renderMatrixEvent(event.getType(), false, event, displayName, getContent); +} + +function BookmarkItemRow({ + item, + room, + displayName, + senderAvatarMxc, + usernameColor, + hour24Clock, + dateFormatString, + onOpen, + onRemove, + highlightRegex, +}: BookmarkItemRowProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const [confirmOpen, setConfirmOpen] = useState(false); + + const handleConfirmRemove = () => { + setConfirmOpen(false); + onRemove(item.bookmark_id); + }; + + return ( + <> + { + return room ? ( + + ) : ( + + ); + }} + onClose={() => setConfirmOpen(false)} + /> + + + + } + /> + + + } + > + + + + + {displayName} + + + + + + + + + + Jump + + { + evt.stopPropagation(); + setConfirmOpen(true); + }} + size="300" + radii="300" + aria-label="Remove bookmark" + style={{ color: color.Critical.Main }} + > + + + + + + + + {room ? ( + + ) : ( + + )} + + + + + ); +} + +type BookmarkResultGroupProps = { + roomId: string; + roomName?: string; + items: BookmarkItemContent[]; + onOpen: (roomId: string, eventId: string) => void; + onRemove: (bookmarkId: string) => void; + hour24Clock: boolean; + dateFormatString: string; + legacyUsernameColor?: boolean; + highlightRegex?: RegExp; +}; +function BookmarkResultGroup({ + roomId, + roomName, + items, + onOpen, + onRemove, + hour24Clock, + dateFormatString, + legacyUsernameColor, + highlightRegex, +}: BookmarkResultGroupProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const room = mx.getRoom(roomId); + + const handleOpenClick: MouseEventHandler = (evt) => { + const eventId = evt.currentTarget.getAttribute('data-event-id'); + if (!eventId) return; + onOpen(roomId, eventId); + }; + + return ( + +
+ + + {room ? ( + ( + + )} + /> + ) : ( + + )} + + + {room?.name ?? roomName ?? roomId} + + +
+ + {items.map((item) => { + const displayName = room + ? (getMemberDisplayName(room, item.sender ?? '') ?? + getMxIdLocalPart(item.sender ?? '') ?? + item.sender ?? + 'Unknown') + : (getMxIdLocalPart(item.sender ?? '') ?? item.sender ?? 'Unknown'); + const senderAvatarMxc = + room && item.sender ? getMemberAvatarMxc(room, item.sender) : undefined; + + const usernameColor = + legacyUsernameColor && item.sender ? colorMXID(item.sender) : undefined; + + return ( + + ); + })} + +
+ ); +} + +type BookmarkFilterInputProps = { + active?: boolean; + loading?: boolean; + searchInputRef: React.RefObject; + onChange: ChangeEventHandler; +}; +function BookmarkFilterInput({ + active, + loading, + searchInputRef, + onChange, +}: BookmarkFilterInputProps) { + return ( + + + Search + + ) : ( + + ) + } + /> + + ); +} + +export function Bookmarks() { + const mx = useMatrixClient(); + const bookmarks = useBookmarkList(); + const loading = useBookmarkLoading(); + const { refresh, remove } = useBookmarkActions(); + const { navigateRoom } = useRoomNavigate(); + const screenSize = useScreenSizeContext(); + const mDirects = useAtomValue(mDirectAtom); + + const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + + const searchInputRef = useRef(null); + const [filterTerm, setFilterTerm] = useState(); + + const handleAccountData = useCallback( + (event: MatrixEvent) => { + if (event.getType() === MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT) { + refresh(); + } + }, + [refresh] + ); + + useEffect(() => { + refresh(); + mx.on(ClientEvent.AccountData, handleAccountData); + return () => { + mx.removeListener(ClientEvent.AccountData, handleAccountData); + }; + }, [mx, refresh, handleAccountData]); + + // Filter bookmarks by search term + const filtered = useMemo(() => { + if (!filterTerm) return bookmarks; + const lower = filterTerm.toLowerCase(); + return bookmarks.filter( + (b) => + (b.body_preview && b.body_preview.toLowerCase().includes(lower)) || + (b.room_name && b.room_name.toLowerCase().includes(lower)) || + (b.sender && b.sender.toLowerCase().includes(lower)) + ); + }, [bookmarks, filterTerm]); + + const highlightRegex = useMemo( + () => (filterTerm ? makeHighlightRegex([filterTerm]) : undefined), + [filterTerm] + ); + + // Group filtered bookmarks by room + const groups = useMemo(() => { + const map = filtered.reduce((acc, item) => { + const existing = acc.get(item.room_id); + if (existing) { + existing.push(item); + } else { + acc.set(item.room_id, [item]); + } + return acc; + }, new Map()); + return Array.from(map.entries()); + }, [filtered]); + + const handleOnChange: ChangeEventHandler = useDebounce( + (evt) => { + if (evt.target.value) setFilterTerm(evt.target.value); + else setFilterTerm(undefined); + }, + { wait: 200 } + ); + + return ( + + + + + {screenSize === ScreenSize.Mobile && ( + + {(onBack) => ( + + + + )} + + )} + + + {screenSize !== ScreenSize.Mobile && } + + Bookmarks + + + + + + + + + + + + + + + {!filterTerm && bookmarks.length === 0 && !loading && ( + + + } + title="Bookmarks" + subTitle='Right-click a message and select "Bookmark Message" to save it here.' + /> + + + )} + + {loading && bookmarks.length === 0 && ( + + {[...Array(4).keys()].map((key) => ( + + ))} + + )} + + {filterTerm && filtered.length === 0 && ( + + + + No bookmarks found for {`"${filterTerm}"`} + + + )} + + {groups.length > 0 && ( + + {filterTerm && ( + + {`Bookmarks matching "${filterTerm}"`} + + + )} + {groups.map(([roomId, items]) => ( + + + + ))} + + )} + + + + + + + ); +} diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx index e1e98f3d5..f2d819f1d 100644 --- a/src/app/pages/client/inbox/Inbox.tsx +++ b/src/app/pages/client/inbox/Inbox.tsx @@ -1,8 +1,16 @@ import { Avatar, Box, Text, toRem } from 'folds'; import { ChatCircleDots, EnvelopeSimple, Tray, sizedIcon } from '$components/icons/phosphor'; import { NavCategory, NavItem, NavItemContent, NavLink } from '$components/nav'; -import { getInboxInvitesPath, getInboxNotificationsPath } from '$pages/pathUtils'; -import { useInboxInvitesSelected, useInboxNotificationsSelected } from '$hooks/router/useInbox'; +import { + getInboxBookmarksPath, + getInboxInvitesPath, + getInboxNotificationsPath, +} from '$pages/pathUtils'; +import { + useInboxBookmarksSelected, + useInboxInvitesSelected, + useInboxNotificationsSelected, +} from '$hooks/router/useInbox'; import { UnreadBadge } from '$components/unread-badge'; import { useNavToActivePathMapper } from '$hooks/useNavToActivePathMapper'; import { PageNav, PageNavContent, PageNavHeader } from '$components/page'; @@ -12,6 +20,7 @@ import { settingsAtom } from '$state/settings'; import { useEffect, useState } from 'react'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; 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'; @@ -55,6 +64,7 @@ function InvitesNavItem({ hideText }: { hideText?: boolean }) { export function Inbox() { useNavToActivePathMapper('inbox'); const notificationsSelected = useInboxNotificationsSelected(); + const bookmarksSelected = useInboxBookmarksSelected(); const setIsResizingSidebar = useSetAtom(isResizingSidebarAtom); const [roomSidebarWidth, setRoomSidebarWidth] = useSetting(settingsAtom, 'roomSidebarWidth'); @@ -117,6 +127,24 @@ export function Inbox() { + + + + + + {sizedIcon(BookmarkIcon, '100', { + filled: bookmarksSelected, + })}{' '} + + + + Bookmarks + + + + + +
diff --git a/src/app/pages/client/inbox/index.ts b/src/app/pages/client/inbox/index.ts index c8036b471..dc02ccee6 100644 --- a/src/app/pages/client/inbox/index.ts +++ b/src/app/pages/client/inbox/index.ts @@ -1,3 +1,4 @@ export * from './Inbox'; export * from './Notifications'; export * from './Invites'; +export * from './Bookmarks'; diff --git a/src/app/pages/client/sidebar/InboxTab.tsx b/src/app/pages/client/sidebar/InboxTab.tsx index 8141fb759..002b00d19 100644 --- a/src/app/pages/client/sidebar/InboxTab.tsx +++ b/src/app/pages/client/sidebar/InboxTab.tsx @@ -12,13 +12,19 @@ import { getInboxPath, joinPathComponent, } from '$pages/pathUtils'; -import { useInboxSelected } from '$hooks/router/useInbox'; +import { + useInboxBookmarksSelected, + useInboxInvitesSelected, + useInboxNotificationsSelected, + useInboxSelected, +} from '$hooks/router/useInbox'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useNavToActivePathAtom } from '$state/hooks/navToActivePath'; import { useInviteCount } from '$hooks/useInviteCount'; -import { getPhosphorIconSize, Tray } from '$components/icons/phosphor'; -import { Text, Box, color } from 'folds'; +import { Text, Box } 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, isMobile }: { isBottom?: boolean; isMobile?: boolean }) { const screenSize = useScreenSizeContext(); @@ -28,6 +34,11 @@ export function InboxTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile? const inviteCount = useInviteCount(); const isSearch = useAtomValue(searchModalAtom); const opened = inboxSelected && !isSearch; + const InboxIconSize = getPhosphorIconSize(isBottom ? 'inline' : 'toolbar'); + + const notificationsSelected = useInboxNotificationsSelected(); + const bookmarksSelected = useInboxBookmarksSelected(); + const invitesSelected = useInboxInvitesSelected(); const handleInboxClick = () => { if (screenSize === ScreenSize.Mobile) { @@ -56,11 +67,13 @@ export function InboxTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile? onClick={handleInboxClick} size={'400'} > - + {(notificationsSelected && ( + + )) || + (bookmarksSelected && ) || + (invitesSelected && ) || ( + + )} {isMobile && ( diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 45710cc69..6769f0fb1 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -335,7 +335,7 @@ function SpaceHeader({ hideText, mx }: { hideText?: boolean; mx: MatrixClient }) {composerIcon(DotsThreeOutlineVerticalIcon, { diff --git a/src/app/pages/client/space/styles.css.ts b/src/app/pages/client/space/styles.css.ts index bef019ba2..dc81e2c51 100644 --- a/src/app/pages/client/space/styles.css.ts +++ b/src/app/pages/client/space/styles.css.ts @@ -8,7 +8,7 @@ export const RoomCoverNavContainer = style({ width: '100%', zIndex: '100', top: '0', - background: 'linear-gradient(180deg, #000 0%, #0000 100%)', + background: 'linear-gradient(180deg, #000 0%, transparent 100%)', }); export const RoomCoverlessNavContainer = recipe({ base: { diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index bf17af8f9..d01bff279 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -30,6 +30,7 @@ import { CREATE_PATH, NAVIGATE_PATH, PROFILE_PATH, + INBOX_BOOKMARKS_PATH, } from './paths'; export const joinPathComponent = (path: Path): string => path.pathname + path.search + path.hash; @@ -162,6 +163,7 @@ export const getProfilePath = (): string => PROFILE_PATH; export const getInboxPath = (): string => INBOX_PATH; export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH; export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH; +export const getInboxBookmarksPath = (): string => INBOX_BOOKMARKS_PATH; export const getSettingsPath = (section?: string, focus?: string): string => { const path = trimTrailingSlash(generatePath(SETTINGS_PATH, { section: section ?? null })); diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index f465dd25e..05917aa2c 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -26,6 +26,7 @@ export type SettingsPathSearchParams = { export const CREATE_PATH_SEGMENT = 'create/'; export const JOIN_PATH_SEGMENT = 'join/'; export const LOBBY_PATH_SEGMENT = 'lobby/'; +export const BOOKMARKS_PATH_SEGMENT = 'bookmarks/'; /** * array of rooms and senders mxId assigned * to search param as string should be "," separated @@ -90,6 +91,7 @@ export type InboxNotificationsPathSearchParams = { }; export const INBOX_NOTIFICATIONS_PATH = `/inbox/${NOTIFICATIONS_PATH_SEGMENT}`; export const INBOX_INVITES_PATH = `/inbox/${INVITES_PATH_SEGMENT}`; +export const INBOX_BOOKMARKS_PATH = `/inbox/${BOOKMARKS_PATH_SEGMENT}`; export const TO_PATH = '/to'; // Deep-link route used by push notification click-back URLs. diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index fb2e6ff44..f67846531 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -3,6 +3,7 @@ import EventEmitter from 'eventemitter3'; import { CallControlState } from './CallControlState'; import type { ElementMediaStateDetail, ElementMediaStatePayload } from './types'; import { ElementWidgetActions } from './types'; +import { getScreenshareButton, isElementToggledOn } from './elementCallDomAdapter'; export enum CallControlEvent { StateUpdate = 'state_update', @@ -22,41 +23,7 @@ export class CallControl extends EventEmitter implements CallControlState { } private get screenshareButton(): HTMLElement | undefined { - const screenshareBtn = this.document?.querySelector( - '[data-testid="incall_screenshare"]' - ) as HTMLElement | null; - - return screenshareBtn ?? undefined; - } - - private get settingsButton(): HTMLElement | undefined { - const leaveBtn = this.document?.querySelector('[data-testid="incall_leave"]'); - - const settingsButton = leaveBtn?.previousElementSibling as HTMLElement | null; - - return settingsButton ?? undefined; - } - - private get reactionsButton(): HTMLElement | undefined { - const reactionsButton = this.settingsButton?.previousElementSibling as HTMLElement | null; - - return reactionsButton ?? undefined; - } - - private get spotlightButton(): HTMLInputElement | undefined { - const spotlightButton = this.document?.querySelector( - 'input[value="spotlight"]' - ) as HTMLInputElement | null; - - return spotlightButton ?? undefined; - } - - private get gridButton(): HTMLInputElement | undefined { - const gridButton = this.document?.querySelector( - 'input[value="grid"]' - ) as HTMLInputElement | null; - - return gridButton ?? undefined; + return getScreenshareButton(this.document); } constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) { @@ -89,16 +56,12 @@ export class CallControl extends EventEmitter implements CallControlState { return this.state.screenshare; } - public get spotlight(): boolean { - return this.state.spotlight; - } - public async applyState() { await this.setMediaState({ audio_enabled: this.microphone, video_enabled: this.video, + audio_output_enabled: this.sound, }); - this.setSound(this.sound); this.emitStateUpdate(); } @@ -109,13 +72,7 @@ export class CallControl extends EventEmitter implements CallControlState { if (screenshareBtn) { this.controlMutationObserver.observe(screenshareBtn, { attributes: true, - attributeFilter: ['data-kind'], - }); - } - const spotlightBtn = this.spotlightButton; - if (spotlightBtn) { - this.controlMutationObserver.observe(spotlightBtn, { - attributes: true, + attributeFilter: ['data-kind', 'aria-pressed', 'aria-checked', 'class'], }); } @@ -131,45 +88,39 @@ export class CallControl extends EventEmitter implements CallControlState { } private setSound(sound: boolean): void { - const callDocument = this.iframe.contentDocument ?? this.iframe.contentWindow?.document; - if (callDocument) { - callDocument.querySelectorAll('audio').forEach((el) => { - el.muted = !sound; - }); - } + this.setMediaState({ + audio_output_enabled: sound, + }); } public onMediaState(evt: CustomEvent) { const { data } = evt.detail; if (!data) return; + const micTurnedOn = data.audio_enabled === true && !this.microphone; + const soundTurnedOff = data.audio_output_enabled === false && this.sound; + const state = new CallControlState( data.audio_enabled ?? this.microphone, data.video_enabled ?? this.video, - this.sound, - this.screenshare, - this.spotlight + data.audio_output_enabled ?? this.sound, + this.screenshare ); this.state = state; this.emitStateUpdate(); - if (this.microphone && !this.sound) { + if (micTurnedOn && !this.sound) { this.toggleSound(); + } else if (soundTurnedOff && this.microphone) { + this.toggleMicrophone(); } } public onControlMutation() { - const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary'; - const spotlight: boolean = this.spotlightButton?.checked ?? false; - - this.state = new CallControlState( - this.microphone, - this.video, - this.sound, - screenshare, - spotlight - ); + const screenshare: boolean = isElementToggledOn(this.screenshareButton); + + this.state = new CallControlState(this.microphone, this.video, this.sound, screenshare); this.emitStateUpdate(); } @@ -194,13 +145,7 @@ export class CallControl extends EventEmitter implements CallControlState { this.setSound(sound); - const state = new CallControlState( - this.microphone, - this.video, - sound, - this.screenshare, - this.spotlight - ); + const state = new CallControlState(this.microphone, this.video, sound, this.screenshare); this.state = state; this.emitStateUpdate(); @@ -213,22 +158,6 @@ export class CallControl extends EventEmitter implements CallControlState { this.screenshareButton?.click(); } - public toggleSpotlight() { - if (this.spotlight) { - this.gridButton?.click(); - return; - } - this.spotlightButton?.click(); - } - - public toggleReactions() { - this.reactionsButton?.click(); - } - - public toggleSettings() { - this.settingsButton?.click(); - } - public dispose() { this.controlMutationObserver.disconnect(); } diff --git a/src/app/plugins/call/CallControlState.ts b/src/app/plugins/call/CallControlState.ts index fc7e3f22b..03984cd7b 100644 --- a/src/app/plugins/call/CallControlState.ts +++ b/src/app/plugins/call/CallControlState.ts @@ -7,19 +7,10 @@ export class CallControlState { public readonly screenshare: boolean; - public readonly spotlight: boolean; - - constructor( - microphone: boolean, - video: boolean, - sound: boolean, - screenshare = false, - spotlight = false - ) { + constructor(microphone: boolean, video: boolean, sound: boolean, screenshare = false) { this.microphone = microphone; this.video = video; this.sound = sound; this.screenshare = screenshare; - this.spotlight = spotlight; } } diff --git a/src/app/plugins/call/CallEmbed.intent.test.ts b/src/app/plugins/call/CallEmbed.intent.test.ts new file mode 100644 index 000000000..637760942 --- /dev/null +++ b/src/app/plugins/call/CallEmbed.intent.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; +import { vi } from 'vitest'; + +vi.mock('../../utils/debugLogger', () => ({ + createDebugLogger: () => ({ + info: vi.fn<(...args: unknown[]) => void>(), + warn: vi.fn<(...args: unknown[]) => void>(), + error: vi.fn<(...args: unknown[]) => void>(), + debug: vi.fn<(...args: unknown[]) => void>(), + }), +})); + +import { CallEmbed } from './CallEmbed'; +import { ElementCallIntent } from './types'; + +type IntentCase = { + dm: boolean; + ongoing: boolean; + video: boolean; + expected: string; +}; + +function createRoom(isCallRoom: boolean) { + return { + roomId: '!room:example.com', + hasEncryptionStateEvent: () => false, + isCallRoom: () => isCallRoom, + } as never; +} + +const intentCases: IntentCase[] = [ + { dm: true, ongoing: false, video: true, expected: ElementCallIntent.StartCallDM }, + { dm: true, ongoing: false, video: false, expected: ElementCallIntent.StartCallDMVoice }, + { dm: true, ongoing: true, video: true, expected: ElementCallIntent.JoinExistingDM }, + { dm: true, ongoing: true, video: false, expected: ElementCallIntent.JoinExistingDMVoice }, + { dm: false, ongoing: false, video: true, expected: ElementCallIntent.StartCall }, + { dm: false, ongoing: false, video: false, expected: ElementCallIntent.StartCallVoice }, + { dm: false, ongoing: true, video: true, expected: ElementCallIntent.JoinExisting }, + { dm: false, ongoing: true, video: false, expected: ElementCallIntent.JoinExistingVoice }, +]; + +describe('CallEmbed.getIntent', () => { + it.each(intentCases)('maps dm=$dm ongoing=$ongoing video=$video to $expected', (tc) => { + const intent = CallEmbed.getIntent(tc.dm, tc.ongoing, tc.video); + expect(intent).toBe(tc.expected); + }); +}); + +describe('CallEmbed.dmCall', () => { + it.each([ + ElementCallIntent.StartCallDM, + ElementCallIntent.StartCallDMVoice, + ElementCallIntent.JoinExistingDM, + ElementCallIntent.JoinExistingDMVoice, + ])('returns true for DM intent %s', (intent) => { + expect(CallEmbed.dmCall(intent)).toBe(true); + }); + + it.each([ + ElementCallIntent.StartCall, + ElementCallIntent.StartCallVoice, + ElementCallIntent.JoinExisting, + ElementCallIntent.JoinExistingVoice, + ])('returns false for room intent %s', (intent) => { + expect(CallEmbed.dmCall(intent)).toBe(false); + }); +}); + +describe('CallEmbed.startingCall', () => { + it.each([ + ElementCallIntent.StartCall, + ElementCallIntent.StartCallVoice, + ElementCallIntent.StartCallDM, + ElementCallIntent.StartCallDMVoice, + ])('returns true for start intent %s', (intent) => { + expect(CallEmbed.startingCall(intent)).toBe(true); + }); + + it.each([ + ElementCallIntent.JoinExisting, + ElementCallIntent.JoinExistingVoice, + ElementCallIntent.JoinExistingDM, + ElementCallIntent.JoinExistingDMVoice, + ])('returns false for join intent %s', (intent) => { + expect(CallEmbed.startingCall(intent)).toBe(false); + }); +}); + +describe('CallEmbed.getWidget', () => { + vi.stubGlobal('window', { + location: { origin: 'https://app.example.com' }, + }); + + const mx = { + baseUrl: 'https://matrix.example.com', + getSafeUserId: () => '@alice:example.com', + getDeviceId: () => 'ALICEDEVICE', + } as never; + + it('adds ring notification delegation for starting DM calls in non-call rooms', () => { + const room = createRoom(false); + const widget = CallEmbed.getWidget(mx, room, ElementCallIntent.StartCallDMVoice, 'dark'); + const url = new URL(widget.getCompleteUrl({ currentUserId: '@alice:example.com' })); + + expect(url.searchParams.get('sendNotificationType')).toBe('ring'); + }); + + it('adds notification delegation for starting room calls in non-call rooms', () => { + const room = createRoom(false); + const widget = CallEmbed.getWidget(mx, room, ElementCallIntent.StartCallVoice, 'dark'); + const url = new URL(widget.getCompleteUrl({ currentUserId: '@alice:example.com' })); + + expect(url.searchParams.get('sendNotificationType')).toBe('notification'); + }); + + it('does not add notification delegation for join intents', () => { + const room = createRoom(false); + const widget = CallEmbed.getWidget(mx, room, ElementCallIntent.JoinExisting, 'dark'); + const url = new URL(widget.getCompleteUrl({ currentUserId: '@alice:example.com' })); + + expect(url.searchParams.get('sendNotificationType')).toBeNull(); + }); + + it('does not add notification delegation in call rooms', () => { + const room = createRoom(true); + const widget = CallEmbed.getWidget(mx, room, ElementCallIntent.StartCallDM, 'dark'); + const url = new URL(widget.getCompleteUrl({ currentUserId: '@alice:example.com' })); + + expect(url.searchParams.get('sendNotificationType')).toBeNull(); + }); + + it('uses elementCallUrl from config when provided', () => { + const room = createRoom(false); + const widget = CallEmbed.getWidget( + mx, + room, + ElementCallIntent.StartCallDM, + 'dark', + 'https://calls.example.com/embed/index.html' + ); + const url = new URL(widget.getCompleteUrl({ currentUserId: '@alice:example.com' })); + + expect(url.origin).toBe('https://calls.example.com'); + expect(url.pathname).toBe('/embed/index.html'); + }); + + it('falls back to bundled element call app when elementCallUrl is invalid', () => { + const room = createRoom(false); + const widget = CallEmbed.getWidget( + mx, + room, + ElementCallIntent.StartCallDM, + 'dark', + 'http://[::1' + ); + const url = new URL(widget.getCompleteUrl({ currentUserId: '@alice:example.com' })); + + expect(url.pathname).toContain('/public/element-call/index.html'); + }); +}); diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index b31323d4b..87b013eb1 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -11,6 +11,7 @@ import { import { CallWidgetDriver } from './CallWidgetDriver'; import { trimTrailingSlash } from '../../utils/common'; import type { ElementCallThemeKind, ElementMediaStateDetail } from './types'; +import { color, config } from 'folds'; import { ElementCallIntent, ElementWidgetActions } from './types'; import { CallControl } from './CallControl'; import { CallControlState } from './CallControlState'; @@ -18,6 +19,20 @@ import { createDebugLogger } from '../../utils/debugLogger'; const debugLog = createDebugLogger('CallEmbed'); +const resolveCssVar = (variable: string): string => { + const match = variable.match(/var\((--[^,)]+)/); + if (match && match[1]) { + const bodyVal = window.getComputedStyle(document.body).getPropertyValue(match[1]).trim(); + if (bodyVal) return bodyVal; + const docElVal = window + .getComputedStyle(document.documentElement) + .getPropertyValue(match[1]) + .trim(); + if (docElVal) return docElVal; + } + return variable; +}; + export class CallEmbed { private mx: MatrixClient; @@ -40,22 +55,44 @@ export class CallEmbed { private readonly disposables: Array<() => void> = []; static getIntent(dm: boolean, ongoing: boolean, video: boolean | undefined): ElementCallIntent { - if (!dm) { - return video ? ElementCallIntent.JoinExisting : ElementCallIntent.JoinExistingDMVoice; + if (ongoing) { + if (dm) { + return video ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExistingDMVoice; + } + return video ? ElementCallIntent.JoinExisting : ElementCallIntent.JoinExistingVoice; } - if (ongoing) { - return video ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExistingDMVoice; + if (dm) { + return video ? ElementCallIntent.StartCallDM : ElementCallIntent.StartCallDMVoice; } - return video ? ElementCallIntent.StartCallDM : ElementCallIntent.StartCallDMVoice; + return video ? ElementCallIntent.StartCall : ElementCallIntent.StartCallVoice; + } + + static dmCall(intent: ElementCallIntent): boolean { + return ( + intent === ElementCallIntent.JoinExistingDM || + intent === ElementCallIntent.JoinExistingDMVoice || + intent === ElementCallIntent.StartCallDM || + intent === ElementCallIntent.StartCallDMVoice + ); + } + + static startingCall(intent: ElementCallIntent): boolean { + return ( + intent === ElementCallIntent.StartCallDM || + intent === ElementCallIntent.StartCallDMVoice || + intent === ElementCallIntent.StartCall || + intent === ElementCallIntent.StartCallVoice + ); } static getWidget( mx: MatrixClient, room: Room, intent: ElementCallIntent, - themeKind: ElementCallThemeKind + themeKind: ElementCallThemeKind, + elementCallUrl?: string ): Widget { const userId = mx.getSafeUserId(); const deviceId = mx.getDeviceId() ?? ''; @@ -77,12 +114,38 @@ export class CallEmbed { perParticipantE2EE: room.hasEncryptionStateEvent().toString(), lang: 'en-EN', theme: themeKind, + header: 'none', }); - const widgetUrl = new URL( - `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`, - window.location.origin - ); + if (!room.isCallRoom() && CallEmbed.startingCall(intent)) { + params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification'); + params.append('waitForCallPickup', CallEmbed.dmCall(intent) ? 'true' : 'false'); + } + + let widgetUrl: URL; + if (elementCallUrl && elementCallUrl.trim()) { + try { + widgetUrl = new URL(elementCallUrl, window.location.origin); + } catch (error) { + debugLog.warn( + 'call', + 'Invalid elementCallUrl in client config, falling back to bundled call app', + { + elementCallUrl, + error: error instanceof Error ? error.message : String(error), + } + ); + widgetUrl = new URL( + `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`, + window.location.origin + ); + } + } else { + widgetUrl = new URL( + `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`, + window.location.origin + ); + } widgetUrl.search = params.toString(); const options: IWidget = { @@ -228,10 +291,8 @@ export class CallEmbed { this.readUpToMap[room.roomId] = roomEvent.getId()!; }); - // Attach listeners for feeding events - the underlying widget classes handle permissions for us. - // Bind once and store via disposables so the same function reference is used for removal. - // Using .bind(this) at call-site would create a new function every time, making .off() a no-op - // and causing MaxListeners warnings when the embed is recreated during sync retries. + // Bind handlers once and route removal through `disposables` so listeners can be + // cleanly torn down when the embed is recreated. const boundOnEvent = this.onEvent.bind(this); const boundOnEventDecrypted = this.onEventDecrypted.bind(this); const boundOnStateUpdate = this.onStateUpdate.bind(this); @@ -282,12 +343,343 @@ export class CallEmbed { if (!doc) return; doc.body.style.setProperty('background', 'none', 'important'); - const controls = doc.body.querySelector('[data-testid="incall_leave"]')?.parentElement - ?.parentElement; - if (controls) { - controls.style.setProperty('position', 'absolute'); - controls.style.setProperty('visibility', 'hidden'); - } + + // Copy stylesheets from parent just in case + const syncStyles = () => { + Array.from(document.styleSheets).forEach((sheet) => { + try { + if (!sheet.href) { + const rules = Array.from(sheet.cssRules) + .map((r) => r.cssText) + .join('\n'); + if (rules && !doc.head.innerHTML.includes(rules.substring(0, 50))) { + const styleEl = doc.createElement('style'); + styleEl.textContent = rules; + doc.head.append(styleEl); + } + } else { + const link = doc.createElement('link'); + link.rel = 'stylesheet'; + link.href = sheet.href; + doc.head.append(link); + } + } catch { + // Ignore CORS errors + } + }); + }; + syncStyles(); + + const updateInjectedCSS = () => { + const styleId = 'sable-call-embed-styles'; + let styleEl = doc.getElementById(styleId); + if (!styleEl) { + styleEl = doc.createElement('style'); + styleEl.id = styleId; + doc.head.append(styleEl); + } + + const appFontFamily = window.getComputedStyle(document.body).fontFamily; + + styleEl.textContent = ` + :root { + /* Backgrounds */ + --cpd-color-bg-canvas-default: ${resolveCssVar(color.Background.Container)} !important; + --cpd-color-bg-canvas-solid: ${resolveCssVar(color.Background.Container)} !important; + --cpd-color-bg-surface-default: ${resolveCssVar(color.SurfaceVariant.Container)} !important; + --cpd-color-bg-surface-solid: ${resolveCssVar(color.SurfaceVariant.Container)} !important; + --cpd-color-bg-surface-raised: ${resolveCssVar(color.Surface.Container)} !important; + + /* Soft Fills for normal buttons */ + --cpd-color-bg-subtle-primary: ${resolveCssVar(color.SurfaceVariant.Container)} !important; + --cpd-color-bg-subtle-secondary: ${resolveCssVar(color.SurfaceVariant.Container)} !important; + --cpd-color-bg-action-secondary-rest: ${resolveCssVar(color.SurfaceVariant.Container)} !important; + --cpd-color-bg-action-secondary-hovered: ${resolveCssVar(color.SurfaceVariant.ContainerHover)} !important; + --cpd-color-bg-action-secondary-pressed: ${resolveCssVar(color.SurfaceVariant.ContainerActive)} !important; + + --cpd-color-bg-action-tertiary-rest: transparent !important; + --cpd-color-bg-action-tertiary-hovered: ${resolveCssVar(color.SurfaceVariant.ContainerHover)} !important; + --cpd-color-bg-action-tertiary-pressed: ${resolveCssVar(color.SurfaceVariant.ContainerActive)} !important; + + /* Soft Fills for primary/active buttons */ + --cpd-color-bg-action-primary-rest: ${resolveCssVar(color.Primary.Container)} !important; + --cpd-color-bg-action-primary-hovered: ${resolveCssVar(color.Primary.ContainerHover)} !important; + --cpd-color-bg-action-primary-pressed: ${resolveCssVar(color.Primary.ContainerActive)} !important; + + /* Soft Fills for critical buttons (Hangup) */ + --cpd-color-bg-critical-primary: ${resolveCssVar(color.Critical.Main)} !important; + --cpd-color-bg-action-critical-rest: ${resolveCssVar(color.Critical.Main)} !important; + --cpd-color-bg-action-critical-hovered: ${resolveCssVar(color.Critical.MainHover)} !important; + --cpd-color-bg-action-critical-pressed: ${resolveCssVar(color.Critical.MainActive)} !important; + + /* Borders */ + --cpd-color-border-interactive-primary: ${resolveCssVar(color.Primary.Main)} !important; + --cpd-color-border-interactive-secondary: ${resolveCssVar(color.Surface.ContainerLine)} !important; + --cpd-color-border-focused: ${resolveCssVar(color.Primary.Main)} !important; + + /* Typography and Icons */ + --cpd-font-family-sans: ${appFontFamily} !important; + --cpd-color-text-primary: ${resolveCssVar(color.Background.OnContainer)} !important; + --cpd-color-text-secondary: ${resolveCssVar(color.Surface.OnContainer)} !important; + --cpd-color-icon-primary: ${resolveCssVar(color.Background.OnContainer)} !important; + --cpd-color-icon-secondary: ${resolveCssVar(color.Surface.OnContainer)} !important; + --cpd-color-icon-tertiary: ${resolveCssVar(color.SurfaceVariant.OnContainer)} !important; + + /* Icons/Text on Soft Fill Backgrounds */ + --cpd-color-icon-on-solid-primary: ${resolveCssVar(color.Primary.OnContainer)} !important; + --cpd-color-text-on-solid-primary: ${resolveCssVar(color.Primary.OnContainer)} !important; + --cpd-color-icon-critical-primary: ${resolveCssVar(color.Critical.OnMain)} !important; + --cpd-color-text-critical-primary: ${resolveCssVar(color.Critical.OnMain)} !important; + + /* Accent/Primary Colors for Checkboxes/Switches */ + --cpd-color-text-action-accent: ${resolveCssVar(color.Primary.Main)} !important; + --stopgap-color-on-solid-accent: ${resolveCssVar(color.Primary.OnMain)} !important; + } + + /* Enforce rounded rectangles instead of circles */ + [class*="button_"], [class*="Button_"], button { + border-radius: ${resolveCssVar(config.radii.R400)} !important; + } + + /* Make the main room background transparent to inherit CallView's background */ + [class*="_inRoom_"] { + background: transparent !important; + } + + /* Completely dismantle Element Call's grouping pills to match Sable's discrete buttons */ + [data-testid="footer-container"] [class*="_container_"] { + background-color: transparent !important; + border: none !important; + gap: ${resolveCssVar(config.space.S100)} !important; + } + + /* Explicitly style normal primary (muted) buttons to use Sable's colors and disable Compound's overlays */ + [class*="button_"][data-kind="primary"]:not([class*="_destructive_"]), button[data-kind="primary"]:not([class*="_destructive_"]) { + background-color: ${resolveCssVar(color.Primary.Container)} !important; + border: 1px solid ${resolveCssVar(color.Primary.ContainerLine)} !important; + color: ${resolveCssVar(color.Primary.OnContainer)} !important; + box-sizing: border-box !important; + } + [class*="button_"][data-kind="primary"]:not([class*="_destructive_"]) svg, + [class*="button_"][data-kind="primary"]:not([class*="_destructive_"]) *, + button[data-kind="primary"]:not([class*="_destructive_"]) svg, + button[data-kind="primary"]:not([class*="_destructive_"]) * { + color: ${resolveCssVar(color.Primary.OnContainer)} !important; + } + [class*="button_"][data-kind="primary"]:not([class*="_destructive_"])::before, + [class*="button_"][data-kind="primary"]:not([class*="_destructive_"])::after, + button[data-kind="primary"]:not([class*="_destructive_"])::before, + button[data-kind="primary"]:not([class*="_destructive_"])::after { + background-color: transparent !important; + border: none !important; + box-sizing: border-box !important; + } + [class*="button_"][data-kind="primary"]:not([class*="_destructive_"]):hover, + button[data-kind="primary"]:not([class*="_destructive_"]):hover { + background-color: ${resolveCssVar(color.Primary.ContainerHover)} !important; + } + [class*="button_"][data-kind="primary"]:not([class*="_destructive_"]):active, + button[data-kind="primary"]:not([class*="_destructive_"]):active { + background-color: ${resolveCssVar(color.Primary.ContainerActive)} !important; + } + [class*="button_"][data-kind="primary"][class*="_destructive_"] { + background-color: ${resolveCssVar(color.Critical.Main)} !important; + border: 1px solid ${resolveCssVar(color.Critical.MainLine)} !important; + color: ${resolveCssVar(color.Critical.OnMain)} !important; + box-sizing: border-box !important; + } + [class*="button_"][data-kind="primary"][class*="_destructive_"] svg, + [class*="button_"][data-kind="primary"][class*="_destructive_"] * { + color: ${resolveCssVar(color.Critical.OnMain)} !important; + } + [class*="button_"][data-kind="primary"][class*="_destructive_"]::before, + [class*="button_"][data-kind="primary"][class*="_destructive_"]::after { + background-color: transparent !important; + border: 1px solid ${resolveCssVar(color.Critical.MainLine)} !important; + box-sizing: border-box !important; + } + [class*="button_"][data-kind="primary"][class*="_destructive_"]:hover { + background-color: ${resolveCssVar(color.Critical.MainHover)} !important; + } + [class*="button_"][data-kind="primary"][class*="_destructive_"]:active { + background-color: ${resolveCssVar(color.Critical.MainActive)} !important; + } + + /* Fix secondary buttons inside the footer to have Sable's exact container styling */ + [data-testid="footer-container"] button[data-kind="secondary"], + [data-testid="footer-container"] button[aria-haspopup="menu"] { + background: ${resolveCssVar(color.SurfaceVariant.Container)} !important; + border: 1px solid ${resolveCssVar(color.Surface.ContainerLine)} !important; + color: var(--cpd-color-icon-secondary) !important; + } + + /* Disable Compound's hover backgrounds on the pseudo-elements for these buttons so our background shows through */ + [data-testid="footer-container"] button[data-kind="secondary"]::before, + [data-testid="footer-container"] button[aria-haspopup="menu"]::before { + background: none !important; + } + + [data-testid="footer-container"] button[data-kind="secondary"]:hover, + [data-testid="footer-container"] button[aria-haspopup="menu"]:hover { + background: ${resolveCssVar(color.SurfaceVariant.ContainerHover)} !important; + } + + [class*="button_"]::before, [class*="Button_"]::before, button::before, + [class*="button_"]::after, [class*="Button_"]::after, button::after, + [data-testid="footer-container"] [class*="_container_"]::before, + [data-testid="footer-container"] [class*="_container_"]::after { + border-radius: inherit !important; + } + + /* Tile styling */ + [class*="_tile_"] { + border-radius: ${resolveCssVar(config.radii.R500)} !important; + } + + [class*="_tile_"][class*="_speaking_"]::before { + background: ${resolveCssVar(color.Primary.Main)} !important; + opacity: 0.8 !important; + } + + /* Avatar background */ + [class*="_tile_"] > [class*="_bg_"] { + background-color: ${resolveCssVar(color.SurfaceVariant.Container)} !important; + } + + /* Remove the dark gradient overlay from the tile foreground */ + [class*="_fg_"] { + background: none !important; + } + + /* Ensure the options button in the tile remains visible without the gradient */ + [class*="_fg_"] button { + background-color: ${resolveCssVar(color.Surface.Container)} !important; + border: 1px solid ${resolveCssVar(color.Surface.ContainerLine)} !important; + color: ${resolveCssVar(color.Surface.OnContainer)} !important; + } + [class*="_fg_"] button:hover { + background-color: ${resolveCssVar(color.Surface.ContainerHover)} !important; + } + + /* Nametag styling */ + [class*="_nameTag_"] { + background-color: ${resolveCssVar(color.Surface.Container)} !important; + border: 1px solid ${resolveCssVar(color.Surface.ContainerLine)} !important; + color: ${resolveCssVar(color.Surface.OnContainer)} !important; + border-radius: ${resolveCssVar(config.radii.R300)} !important; + } + + /* Settings 3-dots button overrides */ + [data-testid="settings-bottom-left"] { + --cpd-icon-button-size: 48px !important; + background-color: ${resolveCssVar(color.SurfaceVariant.Container)} !important; + border-radius: ${resolveCssVar(config.radii.R400)} !important; + } + + /* Layout switcher overrides */ + fieldset[class*="_toggle_"] { + background-color: ${resolveCssVar(color.SurfaceVariant.Container)} !important; + border: 1px solid ${resolveCssVar(color.Surface.ContainerLine)} !important; + border-radius: ${resolveCssVar(config.radii.R400)} !important; + } + fieldset[class*="_toggle_"] input:checked + svg { + background-color: ${resolveCssVar(color.Primary.Container)} !important; + color: ${resolveCssVar(color.Primary.OnContainer)} !important; + border-radius: ${resolveCssVar(config.radii.R300)} !important; + } + + /* Overlay styling */ + [class*="_overlay_"] { + background-color: var(--cpd-color-bg-canvas-default) !important; + } + + /* Slider overrides */ + [role="slider"], [class*="handle"] { + background-color: ${resolveCssVar(color.Primary.Main)} !important; + box-shadow: 0 0 0 2px ${resolveCssVar(color.Surface.Container)} !important; + } + [class*="highlight"] { + background-color: ${resolveCssVar(color.Primary.Main)} !important; + } + [class*="track"] { + background-color: ${resolveCssVar(color.Surface.ContainerLine)} !important; + outline: none !important; + } + + /* Scrollbars */ + ::-webkit-scrollbar { + width: 16px; + height: 16px; + } + ::-webkit-scrollbar-track, + ::-webkit-scrollbar-thumb { + background-color: transparent; + border-radius: ${resolveCssVar(config.radii.Pill)} !important; + border: 4px solid transparent; + background-clip: padding-box; + } + ::-webkit-scrollbar-thumb { + min-height: 35px; + } + :hover::-webkit-scrollbar-thumb, :has(*:hover)::-webkit-scrollbar-thumb { + background-color: ${resolveCssVar(color.SurfaceVariant.ContainerLine)} !important; + } + :hover::-webkit-scrollbar-track, :has(*:hover)::-webkit-scrollbar-track { + background-color: ${resolveCssVar(color.SurfaceVariant.ContainerActive)} !important; + } + + /* Modal Dialog overrides */ + [role="dialog"] { + border-radius: ${resolveCssVar(config.radii.R400)} !important; + } + + /* Tooltips and Menus */ + [role="tooltip"], .cpd-tooltip, [data-radix-popper-content-wrapper] > div, div[class*="_tooltip_"] { + background-color: ${resolveCssVar(color.Surface.Container)} !important; + color: ${resolveCssVar(color.Surface.OnContainer)} !important; + border: 1px solid ${resolveCssVar(color.Surface.ContainerLine)} !important; + border-radius: ${resolveCssVar(config.radii.R400)} !important; + padding: ${resolveCssVar(config.space.S200)} ${resolveCssVar(config.space.S300)} !important; + font-size: ${resolveCssVar(config.fontSize.B300)} !important; + box-shadow: 0 4px 6px ${resolveCssVar(color.Other.Shadow)} !important; + } + + /* Ensure tooltip text inside wrapper inherits correctly */ + [role="tooltip"] *, .cpd-tooltip *, [data-radix-popper-content-wrapper] * { + color: inherit !important; + } + /* Use parent app's font for emojis/reactions */ + [class*="reaction" i], [class*="emoji" i], [class*="reaction" i] * { + font-family: ${appFontFamily} !important; + } + `; + }; + + // Sync theme classes from parent html/body + const syncThemeClasses = () => { + doc.documentElement.className = document.documentElement.className; + doc.body.className = document.body.className; + + const theme = document.documentElement.getAttribute('data-theme'); + if (theme) doc.documentElement.setAttribute('data-theme', theme); + + // Re-evaluate vars and update CSS on theme change + updateInjectedCSS(); + }; + + // Initial injection + syncThemeClasses(); + + const observer = new MutationObserver(syncThemeClasses); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class', 'data-theme', 'style'], + }); + observer.observe(document.body, { + attributes: true, + attributeFilter: ['class', 'data-theme', 'style'], + }); + this.disposables.push(() => observer.disconnect()); } private onEvent(ev: MatrixEvent): void { @@ -307,6 +699,16 @@ export class CallEmbed { }); } + private feedStateUpdateForTimelineEvent(ev: MatrixEvent): void { + if (this.call === null) return; + if (!ev.isState()) return; + const raw = ev.getEffectiveEvent() as IRoomEvent | undefined; + if (raw === undefined) return; + this.call.feedStateUpdate(raw).catch((e) => { + console.error('Error sending state update to widget: ', e); + }); + } + private async onToDeviceEvent(ev: MatrixEvent): Promise { await this.mx.decryptEventIfNeeded(ev); if (ev.isDecryptionFailure()) return; @@ -364,7 +766,7 @@ export class CallEmbed { return true; } - // We can't say for sure whether the widget has seen the event; let's + // We can't say for sure whether the widget has seen the event // just assume that it has return false; } @@ -411,7 +813,10 @@ export class CallEmbed { this.call.feedEvent(raw as IRoomEvent).catch((e) => { console.error('Error sending event to widget: ', e); }); + this.feedStateUpdateForTimelineEvent(ev); } + } else if (ev.isState()) { + this.feedStateUpdateForTimelineEvent(ev); } } diff --git a/src/app/plugins/call/CallWidgetDriver.ts b/src/app/plugins/call/CallWidgetDriver.ts index 2678bf583..99f89b15d 100644 --- a/src/app/plugins/call/CallWidgetDriver.ts +++ b/src/app/plugins/call/CallWidgetDriver.ts @@ -52,7 +52,17 @@ export class CallWidgetDriver extends WidgetDriver { } public async validateCapabilities(requested: Set): Promise> { - const allow = Array.from(requested).filter((cap) => this.allowedCapabilities.has(cap)); + const requestedArray = Array.from(requested); + const allow = requestedArray.filter((cap) => this.allowedCapabilities.has(cap)); + const denied = requestedArray.filter((cap) => !this.allowedCapabilities.has(cap)); + + if (denied.length > 0) { + debugLog.warn('call', 'Call widget requested unsupported capabilities', { + roomId: this.inRoomId, + deniedCapabilities: denied, + }); + } + return new Set(allow); } diff --git a/src/app/plugins/call/callEmbedError.ts b/src/app/plugins/call/callEmbedError.ts new file mode 100644 index 000000000..8c6f6ceca --- /dev/null +++ b/src/app/plugins/call/callEmbedError.ts @@ -0,0 +1,28 @@ +export type CallEmbedStartErrorKind = 'capability' | 'preparing'; + +export type CallEmbedStartError = { + kind: CallEmbedStartErrorKind; + message: string; +}; + +const defaultPreparingMessage = 'Could not prepare the call embed.'; +const capabilityMessage = 'Call start was blocked by capability negotiation.'; + +export const toCallEmbedStartError = (error: unknown): CallEmbedStartError => { + const rawMessage = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : error == null + ? '' + : JSON.stringify(error); + const normalized = rawMessage.toLowerCase(); + const looksLikeCapabilityError = + normalized.includes('capabilit') || normalized.includes('org.matrix.msc'); + + return { + kind: looksLikeCapabilityError ? 'capability' : 'preparing', + message: rawMessage || (looksLikeCapabilityError ? capabilityMessage : defaultPreparingMessage), + }; +}; diff --git a/src/app/plugins/call/elementCallDomAdapter.test.ts b/src/app/plugins/call/elementCallDomAdapter.test.ts new file mode 100644 index 000000000..be062606c --- /dev/null +++ b/src/app/plugins/call/elementCallDomAdapter.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../../utils/debugLogger', () => ({ + createDebugLogger: () => ({ + info: vi.fn<(...args: unknown[]) => void>(), + warn: vi.fn<(...args: unknown[]) => void>(), + error: vi.fn<(...args: unknown[]) => void>(), + debug: vi.fn<(...args: unknown[]) => void>(), + }), +})); +import { getScreenshareButton, isElementToggledOn } from './elementCallDomAdapter'; + +type FakeElement = { + checked?: boolean; + parentElement?: FakeElement; + previousElementSibling?: FakeElement; + getAttribute: (name: string) => string | null; +}; + +const createFakeElement = ( + attrs: Record = {}, + extra: Partial = {} +): FakeElement => ({ + ...extra, + getAttribute: (name: string) => attrs[name] ?? null, +}); + +describe('elementCallDomAdapter', () => { + it('falls back to aria-label selectors when test ids are missing', () => { + const screenshare = createFakeElement(); + const doc = { + querySelector: (selector: string) => { + if (selector === 'button[aria-label*="screen" i]') return screenshare; + return null; + }, + } as Document; + + expect(getScreenshareButton(doc)).toBe(screenshare); + }); + + it('detects toggled state for input, aria and data-kind controls', () => { + const checkbox = createFakeElement({}, { checked: true }); + expect(isElementToggledOn(checkbox as unknown as HTMLElement)).toBe(true); + + const pressedButton = createFakeElement({ 'aria-pressed': 'true' }); + expect(isElementToggledOn(pressedButton as unknown as HTMLElement)).toBe(true); + + const dataKindButton = createFakeElement({ 'data-kind': 'primary' }); + expect(isElementToggledOn(dataKindButton as unknown as HTMLElement)).toBe(true); + }); +}); diff --git a/src/app/plugins/call/elementCallDomAdapter.ts b/src/app/plugins/call/elementCallDomAdapter.ts new file mode 100644 index 000000000..0d4a9c573 --- /dev/null +++ b/src/app/plugins/call/elementCallDomAdapter.ts @@ -0,0 +1,50 @@ +import { createDebugLogger } from '$utils/debugLogger'; + +const debugLog = createDebugLogger('ElementCallDomAdapter'); + +const missingSelectorWarnings = new Set(); + +type SelectorQueryOptions = { + key: string; + selectors: string[]; +}; + +const queryFirst = (doc: Document | undefined, { key, selectors }: SelectorQueryOptions) => { + if (!doc) return undefined; + + for (const selector of selectors) { + const element = doc.querySelector(selector) as HTMLElement | null; + if (element) return element; + } + + if (!missingSelectorWarnings.has(key)) { + missingSelectorWarnings.add(key); + debugLog.warn('call', 'Element Call selector(s) not found', { key, selectors }); + } + + return undefined; +}; + +export const getScreenshareButton = (doc: Document | undefined): HTMLElement | undefined => + queryFirst(doc, { + key: 'screenshare_button', + selectors: ['[data-testid="incall_screenshare"]', 'button[aria-label*="screen" i]'], + }); + +export const isElementToggledOn = (element: HTMLElement | undefined): boolean => { + if (!element) return false; + if ('checked' in element && typeof (element as HTMLInputElement).checked === 'boolean') { + return (element as HTMLInputElement).checked; + } + + const ariaPressed = element.getAttribute('aria-pressed'); + if (ariaPressed !== null) return ariaPressed === 'true'; + + const ariaChecked = element.getAttribute('aria-checked'); + if (ariaChecked !== null) return ariaChecked === 'true'; + + const dataKind = element.getAttribute('data-kind'); + if (dataKind !== null) return dataKind === 'primary'; + + return false; +}; diff --git a/src/app/plugins/call/types.ts b/src/app/plugins/call/types.ts index 4f4fc3817..f6537c43c 100644 --- a/src/app/plugins/call/types.ts +++ b/src/app/plugins/call/types.ts @@ -1,6 +1,8 @@ export enum ElementCallIntent { StartCall = 'start_call', JoinExisting = 'join_existing', + StartCallVoice = 'start_call_voice', + JoinExistingVoice = 'join_existing_voice', StartCallDM = 'start_call_dm', JoinExistingDM = 'join_existing_dm', StartCallDMVoice = 'start_call_dm_voice', @@ -12,6 +14,7 @@ export type ElementCallThemeKind = 'light' | 'dark'; export type ElementMediaStatePayload = { audio_enabled?: boolean; video_enabled?: boolean; + audio_output_enabled?: boolean; }; export type ElementMediaStateDetail = { data?: ElementMediaStatePayload; diff --git a/src/app/plugins/call/utils.test.ts b/src/app/plugins/call/utils.test.ts new file mode 100644 index 000000000..116466b71 --- /dev/null +++ b/src/app/plugins/call/utils.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { EventDirection, MatrixCapabilities, WidgetEventCapability } from 'matrix-widget-api'; +import { getCallCapabilities } from './utils'; + +describe('getCallCapabilities', () => { + const roomId = '!room:example.org'; + const userId = '@alice:example.org'; + const deviceId = 'ALICEDEVICE'; + + it('includes delayed-event capabilities', () => { + const capabilities = getCallCapabilities(roomId, userId, deviceId); + + expect(capabilities.has(MatrixCapabilities.MSC4157SendDelayedEvent)).toBe(true); + expect(capabilities.has(MatrixCapabilities.MSC4157UpdateDelayedEvent)).toBe(true); + }); + + it('includes upload and download media capabilities', () => { + const capabilities = getCallCapabilities(roomId, userId, deviceId); + + expect(capabilities.has(MatrixCapabilities.MSC4039UploadFile)).toBe(true); + expect(capabilities.has(MatrixCapabilities.MSC4039DownloadFile)).toBe(true); + }); + + it('includes call member state send/receive capabilities', () => { + const capabilities = getCallCapabilities(roomId, userId, deviceId); + + expect( + capabilities.has( + WidgetEventCapability.forStateEvent( + EventDirection.Send, + 'org.matrix.msc3401.call.member', + userId + ).raw + ) + ).toBe(true); + expect( + capabilities.has( + WidgetEventCapability.forStateEvent( + EventDirection.Receive, + 'org.matrix.msc3401.call.member' + ).raw + ) + ).toBe(true); + }); + + it('includes rtc notification and decline send/receive capabilities', () => { + const capabilities = getCallCapabilities(roomId, userId, deviceId); + + expect( + capabilities.has( + WidgetEventCapability.forRoomEvent( + EventDirection.Send, + 'org.matrix.msc4075.rtc.notification' + ).raw + ) + ).toBe(true); + expect( + capabilities.has( + WidgetEventCapability.forRoomEvent( + EventDirection.Receive, + 'org.matrix.msc4075.rtc.notification' + ).raw + ) + ).toBe(true); + expect( + capabilities.has( + WidgetEventCapability.forRoomEvent(EventDirection.Send, 'org.matrix.msc4310.rtc.decline') + .raw + ) + ).toBe(true); + expect( + capabilities.has( + WidgetEventCapability.forRoomEvent(EventDirection.Receive, 'org.matrix.msc4310.rtc.decline') + .raw + ) + ).toBe(true); + }); +}); diff --git a/src/app/plugins/call/utils.ts b/src/app/plugins/call/utils.ts index bb63a2652..b61f87aa9 100644 --- a/src/app/plugins/call/utils.ts +++ b/src/app/plugins/call/utils.ts @@ -16,6 +16,8 @@ export function getCallCapabilities( capabilities.add(MatrixCapabilities.Screenshots); capabilities.add(MatrixCapabilities.AlwaysOnScreen); capabilities.add(MatrixCapabilities.MSC3846TurnServers); + capabilities.add(MatrixCapabilities.MSC4039UploadFile); + capabilities.add(MatrixCapabilities.MSC4039DownloadFile); capabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent); capabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent); capabilities.add('moe.sable.thumbnails'); diff --git a/src/app/plugins/pdfjs-dist.ts b/src/app/plugins/pdfjs-dist.ts index d23a7bcce..9a67be484 100644 --- a/src/app/plugins/pdfjs-dist.ts +++ b/src/app/plugins/pdfjs-dist.ts @@ -21,7 +21,7 @@ export const usePdfDocumentLoader = (pdfJS: typeof PdfJsDist | undefined, src: s if (!pdfJS) { throw new Error('PdfJS is not loaded'); } - const doc = await pdfJS.getDocument(src).promise; + const doc = await pdfJS.getDocument({ url: src }).promise; return doc; }, [pdfJS, src]) ); diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index f0fb2116b..cd5c9a343 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -96,6 +96,9 @@ const stripIncomingStyle = ( return props; }; +const attrString = (value: unknown): string | undefined => + typeof value === 'string' ? value : undefined; + const ensureNoopenerRel = (rel: unknown): string => { if (typeof rel !== 'string') return 'noopener'; @@ -855,18 +858,21 @@ export const getReactCustomHtmlParser = ( // Guard: img without a src survives sanitisation (fix for crash #1731) // but we can't convert it  Eskip rendering rather than passing // undefined into mxcUrlToHttp where it would throw. - if (!props.src) return null; + const src = attrString(props.src); + if (!src) return null; - const htmlSrc = mxcUrlToHttp(mx, props.src, params.useAuthentication) ?? undefined; - const fallbackLabel = props.alt || props.title || '[media]'; - const failedToResolveMxc = props.src.startsWith('mxc://') && !htmlSrc; + const alt = attrString(props.alt); + const title = attrString(props.title); + const htmlSrc = mxcUrlToHttp(mx, src, params.useAuthentication) ?? undefined; + const fallbackLabel = alt || title || '[media]'; + const failedToResolveMxc = src.startsWith('mxc://') && !htmlSrc; // Non-mxc images were already converted to links by the sanitiser, // but handle the edge case defensively here too. - if (htmlSrc && !props.src.startsWith('mxc://')) { + if (htmlSrc && !src.startsWith('mxc://')) { return ( - {props.alt || props.title || htmlSrc} + {alt || title || htmlSrc} ); } @@ -875,7 +881,7 @@ export const getReactCustomHtmlParser = ( // When the mxc URL can't be resolved (e.g. federation unavailable), // fall back to rendering the shortcode text so the message stays readable. if (!htmlSrc) { - const label = props.alt || props.title || ''; + const label = alt || title || ''; return ( {label ? `:${label}:` : ''} diff --git a/src/app/state/bookmarks.ts b/src/app/state/bookmarks.ts new file mode 100644 index 000000000..bc9239aad --- /dev/null +++ b/src/app/state/bookmarks.ts @@ -0,0 +1,58 @@ +import { atom, useSetAtom } from 'jotai'; +import type { MatrixClient, MatrixEvent } from 'matrix-js-sdk'; +import { ClientEvent } from 'matrix-js-sdk'; +import { useCallback, useEffect } from 'react'; +import type { BookmarkItemContent } from '$types/matrix-sdk-events'; +import { listBookmarks } from '$features/bookmarks/bookmarkRepository'; +import { MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT } from '$unstable/prefixes'; + +export const bookmarkListAtom = atom([]); +export const bookmarkLoadingAtom = atom(false); +export const bookmarkRefreshErrorAtom = atom(undefined); + +export const bookmarksAtom = { + list: bookmarkListAtom, + loading: bookmarkLoadingAtom, + refreshError: bookmarkRefreshErrorAtom, +}; + +export const bookmarkIdSetAtom = atom>((get) => { + const list = get(bookmarkListAtom); + return new Set(list.map((b) => b.bookmark_id)); +}); + +export const useBindBookmarksAtom = (mx: MatrixClient, bookmarks: typeof bookmarksAtom) => { + const setList = useSetAtom(bookmarks.list); + const setLoading = useSetAtom(bookmarks.loading); + const setRefreshError = useSetAtom(bookmarks.refreshError); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const items = await listBookmarks(mx); + setList(items); + setRefreshError(undefined); + } catch (error) { + setRefreshError(error as Error); + } finally { + setLoading(false); + } + }, [mx, setList, setLoading, setRefreshError]); + + useEffect(() => { + refresh(); + }, [refresh]); + + useEffect(() => { + const handleAccountData = (event: MatrixEvent) => { + if (event.getType() === MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT) { + refresh(); + } + }; + + mx.on(ClientEvent.AccountData, handleAccountData); + return () => { + mx.removeListener(ClientEvent.AccountData, handleAccountData); + }; + }, [mx, refresh]); +}; diff --git a/src/app/state/callEmbed.test.ts b/src/app/state/callEmbed.test.ts new file mode 100644 index 000000000..35e37d76a --- /dev/null +++ b/src/app/state/callEmbed.test.ts @@ -0,0 +1,45 @@ +import { createStore } from 'jotai'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { callEmbedAtom, callEmbedStartErrorAtom } from './callEmbed'; + +const distributionMock = vi.fn<(...args: unknown[]) => void>(); + +vi.mock('@sentry/react', () => ({ + metrics: { + distribution: (...args: unknown[]) => distributionMock(...args), + }, +})); + +describe('callEmbedAtom', () => { + beforeEach(() => { + distributionMock.mockReset(); + }); + + it('disposes previous embed when replaced', () => { + const store = createStore(); + const disposeA = vi.fn<() => void>(); + const disposeB = vi.fn<() => void>(); + const embedA = { dispose: disposeA } as unknown; + const embedB = { dispose: disposeB } as unknown; + + store.set(callEmbedAtom, embedA as never); + store.set(callEmbedAtom, embedB as never); + + expect(disposeA).toHaveBeenCalledTimes(1); + expect(disposeB).not.toHaveBeenCalled(); + expect(distributionMock).toHaveBeenCalledTimes(1); + }); + + it('clears start error when embed is removed', () => { + const store = createStore(); + const dispose = vi.fn<() => void>(); + const embed = { dispose } as unknown; + + store.set(callEmbedStartErrorAtom, { code: 'prepare_failed', message: 'boom' } as never); + store.set(callEmbedAtom, embed as never); + store.set(callEmbedAtom, undefined); + + expect(dispose).toHaveBeenCalledTimes(1); + expect(store.get(callEmbedStartErrorAtom)).toBeNull(); + }); +}); diff --git a/src/app/state/callEmbed.ts b/src/app/state/callEmbed.ts index 84bc0748f..0dbe3dd69 100644 --- a/src/app/state/callEmbed.ts +++ b/src/app/state/callEmbed.ts @@ -1,8 +1,10 @@ import { atom } from 'jotai'; import * as Sentry from '@sentry/react'; import type { CallEmbed } from '../plugins/call'; +import type { CallEmbedStartError } from '$plugins/call/callEmbedError'; const baseCallEmbedAtom = atom(undefined); +const baseCallEmbedStartErrorAtom = atom(null); // Tracks when the active call embed was created, for lifetime measurement. let embedCreatedAt: number | null = null; @@ -29,12 +31,50 @@ export const callEmbedAtom = atom( + (get) => get(baseCallEmbedStartErrorAtom), + (_get, set, nextError) => { + set(baseCallEmbedStartErrorAtom, nextError); + } +); + export const callChatAtom = atom(false); -export const incomingCallRoomIdAtom = atom(null); -export const autoJoinCallIntentAtom = atom(null); +export type IncomingCallNotificationType = 'ring' | 'notification'; +export type IncomingCallIntentKind = 'audio' | 'video'; + +export type IncomingCall = { + roomId: string; + notificationEventId: string; + refEventId: string; + senderId: string; + senderTs: number; + expiresAt: number; + notificationType: IncomingCallNotificationType; + intentKind: IncomingCallIntentKind; + intentRaw?: string; + isDirect: boolean; +}; + +export type AutoJoinCallIntent = { + roomId: string; + video: boolean; +}; + +export const incomingCallAtom = atom(null); +export const incomingCallRoomIdAtom = atom((get) => get(incomingCallAtom)?.roomId ?? null); +export const autoJoinCallIntentAtom = atom(null); export const mutedCallRoomIdAtom = atom(null); +export const callSoundBlockedAtom = atom(false); diff --git a/src/app/state/hooks/useBindAtoms.ts b/src/app/state/hooks/useBindAtoms.ts index 6f6392dc9..58e6390d9 100644 --- a/src/app/state/hooks/useBindAtoms.ts +++ b/src/app/state/hooks/useBindAtoms.ts @@ -2,12 +2,14 @@ import type { MatrixClient } from '$types/matrix-sdk'; import { allInvitesAtom, useBindAllInvitesAtom } from '$state/room-list/inviteList'; import { allRoomsAtom, useBindAllRoomsAtom } from '$state/room-list/roomList'; import { mDirectAtom, useBindMDirectAtom } from '$state/mDirectList'; +import { bookmarksAtom, useBindBookmarksAtom } from '$state/bookmarks'; import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '$state/room/roomToUnread'; import { roomToParentsAtom, useBindRoomToParentsAtom } from '$state/room/roomToParents'; import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '$state/typingMembers'; export const useBindAtoms = (mx: MatrixClient) => { useBindMDirectAtom(mx, mDirectAtom); + useBindBookmarksAtom(mx, bookmarksAtom); useBindAllInvitesAtom(mx, allInvitesAtom); useBindAllRoomsAtom(mx, allRoomsAtom); useBindRoomToParentsAtom(mx, roomToParentsAtom); diff --git a/src/app/state/settings.defaults.test.ts b/src/app/state/settings.defaults.test.ts index 74b6aebf9..2b798084d 100644 --- a/src/app/state/settings.defaults.test.ts +++ b/src/app/state/settings.defaults.test.ts @@ -31,6 +31,54 @@ describe('mergePersistedSettings', () => { const merged = mergePersistedSettings(localStorage.getItem('settings'), {}); expect(merged.saturationLevel).toBe(0); }); + + it('migrates persisted ringtone preferences to valid values', () => { + localStorage.setItem( + 'settings', + JSON.stringify({ + callRingtoneVolume: 140.2, + callRingtoneId: 'invalid-tone', + callRingbackTone: 'nope', + }) + ); + const merged = mergePersistedSettings(localStorage.getItem('settings'), {}); + expect(merged.callRingtoneVolume).toBe(100); + expect(merged.callRingtoneId).toBe(defaultSettings.callRingtoneId); + expect(merged.callRingbackTone).toBe(defaultSettings.callRingbackTone); + }); + + it('migrates legacy ringback presets to new ringback ids', () => { + localStorage.setItem( + 'settings', + JSON.stringify({ + callRingtoneId: 'minimal-ping', + callRingbackTone: 'same-as-ringtone', + }) + ); + const mergedSame = mergePersistedSettings(localStorage.getItem('settings'), {}); + expect(mergedSame.callRingbackTone).toBe('minimal-ping'); + + localStorage.setItem('settings', JSON.stringify({ callRingbackTone: 'default-ringback' })); + const mergedDefault = mergePersistedSettings(localStorage.getItem('settings'), {}); + expect(mergedDefault.callRingbackTone).toBe('classic-soft'); + }); + + it('ignores legacy custom tone metadata keys during migration', () => { + localStorage.setItem( + 'settings', + JSON.stringify({ + callCustomRingtoneName: 'tone.ogg', + callCustomRingtoneSizeBytes: -5, + callCustomRingtoneDurationMs: Number.NaN, + callCustomRingbackName: 'ringback.ogg', + callCustomRingbackSizeBytes: -7, + callCustomRingbackDurationMs: Number.NaN, + }) + ); + const merged = mergePersistedSettings(localStorage.getItem('settings'), {}); + expect(merged).not.toHaveProperty('callCustomRingtoneName'); + expect(merged).not.toHaveProperty('callCustomRingbackName'); + }); }); describe('sanitizeSettingsDefaults', () => { @@ -65,6 +113,27 @@ describe('sanitizeSettingsDefaults', () => { expect(sanitizeSettingsDefaults({ rightSwipeAction: 'nope' })).toEqual({}); }); + it('sanitizes ringtone settings defaults', () => { + expect( + sanitizeSettingsDefaults({ + callRingtoneId: 'classic-soft', + callRingbackTone: 'minimal-ping', + callRingtoneVolume: 73.7, + }) + ).toEqual({ + callRingtoneId: 'classic-soft', + callRingbackTone: 'minimal-ping', + callRingtoneVolume: 74, + }); + expect( + sanitizeSettingsDefaults({ + callRingtoneId: 'bad', + callRingbackTone: 'bad', + callRingtoneVolume: Number.NaN, + }) + ).toEqual({}); + }); + it('accepts icon base size px values from 0 upward', () => { expect( sanitizeSettingsDefaults({ diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index d1294b49d..88ee2ed0d 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -36,6 +36,14 @@ export type PerRoomShowRoomIcon = { }; export type JumboEmojiSize = 'none' | 'extraSmall' | 'small' | 'normal' | 'large' | 'extraLarge'; +export const CALL_TONE_IDS = [ + 'sable-default', + 'classic-soft', + 'minimal-ping', + 'silent', + 'custom', +] as const; +export type CallRingtoneId = (typeof CALL_TONE_IDS)[number]; export type ThemeRemoteFavorite = { fullUrl: string; @@ -191,6 +199,13 @@ export interface Settings { subspaceHierarchyLimit: number; alwaysShowCallButton: boolean; joinCallOnSingleClick: boolean; + incomingCallSoundEnabled: boolean; + incomingVoiceRoomCallSoundEnabled: boolean; + outgoingRingbackEnabled: boolean; + callRingtoneVolume: number; + callRingtoneId: CallRingtoneId; + callRingbackTone: CallRingtoneId; + callSoundOverrideGlobalNotifications: boolean; faviconForMentionsOnly: boolean; highlightMentions: boolean; pkCompat: boolean; @@ -345,6 +360,13 @@ export const defaultSettings: Settings = { subspaceHierarchyLimit: 3, alwaysShowCallButton: false, joinCallOnSingleClick: false, + incomingCallSoundEnabled: true, + incomingVoiceRoomCallSoundEnabled: false, + outgoingRingbackEnabled: true, + callRingtoneVolume: 80, + callRingtoneId: 'sable-default', + callRingbackTone: 'sable-default', + callSoundOverrideGlobalNotifications: false, faviconForMentionsOnly: false, highlightMentions: true, pkCompat: false, @@ -396,6 +418,12 @@ function cloneDefaultSettings(): Settings { }; } +const CALL_TONE_ID_SET = new Set(CALL_TONE_IDS); + +const isCallToneId = (value: unknown): value is CallRingtoneId => CALL_TONE_ID_SET.has(value); + +const clampPercent = (value: number): number => Math.max(0, Math.min(100, Math.round(value))); + function migrateParsedLocalStorage(parsed: Record): void { if (parsed.monochromeMode === true && parsed.saturationLevel === undefined) { parsed.saturationLevel = 0; @@ -433,6 +461,37 @@ function migrateParsedLocalStorage(parsed: Record): void { } delete parsed.themeChatPreviewAnyUrl; delete parsed.themeChatPreviewApprovedCatalogOnly; + + if (typeof parsed.callRingtoneVolume === 'number' && Number.isFinite(parsed.callRingtoneVolume)) { + parsed.callRingtoneVolume = clampPercent(parsed.callRingtoneVolume); + } + + if (!isCallToneId(parsed.callRingtoneId)) { + delete parsed.callRingtoneId; + } + + if (parsed.callRingbackTone === 'same-as-ringtone') { + parsed.callRingbackTone = parsed.callRingtoneId ?? defaultSettings.callRingtoneId; + } else if (parsed.callRingbackTone === 'default-ringback') { + parsed.callRingbackTone = 'classic-soft'; + } + + if (!isCallToneId(parsed.callRingbackTone)) { + delete parsed.callRingbackTone; + } + + const legacyCallCustomMetadataKeys = [ + 'callCustomRingtoneName', + 'callCustomRingtoneSizeBytes', + 'callCustomRingtoneDurationMs', + 'callCustomRingbackName', + 'callCustomRingbackSizeBytes', + 'callCustomRingbackDurationMs', + ] as const; + + for (const key of legacyCallCustomMetadataKeys) { + delete parsed[key]; + } } export function mergePersistedSettings( @@ -548,6 +607,12 @@ function sanitizeSettingsKey(key: keyof Settings, val: unknown): unknown { : undefined; case 'rightSwipeAction': return val === RightSwipeAction.Members || val === RightSwipeAction.Reply ? val : undefined; + case 'callRingtoneId': + case 'callRingbackTone': + return isCallToneId(val) ? val : undefined; + case 'callRingtoneVolume': + if (typeof val !== 'number' || !Number.isFinite(val)) return undefined; + return clampPercent(val); case 'renderUserCards': return val === 'both' || val === 'light' || val === 'dark' || val === 'none' ? val diff --git a/src/app/utils/debugLogger.ts b/src/app/utils/debugLogger.ts index 88f787328..6347df784 100644 --- a/src/app/utils/debugLogger.ts +++ b/src/app/utils/debugLogger.ts @@ -6,6 +6,7 @@ */ import * as Sentry from '@sentry/react'; +import { sanitizeSentryPayload } from './sentryScrubbers'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; @@ -160,13 +161,18 @@ class DebugLoggerService { }; const sentryLevel: Sentry.SeverityLevel = sentryLevelMap[entry.level] ?? 'error'; + const sanitizedData = entry.data !== undefined ? sanitizeSentryPayload(entry.data) : undefined; + // Add breadcrumb for all logs (helps with debugging in Sentry), unless category is disabled if (!this.disabledBreadcrumbCategories.has(entry.category)) Sentry.addBreadcrumb({ category: `${entry.category}.${entry.namespace}`, message: entry.message, level: sentryLevel, - data: entry.data ? { data: entry.data } : undefined, + data: + sanitizedData !== undefined && sanitizedData !== null + ? { data: sanitizedData } + : undefined, timestamp: entry.timestamp / 1000, // Sentry expects seconds }); @@ -174,8 +180,8 @@ class DebugLoggerService { const logMsg = `[${entry.category}:${entry.namespace}] ${entry.message}`; // Flatten primitive values from entry.data so they become searchable attributes in Sentry Logs const logDataAttrs: Record = {}; - if (entry.data && typeof entry.data === 'object' && !(entry.data instanceof Error)) { - Object.entries(entry.data).forEach(([k, v]) => { + if (sanitizedData && typeof sanitizedData === 'object' && !(sanitizedData instanceof Error)) { + Object.entries(sanitizedData as Record).forEach(([k, v]) => { if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') { logDataAttrs[k] = v; } @@ -226,7 +232,7 @@ class DebugLoggerService { }, contexts: { debugLog: { - data: entry.data, + data: sanitizedData, timestamp: new Date(entry.timestamp).toISOString(), }, }, @@ -243,7 +249,7 @@ class DebugLoggerService { }, contexts: { debugLog: { - data: entry.data, + data: sanitizedData, timestamp: new Date(entry.timestamp).toISOString(), }, }, diff --git a/src/app/utils/rtc.ts b/src/app/utils/rtc.ts new file mode 100644 index 000000000..f0f3b6bcc --- /dev/null +++ b/src/app/utils/rtc.ts @@ -0,0 +1,10 @@ +export const webRTCSupported = (): boolean => { + if (typeof window === 'undefined') return false; + + return ( + 'RTCPeerConnection' in window || + 'webkitRTCPeerConnection' in window || + 'mozRTCPeerConnection' in window || + 'RTCIceGatherer' in window + ); +}; diff --git a/src/app/utils/sentryScrubbers.test.ts b/src/app/utils/sentryScrubbers.test.ts index 9518705c9..f16884be6 100644 --- a/src/app/utils/sentryScrubbers.test.ts +++ b/src/app/utils/sentryScrubbers.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { scrubMatrixIds, scrubDataObject, scrubMatrixUrl } from './sentryScrubbers'; +import { + omitSentryIdentifierFields, + scrubMatrixIds, + scrubDataObject, + sanitizeSentryPayload, + scrubMatrixUrl, +} from './sentryScrubbers'; // ─── scrubMatrixIds ─────────────────────────────────────────────────────────── @@ -243,3 +249,32 @@ describe('scrubMatrixUrl – safe inputs', () => { expect(scrubMatrixUrl('')).toBe(''); }); }); + +describe('omitSentryIdentifierFields', () => { + it('removes identifier keys from call telemetry payloads', () => { + expect( + omitSentryIdentifierFields({ + roomId: '!room:example.org', + notificationEventId: '$notif', + notificationType: 'ring', + dm: true, + }) + ).toEqual({ + notificationType: 'ring', + dm: true, + }); + }); +}); + +describe('sanitizeSentryPayload', () => { + it('drops identifier keys and redacts remaining Matrix IDs', () => { + expect( + sanitizeSentryPayload({ + roomId: '!room:example.org', + message: 'from @alice:example.org', + }) + ).toEqual({ + message: 'from @[USER_ID]', + }); + }); +}); diff --git a/src/app/utils/sentryScrubbers.ts b/src/app/utils/sentryScrubbers.ts index 1ace61f44..cc75fe369 100644 --- a/src/app/utils/sentryScrubbers.ts +++ b/src/app/utils/sentryScrubbers.ts @@ -38,6 +38,43 @@ export function scrubDataObject(data: unknown): unknown { return data; } +/** Structured fields that should never be sent to Sentry, even redacted. */ +export const SENTRY_IDENTIFIER_KEYS = new Set([ + 'roomId', + 'notificationEventId', + 'refEventId', + 'senderId', + 'declineEventId', + 'eventId', + 'userId', + 'targetEventId', + 'deviceId', + 'activeNotificationId', + 'activeRefEventId', + 'callerId', + 'recipientId', +]); + +/** Drop identifier-bearing keys before values are scrubbed for Sentry export. */ +export function omitSentryIdentifierFields(data: unknown): unknown { + if (Array.isArray(data)) { + return data.map(omitSentryIdentifierFields); + } + if (data !== null && typeof data === 'object') { + return Object.fromEntries( + Object.entries(data as Record) + .filter(([key]) => !SENTRY_IDENTIFIER_KEYS.has(key)) + .map(([key, value]) => [key, omitSentryIdentifierFields(value)]) + ); + } + return data; +} + +/** Full sanitization pass for Sentry breadcrumbs, logs, and contexts. */ +export function sanitizeSentryPayload(data: unknown): unknown { + return scrubDataObject(omitSentryIdentifierFields(data)); +} + /** * Scrub Matrix-specific identifiers from URLs that appear in Sentry spans, breadcrumbs, * transaction names, and page URLs. Covers both Matrix API paths and client-side app routes. diff --git a/src/app/utils/settingsSync.test.ts b/src/app/utils/settingsSync.test.ts index 608a94343..499a73a33 100644 --- a/src/app/utils/settingsSync.test.ts +++ b/src/app/utils/settingsSync.test.ts @@ -31,6 +31,12 @@ describe('NON_SYNCABLE_KEYS', () => { 'isPeopleDrawer', 'isWidgetDrawer', 'memberSortFilterIndex', + 'incomingCallSoundEnabled', + 'outgoingRingbackEnabled', + 'callRingtoneVolume', + 'callRingtoneId', + 'callRingbackTone', + 'callSoundOverrideGlobalNotifications', 'developerTools', 'settingsSyncEnabled', ] as const; @@ -138,6 +144,7 @@ describe('deserializeFromSync', () => { settings: { pageZoom: 200, isPeopleDrawer: false, + callRingtoneVolume: 20, settingsSyncEnabled: true, developerTools: true, }, @@ -146,12 +153,14 @@ describe('deserializeFromSync', () => { ...base, pageZoom: 100, isPeopleDrawer: true, + callRingtoneVolume: 80, settingsSyncEnabled: false, }; const result = deserializeFromSync(remote, local); expect(result).not.toBeNull(); expect(result!.pageZoom).toBe(100); expect(result!.isPeopleDrawer).toBe(true); + expect(result!.callRingtoneVolume).toBe(80); expect(result!.settingsSyncEnabled).toBe(false); expect(result!.developerTools).toBe(false); }); diff --git a/src/app/utils/settingsSync.ts b/src/app/utils/settingsSync.ts index 83c8ff11f..bd565356b 100644 --- a/src/app/utils/settingsSync.ts +++ b/src/app/utils/settingsSync.ts @@ -14,6 +14,13 @@ export const NON_SYNCABLE_KEYS = new Set([ 'isPeopleDrawer', 'isWidgetDrawer', 'memberSortFilterIndex', + // Call audio is device-local (speaker setup + custom files in IndexedDB) + 'incomingCallSoundEnabled', + 'outgoingRingbackEnabled', + 'callRingtoneVolume', + 'callRingtoneId', + 'callRingbackTone', + 'callSoundOverrideGlobalNotifications', // Developer / diagnostic 'developerTools', // Sync toggle itself must never be uploaded (it's device-local) diff --git a/src/app/utils/user-agent.ts b/src/app/utils/user-agent.ts index e1a2e0a61..504198fed 100644 --- a/src/app/utils/user-agent.ts +++ b/src/app/utils/user-agent.ts @@ -15,7 +15,7 @@ const normalizeMacName = (os?: string) => { return os; }; -const isMac = result.os.name === 'Mac OS'; +const isMac = result.os.name === 'Mac OS' || result.os.name === 'macOS'; export const ua = () => result; export const isMacOS = () => isMac; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 9e0496ee3..26b72e8f0 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -1,12 +1,10 @@ -import type { CryptoCallbacks, MatrixClient, ISyncStateData } from '$types/matrix-sdk'; -import { - ClientEvent, - createClient, - Filter, - IndexedDBStore, - IndexedDBCryptoStore, - SyncState, +import type { + CryptoCallbacks, + MatrixClient, + MSC3575SlidingSyncRequest, + MSC3575SlidingSyncResponse, } from '$types/matrix-sdk'; +import { createClient, IndexedDBStore, IndexedDBCryptoStore } from '$types/matrix-sdk'; import { clearNavToActivePathStore } from '$state/navToActivePath'; import type { Session, Sessions, SessionStoreName } from '$state/sessions'; @@ -19,91 +17,83 @@ import { pushSessionToSW } from '../sw-session'; import { cryptoCallbacks } from './secretStorageKeys'; import type { SlidingSyncConfig, SlidingSyncDiagnostics } from './slidingSync'; import { SlidingSyncManager } from './slidingSync'; +import { PresenceSyncManager } from './presenceSync'; const log = createLogger('initMatrix'); const debugLog = createDebugLogger('initMatrix'); const slidingSyncByClient = new WeakMap(); -const classicSyncObserverByClient = new WeakMap< - MatrixClient, - (state: SyncState, prevState: SyncState | null, data?: ISyncStateData) => void ->(); -const FAST_SYNC_POLL_TIMEOUT_MS = 30_000; +const presenceSyncByClient = new WeakMap(); const SLIDING_SYNC_POLL_TIMEOUT_MS = 20000; -type SyncTransport = 'classic' | 'sliding'; -type SyncTransportReason = - | 'sliding_active' - | 'sliding_disabled_server' - | 'session_opt_out' - | 'missing_proxy' - | 'cold_cache_bootstrap' - | 'probe_failed_fallback' - | 'unknown'; -type SyncTransportMeta = { - transport: SyncTransport; - slidingConfigured: boolean; - slidingEnabledOnServer: boolean; - sessionOptIn: boolean; - slidingRequested: boolean; - fallbackFromSliding: boolean; - reason: SyncTransportReason; -}; -const syncTransportByClient = new WeakMap(); -const fetchRoomEventStartupCleanupByClient = new WeakMap void>(); -const COLD_CACHE_BOOTSTRAP_TIMEOUT_MS = 20000; type FetchRoomEventResult = Awaited>; type MatrixClientWithWritableFetchRoomEvent = MatrixClient & { fetchRoomEvent: (roomId: string, eventId: string) => Promise; }; -type StartupFetchRoomEventPatchOptions = { - stubOnCacheMiss: boolean; +const fetchRoomEventStartupCleanupByClient = new WeakMap void>(); + +const slidingSyncConnIdCleanupByClient = new WeakMap void>(); + +type SlidingSyncMethod = ( + reqBody: MSC3575SlidingSyncRequest, + proxyBaseUrl?: string, + abortSignal?: AbortSignal +) => Promise; + +type MatrixClientWithWritableSlidingSync = MatrixClient & { + slidingSync: SlidingSyncMethod; }; +type SlidingSyncRequestWithConnId = MSC3575SlidingSyncRequest & { conn_id?: string }; + +const SLIDING_SYNC_CONN_ID = 'sable-main'; + +function installSlidingSyncConnId(mx: MatrixClient): void { + slidingSyncConnIdCleanupByClient.get(mx)?.(); + + const mxWritable = mx as MatrixClientWithWritableSlidingSync; + const original = mx.slidingSync.bind(mx) as SlidingSyncMethod; + + mxWritable.slidingSync = (reqBody, proxyBaseUrl, abortSignal) => { + const req = reqBody as SlidingSyncRequestWithConnId; + if (req.conn_id === undefined) { + req.conn_id = SLIDING_SYNC_CONN_ID; + } + return original(reqBody, proxyBaseUrl, abortSignal); + }; + + slidingSyncConnIdCleanupByClient.set(mx, () => { + slidingSyncConnIdCleanupByClient.delete(mx); + mxWritable.slidingSync = original; + }); +} + function installStartupFetchRoomEventPatch( mx: MatrixClient, - options: StartupFetchRoomEventPatchOptions + slidingSyncManager: SlidingSyncManager ): void { fetchRoomEventStartupCleanupByClient.get(mx)?.(); - const { stubOnCacheMiss } = options; const mxWritable = mx as MatrixClientWithWritableFetchRoomEvent; const origFetchRoomEvent = mx.fetchRoomEvent.bind(mx); - let restored = false; const restore = () => { - if (restored) return; - restored = true; fetchRoomEventStartupCleanupByClient.delete(mx); - // Put the real fetchRoomEvent back and detach this mxWritable.fetchRoomEvent = origFetchRoomEvent; - mx.off(ClientEvent.Sync, onSync); - }; - - const onSync = (state: SyncState) => { - // Initial sync burst is over, let normal server fetches run again - if (state === SyncState.Prepared || state === SyncState.Syncing) { - restore(); - } }; mxWritable.fetchRoomEvent = (roomId: string, eventId: string) => { - if (restored) return origFetchRoomEvent(roomId, eventId); - const cachedEvent = mx.getRoom(roomId)?.findEventById(eventId); - if (cachedEvent) { - return Promise.resolve(cachedEvent.event); - } - if (stubOnCacheMiss) { - const payload: FetchRoomEventResult = { - event_id: eventId, - room_id: roomId, - }; - return Promise.resolve(payload); + if (slidingSyncManager.isRoomActive(roomId)) { + return origFetchRoomEvent(roomId, eventId); } - return origFetchRoomEvent(roomId, eventId); + const cachedEvent = mx.getRoom(roomId)?.findEventById(eventId); + const payload: FetchRoomEventResult = cachedEvent?.event ?? { + event_id: eventId, + room_id: roomId, + }; + return Promise.resolve(payload); }; - mx.on(ClientEvent.Sync, onSync); fetchRoomEventStartupCleanupByClient.set(mx, restore); } @@ -196,18 +186,6 @@ const readStoredAccount = (dbName: string): Promise => }); }); -const databaseExists = async (dbName: string): Promise => { - try { - const dbs = await window.indexedDB.databases(); - return dbs.some((db) => db.name === dbName); - } catch { - return false; - } -}; - -const isClientReadyForUi = (syncState: string | null): boolean => - syncState === 'PREPARED' || syncState === 'SYNCING' || syncState === 'CATCHUP'; - const isMismatch = (err: unknown): boolean => { const msg = err instanceof Error ? err.message : String(err); return ( @@ -218,57 +196,6 @@ const isMismatch = (err: unknown): boolean => { ); }; -const waitForClientReady = (mx: MatrixClient, timeoutMs: number): Promise => - /* oxlint-disable promise/no-multiple-resolved */ - new Promise((resolve) => { - const waitStart = performance.now(); - let settled = false; - const finish = () => { - if (settled) return; - settled = true; - mx.removeListener(ClientEvent.Sync, onSync); - clearTimeout(timer); - const waitMs = performance.now() - waitStart; - Sentry.metrics.distribution('sable.sync.client_ready_ms', waitMs, { - attributes: { timed_out: String(timedOut) }, - }); - if (timedOut) { - Sentry.addBreadcrumb({ - category: 'sync', - message: 'waitForClientReady timed out — client may be stuck', - level: 'warning', - data: { timeout_ms: timeoutMs }, - }); - } - resolve(); - }; - /* oxlint-enable promise/no-multiple-resolved */ - - if (isClientReadyForUi(mx.getSyncState())) { - Sentry.metrics.distribution('sable.sync.client_ready_ms', 0, { - attributes: { timed_out: 'false' }, - }); - finish(); - return; - } - - let timer = 0; - let timedOut = false; - const onSync = (state: string) => { - debugLog.info('sync', `Sync state changed: ${state}`, { - state, - ready: isClientReadyForUi(state), - }); - if (isClientReadyForUi(state)) finish(); - }; - - timer = window.setTimeout(() => { - timedOut = true; - finish(); - }, timeoutMs); - mx.on(ClientEvent.Sync, onSync); - }); - /** * Pre-flight check: scans every IndexedDB database and deletes any that * belong to a userId not present in the stored sessions list, or whose @@ -445,7 +372,8 @@ export type StartClientConfig = { timelineLimit?: number; }; -export type ClientSyncDiagnostics = SyncTransportMeta & { +export type ClientSyncDiagnostics = { + transport: 'sliding'; syncState: string | null; sliding?: SlidingSyncDiagnostics; }; @@ -457,268 +385,58 @@ const disposeSlidingSync = (mx: MatrixClient): void => { slidingSyncByClient.delete(mx); }; +const disposePresenceSync = (mx: MatrixClient): void => { + const manager = presenceSyncByClient.get(mx); + if (!manager) return; + manager.dispose(); + presenceSyncByClient.delete(mx); +}; + export const getSlidingSyncManager = (mx: MatrixClient): SlidingSyncManager | undefined => slidingSyncByClient.get(mx); +export const getPresenceSyncManager = (mx: MatrixClient): PresenceSyncManager | undefined => + presenceSyncByClient.get(mx); + export const startClient = async (mx: MatrixClient, config?: StartClientConfig): Promise => { debugLog.info('sync', 'Starting Matrix client', { userId: mx.getUserId() }); disposeSlidingSync(mx); + disposePresenceSync(mx); const slidingConfig = config?.slidingSync; - const slidingEnabledOnServer = resolveSlidingEnabled(slidingConfig?.enabled); - const slidingRequested = slidingEnabledOnServer && config?.sessionSlidingSyncOptIn === true; - const proxyBaseUrl = slidingConfig?.proxyBaseUrl ?? config?.baseUrl; - const hasSlidingProxy = typeof proxyBaseUrl === 'string' && proxyBaseUrl.trim().length > 0; - log.log('startClient sliding config', { - userId: mx.getUserId(), - enabled: slidingConfig?.enabled, - enabledOnServer: slidingEnabledOnServer, - sessionOptIn: config?.sessionSlidingSyncOptIn === true, - requestedEnabled: slidingRequested, - proxyBaseUrl, - hasSlidingProxy, - }); - debugLog.info('sync', 'Sliding sync configuration', { - enabledOnServer: slidingEnabledOnServer, - requested: slidingRequested, - hasProxy: hasSlidingProxy, - }); - - const CLASSIC_SYNC_STARTUP_TIMEOUT_MS = 45_000; - - const startClassicSync = async ( - fallbackFromSliding: boolean, - reason: SyncTransportReason - ): Promise => { - syncTransportByClient.set(mx, { - transport: 'classic', - slidingConfigured: slidingEnabledOnServer, - slidingEnabledOnServer, - sessionOptIn: config?.sessionSlidingSyncOptIn === true, - slidingRequested, - fallbackFromSliding, - reason, - }); - Sentry.metrics.count('sable.sync.transport', 1, { - attributes: { - transport: 'classic', - reason, - fallback: String(fallbackFromSliding), - }, - }); - - const startupTimeout = new Promise((resolve) => { - window.setTimeout(() => { - debugLog.warn('sync', 'Classic sync startup timed out', { - userId: mx.getUserId(), - timeoutMs: CLASSIC_SYNC_STARTUP_TIMEOUT_MS, - }); - resolve(); - }, CLASSIC_SYNC_STARTUP_TIMEOUT_MS); - }); - - const effectivePollTimeout = config?.pollTimeoutMs ?? FAST_SYNC_POLL_TIMEOUT_MS; - const effectiveTimelineLimit = config?.timelineLimit ?? 10; - - const classicFilter = new Filter(mx.getUserId() ?? undefined); - classicFilter.setTimelineLimit(effectiveTimelineLimit); - // Ensure lazy loading stays on (carried by buildDefaultFilter but explicit here - // since we replace the filter entirely rather than merging). - const filterDefinition = classicFilter.getDefinition(); - if (filterDefinition.room) { - filterDefinition.room.timeline = filterDefinition.room.timeline ?? {}; - (filterDefinition.room.timeline as { lazy_load_members?: boolean }).lazy_load_members = true; - } + const proxyBaseUrl = slidingConfig?.proxyBaseUrl ?? config?.baseUrl ?? mx.baseUrl; - installStartupFetchRoomEventPatch(mx, { stubOnCacheMiss: true }); - - let syncStarted: Promise; - try { - syncStarted = mx.startClient({ - lazyLoadMembers: true, - pollTimeout: effectivePollTimeout, - threadSupport: true, - filter: classicFilter, - }); - } catch (syncErr) { - fetchRoomEventStartupCleanupByClient.get(mx)?.(); - throw syncErr; - } - - await Promise.race([syncStarted, startupTimeout]); - // Attach an ongoing classic-sync observer — equivalent to SlidingSyncManager's - // onLifecycle listener. Tracks state transitions, initial-sync timing, and errors. - let classicSyncCount = 0; - const classicSyncStartMs = performance.now(); - let classicInitialSyncDone = false; - const classicSyncListener = ( - state: SyncState, - prevState: SyncState | null, - data?: ISyncStateData - ) => { - classicSyncCount += 1; - Sentry.metrics.count('sable.sync.cycle', 1, { - attributes: { transport: 'classic', state }, - }); - debugLog.info('sync', `Classic sync state: ${state}`, { - state, - prevState: prevState ?? 'null', - syncNumber: classicSyncCount, - error: data?.error?.message, - }); - if (state === SyncState.Error || state === SyncState.Reconnecting) { - debugLog.warn('sync', `Classic sync problem: ${state}`, { - state, - prevState: prevState ?? 'null', - errorMessage: data?.error?.message, - syncNumber: classicSyncCount, - }); - Sentry.metrics.count('sable.sync.error', 1, { - attributes: { transport: 'classic', state }, - }); - Sentry.addBreadcrumb({ - category: 'sync.classic', - message: `Classic sync problem: ${state}`, - level: 'warning', - data: { - state, - prevState, - error: data?.error?.message, - syncNumber: classicSyncCount, - }, - }); - } - if ( - !classicInitialSyncDone && - (state === SyncState.Syncing || state === SyncState.Prepared) - ) { - classicInitialSyncDone = true; - const elapsed = performance.now() - classicSyncStartMs; - debugLog.info('sync', 'Classic sync initial ready', { - state, - syncNumber: classicSyncCount, - elapsed: `${elapsed.toFixed(0)}ms`, - }); - Sentry.metrics.distribution('sable.sync.initial_ms', elapsed, { - attributes: { transport: 'classic' }, - }); - } - }; - classicSyncObserverByClient.set(mx, classicSyncListener); - mx.on(ClientEvent.Sync, classicSyncListener); - }; - - const shouldBootstrapClassicOnColdCache = async (): Promise => { - if (slidingConfig?.bootstrapClassicOnColdCache === false) return false; - const userId = mx.getUserId(); - if (!userId) return false; - - const [storeHasAccount, fallbackStoreHasAccount, hasStoreDb, hasFallbackStoreDb] = - await Promise.all([ - readStoredAccount(`sync${userId}`), - readStoredAccount('web-sync-store'), - databaseExists(`sync${userId}`), - databaseExists('web-sync-store'), - ]); - - const hasWarmCache = - storeHasAccount === userId || - fallbackStoreHasAccount === userId || - hasStoreDb || - hasFallbackStoreDb; - - return !hasWarmCache; - }; - - if (!slidingEnabledOnServer || !slidingRequested) { - await startClassicSync( - false, - slidingEnabledOnServer ? 'session_opt_out' : 'sliding_disabled_server' - ); - return; - } - - if (!hasSlidingProxy) { - await startClassicSync(false, 'missing_proxy'); - return; - } - - if (await shouldBootstrapClassicOnColdCache()) { - log.log('startClient cold-cache bootstrap: using classic sync for this run', mx.getUserId()); - await startClassicSync(false, 'cold_cache_bootstrap'); - waitForClientReady(mx, COLD_CACHE_BOOTSTRAP_TIMEOUT_MS).catch((err) => { - debugLog.warn('network', 'Cold cache bootstrap timed out', { - userId: mx.getUserId(), - timeout: `${COLD_CACHE_BOOTSTRAP_TIMEOUT_MS}ms`, - error: err instanceof Error ? err.message : String(err), - }); - }); - return; - } - - const resolvedProxyBaseUrl = proxyBaseUrl; - const probeTimeoutMs = (() => { - const v = slidingConfig?.probeTimeoutMs; - return typeof v === 'number' && !Number.isNaN(v) && v > 0 ? Math.round(v) : 5000; - })(); - const supported = await SlidingSyncManager.probe(mx, resolvedProxyBaseUrl, probeTimeoutMs); - log.log('startClient sliding probe result', { - userId: mx.getUserId(), - requestedEnabled: slidingRequested, - hasSlidingProxy, - proxyBaseUrl: resolvedProxyBaseUrl, - supported, - }); - if (!supported) { - log.warn('Sliding Sync unavailable, falling back to classic sync for', mx.getUserId()); - debugLog.warn('network', 'Sliding Sync probe failed, falling back to classic sync', { - userId: mx.getUserId(), - proxyBaseUrl: resolvedProxyBaseUrl, - probeTimeout: `${probeTimeoutMs}ms`, - }); - await startClassicSync(true, 'probe_failed_fallback'); - return; - } - - const manager = new SlidingSyncManager(mx, resolvedProxyBaseUrl, { + const manager = new SlidingSyncManager(mx, proxyBaseUrl, { ...slidingConfig, includeInviteList: true, pollTimeoutMs: slidingConfig?.pollTimeoutMs ?? SLIDING_SYNC_POLL_TIMEOUT_MS, }); + + const presenceManager = new PresenceSyncManager(mx); + presenceSyncByClient.set(mx, presenceManager); + + presenceManager.start(); + + installStartupFetchRoomEventPatch(mx, manager); + installSlidingSyncConnId(mx); + manager.attach(); slidingSyncByClient.set(mx, manager); - syncTransportByClient.set(mx, { - transport: 'sliding', - slidingConfigured: true, - slidingEnabledOnServer, - sessionOptIn: config?.sessionSlidingSyncOptIn === true, - slidingRequested, - fallbackFromSliding: false, - reason: 'sliding_active', - }); - Sentry.metrics.count('sable.sync.transport', 1, { - attributes: { - transport: 'sliding', - reason: 'sliding_active', - fallback: 'false', - }, - }); try { - installStartupFetchRoomEventPatch(mx, { stubOnCacheMiss: false }); await mx.startClient({ lazyLoadMembers: true, slidingSync: manager.slidingSync, threadSupport: true, }); } catch (err) { - fetchRoomEventStartupCleanupByClient.get(mx)?.(); debugLog.error('network', 'Failed to start client with sliding sync', { error: err instanceof Error ? err.message : String(err), userId: mx.getUserId(), - proxyBaseUrl: resolvedProxyBaseUrl, + proxyBaseUrl: proxyBaseUrl, stack: err instanceof Error ? err.stack : undefined, }); disposeSlidingSync(mx); + disposePresenceSync(mx); throw err; } }; @@ -726,15 +444,10 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): export const stopClient = (mx: MatrixClient): void => { log.log('stopClient', mx.getUserId()); debugLog.info('sync', 'Stopping client', { userId: mx.getUserId() }); - fetchRoomEventStartupCleanupByClient.get(mx)?.(); + slidingSyncConnIdCleanupByClient.get(mx)?.(); disposeSlidingSync(mx); - const classicSyncListener = classicSyncObserverByClient.get(mx); - if (classicSyncListener) { - mx.removeListener(ClientEvent.Sync, classicSyncListener); - classicSyncObserverByClient.delete(mx); - } + disposePresenceSync(mx); mx.stopClient(); - syncTransportByClient.delete(mx); }; export const clearCacheAndReload = async (mx: MatrixClient) => { @@ -746,17 +459,8 @@ export const clearCacheAndReload = async (mx: MatrixClient) => { }; export const getClientSyncDiagnostics = (mx: MatrixClient): ClientSyncDiagnostics => { - const meta = syncTransportByClient.get(mx) ?? { - transport: 'classic', - slidingConfigured: false, - slidingEnabledOnServer: false, - sessionOptIn: false, - slidingRequested: false, - fallbackFromSliding: false, - reason: 'unknown', - }; return { - ...meta, + transport: 'sliding', syncState: mx.getSyncState(), sliding: slidingSyncByClient.get(mx)?.getDiagnostics(), }; diff --git a/src/client/presenceSync.ts b/src/client/presenceSync.ts new file mode 100644 index 000000000..70e528ee9 --- /dev/null +++ b/src/client/presenceSync.ts @@ -0,0 +1,112 @@ +import type { MatrixClient } from '$types/matrix-sdk'; +import { ClientEvent, User, Method, Filter } from '$types/matrix-sdk'; +import { createDebugLogger } from '$utils/debugLogger'; + +const debugLog = createDebugLogger('presenceSync'); + +export class PresenceSyncManager { + private disposed = false; + + private enabled = true; + + private activeRequest: Promise | null = null; + + private syncToken: string | undefined; + + public constructor( + private readonly mx: MatrixClient, + private readonly pollTimeoutMs: number = 30000, + private readonly pollingIntervalMs: number = 20000 + ) { + debugLog.info('sync', 'PresenceSyncManager initialized'); + } + + public setPresenceEnabled(enabled: boolean): void { + if (this.enabled === enabled) return; + this.enabled = enabled; + if (enabled && !this.activeRequest && !this.disposed) { + this.poll(); + } + } + + public start(): void { + if (this.disposed || this.activeRequest) return; + this.poll(); + } + + public dispose(): void { + this.disposed = true; + this.enabled = false; + } + + private async poll(): Promise { + if (!this.enabled || this.disposed) return; + + this.activeRequest = (async () => { + try { + const filter = new Filter(this.mx.getUserId()); + filter.setDefinition({ + room: { rooms: [] }, + account_data: { types: [] }, + presence: { types: ['m.presence'] }, + }); + + const filterId = await this.mx.getOrCreateFilter('presence_only', filter); + + const response = (await this.mx.http.authedRequest(Method.Get, '/sync', { + filter: filterId, + since: this.syncToken, + timeout: this.pollTimeoutMs, + set_presence: this.enabled ? 'online' : 'offline', + })) as Record; + + debugLog.info('sync', 'PresenceSync response', { + next_batch: response.next_batch, + presence: response.presence, + }); + + this.syncToken = response.next_batch as string | undefined; + + const presence = response.presence as { events?: unknown[] } | undefined; + + if (presence?.events && Array.isArray(presence.events)) { + const mapper = this.mx.getEventMapper(); + presence.events.forEach((rawEvent: unknown) => { + if ( + typeof rawEvent !== 'object' || + !rawEvent || + (rawEvent as Record).type !== 'm.presence' + ) + return; + const presenceEvent = mapper(rawEvent); + + const userId = + presenceEvent.getSender() ?? + (presenceEvent.getContent().user_id as string | undefined); + if (!userId) return; + + let user = this.mx.store.getUser(userId); + if (user) { + user.setPresenceEvent(presenceEvent); + } else { + user = User.createUser(userId, this.mx); + user.setPresenceEvent(presenceEvent); + this.mx.store.storeUser(user); + } + this.mx.emit(ClientEvent.Event, presenceEvent); + }); + } + } catch (err) { + debugLog.error('sync', 'Error during background presence sync', err); + } + })(); + + await this.activeRequest; + this.activeRequest = null; + + if (this.enabled && !this.disposed) { + // Sleep between polls to ensure it's very lightweight + setTimeout(() => this.poll(), this.pollingIntervalMs); + } + } +} diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index 5e5e18ce1..4f8f5c80f 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -1,18 +1,5 @@ /** - * Unit tests for SlidingSyncManager memory management: - * - * 1. dispose() — must call slidingSync.stop() to halt the polling loop and - * abort in-flight requests. Without this the SDK's Promise loop keeps - * running after the client is "stopped", leaking network traffic and - * event listeners. - * - * 2. onMembershipLeave — when the MatrixClient emits a RoomMemberEvent.Membership - * event indicating the local user left or was banned from a room that is - * actively subscribed, unsubscribeFromRoom() should be called automatically. - * - * Note: navigation between rooms does not call unsubscribeFromRoom — - * subscriptions accumulate across the session so returning to a room is - * instant (matching Element Web's model). + * Unit tests for SlidingSyncManager memory management */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -21,8 +8,7 @@ import type { MatrixClient } from '$types/matrix-sdk'; import { SlidingSyncManager, type SlidingSyncConfig } from './slidingSync'; // ── vi.hoisted mocks ───────────────────────────────────────────────────────── -// Must be defined via vi.hoisted so they're available before vi.mock runs -// (vi.mock calls are hoisted above all imports by vitest's transformer). +// Must be defined via vi.hoisted const mocks = vi.hoisted(() => ({ slidingSyncInstance: { on: vi.fn<() => void>(), @@ -55,8 +41,7 @@ vi.mock('@sentry/react', () => ({ })); // ── SlidingSync SDK mock ───────────────────────────────────────────────────── -// vi.fn() wrappers are arrow functions internally and cannot be called with `new`. -// A plain function constructor (returning an object) is the correct pattern. +// A plain function constructor is the correct pattern vi.mock('$types/matrix-sdk', async (importOriginal) => { const actual = await importOriginal>(); function MockSlidingSync() { diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index cea5b1f78..79bd8ebab 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -1,5 +1,4 @@ import type { - Extension, MatrixClient, MSC3575List, MSC3575RoomData, @@ -7,8 +6,6 @@ import type { MSC3575SlidingSyncResponse, } from '$types/matrix-sdk'; import { - ClientEvent, - ExtensionState, KnownMembership, MSC3575_WILDCARD, RoomMemberEvent, @@ -18,48 +15,28 @@ import { MSC3575_STATE_KEY_LAZY, MSC3575_STATE_KEY_ME, EventType, - User, } from '$types/matrix-sdk'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; +import { CustomStateEvent } from '$types/matrix/room'; import * as Sentry from '@sentry/react'; const log = createLogger('slidingSync'); const debugLog = createDebugLogger('slidingSync'); -interface NetworkInformation { - effectiveType?: string; - downlink?: number; - addEventListener?: (event: string, callback: () => void) => void; - removeEventListener?: (event: string, callback: () => void) => void; - onchange?: (() => void) | null; -} - export const LIST_JOINED = 'joined'; export const LIST_INVITES = 'invites'; -export const LIST_DMS = 'dms'; export const LIST_SEARCH = 'search'; -// Separate key for live room-name filtering; avoids conflicting with the spidering list. export const LIST_ROOM_SEARCH = 'room_search'; -// Dynamic list key used for space-scoped room views. export const LIST_SPACE = 'space'; -// One event of timeline per list room is enough to compute unread counts; -// the full history is loaded when the user opens the room. const LIST_TIMELINE_LIMIT = 1; const DEFAULT_LIST_PAGE_SIZE = 250; const DEFAULT_POLL_TIMEOUT_MS = 20000; const DEFAULT_MAX_ROOMS = 5000; -// Sort order for MSC4186 (Simplified Sliding Sync): most recently active first, -// then alphabetical as a tiebreaker. by_notification_level is MSC3575-only and -// not supported by Synapse's native MSC4186 implementation. const LIST_SORT_ORDER = ['by_recency', 'by_name']; -// Subscription key for the room the user is actively viewing. -// Encrypted rooms get [*,*] required_state; unencrypted rooms also request lazy members. const UNENCRYPTED_SUBSCRIPTION_KEY = 'unencrypted'; -// Timeline limit for the active-room subscription (full history load). -// List entries always use LIST_TIMELINE_LIMIT=1 for lightweight previews. const ACTIVE_ROOM_TIMELINE_LIMIT = 50; export type PartialSlidingSyncRequest = { @@ -98,30 +75,6 @@ const clampPositive = (value: number | undefined, fallback: number): number => { return Math.round(value); }; -// Minimal required_state for list entries; enough to render the room list sidebar, -// compute unread state, and build the space hierarchy without fetching full room history. -// Notes: -// - RoomName/RoomCanonicalAlias are omitted: sliding sync returns the room name as a -// top-level field in every list response, so fetching them as state events is redundant. -// - MSC3575_STATE_KEY_LAZY is omitted: lazy-loading members is only needed when the -// user is actively viewing a room; loading them for every list entry wastes bandwidth. -// - SpaceChild with wildcard is required: the roomToParents atom reads m.space.child -// state events (one per child, keyed by child room ID) to build the space hierarchy. -// Without these events the SDK has no parent→child mapping, so all rooms appear as -// orphans in the Home view and spaces appear empty. -// - im.ponies.room_emotes with wildcard is required: custom emoji/sticker packs are -// stored as im.ponies.room_emotes state events (one per pack, keyed by pack state key). -// getGlobalImagePacks reads these from pack rooms listed in im.ponies.emote_rooms -// account data; imagePackRooms also reads them from parent spaces. Without these -// events all list-entry rooms would show no emoji or sticker packs. -// - m.room.topic is required: topics are displayed for joined child rooms in space -// lobby (RoomItem → LocalRoomSummaryLoader → useLocalRoomSummary) and in the -// invite list. Without this event the topic always shows as blank for non-active -// rooms. -// - m.room.canonical_alias is required: getCanonicalAlias() is used in several places -// for non-active rooms — notification serverName extraction, mention autocomplete -// alias display, and getCanonicalAliasOrRoomId for navigation. Without it, aliases -// fall back silently to room IDs. const buildListRequiredState = (): MSC3575RoomSubscription['required_state'] => [ [EventType.RoomJoinRules, ''], [EventType.RoomAvatar, ''], @@ -132,34 +85,50 @@ const buildListRequiredState = (): MSC3575RoomSubscription['required_state'] => [EventType.RoomCanonicalAlias, ''], [EventType.RoomMember, MSC3575_STATE_KEY_ME], ['m.space.child', MSC3575_WILDCARD], - ['im.ponies.room_emotes', MSC3575_WILDCARD], - ['moe.sable.room.abbreviations', ''], + [EventType.GroupCallMemberPrefix, MSC3575_WILDCARD], + [CustomStateEvent.PoniesRoomEmotes, MSC3575_WILDCARD], + [CustomStateEvent.RoomAbbreviations, ''], + [CustomStateEvent.RoomBanner, ''], +]; + +const ACTIVE_ROOM_REQUIRED_STATE: MSC3575RoomSubscription['required_state'] = [ + [EventType.RoomPowerLevels, ''], + [EventType.RoomName, ''], + [EventType.RoomTopic, ''], + [EventType.RoomAvatar, ''], + [EventType.RoomCanonicalAlias, ''], + [EventType.RoomJoinRules, ''], + [EventType.RoomHistoryVisibility, ''], + [EventType.RoomGuestAccess, ''], + [EventType.RoomEncryption, ''], + [EventType.RoomTombstone, ''], + [EventType.RoomCreate, ''], + [EventType.RoomPinnedEvents, ''], + [EventType.RoomServerAcl, ''], + [EventType.RoomThirdPartyInvite, MSC3575_WILDCARD], + [EventType.RoomMember, MSC3575_STATE_KEY_ME], + [EventType.RoomMember, MSC3575_STATE_KEY_LAZY], + ['m.space.child', MSC3575_WILDCARD], + ['m.space.parent', MSC3575_WILDCARD], + [EventType.GroupCallPrefix, ''], + [EventType.GroupCallMemberPrefix, MSC3575_WILDCARD], + ...Object.values(CustomStateEvent).map((type) => [type, MSC3575_WILDCARD] as [string, string]), ]; -// For an active encrypted room: fetch everything so the client can decrypt all events. const buildEncryptedSubscription = (timelineLimit: number): MSC3575RoomSubscription => ({ timeline_limit: timelineLimit, required_state: [[MSC3575_WILDCARD, MSC3575_WILDCARD]], }); -// For an active unencrypted room: fetch everything, plus explicit lazy+ME members so -// the member list and display names are always available. const buildUnencryptedSubscription = (timelineLimit: number): MSC3575RoomSubscription => ({ timeline_limit: timelineLimit, - required_state: [ - [MSC3575_WILDCARD, MSC3575_WILDCARD], - [EventType.RoomMember, MSC3575_STATE_KEY_ME], - [EventType.RoomMember, MSC3575_STATE_KEY_LAZY], - ], + required_state: ACTIVE_ROOM_REQUIRED_STATE, }); const buildLists = (pageSize: number, includeInviteList: boolean): Map => { const lists = new Map(); const listRequiredState = buildListRequiredState(); - // Start with a reasonable initial range that will quickly expand to full list - // Since timeline_limit=1, loading many rooms is very cheap - // This prevents the white page issue from progressive loading delays const initialRange = Math.min(pageSize, 100); lists.set(LIST_JOINED, { @@ -167,7 +136,6 @@ const buildLists = (pageSize: number, includeInviteList: boolean): Map { return list.ranges.reduce((max, range) => Math.max(max, range[1] ?? -1), -1); }; -// MSC4186 presence extension: requests `extensions.presence` in every sliding sync -// poll and feeds received `m.presence` events into the SDK's User objects so that -// components using `useUserPresence` see live updates (same path as regular /sync). -class ExtensionPresence implements Extension<{ enabled: boolean }, { events?: object[] }> { - private enabled = true; - - public constructor(private readonly mx: MatrixClient) {} - - public setEnabled(value: boolean): void { - this.enabled = value; - } - - public name(): string { - return 'presence'; - } - - public when(): ExtensionState { - // Run after the main response body has been processed so room/member state is ready. - return ExtensionState.PostProcess; - } - - public async onRequest(): Promise<{ enabled: boolean }> { - return { enabled: this.enabled }; - } - - public async onResponse(data: { events?: object[] }): Promise { - if (!data?.events?.length) return; - const mapper = this.mx.getEventMapper(); - data.events.forEach((rawEvent) => { - const event = mapper(rawEvent as Parameters[0]); - const userId = event.getSender() ?? (event.getContent().user_id as string | undefined); - if (!userId) return; - let user = this.mx.store.getUser(userId); - if (user) { - user.setPresenceEvent(event); - } else { - user = User.createUser(userId, this.mx); - user.setPresenceEvent(event); - this.mx.store.storeUser(user); - } - this.mx.emit(ClientEvent.Event, event); - }); - } -} - export class SlidingSyncManager { private disposed = false; @@ -257,17 +170,17 @@ export class SlidingSyncManager { private readonly roomTimelineLimit: number; - private readonly onConnectionChange: () => void; - - private readonly onLifecycle: (state: SlidingSyncState, resp: unknown, err?: Error) => void; + private readonly onLifecycle: ( + state: SlidingSyncState, + resp: MSC3575SlidingSyncResponse | null, + err?: Error + ) => void; private readonly onMembershipLeave: ( event: unknown, member: { userId: string; roomId: string; membership?: string } ) => void; - private presenceExtension!: ExtensionPresence; - private listsFullyLoaded = false; private initialSyncCompleted = false; @@ -316,13 +229,6 @@ export class SlidingSyncManager { this.listKeys = Array.from(lists.keys()); this.slidingSync = new SlidingSync(proxyBaseUrl, lists, defaultSubscription, mx, pollTimeoutMs); - // Register the presence extension so m.presence events from the server are fed - // into the SDK's User objects, keeping useUserPresence accurate during sliding sync. - this.presenceExtension = new ExtensionPresence(mx); - this.slidingSync.registerExtension(this.presenceExtension); - - // Register a custom subscription for unencrypted active rooms; encrypted rooms use - // the default subscription (which already has [*,*]). this.slidingSync.addCustomSubscription( UNENCRYPTED_SUBSCRIPTION_KEY, buildUnencryptedSubscription(roomTimelineLimit) @@ -359,30 +265,8 @@ export class SlidingSyncManager { return; } - // Before room data is processed, reset live timelines for active rooms that - // are receiving a full refresh (initial: true) or a post-gap update - // (limited: true). The SDK deliberately does not call resetLiveTimeline() for - // sliding sync, so events from previous visits accumulate in the live - // timeline alongside new events. Resetting here — before the SDK's - // onRoomData listener runs — ensures the fresh batch lands on a clean - // timeline with a correct backward pagination token. - if (state === SlidingSyncState.RequestFinished && resp && !err) { - const rooms = (resp as MSC3575SlidingSyncResponse).rooms ?? {}; - Object.entries(rooms) - .filter(([, roomData]) => roomData.initial || roomData.limited) - .filter(([roomId]) => this.activeRoomSubscriptions.has(roomId)) - .forEach(([roomId]) => { - const room = this.mx.getRoom(roomId); - if (!room) return; - const timelineSet = room.getUnfilteredTimelineSet(); - if (timelineSet.getLiveTimeline().getEvents().length === 0) return; - timelineSet.resetLiveTimeline(); - }); - } - if (err || !resp || state !== SlidingSyncState.Complete) return; - // Track what changed in this sync cycle const changes: Record = {}; let totalRoomCount = 0; let hasChanges = false; @@ -416,10 +300,8 @@ export class SlidingSyncManager { const syncDuration = performance.now() - syncStartTime; - // Mark initial sync as complete after first successful cycle if (!this.initialSyncCompleted) { this.initialSyncCompleted = true; - // Wall-clock ms from attach() — the actual user-perceived wait for first data. const initialElapsed = this.attachTime != null ? performance.now() - this.attachTime : syncDuration; debugLog.info('sync', 'Initial sync completed', { @@ -462,32 +344,6 @@ export class SlidingSyncManager { if (!this.activeRoomSubscriptions.has(member.roomId)) return; this.unsubscribeFromRoom(member.roomId); }; - - this.onConnectionChange = () => { - const isOnline = navigator.onLine; - const connectionInfo = - typeof navigator !== 'undefined' - ? (navigator as unknown as { connection?: NetworkInformation }).connection - : undefined; - const effectiveType = connectionInfo?.effectiveType; - const downlink = connectionInfo?.downlink; - - debugLog.info('network', `Network connectivity changed: ${isOnline ? 'online' : 'offline'}`, { - online: isOnline, - effectiveType, - downlink: downlink ? `${downlink} Mbps` : undefined, - }); - - if (!isOnline) { - debugLog.warn('network', 'Device went offline - sync paused', { - syncNumber: this.syncCount, - }); - } else { - debugLog.info('network', 'Device back online - sync will resume', { - syncNumber: this.syncCount, - }); - } - }; } public attach(): void { @@ -508,29 +364,8 @@ export class SlidingSyncManager { this.slidingSync.on(SlidingSyncEvent.Lifecycle, this.onLifecycle); this.mx.on(RoomMemberEvent.Membership, this.onMembershipLeave); - const connection = ( - typeof navigator !== 'undefined' - ? (navigator as unknown as { connection?: NetworkInformation }).connection - : undefined - ) as - | { - addEventListener?: (e: string, cb: () => void) => void; - removeEventListener?: (e: string, cb: () => void) => void; - onchange?: (() => void) | null; - } - | undefined; - connection?.addEventListener?.('change', this.onConnectionChange); - // oxlint-disable-next-line unicorn/prefer-add-event-listener - if (connection && connection.onchange === null) connection.onchange = this.onConnectionChange; - if (typeof window !== 'undefined') { - window.addEventListener('online', this.onConnectionChange); - window.addEventListener('offline', this.onConnectionChange); - } - debugLog.info('sync', 'Sliding sync listeners attached successfully', { - hasConnectionAPI: !!connection, - hasWindowEvents: typeof window !== 'undefined', - }); + debugLog.info('sync', 'Sliding sync listeners attached successfully'); } public dispose(): void { @@ -541,43 +376,18 @@ export class SlidingSyncManager { initialSyncCompleted: this.initialSyncCompleted, }); - // Clean up pending room-data latency listeners before marking disposed. - // SlidingSync.stop() will removeAllListeners anyway, but this keeps the Map tidy. this.pendingRoomDataListeners.clear(); this.disposed = true; - // Stop the SDK's internal polling loop and abort any in-flight requests. this.slidingSync.stop(); this.slidingSync.removeListener(SlidingSyncEvent.Lifecycle, this.onLifecycle); this.mx.removeListener(RoomMemberEvent.Membership, this.onMembershipLeave); - const connection = ( - typeof navigator !== 'undefined' - ? (navigator as unknown as { connection?: NetworkInformation }).connection - : undefined - ) as - | { - addEventListener?: (e: string, cb: () => void) => void; - removeEventListener?: (e: string, cb: () => void) => void; - onchange?: (() => void) | null; - } - | undefined; - connection?.removeEventListener?.('change', this.onConnectionChange); - // oxlint-disable-next-line unicorn/prefer-add-event-listener - if (connection?.onchange === this.onConnectionChange) connection.onchange = null; - if (typeof window !== 'undefined') { - window.removeEventListener('online', this.onConnectionChange); - window.removeEventListener('offline', this.onConnectionChange); - } debugLog.info('sync', 'Sliding sync disposed successfully', { totalSyncCycles: this.syncCount, }); } - public setPresenceEnabled(enabled: boolean): void { - this.presenceExtension.setEnabled(enabled); - } - public getDiagnostics(): SlidingSyncDiagnostics { return { proxyBaseUrl: this.proxyBaseUrl, @@ -596,7 +406,6 @@ export class SlidingSyncManager { } private expandListsToKnownCount(): void { - // Stop expanding once we've loaded all rooms - prevents continuous updates if (this.listsFullyLoaded) return; let allListsComplete = true; @@ -627,23 +436,16 @@ export class SlidingSyncManager { const existing = this.slidingSync.getListParams(key); const currentEnd = getListEndIndex(existing); - // Calculate how many rooms we still need to load const maxEnd = Math.min(knownCount, this.maxRooms) - 1; if (currentEnd >= maxEnd) { - // This list is fully loaded expansionDetails[key] = { status: 'complete', knownCount, currentEnd }; return; } allListsComplete = false; - // Progressive expansion: load in moderate chunks to balance speed with stability - // Chunk size reduced to 100 to prevent timeline ordering issues when opening rooms - // while lists are still expanding. Rooms should get at least one clean sync from - // their list before the active subscription requests a high timeline limit. - const chunkSize = 100; - const desiredEnd = Math.min(currentEnd + chunkSize, maxEnd); + const desiredEnd = maxEnd; if (desiredEnd === currentEnd) { expansionDetails[key] = { @@ -690,7 +492,6 @@ export class SlidingSyncManager { const expansionDuration = performance.now() - expansionStartTime; const hasExpansions = Object.values(expansionDetails).some((d) => d.status === 'expanding'); - // Mark as fully loaded once all lists are complete if (allListsComplete) { this.listsFullyLoaded = true; log.log(`Sliding Sync all lists fully loaded for ${this.mx.getUserId()}`); @@ -728,15 +529,6 @@ export class SlidingSyncManager { } } - /** - * Ensure a dynamic list is registered (or updated) on the sliding sync session. - * If the list does not yet exist it is created with sensible defaults merged with - * `updateArgs`. If it already exists and the merged result differs, only the ranges - * are updated (cheaper — avoids resending sticky params) when `updateArgs` only - * contains `ranges`; otherwise the full list is replaced. - * - * This mirrors Element Web's `SlidingSyncManager.ensureListRegistered`. - */ public ensureListRegistered(listKey: string, updateArgs: PartialSlidingSyncRequest): MSC3575List { let list = this.slidingSync.getListParams(listKey); if (!list) { @@ -760,7 +552,6 @@ export class SlidingSyncManager { this.slidingSync.setList(listKey, list); } } catch (error) { - // ignore — the list will be re-sent on the next sync cycle debugLog.warn('sync', `Failed to update list "${listKey}"`, { list: listKey, error: error instanceof Error ? error.message : String(error), @@ -770,104 +561,6 @@ export class SlidingSyncManager { return this.slidingSync.getListParams(listKey) ?? list; } - /** - * Spider through all rooms by incrementally expanding the search list, matching - * Element Web's `startSpidering` behaviour. Called once after `attach()` and runs - * in the background; callers must not await it. - * - * The first request uses `setList` to register the list with its full config; - * subsequent page advances use the cheaper `setListRanges` (sticky params are - * not resent). A gap sleep is applied before the first request and after each - * subsequent one to avoid hammering the proxy at startup. - */ - public async startSpidering(batchSize: number, gapBetweenRequestsMs: number): Promise { - // Delay before the first request — startSpidering is called right after attach(), - // so give the initial sync a moment to settle first. - await new Promise((res) => { - setTimeout(res, gapBetweenRequestsMs); - }); - if (this.disposed) return; - - // Use a single expanding range [[0, endIndex]] rather than a two-range sliding - // window. Synapse's extension handler asserts len(actual_list.ops) == 1, which - // fails when the response contains multiple ops (one per range). A single range - // always produces a single SYNC op, avoiding the assertion. - let endIndex = batchSize - 1; - let hasMore = true; - let firstTime = true; - let batchCount = 0; - - await Sentry.startSpan( - { - name: 'sync.spidering', - op: 'matrix.sync', - attributes: { 'sync.transport': 'sliding' }, - }, - async (span) => { - const spideringRequiredState: MSC3575List['required_state'] = [ - [EventType.RoomJoinRules, ''], - [EventType.RoomAvatar, ''], - [EventType.RoomTombstone, ''], - [EventType.RoomEncryption, ''], - [EventType.RoomCreate, ''], - [EventType.RoomTopic, ''], - [EventType.RoomCanonicalAlias, ''], - [EventType.RoomMember, MSC3575_STATE_KEY_ME], - ['m.space.child', MSC3575_WILDCARD], - ['im.ponies.room_emotes', MSC3575_WILDCARD], - ]; - - while (hasMore) { - if (this.disposed) return; - batchCount += 1; - const ranges: [number, number][] = [[0, endIndex]]; - try { - if (firstTime) { - // Full setList on first call to register the list with all params. - this.slidingSync.setList(LIST_SEARCH, { - ranges, - sort: ['by_recency'], - timeline_limit: 0, - required_state: spideringRequiredState, - }); - } else { - // Cheaper range-only update for subsequent pages; sticky params are preserved. - this.slidingSync.setListRanges(LIST_SEARCH, ranges); - } - } catch { - // Swallow errors — the next iteration will retry with updated ranges. - } finally { - // oxlint-disable-next-line no-await-in-loop - await new Promise((res) => { - setTimeout(res, gapBetweenRequestsMs); - }); - } - - if (this.disposed) return; - const listData = this.slidingSync.getListData(LIST_SEARCH); - hasMore = endIndex + 1 < (listData?.joinedCount ?? 0); - endIndex += batchSize; - firstTime = false; - } - const finalCount = this.slidingSync.getListData(LIST_SEARCH)?.joinedCount ?? 0; - span.setAttributes({ - 'spidering.batches': batchCount, - 'spidering.total_rooms': finalCount, - }); - log.log(`Sliding Sync spidering complete for ${this.mx.getUserId()}`); - } - ); - } - - /** - * Enable or disable server-side room name filtering. - * When `query` is a non-empty string, registers (or updates) a dedicated - * `room_search` list that uses the MSC4186 `room_name_like` filter so the - * server returns only rooms whose name matches the query. When `query` is - * null or empty the list is reset to an unfiltered minimal range — callers - * should hide/ignore the list results in that case. - * This is a no-op after dispose(). - */ public setRoomNameSearch(query: string | null): void { if (this.disposed) return; const trimmed = query?.trim() ?? ''; @@ -879,15 +572,6 @@ export class SlidingSyncManager { }); } - /** - * Activate or clear a space-scoped room list. - * When `spaceId` is provided, registers (or updates) a dedicated `space` - * list filtered to rooms that are children of that space, returning the - * first page sorted by recency. This supplements the main `joined` list - * rather than replacing it, so background sync of all rooms is unaffected. - * Pass `null` to deactivate the space list (collapses range to 0–0). - * This is a no-op after dispose(). - */ public setSpaceScope(spaceId: string | null): void { if (this.disposed) return; const filters: MSC3575List['filters'] = spaceId @@ -900,24 +584,15 @@ export class SlidingSyncManager { }); } - /** - * Subscribe to a room with the appropriate active-room subscription. - * Encrypted rooms use the default subscription ([*,*]); unencrypted rooms use a - * custom subscription that also requests lazy members. - * If the room is not yet known to the SDK (e.g. navigating directly to a room URL - * before the list has synced it), we default to the encrypted subscription — it is - * always safe to over-request state. - * Safe to call when already subscribed — the SDK deduplicates. - * This is a no-op after dispose(). - */ + public isRoomActive(roomId: string): boolean { + return this.activeRoomSubscriptions.has(roomId); + } + public subscribeToRoom(roomId: string): void { if (this.disposed) return; const room = this.mx.getRoom(roomId); const isEncrypted = this.mx.isRoomEncrypted(roomId); if (room && !isEncrypted) { - // Only use the unencrypted (lazy-load) subscription when we are certain - // the room is unencrypted. Unknown rooms fall through to the safer - // encrypted default. this.slidingSync.useCustomSubscription(roomId, UNENCRYPTED_SUBSCRIPTION_KEY); } this.activeRoomSubscriptions.add(roomId); @@ -941,8 +616,6 @@ export class SlidingSyncManager { activeSubscriptions: this.activeRoomSubscriptions.size, }, }); - // One-shot listener: measure latency from subscription request to first room data. - // Clean up any stale listener for the same roomId first. const existingListener = this.pendingRoomDataListeners.get(roomId); if (existingListener) { this.slidingSync.removeListener(SlidingSyncEvent.RoomData, existingListener); @@ -951,8 +624,6 @@ export class SlidingSyncManager { const onFirstRoomData = (dataRoomId: string) => { if (dataRoomId !== roomId) return; const latencyMs = Math.round(performance.now() - subscribeMs); - // Measure how many events landed on the live timeline as part of this - // subscription activation — this is the "page" the timeline has to absorb. const subscribedRoom = this.mx.getRoom(roomId); const eventCount = subscribedRoom?.getLiveTimeline().getEvents().length ?? 0; debugLog.info('sync', 'Room subscription: first data received (sliding)', { @@ -979,14 +650,8 @@ export class SlidingSyncManager { this.slidingSync.on(SlidingSyncEvent.RoomData, onFirstRoomData); } - /** - * Remove the explicit room subscription for a room. - * Rooms that are still in a list will continue to receive background updates. - * This is a no-op after dispose(). - */ public unsubscribeFromRoom(roomId: string): void { if (this.disposed) return; - // Clean up any pending first-data latency listener for this room. const pendingListener = this.pendingRoomDataListeners.get(roomId); if (pendingListener) { this.slidingSync.removeListener(SlidingSyncEvent.RoomData, pendingListener); @@ -1003,39 +668,4 @@ export class SlidingSyncManager { syncCycle: this.syncCount, }); } - - public static async probe( - mx: MatrixClient, - proxyBaseUrl: string, - probeTimeoutMs: number - ): Promise { - return Sentry.startSpan( - { name: 'sync.probe', op: 'matrix.sync', attributes: { 'sync.proxy': proxyBaseUrl } }, - async (span) => { - try { - const response = await mx.slidingSync( - { - lists: { - probe: { - ranges: [[0, 0]], - timeline_limit: 1, - required_state: [], - }, - }, - timeout: 0, - clientTimeout: probeTimeoutMs, - }, - proxyBaseUrl - ); - - const supported = typeof response.pos === 'string' && response.pos.length > 0; - span.setAttribute('probe.supported', supported); - return supported; - } catch { - span.setAttribute('probe.supported', false); - return false; - } - } - ); - } } diff --git a/src/instrument.ts b/src/instrument.ts index 4dc0e5f58..02c0a6441 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -15,7 +15,7 @@ import { createRoutesFromChildren, matchRoutes, } from 'react-router-dom'; -import { scrubMatrixIds, scrubDataObject, scrubMatrixUrl } from './app/utils/sentryScrubbers'; +import { scrubMatrixIds, sanitizeSentryPayload, scrubMatrixUrl } from './app/utils/sentryScrubbers'; const dsn = import.meta.env.VITE_SENTRY_DSN; const environment = import.meta.env.VITE_SENTRY_ENVIRONMENT || import.meta.env.MODE; @@ -100,7 +100,7 @@ if (dsn && sentryEnabled) { // Redact Matrix IDs from any string-valued log attributes (e.g. roomId, userId) // These are flattened from the structured data object and sent as searchable attributes. if (log.attributes && typeof log.attributes === 'object') { - log.attributes = scrubDataObject(log.attributes) as typeof log.attributes; + log.attributes = sanitizeSentryPayload(log.attributes) as typeof log.attributes; } return log; }, @@ -173,7 +173,9 @@ if (dsn && sentryEnabled) { // Scrub Matrix IDs from all remaining string values in the breadcrumb data object. // debugLog passes structured data (e.g. { roomId, targetEventId }) that would otherwise // bypass the URL-specific scrubbers above. - const scrubbedData = bData ? (scrubDataObject(bData) as Record) : undefined; + const scrubbedData = bData + ? (sanitizeSentryPayload(bData) as Record) + : undefined; // Scrub message text — token values and Matrix entity IDs const message = breadcrumb.message ? scrubMatrixIds(breadcrumb.message) : breadcrumb.message; @@ -234,7 +236,7 @@ if (dsn && sentryEnabled) { // Scrub contexts (e.g. debugLog context from captureMessage in debugLogger.ts, // which can carry structured data fields like roomId, targetEventId, etc.) if (event.contexts) { - event.contexts = scrubDataObject(event.contexts) as typeof event.contexts; + event.contexts = sanitizeSentryPayload(event.contexts) as typeof event.contexts; } // Scrub request data diff --git a/src/sw.ts b/src/sw.ts index 78255b701..ac3b9720b 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -481,6 +481,7 @@ async function handleMinimalPushPayload( room_id: roomId, event_id: eventId, user_id: session.userId, + sender_id: sender, }; if (eventType === 'm.room.encrypted') { @@ -623,6 +624,57 @@ const MEDIA_PATHS = [ '/_matrix/media/r0/thumbnail', ]; +const ELEMENT_CALL_RINGTONE_PATH = '/public/element-call/assets/ringtone-'; +let silentWavBytesCache: Uint8Array | undefined; + +function createSilentWavBytes(durationMs = 250): Uint8Array { + if (silentWavBytesCache) return silentWavBytesCache; + + const sampleRate = 8000; + const channels = 1; + const bitsPerSample = 16; + const bytesPerSample = bitsPerSample / 8; + const frameCount = Math.max(1, Math.floor((sampleRate * durationMs) / 1000)); + const dataSize = frameCount * channels * bytesPerSample; + const buffer = new ArrayBuffer(44 + dataSize); + const view = new DataView(buffer); + + // RIFF header + view.setUint32(0, 0x52494646, false); // "RIFF" + view.setUint32(4, 36 + dataSize, true); + view.setUint32(8, 0x57415645, false); // "WAVE" + + // fmt chunk + view.setUint32(12, 0x666d7420, false); // "fmt " + view.setUint32(16, 16, true); // PCM chunk size + view.setUint16(20, 1, true); // PCM format + view.setUint16(22, channels, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * channels * bytesPerSample, true); + view.setUint16(32, channels * bytesPerSample, true); + view.setUint16(34, bitsPerSample, true); + + // data chunk + view.setUint32(36, 0x64617461, false); // "data" + view.setUint32(40, dataSize, true); + + // PCM data is already zeroed => silence. + silentWavBytesCache = new Uint8Array(buffer); + return silentWavBytesCache; +} + +function isElementCallRingtoneRequest(url: string): boolean { + try { + const { pathname } = new URL(url); + return ( + pathname.startsWith(ELEMENT_CALL_RINGTONE_PATH) && + (pathname.endsWith('.mp3') || pathname.endsWith('.ogg') || pathname.endsWith('.wav')) + ); + } catch { + return false; + } +} + function mediaPath(url: string): boolean { try { const { pathname } = new URL(url); @@ -665,7 +717,26 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { self.addEventListener('fetch', (event: FetchEvent) => { const { url, method } = event.request; - if (method !== 'GET' || !mediaPath(url)) return; + if (method !== 'GET') return; + + if (isElementCallRingtoneRequest(url)) { + const silentWavBytes = createSilentWavBytes(); + const silentWavBuffer = new Uint8Array(silentWavBytes).buffer; + event.respondWith( + Promise.resolve( + new Response(silentWavBuffer, { + status: 200, + headers: { + 'Content-Type': 'audio/wav', + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }) + ) + ); + return; + } + + if (!mediaPath(url)) return; const { clientId } = event; @@ -837,6 +908,15 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { const pushRoomId: string | undefined = data?.room_id ?? undefined; const pushEventId: string | undefined = data?.event_id ?? undefined; const isInvite = data?.content?.membership === 'invite'; + const callNotificationType: string | undefined = data?.callNotificationType ?? undefined; + const callIntentKind: string | undefined = data?.callIntentKind ?? undefined; + const callIntentRaw: string | undefined = data?.callIntentRaw ?? undefined; + const callRefEventId: string | undefined = data?.callRefEventId ?? undefined; + const callSenderId: string | undefined = data?.sender_id ?? data?.callSenderId ?? undefined; + const callSenderTs: number | undefined = + typeof data?.callSenderTs === 'number' ? data.callSenderTs : undefined; + const callExpiresAt: number | undefined = + typeof data?.callExpiresAt === 'number' ? data.callExpiresAt : undefined; console.debug('[SW notificationclick] notification data:', JSON.stringify(data, null, 2)); console.debug('[SW notificationclick] resolved fields:', { @@ -864,11 +944,25 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { if (pushUserId) u.searchParams.set('uid', pushUserId); targetUrl = u.href; } else if (pushUserId && pushRoomId) { - const callParam = isCall ? '?joinCall=true' : ''; const segments = pushEventId - ? `to/${encodeURIComponent(pushUserId)}/${encodeURIComponent(pushRoomId)}/${encodeURIComponent(pushEventId)}/${callParam}` - : `to/${encodeURIComponent(pushUserId)}/${encodeURIComponent(pushRoomId)}/${callParam}`; - targetUrl = new URL(segments, scope).href; + ? `to/${encodeURIComponent(pushUserId)}/${encodeURIComponent(pushRoomId)}/${encodeURIComponent(pushEventId)}` + : `to/${encodeURIComponent(pushUserId)}/${encodeURIComponent(pushRoomId)}`; + const target = new URL(segments, scope); + if (isCall) { + target.searchParams.set('call', '1'); + if (callNotificationType) target.searchParams.set('callType', callNotificationType); + if (callIntentKind) target.searchParams.set('callIntentKind', callIntentKind); + if (callIntentRaw) target.searchParams.set('callIntentRaw', callIntentRaw); + if (callRefEventId) target.searchParams.set('callRefEventId', callRefEventId); + if (callSenderId) target.searchParams.set('callSenderId', callSenderId); + if (typeof callSenderTs === 'number') { + target.searchParams.set('callSenderTs', String(callSenderTs)); + } + if (typeof callExpiresAt === 'number') { + target.searchParams.set('callExpiresAt', String(callExpiresAt)); + } + } + targetUrl = target.href; } else { // Fallback: no room ID or no user ID in payload. targetUrl = new URL('inbox/notifications/', scope).href; @@ -906,6 +1000,13 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { eventId: pushEventId, isInvite, isCall, + callNotificationType, + callIntentKind, + callIntentRaw, + callRefEventId, + callSenderId, + callSenderTs, + callExpiresAt, }); // oxlint-disable-next-line no-await-in-loop await wc.focus(); diff --git a/src/sw/pushCallNotificationCopy.test.ts b/src/sw/pushCallNotificationCopy.test.ts new file mode 100644 index 000000000..6d8b19404 --- /dev/null +++ b/src/sw/pushCallNotificationCopy.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { resolveCallNotificationCopy } from './pushCallNotificationCopy'; + +describe('resolveCallNotificationCopy', () => { + it('uses generic room-call copy when previews are hidden', () => { + expect( + resolveCallNotificationCopy({ + notificationType: 'notification', + intentKind: 'audio', + showPreviewDetails: false, + }) + ).toEqual({ + title: 'Room call started', + body: 'Open Sable to join.', + }); + }); + + it('uses sender and room details for ring notifications', () => { + expect( + resolveCallNotificationCopy({ + notificationType: 'ring', + intentKind: 'video', + senderDisplayName: 'Alice', + roomName: 'General', + showPreviewDetails: true, + }) + ).toEqual({ + title: 'Incoming video call', + body: 'Alice is calling you in General', + }); + }); +}); diff --git a/src/sw/pushCallNotificationCopy.ts b/src/sw/pushCallNotificationCopy.ts new file mode 100644 index 000000000..86a398300 --- /dev/null +++ b/src/sw/pushCallNotificationCopy.ts @@ -0,0 +1,112 @@ +type CallNotificationType = 'ring' | 'notification'; +type CallIntentKind = 'audio' | 'video'; + +export type CallNotificationCopyContext = { + notificationType: CallNotificationType; + intentKind: CallIntentKind; + senderDisplayName?: string; + roomName?: string; + showPreviewDetails: boolean; +}; + +type CopyTemplate = { + title: string | ((ctx: CallNotificationCopyContext) => string); + body: string | ((ctx: CallNotificationCopyContext) => string | undefined); +}; + +type CopyRule = { + when: (ctx: CallNotificationCopyContext) => boolean; + template: CopyTemplate; +}; + +const firstMatchingTemplate = ( + ctx: CallNotificationCopyContext, + rules: CopyRule[] +): CopyTemplate | undefined => { + for (const rule of rules) { + if (rule.when(ctx)) return rule.template; + } + return undefined; +}; + +const ROOM_CALL_RULES: CopyRule[] = [ + { + when: (ctx) => !ctx.showPreviewDetails, + template: { title: 'Room call started', body: 'Open Sable to join.' }, + }, + { + when: (ctx) => Boolean(ctx.senderDisplayName && ctx.roomName), + template: { + title: 'Room call started', + body: (ctx) => `${ctx.senderDisplayName} started a call in ${ctx.roomName}`, + }, + }, + { + when: (ctx) => Boolean(ctx.roomName), + template: { title: 'Room call started', body: (ctx) => `A call started in ${ctx.roomName}` }, + }, + { + when: (ctx) => Boolean(ctx.senderDisplayName), + template: { + title: 'Room call started', + body: (ctx) => `${ctx.senderDisplayName} started a call`, + }, + }, + { + when: () => true, + template: { title: 'Room call started', body: 'A room call started.' }, + }, +]; + +const RING_CALL_RULES: CopyRule[] = [ + { + when: (ctx) => !ctx.showPreviewDetails, + template: { + title: (ctx) => (ctx.intentKind === 'video' ? 'Incoming video call' : 'Incoming voice call'), + body: 'Open Sable to answer.', + }, + }, + { + when: (ctx) => Boolean(ctx.senderDisplayName && ctx.roomName), + template: { + title: (ctx) => (ctx.intentKind === 'video' ? 'Incoming video call' : 'Incoming voice call'), + body: (ctx) => `${ctx.senderDisplayName} is calling you in ${ctx.roomName}`, + }, + }, + { + when: (ctx) => Boolean(ctx.senderDisplayName), + template: { + title: (ctx) => (ctx.intentKind === 'video' ? 'Incoming video call' : 'Incoming voice call'), + body: (ctx) => `${ctx.senderDisplayName} is calling you`, + }, + }, + { + when: (ctx) => Boolean(ctx.roomName), + template: { + title: (ctx) => (ctx.intentKind === 'video' ? 'Incoming video call' : 'Incoming voice call'), + body: (ctx) => `Incoming call in ${ctx.roomName}`, + }, + }, + { + when: () => true, + template: { + title: (ctx) => (ctx.intentKind === 'video' ? 'Incoming video call' : 'Incoming voice call'), + body: 'Incoming call', + }, + }, +]; + +export const resolveCallNotificationCopy = ( + ctx: CallNotificationCopyContext +): { title: string; body: string | undefined } => { + const rules = ctx.notificationType === 'notification' ? ROOM_CALL_RULES : RING_CALL_RULES; + const template = firstMatchingTemplate(ctx, rules); + if (!template) { + return { title: 'Incoming call', body: undefined }; + } + + return { + title: typeof template.title === 'function' ? template.title(ctx) : template.title, + body: typeof template.body === 'function' ? template.body(ctx) : template.body, + }; +}; diff --git a/src/sw/pushNotification.ts b/src/sw/pushNotification.ts index d040d066e..fdc6a8e7f 100644 --- a/src/sw/pushNotification.ts +++ b/src/sw/pushNotification.ts @@ -1,12 +1,14 @@ /* oxlint-disable no-console */ // Keep the service worker import graph narrow, the app barrel pulls in runtime Matrix SDK modules that break SW script evaluation import { EventType } from 'matrix-js-sdk/lib/@types/event'; +import { normalizeCallIntent } from '../app/features/call/callIntent'; import { buildRoomMessageNotification, DEFAULT_NOTIFICATION_ICON, DEFAULT_NOTIFICATION_BADGE, resolveNotificationPreviewText, } from '../app/utils/notificationStyle'; +import { resolveCallNotificationCopy } from './pushCallNotificationCopy'; type NotificationSettings = { showMessageContent: boolean; @@ -15,8 +17,16 @@ type NotificationSettings = { interface MatrixPushData { type?: string; - content?: { notification_type?: string; membership?: string }; + content?: { + notification_type?: string; + membership?: string; + sender_ts?: number; + lifetime?: number; + 'm.call.intent'?: string; + 'm.relates_to'?: { event_id?: string }; + }; sender_display_name?: string; + sender_id?: string; room_name?: string; room_id?: string; room_avatar_url?: string; @@ -27,6 +37,37 @@ interface MatrixPushData { } const resolveSilent = (): boolean => false; +const MAX_CALL_NOTIFICATION_LIFETIME_MS = 120_000; + +const isCallNotificationType = (value: unknown): value is 'ring' | 'notification' => + value === 'ring' || value === 'notification'; + +const getCallTiming = ( + content: MatrixPushData['content'], + originTs: number +): { senderTs: number; expiresAt: number } => { + const senderTsCandidate = content?.sender_ts; + const lifetimeCandidate = content?.lifetime; + + if (typeof senderTsCandidate !== 'number' || !Number.isFinite(senderTsCandidate)) { + const senderTs = originTs; + return { + senderTs, + expiresAt: senderTs + MAX_CALL_NOTIFICATION_LIFETIME_MS, + }; + } + + const senderTs = senderTsCandidate - originTs > 20_000 ? originTs : senderTsCandidate; + const lifetime = + typeof lifetimeCandidate === 'number' && Number.isFinite(lifetimeCandidate) + ? Math.min(Math.max(lifetimeCandidate, 0), MAX_CALL_NOTIFICATION_LIFETIME_MS) + : MAX_CALL_NOTIFICATION_LIFETIME_MS; + + return { + senderTs, + expiresAt: senderTs + lifetime, + }; +}; export const createPushNotifications = ( self: ServiceWorkerGlobalScope, @@ -38,13 +79,15 @@ export const createPushNotifications = ( data: Record, silent?: boolean, icon?: string, - badge?: string + badge?: string, + tagOverride?: string ) => { const roomId: string | undefined = data?.room_id as string | undefined; // Group by room so new messages in the same room replace the previous // notification rather than stacking individually. renotify: true ensures // the user is still alerted when the existing tag is replaced. - const tag: string = roomId ? `room-${roomId}` : ((data?.event_id as string) ?? 'Cinny'); + const tag: string = + tagOverride ?? (roomId ? `room-${roomId}` : ((data?.event_id as string) ?? 'Cinny')); const renotify = !!roomId; // `renotify` is a valid Web API property absent from TypeScript's NotificationOptions type. // Build the options object separately to avoid the excess-property check, then cast. @@ -62,26 +105,57 @@ export const createPushNotifications = ( }; const handleCallNotification = async (pushData: MatrixPushData) => { - const content = pushData?.content as { notification_type?: string } | undefined; - if (content?.notification_type !== 'ring') return; + if (pushData.type === EventType.RoomMessageEncrypted) return; + + const notificationTypeRaw = pushData?.content?.notification_type; + if (!isCallNotificationType(notificationTypeRaw)) return; + const intentRaw = + typeof pushData?.content?.['m.call.intent'] === 'string' + ? pushData.content['m.call.intent'] + : undefined; + const intentKind = normalizeCallIntent(undefined, intentRaw); const senderDisplayName = pushData?.sender_display_name; const roomName = pushData?.room_name; - const title = 'Incoming Call'; - const body = senderDisplayName - ? `${senderDisplayName} is calling you ${roomName ? `in ${roomName}` : ''}` - : 'Incoming voice chat'; + const showPreviewDetails = getNotificationSettings().showMessageContent; + const copy = resolveCallNotificationCopy({ + notificationType: notificationTypeRaw, + intentKind, + senderDisplayName, + roomName, + showPreviewDetails, + }); + const originTs = typeof pushData.timestamp === 'number' ? pushData.timestamp : Date.now(); + const { senderTs, expiresAt } = getCallTiming(pushData.content, originTs); const data = { type: pushData?.type, room_id: pushData?.room_id, + event_id: pushData?.event_id, user_id: pushData?.user_id, + sender_id: pushData?.sender_id, timestamp: Date.now(), isCall: true, + callNotificationType: notificationTypeRaw, + callIntentKind: intentKind, + callIntentRaw: intentRaw, + callNotificationEventId: pushData?.event_id, + callRefEventId: pushData?.content?.['m.relates_to']?.event_id, + callSenderTs: senderTs, + callExpiresAt: expiresAt, ...pushData.data, }; - await showNotificationWithData(title, body, data, resolveSilent(), pushData?.room_avatar_url); + const callTag = pushData?.room_id ? `call-${pushData.room_id}` : undefined; + await showNotificationWithData( + copy.title, + copy.body, + data, + resolveSilent(), + pushData?.room_avatar_url, + undefined, + callTag + ); }; const handleRoomMessageNotification = async (pushData: MatrixPushData) => { diff --git a/src/test/fixtures/call/matrixRtcMemberships.test.ts b/src/test/fixtures/call/matrixRtcMemberships.test.ts new file mode 100644 index 000000000..0fbb02d6f --- /dev/null +++ b/src/test/fixtures/call/matrixRtcMemberships.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { matrixRtcMembershipFixtures } from './matrixRtcMemberships'; + +describe('matrixRtcMembershipFixtures', () => { + it('includes all baseline membership scenarios for call signaling tests', () => { + expect(matrixRtcMembershipFixtures.noMembers).toHaveLength(0); + expect(matrixRtcMembershipFixtures.remoteOnly).toHaveLength(1); + expect(matrixRtcMembershipFixtures.selfOnly).toHaveLength(1); + expect(matrixRtcMembershipFixtures.selfAndRemote).toHaveLength(2); + expect(matrixRtcMembershipFixtures.staleSelfAfterActiveCall).toHaveLength(1); + }); +}); diff --git a/src/test/fixtures/call/matrixRtcMemberships.ts b/src/test/fixtures/call/matrixRtcMemberships.ts new file mode 100644 index 000000000..403905689 --- /dev/null +++ b/src/test/fixtures/call/matrixRtcMemberships.ts @@ -0,0 +1,50 @@ +export type MatrixRtcMembershipFixture = { + userId: string; + sender: string; + deviceId: string; + expiresTs: number; +}; + +const BASE_TS = 1_700_000_000_000; + +export const matrixRtcMembershipFixtures = { + noMembers: [] as MatrixRtcMembershipFixture[], + remoteOnly: [ + { + userId: '@remote:example.org', + sender: '@remote:example.org', + deviceId: 'REMOTE_DEVICE', + expiresTs: BASE_TS + 60_000, + }, + ] as MatrixRtcMembershipFixture[], + selfOnly: [ + { + userId: '@self:example.org', + sender: '@self:example.org', + deviceId: 'SELF_DEVICE', + expiresTs: BASE_TS + 60_000, + }, + ] as MatrixRtcMembershipFixture[], + selfAndRemote: [ + { + userId: '@self:example.org', + sender: '@self:example.org', + deviceId: 'SELF_DEVICE', + expiresTs: BASE_TS + 60_000, + }, + { + userId: '@remote:example.org', + sender: '@remote:example.org', + deviceId: 'REMOTE_DEVICE', + expiresTs: BASE_TS + 60_000, + }, + ] as MatrixRtcMembershipFixture[], + staleSelfAfterActiveCall: [ + { + userId: '@self:example.org', + sender: '@self:example.org', + deviceId: 'SELF_DEVICE', + expiresTs: BASE_TS - 10_000, + }, + ] as MatrixRtcMembershipFixture[], +}; diff --git a/src/types/matrix-sdk-events.d.ts b/src/types/matrix-sdk-events.d.ts index 7a85116ee..ef0b26695 100644 --- a/src/types/matrix-sdk-events.d.ts +++ b/src/types/matrix-sdk-events.d.ts @@ -37,6 +37,29 @@ type RoomCosmeticsPronounsEventContent = { type RoomBannerContent = { url?: string; }; + +type BookmarkIndexContent = { + version: 1; + revision: number; + updated_ts: number; + bookmark_ids: string[]; +}; + +type BookmarkItemContent = { + version: 1; + bookmark_id: string; + uri: string; + room_id: string; + event_id: string; + event_ts: number; + bookmarked_ts: number; + sender?: string; + room_name?: string; + body_preview?: string; + msgtype?: string; + deleted?: boolean; +}; + declare module 'matrix-js-sdk/lib/@types/event' { interface StateEvents { [prefix.MATRIX_UNSTABLE_STATE_ROOM_EMOTES_PROPERTY_NAME]: PackContent; @@ -59,6 +82,8 @@ declare module 'matrix-js-sdk/lib/@types/event' { [prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_SETTINGS_PROPERTY_NAME]: Record; [prefix.MATRIX_SABLE_UNSTABLE_DISMISSED_INVITES]: { roomIds: string[] }; [prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_ADDED_SERVERS_PROPERTY_NAME]: AddedServersContent; + [prefix.MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT]: BookmarkIndexContent; + [prefix.MATRIX_SABLE_UNSTABLE_BOOKMARK_ITEM_EVENT_PREFIX]: BookmarkItemContent; [prefix.MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS]: { gifs: Omit[] }; } } diff --git a/src/unstable/prefixes/sable/accountdata.ts b/src/unstable/prefixes/sable/accountdata.ts index 57bde6303..001404ec5 100644 --- a/src/unstable/prefixes/sable/accountdata.ts +++ b/src/unstable/prefixes/sable/accountdata.ts @@ -14,4 +14,6 @@ export const MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME = export const MATRIX_SABLE_UNSTABLE_DISMISSED_INVITES = 'moe.sable.dismissed_invites'; export const MATRIX_SABLE_UNSTABLE_ACCOUNT_ADDED_SERVERS_PROPERTY_NAME = 'moe.sable.added_servers'; +export const MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT = 'pl.chrome.bookmarks.index'; +export const MATRIX_SABLE_UNSTABLE_BOOKMARK_ITEM_EVENT_PREFIX = 'pl.chrome.bookmark.'; export const MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS = 'moe.sable.favorite_gifs'; From f37a24b67438a0f26028816f74fc71d80265da1a Mon Sep 17 00:00:00 2001 From: Shea Date: Sun, 5 Jul 2026 07:45:42 +0300 Subject: [PATCH 12/16] maybe better unverified and fix righthand view things Signed-off-by: Shea --- .../message/modals/GlobalModalManager.tsx | 4 +-- .../UnverifiedNoticeBanner.tsx | 27 ++++++++++++------- src/app/pages/client/sidebar/UserMenuTab.tsx | 23 +++++++++++----- src/app/pages/client/space/Space.tsx | 2 +- 4 files changed, 37 insertions(+), 19 deletions(-) diff --git a/src/app/components/message/modals/GlobalModalManager.tsx b/src/app/components/message/modals/GlobalModalManager.tsx index a78506cad..509d0c8fd 100644 --- a/src/app/components/message/modals/GlobalModalManager.tsx +++ b/src/app/components/message/modals/GlobalModalManager.tsx @@ -34,8 +34,8 @@ export function GlobalModalManager() { focusTrapOptions={{ initialFocus: false, onDeactivate: close, - allowOutsideClick: (e) => { - e.preventDefault(); + allowOutsideClick: (e: { preventDefault?: () => void }) => { + if (e.preventDefault) e.preventDefault(); close(); return false; }, diff --git a/src/app/components/unverified-notice/UnverifiedNoticeBanner.tsx b/src/app/components/unverified-notice/UnverifiedNoticeBanner.tsx index df39f71b7..11a912e21 100644 --- a/src/app/components/unverified-notice/UnverifiedNoticeBanner.tsx +++ b/src/app/components/unverified-notice/UnverifiedNoticeBanner.tsx @@ -4,35 +4,42 @@ import * as css from './UnverifiedNoticeBanner.css'; import { useOpenSettings } from '$features/settings'; import { ShieldWarningIcon } from '@phosphor-icons/react'; import { useIsUnverified, useUnverifiedDevices } from '$pages/client/sidebar/UserMenuTab'; -import { atom, useAtom } from 'jotai'; +import { getLocalStorageItem, setLocalStorageItem } from '$state/utils/atomWithLocalStorage'; import { useEffect, useState } from 'react'; -const dismissedNotice = atom(false); - export function UnverifiedNoticeBanner() { const openSettings = useOpenSettings(); - const [isDismissedNotice, setDismissedNotice] = useAtom(dismissedNotice); const isUnverified = useIsUnverified(); - const unverifiedDeviceCount = useUnverifiedDevices() ?? 0; + const unverifiedDeviceCount = useUnverifiedDevices(); const hasUnverified = isUnverified || (unverifiedDeviceCount !== undefined && unverifiedDeviceCount > 0); const [dismissCount, setDismissCount] = useState(unverifiedDeviceCount); + const [isDismissedNotice, setIsDismissedNotice] = useState( + getLocalStorageItem('dismissNotice', false) + ); useEffect(() => { - if (unverifiedDeviceCount > 0 && dismissCount < unverifiedDeviceCount) - setDismissedNotice(false); + if ( + unverifiedDeviceCount && + dismissCount && + unverifiedDeviceCount > 0 && + dismissCount < unverifiedDeviceCount + ) { + setLocalStorageItem('dismissNotice', false); + setIsDismissedNotice(false); + } setDismissCount(unverifiedDeviceCount); - }, [unverifiedDeviceCount, setDismissedNotice, dismissCount]); + }, [unverifiedDeviceCount, dismissCount]); if (!hasUnverified || isDismissedNotice) return null; const handleVerify = () => { openSettings('devices'); }; - const handleDismiss = () => { - setDismissedNotice(true); + setLocalStorageItem('dismissNotice', true); + setIsDismissedNotice(true); }; return ( diff --git a/src/app/pages/client/sidebar/UserMenuTab.tsx b/src/app/pages/client/sidebar/UserMenuTab.tsx index 415f8c8b9..27bab1570 100644 --- a/src/app/pages/client/sidebar/UserMenuTab.tsx +++ b/src/app/pages/client/sidebar/UserMenuTab.tsx @@ -20,7 +20,7 @@ import { config, toRem, } from 'folds'; -import FocusTrap from 'focus-trap-react'; +import { FocusTrap } from 'focus-trap-react'; import { SidebarAvatar, SidebarItem, SidebarItemBadge } from '../../../components/sidebar'; import { UserAvatar } from '../../../components/user-avatar'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; @@ -150,7 +150,7 @@ function AccountRow({ ); } -export function AccountMenuOption({ isMobile }: { isMobile: boolean }) { +export function AccountMenuOption({ isMobile, isRight }: { isMobile: boolean; isRight?: boolean }) { const mx = useMatrixClient(); const navigate = useNavigate(); const sessions = useAtomValue(sessionsAtom); @@ -286,7 +286,8 @@ export function AccountMenuOption({ isMobile }: { isMobile: boolean }) { : { minWidth: toRem(240), position: 'absolute', - left: '98%', + left: isRight ? undefined : '98%', + right: isRight ? '98%' : undefined, padding: toRem(15), bottom: toRem(-15), } @@ -389,9 +390,11 @@ const PresenceOptions: Array<{ value: Presence; label: string }> = [ export function PresenceMenuOption({ initialOpen, isMobile, + isRight, }: { initialOpen: boolean; isMobile: boolean; + isRight?: boolean; }) { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); @@ -488,7 +491,8 @@ export function PresenceMenuOption({ : { minWidth: toRem(240), position: 'absolute', - left: '98%', + left: isRight ? undefined : '98%', + right: isRight ? '98%' : undefined, padding: toRem(15), bottom: toRem(25), } @@ -751,10 +755,17 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi
- + window.innerWidth / 2} + />
- + window.innerWidth / 2} + /> {(oldSidebar || isMobile) && ( <> diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 6769f0fb1..91188ccde 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -1050,7 +1050,7 @@ export function Space() { })} {getConnectorSVG(hierarchy, virtualizedItems)} -
+ {!isMobile &&
} From de07c02c3f86f95ed6cae56c44a4be4715342a03 Mon Sep 17 00:00:00 2001 From: Shea Date: Sun, 5 Jul 2026 15:23:05 +0300 Subject: [PATCH 13/16] some small changes Signed-off-by: Shea --- src/app/components/message/modals/Options.tsx | 20 +-- src/app/features/settings/Settings.tsx | 4 +- src/app/pages/client/profile/Profile.tsx | 119 ++++++++++++++++-- src/app/pages/client/sidebar/InboxTab.tsx | 34 +++-- src/app/pages/client/sidebar/MessageTab.tsx | 4 +- src/app/pages/client/sidebar/NavigateTab.tsx | 4 +- src/app/pages/client/sidebar/UserMenuTab.tsx | 35 ++++-- 7 files changed, 181 insertions(+), 39 deletions(-) diff --git a/src/app/components/message/modals/Options.tsx b/src/app/components/message/modals/Options.tsx index 7ad72bfae..371086069 100644 --- a/src/app/components/message/modals/Options.tsx +++ b/src/app/components/message/modals/Options.tsx @@ -654,35 +654,35 @@ export function OptionMenu({ Reply - {!isThreadedMessage && ( + {canEditEvent(mx, mEvent) && onEditId && ( { - onReplyClick(evt, true); + onClick={() => { + onEditId(mEvent.getId()); onTotalClose(); }} > - Reply in Thread + Edit Message )} - {canEditEvent(mx, mEvent) && onEditId && ( + {!isThreadedMessage && ( { - onEditId(mEvent.getId()); + onClick={(evt) => { + onReplyClick(evt, true); onTotalClose(); }} > - Edit Message + Reply in Thread )} diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index bf22676a0..90654dc3e 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/pages/client/profile/Profile.tsx b/src/app/pages/client/profile/Profile.tsx index 9fbf79ab4..24f928b68 100644 --- a/src/app/pages/client/profile/Profile.tsx +++ b/src/app/pages/client/profile/Profile.tsx @@ -1,7 +1,21 @@ -import { Box, toRem, Text, color, config, Menu, Icon, Icons, Line, MenuItem } from 'folds'; -import { SquaresFour, sizedIcon } from '$components/icons/phosphor'; +import { + Box, + toRem, + Text, + color, + config, + Menu, + Icon, + Icons, + Line, + MenuItem, + OverlayBackdrop, + Overlay, + OverlayCenter, +} from 'folds'; +import { SquaresFour, menuIcon, settingsNavIcon, sizedIcon } from '$components/icons/phosphor'; import { PageNav, PageNavHeader } from '$components/page'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; @@ -19,8 +33,14 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useUserPresence } from '$hooks/useUserPresence'; import { useUserProfile } from '$hooks/useUserProfile'; -import { useOpenSettings } from '$features/settings'; +import type { SettingsMenuItem } from '$features/settings'; +import { settingsMenuIcons, settingsSections, useOpenSettings } from '$features/settings'; import { UserQuickTools } from '../sidebar/UserQuickTools'; +import { 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(); @@ -31,6 +51,7 @@ export function ProfileMobile() { const userId = mx.getUserId() ?? ''; const profile = useUserProfile(userId); const presence = useUserPresence(userId); + const [isSettingsOpen, setIsSettingsOpen] = useState(true); const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; const heroAvatarUrl = profile.avatarUrl @@ -54,6 +75,18 @@ export function ProfileMobile() { 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 && ( @@ -99,9 +132,18 @@ export function ProfileMobile() { direction="Column" gap="0" alignItems="Center" + justifyContent="SpaceBetween" style={{ width: '100%', minWidth: '100%' }} > - + - + + + + @@ -128,13 +173,73 @@ export function ProfileMobile() { } - onClick={() => openSettings()} + after={ + + } + onClick={() => 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/sidebar/InboxTab.tsx b/src/app/pages/client/sidebar/InboxTab.tsx index 002b00d19..67851b5d0 100644 --- a/src/app/pages/client/sidebar/InboxTab.tsx +++ b/src/app/pages/client/sidebar/InboxTab.tsx @@ -21,7 +21,7 @@ import { import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useNavToActivePathAtom } from '$state/hooks/navToActivePath'; import { useInviteCount } from '$hooks/useInviteCount'; -import { Text, Box } from 'folds'; +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'; @@ -56,7 +56,7 @@ export function InboxTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile? }; return ( - + {(triggerRef) => ( @@ -68,15 +68,35 @@ export function InboxTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile? size={'400'} > {(notificationsSelected && ( - + )) || - (bookmarksSelected && ) || - (invitesSelected && ) || ( - + (bookmarksSelected && ( + + )) || + (invitesSelected && ( + + )) || ( + )} {isMobile && ( - + Inbox )} diff --git a/src/app/pages/client/sidebar/MessageTab.tsx b/src/app/pages/client/sidebar/MessageTab.tsx index ac1f86b87..7e5d7473e 100644 --- a/src/app/pages/client/sidebar/MessageTab.tsx +++ b/src/app/pages/client/sidebar/MessageTab.tsx @@ -63,7 +63,7 @@ export function MessageTab({ isBottom, isMobile }: { isBottom?: boolean; isMobil : undefined; return ( - + {(triggerRef) => ( @@ -96,7 +96,7 @@ export function MessageTab({ isBottom, isMobile }: { isBottom?: boolean; isMobil )} {isMobile && ( - + Messages )} diff --git a/src/app/pages/client/sidebar/NavigateTab.tsx b/src/app/pages/client/sidebar/NavigateTab.tsx index e6e717958..47e5f3819 100644 --- a/src/app/pages/client/sidebar/NavigateTab.tsx +++ b/src/app/pages/client/sidebar/NavigateTab.tsx @@ -19,7 +19,7 @@ export function NavigateTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi }; return ( - + {(triggerRef) => ( @@ -37,7 +37,7 @@ export function NavigateTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi /> {isMobile && ( - + Navigate )} diff --git a/src/app/pages/client/sidebar/UserMenuTab.tsx b/src/app/pages/client/sidebar/UserMenuTab.tsx index 27bab1570..6f321da7a 100644 --- a/src/app/pages/client/sidebar/UserMenuTab.tsx +++ b/src/app/pages/client/sidebar/UserMenuTab.tsx @@ -67,6 +67,7 @@ function AccountRow({ displayName, avatarUrl, isBusy, + isMobile, unread, onSwitch, onSignOut, @@ -76,6 +77,7 @@ function AccountRow({ displayName?: string; avatarUrl?: string; isBusy?: boolean; + isMobile?: boolean; unread?: { total: number; highlight: number }; onSwitch: (session: Session) => void; onSignOut: (session: Session) => void; @@ -91,6 +93,7 @@ function AccountRow({ style={{ opacity: isBusy ? 0.6 : undefined, height: 'auto', + background: isMobile ? color.Background.Container : undefined, }} before={ @@ -265,7 +268,11 @@ export function AccountMenuOption({ isMobile, isRight }: { isMobile: boolean; is } style={{ position: 'relative', - background: isOpen && !isMobile ? color.Secondary.Container : color.Surface.Container, + background: isMobile + ? color.Background.Container + : isOpen + ? color.Secondary.Container + : color.Surface.Container, }} onClick={() => isMobile && setIsOpen(!isOpen)} {...hoverProps} @@ -300,6 +307,7 @@ export function AccountMenuOption({ isMobile, isRight }: { isMobile: boolean; is border: 0, boxShadow: 'none', gap: 0, + background: color.Background.Container, } : {} } @@ -330,10 +338,17 @@ export function AccountMenuOption({ isMobile, isRight }: { isMobile: boolean; is onSignOut={(pendingSession) => { setConfirmSignOutSession(pendingSession); }} + isMobile={isMobile} /> ); })} - + Add Account @@ -388,11 +403,9 @@ const PresenceOptions: Array<{ value: Presence; label: string }> = [ ]; export function PresenceMenuOption({ - initialOpen, isMobile, isRight, }: { - initialOpen: boolean; isMobile: boolean; isRight?: boolean; }) { @@ -403,7 +416,7 @@ export function PresenceMenuOption({ const presence = useUserPresence(userId); const currentPresence = presence?.presence ?? Presence.Online; - const [isOpen, setIsOpen] = useState(initialOpen); + const [isOpen, setIsOpen] = useState(false); const { hoverProps } = useHover({ onHoverChange: (h) => { @@ -471,7 +484,11 @@ export function PresenceMenuOption({ } style={{ position: 'relative', - background: isOpen && !isMobile ? color.Secondary.Container : color.Surface.Container, + background: isMobile + ? color.Background.Container + : isOpen + ? color.Secondary.Container + : color.Surface.Container, }} onClick={() => isMobile && setIsOpen(!isOpen)} {...hoverProps} @@ -505,6 +522,7 @@ export function PresenceMenuOption({ border: 0, boxShadow: 'none', gap: 0, + background: color.Background.Container, } : {} } @@ -657,7 +675,7 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi const handleCloseMenu = () => setMenuAnchor(undefined); return ( - + {isMobile && ( - + Account )} @@ -756,7 +774,6 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi window.innerWidth / 2} /> From 2179d9d082c7314bbcb4af49c6e9b81fa064d22d Mon Sep 17 00:00:00 2001 From: Shea Date: Sun, 5 Jul 2026 21:36:43 +0300 Subject: [PATCH 14/16] fix navigation and some visual issues Co-authored-by: niki <295591807+nikiwastaken@users.noreply.github.com> Signed-off-by: Shea --- src/app/components/message/modals/Options.tsx | 39 ++++-- src/app/features/navigate/NavigateModal.tsx | 20 +++- src/app/pages/client/inbox/Inbox.tsx | 7 +- src/app/pages/client/navigate/Navigate.tsx | 43 +++---- src/app/pages/client/profile/Profile.tsx | 113 +++++++++--------- src/app/pages/client/sidebar/UserMenuTab.tsx | 2 +- 6 files changed, 129 insertions(+), 95 deletions(-) diff --git a/src/app/components/message/modals/Options.tsx b/src/app/components/message/modals/Options.tsx index 371086069..da37d35bd 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 && @@ -654,35 +671,35 @@ export function OptionMenu({ Reply - {canEditEvent(mx, mEvent) && onEditId && ( + {!isThreadedMessage && ( { - onEditId(mEvent.getId()); + onClick={(evt) => { + onReplyClick(evt, true); onTotalClose(); }} > - Edit Message + Reply in Thread )} - {!isThreadedMessage && ( + {canEditEvent(mx, mEvent) && onEditId && !isModal && ( { - onReplyClick(evt, true); + onClick={() => { + onEditId(mEvent.getId()); onTotalClose(); }} > - Reply in Thread + Edit Message )} diff --git a/src/app/features/navigate/NavigateModal.tsx b/src/app/features/navigate/NavigateModal.tsx index 8da2f7a3d..3687ef2eb 100644 --- a/src/app/features/navigate/NavigateModal.tsx +++ b/src/app/features/navigate/NavigateModal.tsx @@ -2,6 +2,7 @@ import FocusTrap from 'focus-trap-react'; import { Avatar, Box, + color, config, IconButton, Input, @@ -133,9 +134,10 @@ export type RoomSearchPickRoomConfig = { export type RoomSearchModalProps = { requestClose?: () => void; pickRoom?: RoomSearchPickRoomConfig; + isMobile?: boolean; }; -export function RoomSearchModal({ requestClose, pickRoom }: RoomSearchModalProps) { +export function RoomSearchModal({ requestClose, pickRoom, isMobile }: RoomSearchModalProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const scrollRef = useRef(null); @@ -307,7 +309,10 @@ export function RoomSearchModal({ requestClose, pickRoom }: RoomSearchModalProps - + diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx index f2d819f1d..0439c5fc7 100644 --- a/src/app/pages/client/inbox/Inbox.tsx +++ b/src/app/pages/client/inbox/Inbox.tsx @@ -91,7 +91,12 @@ export function Inbox() { {!hideText ? ( - + Inbox diff --git a/src/app/pages/client/navigate/Navigate.tsx b/src/app/pages/client/navigate/Navigate.tsx index bd238946e..a43b02f27 100644 --- a/src/app/pages/client/navigate/Navigate.tsx +++ b/src/app/pages/client/navigate/Navigate.tsx @@ -1,13 +1,6 @@ import { Box, Scroll, toRem, Text, color, config } from 'folds'; import { SquaresFour, sizedIcon } from '$components/icons/phosphor'; -import { - Page, - PageContent, - PageContentCenter, - PageHeroSection, - PageNav, - PageNavHeader, -} from '$components/page'; +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'; @@ -15,7 +8,6 @@ import { settingsAtom } from '$state/settings'; import { SidebarResizer } from '../sidebar/SidebarResizer'; import { useSetAtom } from 'jotai'; import { isResizingSidebarAtom } from '$state/isResizingSidebar'; -import { ListMagnifyingGlassIcon } from '@phosphor-icons/react'; import { RoomSearchModal } from '$features/navigate'; import { UserQuickTools } from '../sidebar/UserQuickTools'; @@ -41,8 +33,9 @@ export function Navigate() { position: 'relative', width: toRem(curWidth), borderRight: 'solid', - borderColor: color.SurfaceVariant.ContainerLine, + borderColor: color.Background.ContainerLine, borderWidth: `0 ${config.borderWidth.B300} 0 0`, + background: color.Background.Container, }} > @@ -50,8 +43,8 @@ export function Navigate() { {!hideText ? ( - - Navigate + + Inbox ) : ( @@ -59,6 +52,7 @@ export function Navigate() { )} + )} - + + + + {!hideText ? ( + + + Navigate + + + ) : ( + sizedIcon(SquaresFour, '200', { filled: true }) + )} + + - - - {sizedIcon(ListMagnifyingGlassIcon, '600')} - Navigate - - - + + + diff --git a/src/app/pages/client/profile/Profile.tsx b/src/app/pages/client/profile/Profile.tsx index 24f928b68..3373ee2bc 100644 --- a/src/app/pages/client/profile/Profile.tsx +++ b/src/app/pages/client/profile/Profile.tsx @@ -5,15 +5,13 @@ import { color, config, Menu, - Icon, - Icons, Line, MenuItem, OverlayBackdrop, Overlay, OverlayCenter, } from 'folds'; -import { SquaresFour, menuIcon, settingsNavIcon, sizedIcon } from '$components/icons/phosphor'; +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'; @@ -36,7 +34,7 @@ import { useUserProfile } from '$hooks/useUserProfile'; import type { SettingsMenuItem } from '$features/settings'; import { settingsMenuIcons, settingsSections, useOpenSettings } from '$features/settings'; import { UserQuickTools } from '../sidebar/UserQuickTools'; -import { SignOutIcon } from '@phosphor-icons/react'; +import { CaretDownIcon, CaretRightIcon, SignOutIcon } from '@phosphor-icons/react'; import { UseStateProvider } from '$components/UseStateProvider'; import { FocusTrap } from 'focus-trap-react'; import { LogoutDialog } from '$components/LogoutDialog'; @@ -51,7 +49,7 @@ export function ProfileMobile() { const userId = mx.getUserId() ?? ''; const profile = useUserProfile(userId); const presence = useUserPresence(userId); - const [isSettingsOpen, setIsSettingsOpen] = useState(true); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; const heroAvatarUrl = profile.avatarUrl @@ -144,17 +142,19 @@ export function ProfileMobile() { background: color.Background.Container, }} > - + + - - + + + @@ -169,18 +169,15 @@ export function ProfileMobile() { - + } - after={ - - } + before={menuIcon(GearSix)} + style={{ + background: isSettingsOpen ? color.Surface.Container : color.Background.Container, + }} + after={menuIcon(isSettingsOpen && isMobile ? CaretDownIcon : CaretRightIcon)} onClick={() => setIsSettingsOpen(!isSettingsOpen)} > @@ -188,17 +185,17 @@ export function ProfileMobile() { {isSettingsOpen && ( -
+ {menuItems.map((item) => { const IconComponent = item.icon; return ( openSettings(item.id)} > @@ -207,39 +204,39 @@ export function ProfileMobile() { ); })} + + )} - - {(logout, setLogout) => ( - <> - setLogout(true)} - > - Logout - - {logout && ( - }> - - setLogout(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - setLogout(false)} /> - - - - )} - + + {(logout, setLogout) => ( + + setLogout(true)} + > + Logout + + {logout && ( + }> + + setLogout(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + setLogout(false)} /> + + + )} - -
- )} +
+ )} +
diff --git a/src/app/pages/client/sidebar/UserMenuTab.tsx b/src/app/pages/client/sidebar/UserMenuTab.tsx index 6f321da7a..fcc5b2ffd 100644 --- a/src/app/pages/client/sidebar/UserMenuTab.tsx +++ b/src/app/pages/client/sidebar/UserMenuTab.tsx @@ -416,7 +416,7 @@ export function PresenceMenuOption({ const presence = useUserPresence(userId); const currentPresence = presence?.presence ?? Presence.Online; - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(isMobile); const { hoverProps } = useHover({ onHoverChange: (h) => { From 413ac64339517be031d4ed449c6be1042940db9c Mon Sep 17 00:00:00 2001 From: Shea Date: Sun, 5 Jul 2026 23:47:06 +0300 Subject: [PATCH 15/16] small changes Signed-off-by: Shea --- src/app/pages/client/navigate/Navigate.tsx | 14 +++---- src/app/pages/client/profile/Profile.tsx | 39 +++++++++++++++++--- src/app/pages/client/sidebar/UserMenuTab.tsx | 29 ++++++++------- 3 files changed, 54 insertions(+), 28 deletions(-) diff --git a/src/app/pages/client/navigate/Navigate.tsx b/src/app/pages/client/navigate/Navigate.tsx index a43b02f27..ccb95bc54 100644 --- a/src/app/pages/client/navigate/Navigate.tsx +++ b/src/app/pages/client/navigate/Navigate.tsx @@ -71,15 +71,11 @@ export function Navigate() { - {!hideText ? ( - - - Navigate - - - ) : ( - sizedIcon(SquaresFour, '200', { filled: true }) - )} + + + Navigate + + diff --git a/src/app/pages/client/profile/Profile.tsx b/src/app/pages/client/profile/Profile.tsx index 3373ee2bc..300afc1ff 100644 --- a/src/app/pages/client/profile/Profile.tsx +++ b/src/app/pages/client/profile/Profile.tsx @@ -34,7 +34,12 @@ 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, SignOutIcon } from '@phosphor-icons/react'; +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'; @@ -133,6 +138,15 @@ export function ProfileMobile() { justifyContent="SpaceBetween" style={{ width: '100%', minWidth: '100%' }} > + + + + + Account + + + + + openSettings('account')} + > + + Edit profile + + - + @@ -209,7 +236,8 @@ export function ProfileMobile() { {(logout, setLogout) => ( - + <> + setLogout(true)} > - Logout + Logout {logout && ( }> @@ -234,9 +262,10 @@ export function ProfileMobile() { )} - + )} +
diff --git a/src/app/pages/client/sidebar/UserMenuTab.tsx b/src/app/pages/client/sidebar/UserMenuTab.tsx index fcc5b2ffd..ae516cdac 100644 --- a/src/app/pages/client/sidebar/UserMenuTab.tsx +++ b/src/app/pages/client/sidebar/UserMenuTab.tsx @@ -8,8 +8,6 @@ import { Chip, Dialog, Header, - Icon, - Icons, Line, Menu, MenuItem, @@ -38,7 +36,7 @@ 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, getProfilePath, withSearchParam } from '$pages/pathUtils'; @@ -56,7 +54,14 @@ import { useUnverifiedDeviceCount, VerificationStatus, } from '$hooks/useDeviceVerificationStatus'; -import { ShieldWarningIcon } from '@phosphor-icons/react'; +import { + CaretDownIcon, + CaretRightIcon, + GearSixIcon, + PencilSimpleIcon, + ShieldWarningIcon, + UserIcon, +} from '@phosphor-icons/react'; import * as css from './UserMenuTab.css'; const log = createLogger('AccountSwitcherTab'); @@ -262,10 +267,8 @@ export function AccountMenuOption({ isMobile, isRight }: { isMobile: boolean; is } - after={ - - } + before={menuIcon(UserIcon)} + after={isOpen && isMobile ? menuIcon(CaretDownIcon) : menuIcon(CaretRightIcon)} style={{ position: 'relative', background: isMobile @@ -465,7 +468,7 @@ export function PresenceMenuOption({ display: 'flex', justifyContent: 'center', alignContent: 'center', - width: 18, + width: toRem(20), }} > {savingStatus ? ( @@ -479,9 +482,7 @@ export function PresenceMenuOption({ )}
} - after={ - - } + after={isOpen && isMobile ? menuIcon(CaretDownIcon) : menuIcon(CaretRightIcon)} style={{ position: 'relative', background: isMobile @@ -766,7 +767,7 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi onClick={() => openSettings('account')} size="300" radii="300" - before={} + before={menuIcon(PencilSimpleIcon)} > Edit Profile @@ -790,7 +791,7 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi } + before={menuIcon(GearSixIcon)} onClick={() => openSettings()} > From 87f19186ac3fbba0da7767a5ac3644202ff3ec64 Mon Sep 17 00:00:00 2001 From: Shea Date: Mon, 6 Jul 2026 13:18:20 +0300 Subject: [PATCH 16/16] fix double dipping Signed-off-by: Shea --- src/app/components/sidebar/Sidebar.css.ts | 1 - src/app/pages/client/sidebar/UserMenuTab.tsx | 60 +++++++++++-------- .../client/sidebar/UserQuickTools.css.ts | 2 +- .../pages/client/sidebar/UserQuickTools.tsx | 5 +- 4 files changed, 39 insertions(+), 29 deletions(-) diff --git a/src/app/components/sidebar/Sidebar.css.ts b/src/app/components/sidebar/Sidebar.css.ts index db345edaa..e4492df8e 100644 --- a/src/app/components/sidebar/Sidebar.css.ts +++ b/src/app/components/sidebar/Sidebar.css.ts @@ -123,7 +123,6 @@ export const SidebarItemBottom = recipe({ base: [ DefaultReset, { - minHeight: toRem(30), display: 'flex', alignItems: 'center', justifyContent: 'center', diff --git a/src/app/pages/client/sidebar/UserMenuTab.tsx b/src/app/pages/client/sidebar/UserMenuTab.tsx index ae516cdac..72e66f482 100644 --- a/src/app/pages/client/sidebar/UserMenuTab.tsx +++ b/src/app/pages/client/sidebar/UserMenuTab.tsx @@ -670,37 +670,45 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi } const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((cur) => (cur ? undefined : cords)); + setMenuAnchor(() => cords); }; const handleCloseMenu = () => setMenuAnchor(undefined); + const isActive = (!!menuAnchor || profileSelected) && !isMobile; + return ( - - - - }> - - {nameInitials(displayName)}} - /> - - - + + + }> + + {nameInitials(displayName)}} + /> + + {isMobile && ( Account diff --git a/src/app/pages/client/sidebar/UserQuickTools.css.ts b/src/app/pages/client/sidebar/UserQuickTools.css.ts index 0e194fd8d..f085e5fc8 100644 --- a/src/app/pages/client/sidebar/UserQuickTools.css.ts +++ b/src/app/pages/client/sidebar/UserQuickTools.css.ts @@ -7,6 +7,6 @@ export const UserQuickTools = style({ zIndex: '1000', height: toRem(74), bottom: '0', - 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 e65799d61..b9357e8e4 100644 --- a/src/app/pages/client/sidebar/UserQuickTools.tsx +++ b/src/app/pages/client/sidebar/UserQuickTools.tsx @@ -38,6 +38,7 @@ export function UserQuickTools({ width: toRem(width ?? 100), position: 'absolute', right: '0', + padding: `0 ${config.space.S300}`, } } > @@ -46,7 +47,9 @@ export function UserQuickTools({ - + + + ) : ( <>