// 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 * `` 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(/]*>/gi, '') .replace(/<\/p>\s*]*>/gi, '\n\n') .replace(//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(' · ') }