feat(teams): the phase 5 surface — discussion, replies, reports, and two admin screens

241 client tests pass (224 before).

**The forum panel becomes a forum.** It was "Announcements" with one composer;
it now has two, because phase 5 split one server capability into two: `canPost`
means "may open a discussion" and every participant may — a granted guest with no
game character included, which is path 3 doing its job — while `canAnnounce` is
the leader-only half `canPost` used to carry alone. Threads gain replies, an edit
control, per-post moderation and a report control, all still inside the one slot
the module declares, still navigating by `?thread=`.

**Almost nothing here is the client's decision, and the file says so.** `canPost`,
`canAnnounce`, `canReply` and each post's `canEdit`/`editableUntil` are read, not
computed. The one local judgement is a ticking clock that WITHDRAWS an edit offer
whose deadline passed while the page sat open — it can never grant one, because a
time-bounded permission must not take its clock from the party it bounds. That
asymmetry is the first thing client/test/teamForum.test.js asserts.

The panel's pure parts moved to `lib/teamForum.js` so they can be tested without a
browser, following teamActivity.js and teamAdmin.js. Two of them are subtler than
they look:

  * `stripToText` decodes entities AFTER stripping tags, and `&` last of all.
    Decoding first turns an author's literal "<script>" into a real tag the
    strip pass then deletes — silently losing text that was never dangerous.
  * `threadSummary` counts REPLIES, which is one fewer than `postCount`. Showing
    the raw count tells a reader a brand-new thread already has one reply.

**Three admin surfaces.** The forum settings screen gains the edit-window field
(0 = posts permanent once written). The reports queue is a new screen beside
Appeals — under moderation rather than under Teams, because a staffer working a
queue should have one place to work and `target_type` is deliberately open-ended,
so the next reportable thing arrives as a row rather than as another nav entry.
Its copy tells a member where a report lands and that reporting changes nothing,
because a member who expects a post to vanish and watches it stay reports it
again. There is no leader-facing view and there is not meant to be.

And the per-Team forum moderation ledger finally renders: the route and
`api.admin.teamForumModeration()` have both existed since phase 4 with nothing
calling them, which made `actor_role` — the column that keeps a leader's ordinary
housekeeping distinguishable from a staff intervention — readable only from a DB
client.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-18 13:08:59 -05:00
parent 128de0ff2e
commit 3f7e61af1c
11 changed files with 1145 additions and 47 deletions

View File

@@ -1,9 +1,10 @@
import { useCallback, useEffect, useState } from 'react'
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).
@@ -28,6 +29,15 @@ import { useSite } from '../contexts/SiteContext.jsx'
// 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
@@ -40,7 +50,7 @@ export default function TeamForumPanel({ externalId, moduleId }) {
const [team, setTeam] = useState(null)
const [state, setState] = useState({ loading: true, forum: null })
const [thread, setThread] = useState(null)
const [composing, setComposing] = useState(false)
const [composing, setComposing] = useState(null) // 'discussion' | 'announcement' | null
const openThreadId = params.get('thread')
const imageMode = settings?.teams_forum_images || 'disabled'
@@ -54,6 +64,14 @@ export default function TeamForumPanel({ externalId, moduleId }) {
}
}, [])
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
@@ -100,9 +118,12 @@ export default function TeamForumPanel({ externalId, moduleId }) {
if (openThreadId && thread) {
return (
<ThreadView
slug={team.slug}
thread={thread}
canModerate={forum.canModerate}
imageMode={imageMode}
onBack={() => openThread(null)}
onChanged={() => loadThread(team.slug, thread.id)}
onModerate={async (action) => {
await api.teamForumModerate(team.slug, thread.id, { action })
await loadThreads(team.slug)
@@ -116,22 +137,38 @@ export default function TeamForumPanel({ externalId, moduleId }) {
<section style={{ marginTop: 26 }}>
<header style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: 0 }}>
Announcements
Forum
</h2>
{forum.canPost && !composing && (
<button type="button" className="pill" onClick={() => setComposing(true)}>
Post an announcement
</button>
{!composing && (
<div style={{ display: 'flex', gap: 8 }}>
{/*
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 && (
<button type="button" className="pill" onClick={() => setComposing('discussion')}>
Start a discussion
</button>
)}
{forum.canAnnounce && (
<button type="button" className="pill" onClick={() => setComposing('announcement')}>
Post an announcement
</button>
)}
</div>
)}
</header>
{composing && (
<Composer
slug={team.slug}
type={composing}
imageMode={imageMode}
onCancel={() => setComposing(false)}
onCancel={() => setComposing(null)}
onPosted={async () => {
setComposing(false)
setComposing(null)
await loadThreads(team.slug)
}}
/>
@@ -139,7 +176,7 @@ export default function TeamForumPanel({ externalId, moduleId }) {
{forum.threads.length === 0 && !composing && (
<p className="sans dim" style={{ fontSize: '0.9rem', marginTop: 8 }}>
Nothing has been announced here yet.
Nothing has been posted here yet.
</p>
)}
@@ -158,10 +195,10 @@ export default function TeamForumPanel({ externalId, moduleId }) {
}}
>
{t.pinned && <span className="dim" style={{ marginRight: 6 }} title="Pinned">📌</span>}
{t.locked && <span className="dim" style={{ marginRight: 6 }} title="Locked">🔒</span>}
<strong>{t.title}</strong>
<span className="dim" style={{ marginLeft: 8, fontSize: '0.82rem' }}>
{t.author}
{t.status === 'hidden' && ' · hidden'}
{threadSummary(t)}
</span>
</button>
</li>
@@ -272,22 +309,167 @@ function GuestManager({ slug }) {
)
}
function ThreadView({ thread, canModerate, onBack, onModerate }) {
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 (
<section style={{ marginTop: 26 }}>
<button type="button" className="pill" onClick={onBack} style={{ marginBottom: 10 }}>
All announcements
All threads
</button>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: '0 0 4px' }}>
{thread.title}
</h2>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
{thread.type === 'announcement' ? 'Announcement · ' : ''}
{thread.author}
{thread.authorDeleted && ' (account removed)'}
{thread.locked && ' · locked'}
</p>
{thread.posts.map((post) => (
<article key={post.id} style={{ marginBottom: 16 }}>
<PostView
key={post.id}
slug={slug}
post={post}
canModerate={canModerate}
now={now}
onChanged={onChanged}
/>
))}
{/*
`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 && (
<button type="button" className="pill" onClick={() => setReplying(true)} style={{ marginTop: 4 }}>
Reply
</button>
)}
{thread.canReply && replying && (
<ReplyBox
slug={slug}
threadId={thread.id}
imageMode={imageMode}
onCancel={() => setReplying(false)}
onPosted={async () => {
setReplying(false)
await onChanged()
}}
/>
)}
{!thread.canReply && thread.locked && (
<p className="sans dim" style={{ fontSize: '0.85rem', marginTop: 10 }}>
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.
</p>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap' }}>
<ReportControl
slug={slug}
targetType="team_forum_thread"
targetId={thread.id}
label="Report this thread"
/>
{canModerate && (
<>
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
{thread.pinned ? 'Unpin' : 'Pin'}
</button>
<button type="button" className="pill" onClick={() => onModerate(thread.locked ? 'unlock' : 'lock')}>
{thread.locked ? 'Unlock' : 'Lock'}
</button>
<button type="button" className="pill" onClick={() => onModerate(thread.status === 'hidden' ? 'unhide' : 'hide')}>
{thread.status === 'hidden' ? 'Unhide' : 'Hide'}
</button>
</>
)}
</div>
</section>
)
}
/**
* 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 (
<article style={{ marginBottom: 16 }}>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 2px' }}>
{post.author}
{post.authorDeleted && ' (account removed)'}
{post.editedAt && ' · edited'}
{post.status === 'hidden' && ' · hidden'}
</p>
{editing ? (
<form onSubmit={save} style={{ display: 'grid', gap: 8 }}>
<textarea
className="textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={6}
required
/>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Save</button>
<button type="button" className="pill" onClick={() => setEditing(false)}>Cancel</button>
</div>
</form>
) : (
<>
{/*
Sanitised on write with the forum's own profile, rendered server-side
under the operator's image policy, and re-sanitised here — the same
@@ -307,25 +489,138 @@ function ThreadView({ thread, canModerate, onBack, onModerate }) {
className="prose"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.body || '', { ADD_ATTR: ['referrerpolicy'] }) }}
/>
</article>
))}
</>
)}
{canModerate && (
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
{thread.pinned ? 'Unpin' : 'Pin'}
</button>
<button type="button" className="pill" onClick={() => onModerate(thread.status === 'hidden' ? 'unhide' : 'hide')}>
{thread.status === 'hidden' ? 'Unhide' : 'Hide'}
</button>
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
{!editing && (
<div style={{ display: 'flex', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
{stillEditable && (
<button
type="button"
className="pill"
onClick={() => { setBody(stripToText(post.body)); setEditing(true) }}
>
Edit
</button>
)}
{/* Reporting your own post is pointless rather than harmful, but
offering it reads as an invitation to misunderstand the control. */}
{!post.mine && (
<ReportControl
slug={slug}
targetType="team_forum_post"
targetId={post.id}
label="Report"
/>
)}
{canModerate && (
<>
<button type="button" className="pill" onClick={() => moderate(post.status === 'hidden' ? 'unhide' : 'hide')}>
{post.status === 'hidden' ? 'Unhide' : 'Hide'}
</button>
<button type="button" className="pill" onClick={() => moderate('delete')}>Delete</button>
</>
)}
</div>
)}
</section>
</article>
)
}
function Composer({ slug, imageMode, onCancel, onPosted }) {
const [title, setTitle] = useState('')
/**
* The report control — the first user-facing report flow this site has ever had.
*
* **It goes to site staff, and it says so.** The gap it closes is that leaders
* moderate their own Team's forum and a Team's leaders are exactly the people who
* will not report their own Team, so telling a member where the report lands is
* not reassurance copy — it is the whole reason the control is worth using in a
* Team whose leadership is the problem.
*
* A report changes nothing about the content, and the confirmation says that too,
* because a member who expects a post to vanish and watches it stay will report
* it again.
*/
function ReportControl({ slug, targetType, targetId, label }) {
const [open, setOpen] = useState(false)
const [reason, setReason] = useState('abuse')
const [detail, setDetail] = useState('')
const [done, setDone] = useState(false)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const submit = async (event) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
await api.teamForumReport(slug, { targetType, targetId, reason, detail: detail || undefined })
setDone(true)
setOpen(false)
} catch (err) {
setError(err.message || 'Could not send that')
} finally {
setBusy(false)
}
}
if (done) {
return (
<span className="sans dim" style={{ fontSize: '0.8rem' }}>
Reported to site staff.
</span>
)
}
if (!open) {
return (
<button type="button" className="pill" onClick={() => setOpen(true)}>
{label}
</button>
)
}
return (
<form
onSubmit={submit}
style={{
display: 'grid', gap: 8, marginTop: 8, padding: 12, width: '100%',
border: '1px solid var(--rule, #ccc)', borderRadius: 6,
}}
>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
This goes to <strong>site staff</strong>, not to this Team&rsquo;s leaders. Reporting does not
hide or change anything it asks a staffer to look.
</p>
<label className="sans" style={{ fontSize: '0.85rem' }}>
Reason
{' '}
<select className="input" value={reason} onChange={(e) => setReason(e.target.value)}>
{REPORT_REASONS.map(([value, text]) => (
<option key={value} value={value}>{text}</option>
))}
</select>
</label>
<textarea
className="textarea"
value={detail}
onChange={(e) => setDetail(e.target.value)}
placeholder="Anything a staffer should know (optional)"
maxLength={500}
rows={3}
/>
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Send report</button>
<button type="button" className="pill" onClick={() => setOpen(false)}>Cancel</button>
</div>
</form>
)
}
/** A reply to an open discussion thread. */
function ReplyBox({ slug, threadId, imageMode, onCancel, onPosted }) {
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
@@ -335,7 +630,7 @@ function Composer({ slug, imageMode, onCancel, onPosted }) {
setBusy(true)
setError(null)
try {
await api.teamForumPost(slug, { type: 'announcement', title, body })
await api.teamForumReply(slug, threadId, { body })
await onPosted()
} catch (err) {
setError(err.message || 'Could not post that')
@@ -344,18 +639,78 @@ function Composer({ slug, imageMode, onCancel, onPosted }) {
}
}
return (
<form onSubmit={submit} style={{ display: 'grid', gap: 8, marginTop: 10 }}>
<textarea
className="textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write a reply. Paste an image URL on its own line to share a picture."
rows={5}
required
/>
{imageMode === 'uploads' && (
<ImageAttacher slug={slug} onAttached={(url) => setBody((c) => `${c}${c ? '\n\n' : ''}${url}`)} onError={setError} />
)}
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Post reply</button>
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
</div>
</form>
)
}
/**
* The upload control, shared by both composers.
*
* The URL goes into the BODY as text, never as an `<img>` tag. The author never
* writes markup here — core decides at render time whether a URL becomes a
* picture, which is what makes the operator's image policy enforceable rather
* than decorative.
*/
function ImageAttacher({ slug, onAttached, onError }) {
const attach = async (event) => {
const file = event.target.files?.[0]
if (!file) return
try {
const { url } = await api.teamForumUpload(slug, file)
// The URL goes into the BODY as text, not as an <img> tag. The author never
// writes markup here — core decides at render time whether a URL becomes a
// picture, which is what makes the operator's image policy enforceable
// rather than decorative.
setBody((current) => `${current}${current ? '\n\n' : ''}${url}`)
onAttached(url)
} catch (err) {
setError(err.message || 'Could not upload that')
onError(err.message || 'Could not upload that')
}
}
return (
<label className="sans dim" style={{ fontSize: '0.85rem' }}>
Attach an image: <input type="file" accept="image/*" onChange={attach} />
</label>
)
}
function Composer({ slug, type, imageMode, onCancel, onPosted }) {
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const isAnnouncement = type === 'announcement'
const submit = async (event) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
// `type` is always sent explicitly. The server defaults an absent one to
// `announcement` so that a phase-4 client keeps meaning what it meant, and
// relying on that default here would make a discussion depend on a
// compatibility shim.
await api.teamForumPost(slug, { type, title, body })
await onPosted()
} catch (err) {
setError(err.message || 'Could not post that')
} finally {
setBusy(false)
}
}
@@ -373,18 +728,25 @@ function Composer({ slug, imageMode, onCancel, onPosted }) {
className="textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write your announcement. Paste an image URL on its own line to share a picture."
placeholder={isAnnouncement
? 'Write your announcement. Paste an image URL on its own line to share a picture.'
: 'Start the discussion. Paste an image URL on its own line to share a picture.'}
rows={6}
required
/>
{isAnnouncement && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
Announcements cannot be replied to.
</p>
)}
{imageMode === 'uploads' && (
<label className="sans dim" style={{ fontSize: '0.85rem' }}>
Attach an image: <input type="file" accept="image/*" onChange={attach} />
</label>
<ImageAttacher slug={slug} onAttached={(url) => setBody((c) => `${c}${c ? '\n\n' : ''}${url}`)} onError={setError} />
)}
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Post</button>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
{isAnnouncement ? 'Post announcement' : 'Start discussion'}
</button>
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
</div>
</form>