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, 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>
))}
</>
);
}