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
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 && (

{thread.user.name}

{(fav.error || blk.error) && (

fout: {fav.error ?? blk.error}

)}
)} {/* Zoeken door het verleden van dit gesprek. Blijft staan terwijl je door de treffers gaat. */} {thread && tab === "brieven" && (
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 && (

{hitsQ.loading && !hits ? "zoeken…" : hits ? `${hits.letters.length}${hits.truncated ? "+" : ""} ${hits.letters.length === 1 ? "brief" : "brieven"}` : ""}

)}
)}
{thread && searching && ( <> {hitsQ.error && (

fout: {hitsQ.error}

)} {!hitsQ.loading && !hitsQ.error && shown.length === 0 && (

Geen brief met dat woord. Zoeken gaat door alle brieven tussen jullie, ook de oudste.

)} {/* 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 && (

Dit zijn de {THREAD_HITS_MAX} nieuwste treffers. Zoek op een tweede woord om gerichter te vinden wat je zoekt.

)} )} {thread && !searching && ( <> {/* Bovenaan: oudere brieven bijladen */} {hasMore ? ( ) : ( /* 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 */
Profiel van {thread.user.name} {genderLabel(thread.user)} · {thread.user.age} jaar {thread.user.relationship === "relatie" && <> · in een relatie}

{thread.user.text.trim() || "(nog geen profieltekst)"}

)} {olderFor?.error && (

fout: {olderFor.error}

)} )} {thread && shown.map((l, i) => (
{!searching && i === newIndex && (

▼ nieuw

)}
= 0 && i >= newIndex && l.from !== meId} onReport={() => onReport(l)} />
))}
{/* 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 && ( )} {/* Onderaan: de tabbladen. Geblokkeerd (door wie dan ook): geen tabs, alleen een neutrale regel — geen reden, geen 'je bent geblokkeerd'. */} {thread && (thread.canReply ? ( ) : (

Post aan {thread.user.name} kan niet meer bezorgd worden.

))} ); }