diff --git a/src/components/os/Desktop.test.tsx b/src/components/os/Desktop.test.tsx new file mode 100644 index 0000000..5c435a2 --- /dev/null +++ b/src/components/os/Desktop.test.tsx @@ -0,0 +1,165 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { generateSecretKey, nip19 } from 'nostr-tools'; + +import { TestApp } from '@/test/TestApp'; +import { Desktop, FOLDER_ID_PREFIX } from './Desktop'; +import { WindowManagerProvider } from '@/os/WindowManagerProvider'; +import { useLoginActions } from '@/hooks/useLoginActions'; +import { folderStorageKey, loadFolderState } from '@/os/folders'; +import { iconLayoutStorageKey } from '@/os/iconLayout'; + +function LoginProbe() { + const actions = useLoginActions(); + return ( + + ); +} + +// Radix Popper (context menus) constructs `new ResizeObserver(cb)`, which the +// vi.fn() mock in src/test/setup.ts does not support — same workaround as +// WallpaperSection.test.tsx. +beforeEach(() => { + global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; +}); + +async function renderDesktop(withLoginProbe = false) { + const result = render( + + + + {withLoginProbe && } + + , + ); + // NostrLoginProvider renders null while it reads logins from storage. + await screen.findByRole('main'); + return result; +} + +describe('Desktop folders', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('creates a folder around an app from the app context menu', async () => { + await renderDesktop(); + + fireEvent.contextMenu(screen.getByRole('button', { name: /^Feed —/ })); + const subTrigger = await screen.findByText('Move to folder'); + fireEvent.keyDown(subTrigger, { key: 'ArrowRight' }); + fireEvent.click(await screen.findByText('New folder with Feed…')); + + const dialog = await screen.findByRole('dialog'); + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Social' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + + expect(dialog).not.toBeInTheDocument(); + // The folder appears on the grid and the app icon leaves it. + expect(await screen.findByRole('button', { name: /Social — folder with 1 app/ })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^Feed —/ })).not.toBeInTheDocument(); + + // The state persists under the anonymous user key (debounced save). + await waitFor(() => expect(loadFolderState(null, ['feed']).folders).toHaveLength(1)); + const saved = loadFolderState(null, ['feed']); + expect(saved.folders[0].name).toBe('Social'); + expect(saved.membership).toEqual({ feed: saved.folders[0].id }); + + // Nudging the layout persists a grid slot for the folder entry. + const folderIcon = screen.getByRole('button', { name: /Social — folder with 1 app/ }); + folderIcon.focus(); + fireEvent.keyDown(folderIcon, { key: ' ' }); + fireEvent.keyDown(folderIcon, { key: 'ArrowRight' }); + fireEvent.keyDown(folderIcon, { key: 'Enter' }); + + await waitFor(() => { + const layout = JSON.parse(localStorage.getItem(iconLayoutStorageKey) ?? '{}') as { desktop?: { id: string }[] }; + expect(layout.desktop?.some((slot) => slot.id === `${FOLDER_ID_PREFIX}${saved.folders[0].id}`)).toBe(true); + }); + }); + + it('opens a folder and moves an app back out of it', async () => { + await renderDesktop(); + + fireEvent.contextMenu(screen.getByRole('button', { name: /^Feed —/ })); + fireEvent.keyDown(await screen.findByText('Move to folder'), { key: 'ArrowRight' }); + fireEvent.click(await screen.findByText('New folder with Feed…')); + fireEvent.change(await screen.findByLabelText('Name'), { target: { value: 'Social' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + + fireEvent.doubleClick(await screen.findByRole('button', { name: /Social — folder with 1 app/ })); + expect(await screen.findByRole('dialog', { name: 'Folder Social' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Remove Feed from Social' })); + + // The app is back on the grid, and the removal persists (debounced). + expect(await screen.findByRole('button', { name: /^Feed —/ })).toBeInTheDocument(); + await waitFor(() => { + const saved = loadFolderState(null, ['feed']); + expect(saved.folders).toHaveLength(1); + expect(saved.membership).toEqual({}); + }); + }); + + it('deletes an empty folder from its context menu without confirmation', async () => { + await renderDesktop(); + + fireEvent.contextMenu(screen.getByRole('main')); + fireEvent.click(await screen.findByText('New folder')); + fireEvent.change(await screen.findByLabelText('Name'), { target: { value: 'Empty one' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + + const icon = await screen.findByRole('button', { name: /Empty one — folder with 0 apps/ }); + fireEvent.contextMenu(icon); + fireEvent.click(await screen.findByText('Delete folder')); + + expect(screen.queryByRole('button', { name: /Empty one/ })).not.toBeInTheDocument(); + expect(loadFolderState(null, []).folders).toHaveLength(0); + }); + + it('loads folder state persisted under the user key', async () => { + // Pre-seed the anonymous key; the signed-out desktop must pick it up. + localStorage.setItem( + folderStorageKey(null), + JSON.stringify({ + version: 1, + folders: [{ id: 'persisted', name: 'Kept' }], + membership: { feed: 'persisted' }, + }), + ); + await renderDesktop(); + expect(await screen.findByRole('button', { name: /Kept — folder with 1 app/ })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^Feed —/ })).not.toBeInTheDocument(); + }); + + it('switches to the signed-in user’s folders on login and keeps the anonymous ones', async () => { + localStorage.setItem( + folderStorageKey(null), + JSON.stringify({ + version: 1, + folders: [{ id: 'anon-f', name: 'Anon Folder' }], + membership: { feed: 'anon-f' }, + }), + ); + await renderDesktop(true); + expect(await screen.findByRole('button', { name: /Anon Folder — folder with 1 app/ })).toBeInTheDocument(); + + act(() => screen.getByTestId('login-probe').click()); + + // The anonymous folder leaves the grid; the fresh user starts empty. + await waitFor(() => { + expect(screen.queryByRole('button', { name: /Anon Folder/ })).not.toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: /^Feed —/ })).toBeInTheDocument(); + + // The anonymous state survives under its own key. + const anon = JSON.parse(localStorage.getItem(folderStorageKey(null)) ?? '{}') as { folders?: unknown[] }; + expect(anon.folders).toHaveLength(1); + }); +}); diff --git a/src/components/os/Desktop.tsx b/src/components/os/Desktop.tsx index cec1c84..7d14299 100644 --- a/src/components/os/Desktop.tsx +++ b/src/components/os/Desktop.tsx @@ -46,6 +46,16 @@ function geometryFor(width: number, height: number): GridGeometry { }; } +/** + * Context menus nest (icon menus inside the desktop menu), and Radix opens + * the menu of every trigger in the bubble path. Real right-click is handled + * by the innermost trigger; only touch long-presses bubble, so the inner + * trigger swallows them before the desktop menu would also open. + */ +function stopTouchContextMenu(event: React.MouseEvent) { + if ((event.nativeEvent as PointerEvent).pointerType === 'touch') event.stopPropagation(); +} + interface FolderDialogState { open: boolean; /** Set when renaming an existing folder. */ @@ -288,6 +298,7 @@ export function Desktop() { pointerStart.current = { id: app.id, x: event.clientX, y: event.clientY, moved: false }; setSelected(app.id); }} + onContextMenu={stopTouchContextMenu} onKeyDown={(event) => iconKeyDown(app.id, event)} onSelect={() => { if (!pointerStart.current?.moved) setSelected(app.id); }} onOpen={() => openApp(app.id)} @@ -344,6 +355,7 @@ export function Desktop() { pointerStart.current = { id: iconId, x: event.clientX, y: event.clientY, moved: false }; setSelected(iconId); }} + onContextMenu={stopTouchContextMenu} onKeyDown={(event) => { iconKeyDown(iconId, event); if (event.defaultPrevented) return; diff --git a/src/components/os/DesktopIcon.tsx b/src/components/os/DesktopIcon.tsx index 1d878c0..921799a 100644 --- a/src/components/os/DesktopIcon.tsx +++ b/src/components/os/DesktopIcon.tsx @@ -7,6 +7,8 @@ interface DesktopIconProps { onSelect: () => void; onOpen: () => void; onPointerDown?: (event: React.PointerEvent) => void; + /** Stops long-press context menus from bubbling to the desktop surface. */ + onContextMenu?: (event: React.MouseEvent) => void; onKeyDown?: (event: React.KeyboardEvent) => void; tabIndex?: number; dragging?: boolean; @@ -20,6 +22,7 @@ export function DesktopIcon({ onSelect, onOpen, onPointerDown, + onContextMenu, onKeyDown, tabIndex, dragging, @@ -35,6 +38,7 @@ export function DesktopIcon({ tabIndex={tabIndex} style={style} onPointerDown={onPointerDown} + onContextMenu={onContextMenu} onClick={onSelect} onDoubleClick={onOpen} onKeyDown={(event) => { diff --git a/src/components/os/FolderIcon.tsx b/src/components/os/FolderIcon.tsx index 820656e..9595b72 100644 --- a/src/components/os/FolderIcon.tsx +++ b/src/components/os/FolderIcon.tsx @@ -13,6 +13,8 @@ export interface FolderVisualProps { onSelect: () => void; onOpen: () => void; onPointerDown?: (event: React.PointerEvent) => void; + /** Stops long-press context menus from bubbling to the desktop surface. */ + onContextMenu?: (event: React.MouseEvent) => void; onKeyDown?: (event: React.KeyboardEvent) => void; tabIndex?: number; dragging?: boolean; @@ -21,6 +23,8 @@ export interface FolderVisualProps { dropTarget?: boolean; style?: CSSProperties; className?: string; + /** Hit-target id used by drag-and-drop on the home screen. */ + 'data-home-icon-id'?: string; } /** @@ -35,6 +39,7 @@ export function FolderVisual({ onSelect, onOpen, onPointerDown, + onContextMenu, onKeyDown, tabIndex, dragging, @@ -42,13 +47,16 @@ export function FolderVisual({ dropTarget, style, className, + 'data-home-icon-id': homeIconId, }: FolderVisualProps) { return (