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>
755 lines
27 KiB
JavaScript
755 lines
27 KiB
JavaScript
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 (
|
|
<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)
|
|
openThread(null)
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<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 }}>
|
|
Forum
|
|
</h2>
|
|
{!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(null)}
|
|
onPosted={async () => {
|
|
setComposing(null)
|
|
await loadThreads(team.slug)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{forum.threads.length === 0 && !composing && (
|
|
<p className="sans dim" style={{ fontSize: '0.9rem', marginTop: 8 }}>
|
|
Nothing has been posted here yet.
|
|
</p>
|
|
)}
|
|
|
|
{forum.canModerate && <GuestManager slug={team.slug} />}
|
|
|
|
<ul style={{ listStyle: 'none', padding: 0, margin: '12px 0 0', display: 'grid', gap: 8 }}>
|
|
{forum.threads.map((t) => (
|
|
<li key={t.id}>
|
|
<button
|
|
type="button"
|
|
className="sans"
|
|
onClick={() => openThread(t.id)}
|
|
style={{
|
|
background: 'none', border: 0, padding: 0, cursor: 'pointer',
|
|
textAlign: 'left', color: 'var(--ink)', font: 'inherit',
|
|
}}
|
|
>
|
|
{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' }}>
|
|
{threadSummary(t)}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<button type="button" className="pill" onClick={() => setOpen(true)} style={{ marginTop: 10 }}>
|
|
Forum guests
|
|
</button>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<section style={{ marginTop: 12, padding: 12, border: '1px solid var(--rule, #ccc)', borderRadius: 6 }}>
|
|
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
|
<h3 className="sans" style={{ margin: 0, fontSize: '0.95rem' }}>Forum guests</h3>
|
|
<button type="button" className="pill" onClick={() => setOpen(false)}>Close</button>
|
|
</header>
|
|
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '6px 0 10px' }}>
|
|
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.` : ''}
|
|
</p>
|
|
|
|
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 10px', display: 'grid', gap: 6 }}>
|
|
{(data?.guests || []).map((g) => (
|
|
<li key={g.userId} className="sans" style={{ fontSize: '0.88rem', display: 'flex', gap: 8 }}>
|
|
<span>{g.username}</span>
|
|
<button type="button" className="pill" onClick={() => revoke(g.userId)}>Remove</button>
|
|
</li>
|
|
))}
|
|
{data && data.guests.length === 0 && (
|
|
<li className="sans dim" style={{ fontSize: '0.85rem' }}>No guests yet.</li>
|
|
)}
|
|
</ul>
|
|
|
|
<form onSubmit={add} style={{ display: 'flex', gap: 8 }}>
|
|
<input
|
|
className="input"
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
placeholder="Account name"
|
|
maxLength={32}
|
|
required
|
|
/>
|
|
<button type="submit" className="btn btn-primary btn-sq">Add</button>
|
|
</form>
|
|
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
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 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) => (
|
|
<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
|
|
defence-in-depth every other body-HTML surface on this site applies
|
|
(FiveOnFriday, NewsletterIssue, the rich-text block).
|
|
|
|
`ADD_ATTR: ['referrerpolicy']` is load-bearing and not a preference.
|
|
DOMPurify's default allowlist carries `loading` but NOT
|
|
`referrerpolicy`, so a plain sanitize() call silently strips the one
|
|
attribute that limits what a remote embed leaks to the host serving it
|
|
— the privacy property the admin help text promises an operator. The
|
|
<img> itself is core's own output with a fixed attribute set, so
|
|
nothing here is widening what an author can write.
|
|
*/}
|
|
{/* eslint-disable-next-line react/no-danger */}
|
|
<div
|
|
className="prose"
|
|
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.body || '', { ADD_ATTR: ['referrerpolicy'] }) }}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{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>
|
|
)}
|
|
</article>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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’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)
|
|
|
|
const submit = async (event) => {
|
|
event.preventDefault()
|
|
setBusy(true)
|
|
setError(null)
|
|
try {
|
|
await api.teamForumReply(slug, threadId, { body })
|
|
await onPosted()
|
|
} catch (err) {
|
|
setError(err.message || 'Could not post that')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
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)
|
|
onAttached(url)
|
|
} catch (err) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={submit} style={{ display: 'grid', gap: 8, marginTop: 12 }}>
|
|
<input
|
|
className="input"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder="Title"
|
|
maxLength={200}
|
|
required
|
|
/>
|
|
<textarea
|
|
className="textarea"
|
|
value={body}
|
|
onChange={(e) => setBody(e.target.value)}
|
|
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' && (
|
|
<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}>
|
|
{isAnnouncement ? 'Post announcement' : 'Start discussion'}
|
|
</button>
|
|
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
|
|
</div>
|
|
</form>
|
|
)
|
|
}
|