import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import * as api from "./api"; import Nav from "./components/Nav"; import Settings from "./components/Settings"; import { BTN_PRIMARY, Dialog } from "./components/ui"; import { useAppearance, type Theme } from "./hooks/useAppearance"; import type { Config, Group, HiddenEvent, NotificationClick, PasswordOffer, 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 [backdrop, setBackdrop] = useState(null); const [offer, setOffer] = useState(null); const [saved, setSaved] = 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; 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(); /* Rounded to whole pixels, and to the same edges each time. A fractional left edge leaves a sliver of the stage's own background showing between the nav's border and the app — which reads as a second, lighter border line running down the whole window. */ const x = Math.round(r.x); const y = Math.round(r.y); void api.setStage(x, y, Math.round(r.right) - x, Math.round(r.bottom) - y, 0); }, []); 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)); }), // A login was submitted. The password is held in Rust; this only asks. listen("password-offer", (e) => setOffer(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; return (
); }