1- import type { MouseEventHandler , ReactNode , RefObject } from 'react' ;
1+ import type { FormEventHandler , MouseEventHandler , ReactNode , RefObject , ChangeEvent } from 'react' ;
22import { forwardRef , useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
33import { useNavigate } from 'react-router-dom' ;
44import type { RectCords } from 'folds' ;
55import {
66 Box ,
7+ Button ,
8+ Dialog ,
9+ Header ,
710 Icon ,
811 IconButton ,
912 Icons ,
13+ Input ,
1014 Line ,
1115 Menu ,
1216 MenuItem ,
17+ Overlay ,
18+ OverlayBackdrop ,
19+ OverlayCenter ,
1320 PopOut ,
1421 Text ,
1522 config ,
1623 toRem ,
1724} from 'folds' ;
1825import { useAtom , useAtomValue } from 'jotai' ;
19- import type { Room } from '$types/matrix-sdk' ;
26+ import type { MatrixClient , Room } from '$types/matrix-sdk' ;
2027import {
2128 draggable ,
2229 dropTargetForElements ,
@@ -58,6 +65,7 @@ import type { ISidebarFolder, SidebarItems, TSidebarItem } from '$hooks/useSideb
5865import {
5966 makeCinnySpacesContent ,
6067 parseSidebar ,
68+ renameSidebarFolderItem ,
6169 sidebarItemWithout ,
6270 useSidebarItems ,
6371} from '$hooks/useSidebarItems' ;
@@ -213,6 +221,121 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
213221 }
214222) ;
215223
224+ type FolderMenuProps = {
225+ requestClose : ( ) => void ;
226+ onRename : ( ) => void ;
227+ } ;
228+ const FolderMenu = forwardRef < HTMLDivElement , FolderMenuProps > (
229+ ( { requestClose, onRename } , ref ) => (
230+ < Menu ref = { ref } style = { { maxWidth : toRem ( 160 ) , width : '100vw' } } >
231+ < Box direction = "Column" gap = "100" style = { { padding : config . space . S100 } } >
232+ < MenuItem
233+ size = "300"
234+ radii = "300"
235+ onClick = { ( ) => {
236+ onRename ( ) ;
237+ requestClose ( ) ;
238+ } }
239+ after = { < Icon size = "100" src = { Icons . Pencil } /> }
240+ >
241+ < Text style = { { flexGrow : 1 } } as = "span" size = "T300" truncate >
242+ Rename
243+ </ Text >
244+ </ MenuItem >
245+ </ Box >
246+ </ Menu >
247+ )
248+ ) ;
249+
250+ const FOLDER_NAME_MAX_LENGTH = 200 ;
251+
252+ const folderDefaultDisplayName = ( mx : MatrixClient , folder : ISidebarFolder ) : string => {
253+ const auto = folder . content . map ( ( i ) => mx . getRoom ( i ) ?. name ?? '' ) . join ( ', ' ) ;
254+ return ( folder . name ?? auto ) || 'Unnamed' ;
255+ } ;
256+
257+ type RenameFolderDialogProps = {
258+ mx : MatrixClient ;
259+ folder : ISidebarFolder ;
260+ onClose : ( ) => void ;
261+ onSave : ( name : string ) => void ;
262+ } ;
263+ function RenameFolderDialog ( { mx, folder, onClose, onSave } : Readonly < RenameFolderDialogProps > ) {
264+ const [ draft , setDraft ] = useState ( ( ) => folderDefaultDisplayName ( mx , folder ) ) ;
265+
266+ useEffect ( ( ) => {
267+ setDraft ( folderDefaultDisplayName ( mx , folder ) ) ;
268+ } , [ mx , folder ] ) ;
269+
270+ const handleSubmit : FormEventHandler < HTMLFormElement > = ( evt ) => {
271+ evt . preventDefault ( ) ;
272+ onSave ( draft ) ;
273+ } ;
274+
275+ return (
276+ < Overlay open backdrop = { < OverlayBackdrop /> } >
277+ < OverlayCenter >
278+ < FocusTrap
279+ focusTrapOptions = { {
280+ initialFocus : false ,
281+ clickOutsideDeactivates : true ,
282+ onDeactivate : onClose ,
283+ escapeDeactivates : stopPropagation ,
284+ } }
285+ >
286+ < Dialog variant = "Surface" >
287+ < Header
288+ style = { {
289+ padding : `0 ${ config . space . S200 } 0 ${ config . space . S400 } ` ,
290+ borderBottomWidth : config . borderWidth . B300 ,
291+ } }
292+ variant = "Surface"
293+ size = "500"
294+ >
295+ < Box grow = "Yes" >
296+ < Text size = "H4" > Rename Folder</ Text >
297+ </ Box >
298+ < IconButton size = "300" onClick = { onClose } radii = "300" >
299+ < Icon src = { Icons . Cross } />
300+ </ IconButton >
301+ </ Header >
302+ < Box
303+ as = "form"
304+ onSubmit = { handleSubmit }
305+ style = { { padding : config . space . S400 } }
306+ direction = "Column"
307+ gap = "400"
308+ >
309+ < Text priority = "400" size = "T300" >
310+ Choose a short label for this folder. Leave empty to show space names again.
311+ </ Text >
312+ < Box direction = "Column" gap = "100" >
313+ < Text size = "L400" > Folder name</ Text >
314+ < Input
315+ name = "folderName"
316+ variant = "Background"
317+ value = { draft }
318+ maxLength = { FOLDER_NAME_MAX_LENGTH }
319+ onChange = { ( e : ChangeEvent < HTMLInputElement > ) => setDraft ( e . target . value ) }
320+ autoFocus
321+ />
322+ </ Box >
323+ < Box direction = "Row" gap = "200" justifyContent = "End" >
324+ < Button type = "button" variant = "Secondary" fill = "Soft" onClick = { onClose } >
325+ < Text size = "B400" > Cancel</ Text >
326+ </ Button >
327+ < Button type = "submit" variant = "Primary" >
328+ < Text size = "B400" > Save</ Text >
329+ </ Button >
330+ </ Box >
331+ </ Box >
332+ </ Dialog >
333+ </ FocusTrap >
334+ </ OverlayCenter >
335+ </ Overlay >
336+ ) ;
337+ }
338+
216339type InstructionType = Instruction [ 'type' ] ;
217340type FolderDraggable = {
218341 folder : ISidebarFolder ;
@@ -495,9 +618,15 @@ function SpaceTab({
495618type OpenedSpaceFolderProps = {
496619 folder : ISidebarFolder ;
497620 onClose : MouseEventHandler < HTMLButtonElement > ;
621+ onFolderContextMenu ?: MouseEventHandler < HTMLDivElement > ;
498622 children ?: ReactNode ;
499623} ;
500- function OpenedSpaceFolder ( { folder, onClose, children } : Readonly < OpenedSpaceFolderProps > ) {
624+ function OpenedSpaceFolder ( {
625+ folder,
626+ onClose,
627+ onFolderContextMenu,
628+ children,
629+ } : Readonly < OpenedSpaceFolderProps > ) {
501630 const aboveTargetRef = useRef < HTMLDivElement > ( null ) ;
502631 const belowTargetRef = useRef < HTMLDivElement > ( null ) ;
503632
@@ -513,7 +642,7 @@ function OpenedSpaceFolder({ folder, onClose, children }: Readonly<OpenedSpaceFo
513642 data-drop-below = { orderBelow === 'reorder-below' }
514643 >
515644 < SidebarFolderDropTarget ref = { aboveTargetRef } position = "Top" />
516- < SidebarAvatar size = "300" >
645+ < SidebarAvatar size = "300" onContextMenu = { onFolderContextMenu } >
517646 < IconButton data-id = { folder . id } size = "300" variant = "Background" onClick = { onClose } >
518647 < Icon size = "400" src = { Icons . ChevronTop } filled />
519648 </ IconButton >
@@ -530,13 +659,15 @@ type ClosedSpaceFolderProps = {
530659 onOpen : MouseEventHandler < HTMLButtonElement > ;
531660 onDragging : ( dragItem ?: SidebarDraggable ) => void ;
532661 disabled ?: boolean ;
662+ onFolderContextMenu ?: MouseEventHandler < HTMLButtonElement > ;
533663} ;
534664function ClosedSpaceFolder ( {
535665 folder,
536666 selected,
537667 onOpen,
538668 onDragging,
539669 disabled,
670+ onFolderContextMenu,
540671} : Readonly < ClosedSpaceFolderProps > ) {
541672 const mx = useMatrixClient ( ) ;
542673 const useAuthentication = useMediaAuthentication ( ) ;
@@ -547,8 +678,7 @@ function ClosedSpaceFolder({
547678 const dropState = useDropTarget ( spaceDraggable , handlerRef ) ;
548679 const dropType = dropState ?. type ;
549680
550- const tooltipName =
551- folder . name ?? folder . content . map ( ( i ) => mx . getRoom ( i ) ?. name ?? '' ) . join ( ', ' ) ?? 'Unnamed' ;
681+ const tooltipName = folderDefaultDisplayName ( mx , folder ) ;
552682
553683 return (
554684 < RoomsUnreadProvider rooms = { folder . content } >
@@ -563,7 +693,13 @@ function ClosedSpaceFolder({
563693 >
564694 < SidebarItemTooltip tooltip = { disabled ? undefined : tooltipName } >
565695 { ( tooltipRef ) => (
566- < SidebarFolder data-id = { folder . id } as = "button" ref = { tooltipRef } onClick = { onOpen } >
696+ < SidebarFolder
697+ data-id = { folder . id }
698+ as = "button"
699+ ref = { tooltipRef }
700+ onClick = { onOpen }
701+ onContextMenu = { onFolderContextMenu }
702+ >
567703 { folder . content . map ( ( sId ) => {
568704 const space = mx . getRoom ( sId ) ;
569705 if ( ! space ) return null ;
@@ -612,6 +748,33 @@ export function SpaceTabs({ scrollRef }: Readonly<SpaceTabsProps>) {
612748 const navToActivePath = useAtomValue ( useNavToActivePathAtom ( ) ) ;
613749 const [ openedFolder , setOpenedFolder ] = useAtom ( useOpenedSidebarFolderAtom ( ) ) ;
614750 const [ draggingItem , setDraggingItem ] = useState < SidebarDraggable > ( ) ;
751+ const [ folderMenuState , setFolderMenuState ] = useState < {
752+ folder : ISidebarFolder ;
753+ anchor : RectCords ;
754+ } > ( ) ;
755+ const [ renameTargetFolder , setRenameTargetFolder ] = useState < ISidebarFolder > ( ) ;
756+
757+ const handleFolderContextMenu = useCallback (
758+ ( folder : ISidebarFolder ) : MouseEventHandler =>
759+ ( evt ) => {
760+ evt . preventDefault ( ) ;
761+ setFolderMenuState ( {
762+ folder,
763+ anchor : evt . currentTarget . getBoundingClientRect ( ) ,
764+ } ) ;
765+ } ,
766+ [ ]
767+ ) ;
768+
769+ const handleRenameFolderApply = useCallback (
770+ ( folderId : string , rawName : string ) => {
771+ const newItems = renameSidebarFolderItem ( sidebarItems , folderId , rawName ) ;
772+ const newSpacesContent = makeCinnySpacesContent ( mx , newItems ) ;
773+ localEchoSidebarItem ( parseSidebar ( mx , orphanSpaces , newSpacesContent ) ) ;
774+ mx . setAccountData ( CustomAccountDataEvent . CinnySpaces , newSpacesContent ) ;
775+ } ,
776+ [ mx , sidebarItems , orphanSpaces , localEchoSidebarItem ]
777+ ) ;
615778
616779 useDnDMonitor (
617780 scrollRef ,
@@ -797,13 +960,54 @@ export function SpaceTabs({ scrollRef }: Readonly<SpaceTabsProps>) {
797960 if ( sidebarItems . length === 0 ) return null ;
798961 return (
799962 < >
963+ { folderMenuState && (
964+ < PopOut
965+ anchor = { folderMenuState . anchor }
966+ position = "Right"
967+ align = "Start"
968+ content = {
969+ < FocusTrap
970+ focusTrapOptions = { {
971+ initialFocus : false ,
972+ returnFocusOnDeactivate : false ,
973+ onDeactivate : ( ) => setFolderMenuState ( undefined ) ,
974+ clickOutsideDeactivates : true ,
975+ isKeyForward : ( evt : KeyboardEvent ) => evt . key === 'ArrowDown' ,
976+ isKeyBackward : ( evt : KeyboardEvent ) => evt . key === 'ArrowUp' ,
977+ escapeDeactivates : stopPropagation ,
978+ } }
979+ >
980+ < FolderMenu
981+ requestClose = { ( ) => setFolderMenuState ( undefined ) }
982+ onRename = { ( ) => setRenameTargetFolder ( folderMenuState . folder ) }
983+ />
984+ </ FocusTrap >
985+ }
986+ />
987+ ) }
988+ { renameTargetFolder && (
989+ < RenameFolderDialog
990+ mx = { mx }
991+ folder = { renameTargetFolder }
992+ onClose = { ( ) => setRenameTargetFolder ( undefined ) }
993+ onSave = { ( name ) => {
994+ handleRenameFolderApply ( renameTargetFolder . id , name ) ;
995+ setRenameTargetFolder ( undefined ) ;
996+ } }
997+ />
998+ ) }
800999 < SidebarStackSeparator />
8011000 < SidebarStack >
8021001 { sidebarItems . map ( ( item ) => {
8031002 if ( typeof item === 'object' ) {
8041003 if ( openedFolder . has ( item . id ) ) {
8051004 return (
806- < OpenedSpaceFolder key = { item . id } folder = { item } onClose = { handleFolderToggle } >
1005+ < OpenedSpaceFolder
1006+ key = { item . id }
1007+ folder = { item }
1008+ onClose = { handleFolderToggle }
1009+ onFolderContextMenu = { handleFolderContextMenu ( item ) }
1010+ >
8071011 { item . content . map ( ( sId ) => {
8081012 const space = mx . getRoom ( sId ) ;
8091013 if ( ! space ) return null ;
@@ -835,6 +1039,7 @@ export function SpaceTabs({ scrollRef }: Readonly<SpaceTabsProps>) {
8351039 selected = { ! ! selectedSpaceId && item . content . includes ( selectedSpaceId ) }
8361040 onOpen = { handleFolderToggle }
8371041 onDragging = { setDraggingItem }
1042+ onFolderContextMenu = { handleFolderContextMenu ( item ) }
8381043 disabled = {
8391044 typeof draggingItem === 'object' ? draggingItem . folder . id === item . id : false
8401045 }
0 commit comments