mirror of
https://github.com/layer-systems/website.git
synced 2026-09-13 14:14:07 +02:00
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:
committed by
GitHub
parent
0cec461ea1
commit
433168a705
165
src/components/os/Desktop.test.tsx
Normal file
165
src/components/os/Desktop.test.tsx
Normal 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 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'> {
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user