Een dating-app op basis van brieven, niet van gezichten!
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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 (
<div className="app-bg" style={{ background: PAPER, color: INK, fontFamily: FONT_SANS }}>
<div className="app-shell" style={{ background: "#fff", boxShadow: "0 0 40px rgba(31,44,85,0.08)" }}>
<AirmailEdge />
<div className="safe-top" aria-hidden="true" />
{token ? (
<Shell key={token} token={token} onboarding={onboarding} onOnboarded={() => setOnboarding(false)} />
) : (
<>
<Header />
<AuthView onDone={(_user, { isNew }) => setOnboarding(isNew)} />
</>
)}
</div>
</div>
);
}
/* 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 && (
<span
aria-label={`${unread} ongelezen`}
style={{
display: "inline-block", minWidth: 18, padding: "0 5px", marginLeft: 6, borderRadius: 9,
background: RED, color: "#fff", fontFamily: FONT_MONO, fontSize: 11, lineHeight: "18px", verticalAlign: "1px",
}}
>
{unread}
</span>
);
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 && <LoadingScreen loading={meQ.loading} error={meQ.error} onRetry={meQ.reload} />}
{/* AVG: geen (vastgelegde) toestemming voor de gevoelige gegevens? Dan eerst die vraag, niets anders */}
{me && !me.consentAt && (
<ConsentView me={me} onConsent={giveConsent} onExport={exportData} onDelete={deleteAccount} onLogout={logout} />
)}
{/* Waarschuwing van de beheerder: blokkeert de app tot hij gelezen en weggeklikt is */}
{warning && <WarningView me={me} warning={warning} onAcknowledge={ackWarning} />}
{/* Geen kop in de app zelf: die staat alleen op het inlog-/registratiescherm (met het logo) */}
{ready && !onboarding && (
<Tabs
items={tabs}
active={tab}
onSelect={(id) => { if (mayLeave()) { setTab(id); setOpenThread(null); } }}
/>
)}
{ready && tab === "zoeken" && (
<SearchView
me={me}
query={query}
onQueryChange={setQuery}
results={searchQ.data?.results ?? []}
truncated={searchQ.data?.truncated ?? false}
loading={searchQ.loading && !searchQ.data}
error={searchQ.error}
onRetry={searchQ.reload}
onWrite={(userId) => openCorrespondence(userId, { tab: "schrijven", from: "zoeken" })}
onReport={(user) => setReport({ user, letter: null, canBlock: false })}
onEditProfile={() => setTab("profiel")}
/>
)}
{ready && tab === "postvak" && !openThread && (
<InboxView
meId={me.id}
threads={threads}
loading={threadsQ.loading && !threadsQ.data}
error={threadsQ.error}
onRetry={threadsQ.reload}
onOpen={(userId) => openCorrespondence(userId)}
/>
)}
{ready && tab === "postvak" && openThread && (
<ThreadView
key={openThread}
meId={me.id}
thread={thread?.user.id === openThread ? thread : null}
loading={threadQ.loading && thread?.user.id !== openThread}
error={threadQ.error}
onRetry={threadQ.reload}
onToggleFavorite={toggleFavorite}
onBlock={blockUser}
onReport={(letter) => setReport({ user: thread.user, letter, canBlock: true })}
onLoadOlder={loadOlder}
onSearch={searchInThread}
onSend={send}
initialTab={entry.tab}
onDraftChange={(has) => { draft.current = has; }}
/>
)}
{ready && tab === "profiel" && (
<ProfileEditor
me={me}
onboarding={onboarding}
onSave={saveProfile}
onLogout={logout}
blocked={blockedQ.data ?? []}
onUnblock={unblockUser}
onUpdateAccount={updateAccount}
onLogoutOthers={logoutOtherDevices}
onExport={exportData}
onDelete={deleteAccount}
token={token}
lastLetterId={lastLetterId}
/>
)}
{report && (
<ReportDialog
subject={report.user}
letter={report.letter}
canBlock={report.canBlock}
onSubmit={submitReport}
onClose={() => setReport(null)}
/>
)}
</>
);
}