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>
86 lines
3.5 KiB
JavaScript
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
|
|
// "<script>" become a real tag the next pass then removes, which is a
|
|
// different string from the one the author wrote.
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/ /g, ' ')
|
|
// `&` last of all, or "&lt;" would decode two steps into "<".
|
|
.replace(/&/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(' · ')
|
|
}
|