-
- {desktopApps().map((app) => (
+
+ {orderedApps.map((app) => (
))}
+
{announcement}
);
}
diff --git a/src/os/iconLayout.ts b/src/os/iconLayout.ts
new file mode 100644
index 0000000..30ef6f3
--- /dev/null
+++ b/src/os/iconLayout.ts
@@ -0,0 +1,133 @@
+import { z } from 'zod';
+
+const STORAGE_KEY = 'nostr:icon-layout';
+const VERSION = 1;
+
+export interface DesktopSlot {
+ id: string;
+ col: number;
+ row: number;
+}
+
+export interface IconLayout {
+ desktop: DesktopSlot[];
+ mobile: string[];
+}
+
+export interface GridGeometry {
+ columns: number;
+ rows: number;
+}
+
+const SlotSchema = z.object({
+ id: z.string().min(1),
+ col: z.number().int().min(0),
+ row: z.number().int().min(0),
+});
+
+const LayoutSchema = z.object({
+ version: z.literal(VERSION),
+ desktop: z.array(SlotSchema).max(200),
+ mobile: z.array(z.string().min(1)).max(200),
+});
+
+function firstFree(occupied: Set
, geometry: GridGeometry): Omit {
+ for (let row = 0; row < geometry.rows; row += 1) {
+ for (let col = 0; col < geometry.columns; col += 1) {
+ if (!occupied.has(`${col}:${row}`)) return { col, row };
+ }
+ }
+ // A very small viewport can temporarily have fewer cells than apps. Keep
+ // the remaining icons in the next stable row; rendering clamps on resize.
+ return { col: 0, row: geometry.rows };
+}
+
+export function defaultDesktopLayout(ids: string[], geometry: GridGeometry): DesktopSlot[] {
+ const occupied = new Set();
+ return ids.map((id) => {
+ const slot = firstFree(occupied, geometry);
+ occupied.add(`${slot.col}:${slot.row}`);
+ return { id, ...slot };
+ });
+}
+
+/** Drops unknown IDs, clamps coordinates, resolves collisions, then adds new apps. */
+export function reconcileDesktopLayout(
+ saved: DesktopSlot[],
+ ids: string[],
+ geometry: GridGeometry,
+): DesktopSlot[] {
+ const eligible = new Set(ids);
+ const seen = new Set();
+ const occupied = new Set();
+ const reconciled: DesktopSlot[] = [];
+
+ for (const slot of saved) {
+ if (!eligible.has(slot.id) || seen.has(slot.id)) continue;
+ seen.add(slot.id);
+ const col = Math.min(slot.col, Math.max(0, geometry.columns - 1));
+ const row = Math.min(slot.row, Math.max(0, geometry.rows - 1));
+ const key = `${col}:${row}`;
+ const position = occupied.has(key) ? firstFree(occupied, geometry) : { col, row };
+ occupied.add(`${position.col}:${position.row}`);
+ reconciled.push({ id: slot.id, ...position });
+ }
+
+ for (const id of ids) {
+ if (seen.has(id)) continue;
+ const position = firstFree(occupied, geometry);
+ occupied.add(`${position.col}:${position.row}`);
+ reconciled.push({ id, ...position });
+ }
+ return reconciled;
+}
+
+export function reconcileMobileLayout(saved: string[], ids: string[]): string[] {
+ const eligible = new Set(ids);
+ const seen = new Set();
+ const ordered = saved.filter((id) => eligible.has(id) && !seen.has(id) && (seen.add(id), true));
+ return [...ordered, ...ids.filter((id) => !seen.has(id))];
+}
+
+export function loadIconLayout(ids: string[], geometry: GridGeometry): IconLayout {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return { desktop: defaultDesktopLayout(ids, geometry), mobile: [...ids] };
+ const parsed = LayoutSchema.parse(JSON.parse(raw));
+ return {
+ desktop: reconcileDesktopLayout(parsed.desktop, ids, geometry),
+ mobile: reconcileMobileLayout(parsed.mobile, ids),
+ };
+ } catch {
+ return { desktop: defaultDesktopLayout(ids, geometry), mobile: [...ids] };
+ }
+}
+
+export function saveIconLayout(layout: IconLayout): void {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: VERSION, ...layout }));
+ } catch {
+ // Storage can be unavailable in private browsing; the in-memory layout remains usable.
+ }
+}
+
+export function resetIconLayout(profile: 'desktop' | 'mobile' | 'both'): void {
+ try {
+ if (profile === 'both') localStorage.removeItem(STORAGE_KEY);
+ } catch {
+ // The caller still resets its in-memory state.
+ }
+}
+
+export function swapDesktopSlots(slots: DesktopSlot[], id: string, target: Omit): DesktopSlot[] {
+ const source = slots.find((slot) => slot.id === id);
+ if (!source) return slots;
+ const displaced = slots.find((slot) => slot.col === target.col && slot.row === target.row && slot.id !== id);
+ return slots.map((slot) => {
+ if (slot.id === id) return { ...slot, ...target };
+ if (slot.id === displaced?.id) return { ...slot, col: source.col, row: source.row };
+ return slot;
+ });
+}
+
+export const iconLayoutStorageKey = STORAGE_KEY;
diff --git a/src/os/useIconLayout.ts b/src/os/useIconLayout.ts
new file mode 100644
index 0000000..ffa942d
--- /dev/null
+++ b/src/os/useIconLayout.ts
@@ -0,0 +1,72 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ defaultDesktopLayout,
+ iconLayoutStorageKey,
+ loadIconLayout,
+ reconcileDesktopLayout,
+ reconcileMobileLayout,
+ resetIconLayout,
+ saveIconLayout,
+ type DesktopSlot,
+ type GridGeometry,
+ type IconLayout,
+} from './iconLayout';
+
+const SAVE_DELAY = 250;
+
+export function useIconLayout(ids: string[], geometry: GridGeometry) {
+ const idsKey = ids.join('|');
+ const [layout, setLayout] = useState(() => loadIconLayout(ids, geometry));
+ const stableIds = useMemo(() => (idsKey ? idsKey.split('|') : []), [idsKey]);
+ const stableGeometry = useMemo(
+ () => ({ columns: geometry.columns, rows: geometry.rows }),
+ [geometry.columns, geometry.rows],
+ );
+ const normalized = useMemo(() => ({
+ desktop: reconcileDesktopLayout(layout.desktop, stableIds, stableGeometry),
+ mobile: reconcileMobileLayout(layout.mobile, stableIds),
+ }), [layout, stableGeometry, stableIds]);
+
+ const saveTimer = useRef | undefined>(undefined);
+ useEffect(() => {
+ clearTimeout(saveTimer.current);
+ saveTimer.current = setTimeout(() => saveIconLayout(normalized), SAVE_DELAY);
+ return () => clearTimeout(saveTimer.current);
+ }, [normalized]);
+
+ useEffect(() => {
+ const onStorage = (event: StorageEvent) => {
+ if (event.key !== iconLayoutStorageKey) return;
+ setLayout(loadIconLayout(stableIds, stableGeometry));
+ };
+ window.addEventListener('storage', onStorage);
+ const onReset = () => setLayout(loadIconLayout(stableIds, stableGeometry));
+ window.addEventListener('icon-layout-reset', onReset);
+ return () => {
+ window.removeEventListener('storage', onStorage);
+ window.removeEventListener('icon-layout-reset', onReset);
+ };
+ }, [stableGeometry, stableIds]);
+
+ const setDesktop = useCallback((updater: (slots: DesktopSlot[]) => DesktopSlot[]) => {
+ setLayout((current) => ({
+ ...current,
+ desktop: updater(reconcileDesktopLayout(current.desktop, stableIds, stableGeometry)),
+ }));
+ }, [stableGeometry, stableIds]);
+ const setMobile = useCallback((updater: (order: string[]) => string[]) => {
+ setLayout((current) => ({ ...current, mobile: updater(reconcileMobileLayout(current.mobile, stableIds)) }));
+ }, [stableIds]);
+ const reset = useCallback((profile: 'desktop' | 'mobile' | 'both') => {
+ resetIconLayout(profile);
+ const next = {
+ desktop: profile === 'mobile' ? normalized.desktop : defaultDesktopLayout(stableIds, stableGeometry),
+ mobile: profile === 'desktop' ? normalized.mobile : [...stableIds],
+ };
+ saveIconLayout(next);
+ setLayout(next);
+ window.dispatchEvent(new Event('icon-layout-reset'));
+ }, [normalized.desktop, normalized.mobile, stableGeometry, stableIds]);
+
+ return useMemo(() => ({ layout: normalized, setDesktop, setMobile, reset }), [normalized, reset, setDesktop, setMobile]);
+}