import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import * as api from "./api"; import Nav from "./components/Nav"; import Settings from "./components/Settings"; import { BTN_PRIMARY } from "./components/ui"; import { useAppearance, type Theme } from "./hooks/useAppearance"; import type { Config, Group, HiddenEvent, NotificationClick, SwitchEvent } from "./types"; export default function App() { const [config, setConfig] = useState(null); const [activeId, setActiveId] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); const [focusHidden, setFocusHidden] = useState(null); const [theme, setTheme] = useAppearance("system"); const [unread, setUnread] = useState>({}); const [fullscreen, setFullscreen] = useState(false); const [backdrop, setBackdrop] = useState(null); const stageRef = useRef(null); const booted = useRef(false); // Read inside listeners, which are registered once and would otherwise // capture the first render's value forever. const activeRef = useRef(null); activeRef.current = activeId; const collapsed = config?.settings.navCollapsed ?? false; /* The window has no frame of its own, so in a normal window the shell keeps a margin around itself: somewhere to grab that is not a web page, and the only way to move or place the window by hand. Full screen has no use for it and gives the room back. */ useEffect(() => { const w = getCurrentWindow(); const check = () => void w.isFullscreen().then(setFullscreen); check(); const un = w.onResized(check); return () => { void un.then((f) => f()); }; }, []); useEffect(() => { api.getConfig().then((c) => { setConfig(c); setTheme(c.settings.theme); const first = [...c.apps].sort((a, b) => a.order - b.order)[0]; setActiveId(first?.id ?? null); }); }, [setTheme]); /** * Tells Rust where the stage is. * * An app's webview is a native view that takes no part in CSS layout, so the * only way it lands in the right place is for the shell to measure the hole * it left and report it. */ const report = useCallback(() => { const el = stageRef.current; if (!el) return; const r = el.getBoundingClientRect(); void api.setStage(r.x, r.y, r.width, r.height); }, []); useLayoutEffect(() => { const el = stageRef.current; if (!el || !config) return; report(); const ro = new ResizeObserver(report); ro.observe(el); window.addEventListener("resize", report); return () => { ro.disconnect(); window.removeEventListener("resize", report); }; }, [config, report]); // Webviews are built only once the stage has been measured, so they are born // at the right size instead of against a guess. useEffect(() => { if (!config || booted.current || config.apps.length === 0) return; booted.current = true; report(); void api.bootstrap(); }, [config, report]); useEffect(() => { if (activeId) void api.setActive(activeId); }, [activeId]); useEffect(() => { const unlisten = [ // A link in one app that points at another: Rust decided, the shell moves. listen("switch-app", async (e) => { setActiveId(e.payload.appId); await api.navigateApp(e.payload.appId, e.payload.url); }), // Something was right-clicked away inside a page. listen("hidden-added", () => { void api.getConfig().then(setConfig); }), // "Manage hidden elements…" from a page's context menu. listen("manage-hidden", (e) => { setFocusHidden(e.payload); setSettingsOpen(true); }), // A macOS banner was clicked. Switch to the app that raised it, then let // that page's own handler run — it is the only thing that knows which // message the notification was about. listen("notification-clicked", async (e) => { await api.focusWindow(); setSettingsOpen(false); setActiveId(e.payload.appId); await api.setActive(e.payload.appId); await api.notificationClick(e.payload.appId, e.payload.notificationId); }), // Zoom changed from the menu bar; keep Settings' sliders honest. listen<[string, number]>("zoom-changed", () => { void api.getConfig().then(setConfig); }), listen<[string, number][]>("unread-changed", (e) => { setUnread(Object.fromEntries(e.payload)); }), ]; return () => { unlisten.forEach((p) => p.then((f) => f())); }; }, []); /* A native view paints over anything the shell draws, so a dialog needs the app moved out of the way rather than merely a higher z-index — and once it is moved there is nothing left behind the dialog to look at. A still taken on the way out, blurred, puts the background back. */ useEffect(() => { if (!config) return; let cancelled = false; if (settingsOpen) { void (async () => { const shot = await api.stageSnapshot().catch(() => null); if (cancelled) return; setBackdrop(shot); await api.hideStage(); })(); } else { setBackdrop(null); void api.showStage(); } return () => { cancelled = true; }; }, [settingsOpen, config]); const toggleCollapse = () => { if (!config) return; const next = !collapsed; setConfig({ ...config, settings: { ...config.settings, navCollapsed: next } }); void api.setNavCollapsed(next); }; const toggleGroup = (g: Group) => { if (!config) return; const updated = { ...g, collapsed: !g.collapsed }; setConfig({ ...config, groups: config.groups.map((x) => (x.id === g.id ? updated : x)) }); void api.updateGroup(updated); }; const onTheme = (t: Theme) => { setTheme(t); void api.setTheme(t); }; if (!config) return null; const frame = fullscreen ? 0 : 6; return (
{settingsOpen && ( { setSettingsOpen(false); setFocusHidden(null); }} /> )}
); }