Fix folder persistence key, context-menu nesting, and add desktop integration tests

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-07 15:29:54 +00:00
committed by GitHub
parent 0cec461ea1
commit 433168a705
9 changed files with 242 additions and 36 deletions

View File

@@ -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 (
<button data-testid="login-probe" onClick={() => actions.nsec(nip19.nsecEncode(generateSecretKey()))}>
log in
</button>
);
}
// 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(
<TestApp>
<WindowManagerProvider>
<Desktop />
{withLoginProbe && <LoginProbe />}
</WindowManagerProvider>
</TestApp>,
);
// 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 users 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);
});
});

View File

@@ -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;

View File

@@ -7,6 +7,8 @@ interface DesktopIconProps {
onSelect: () => void;
onOpen: () => void;
onPointerDown?: (event: React.PointerEvent<HTMLButtonElement>) => void;
/** Stops long-press context menus from bubbling to the desktop surface. */
onContextMenu?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLButtonElement>) => 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) => {

View File

@@ -13,6 +13,8 @@ export interface FolderVisualProps {
onSelect: () => void;
onOpen: () => void;
onPointerDown?: (event: React.PointerEvent<HTMLButtonElement>) => void;
/** Stops long-press context menus from bubbling to the desktop surface. */
onContextMenu?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLButtonElement>) => 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 (
<button
type="button"
tabIndex={tabIndex}
style={style}
data-home-icon-id={homeIconId}
onPointerDown={onPointerDown}
onContextMenu={onContextMenu}
onClick={onSelect}
onDoubleClick={onOpen}
onKeyDown={onKeyDown}

View File

@@ -46,6 +46,14 @@ import { MAX_FOLDERS, type Folder } from '@/os/folders';
const MOBILE_DRAG_THRESHOLD = 8;
/**
* Tile context menus nest inside nothing else on the home screen, but a
* long-press contextmenu event must never reach a surrounding menu.
*/
function stopTouchContextMenu(event: React.MouseEvent) {
if ((event.nativeEvent as PointerEvent).pointerType === 'touch') event.stopPropagation();
}
/**
* On a phone the window metaphor only gets in the way, so the same apps and
* the same window state are presented as a home screen plus one full-screen
@@ -424,6 +432,7 @@ function HomeScreen({ onOpen }: { onOpen: (id: string) => void }) {
<button
type="button"
onClick={() => onOpen(app.id)}
onContextMenu={stopTouchContextMenu}
className="flex flex-col items-center gap-2 rounded-xl p-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring active:scale-95"
>
<span className="flex size-14 items-center justify-center rounded-2xl border border-os-window-border bg-background shadow-sm">
@@ -496,6 +505,7 @@ function HomeScreen({ onOpen }: { onOpen: (id: string) => void }) {
if (event.button !== 0) return;
dragStart.current = { id: entry.id, x: event.clientX, y: event.clientY, moved: false };
}}
onContextMenu={stopTouchContextMenu}
onClick={() => {
if (suppressClick.current) {
suppressClick.current = false;
@@ -517,33 +527,33 @@ function HomeScreen({ onOpen }: { onOpen: (id: string) => void }) {
) : (
<ContextMenu key={entry.id}>
<ContextMenuTrigger asChild>
<span data-home-icon-id={entry.id} className="contents">
<FolderVisual
label={entry.folder.name}
count={(folderContents.get(entry.folder.id) ?? []).length}
badges={(folderContents.get(entry.folder.id) ?? []).slice(0, 3).map((app) => (
<app.icon key={app.id} className="size-2.5 text-primary" />
))}
selected={picked === entry.id}
pickedUp={picked === entry.id}
dragging={dragging === entry.id}
dropTarget={target === entry.id && dragging !== entry.id}
onPointerDown={(event) => {
if (event.button !== 0) return;
dragStart.current = { id: entry.id, x: event.clientX, y: event.clientY, moved: false };
}}
onSelect={() => {
if (suppressClick.current) {
suppressClick.current = false;
return;
}
setOpenFolderId(entry.folder.id);
}}
onOpen={() => setOpenFolderId(entry.folder.id)}
onKeyDown={(event) => onKeyDown(entry.id, event)}
className={homeTileClass(entry.id)}
/>
</span>
<FolderVisual
label={entry.folder.name}
count={(folderContents.get(entry.folder.id) ?? []).length}
badges={(folderContents.get(entry.folder.id) ?? []).slice(0, 3).map((app) => (
<app.icon key={app.id} className="size-2.5 text-primary" />
))}
selected={picked === entry.id}
pickedUp={picked === entry.id}
dragging={dragging === entry.id}
dropTarget={target === entry.id && dragging !== entry.id}
data-home-icon-id={entry.id}
onPointerDown={(event) => {
if (event.button !== 0) return;
dragStart.current = { id: entry.id, x: event.clientX, y: event.clientY, moved: false };
}}
onContextMenu={stopTouchContextMenu}
onSelect={() => {
if (suppressClick.current) {
suppressClick.current = false;
return;
}
setOpenFolderId(entry.folder.id);
}}
onOpen={() => setOpenFolderId(entry.folder.id)}
onKeyDown={(event) => onKeyDown(entry.id, event)}
className={homeTileClass(entry.id)}
/>
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem onSelect={() => setOpenFolderId(entry.folder.id)}>Open</ContextMenuItem>

View File

@@ -125,9 +125,9 @@ describe('persistence', () => {
});
it('scopes the storage key per user with an anonymous fallback', () => {
expect(folderStorageKey(null)).toBe('nostr:icon-layout');
expect(folderStorageKey(undefined)).toBe('nostr:icon-layout');
expect(folderStorageKey('abc123')).toBe('nostr:icon-layout:abc123');
expect(folderStorageKey(null)).toBe('nostr:folders');
expect(folderStorageKey(undefined)).toBe('nostr:folders');
expect(folderStorageKey('abc123')).toBe('nostr:folders:abc123');
});
it('round-trips a folder state through localStorage', () => {

View File

@@ -1,6 +1,8 @@
import { z } from 'zod';
const BASE_STORAGE_KEY = 'nostr:icon-layout';
// Separate from the icon positions key: folders persist per user, while the
// anonymous positions layout keeps its pre-folders key for compatibility.
const BASE_STORAGE_KEY = 'nostr:folders';
const VERSION = 1;
export const MAX_FOLDERS = 32;

View File

@@ -33,8 +33,6 @@ const LayoutSchema = z.object({
version: z.literal(VERSION),
desktop: z.array(SlotSchema).max(250),
mobile: z.array(z.string().min(1)).max(250),
// Pre-folders saves have no `folders` field; it defaults to empty.
folders: z.unknown().optional(),
});
function firstFree(occupied: Set<string>, geometry: GridGeometry): Omit<DesktopSlot, 'id'> {

View File

@@ -62,10 +62,17 @@ export function useFolders(appIds: string[]) {
}, [stableIds]);
const createFolder = useCallback((name: string): string => {
const id = addFolder(normalized, name).folder.id;
update((current) => addFolder(current, name).state);
// The updater runs during the setState re-render, so the id has to be
// minted inside it — capturing it beforehand could give callers an id
// that a concurrent update (or a React strict-mode retry) dropped.
let id = '';
update((current) => {
const added = addFolder(current, name);
id = added.folder.id;
return added.state;
});
return id;
}, [normalized, update]);
}, [update]);
const rename = useCallback((folderId: string, name: string) => {
update((current) => renameFolder(current, folderId, name));