import { useEffect, useRef, useState } from "react"; import { App as CapApp } from "@capacitor/app"; import { INK, PAPER, RED, FONT_SANS, FONT_MONO } from "./theme"; import { api } from "./api/client"; import { useSession } from "./lib/session"; import { syncPostSession, takePostOpen } from "./lib/postbode"; import { log } from "./lib/log"; import { useAsync } from "./hooks/useAsync"; import AirmailEdge from "./components/AirmailEdge"; import Header from "./components/Header"; import Tabs from "./components/Tabs"; import LoadingScreen from "./components/LoadingScreen"; import AuthView from "./features/auth/AuthView"; import ConsentView from "./features/auth/ConsentView"; import WarningView from "./features/auth/WarningView"; import SearchView from "./features/search/SearchView"; import InboxView from "./features/inbox/InboxView"; import ThreadView from "./features/inbox/ThreadView"; import ProfileEditor from "./features/profile/ProfileEditor"; import ReportDialog from "./features/report/ReportDialog"; /* Hoe vaak het postvak op de achtergrond ververst (nieuwe post zonder herstart) */ const MAIL_POLL_MS = 30_000; /* * Sessiepoort: zonder token het inlog-/registratiescherm, mét token de app. * `key={token}` zorgt dat de app-state schoon begint bij elke nieuwe sessie. */ export default function App() { const token = useSession(); const [onboarding, setOnboarding] = useState(false); // net geregistreerd → eerst het profiel /* * De bezorging van "er ligt post voor je" volgt de sessie: bij inloggen krijgt hij het nieuwe * token, bij uitloggen niets — en dan stopt het dagelijkse kijken (zie lib/postbode.js). Hier, * omdat dit de enige plek is waar het token van eigenaar wisselt. */ useEffect(() => { syncPostSession(token); }, [token]); return (
); } /* De ingelogde app: tabs, views en de brief-dialoog */ function Shell({ token, onboarding, onOnboarded }) { const [tab, setTab] = useState("zoeken"); const [query, setQuery] = useState(""); /* Net geregistreerd: eerst het profiel (de vlag komt net ná het token binnen, vandaar een effect) */ useEffect(() => { if (onboarding) setTab("profiel"); }, [onboarding]); const [openThread, setOpenThread] = useState(null); // userId van gesprekspartner /* * Hoe de open correspondentie begon: op welk tabblad (vanuit Zoeken meteen bij het schrijven) en * waar we vandaan kwamen, zodat de terugknop je terugbrengt waar je was in plaats van in het postvak. */ const [entry, setEntry] = useState({ tab: "brieven", from: "postvak" }); /* Ligt er een begonnen brief? ThreadView houdt het bij; het is alleen nodig bij het verlaten */ const draft = useRef(false); /* Meldingsdialoog: { user, letter, canBlock } — letter null = de persoon melden, niet één brief */ const [report, setReport] = useState(null); const [mailVersion, setMailVersion] = useState(0); // bump → postvak en open correspondentie herladen const refreshMail = () => setMailVersion((v) => v + 1); const meQ = useAsync((o) => api.getMe(o), []); const searchQ = useAsync((o) => api.search(query, o), [query, meQ.data], { delay: 200 }); const threadsQ = useAsync((o) => api.listThreads(o), [mailVersion]); const blockedQ = useAsync((o) => api.listBlocked(o), [mailVersion]); // lijst bij Mijn profiel const threadQ = useAsync( (o) => (openThread ? api.getThread(openThread, {}, o) : Promise.resolve(null)), [openThread, mailVersion] ); const me = meQ.data; const threads = threadsQ.data ?? []; const unread = threads.reduce((n, t) => n + t.unread, 0); /* * De hoogste brief die deze app kent. Gaat mee als de meldingsinstelling aangezet wordt: de eerste * melding hoort over post te gaan die daarna komt, niet over wat er op dat moment al ligt. */ const lastLetterId = threads.reduce((hoogste, t) => (Number(t.last.id) > Number(hoogste) ? t.last.id : hoogste), "0"); /* Open correspondentie gelezen? Dan markeren en de postvak-teller bijwerken */ const thread = threadQ.data; useEffect(() => { if (!me || !thread || thread.user.id !== openThread) return; if (!thread.letters.some((l) => l.to === me.id && !l.readAt)) return; let cancelled = false; /* Mislukt dit, dan blijft de teller staan en gaat het bij de volgende keer kijken vanzelf goed */ api.markThreadRead(openThread) .then(() => !cancelled && threadsQ.reload()) .catch((e) => log("✗ markThreadRead", e?.message ?? String(e))); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [thread, me, openThread]); /* Nieuwe post ophalen: periodiek, en zodra de app weer in beeld komt */ useEffect(() => { const onVisible = () => document.visibilityState === "visible" && refreshMail(); const timer = setInterval(onVisible, MAIL_POLL_MS); document.addEventListener("visibilitychange", onVisible); return () => { clearInterval(timer); document.removeEventListener("visibilitychange", onVisible); }; }, []); /* * Getikt op "er ligt post voor je" → het postvak, niet Zoeken. Bij een koude start staat het in * de intent waarmee de app opkwam, bij een app die al draaide komt hij binnen terwijl we hier * staan — vandaar ook bij het weer actief worden. Een openstaande correspondentie blijft staan: * een melding hoort geen begonnen brief weg te gooien. */ useEffect(() => { const kijk = () => takePostOpen().then((waarheen) => { if (waarheen === "postvak") setTab("postvak"); }); kijk(); const sub = CapApp.addListener("appStateChange", ({ isActive }) => { if (isActive) kijk(); }); return () => { sub.then((h) => h.remove()); }; }, []); /* * Een correspondentie openen. Vanuit het postvak begin je bij de brieven; vanuit Zoeken meteen op * het schrijfblad — daar is "schrijf deze persoon" de bedoeling, en de brieven (er zijn er dan vaak * nog geen) staan ernaast. Sluiten brengt je terug waar je vandaan kwam. */ const openCorrespondence = (userId, e = { tab: "brieven", from: "postvak" }) => { setEntry(e); setTab("postvak"); setOpenThread(userId); }; /* Weggaan bij een begonnen brief: hetzelfde als vroeger het sluiten van de dialoog */ const mayLeave = () => !draft.current || window.confirm("Deze brief weggooien?"); const closeCorrespondence = () => { setOpenThread(null); if (entry.from === "zoeken") setTab("zoeken"); }; /* * Android-terugknop: sluit eerst de meldingsdialoog, dan de correspondentie (met bevestiging als * er een brief begonnen is), dan terug naar Zoeken; pas daarna verlaat hij de app. In de browser * gebeurt er niets. */ const nav = useRef({}); nav.current = { report, openThread, tab, from: entry.from }; useEffect(() => { const sub = CapApp.addListener("backButton", () => { const s = nav.current; if (s.report) setReport(null); else if (s.openThread) { if (!draft.current || window.confirm("Deze brief weggooien?")) { setOpenThread(null); if (s.from === "zoeken") setTab("zoeken"); } } else if (s.tab !== "zoeken") setTab("zoeken"); else CapApp.exitApp(); }); return () => { sub.then((h) => h.remove()); }; }, []); /* Verzenden; ThreadView springt daarna zelf terug naar de brieven en gooit het concept weg */ const send = async (text, photo) => { await api.sendLetter({ to: openThread, text, photo }); // fouten vangt ComposePane refreshMail(); }; const saveProfile = async (profile) => { const next = await api.updateMe(profile); meQ.reload(); // andere resultaten na filterwijziging if (onboarding) { onOnboarded(); setTab("zoeken"); } return next; }; const logout = () => api.logout(); // wist het token → App toont het inlogscherm /* AVG (bij Mijn profiel): inzage en definitief wissen; na wissen is het token weg → inlogscherm */ const exportData = () => api.exportMe(); const deleteAccount = (password, farewell) => api.deleteMe(password, farewell); /* AVG: toestemming alsnog geven (ConsentView) en accountgegevens wijzigen (AccountSection); daarna `me` verversen */ const giveConsent = async () => { await api.giveConsent(); meQ.reload(); }; /* Waarschuwing van de beheerder weggeklikt (WarningView); daarna komt de app pas vrij */ const ackWarning = async (id) => { await api.ackWarning(id); meQ.reload(); }; const updateAccount = async (changes) => { const next = await api.updateAccount(changes); meQ.reload(); // searchQ hangt aan me → zoekresultaten volgen vanzelf (gender/leeftijd veranderen de matching) return next; }; /* Overal elders uitloggen; dit toestel blijft, dus er hoeft niets herladen te worden */ const logoutOtherDevices = (currentPassword) => api.logoutOthers(currentPassword); /* Oudere brieven van de open correspondentie bijladen (ThreadView houdt ze zelf bij) */ const loadOlder = (before) => api.getThread(openThread, { before }); /* Zoeken door de open correspondentie: de server doorzoekt álle brieven, niet alleen de geladen */ const searchInThread = (q, o) => api.searchThread(openThread, q, o); /* Favoriet aan/uit voor de open correspondentie; daarna postvak (volgorde) en correspondentie (knop) herladen */ const toggleFavorite = async (next) => { await api.setFavorite(openThread, next); refreshMail(); }; /* Blokkeren vanuit de open correspondentie: die verdwijnt uit het postvak, dus terug naar het overzicht */ const blockUser = async () => { await api.setBlock(openThread, true); closeCorrespondence(); refreshMail(); }; /* Deblokkeren (bij Mijn profiel): correspondentie en zoekresultaten komen terug */ const unblockUser = async (userId) => { await api.setBlock(userId, false); refreshMail(); searchQ.reload(); }; /* * Melden: gaat naar de beheerder, niet naar de ander. Vanuit een correspondentie kan er meteen * geblokkeerd worden (dan sluit de correspondentie, net als bij de blokkeerknop); vanuit Zoeken * ligt er geen post en biedt de dialoog dat niet aan. */ const submitReport = async ({ reason, details, block }) => { const result = await api.report({ about: report.user.id, letterId: report.letter?.id ?? null, reason, details, block, }); if (result.blocked) { closeCorrespondence(); refreshMail(); searchQ.reload(); } return result; }; const badge = unread > 0 && ( {unread} ); const tabs = [ { id: "zoeken", label: "Zoeken" }, { id: "postvak", label: <>Postvak{badge} }, { id: "profiel", label: "Mijn profiel" }, ]; /* * Twee poorten vóór de app, in deze volgorde: eerst de AVG-toestemming (zonder grondslag mag er * niets), dan een openstaande waarschuwing van de beheerder (die moet gelezen zijn). Pas daarna * `ready` — tabs en views. */ const warning = me?.consentAt ? me.warning : null; const ready = Boolean(me?.consentAt) && !warning; return ( <> {/* Eerste keer laden: laadscherm in plaats van header + tabs (sluit aan op de native splash) */} {!me && } {/* AVG: geen (vastgelegde) toestemming voor de gevoelige gegevens? Dan eerst die vraag, niets anders */} {me && !me.consentAt && ( )} {/* Waarschuwing van de beheerder: blokkeert de app tot hij gelezen en weggeklikt is */} {warning && } {/* Geen kop in de app zelf: die staat alleen op het inlog-/registratiescherm (met het logo) */} {ready && !onboarding && ( { if (mayLeave()) { setTab(id); setOpenThread(null); } }} /> )} {ready && tab === "zoeken" && ( openCorrespondence(userId, { tab: "schrijven", from: "zoeken" })} onReport={(user) => setReport({ user, letter: null, canBlock: false })} onEditProfile={() => setTab("profiel")} /> )} {ready && tab === "postvak" && !openThread && ( openCorrespondence(userId)} /> )} {ready && tab === "postvak" && openThread && ( setReport({ user: thread.user, letter, canBlock: true })} onLoadOlder={loadOlder} onSearch={searchInThread} onSend={send} initialTab={entry.tab} onDraftChange={(has) => { draft.current = has; }} /> )} {ready && tab === "profiel" && ( )} {report && ( setReport(null)} /> )} ); }