diff --git a/package-lock.json b/package-lock.json index 36fc8ce..55ebf24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6069,7 +6069,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6090,7 +6089,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6111,7 +6109,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6132,7 +6129,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6153,7 +6149,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6174,7 +6169,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6195,7 +6189,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6216,7 +6209,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6237,7 +6229,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6258,7 +6249,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6279,7 +6269,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/components/os/FolderDialog.test.tsx b/src/components/os/FolderDialog.test.tsx new file mode 100644 index 0000000..f56acbc --- /dev/null +++ b/src/components/os/FolderDialog.test.tsx @@ -0,0 +1,54 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { FolderDialog } from './FolderDialog'; +import type { Folder } from '@/os/folders'; + +const folders: Folder[] = [{ id: 'f1', name: 'Social' }]; + +function renderDialog(overrides: Partial[0]> = {}) { + const onSubmit = vi.fn(); + const onOpenChange = vi.fn(); + render( + , + ); + return { onSubmit, onOpenChange }; +} + +describe('FolderDialog', () => { + it('creates a folder with the normalized name', () => { + const { onSubmit, onOpenChange } = renderDialog(); + fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' My Tools ' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + expect(onSubmit).toHaveBeenCalledWith('My Tools'); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('shows an error when the name is empty', () => { + const { onSubmit } = renderDialog(); + fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' ' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.getByRole('alert')).toHaveTextContent('Give the folder a name.'); + }); + + it('shows an error for a duplicate name', () => { + const { onSubmit } = renderDialog(); + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'social' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.getByRole('alert')).toHaveTextContent('already exists'); + }); + + it('allows keeping the current name when renaming', () => { + const { onSubmit } = renderDialog({ folder: folders[0] }); + expect(screen.getByLabelText('Name')).toHaveValue('Social'); + fireEvent.click(screen.getByRole('button', { name: 'Rename' })); + expect(onSubmit).toHaveBeenCalledWith('Social'); + }); +}); diff --git a/src/components/os/FolderDialog.tsx b/src/components/os/FolderDialog.tsx new file mode 100644 index 0000000..af3850f --- /dev/null +++ b/src/components/os/FolderDialog.tsx @@ -0,0 +1,90 @@ +import { useId, useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { folderNameTaken, normalizeFolderName, type Folder } from '@/os/folders'; + +interface FolderDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Existing folder names, for the duplicate check. */ + folders: Folder[]; + /** Set when renaming; omitted when creating. */ + folder?: Folder; + onSubmit: (name: string) => void; +} + +/** Small create/rename dialog with an inline duplicate-name error. */ +export function FolderDialog({ open, onOpenChange, folders, folder, onSubmit }: FolderDialogProps) { + const errorId = useId(); + // Keyed remount resets the input; this only seeds the initial value. + const [name, setName] = useState(() => folder?.name ?? ''); + const [error, setError] = useState(''); + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + const normalized = normalizeFolderName(name); + if (!normalized) { + setError('Give the folder a name.'); + return; + } + if (folderNameTaken(folders, normalized, folder?.id)) { + setError(`A folder named “${normalized}” already exists.`); + return; + } + onSubmit(normalized); + onOpenChange(false); + }; + + return ( + + +
+ + {folder ? 'Rename folder' : 'New folder'} + + {folder + ? 'Apps inside stay put when you rename it.' + : 'Group apps together on the desktop and the home screen.'} + + +
+ + { + setName(event.target.value); + if (error) setError(''); + }} + /> + {error && ( + + )} +
+ + + + +
+
+
+ ); +} diff --git a/src/components/os/FolderIcon.tsx b/src/components/os/FolderIcon.tsx new file mode 100644 index 0000000..820656e --- /dev/null +++ b/src/components/os/FolderIcon.tsx @@ -0,0 +1,96 @@ +import type { CSSProperties } from 'react'; +import { Folder as FolderGlyph } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export interface FolderVisualProps { + /** The folder's display name. */ + label: string; + /** Stacked behind the folder glyph, e.g. the icons of the apps inside. */ + badges: React.ReactNode[]; + /** How many apps are inside; announced to assistive tech. */ + count: number; + selected: boolean; + onSelect: () => void; + onOpen: () => void; + onPointerDown?: (event: React.PointerEvent) => void; + onKeyDown?: (event: React.KeyboardEvent) => void; + tabIndex?: number; + dragging?: boolean; + pickedUp?: boolean; + /** Highlighted while an app is dragged over the folder. */ + dropTarget?: boolean; + style?: CSSProperties; + className?: string; +} + +/** + * A folder on the desktop/home screen, styled after DesktopIcon: the glyph + * carries up to three mini-badges with the icons of the apps inside. + */ +export function FolderVisual({ + label, + badges, + count, + selected, + onSelect, + onOpen, + onPointerDown, + onKeyDown, + tabIndex, + dragging, + pickedUp, + dropTarget, + style, + className, +}: FolderVisualProps) { + return ( + + ); +} diff --git a/src/components/os/FolderWindow.tsx b/src/components/os/FolderWindow.tsx new file mode 100644 index 0000000..a2b4c4d --- /dev/null +++ b/src/components/os/FolderWindow.tsx @@ -0,0 +1,79 @@ +import { FolderOpen, Folder as FolderGlyph, LogOut, Pencil, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { getApp } from '@/os/registry'; +import type { Folder } from '@/os/folders'; + +interface FolderWindowProps { + folder: Folder; + /** App ids inside this folder, in registry order. */ + appIds: string[]; + onOpenApp: (appId: string) => void; + onRemoveApp: (appId: string) => void; + onRename: () => void; + onDelete: () => void; +} + +/** + * The contents of a desktop folder, shown as a floating panel below the + * folder icon while it is open. Not an OS window — folders are lightweight. + */ +export function FolderWindow({ folder, appIds, onOpenApp, onRemoveApp, onRename, onDelete }: FolderWindowProps) { + const apps = appIds + .map((id) => getApp(id)) + .filter((app): app is NonNullable => Boolean(app)); + + return ( +
+
+ + {folder.name} +
+ + +
+
+ + {apps.length === 0 ? ( +

+ This folder is empty. Drag an app onto the folder, or use its context menu to move it here. +

+ ) : ( +
    + {apps.map((app) => ( +
  • + + +
  • + ))} +
+ )} + +
+ + {apps.length} {apps.length === 1 ? 'app' : 'apps'} +
+
+ ); +} diff --git a/src/os/folders.test.ts b/src/os/folders.test.ts new file mode 100644 index 0000000..db76b8f --- /dev/null +++ b/src/os/folders.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + EMPTY_FOLDERS, + MAX_FOLDER_NAME, + addFolder, + appsInFolder, + clearFolderState, + folderNameTaken, + folderStorageKey, + loadFolderState, + normalizeFolderName, + reconcileFolderState, + removeFolder, + renameFolder, + saveFolderState, + setAppFolder, + type FolderState, +} from './folders'; + +const stateWithFolder = (): { state: FolderState; folderId: string } => { + const { state, folder } = addFolder(EMPTY_FOLDERS, 'Tools'); + return { state, folderId: folder.id }; +}; + +describe('normalizeFolderName', () => { + it('trims and collapses whitespace', () => { + expect(normalizeFolderName(' My Folder ')).toBe('My Folder'); + }); + + it('caps the length', () => { + expect(normalizeFolderName('x'.repeat(100))).toHaveLength(MAX_FOLDER_NAME); + }); +}); + +describe('folderNameTaken', () => { + it('matches case-insensitively and ignores the excluded folder', () => { + const { state, folderId } = stateWithFolder(); + expect(folderNameTaken(state.folders, 'tools')).toBe(true); + expect(folderNameTaken(state.folders, 'TOOLS')).toBe(true); + expect(folderNameTaken(state.folders, 'Tools', folderId)).toBe(false); + expect(folderNameTaken(state.folders, 'Other')).toBe(false); + }); +}); + +describe('folder operations', () => { + it('creates folders with unique ids', () => { + const first = addFolder(EMPTY_FOLDERS, 'One'); + const second = addFolder(first.state, 'Two'); + expect(second.state.folders).toHaveLength(2); + expect(second.state.folders[0].id).not.toBe(second.state.folders[1].id); + }); + + it('renames a folder without touching the others', () => { + const { state, folderId } = stateWithFolder(); + const renamed = renameFolder(state, folderId, 'Utilities'); + expect(renamed.folders[0].name).toBe('Utilities'); + }); + + it('assigns an app to a folder and lists it', () => { + const { state, folderId } = stateWithFolder(); + const assigned = setAppFolder(state, 'feed', folderId); + expect(appsInFolder(assigned, folderId)).toEqual(['feed']); + }); + + it('moves an app between folders', () => { + const { state, folderId } = stateWithFolder(); + const { state: withTwo, folder: other } = addFolder(state, 'More'); + const assigned = setAppFolder(setAppFolder(withTwo, 'feed', folderId), 'feed', other.id); + expect(appsInFolder(assigned, folderId)).toEqual([]); + expect(appsInFolder(assigned, other.id)).toEqual(['feed']); + }); + + it('returns an app to the top level with null', () => { + const { state, folderId } = stateWithFolder(); + const assigned = setAppFolder(state, 'feed', folderId); + const cleared = setAppFolder(assigned, 'feed', null); + expect(cleared.membership).toEqual({}); + }); + + it('ignores assignments to unknown folders', () => { + const { state } = stateWithFolder(); + const assigned = setAppFolder(state, 'feed', 'no-such-folder'); + expect(assigned.membership).toEqual({}); + }); + + it('deleting a folder returns its apps to the top level', () => { + const { state, folderId } = stateWithFolder(); + const assigned = setAppFolder(state, 'feed', folderId); + const removed = removeFolder(assigned, folderId); + expect(removed.folders).toEqual([]); + expect(removed.membership).toEqual({}); + }); +}); + +describe('reconcileFolderState', () => { + it('drops membership for unknown folders and apps', () => { + const { state, folderId } = stateWithFolder(); + const dirty: FolderState = { + folders: state.folders, + membership: { feed: folderId, ghost: folderId, settings: 'unknown-folder' }, + }; + const clean = reconcileFolderState(dirty, ['feed', 'settings']); + expect(clean.membership).toEqual({ feed: folderId }); + }); + + it('drops duplicate folder ids and empty names', () => { + const { folderId } = stateWithFolder(); + const dirty: FolderState = { + folders: [ + { id: folderId, name: 'One' }, + { id: folderId, name: 'Duplicate' }, + { id: 'blank', name: ' ' }, + ], + membership: { feed: folderId, notes: 'blank' }, + }; + const clean = reconcileFolderState(dirty, ['feed', 'notes']); + expect(clean.folders).toEqual([{ id: folderId, name: 'One' }]); + expect(clean.membership).toEqual({ feed: folderId }); + }); +}); + +describe('persistence', () => { + beforeEach(() => { + localStorage.clear(); + }); + + 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'); + }); + + it('round-trips a folder state through localStorage', () => { + const { state, folderId } = stateWithFolder(); + const assigned = setAppFolder(state, 'feed', folderId); + saveFolderState(assigned, 'user-a'); + expect(loadFolderState('user-a', ['feed'])).toEqual(assigned); + // A different user must not see it. + expect(loadFolderState('user-b', ['feed'])).toEqual(EMPTY_FOLDERS); + }); + + it('returns an empty state for corrupt payloads', () => { + localStorage.setItem(folderStorageKey(null), '{not json'); + expect(loadFolderState(null, ['feed'])).toEqual(EMPTY_FOLDERS); + localStorage.setItem(folderStorageKey(null), JSON.stringify({ version: 99, folders: [] })); + expect(loadFolderState(null, ['feed'])).toEqual(EMPTY_FOLDERS); + }); + + it('clears only the requested user key', () => { + const { state } = stateWithFolder(); + saveFolderState(state, 'user-a'); + saveFolderState(state, null); + clearFolderState('user-a'); + expect(loadFolderState('user-a', [])).toEqual(EMPTY_FOLDERS); + expect(loadFolderState(null, [])).toEqual(state); + }); +}); diff --git a/src/os/folders.ts b/src/os/folders.ts new file mode 100644 index 0000000..f0f59d7 --- /dev/null +++ b/src/os/folders.ts @@ -0,0 +1,138 @@ +import { z } from 'zod'; + +const BASE_STORAGE_KEY = 'nostr:icon-layout'; +const VERSION = 1; + +export const MAX_FOLDERS = 32; +export const MAX_FOLDER_NAME = 40; + +export interface Folder { + id: string; + name: string; +} + +export interface FolderState { + /** Every folder, in creation order. */ + folders: Folder[]; + /** `appId -> folderId`; apps without an entry live at the top level. */ + membership: Record; +} + +export const EMPTY_FOLDERS: FolderState = { folders: [], membership: {} }; + +const FolderSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1).max(MAX_FOLDER_NAME), +}); + +const FolderStateSchema = z.object({ + version: z.literal(VERSION), + folders: z.array(FolderSchema).max(MAX_FOLDERS), + membership: z.record(z.string().min(1), z.string().min(1)).refine( + (membership) => Object.keys(membership).length <= 500, + { message: 'too many folder assignments' }, + ), +}); + +/** Per-user key when signed in, the shared anonymous key otherwise. */ +export function folderStorageKey(userKey?: string | null): string { + return userKey ? `${BASE_STORAGE_KEY}:${userKey}` : BASE_STORAGE_KEY; +} + +export function normalizeFolderName(input: string): string { + return input.trim().replace(/\s+/g, ' ').slice(0, MAX_FOLDER_NAME); +} + +/** Case-insensitive duplicate check used by the create/rename dialog. */ +export function folderNameTaken(folders: Folder[], name: string, excludeId?: string): boolean { + const needle = name.toLowerCase(); + return folders.some((folder) => folder.id !== excludeId && folder.name.toLowerCase() === needle); +} + +/** Drops assignments to folders/apps that no longer exist and dedupes folder ids. */ +export function reconcileFolderState(state: FolderState, appIds: string[]): FolderState { + const seen = new Set(); + const folders: Folder[] = []; + for (const folder of state.folders) { + if (seen.has(folder.id)) continue; + const name = normalizeFolderName(folder.name); + if (!name) continue; + seen.add(folder.id); + folders.push({ id: folder.id, name }); + } + const eligible = new Set(folders.map((folder) => folder.id)); + const knownApps = new Set(appIds); + const membership: Record = {}; + for (const [appId, folderId] of Object.entries(state.membership)) { + if (eligible.has(folderId) && knownApps.has(appId)) membership[appId] = folderId; + } + return { folders, membership }; +} + +let counter = 0; + +export function createFolderId(): string { + counter = (counter + 1) % 36 ** 4; + return `f${Date.now().toString(36)}${counter.toString(36)}`; +} + +export function addFolder(state: FolderState, name: string): { state: FolderState; folder: Folder } { + const folder = { id: createFolderId(), name }; + return { state: { ...state, folders: [...state.folders, folder] }, folder }; +} + +export function renameFolder(state: FolderState, folderId: string, name: string): FolderState { + return { + ...state, + folders: state.folders.map((folder) => (folder.id === folderId ? { ...folder, name } : folder)), + }; +} + +/** Removes the folder; its apps return to the top level. */ +export function removeFolder(state: FolderState, folderId: string): FolderState { + const membership: Record = {}; + for (const [appId, assigned] of Object.entries(state.membership)) { + if (assigned !== folderId) membership[appId] = assigned; + } + return { folders: state.folders.filter((folder) => folder.id !== folderId), membership }; +} + +/** Assigns an app to a folder, or back to the top level with `folderId: null`. */ +export function setAppFolder(state: FolderState, appId: string, folderId: string | null): FolderState { + const membership = { ...state.membership }; + if (folderId && state.folders.some((folder) => folder.id === folderId)) membership[appId] = folderId; + else delete membership[appId]; + return { ...state, membership }; +} + +export function appsInFolder(state: FolderState, folderId: string): string[] { + return Object.entries(state.membership) + .filter(([, assigned]) => assigned === folderId) + .map(([appId]) => appId); +} + +export function loadFolderState(userKey: string | null | undefined, appIds: string[]): FolderState { + try { + const raw = localStorage.getItem(folderStorageKey(userKey)); + if (!raw) return EMPTY_FOLDERS; + return reconcileFolderState(FolderStateSchema.parse(JSON.parse(raw)), appIds); + } catch { + return EMPTY_FOLDERS; + } +} + +export function saveFolderState(state: FolderState, userKey: string | null | undefined): void { + try { + localStorage.setItem(folderStorageKey(userKey), JSON.stringify({ version: VERSION, ...state })); + } catch { + // Storage can be unavailable in private browsing; the in-memory state remains usable. + } +} + +export function clearFolderState(userKey: string | null | undefined): void { + try { + localStorage.removeItem(folderStorageKey(userKey)); + } catch { + // The caller still resets its in-memory state. + } +} diff --git a/src/os/useFolders.ts b/src/os/useFolders.ts new file mode 100644 index 0000000..b2dfbb7 --- /dev/null +++ b/src/os/useFolders.ts @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { + EMPTY_FOLDERS, + addFolder, + clearFolderState, + loadFolderState, + reconcileFolderState, + removeFolder, + renameFolder, + saveFolderState, + setAppFolder, + type FolderState, +} from './folders'; + +const SAVE_DELAY = 250; + +/** + * Folder state for the desktop/home-screen icons. Persists per signed-in + * user (anonymous key when signed out) so the structure survives sign-in/out + * cycles for both identities. + */ +export function useFolders(appIds: string[]) { + const { user } = useCurrentUser(); + const userKey = user?.pubkey ?? null; + const idsKey = appIds.join('|'); + const stableIds = useMemo(() => (idsKey ? idsKey.split('|') : []), [idsKey]); + + const [state, setState] = useState(() => loadFolderState(userKey, stableIds)); + // Render-phase sync for a key change (sign-in/out): the same pattern + // useLocalStorage uses for its storageKey argument. + const [loadedKey, setLoadedKey] = useState(userKey); + if (loadedKey !== userKey) { + setLoadedKey(userKey); + setState(loadFolderState(userKey, stableIds)); + } + + const normalized = useMemo(() => reconcileFolderState(state, stableIds), [state, stableIds]); + + const dirty = useRef(false); + const saveTimer = useRef | undefined>(undefined); + useEffect(() => { + if (!dirty.current) return; + clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(() => { + dirty.current = false; + saveFolderState(normalized, userKey); + }, SAVE_DELAY); + return () => clearTimeout(saveTimer.current); + }, [normalized, userKey]); + + // A reset from another consumer (e.g. Settings) reloads the current key. + useEffect(() => { + const onReset = () => setState(loadFolderState(userKey, stableIds)); + window.addEventListener('icon-layout-reset', onReset); + return () => window.removeEventListener('icon-layout-reset', onReset); + }, [userKey, stableIds]); + + const update = useCallback((updater: (current: FolderState) => FolderState) => { + dirty.current = true; + setState((current) => updater(reconcileFolderState(current, stableIds))); + }, [stableIds]); + + const createFolder = useCallback((name: string): string => { + const id = addFolder(normalized, name).folder.id; + update((current) => addFolder(current, name).state); + return id; + }, [normalized, update]); + + const rename = useCallback((folderId: string, name: string) => { + update((current) => renameFolder(current, folderId, name)); + }, [update]); + + const remove = useCallback((folderId: string) => { + update((current) => removeFolder(current, folderId)); + }, [update]); + + const assign = useCallback((appId: string, folderId: string | null) => { + update((current) => setAppFolder(current, appId, folderId)); + }, [update]); + + const resetFolders = useCallback(() => { + clearFolderState(userKey); + dirty.current = false; + clearTimeout(saveTimer.current); + setState(EMPTY_FOLDERS); + window.dispatchEvent(new Event('icon-layout-reset')); + }, [userKey]); + + return useMemo( + () => ({ folderState: normalized, createFolder, renameFolder: rename, removeFolder: remove, setAppFolder: assign, resetFolders }), + [normalized, createFolder, rename, remove, assign, resetFolders], + ); +}