feat(call): add docked call panel and remote cursor overlay

- Implemented DockedCallPanel component to render the docked call interface when an active call is present.
- Created RemoteCursorOverlay component to display remote cursor positions during screen sharing.
- Added hooks for managing docked call state and remote cursor functionality.
- Introduced pending drag state management for seamless transitions between docked and floating states.
- Developed embed filter management components for personal and room-wide settings, allowing users to customize URL embed visibility.
- Implemented auto-joining functionality for rooms within spaces, enhancing user experience during space navigation.
- Added state management for tracking joining progress of rooms in spaces.
This commit is contained in:
2026-02-19 17:49:48 +11:00
parent bfbdf98468
commit 3f6f2134ad
42 changed files with 3801 additions and 219 deletions

View File

@@ -3,7 +3,6 @@ import { Provider as JotaiProvider } from 'jotai';
import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds';
import { RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ClientConfigLoader } from '../components/ClientConfigLoader';
import { ClientConfigProvider } from '../hooks/useClientConfig';
@@ -39,7 +38,6 @@ function App() {
<JotaiProvider>
<RouterProvider router={createRouter(clientConfig, screenSize)} />
</JotaiProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</ClientConfigProvider>
)}

View File

@@ -1,5 +1,6 @@
import React, { ReactNode } from 'react';
import { Box } from 'folds';
import { DockedCallPanel } from '../../features/call/DockedCallPanel';
type ClientLayoutProps = {
nav: ReactNode;
@@ -10,6 +11,7 @@ export function ClientLayout({ nav, children }: ClientLayoutProps) {
<Box grow="Yes">
<Box shrink="No">{nav}</Box>
<Box grow="Yes">{children}</Box>
<DockedCallPanel />
</Box>
);
}

View File

@@ -18,6 +18,7 @@ import {
SearchTab,
} from './sidebar';
import { CreateTab } from './sidebar/CreateTab';
import { HiddenSpacesTabs } from './sidebar/SpaceTabs';
export function SidebarNav() {
const scrollRef = useRef<HTMLDivElement>(null);
@@ -37,6 +38,7 @@ export function SidebarNav() {
<ExploreTab />
<CreateTab />
</SidebarStack>
<HiddenSpacesTabs />
</Scroll>
}
sticky={

View File

@@ -18,6 +18,7 @@ import {
import { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom, useAtomValue } from 'jotai';
import FocusTrap from 'focus-trap-react';
import { Room } from 'matrix-js-sdk';
import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort';
import {
NavButton,
@@ -64,44 +65,73 @@ import {
} from '../../../hooks/useRoomsNotificationPreferences';
import { UseStateProvider } from '../../../components/UseStateProvider';
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
import { ManageRoomsPrompt } from '../../../components/manage-rooms-prompt';
import { _RoomSearchParams } from '../../paths';
type HomeMenuProps = {
requestClose: () => void;
onManageRooms: () => void;
};
const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(({ requestClose }, ref) => {
const orphanRooms = useHomeRooms();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const mx = useMatrixClient();
const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(
({ requestClose, onManageRooms }, ref) => {
const orphanRooms = useHomeRooms();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const mx = useMatrixClient();
const handleMarkAsRead = () => {
if (!unread) return;
orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity));
requestClose();
};
const handleMarkAsRead = () => {
if (!unread) return;
orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity));
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
</Menu>
);
});
const handleManageRooms = () => {
requestClose();
onManageRooms();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
<MenuItem
onClick={handleManageRooms}
size="300"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
aria-disabled={orphanRooms.length === 0}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Manage Rooms
</Text>
</MenuItem>
</Box>
</Menu>
);
}
);
function HomeHeader() {
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [showManageRooms, setShowManageRooms] = useState(false);
const mx = useMatrixClient();
const orphanRooms = useHomeRooms();
const rooms = useMemo(() => {
return orphanRooms
.map((roomId) => mx.getRoom(roomId))
.filter((room): room is Room => room !== null);
}, [mx, orphanRooms]);
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const cords = evt.currentTarget.getBoundingClientRect();
@@ -144,10 +174,20 @@ function HomeHeader() {
escapeDeactivates: stopPropagation,
}}
>
<HomeMenu requestClose={() => setMenuAnchor(undefined)} />
<HomeMenu
requestClose={() => setMenuAnchor(undefined)}
onManageRooms={() => setShowManageRooms(true)}
/>
</FocusTrap>
}
/>
{showManageRooms && (
<ManageRoomsPrompt
rooms={rooms}
onDone={() => setShowManageRooms(false)}
onCancel={() => setShowManageRooms(false)}
/>
)}
</>
);
}

View File

@@ -69,8 +69,12 @@ import {
SidebarItems,
TSidebarItem,
makeCinnySpacesContent,
makeHideSpaceContent,
makeUnhideSpaceContent,
parseHiddenSpaces,
parseSidebar,
sidebarItemWithout,
useHiddenSpaces,
useSidebarItems,
} from '../../../hooks/useSidebarItems';
import { AccountDataEvent } from '../../../../types/matrix/accountData';
@@ -93,14 +97,17 @@ import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
import { UseStateProvider } from '../../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../../components/leave-space-prompt';
type SpaceMenuProps = {
room: Room;
requestClose: () => void;
onUnpin?: (roomId: string) => void;
onHide?: (roomId: string) => void;
};
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
({ room, requestClose, onUnpin }, ref) => {
({ room, requestClose, onUnpin, onHide }, ref) => {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const roomToParents = useAtomValue(roomToParentsAtom);
@@ -146,6 +153,11 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
requestClose();
};
const handleHide = () => {
onHide?.(room.roomId);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
{invitePrompt && room && (
@@ -181,6 +193,18 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
</Text>
</MenuItem>
)}
{onHide && (
<MenuItem
size="300"
radii="300"
onClick={handleHide}
after={<Icon size="100" src={Icons.EyeBlind} />}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Hide Space
</Text>
</MenuItem>
)}
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
@@ -219,6 +243,42 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
</Text>
</MenuItem>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={(evt) => {
if (evt.shiftKey) {
mx.leave(room.roomId);
requestClose();
} else {
setPromptLeave(true);
}
}}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
aria-pressed={promptLeave}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Leave Space
</Text>
</MenuItem>
{promptLeave && (
<LeaveSpacePrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
</Menu>
);
}
@@ -390,6 +450,7 @@ type SpaceTabProps = {
onDragging: (dragItem?: SidebarDraggable) => void;
disabled?: boolean;
onUnpin?: (roomId: string) => void;
onHide?: (roomId: string) => void;
};
function SpaceTab({
space,
@@ -399,6 +460,7 @@ function SpaceTab({
onDragging,
disabled,
onUnpin,
onHide,
}: SpaceTabProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
@@ -489,6 +551,7 @@ function SpaceTab({
room={space}
requestClose={() => setMenuAnchor(undefined)}
onUnpin={onUnpin}
onHide={onHide}
/>
</FocusTrap>
}
@@ -605,6 +668,176 @@ function ClosedSpaceFolder({
);
}
type HiddenSpacesFolderProps = {
hiddenSpaces: string[];
open: boolean;
onToggle: () => void;
onSpaceClick: MouseEventHandler<HTMLButtonElement>;
onUnhide: (roomId: string) => void;
selectedSpaceId?: string;
};
/**
* A special folder that displays hidden spaces
*/
function HiddenSpacesFolder({
hiddenSpaces,
open,
onToggle,
onSpaceClick,
onUnhide,
selectedSpaceId,
}: HiddenSpacesFolderProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [menuSpaceId, setMenuSpaceId] = useState<string>();
const handleContextMenu = (spaceId: string): MouseEventHandler<HTMLButtonElement> => (evt) => {
evt.preventDefault();
const cords = evt.currentTarget.getBoundingClientRect();
setMenuSpaceId(spaceId);
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
const handleUnhideClick = () => {
if (menuSpaceId) {
onUnhide(menuSpaceId);
}
setMenuAnchor(undefined);
};
const [hovered, setHovered] = useState(false);
// Check if any hidden space has unreads
const hasUnreads = hiddenSpaces.some((spaceId) => {
const space = mx.getRoom(spaceId);
return space && space.getUnreadNotificationCount() > 0;
});
if (!open) {
// Only show when hovered, has unreads, or a hidden space is selected
const isVisible = hovered || hasUnreads || (!!selectedSpaceId && hiddenSpaces.includes(selectedSpaceId));
return (
<RoomsUnreadProvider rooms={hiddenSpaces}>
{(unread) => (
<SidebarItem
active={!!selectedSpaceId && hiddenSpaces.includes(selectedSpaceId)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
opacity: isVisible ? 1 : 0,
transition: 'opacity 0.15s ease',
}}
>
<SidebarItemTooltip tooltip="Hidden Spaces">
{(tooltipRef) => (
<SidebarAvatar as="button" ref={tooltipRef} outlined onClick={onToggle}>
<Icon src={Icons.EyeBlind} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
{unread && (
<SidebarItemBadge hasCount={unread.total > 0}>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</SidebarItemBadge>
)}
</SidebarItem>
)}
</RoomsUnreadProvider>
);
}
return (
<SidebarFolder state="Open">
<SidebarAvatar size="300">
<IconButton size="300" variant="Background" onClick={onToggle}>
<Icon size="400" src={Icons.ChevronTop} filled />
</IconButton>
</SidebarAvatar>
{hiddenSpaces.map((spaceId) => {
const space = mx.getRoom(spaceId);
if (!space) return null;
const selected = space.roomId === selectedSpaceId;
return (
<RoomUnreadProvider key={space.roomId} roomId={space.roomId}>
{(unread) => (
<SidebarItem active={selected} data-inside-folder>
<SidebarItemTooltip tooltip={space.name}>
{(triggerRef) => (
<SidebarAvatar
as="button"
data-id={space.roomId}
ref={triggerRef}
size="300"
onClick={onSpaceClick}
onContextMenu={handleContextMenu(space.roomId)}
>
<RoomAvatar
roomId={space.roomId}
src={getRoomAvatarUrl(mx, space, 96, useAuthentication) ?? undefined}
alt={space.name}
renderFallback={() => (
<Text size="H6">{nameInitials(space.name, 2)}</Text>
)}
/>
</SidebarAvatar>
)}
</SidebarItemTooltip>
{unread && (
<SidebarItemBadge hasCount={unread.total > 0}>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</SidebarItemBadge>
)}
</SidebarItem>
)}
</RoomUnreadProvider>
);
})}
{menuAnchor && (
<PopOut
anchor={menuAnchor}
position="Right"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
radii="300"
onClick={handleUnhideClick}
after={<Icon size="100" src={Icons.Eye} />}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Unhide Space
</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
/>
)}
</SidebarFolder>
);
}
type SpaceTabsProps = {
scrollRef: RefObject<HTMLDivElement>;
};
@@ -615,6 +848,7 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
const roomToParents = useAtomValue(roomToParentsAtom);
const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
const [sidebarItems, localEchoSidebarItem] = useSidebarItems(orphanSpaces);
const [, localEchoHiddenSpaces] = useHiddenSpaces();
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const [openedFolder, setOpenedFolder] = useAtom(useOpenedSidebarFolderAtom());
const [draggingItem, setDraggingItem] = useState<SidebarDraggable>();
@@ -797,6 +1031,16 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
[mx, sidebarItems, orphanSpaces, localEchoSidebarItem]
);
const handleHide = useCallback(
(roomId: string) => {
const newContent = makeHideSpaceContent(mx, roomId);
localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newContent));
localEchoHiddenSpaces(parseHiddenSpaces(mx, newContent));
mx.setAccountData(AccountDataEvent.CinnySpaces, newContent);
},
[mx, orphanSpaces, localEchoSidebarItem, localEchoHiddenSpaces]
);
if (sidebarItems.length === 0) return null;
return (
<>
@@ -824,6 +1068,7 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
: false
}
onUnpin={orphanSpaces.includes(space.roomId) ? undefined : handleUnpin}
onHide={handleHide}
/>
);
})}
@@ -857,6 +1102,7 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
onDragging={setDraggingItem}
disabled={typeof draggingItem === 'string' ? draggingItem === space.roomId : false}
onUnpin={orphanSpaces.includes(space.roomId) ? undefined : handleUnpin}
onHide={handleHide}
/>
);
})}
@@ -864,3 +1110,67 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
</>
);
}
/**
* Separate component for hidden spaces that can be rendered after other sidebar items
*/
export function HiddenSpacesTabs() {
const navigate = useNavigate();
const mx = useMatrixClient();
const screenSize = useScreenSizeContext();
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, useAtomValue(roomToParentsAtom));
const [hiddenSpaces, localEchoHiddenSpaces] = useHiddenSpaces();
const [, localEchoSidebarItem] = useSidebarItems(orphanSpaces);
const [hiddenFolderOpen, setHiddenFolderOpen] = useState(false);
const selectedSpaceId = useSelectedSpace();
const handleSpaceClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget;
const targetSpaceId = target.getAttribute('data-id');
if (!targetSpaceId) return;
const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId));
if (screenSize === ScreenSize.Mobile) {
navigate(spacePath);
return;
}
const activePath = navToActivePath.get(targetSpaceId);
if (activePath && activePath.pathname.startsWith(spacePath)) {
navigate(joinPathComponent(activePath));
return;
}
navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId)));
};
const handleUnhide = useCallback(
(roomId: string) => {
const newContent = makeUnhideSpaceContent(mx, roomId);
localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newContent));
localEchoHiddenSpaces(parseHiddenSpaces(mx, newContent));
mx.setAccountData(AccountDataEvent.CinnySpaces, newContent);
},
[mx, orphanSpaces, localEchoSidebarItem, localEchoHiddenSpaces]
);
if (hiddenSpaces.length === 0) return null;
return (
<>
<SidebarStackSeparator />
<SidebarStack>
<HiddenSpacesFolder
hiddenSpaces={hiddenSpaces}
open={hiddenFolderOpen}
onToggle={() => setHiddenFolderOpen(!hiddenFolderOpen)}
onSpaceClick={handleSpaceClick}
onUnhide={handleUnhide}
selectedSpaceId={selectedSpaceId}
/>
</SidebarStack>
</>
);
}