-
- {desktopApps().map((app) => (
+
+ {orderedApps.map((app) => (
))}
+
{announcement}
);
}
diff --git a/src/os/iconLayout.test.ts b/src/os/iconLayout.test.ts
new file mode 100644
index 0000000..99e1198
--- /dev/null
+++ b/src/os/iconLayout.test.ts
@@ -0,0 +1,129 @@
+import { describe, expect, it } from 'vitest';
+import { defaultDesktopLayout, reconcileDesktopLayout, reconcileMobileLayout, swapDesktopSlots } from './iconLayout';
+
+const geometry = { columns: 4, rows: 4 };
+
+describe('defaultDesktopLayout', () => {
+ it('places apps in row-major order starting from the first cell', () => {
+ const slots = defaultDesktopLayout(['a', 'b', 'c'], geometry);
+ expect(slots).toEqual([
+ { id: 'a', col: 0, row: 0 },
+ { id: 'b', col: 1, row: 0 },
+ { id: 'c', col: 2, row: 0 },
+ ]);
+ });
+
+ it('keeps overflow icons on the last row when there are more apps than cells', () => {
+ const ids = Array.from({ length: 18 }, (_, i) => `app-${i}`);
+ const slots = defaultDesktopLayout(ids, geometry);
+ for (const slot of slots) {
+ expect(slot.row).toBeLessThan(geometry.rows);
+ }
+ });
+});
+
+describe('reconcileDesktopLayout', () => {
+ it('drops unknown ids and keeps known ones in place', () => {
+ const saved = [
+ { id: 'a', col: 0, row: 0 },
+ { id: 'stale', col: 1, row: 0 },
+ ];
+ const reconciled = reconcileDesktopLayout(saved, ['a'], geometry);
+ expect(reconciled).toEqual([{ id: 'a', col: 0, row: 0 }]);
+ });
+
+ it('clamps out-of-bounds coordinates to the grid', () => {
+ const saved = [{ id: 'a', col: 99, row: 99 }];
+ const reconciled = reconcileDesktopLayout(saved, ['a'], geometry);
+ expect(reconciled[0].col).toBeLessThan(geometry.columns);
+ expect(reconciled[0].row).toBeLessThan(geometry.rows);
+ });
+
+ it('resolves collisions by moving the later slot to the next free cell', () => {
+ const saved = [
+ { id: 'a', col: 0, row: 0 },
+ { id: 'b', col: 0, row: 0 },
+ ];
+ const reconciled = reconcileDesktopLayout(saved, ['a', 'b'], geometry);
+ const a = reconciled.find((slot) => slot.id === 'a');
+ const b = reconciled.find((slot) => slot.id === 'b');
+ expect(a).toEqual({ id: 'a', col: 0, row: 0 });
+ expect(b).not.toEqual({ id: 'b', col: 0, row: 0 });
+ });
+
+ it('appends new apps to the first free cell after known ones', () => {
+ const saved = [{ id: 'a', col: 0, row: 0 }];
+ const reconciled = reconcileDesktopLayout(saved, ['a', 'b'], geometry);
+ expect(reconciled).toEqual([
+ { id: 'a', col: 0, row: 0 },
+ { id: 'b', col: 1, row: 0 },
+ ]);
+ });
+
+ it('drops duplicate saved entries for the same id', () => {
+ const saved = [
+ { id: 'a', col: 0, row: 0 },
+ { id: 'a', col: 1, row: 0 },
+ ];
+ const reconciled = reconcileDesktopLayout(saved, ['a'], geometry);
+ expect(reconciled).toEqual([{ id: 'a', col: 0, row: 0 }]);
+ });
+
+ it('keeps overflow icons within the grid when there are more apps than cells', () => {
+ const ids = Array.from({ length: 18 }, (_, i) => `app-${i}`);
+ const reconciled = reconcileDesktopLayout([], ids, geometry);
+ for (const slot of reconciled) {
+ expect(slot.row).toBeLessThan(geometry.rows);
+ expect(slot.col).toBeLessThan(geometry.columns);
+ }
+ });
+});
+
+describe('reconcileMobileLayout', () => {
+ it('keeps known ids in their saved order and drops unknown ones', () => {
+ const ordered = reconcileMobileLayout(['b', 'stale', 'a'], ['a', 'b']);
+ expect(ordered).toEqual(['b', 'a']);
+ });
+
+ it('appends new apps that were not in the saved order', () => {
+ const ordered = reconcileMobileLayout(['a'], ['a', 'b']);
+ expect(ordered).toEqual(['a', 'b']);
+ });
+
+ it('drops duplicate saved entries for the same id', () => {
+ const ordered = reconcileMobileLayout(['a', 'a', 'b'], ['a', 'b']);
+ expect(ordered).toEqual(['a', 'b']);
+ });
+});
+
+describe('swapDesktopSlots', () => {
+ it('swaps the moved icon with the icon occupying the target cell', () => {
+ const slots = [
+ { id: 'a', col: 0, row: 0 },
+ { id: 'b', col: 1, row: 0 },
+ ];
+ const next = swapDesktopSlots(slots, 'a', { col: 1, row: 0 });
+ expect(next).toEqual([
+ { id: 'a', col: 1, row: 0 },
+ { id: 'b', col: 0, row: 0 },
+ ]);
+ });
+
+ it('moves the icon to an empty cell without disturbing others', () => {
+ const slots = [
+ { id: 'a', col: 0, row: 0 },
+ { id: 'b', col: 1, row: 0 },
+ ];
+ const next = swapDesktopSlots(slots, 'a', { col: 2, row: 0 });
+ expect(next).toEqual([
+ { id: 'a', col: 2, row: 0 },
+ { id: 'b', col: 1, row: 0 },
+ ]);
+ });
+
+ it('returns the original slots unchanged when the id is not found', () => {
+ const slots = [{ id: 'a', col: 0, row: 0 }];
+ const next = swapDesktopSlots(slots, 'missing', { col: 1, row: 0 });
+ expect(next).toBe(slots);
+ });
+});
diff --git a/src/os/iconLayout.ts b/src/os/iconLayout.ts
new file mode 100644
index 0000000..51ff0c7
--- /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 last stable row so they stay on-screen.
+ return { col: 0, row: Math.max(0, geometry.rows - 1) };
+}
+
+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..68a65e4
--- /dev/null
+++ b/src/os/useIconLayout.ts
@@ -0,0 +1,81 @@
+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 dirty = useRef(false);
+ const saveTimer = useRef | undefined>(undefined);
+ useEffect(() => {
+ if (!dirty.current) return;
+ clearTimeout(saveTimer.current);
+ saveTimer.current = setTimeout(() => {
+ dirty.current = false;
+ 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[]) => {
+ dirty.current = true;
+ setLayout((current) => ({
+ ...current,
+ desktop: updater(reconcileDesktopLayout(current.desktop, stableIds, stableGeometry)),
+ }));
+ }, [stableGeometry, stableIds]);
+ const setMobile = useCallback((updater: (order: string[]) => string[]) => {
+ dirty.current = true;
+ 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],
+ };
+ dirty.current = false;
+ clearTimeout(saveTimer.current);
+ 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]);
+}