Files
website/client/src/lib/teamForum.js
wtclaude 3f7e61af1c 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>
2026-08-18 13:08:59 -05:00

86 lines
3.5 KiB
JavaScript

// The Team forum's client-side judgements — the few there are (TEAMS.md Part 5).
//
// This file is small on purpose. **Almost nothing about the forum is the
// client's to decide**: who may post, who may moderate, whether an image
// renders, and whether a post may be edited are all answered by the server and
// read from the payload. What is left here is the handful of pure functions that
// turn those answers into what a reader sees, and they are extracted so they can
// be tested without a browser.
//
// The one that deserves a second look is `editOfferOpen`. It can only ever take
// an offer AWAY — the server grants the edit and re-derives the window from
// `created_at` when the write arrives. A client that granted one would be
// deciding a time-bounded permission against the clock of the party it bounds.
export const REPORT_REASONS = [
['abuse', 'Abusive or harassing'],
['spam', 'Spam'],
['sexual', 'Sexual content'],
['illegal', 'Illegal content'],
['impersonation', 'Impersonation'],
['other', 'Something else'],
]
/**
* Should the Edit control still be offered for this post?
*
* Three states, and the middle one is the reason this exists:
* • the server said no → no offer, and nothing here can create one
* • the server said yes, no deadline (staff) → offer
* • the server said yes with a deadline that has since passed while the page
* sat open → withdraw the offer, rather than leave a button that fails
*/
export function editOfferOpen(post, now = Date.now()) {
if (!post || !post.canEdit) return false
if (!post.editableUntil) return true
const until = new Date(post.editableUntil).getTime()
return Number.isFinite(until) && until > now
}
/**
* Turn a rendered body back into something an author can edit.
*
* The server stores sanitised HTML and generates images at READ time from the
* URLs an author wrote (§5.5.3), so what comes back is not what was typed. The
* `<img>` has to go — it is core's output, not the author's input, and leaving it
* in would let an author "edit" markup they never wrote and cannot control.
* The URL survives as the link text beside it, which is what re-renders.
*/
export function stripToText(html) {
return String(html || '')
.replace(/<img[^>]*>/gi, '')
.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]*>/g, '')
// Entities last: unescaping before tag-stripping would let an escaped
// "&lt;script&gt;" become a real tag the next pass then removes, which is a
// different string from the one the author wrote.
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
// `&amp;` last of all, or "&amp;lt;" would decode two steps into "<".
.replace(/&amp;/g, '&')
.trim()
}
/**
* The one-line summary under a thread's title in the list.
*
* `postCount` counts every post including the opening one, so a discussion's
* REPLY count is one less — and an announcement has no replies to count at all,
* which is why the count is omitted rather than shown as zero.
*/
export function threadSummary(thread) {
const parts = []
if (thread.type === 'announcement') parts.push('Announcement')
parts.push(thread.author)
if (thread.type === 'discussion' && thread.postCount > 1) {
const replies = thread.postCount - 1
parts.push(`${replies} ${replies === 1 ? 'reply' : 'replies'}`)
}
if (thread.status === 'hidden') parts.push('hidden')
return parts.join(' · ')
}