+
+
+
+
{openFolder.name}
+ {contents.length === 0 ? (
+
+ This folder is empty. Long-press an app on the home screen to move it here.
+
+ ) : (
+
+ {contents.map((app) => (
+
+
+
+
+ {appContextMenu(app.id, app.title, openFolder.id)}
+
+ ))}
+
+ )}
+
+
+
+
+
setFolderDialog((current) => ({ ...current, open }))}
+ folders={folders}
+ folder={folderDialog.folder}
+ onSubmit={(name) => {
+ if (folderDialog.folder) {
+ renameFolder(folderDialog.folder.id, name);
+ setAnnouncement(`Folder renamed to ${name}.`);
+ }
+ }}
+ />
+ setConfirmDelete(null)} onConfirm={deleteFolder} />
+ {announcement}
+
+ );
+ }
+
+ const entries = layout.mobile
+ .map((id) => {
+ if (id.startsWith(FOLDER_ID_PREFIX)) {
+ const folder = folders.find((item) => item.id === id.slice(FOLDER_ID_PREFIX.length));
+ return folder ? { type: 'folder' as const, id, folder } : null;
+ }
+ const app = byId.get(id);
+ return app ? { type: 'app' as const, id, app } : null;
+ })
+ .filter((entry): entry is NonNullable => Boolean(entry));
+
return (
- {orderedApps.map((app) => (
-
- ))}
+ {entries.map((entry) =>
+ entry.type === 'app' ? (
+
+
+
+
+ {appContextMenu(entry.app.id, entry.app.title, null)}
+
+ ) : (
+
+
+
+ (
+
+ ))}
+ 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)}
+ />
+
+
+
+ setOpenFolderId(entry.folder.id)}>Open
+ setFolderDialog({ open: true, folder: entry.folder })}>Rename…
+
+ requestDeleteFolder(entry.folder)}
+ className="text-destructive focus:text-destructive"
+ >
+ Delete folder
+
+
+
+ ),
+ )}
+
+ {folders.length < MAX_FOLDERS && (
+
+
+
+ )}
+
+
setFolderDialog((current) => ({ ...current, open }))}
+ folders={folders}
+ folder={folderDialog.folder}
+ onSubmit={(name) => {
+ if (folderDialog.folder) {
+ renameFolder(folderDialog.folder.id, name);
+ setAnnouncement(`Folder renamed to ${name}.`);
+ } else {
+ const id = createFolder(name);
+ if (folderDialog.appId) {
+ setAppFolder(folderDialog.appId, id);
+ setAnnouncement(`Folder ${name} created with ${folderDialog.appId} inside.`);
+ } else {
+ setAnnouncement(`Folder ${name} created.`);
+ }
+ }
+ }}
+ />
+ setConfirmDelete(null)} onConfirm={deleteFolder} />
{announcement}
);
}
+function DeleteFolderDialog({ folder, onCancel, onConfirm }: { folder: Folder | null; onCancel: () => void; onConfirm: (folder: Folder) => void }) {
+ return (
+ { if (!open) onCancel(); }}>
+
+
+ Delete folder “{folder?.name}”?
+
+ The apps inside move back to the home screen. Nothing is uninstalled.
+
+
+
+ Cancel
+ { if (folder) onConfirm(folder); }}
+ >
+ Delete folder
+
+
+
+
+ );
+}
+
function MobileSkeleton() {
return (
diff --git a/src/os/iconLayout.ts b/src/os/iconLayout.ts
index 51ff0c7..84c6169 100644
--- a/src/os/iconLayout.ts
+++ b/src/os/iconLayout.ts
@@ -1,8 +1,12 @@
import { z } from 'zod';
+import { reconcileFolderState, type FolderState } from './folders';
const STORAGE_KEY = 'nostr:icon-layout';
const VERSION = 1;
+/** Grid entries are app ids or folder entries (`folder:`). */
+export type GridEntryId = string;
+
export interface DesktopSlot {
id: string;
col: number;
@@ -27,8 +31,10 @@ const SlotSchema = z.object({
const LayoutSchema = z.object({
version: z.literal(VERSION),
- desktop: z.array(SlotSchema).max(200),
- mobile: z.array(z.string().min(1)).max(200),
+ 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, geometry: GridGeometry): Omit {
@@ -89,20 +95,33 @@ export function reconcileMobileLayout(saved: string[], ids: string[]): string[]
return [...ordered, ...ids.filter((id) => !seen.has(id))];
}
-export function loadIconLayout(ids: string[], geometry: GridGeometry): IconLayout {
+export function loadIconLayout(ids: string[], geometry: GridGeometry, folders?: FolderState): IconLayout {
+ const entries = entryIds(ids, folders);
try {
const raw = localStorage.getItem(STORAGE_KEY);
- if (!raw) return { desktop: defaultDesktopLayout(ids, geometry), mobile: [...ids] };
+ if (!raw) return { desktop: defaultDesktopLayout(entries, geometry), mobile: [...entries] };
const parsed = LayoutSchema.parse(JSON.parse(raw));
return {
- desktop: reconcileDesktopLayout(parsed.desktop, ids, geometry),
- mobile: reconcileMobileLayout(parsed.mobile, ids),
+ desktop: reconcileDesktopLayout(parsed.desktop, entries, geometry),
+ mobile: reconcileMobileLayout(parsed.mobile, entries),
};
} catch {
- return { desktop: defaultDesktopLayout(ids, geometry), mobile: [...ids] };
+ return { desktop: defaultDesktopLayout(entries, geometry), mobile: [...entries] };
}
}
+/** Reconciles persisted folder state against the current app catalogue. */
+export function reconcileFolders(folders: FolderState | undefined, ids: string[]): FolderState {
+ return reconcileFolderState(folders ?? { folders: [], membership: {} }, ids);
+}
+
+/** The grid entry ids: top-level apps plus one entry per folder. */
+export function entryIds(ids: string[], folders?: FolderState): string[] {
+ if (!folders) return ids;
+ const clean = reconcileFolderState(folders, ids);
+ return [...ids.filter((id) => !clean.membership[id]), ...clean.folders.map((folder) => `folder:${folder.id}`)];
+}
+
export function saveIconLayout(layout: IconLayout): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: VERSION, ...layout }));
diff --git a/src/os/useIconLayout.ts b/src/os/useIconLayout.ts
index 68a65e4..dfd470e 100644
--- a/src/os/useIconLayout.ts
+++ b/src/os/useIconLayout.ts
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
defaultDesktopLayout,
+ entryIds,
iconLayoutStorageKey,
loadIconLayout,
reconcileDesktopLayout,
@@ -11,12 +12,21 @@ import {
type GridGeometry,
type IconLayout,
} from './iconLayout';
+import type { FolderState } from './folders';
const SAVE_DELAY = 250;
-export function useIconLayout(ids: string[], geometry: GridGeometry) {
- const idsKey = ids.join('|');
- const [layout, setLayout] = useState(() => loadIconLayout(ids, geometry));
+/**
+ * Tracks the position of every desktop grid entry (apps and, when `folders`
+ * is given, folder icons). Apps assigned to a folder leave the grid — the
+ * folder's entry takes their place.
+ */
+export function useIconLayout(ids: string[], geometry: GridGeometry, folders?: FolderState) {
+ // Only the derived entry ids feed the hook's state, so a fresh `folders`
+ // object each render does not reset anything.
+ const entries = entryIds(ids, folders);
+ const idsKey = entries.join('|');
+ const [layout, setLayout] = useState(() => loadIconLayout(entries, geometry));
const stableIds = useMemo(() => (idsKey ? idsKey.split('|') : []), [idsKey]);
const stableGeometry = useMemo(
() => ({ columns: geometry.columns, rows: geometry.rows }),