diff --git a/client/src/api/client.js b/client/src/api/client.js index d35a67e..447803d 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -152,6 +152,26 @@ export const api = { if (opts.offset != null) qs.set('offset', String(opts.offset)) return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`) }, + // The Team FORUM, under /player because a participant may be a plain player and + // a leader is a player (TEAMS.md §2.11). Core's, for the same reason the feed is + // core's: only core resolves whether this viewer is inside the Team, and the + // member/guest split is a security boundary. The module renders the PLACE. + teamForumThreads: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`), + teamForumThread: (slug, id) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}`), + teamForumPost: (slug, body) => + req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }), + teamForumModerate: (slug, id, body) => + req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', body }), + teamForumUpload: (slug, file) => { + const fd = new FormData() + fd.append('image', file) + return req(`/player/teams/${encodeURIComponent(slug)}/forum/uploads`, { method: 'POST', body: fd, raw: true }) + }, + teamGrantList: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/grants`), + teamGrantAdd: (slug, body) => + req(`/player/teams/${encodeURIComponent(slug)}/grants`, { method: 'POST', body }), + teamGrantRevoke: (slug, userId) => + req(`/player/teams/${encodeURIComponent(slug)}/grants/${userId}`, { method: 'DELETE' }), wikiTags: () => req('/public/wiki/tags'), wikiPage: (slug) => req(`/public/wiki/${slug}`), // CMS pages (block-based). Published-only for the public; a draft-preview link @@ -284,6 +304,13 @@ export const api = { req(`/admin/teams/${id}/leader-override`, { method: 'POST', body }), clearTeamLeaderOverride: (id, memberKey) => req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }), + teamForumSettings: () => req('/admin/teams/forum/settings'), + teamForumUploads: (opts = {}) => { + const qs = new URLSearchParams() + if (opts.deleted) qs.set('deleted', '1') + return req(`/admin/teams/forum/uploads${withQs(qs.toString())}`) + }, + teamForumModeration: (id) => req(`/admin/teams/${id}/forum/moderation`), teamReviewQueue: () => req('/admin/teams/review'), teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`), decideTeamRequest: (id, status, note) => diff --git a/client/src/main.jsx b/client/src/main.jsx index 258fbbd..0d26693 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -5,6 +5,7 @@ import App from './App.jsx' import { publishSharedDependencies } from './modules/shared.js' import { declareSlot, applyCoreFills, fillModuleSlot } from './modules/registry.js' import TeamActivityFeed from './modules/TeamActivityFeed.jsx' +import TeamForumPanel from './modules/TeamForumPanel.jsx' import './styles/theme.css' // Publish window.__rg BEFORE rendering and before any module chunk evaluates. @@ -75,6 +76,14 @@ declareSlot('player.invite.accepted') // unfilled slot rendering nothing. fillModuleSlot('uo.guild.detail', TeamActivityFeed) +// The forum is core's for the same reason and goes in a SECOND place the module +// declares, rather than joining the feed in the first: a slot takes one component +// (first fill wins), and stacking two unrelated panels into one fill would make +// the module unable to place them separately on its own page. It also keeps the +// two independent — a deployment with the forum switched off renders the feed +// exactly as before. +fillModuleSlot('uo.guild.forum', TeamForumPanel) + // Render on DOMContentLoaded rather than immediately, and that is the one line // of core's boot the module system changes. // diff --git a/client/src/modules/TeamForumPanel.jsx b/client/src/modules/TeamForumPanel.jsx new file mode 100644 index 0000000..d961654 --- /dev/null +++ b/client/src/modules/TeamForumPanel.jsx @@ -0,0 +1,382 @@ +import { useCallback, useEffect, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import { api } from '../api/client.js' +import { useAuth } from '../contexts/AuthContext.jsx' +import { useSite } from '../contexts/SiteContext.jsx' + +// 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. +// +// 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(false) + + 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 }) + } + }, []) + + 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 ( + openThread(null)} + onModerate={async (action) => { + await api.teamForumModerate(team.slug, thread.id, { action }) + await loadThreads(team.slug) + openThread(null) + }} + /> + ) + } + + return ( +
+
+

+ Announcements +

+ {forum.canPost && !composing && ( + + )} +
+ + {composing && ( + setComposing(false)} + onPosted={async () => { + setComposing(false) + await loadThreads(team.slug) + }} + /> + )} + + {forum.threads.length === 0 && !composing && ( +

+ Nothing has been announced here yet. +

+ )} + + {forum.canModerate && } + +
    + {forum.threads.map((t) => ( +
  • + +
  • + ))} +
+
+ ) +} + +/** + * 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 ( + + ) + } + + return ( +
+
+

Forum guests

+ +
+

+ 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.` : ''} +

+ + + +
+ setUsername(e.target.value)} + placeholder="Account name" + maxLength={32} + required + /> + +
+ {error &&

{error}

} +
+ ) +} + +function ThreadView({ thread, canModerate, onBack, onModerate }) { + return ( +
+ +

+ {thread.title} +

+

+ {thread.author} + {thread.authorDeleted && ' (account removed)'} +

+ + {thread.posts.map((post) => ( +
+ {/* + Rendered server-side under the operator's image policy, which is why + this is dangerouslySetInnerHTML and not a sanitizer call here. The body + was sanitised on write with the forum's own profile — one in which + `img` is never allowed — and any in it was emitted by core's own + renderer with a fixed attribute set. A client-side sanitiser would have + to strip exactly the tag core just decided to add. + */} + {/* eslint-disable-next-line react/no-danger */} +
+
+ ))} + + {canModerate && ( +
+ + +
+ )} +
+ ) +} + +function Composer({ slug, imageMode, onCancel, onPosted }) { + const [title, setTitle] = useState('') + 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.teamForumPost(slug, { type: 'announcement', title, body }) + await onPosted() + } catch (err) { + setError(err.message || 'Could not post that') + } finally { + setBusy(false) + } + } + + 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 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}`) + } catch (err) { + setError(err.message || 'Could not upload that') + } + } + + return ( +
+ setTitle(e.target.value)} + placeholder="Title" + maxLength={200} + required + /> +