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.

19.4 kB Ruw bestand
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { INK, PAPER, RED, MUTE, SKY, FONT_MONO, FONT_SERIF } from "../../theme";
import { genderLabel, THREAD_HITS_MAX } from "../../constants";
import { useAsync } from "../../hooks/useAsync";
import Notice from "../../components/Notice";
import Tabs from "../../components/Tabs";
import LetterCard from "./LetterCard";
import ComposePane from "../compose/ComposePane";

/*
 * Eén correspondentie, als één geheel: onderaan twee tabbladen — de brieven en het schrijven.
 * Schrijven is dus geen dialoog bovenop het gesprek maar een tabblad ernaast; je kunt tijdens het
 * schrijven terugbladeren en zoeken zonder je concept kwijt te raken (het paneel blijft gemonteerd,
 * alleen verborgen). Verlaten kan alleen via de tab Postvak of de terugknop, en dán vraagt App om
 * bevestiging als er een begonnen brief ligt — vandaar onDraftChange.
 *
 * De brieven zelf staan als een chat, oud → nieuw, maar je opent niet bovenaan:
 *   - Zijn er ongelezen brieven → je landt op de eerste ongelezen (met een "▼ nieuw"-lijn erboven).
 *   - Anders → onderaan, bij de nieuwste brief.
 *   - Oudere brieven laden bij zodra de sentinel bovenaan in beeld komt (of via de knop);
 *     de scrollpositie blijft daarbij staan.
 *
 * Boven het scrollvlak staat een vaste balk met de naam en de acties (favoriet, melden, blokkeren),
 * en daaronder de zoekbalk: die doorzoekt de hele correspondentie op de server, niet alleen de
 * geladen brieven. Zoeken vervangt de brieven zolang er iets in het veld staat; de treffers zijn
 * gewone brieven met de gevonden woorden onderstreept, in dezelfde volgorde als het gesprek
 * (oud → nieuw) en je landt onderaan bij de nieuwste — hetzelfde kompas als de brieven zelf:
 * omhoog is terug in de tijd. De server geeft ze nieuwste-eerst aan (zo pakt de limiet de
 * nieuwste treffers); hier draaien we ze om voor de weergave.
 *
 * thread = { user, letters, favorite, hasMore, canReply, canPhoto } | null (nog aan het laden) — de nieuwste
 * pagina, komt van App en ververst mee met het postvak. onLoadOlder(beforeLetterId) →
 * Promise<{ letters, hasMore }>. onSearch(q, { signal }) → Promise<{ letters, truncated }>.
 * onSend(text, photo) → Promise (App verstuurt en ververst de post).
 * onToggleFavorite(next) → Promise; favorieten staan bovenaan in het postvak.
 * onBlock() → Promise; App sluit daarna de correspondentie (die verdwijnt uit het postvak).
 * onReport(letter | null) opent de meldingsdialoog: met een brief gaat de melding over die brief,
 * zonder over de persoon. Melden mag ook als er niet geblokkeerd kan worden — het gaat naar de
 * beheerder, niet over jouw postvak.
 * canReply = false: een van beiden heeft de ander geblokkeerd → geen tabblad Schrijven, alleen een
 * neutrale regel. canPhoto = false: de ander heeft mij nog nooit geschreven → geen fotokiezer in
 * het schrijfblad. initialTab = "schrijven" opent meteen op het schrijfblad (vanuit Zoeken).
 */
export default function ThreadView({
  meId, thread, loading, error, onRetry, onToggleFavorite, onBlock, onReport,
  onLoadOlder, onSearch, onSend, initialTab = "brieven", onDraftChange,
}) {
  const userId = thread?.user.id ?? null;
  const scroller = useRef(null); // de <main> met de brieven, die scrolt

  /* ---------- Tabbladen ---------- */
  const [tabFor, setTabFor] = useState({ userId: null, tab: initialTab });
  const wanted = tabFor.userId === userId ? tabFor.tab : initialTab;
  /* Nog aan het laden, of geblokkeerd (door wie dan ook): er valt niets te schrijven, dus de brieven */
  const tab = !thread || !thread.canReply ? "brieven" : wanted;
  const savedScroll = useRef(0);
  const selectTab = (next) => {
    if (next === tab) return;
    if (tab === "brieven") savedScroll.current = scroller.current?.scrollTop ?? 0;
    setTabFor({ userId, tab: next });
  };

  /* Concept weggooien na verzenden: nieuwe sleutel → schoon paneel */
  const [sent, setSent] = useState(0);
  const send = async (text, photo) => {
    await onSend(text, photo); // fouten vangt ComposePane
    setSent((n) => n + 1);
    setTabFor({ userId, tab: "brieven" });
  };

  /* ---------- Zoeken door de hele correspondentie (serverkant) ---------- */
  const [qFor, setQFor] = useState({ userId: null, q: "" });
  const q = qFor.userId === userId ? qFor.q : "";
  const searching = q.trim().length > 0;
  const hitsQ = useAsync(
    (o) => (searching && userId ? onSearch(q, o) : Promise.resolve(null)),
    [q, userId],
    { delay: 200 }
  );
  const hits = searching ? hitsQ.data : null;

  /* ---------- Favoriet ---------- */
  const [fav, setFav] = useState({ busy: false, error: null });
  const toggleFavorite = async () => {
    setFav({ busy: true, error: null });
    try {
      await onToggleFavorite(!thread.favorite);
      setFav({ busy: false, error: null });
    } catch (e) {
      setFav({ busy: false, error: e.message });
    }
  };

  /* ---------- Blokkeren (met bevestiging; stil voor de ander) ---------- */
  const [blk, setBlk] = useState({ busy: false, error: null });
  const block = async () => {
    const name = thread.user.name;
    if (!window.confirm(`${name} blokkeren?\n\n${name} kan je niet meer schrijven en verdwijnt uit je postvak en uit Zoeken. ${name} krijgt hier geen bericht van. Je kunt dit terugdraaien bij Mijn profiel.`)) return;
    setBlk({ busy: true, error: null });
    try {
      await onBlock();
    } catch (e) {
      setBlk({ busy: false, error: e.message });
    }
  };

  /* ---------- Oudere brieven (per correspondent bijgehouden; App levert alleen de nieuwste pagina) ---------- */
  const [older, setOlder] = useState({ userId: null, letters: [], hasMore: null, busy: false, error: null });
  const olderFor = older.userId === userId ? older : null;
  const hasMore = olderFor?.hasMore ?? thread?.hasMore ?? false;
  const prevScrollHeight = useRef(null); // voor scrollbehoud bij invoegen bovenaan

  /* Nieuwste pagina + bijgeladen oudere, ontdubbeld en oud → nieuw */
  const letters = (() => {
    if (!thread) return [];
    const seen = new Set();
    return [...(olderFor?.letters ?? []), ...thread.letters]
      .filter((l) => !seen.has(l.id) && seen.add(l.id))
      .sort((a, b) => a.date - b.date || Number(a.id) - Number(b.id));
  })();

  const loadOlder = async () => {
    if (!thread || !hasMore || older.busy) return;
    const oldest = letters[0];
    if (!oldest) return;
    prevScrollHeight.current = scroller.current?.scrollHeight ?? null;
    setOlder((s) => ({ ...(s.userId === userId ? s : { userId, letters: [], hasMore: null }), busy: true, error: null }));
    try {
      const page = await onLoadOlder(oldest.id);
      setOlder((s) => ({
        userId,
        letters: [...page.letters, ...(s.userId === userId ? s.letters : [])],
        hasMore: page.hasMore,
        busy: false,
        error: null,
      }));
    } catch (e) {
      prevScrollHeight.current = null;
      setOlder((s) => ({ ...s, userId, busy: false, error: e.message }));
    }
  };

  /* Na het invoegen bovenaan: zoveel omlaag scrollen als er bij kwam, zodat je dezelfde brief blijft zien */
  useLayoutEffect(() => {
    const el = scroller.current;
    if (!el || prevScrollHeight.current == null) return;
    el.scrollTop += el.scrollHeight - prevScrollHeight.current;
    prevScrollHeight.current = null;
  }, [olderFor?.letters.length]);

  /* ---------- Openen: landen op de eerste ongelezen, anders onderaan ---------- */
  const firstNewId = useRef({ userId: null, id: null }); // vastgelegd bij openen; blijft staan als de server ze al 'gelezen' heeft
  if (thread && firstNewId.current.userId !== userId) {
    const first = thread.letters.find((l) => l.to === meId && !l.readAt);
    firstNewId.current = { userId, id: first?.id ?? null };
  }
  const newId = firstNewId.current.userId === userId ? firstNewId.current.id : null;
  const newIndex = newId ? letters.findIndex((l) => l.id === newId) : -1;

  const [landed, setLanded] = useState(null); // userId waarvoor de beginpositie al gezet is
  useLayoutEffect(() => {
    const el = scroller.current;
    /* Alleen als de brieven ook echt in beeld staan: op een verborgen vlak levert scrollTop niets op */
    if (!el || !thread || landed === userId || tab !== "brieven" || searching) return;
    const target = newId ? el.querySelector(`[data-letter="${newId}"]`) : null;
    if (target) {
      const marker = target.previousElementSibling ?? target; // de "▼ nieuw"-lijn
      const top = marker.getBoundingClientRect().top - el.getBoundingClientRect().top + el.scrollTop;
      el.scrollTop = Math.max(0, top - 8);
    } else {
      el.scrollTop = el.scrollHeight;
    }
    setLanded(userId);
  }, [thread, userId, newId, landed, tab, searching]);

  /* Terug van het schrijfblad: weer op de plek in de brieven waar je gebleven was */
  useLayoutEffect(() => {
    if (tab === "brieven" && landed === userId && scroller.current) scroller.current.scrollTop = savedScroll.current;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [tab]);

  /* Elke lading treffers landt onderaan, bij de nieuwste — net als het gesprek zelf */
  useLayoutEffect(() => {
    if (searching && scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight;
  }, [hits, searching]);

  /* ---------- Sentinel bovenaan: automatisch bijladen zodra hij in beeld komt (pas ná het landen) ---------- */
  const sentinel = useRef(null);
  useEffect(() => {
    const el = sentinel.current;
    const root = scroller.current;
    if (!el || !root || landed !== userId || !hasMore || searching || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && loadOlder(), { root, rootMargin: "120px 0px 0px 0px" });
    io.observe(el);
    return () => io.disconnect();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [landed, userId, hasMore, older.busy, searching, letters[0]?.id]);

  /* De server geeft de treffers nieuwste-eerst; getoond wordt oud → nieuw, als het gesprek */
  const shown = searching ? [...(hits?.letters ?? [])].reverse() : letters;

  return (
    <>
      {/* Vaste balk: naam + acties; scrolt niet mee */}
      {thread && (
        <div style={{ borderBottom: `1px solid ${SKY}`, padding: "10px 20px", background: "#fff" }}>
          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 }}>
            <h2 style={{ margin: 0, fontSize: 18, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
              {thread.user.name}
            </h2>
            <div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
              <button
                onClick={toggleFavorite}
                disabled={fav.busy}
                aria-pressed={thread.favorite}
                aria-label={thread.favorite ? "Favoriet — haal weg" : "Markeer als favoriet"}
                title={thread.favorite ? "Favoriet — staat bovenaan in je postvak" : "Markeer als favoriet"}
                style={{
                  background: "none", border: `2px solid ${thread.favorite ? RED : MUTE}`, color: thread.favorite ? RED : MUTE,
                  fontFamily: FONT_MONO, fontSize: 11, fontWeight: 700, padding: "5px 9px", whiteSpace: "nowrap",
                  opacity: fav.busy ? 0.6 : 1,
                }}
              >
                {thread.favorite ? "★ favoriet" : "☆ favoriet"}
              </button>
              <button
                onClick={() => onReport(null)}
                aria-label={`${thread.user.name} melden bij de beheerder`}
                title="Melden — gaat naar de beheerder; de ander hoort er niets van"
                style={{
                  background: "none", border: `2px solid ${MUTE}`, color: MUTE,
                  fontFamily: FONT_MONO, fontSize: 11, fontWeight: 700, padding: "5px 9px", whiteSpace: "nowrap",
                }}
              >
                ⚑ meld
              </button>
              <button
                onClick={block}
                disabled={blk.busy}
                aria-label={`${thread.user.name} blokkeren`}
                title="Blokkeren — stil; terug te draaien bij Mijn profiel"
                style={{
                  background: "none", border: `2px solid ${MUTE}`, color: MUTE,
                  fontFamily: FONT_MONO, fontSize: 11, fontWeight: 700, padding: "5px 9px", whiteSpace: "nowrap",
                  opacity: blk.busy ? 0.6 : 1,
                }}
              >
                ⊘ blokkeer
              </button>
            </div>
          </div>
          {(fav.error || blk.error) && (
            <p role="alert" style={{ fontFamily: FONT_MONO, fontSize: 12, color: RED, margin: "6px 0 0" }}>fout: {fav.error ?? blk.error}</p>
          )}
        </div>
      )}

      {/* Zoeken door het verleden van dit gesprek. Blijft staan terwijl je door de treffers gaat. */}
      {thread && tab === "brieven" && (
        <div style={{ borderBottom: `1px solid ${SKY}`, padding: "8px 20px 10px", background: "#fff" }}>
          <input
            type="search"
            value={q}
            onChange={(e) => setQFor({ userId, q: e.target.value })}
            placeholder={`Zoek in de brieven van ${thread.user.name} en jou…`}
            aria-label="Zoek in deze correspondentie"
            style={{
              width: "100%", padding: "9px 12px", fontSize: 14, border: `2px solid ${INK}`,
              borderRadius: 0, background: PAPER,
            }}
          />
          {searching && (
            <p aria-live="polite" style={{ fontFamily: FONT_MONO, fontSize: 11, color: MUTE, margin: "6px 0 0" }}>
              {hitsQ.loading && !hits
                ? "zoeken…"
                : hits
                  ? `${hits.letters.length}${hits.truncated ? "+" : ""} ${hits.letters.length === 1 ? "brief" : "brieven"}`
                  : ""}
            </p>
          )}
        </div>
      )}

      <main
        ref={scroller}
        className="app-main"
        style={{ display: tab === "brieven" ? "flex" : "none", paddingBottom: 20, flexDirection: "column" }}
      >
        <Notice loading={loading} error={error} onRetry={onRetry} />
        {thread && searching && (
          <>
            {hitsQ.error && (
              <p role="alert" style={{ fontFamily: FONT_MONO, fontSize: 12, color: RED, margin: "0 0 12px" }}>fout: {hitsQ.error}</p>
            )}
            {!hitsQ.loading && !hitsQ.error && shown.length === 0 && (
              <p style={{ fontFamily: FONT_MONO, fontSize: 12, color: MUTE, lineHeight: 1.6, margin: 0 }}>
                Geen brief met dat woord. Zoeken gaat door alle brieven tussen jullie, ook de oudste.
              </p>
            )}
            {/* Meer treffers dan de server in één keer geeft: de weggelaten zijn de óudere, dus
                de melding staat bovenaan — waar in het gewone beeld ook "eerdere brieven" staat */}
            {hits?.truncated && (
              <p style={{ fontFamily: FONT_MONO, fontSize: 11, color: MUTE, margin: "0 0 18px", lineHeight: 1.6 }}>
                Dit zijn de {THREAD_HITS_MAX} nieuwste treffers. Zoek op een tweede woord om gerichter te
                vinden wat je zoekt.
              </p>
            )}
          </>
        )}
        {thread && !searching && (
          <>
            {/* Bovenaan: oudere brieven bijladen */}
            {hasMore ? (
              <button
                ref={sentinel}
                onClick={loadOlder}
                disabled={older.busy}
                style={{
                  background: "none", border: `1px dashed ${MUTE}`, color: MUTE, fontFamily: FONT_MONO, fontSize: 11,
                  padding: "8px 12px", marginBottom: 18, width: "100%",
                }}
              >
                {older.busy ? "eerdere brieven laden…" : "↑ eerdere brieven"}
              </button>
            ) : (
              /* Aan het begin van de correspondentie: het profiel van de ander als eerste 'brief' —
                 in Zoeken is die persoon later misschien niet meer te vinden, hier wel terug te lezen */
              <article
                aria-label={`Profiel van ${thread.user.name}`}
                style={{ background: SKY, border: `1px dashed ${MUTE}`, padding: "14px 18px", marginBottom: 18 }}
              >
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 10, marginBottom: 6 }}>
                  <strong style={{ fontSize: 15 }}>Profiel van {thread.user.name}</strong>
                  <span style={{ fontFamily: FONT_MONO, fontSize: 10, color: MUTE, whiteSpace: "nowrap" }}>
                    {genderLabel(thread.user)} · {thread.user.age} jaar
                    {thread.user.relationship === "relatie" && <> · in een relatie</>}
                  </span>
                </div>
                <p style={{ fontFamily: FONT_SERIF, fontSize: 15, lineHeight: 1.65, margin: 0, whiteSpace: "pre-wrap", color: thread.user.text.trim() ? undefined : MUTE }}>
                  {thread.user.text.trim() || "(nog geen profieltekst)"}
                </p>
              </article>
            )}
            {olderFor?.error && (
              <p role="alert" style={{ fontFamily: FONT_MONO, fontSize: 12, color: RED, margin: "-8px 0 12px" }}>fout: {olderFor.error}</p>
            )}
          </>
        )}

        {thread && shown.map((l, i) => (
          <div key={l.id} style={{ display: "contents" }}>
            {!searching && i === newIndex && (
              <p
                aria-label="Vanaf hier nieuw"
                style={{
                  display: "flex", alignItems: "center", gap: 10, margin: "0 0 14px",
                  fontFamily: FONT_MONO, fontSize: 10, color: RED, fontWeight: 700, letterSpacing: "0.08em",
                }}
              >
                <span style={{ flex: 1, borderTop: `1px solid ${RED}` }} />
                ▼ nieuw
                <span style={{ flex: 1, borderTop: `1px solid ${RED}` }} />
              </p>
            )}
            <div data-letter={l.id}>
              <LetterCard
                letter={l}
                meId={meId}
                other={thread.user}
                fresh={!searching && newIndex >= 0 && i >= newIndex && l.from !== meId}
                onReport={() => onReport(l)}
              />
            </div>
          </div>
        ))}
      </main>

      {/* Blijft gemonteerd terwijl je op het andere tabblad kijkt: het concept mag niet verdwijnen.
          De sleutel verandert na verzenden (en bij een andere correspondent) → schoon paneel. */}
      {thread?.canReply && (
        <ComposePane
          key={`${userId}:${sent}`}
          recipient={thread.user}
          canPhoto={thread.canPhoto}
          active={tab === "schrijven"}
          onSend={send}
          onDraftChange={onDraftChange}
        />
      )}

      {/* Onderaan: de tabbladen. Geblokkeerd (door wie dan ook): geen tabs, alleen een neutrale
          regel — geen reden, geen 'je bent geblokkeerd'. */}
      {thread && (thread.canReply ? (
        <Tabs
          bottom
          items={[{ id: "brieven", label: "Correspondentie" }, { id: "schrijven", label: "✉ Schrijven" }]}
          active={tab}
          onSelect={selectTab}
        />
      ) : (
        <p
          style={{
            margin: 0, padding: "12px 20px calc(12px + env(safe-area-inset-bottom, 0px))", textAlign: "center",
            fontFamily: FONT_MONO, fontSize: 12, color: MUTE, borderTop: `2px solid ${INK}`, background: "#fff",
          }}
        >
          Post aan {thread.user.name} kan niet meer bezorgd worden.
        </p>
      ))}
    </>
  );
}