import { useCallback, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import DOMPurify from 'dompurify' import { api } from '../api/client.js' import { useAuth } from '../contexts/AuthContext.jsx' import { useSite } from '../contexts/SiteContext.jsx' import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../lib/teamForum.js' // Core's Team forum, rendered into a second slot a MODULE declares // (TEAMS.md Part 5, and the phase 3 amendment to §3.4). // // **Why the forum is core's content on a module's page.** Everything that decides // who may read a thread is core's — the §2.5 resolver, the grants ledger, the // member/guest distinction — and none of it is a module's to reimplement. But // core does not own the word for a Team, so it publishes no Team page: the module // that says "guild" owns the page and declares a place on it, and core fills the // place. Same direction as the activity feed, same reason. // // **It is a whole forum inside one slot, and navigates by SEARCH PARAM.** A // thread needs to be linkable, and core cannot mount a route for it — the route // belongs to the module's page. `?thread=12` gives a shareable URL that works // under whatever path the module chose, with no route of core's anywhere in it, // and the browser's back button behaves. That is the whole reason this component // holds a list view and a detail view rather than being two components. // // **The image mode is published so this can draw the right composer — never to // decide what renders.** Post bodies arrive already rendered by the server under // the current policy (§5.5.3); the mode is read here only to show or hide an // upload control that would otherwise 404. If the two ever disagree, the server // is right. // // **Phase 5 added discussion, and with it three capabilities this file must not // invent for itself.** `canPost`, `canAnnounce` and each post's `canEdit` are // computed on the server and read here. In particular the edit window is a // server decision twice over — the read path stamps `canEdit`/`editableUntil` and // the write re-derives it — because a time-bounded permission must not take its // clock from the party it bounds. What this file does with `editableUntil` is // stop OFFERING an edit whose deadline has passed while the page sat open; it // never grants one. // // Like the feed, everything here degrades to rendering nothing. A 404 from the // thread list is the ordinary case — the forum is switched off, or this viewer // has no access — and putting an error box on a page core does not own would be // core reporting its own absence as a defect on someone else's surface. export default function TeamForumPanel({ externalId, moduleId }) { const { user } = useAuth() const { settings } = useSite() const [params, setParams] = useSearchParams() const [team, setTeam] = useState(null) const [state, setState] = useState({ loading: true, forum: null }) const [thread, setThread] = useState(null) const [composing, setComposing] = useState(null) // 'discussion' | 'announcement' | null const openThreadId = params.get('thread') const imageMode = settings?.teams_forum_images || 'disabled' const forumsEnabled = String(settings?.teams_forums_enabled ?? '0') === '1' const loadThreads = useCallback(async (slug) => { try { setState({ loading: false, forum: await api.teamForumThreads(slug) }) } catch { setState({ loading: false, forum: null }) } }, []) const loadThread = useCallback(async (slug, id) => { try { setThread(await api.teamForumThread(slug, id)) } catch { setThread(null) } }, []) useEffect(() => { let active = true // An anonymous visitor has no forum by definition — every route is behind // requireAuth — so skip the two calls rather than provoking a 401 per page. if (!externalId || !moduleId || !user || !forumsEnabled) { setState({ loading: false, forum: null }) return undefined } // The module names the Team its own way; core resolves that to a slug. Same // two-call shape as the activity feed, and for the same reason: a module // never has to hold core's identifiers. api.teamByExternalId(moduleId, externalId) .then(async (found) => { if (!active) return setTeam(found) await loadThreads(found.slug) }) .catch(() => { if (active) setState({ loading: false, forum: null }) }) return () => { active = false } }, [externalId, moduleId, user, forumsEnabled, loadThreads]) useEffect(() => { let active = true if (!team || !openThreadId) { setThread(null) return undefined } api.teamForumThread(team.slug, openThreadId) .then((t) => { if (active) setThread(t) }) .catch(() => { if (active) setThread(null) }) return () => { active = false } }, [team, openThreadId]) const openThread = (id) => { const next = new URLSearchParams(params) if (id == null) next.delete('thread') else next.set('thread', String(id)) setParams(next) } const { loading, forum } = state if (loading || !forum) return null if (openThreadId && thread) { return ( openThread(null)} onChanged={() => loadThread(team.slug, thread.id)} onModerate={async (action) => { await api.teamForumModerate(team.slug, thread.id, { action }) await loadThreads(team.slug) openThread(null) }} /> ) } return (

Forum

{!composing && (
{/* Two buttons, because phase 5 split one capability in two. `canPost` means "may open a discussion" and every participant may — including a granted guest with no game character, which is path 3 doing its job. `canAnnounce` is the leader-only half. */} {forum.canPost && ( )} {forum.canAnnounce && ( )}
)}
{composing && ( setComposing(null)} onPosted={async () => { setComposing(null) await loadThreads(team.slug) }} /> )} {forum.threads.length === 0 && !composing && (

Nothing has been posted here yet.

)} {forum.canModerate && }
    {forum.threads.map((t) => (
  • ))}
) } /** * The leader's grant control — §2.5 path 3, exercised by a leader rather than by * staff. * * Worth being explicit about what this admits someone to and what it does not: a * grant may name ANY account, including one with no linked game character, and it * writes nothing but the grants ledger. A guest here never appears on the roster, * never counts towards the Team's membership, and never becomes eligible for a * Discord role — an integration cannot verify that an unlinked account is a real * game member, so it must not hand that account a privilege somewhere * impersonation has consequences. * * A leader is capped; staff are not. The cap is shown rather than only enforced, * because a leader who hits a limit they were never told about reads it as a bug. */ function GuestManager({ slug }) { const [open, setOpen] = useState(false) const [data, setData] = useState(null) const [username, setUsername] = useState('') const [error, setError] = useState(null) const load = useCallback(async () => { try { setData(await api.teamGrantList(slug)) } catch { setData(null) } }, [slug]) useEffect(() => { if (open) load() }, [open, load]) const add = async (event) => { event.preventDefault() setError(null) try { await api.teamGrantAdd(slug, { username }) setUsername('') await load() } catch (err) { setError(err.message || 'Could not grant access') } } const revoke = async (userId) => { setError(null) try { await api.teamGrantRevoke(slug, userId) await load() } catch (err) { setError(err.message || 'Could not revoke that') } } if (!open) { return ( ) } return (

Forum guests

Guests read and post in this forum without being members of the Team. They do not appear on the roster and are not counted as members. {data?.cap ? ` Up to ${data.cap} at a time.` : ''}

setUsername(e.target.value)} placeholder="Account name" maxLength={32} required />
{error &&

{error}

}
) } function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, onModerate }) { // A clock that ticks, so an edit control whose deadline passed while the page // sat open goes away instead of becoming a button that fails. It only ever // REMOVES an offer — the server decides whether an edit happens, and re-derives // the window from created_at when it does. const [now, setNow] = useState(() => Date.now()) useEffect(() => { const id = setInterval(() => setNow(Date.now()), 30_000) return () => clearInterval(id) }, []) const [replying, setReplying] = useState(false) return (

{thread.title}

{thread.type === 'announcement' ? 'Announcement · ' : ''} {thread.author} {thread.authorDeleted && ' (account removed)'} {thread.locked && ' · locked'}

{thread.posts.map((post) => ( ))} {/* `canReply` is the server's answer to "does this thread take replies right now", and it folds together the two reasons it might not: an announcement takes none by TYPE, and a locked thread takes none by STATE. Both are reported separately above so the reader can see which. */} {thread.canReply && !replying && ( )} {thread.canReply && replying && ( setReplying(false)} onPosted={async () => { setReplying(false) await onChanged() }} /> )} {!thread.canReply && thread.locked && (

This thread is locked. Nobody can reply to it, including staff — a moderator who wants the last word unlocks it first, which leaves a record.

)}
{canModerate && ( <> )}
) } /** * One post, with whatever this reader may do to it. * * Every capability shown here was decided by the server and is read, not * computed: `canEdit` and `editableUntil` come stamped on the post, and * `canModerate` on the thread. The one local judgement is whether an * already-granted edit window has since elapsed, which can only take an offer * away. */ function PostView({ slug, post, canModerate, now, onChanged }) { const [editing, setEditing] = useState(false) const [body, setBody] = useState('') const [error, setError] = useState(null) const [busy, setBusy] = useState(false) const stillEditable = useMemo(() => editOfferOpen(post, now), [post, now]) const save = async (event) => { event.preventDefault() setBusy(true) setError(null) try { await api.teamForumEditPost(slug, post.id, { body }) setEditing(false) await onChanged() } catch (err) { setError(err.message || 'Could not save that') } finally { setBusy(false) } } const moderate = async (action) => { setError(null) try { await api.teamForumModeratePost(slug, post.id, { action }) await onChanged() } catch (err) { setError(err.message || 'Could not do that') } } return (

{post.author} {post.authorDeleted && ' (account removed)'} {post.editedAt && ' · edited'} {post.status === 'hidden' && ' · hidden'}

{editing ? (