Een dating-app op basis van brieven, niet van gezichten!

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

15.0 kB Ruw bestand
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)}
        />
      )}
    </>
  );
}