Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/feat_right_click_rename_folder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Added the ability to right click on a folder to rename it.
12 changes: 12 additions & 0 deletions src/app/hooks/useSidebarItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,18 @@ export const useSidebarItems = (
return [sidebarItems, setSidebarItems];
};

export const renameSidebarFolderItem = (
items: SidebarItems,
folderId: string,
name: string | undefined
): SidebarItems => {
const trimmed = name?.trim();
const nextName = trimmed ? trimmed : undefined;
return items.map((item) =>
typeof item === 'object' && item.id === folderId ? { ...item, name: nextName } : item
);
};

export const sidebarItemWithout = (items: SidebarItems, roomId: string) => {
const newItems: SidebarItems = items
.map((item) => {
Expand Down
221 changes: 213 additions & 8 deletions src/app/pages/client/sidebar/SpaceTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
import type { MouseEventHandler, ReactNode, RefObject } from 'react';
import type { FormEventHandler, MouseEventHandler, ReactNode, RefObject, ChangeEvent } from 'react';
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { RectCords } from 'folds';
import {
Box,
Button,
Dialog,
Header,
Icon,
IconButton,
Icons,
Input,
Line,
Menu,
MenuItem,
Overlay,
OverlayBackdrop,
OverlayCenter,
PopOut,
Text,
config,
toRem,
} from 'folds';
import { useAtom, useAtomValue } from 'jotai';
import type { Room } from '$types/matrix-sdk';
import type { MatrixClient, Room } from '$types/matrix-sdk';
import {
draggable,
dropTargetForElements,
Expand Down Expand Up @@ -58,6 +65,7 @@ import type { ISidebarFolder, SidebarItems, TSidebarItem } from '$hooks/useSideb
import {
makeCinnySpacesContent,
parseSidebar,
renameSidebarFolderItem,
sidebarItemWithout,
useSidebarItems,
} from '$hooks/useSidebarItems';
Expand Down Expand Up @@ -213,6 +221,121 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
}
);

type FolderMenuProps = {
requestClose: () => void;
onRename: () => void;
};
const FolderMenu = forwardRef<HTMLDivElement, FolderMenuProps>(
({ requestClose, onRename }, ref) => (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
radii="300"
onClick={() => {
onRename();
requestClose();
}}
after={<Icon size="100" src={Icons.Pencil} />}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Rename
</Text>
</MenuItem>
</Box>
</Menu>
)
);

const FOLDER_NAME_MAX_LENGTH = 200;

const folderDefaultDisplayName = (mx: MatrixClient, folder: ISidebarFolder): string => {
const auto = folder.content.map((i) => mx.getRoom(i)?.name ?? '').join(', ');
return (folder.name ?? auto) || 'Unnamed';
};

type RenameFolderDialogProps = {
mx: MatrixClient;
folder: ISidebarFolder;
onClose: () => void;
onSave: (name: string) => void;
};
function RenameFolderDialog({ mx, folder, onClose, onSave }: Readonly<RenameFolderDialogProps>) {
const [draft, setDraft] = useState(() => folderDefaultDisplayName(mx, folder));

useEffect(() => {
setDraft(folderDefaultDisplayName(mx, folder));
}, [mx, folder]);

const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
onSave(draft);
};

return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: onClose,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Rename Folder</Text>
</Box>
<IconButton size="300" onClick={onClose} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box
as="form"
onSubmit={handleSubmit}
style={{ padding: config.space.S400 }}
direction="Column"
gap="400"
>
<Text priority="400" size="T300">
Choose a short label for this folder. Leave empty to show space names again.
</Text>
<Box direction="Column" gap="100">
<Text size="L400">Folder name</Text>
<Input
name="folderName"
variant="Background"
value={draft}
maxLength={FOLDER_NAME_MAX_LENGTH}
onChange={(e: ChangeEvent<HTMLInputElement>) => setDraft(e.target.value)}
autoFocus
/>
</Box>
<Box direction="Row" gap="200" justifyContent="End">
<Button type="button" variant="Secondary" fill="Soft" onClick={onClose}>
<Text size="B400">Cancel</Text>
</Button>
<Button type="submit" variant="Primary">
<Text size="B400">Save</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

type InstructionType = Instruction['type'];
type FolderDraggable = {
folder: ISidebarFolder;
Expand Down Expand Up @@ -495,9 +618,15 @@ function SpaceTab({
type OpenedSpaceFolderProps = {
folder: ISidebarFolder;
onClose: MouseEventHandler<HTMLButtonElement>;
onFolderContextMenu?: MouseEventHandler<HTMLDivElement>;
children?: ReactNode;
};
function OpenedSpaceFolder({ folder, onClose, children }: Readonly<OpenedSpaceFolderProps>) {
function OpenedSpaceFolder({
folder,
onClose,
onFolderContextMenu,
children,
}: Readonly<OpenedSpaceFolderProps>) {
const aboveTargetRef = useRef<HTMLDivElement>(null);
const belowTargetRef = useRef<HTMLDivElement>(null);

Expand All @@ -513,7 +642,7 @@ function OpenedSpaceFolder({ folder, onClose, children }: Readonly<OpenedSpaceFo
data-drop-below={orderBelow === 'reorder-below'}
>
<SidebarFolderDropTarget ref={aboveTargetRef} position="Top" />
<SidebarAvatar size="300">
<SidebarAvatar size="300" onContextMenu={onFolderContextMenu}>
<IconButton data-id={folder.id} size="300" variant="Background" onClick={onClose}>
<Icon size="400" src={Icons.ChevronTop} filled />
</IconButton>
Expand All @@ -530,13 +659,15 @@ type ClosedSpaceFolderProps = {
onOpen: MouseEventHandler<HTMLButtonElement>;
onDragging: (dragItem?: SidebarDraggable) => void;
disabled?: boolean;
onFolderContextMenu?: MouseEventHandler<HTMLButtonElement>;
};
function ClosedSpaceFolder({
folder,
selected,
onOpen,
onDragging,
disabled,
onFolderContextMenu,
}: Readonly<ClosedSpaceFolderProps>) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
Expand All @@ -547,8 +678,7 @@ function ClosedSpaceFolder({
const dropState = useDropTarget(spaceDraggable, handlerRef);
const dropType = dropState?.type;

const tooltipName =
folder.name ?? folder.content.map((i) => mx.getRoom(i)?.name ?? '').join(', ') ?? 'Unnamed';
const tooltipName = folderDefaultDisplayName(mx, folder);

return (
<RoomsUnreadProvider rooms={folder.content}>
Expand All @@ -563,7 +693,13 @@ function ClosedSpaceFolder({
>
<SidebarItemTooltip tooltip={disabled ? undefined : tooltipName}>
{(tooltipRef) => (
<SidebarFolder data-id={folder.id} as="button" ref={tooltipRef} onClick={onOpen}>
<SidebarFolder
data-id={folder.id}
as="button"
ref={tooltipRef}
onClick={onOpen}
onContextMenu={onFolderContextMenu}
>
{folder.content.map((sId) => {
const space = mx.getRoom(sId);
if (!space) return null;
Expand Down Expand Up @@ -612,6 +748,33 @@ export function SpaceTabs({ scrollRef }: Readonly<SpaceTabsProps>) {
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const [openedFolder, setOpenedFolder] = useAtom(useOpenedSidebarFolderAtom());
const [draggingItem, setDraggingItem] = useState<SidebarDraggable>();
const [folderMenuState, setFolderMenuState] = useState<{
folder: ISidebarFolder;
anchor: RectCords;
}>();
const [renameTargetFolder, setRenameTargetFolder] = useState<ISidebarFolder>();

const handleFolderContextMenu = useCallback(
(folder: ISidebarFolder): MouseEventHandler =>
(evt) => {
evt.preventDefault();
setFolderMenuState({
folder,
anchor: evt.currentTarget.getBoundingClientRect(),
});
},
[]
);

const handleRenameFolderApply = useCallback(
(folderId: string, rawName: string) => {
const newItems = renameSidebarFolderItem(sidebarItems, folderId, rawName);
const newSpacesContent = makeCinnySpacesContent(mx, newItems);
localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent));
mx.setAccountData(CustomAccountDataEvent.CinnySpaces, newSpacesContent);
},
[mx, sidebarItems, orphanSpaces, localEchoSidebarItem]
);

useDnDMonitor(
scrollRef,
Expand Down Expand Up @@ -797,13 +960,54 @@ export function SpaceTabs({ scrollRef }: Readonly<SpaceTabsProps>) {
if (sidebarItems.length === 0) return null;
return (
<>
{folderMenuState && (
<PopOut
anchor={folderMenuState.anchor}
position="Right"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setFolderMenuState(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<FolderMenu
requestClose={() => setFolderMenuState(undefined)}
onRename={() => setRenameTargetFolder(folderMenuState.folder)}
/>
</FocusTrap>
}
/>
)}
{renameTargetFolder && (
<RenameFolderDialog
mx={mx}
folder={renameTargetFolder}
onClose={() => setRenameTargetFolder(undefined)}
onSave={(name) => {
handleRenameFolderApply(renameTargetFolder.id, name);
setRenameTargetFolder(undefined);
}}
/>
)}
<SidebarStackSeparator />
<SidebarStack>
{sidebarItems.map((item) => {
if (typeof item === 'object') {
if (openedFolder.has(item.id)) {
return (
<OpenedSpaceFolder key={item.id} folder={item} onClose={handleFolderToggle}>
<OpenedSpaceFolder
key={item.id}
folder={item}
onClose={handleFolderToggle}
onFolderContextMenu={handleFolderContextMenu(item)}
>
{item.content.map((sId) => {
const space = mx.getRoom(sId);
if (!space) return null;
Expand Down Expand Up @@ -835,6 +1039,7 @@ export function SpaceTabs({ scrollRef }: Readonly<SpaceTabsProps>) {
selected={!!selectedSpaceId && item.content.includes(selectedSpaceId)}
onOpen={handleFolderToggle}
onDragging={setDraggingItem}
onFolderContextMenu={handleFolderContextMenu(item)}
disabled={
typeof draggingItem === 'object' ? draggingItem.folder.id === item.id : false
}
Expand Down
Loading