openvibers/openvibe.community Commits
The Forum: Community's boards are called the Forum and forums, not Spaces (openvibe.space is a different product); /forum answers 301 to /s; /s addresses, the API and the database keep their names (#94)
Changes
@@@ -130,7 +130,7 @@ Pages (server-rendered HTML):| `GET /p/:slug/download` | The text as an attachment (`slug.ext`); screenshots bounce to the image || `GET /new`, `POST /new` | Create (signed-in or anonymous). `?fork=slug` prefills. The POST is the no-JS fallback; with JS the form talks to `/api/pastes` || `GET /my` | The signed-in user's pastes (public, unlisted and private); anonymous → sign-in |--| `GET /s` | Spaces |++| `GET /s` | The Forum (board index); `GET /forum[/…]` answers 301 → `/s[/…]` || `GET /s/:space` | Threads — `?sort=hot\|new\|top`, `?page=` || `GET /s/:space/t/:slug` | A thread with its posts (`?page=`), reply / vote / moderation forms || `GET /s/:space/new`, `POST /s/:space/new` | Start a thread (signed in) |@@@ -313,7 +313,10 @@ await api(`/${comments[0].id}/votes`, { method: 'POST', body: JSON.stringify({ vRender `message` as text (it is plain text, never HTML).--## Forum: spaces, threads, posts++## Forum: forums, threads, posts++++People see **the Forum** and **forums** (renamed from "Spaces" on 2026-10-17: openvibe.space is a different product).++In the code, the database and the API a forum is still a `space` (`spaces`, `/api/v1/spaces`, `community.space.*`).Spaces hold threads; a thread is its opening post plus replies (Markdown). Seeded spaces:`general`, `feedback` (feature requests), `showcase`. Space visibility: `public` (anyone@@@ -31,11 +31,11 @@"features": ["pastes: code, text and screenshots, with versions, likes, comments, forks and burn-after-read","typed comment threads embedded by other products",-- "forum: spaces, threads, posts, categories and votes (returned from OpenVibe.Space 2026-10-08, owner decision)",-- "per-space moderators (migration 0005)",++ "the Forum: forums (spaces in the API), threads, posts, categories and votes (returned from OpenVibe.Space 2026-10-08, owner decision; called the Forum since 2026-10-17)",++ "per-forum moderators (migration 0005)","resource index for OpenVibe.Services (community.resource.read: pastes and spaces, person-owned)",-- "members-only spaces and threads gated by OpenVibe.VIP",-- "a space's chat room on OpenVibe.Chat",++ "members-only forums and threads gated by OpenVibe.VIP",++ "a forum's chat room on OpenVibe.Chat","Pulse: the network's public activity from Events","Discord relay (off in production)","server-rendered pages with full SEO",@@@ -20,9 +20,9 @@{ "key": "community-attachments", "category": "features", "status": "done", "title": "Images on Community posts","summary": "Attach up to four images to a thread or a reply, with or without JavaScript. They are stored in OpenVibe.Media like every other upload, with location and camera details removed first." },{ "key": "feedback-and-roadmap", "category": "features", "status": "done", "title": "Categories, feature requests and this roadmap",-- "summary": "Feedback has categories (ideas, bugs, questions), requests you can vote on, and a status staff keep up to date. This Roadmap space shows what is being built, with a thread for every item." },++ "summary": "Feedback has categories (ideas, bugs, questions), requests you can vote on, and a status staff keep up to date. This Roadmap forum shows what is being built, with a thread for every item." },{ "key": "forum-boards", "category": "features", "status": "done", "title": "Forum boards, ratings and pastes in posts",-- "summary": "Spaces can be classic forum boards, like vBulletin or SMF: a board index, topic tables with replies, views and the last post, and author panels beside every post with quotes. Or they can be feeds ranked by votes. Rate any post the Facepunch way (Agree, Winner, Funny, Informative, Friendly, Sympathy, Dumb, Disgusting, Bad reading, Late), attach pastes and images, and crosspost a thread from a forum to a feed." },++ "summary": "Forums can be classic boards, like vBulletin or SMF: a board index, topic tables with replies, views and the last post, and author panels beside every post with quotes. Or they can be feeds ranked by votes. Rate any post the Facepunch way (Agree, Winner, Funny, Informative, Friendly, Sympathy, Dumb, Disgusting, Bad reading, Late), attach pastes and images, and crosspost a thread from a forum to a feed." },{ "key": "your-data", "category": "features", "status": "done", "title": "See, download and delete your data","summary": "Download everything OpenVibe holds about you in one export, or delete your account: every OpenVibe site takes part." },{ "key": "ai-preferences", "category": "features", "status": "done", "title": "AI that writes the way you ask",@@@ -64,7 +64,7 @@ function createChatRooms({ config, fetchImpl = globalThis.fetch } = {}) {};}if (r.status === 404) fail(404, 'chat_room.not_found', 'No such chat room (or it is private and you are not in it)');-- if (r.status === 403) fail(403, 'chat_room.not_owner', 'Only the chat room\'s owner can attach it to a space');++ if (r.status === 403) fail(403, 'chat_room.not_owner', 'Only the chat room\'s owner can attach it to a forum');if (r.status === 401) fail(401, 'auth.required', 'Sign in again to attach a chat room');if (r.status === 422) fail(400, 'chat_room.invalid', (r.data && r.data.error) || 'Chat refused that attachment');fail(502, 'chat_room.unavailable', 'OpenVibe.Chat could not attach the room right now');@@@ -13,7 +13,7 @@ const { isoDate, clean } = require('./render/jsonld');const SITEMAP_TTL_MS = 60 * 60 * 1000;const ROBOTS_DISALLOW = ['/api/', '/auth/', '/my', '/new', '/s/*/new', '/*?sso='];--const SUMMARY = 'The discussion side of the OpenVibe network: pastes (text and screenshots), spaces with threads, and the Pulse feed of what is happening across the network.';++const SUMMARY = 'The discussion side of the OpenVibe network: pastes (text and screenshots), forums with threads, and the Pulse feed of what is happening across the network.';const CONTENT_LABELS = 'Pastes made by the OpenVibe AI (moments caught from live streams) carry origin "ai" and are labelled as AI-generated; everything else was written by people.';// The forum service (server/forum/service.js), when the app has one: threads join the sitemap,@@@ -27,7 +27,7 @@ function llmsSections() {return [{ title: 'Browse', links: [{ title: 'Pastes', url: `${u}/pastes`, note: 'public pastes, newest first (?type=pastes|images|all, ?sort=views, ?lang=<language>)' },-- { title: 'Spaces', url: `${u}/s`, note: 'open discussion spaces and their threads' },++ { title: 'Forum', url: `${u}/s`, note: 'open discussion forums and their threads' },{ title: 'Pulse', url: `${u}/pulse`, note: 'network activity' },] },{ title: 'Machine-readable', links: [@@@ -64,7 +64,7 @@ function createSpacesApi({ forum, viewers, limits }) {const v = req.viewer;if (v && v.kind === 'service') return serviceCap(MOD)(req, res, next);return forum.canModerate(v, p(req).space)-- .then((ok) => (ok ? next() : contracts.http.sendProblem(res, 403, 'capability.denied', { detail: "Only this space's moderators", ctx: req.ov })))++ .then((ok) => (ok ? next() : contracts.http.sendProblem(res, 403, 'capability.denied', { detail: "Only this forum's moderators", ctx: req.ov }))).catch(next);};// Per-actor limits (server/actor-limits.js): reads take the defaults. The person limits in service.js@@@ -3,7 +3,8 @@/*** Server-rendered forum routes (the pages are in render/forum.js, the rules in service.js):*-- * GET /s spaces++ * GET /s the Forum's board index (a forum is a `space` in code and the API)++ * GET /forum, /forum/<rest> 301 → /s, /s/<rest> (query kept)* GET /s/feed.xml RSS: latest threads in public spaces* GET /s/:space threads (?sort=hot|new|top&page=)* GET /s/:space/feed.xml RSS: latest threads of one public space@@@ -71,6 +72,13 @@ function createForumRoutes({ forum, viewers, config }) {next();}++ // People call it the Forum (spaces are OpenVibe.Space's), so /forum and /forum/<rest> answer too: a 301 to the++ // /s address every link, sitemap entry and Search document already uses.++ router.use('/forum', (req, res, next) => {++ if (req.method !== 'GET' && req.method !== 'HEAD') return next();++ res.redirect(301, `/s${req.url === '/' || req.url.startsWith('/?') ? req.url.slice(1) : req.url}`);++ });++// The board index (vBulletin/SMF style): every space, grouped, with topics, posts and the last post.router.get('/s', withViewer, wrap(async (req, res, next) => {try { html(res, boardPages.boardIndexPage({ ...(await forum.listSpaces(req.viewer)), user: req.user, canModerate: forum.isModerator(req.viewer) })); } catch (err) { failPage(req, res, err, next); }@@@ -80,7 +88,7 @@ function createForumRoutes({ forum, viewers, config }) {router.get('/s/new-space', withViewer, wrap(async (req, res, next) => {try {if (!req.user) return login(res, '/s/new-space');-- if (!forum.isModerator(req.viewer)) throw new ApiError(403, 'capability.denied', 'Only moderators create spaces');++ if (!forum.isModerator(req.viewer)) throw new ApiError(403, 'capability.denied', 'Only moderators create forums');html(res, boardPages.newSpacePage({ groups: await forum.groups() }));} catch (err) { failPage(req, res, err, next); }}));@@@ -110,7 +118,7 @@ function createForumRoutes({ forum, viewers, config }) {const threadFeed = async (res, space) => res.type('application/rss+xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(seo.feedXml({title: space ? `${SITE_NAME} — ${space.name}` : `${SITE_NAME} — latest threads`,link: `${config.baseUrl}${space ? `/s/${space.slug}` : '/s'}`,-- description: space ? (space.description || space.name) : 'New threads in the public spaces of OpenVibe.Community.',++ description: space ? (space.description || space.name) : 'New threads in the public forums of OpenVibe.Community.',language: 'en',selfUrl: `${config.baseUrl}${space ? `/s/${space.slug}` : '/s'}/feed.xml`,items: discovery.threadFeedItems(await forum.recentPublic({ limit: 30, ...(space ? { space: space.slug } : {}) })),@@@ -188,8 +188,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi/** The space, or 404 (staff spaces look missing) / 401 (members-only, signed out). */async function spaceFor(v, slug) {const space = /^[a-z0-9-]{1,40}$/.test(String(slug)) ? await store.getSpace(db, slug) : null;-- if (!space || (space.visibility === 'staff' && !moderator(v))) fail(404, 'space.not_found', 'No such space');-- if (!canRead(v, space)) fail(401, 'auth.required', 'Sign in to read this space');++ if (!space || (space.visibility === 'staff' && !moderator(v))) fail(404, 'space.not_found', 'No such forum');++ if (!canRead(v, space)) fail(401, 'auth.required', 'Sign in to read this forum');return space;}@@@ -427,16 +427,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi/** The space, when the viewer moderates it: 404 (no such space), else 403. */async function moderatedSpace(v, slug, what) {const space = await spaceFor(v, slug);-- if (!await moderates(v, space)) fail(403, 'capability.denied', `Only this space's moderators ${what}`);++ if (!await moderates(v, space)) fail(403, 'capability.denied', `Only this forum's moderators ${what}`);return space;}/** Validated space fields from a settings body (only what was sent). */async function spaceFields(body, space) {const f = {};-- if (body.name !== undefined) { const n = cleanTitle(body.name); if (n.length < 2 || n.length > 60) fail(400, 'space.invalid_name', 'Space names are 2 to 60 characters'); f.name = n; }++ if (body.name !== undefined) { const n = cleanTitle(body.name); if (n.length < 2 || n.length > 60) fail(400, 'space.invalid_name', 'Forum names are 2 to 60 characters'); f.name = n; }if (body.description !== undefined) f.description = body.description == null ? null : cleanTitle(body.description).slice(0, 300) || null;-- if (body.style !== undefined) { if (!STYLES.includes(body.style)) fail(400, 'space.invalid_style', 'style is feed or forum'); f.style = body.style; }++ if (body.style !== undefined) { if (!STYLES.includes(body.style)) fail(400, 'space.invalid_style', 'style is feed or forum (classic)'); f.style = body.style; }for (const k of ['votes', 'reactions']) if (body[k] !== undefined) f[k] = (body[k] === true || body[k] === 1 || body[k] === '1' || body[k] === 'true') ? 1 : 0;if (body.position !== undefined) f.position = Number.isInteger(Number(body.position)) ? Number(body.position) : 0;if (body.kind !== undefined) { if (!['discussion', 'request', 'roadmap'].includes(body.kind)) fail(400, 'space.invalid_kind', 'kind is discussion, request or roadmap'); f.thread_kind = body.kind; }@@@ -447,8 +447,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi}if (body.parent !== undefined) {const p = body.parent ? await store.getSpace(db, String(body.parent)) : null;-- if (body.parent && !p) fail(404, 'space.not_found', 'No such parent space');-- if (p && space && (p.id === space.id || p.parent_id === space.id)) fail(400, 'space.invalid_parent', 'A space cannot sit under itself or its own child');++ if (body.parent && !p) fail(404, 'space.not_found', 'No such parent forum');++ if (p && space && (p.id === space.id || p.parent_id === space.id)) fail(400, 'space.invalid_parent', 'A forum cannot sit under itself or its own child');if (p && p.parent_id) fail(400, 'space.invalid_parent', 'Child boards go one level deep');f.parent_id = p ? p.id : null;}@@@ -537,7 +537,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst groups = (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name, description: g.description || null, spaces: top.filter((sp) => sp.group && sp.group.slug === g.slug) })).filter((g) => g.spaces.length);const other = top.filter((sp) => !sp.group);-- if (other.length) groups.push({ slug: null, name: 'More spaces', description: null, spaces: other });++ if (other.length) groups.push({ slug: null, name: 'More forums', description: null, spaces: other });return { spaces: shaped, groups };},@@@ -560,7 +560,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viasync updateSpaceSettings(v, spaceSlug, body = {}) {const space = await moderatedSpace(v, spaceSlug, 'change it');// Where a space sits on the board index (its group, parent board and position) is the board index's: staff.-- if (!moderator(v) && ['group', 'parent', 'position'].some((k) => body[k] !== undefined)) fail(403, 'capability.denied', 'Only moderators of the board index move a space on it');++ if (!moderator(v) && ['group', 'parent', 'position'].some((k) => body[k] !== undefined)) fail(403, 'capability.denied', 'Only moderators of the board index move a forum on it');const fields = await spaceFields(body, space);const next = await store.updateSpace(db, space.id, fields);const row = (await store.listSpaces(db, ['public', 'members', 'staff'])).find((r) => r.id === next.id) || next;@@@ -571,13 +571,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi/** A new space — moderators. { slug, name, description?, style?, votes?, reactions?, group?, parent?, visibility?, kind? } */async createSpace(v, body = {}) {-- if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators create spaces');++ if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators create forums');const slug = String(body.slug || '').trim().toLowerCase();-- if (!SPACE_SLUG.test(slug) || ['new-space', 'discuss', 'feed', 'new'].includes(slug)) fail(400, 'space.invalid_slug', 'A space slug is 2 to 40 lowercase letters, digits and dashes (not new-space, discuss, feed or new)');++ if (!SPACE_SLUG.test(slug) || ['new-space', 'discuss', 'feed', 'new'].includes(slug)) fail(400, 'space.invalid_slug', 'A forum address is 2 to 40 lowercase letters, digits and dashes (not new-space, discuss, feed or new)');if (await store.getSpace(db, slug)) fail(409, 'space.slug_taken', 'That address is taken');const visibility = ['public', 'members', 'staff'].includes(body.visibility) ? body.visibility : 'public';const fields = await spaceFields({ ...body, name: body.name }, null);-- if (!fields.name) fail(400, 'space.invalid_name', 'Name the space');++ if (!fields.name) fail(400, 'space.invalid_name', 'Name the forum');const style = fields.style || 'feed';await store.createSpace(db, { slug, visibility, created_by: v.subject || v.service || 'staff', style, votes: fields.votes != null ? fields.votes : (style === 'forum' ? 0 : 1),reactions: fields.reactions != null ? fields.reactions : 1, name: fields.name, description: fields.description || null, group_id: fields.group_id || null,@@@ -600,7 +600,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viasync attachChatRoom(v, spaceSlug, body = {}) {const space = await spaceFor(v, spaceSlug);if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');-- if (!await canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff attach a chat room');++ if (!await canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the forum\'s owner or staff attach a chat room');if (!v || v.kind !== 'user' || !v.token) fail(403, 'chat_room.person_only', 'Attach a chat room while signed in with your own account');if (!chatRooms) fail(503, 'chat_room.unavailable', 'Chat rooms cannot be attached right now');const slug = chatRooms.parseRoomRef(body.room);@@@ -623,7 +623,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viasync detachChatRoom(v, spaceSlug) {const space = await spaceFor(v, spaceSlug);if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');-- if (!await canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff detach its chat room');++ if (!await canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the forum\'s owner or staff detach its chat room');const before = await chatRoomRow(space.id);if (!before) return { detached: false, chat: null };await db.prepare('DELETE FROM space_chat_rooms WHERE space_id = ?').run(space.id);@@@ -643,9 +643,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst page = Math.max(parseInt(q.page, 10) || 1, 1);const perPage = Math.min(Math.max(parseInt(q.limit, 10) || THREADS_PER_PAGE, 1), 100);const category = q.category ? await store.getCategory(db, space.id, q.category) : null;-- if (q.category && !category) fail(404, 'category.not_found', 'No such category in this space');++ if (q.category && !category) fail(404, 'category.not_found', 'No such category in this forum');const statuses = STATUSES[space.thread_kind] || [];-- if (q.status && !statuses.includes(q.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'Threads in this space have no status');++ if (q.status && !statuses.includes(q.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'Threads in this forum have no status');const { rows, total } = await store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date(), categoryId: category ? category.id : null, status: q.status || null });const index = await this.listSpaces(v);const self = index.spaces.find((x) => x.slug === space.slug) || {};@@@ -699,7 +699,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viasync react(v, postId, body = {}) {if (!person(v)) fail(401, 'auth.required', 'Sign in to rate posts');const { post, thread, space } = await postFor(v, postId);-- if (space.reactions === 0) fail(403, 'space.reactions_off', 'Ratings are off in this space');++ if (space.reactions === 0) fail(403, 'space.reactions_off', 'Ratings are off in this forum');await requireMembership(v, space, thread);if (thread.locked && !await moderates(v, space)) fail(403, 'thread.locked', 'This thread is locked');if (post.author_subject === v.subject) fail(403, 'reaction.own_post', 'You cannot rate your own post');@@@ -723,7 +723,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viawait requireMembership(v, space, thread);if (thread.members_only_owner || space.members_only_owner) fail(403, 'crosspost.members_only', 'Members-only threads stay where they are');const target = await spaceFor(v, String(body.to || ''));-- if (target.id === space.id) fail(400, 'crosspost.same_space', 'Pick another space');++ if (target.id === space.id) fail(400, 'crosspost.same_space', 'Pick another forum');mayPostIn(v, target);await requireMembership(v, target);const kind = target.thread_kind || 'discussion';@@@ -786,7 +786,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi/** Delete a category — moderators. Its threads stay, without a category. */async deleteCategory(v, spaceSlug, slug) {const space = await moderatedSpace(v, spaceSlug, 'manage categories');-- if (!await store.deleteCategory(db, space.id, slug)) fail(404, 'category.not_found', 'No such category in this space');++ if (!await store.deleteCategory(db, space.id, slug)) fail(404, 'category.not_found', 'No such category in this forum');pingSpace(space);return { ok: true };},@@@ -797,7 +797,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (!(person(v) && thread.author_subject === v.subject) && !await moderates(v, space)) fail(403, 'thread.not_yours', 'Only the author or a moderator changes the category');await requireMembership(v, space, thread);const category = body.category ? await store.getCategory(db, space.id, body.category) : null;-- if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');++ if (body.category && !category) fail(404, 'category.not_found', 'No such category in this forum');const next = await store.setThreadCategory(db, thread.id, category ? category.id : null);const projections = await authors.projectionsFor([next.author_subject]);pingThread(space, next);@@@ -874,7 +874,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst kind = space.thread_kind || 'discussion';if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');const category = body.category ? await store.getCategory(db, space.id, body.category) : null;-- if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');++ if (body.category && !category) fail(404, 'category.not_found', 'No such category in this forum');const title = cleanTitle(body.title);if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);const text = cleanBody(body.body != null ? body.body : body.body_markdown);@@@ -974,7 +974,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (value === null) fail(400, 'vote.invalid', 'value must be 1, -1 or 0');if (!person(v)) fail(401, 'auth.required', 'Sign in to vote');const { space, thread } = await threadFor(v, spaceSlug, threadSlug);-- if (space.votes === 0) fail(403, 'space.votes_off', 'Votes are off in this space');++ if (space.votes === 0) fail(403, 'space.votes_off', 'Votes are off in this forum');await requireMembership(v, space, thread);if (thread.locked) fail(403, 'thread.locked', 'This thread is locked');voteLimiter.check(`s:${v.subject}`);@@@ -79,7 +79,7 @@ function createRelayApi({ relay, db, viewers, inbound = null, limits }) {router.post('/mappings', manage, jsonBody, run(async (req) => {const b = req.body || {};const space = await forumStore.getSpace(db, String(b.space || ''));-- if (!space) fail(400, 'relay.invalid_space', 'Unknown space');++ if (!space) fail(400, 'relay.invalid_space', 'Unknown forum');const ref = String(b.webhook_url_ref || '');if (!relay.ENV_NAME.test(ref)) fail(400, 'relay.invalid_ref', 'webhook_url_ref is the NAME of an environment variable (A-Z, 0-9, _), never the URL');if (!relay.refAllowed(ref)) fail(400, 'relay.ref_not_allowed', 'webhook_url_ref must be an allowed webhook variable (DISCORD_RELAY_WEBHOOK_VARS, else DISCORD_WEBHOOK_*)');@@@ -89,7 +89,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl/** Why this thread (and a post of it) may not leave the site, or null. */function refusal(thread, space, post = null) {if (thread.origin === 'discord' || (post && post.origin === 'discord')) return `loop prevention: ${post && post.origin === 'discord' ? 'post' : 'thread'} came from Discord`;-- if (space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return 'the thread is no longer public (members-only or a restricted space)';++ if (space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return 'the thread is no longer public (members-only or a restricted forum)';return null;}/** Queue-time filter: the same rules, quietly. */@@@ -18,7 +18,7 @@ const f = require('./forum');const STYLE_LABELS = { forum: 'Forum', feed: 'Feed' };const monthYear = (iso) => { const d = iso ? new Date(iso) : null; return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' }) : null; };const crumbs = (items) => `<nav class="crumbs" aria-label="Breadcrumb">${items.map(([label, href], i) => (i === items.length - 1 ? `<span aria-current="page">${esc(label)}</span>` : `<a href="${esc(href)}">${esc(label)}</a>`)).join(' › ')}</nav>`;--const trail = (space) => [['Home', '/'], ['Spaces', '/s'], ...(space.group ? [[space.group.name, `/s#g-${space.group.slug}`]] : []), ...(space.parent ? [[space.parent.name, `/s/${space.parent.slug}`]] : [])];++const trail = (space) => [['Home', '/'], ['Forum', '/s'], ...(space.group ? [[space.group.name, `/s#g-${space.group.slug}`]] : []), ...(space.parent ? [[space.parent.name, `/s/${space.parent.slug}`]] : [])];const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;/** One space as a board-index row: icon, name, description, child boards, topics, posts, last post. */@@@ -41,7 +41,7 @@ function boardRow(sp) {function boardTable(spaces, caption) {return `<table class="board-table"><caption class="sr-only">${esc(caption)}</caption>-- <thead><tr><th scope="col">Space</th><th scope="col" class="num">Topics</th><th scope="col" class="num">Posts</th><th scope="col">Last post</th></tr></thead>++ <thead><tr><th scope="col">Forum</th><th scope="col" class="num">Topics</th><th scope="col" class="num">Posts</th><th scope="col">Last post</th></tr></thead><tbody>${spaces.map(boardRow).join('')}</tbody></table>`;}@@@ -49,24 +49,24 @@ function boardTable(spaces, caption) {function boardIndexPage({ groups = [], user, canModerate = false }) {const body = `<header class="page-head">-- ${crumbs([['Home', '/'], ['Spaces', '/s']])}-- <h1>Spaces</h1>-- <p class="muted">Forums and feeds for the people of OpenVibe. Forum boards read like a classic forum, newest reply on top. Feeds rank posts by votes. Read without an account; sign in to post, rate and vote. See what is happening across the network on <a href="/pulse">Pulse</a>.</p>-- ${canModerate ? '<p><a class="btn btn-sm" href="/s/new-space"><i class="fa-solid fa-plus" aria-hidden="true"></i> New space</a></p>' : ''}++ ${crumbs([['Home', '/'], ['Forum', '/s']])}++ <h1>Forum</h1>++ <p class="muted">Forums for the people of OpenVibe. Classic forums read like a message board, newest reply on top. Feed forums rank posts by votes. Read without an account; sign in to post, rate and vote. See what is happening across the network on <a href="/pulse">Pulse</a>.</p>++ ${canModerate ? '<p><a class="btn btn-sm" href="/s/new-space"><i class="fa-solid fa-plus" aria-hidden="true"></i> New forum</a></p>' : ''}</header>${groups.length ? groups.map((g) => `<section class="board-group" id="g-${esc(g.slug || 'more')}" aria-labelledby="gh-${esc(g.slug || 'more')}"><h2 class="board-group-h" id="gh-${esc(g.slug || 'more')}">${esc(g.name)}</h2>${g.description ? `<p class="muted small board-group-p">${esc(g.description)}</p>` : ''}${boardTable(g.spaces, g.name)}--</section>`).join('') : '<p class="empty">No spaces yet.</p>'}++</section>`).join('') : '<p class="empty">No forums yet.</p>'}${user ? '' : '<p class="muted small">Sign in with your OpenVibe account to post.</p>'}`;return renderPage({-- title: 'Spaces',-- description: 'Forums and feeds on OpenVibe.Community: general talk, help, feedback and feature requests, the roadmap, a showcase of what people make, and off-topic.',++ title: 'Forum',++ description: 'Forums on OpenVibe.Community: general talk, help, feedback and feature requests, the roadmap, a showcase of what people make, and off-topic.',canonicalPath: '/s',active: 'spaces',feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],-- jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }])],++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }])],body,});}@@@ -96,11 +96,11 @@ function topicRow(t, space) {/** Moderators: the space's settings (style, votes, ratings, group, description), no JS. */function settingsForm(space, groups = []) {const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;-- return `<details class="space-settings"><summary><i class="fa-solid fa-gear" aria-hidden="true"></i> Space settings</summary>++ return `<details class="space-settings"><summary><i class="fa-solid fa-gear" aria-hidden="true"></i> Forum settings</summary><form class="paste-form" method="post" action="/s/${esc(space.slug)}/settings"><label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(space.name)}"></label><label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(space.description || '')}"></label>-- <label class="field"><span>Style</span><select name="style">${opt('forum', 'Forum: newest reply first, author panels, quotes', space.style === 'forum')}${opt('feed', 'Feed: ranked by votes, hot/new/top', space.style !== 'forum')}</select></label>++ <label class="field"><span>Style</span><select name="style">${opt('forum', 'Classic: newest reply first, author panels, quotes', space.style === 'forum')}${opt('feed', 'Feed: ranked by votes, hot/new/top', space.style !== 'forum')}</select></label><label class="check"><input type="checkbox" name="votes" value="1"${space.votes ? ' checked' : ''}> Up and down votes</label><label class="check"><input type="checkbox" name="reactions" value="1"${space.reactions ? ' checked' : ''}> Ratings on posts (Agree, Winner, Funny…)</label><label class="field"><span>Group on the board index</span><select name="group">${opt('', 'None', !space.group)}${groups.map((g) => opt(g.slug, g.name, space.group && space.group.slug === g.slug)).join('')}</select></label>@@@ -122,7 +122,7 @@ function moderatorsBox(space, moderators = [], { me = null, error = null } = {})}).join('');return `<details class="space-settings" id="moderators"${error ? ' open' : ''}><summary><i class="fa-solid fa-user-shield" aria-hidden="true"></i> Moderators (${moderators.length})</summary>${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}--${rows ? `<ul class="mod-list">${rows}</ul>` : '<p class="muted small">No one moderates this space yet; discussion staff do.</p>'}++${rows ? `<ul class="mod-list">${rows}</ul>` : '<p class="muted small">No one moderates this forum yet; discussion staff do.</p>'}<form class="paste-form" method="post" action="/s/${slug}/moderators"><label class="field"><span>Add a moderator</span><input name="subject" required maxlength="41" placeholder="@username" autocomplete="off" autocapitalize="none" spellcheck="false"></label><div class="form-actions"><button class="btn btn-primary" type="submit">Add moderator</button></div>@@@ -165,7 +165,7 @@ ${viewer.can_moderate ? settingsForm(space, groups) + moderatorsBox(space, moderrobots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',active: 'spaces',feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],-- jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],body,});}@@@ -255,7 +255,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewefeeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],jsonLd: [...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),-- ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),++ ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),],body,});@@@ -266,22 +266,22 @@ function newSpacePage({ groups = [], error = null, values = {} }) {const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;const body = `<header class="page-head">-- ${crumbs([['Home', '/'], ['Spaces', '/s'], ['New space', '/s/new-space']])}-- <h1>New space</h1>-- <p class="muted">A forum board reads like a classic forum: the newest reply on top, author panels and quotes. A feed ranks posts by votes, like a subreddit. You can change the style later.</p>++ ${crumbs([['Home', '/'], ['Forum', '/s'], ['New forum', '/s/new-space']])}++ <h1>New forum</h1>++ <p class="muted">A classic forum reads like a message board: the newest reply on top, author panels and quotes. A feed forum ranks posts by votes, like a subreddit. You can change the style later.</p></header><form class="paste-form" method="post" action="/s/new-space">${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}<label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(values.name || '')}"></label><label class="field"><span>Address (/s/…)</span><input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,39}" maxlength="40" value="${esc(values.slug || '')}" placeholder="music"></label><label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(values.description || '')}"></label>-- <label class="field"><span>Style</span><select name="style">${opt('forum', 'Forum', values.style !== 'feed')}${opt('feed', 'Feed', values.style === 'feed')}</select></label>++ <label class="field"><span>Style</span><select name="style">${opt('forum', 'Classic', values.style !== 'feed')}${opt('feed', 'Feed', values.style === 'feed')}</select></label><label class="check"><input type="checkbox" name="votes" value="1"${values.votes ? ' checked' : ''}> Up and down votes</label><label class="check"><input type="checkbox" name="reactions" value="1"${values.reactions !== false ? ' checked' : ''}> Ratings on posts</label><label class="field"><span>Group on the board index</span><select name="group">${opt('', 'None', !values.group)}${groups.map((g) => opt(g.slug, g.name, values.group === g.slug)).join('')}</select></label>-- <div class="form-actions"><button class="btn btn-primary" type="submit">Create space</button></div>++ <div class="form-actions"><button class="btn btn-primary" type="submit">Create forum</button></div></form>`;-- return renderPage({ title: 'New space', canonicalPath: '/s/new-space', robots: 'noindex,nofollow', active: 'spaces', footerVariant: 'compact', body });++ return renderPage({ title: 'New forum', canonicalPath: '/s/new-space', robots: 'noindex,nofollow', active: 'spaces', footerVariant: 'compact', body });}/** "Discuss in a space": pick where a paste should be talked about; each space's new-topic form attaches it. */@@@ -289,13 +289,13 @@ function discussPage({ groups = [], paste = '', user }) {const q = paste ? `?paste=${encodeURIComponent(paste)}` : '';const body = `<header class="page-head">-- ${crumbs([['Home', '/'], ['Spaces', '/s'], ['Discuss', '/s/discuss']])}-- <h1>Discuss${paste ? ' this paste' : ''} in a space</h1>-- <p class="muted">Pick a space. Its new-topic form opens with ${paste ? `the paste <a href="/p/${esc(paste)}">${esc(paste)}</a>` : 'your paste'} attached.</p>++ ${crumbs([['Home', '/'], ['Forum', '/s'], ['Discuss', '/s/discuss']])}++ <h1>Discuss${paste ? ' this paste' : ''} in the forum</h1>++ <p class="muted">Pick a forum. Its new-topic form opens with ${paste ? `the paste <a href="/p/${esc(paste)}">${esc(paste)}</a>` : 'your paste'} attached.</p>${user ? '' : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/s/discuss${q}`)}">Sign in with your OpenVibe account</a> to start a topic.</p>`}</header>${groups.map((g) => `<section class="board-group"><h2 class="board-group-h">${esc(g.name)}</h2><ul class="discuss-list">${g.spaces.filter((sp) => sp.thread_kind !== 'roadmap').map((sp) => `<li><a class="btn" href="/s/${esc(sp.slug)}/new${q}">${esc(sp.name)}</a> <span class="muted small">${esc(sp.description || '')}</span></li>`).join('')}</ul></section>`).join('')}`;-- return renderPage({ title: 'Discuss in a space', canonicalPath: '/s/discuss', robots: 'noindex,follow', active: 'spaces', footerVariant: 'compact', body });++ return renderPage({ title: 'Discuss in the forum', canonicalPath: '/s/discuss', robots: 'noindex,follow', active: 'spaces', footerVariant: 'compact', body });}module.exports = { boardIndexPage, forumSpacePage, forumTopicPage, newSpacePage, settingsForm, moderatorsBox, discussPage };@@@ -59,21 +59,21 @@ function spacesPage({ spaces }) {${s.description ? `<p class="muted">${esc(s.description)}</p>` : ''}<p class="small muted">${num(s.thread_count)} ${s.thread_count === 1 ? 'thread' : 'threads'}${s.last_activity_at ? ` · last activity ${timeTag(s.last_activity_at)}` : ''}</p></li>`).join('')}-- </ul>` : '<p class="empty">No spaces yet.</p>';++ </ul>` : '<p class="empty">No forums yet.</p>';const body = `<header class="page-head">-- <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <span aria-current="page">Spaces</span></nav>-- <h1>Spaces</h1>++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <span aria-current="page">Forum</span></nav>++ <h1>Forum</h1><p class="muted">Long-form discussion for the people of OpenVibe. Read without an account; sign in to start a thread or reply. See what is happening across the network on <a href="/pulse">Pulse</a>.</p></header>${list}`;return renderPage({-- title: 'Spaces',-- description: 'Spaces on OpenVibe.Community: general talk, feedback and feature requests, and a showcase of what the people of OpenVibe make.',++ title: 'Forum',++ description: 'Forums on OpenVibe.Community: general talk, feedback and feature requests, and a showcase of what the people of OpenVibe make.',canonicalPath: '/s',active: 'spaces',feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],-- jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }])],++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }])],body,});}@@@ -124,7 +124,7 @@ function spacePage({ space, threads, sort, page, pages, total, user, categoriesconst categoryName = category ? (categories.find((c) => c.slug === category) || {}).name : null;const body = `<header class="page-head">-- <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › <span aria-current="page">${esc(space.name)}</span></nav>++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › <span aria-current="page">${esc(space.name)}</span></nav><h1>${esc(space.name)}${visBadge(space)}</h1>${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}${start}@@@ -144,7 +144,7 @@ ${footer}`;robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',active: 'spaces',feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],-- jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],body,});}@@@ -253,7 +253,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, uconst body = `<article class="thread">-- <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span></nav>++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span></nav><header class="thread-head">${space.votes === false ? '' : voteForm(base, thread, viewer)}<div>@@@ -284,7 +284,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, ufeeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],jsonLd: [...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),-- ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),++ ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),],body,});@@@ -309,7 +309,7 @@ function newThreadPage({ space, user, values = {}, error = null, categories = []</form>` : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/s/${space.slug}/new`)}">Sign in with your OpenVibe account</a> to start a thread. Reading needs no account.</p>`;const body = `<header class="page-head">-- <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">New thread</span></nav>++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">New thread</span></nav><h1>New thread in ${esc(space.name)}</h1></header>${form}`;@@@ -336,7 +336,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason: reason === 'vip_unavailable' || reason === 'entitlement_unknown'? '<p class="muted">We could not confirm your membership just now. Try again in a moment.</p>': '<p class="muted">Your account does not have an active membership for this.</p>';-- const crumbs = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › ${thread ? `<a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span>` : `<span aria-current="page">${esc(space.name)}</span>`}</nav>`;++ const crumbs = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › ${thread ? `<a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span>` : `<span aria-current="page">${esc(space.name)}</span>`}</nav>`;const body = `<header class="page-head">${crumbs}@@@ -344,7 +344,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason${!thread && space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}</header><section class="members-only">-- <p>This ${thread ? 'thread' : 'space'} is for members of ${esc(creator)} on OpenVibe.VIP. Members read and reply; everyone else sees this page.</p>++ <p>This ${thread ? 'thread' : 'forum'} is for members of ${esc(creator)} on OpenVibe.VIP. Members read and reply; everyone else sees this page.</p><p>${vipJoin(mo)}</p>${why}</section>`;@@@ -18,7 +18,7 @@ const { escapeHtml } = require('./highlight');const SITE_NAME = 'OpenVibe.Community';const NETWORK_URL = 'https://openvibe.network';--const DEFAULT_DESCRIPTION = 'The people of OpenVibe — a community-run, open source home for pastes, spaces and threads, and submissions. Free speech within the rules.';++const DEFAULT_DESCRIPTION = 'The people of OpenVibe — a community-run, open source home for pastes, forums and threads, and submissions. Free speech within the rules.';const DEFAULT_OG_IMAGE = `${config.baseUrl}/og-default.png`;// Content-hashed asset URLs so browsers and nginx can cache them for a year and still pick up@@@ -61,7 +61,7 @@ function navbarInit(opts) {apiBase: NETWORK_URL,links: [{ label: 'Pastes', href: '/pastes', active: opts.active === 'pastes' },-- { label: 'Spaces', href: '/s', active: opts.active === 'spaces' },++ { label: 'Forum', href: '/s', active: opts.active === 'spaces' },{ label: 'Pulse', href: '/pulse', active: opts.active === 'pulse' },{ label: 'Search', href: '/search', icon: 'fa-magnifying-glass', active: opts.active === 'search' },{ label: 'New paste', href: '/new', icon: 'fa-plus', active: opts.active === 'new' },@@@ -90,7 +90,7 @@ function footerInit(opts) {heading: 'Community',items: [{ name: 'Pastes', url: '/pastes' },-- { name: 'Spaces', url: '/s' },++ { name: 'Forum', url: '/s' },{ name: 'Pulse', url: '/pulse' },{ name: 'New paste', url: '/new' },{ name: 'My pastes', url: '/my' },@@@ -153,8 +153,8 @@ function homePage({ latest, trending, languages, user }) {items: [{ icon: 'fa-paste', title: 'Pastes', text: 'Code, logs and configs with highlighting for every common language, a raw link and a download.', href: '/new' },{ icon: 'fa-image', title: 'Screenshots', text: 'Drop an image and share it with a link. Location data is stripped before it is stored.', href: '/new' },-- { icon: 'fa-layer-group', title: 'Spaces', text: 'General, Feedback and Showcase: places to talk, share and get help.', href: '/s' },-- { icon: 'fa-comments', title: 'Threads', text: 'Long-form discussion that outlives a chat scrollback. Start one in any open space.', href: '/s/general' },++ { icon: 'fa-layer-group', title: 'Forum', text: 'General, Feedback and Showcase: places to talk, share and get help.', href: '/s' },++ { icon: 'fa-comments', title: 'Threads', text: 'Long-form discussion that outlives a chat scrollback. Start one in any open forum.', href: '/s/general' },{ icon: 'fa-wave-square', title: 'Pulse', text: 'What is happening across the OpenVibe network, in one feed.', href: '/pulse' },{ icon: 'fa-code-branch', title: 'Open source', text: 'Every line that runs this site is public. Read it, file an issue, send a fix.', href: 'https://github.com/OpenVibers/OpenVibe.Community' },],@@@ -175,12 +175,12 @@ const { url } = await res.json(); // "/p/<slug>"` },}) + showcase.cta({title: 'Start a paste',text: 'Paste it, get a link, share it anywhere. Sign in to keep your pastes together under your OpenVibe account, or post without one.',-- actions: [{ label: 'New paste', href: '/new', primary: true, icon: 'fa-plus' }, { label: 'Visit the Spaces', href: '/s' }],++ actions: [{ label: 'New paste', href: '/new', primary: true, icon: 'fa-plus' }, { label: 'Visit the Forum', href: '/s' }],}) + `<section class="section">${frame.shipped({ service: 'community', title: 'Recently shipped on OpenVibe.Community' })}</section>`;return renderPage({title: null,-- description: 'The people of OpenVibe. A community-run, open source home for pastes, spaces and threads. Free speech within the rules.',++ description: 'The people of OpenVibe. A community-run, open source home for pastes, forums and threads. Free speech within the rules.',canonicalPath: '/',active: 'home',jsonLd: [ld.websiteLd()],@@@ -302,7 +302,7 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne${!isShot ? `<a class="btn btn-sm" href="/p/${esc(p.slug)}/raw"><i class="fa-solid fa-file-lines" aria-hidden="true"></i> Raw</a>` : `<a class="btn btn-sm" href="/p/${esc(p.slug)}/screenshot"><i class="fa-solid fa-image" aria-hidden="true"></i> Full image</a>`}<a class="btn btn-sm" href="/p/${esc(p.slug)}/download" download="${esc(p.slug)}.${esc(isShot ? 'png' : ext)}"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</a>${!isShot ? `<a class="btn btn-sm" href="/new?fork=${esc(p.slug)}"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Fork</a>` : ''}-- ${p.visibility !== 'private' && !p.burn_after_read ? `<a class="btn btn-sm" href="/s/discuss?paste=${encodeURIComponent(p.slug)}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in a space</a>` : ''}++ ${p.visibility !== 'private' && !p.burn_after_read ? `<a class="btn btn-sm" href="/s/discuss?paste=${encodeURIComponent(p.slug)}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in the forum</a>` : ''}${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>` : ''}</div>`;@@@ -15,7 +15,21 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(r.headers.get('location'), null, path);}const board = await t.get('/s');-- assert.ok(board.text.includes('Spaces · OpenVibe.Community'), 'the board index is Community\'s');++ assert.ok(board.text.includes('Forum · OpenVibe.Community'), 'the board index is Community\'s');++ });++ await check('the boards are called the Forum (openvibe.space is a different product); /forum answers with a 301 to /s', async () => {++ const board = await t.get('/s');++ assert.ok(board.text.includes('<h1>Forum</h1>') && !/<h1>Spaces<\/h1>|>New space<|No spaces yet/.test(board.text), 'the page says Forum');++ const cfg = JSON.parse(board.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);++ assert.strictEqual(cfg.navbar.links.find((l) => l.href === '/s').label, 'Forum', 'the nav says Forum');++ assert.ok(!cfg.navbar.links.some((l) => /space/i.test(l.label)), 'no nav link says Spaces');++ for (const [from, to] of [['/forum', '/s'], ['/forum/', '/s'], ['/forum?x=1', '/s?x=1'], ['/forum/general?sort=new', '/s/general?sort=new'], ['/forum/general/t/hello', '/s/general/t/hello']]) {++ const r = await t.get(from, { redirect: 'manual' });++ assert.strictEqual(r.status, 301, from);++ assert.strictEqual(r.headers.get('location'), to, from);++ }++ const nope = await t.get('/forums', { redirect: 'manual' });++ assert.notStrictEqual(nope.status, 301, '/forums is not /forum');});await t.close();done();@@@ -28,7 +28,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toStringawait check('/s lists the public spaces with SEO head, breadcrumbs and the threads feed', async () => {const r = await t.get('/s');assert.strictEqual(r.status, 200);-- has(r.text, '<title>Spaces · OpenVibe.Community</title>');++ has(r.text, '<title>Forum · OpenVibe.Community</title>');has(r.text, '<link rel="canonical" href="https://openvibe.community/s">');has(r.text, '<meta name="robots" content="index,follow">');has(r.text, 'href="/s/feed.xml"');
@@ -130,7 +130,7 @@ Pages (server-rendered HTML):
@@ -130,7 +130,7 @@ Pages (server-rendered HTML):
| `GET /p/:slug/download` | The text as an attachment (`slug.ext`); screenshots bounce to the image |
| `GET /p/:slug/download` | The text as an attachment (`slug.ext`); screenshots bounce to the image |
| `GET /new`, `POST /new` | Create (signed-in or anonymous). `?fork=slug` prefills. The POST is the no-JS fallback; with JS the form talks to `/api/pastes` |
| `GET /new`, `POST /new` | Create (signed-in or anonymous). `?fork=slug` prefills. The POST is the no-JS fallback; with JS the form talks to `/api/pastes` |
| `GET /my` | The signed-in user's pastes (public, unlisted and private); anonymous → sign-in |
| `GET /my` | The signed-in user's pastes (public, unlisted and private); anonymous → sign-in |
-| `GET /s` | Spaces |
+| `GET /s` | The Forum (board index); `GET /forum[/…]` answers 301 → `/s[/…]` |
| `GET /s/:space` | Threads — `?sort=hot\|new\|top`, `?page=` |
| `GET /s/:space` | Threads — `?sort=hot\|new\|top`, `?page=` |
| `GET /s/:space/t/:slug` | A thread with its posts (`?page=`), reply / vote / moderation forms |
| `GET /s/:space/t/:slug` | A thread with its posts (`?page=`), reply / vote / moderation forms |
| `GET /s/:space/new`, `POST /s/:space/new` | Start a thread (signed in) |
| `GET /s/:space/new`, `POST /s/:space/new` | Start a thread (signed in) |
@@ -313,7 +313,10 @@ await api(`/${comments[0].id}/votes`, { method: 'POST', body: JSON.stringify({ v
@@ -313,7 +313,10 @@ await api(`/${comments[0].id}/votes`, { method: 'POST', body: JSON.stringify({ v
Render `message` as text (it is plain text, never HTML).
Render `message` as text (it is plain text, never HTML).
-## Forum: spaces, threads, posts
+## Forum: forums, threads, posts
+
+People see **the Forum** and **forums** (renamed from "Spaces" on 2026-10-17: openvibe.space is a different product).
+In the code, the database and the API a forum is still a `space` (`spaces`, `/api/v1/spaces`, `community.space.*`).
Spaces hold threads; a thread is its opening post plus replies (Markdown). Seeded spaces:
Spaces hold threads; a thread is its opening post plus replies (Markdown). Seeded spaces:
`general`, `feedback` (feature requests), `showcase`. Space visibility: `public` (anyone
`general`, `feedback` (feature requests), `showcase`. Space visibility: `public` (anyone
@@ -31,11 +31,11 @@
@@ -31,11 +31,11 @@
"features": [
"features": [
"pastes: code, text and screenshots, with versions, likes, comments, forks and burn-after-read",
"pastes: code, text and screenshots, with versions, likes, comments, forks and burn-after-read",
"typed comment threads embedded by other products",
"typed comment threads embedded by other products",
- "forum: spaces, threads, posts, categories and votes (returned from OpenVibe.Space 2026-10-08, owner decision)",
- "per-space moderators (migration 0005)",
+ "the Forum: forums (spaces in the API), threads, posts, categories and votes (returned from OpenVibe.Space 2026-10-08, owner decision; called the Forum since 2026-10-17)",
+ "per-forum moderators (migration 0005)",
"resource index for OpenVibe.Services (community.resource.read: pastes and spaces, person-owned)",
"resource index for OpenVibe.Services (community.resource.read: pastes and spaces, person-owned)",
- "members-only spaces and threads gated by OpenVibe.VIP",
- "a space's chat room on OpenVibe.Chat",
+ "members-only forums and threads gated by OpenVibe.VIP",
+ "a forum's chat room on OpenVibe.Chat",
"Pulse: the network's public activity from Events",
"Pulse: the network's public activity from Events",
"Discord relay (off in production)",
"Discord relay (off in production)",
"server-rendered pages with full SEO",
"server-rendered pages with full SEO",
@@ -20,9 +20,9 @@
@@ -20,9 +20,9 @@
{ "key": "community-attachments", "category": "features", "status": "done", "title": "Images on Community posts",
{ "key": "community-attachments", "category": "features", "status": "done", "title": "Images on Community posts",
"summary": "Attach up to four images to a thread or a reply, with or without JavaScript. They are stored in OpenVibe.Media like every other upload, with location and camera details removed first." },
"summary": "Attach up to four images to a thread or a reply, with or without JavaScript. They are stored in OpenVibe.Media like every other upload, with location and camera details removed first." },
{ "key": "feedback-and-roadmap", "category": "features", "status": "done", "title": "Categories, feature requests and this roadmap",
{ "key": "feedback-and-roadmap", "category": "features", "status": "done", "title": "Categories, feature requests and this roadmap",
- "summary": "Feedback has categories (ideas, bugs, questions), requests you can vote on, and a status staff keep up to date. This Roadmap space shows what is being built, with a thread for every item." },
+ "summary": "Feedback has categories (ideas, bugs, questions), requests you can vote on, and a status staff keep up to date. This Roadmap forum shows what is being built, with a thread for every item." },
{ "key": "forum-boards", "category": "features", "status": "done", "title": "Forum boards, ratings and pastes in posts",
{ "key": "forum-boards", "category": "features", "status": "done", "title": "Forum boards, ratings and pastes in posts",
- "summary": "Spaces can be classic forum boards, like vBulletin or SMF: a board index, topic tables with replies, views and the last post, and author panels beside every post with quotes. Or they can be feeds ranked by votes. Rate any post the Facepunch way (Agree, Winner, Funny, Informative, Friendly, Sympathy, Dumb, Disgusting, Bad reading, Late), attach pastes and images, and crosspost a thread from a forum to a feed." },
+ "summary": "Forums can be classic boards, like vBulletin or SMF: a board index, topic tables with replies, views and the last post, and author panels beside every post with quotes. Or they can be feeds ranked by votes. Rate any post the Facepunch way (Agree, Winner, Funny, Informative, Friendly, Sympathy, Dumb, Disgusting, Bad reading, Late), attach pastes and images, and crosspost a thread from a forum to a feed." },
{ "key": "your-data", "category": "features", "status": "done", "title": "See, download and delete your data",
{ "key": "your-data", "category": "features", "status": "done", "title": "See, download and delete your data",
"summary": "Download everything OpenVibe holds about you in one export, or delete your account: every OpenVibe site takes part." },
"summary": "Download everything OpenVibe holds about you in one export, or delete your account: every OpenVibe site takes part." },
{ "key": "ai-preferences", "category": "features", "status": "done", "title": "AI that writes the way you ask",
{ "key": "ai-preferences", "category": "features", "status": "done", "title": "AI that writes the way you ask",
@@ -64,7 +64,7 @@ function createChatRooms({ config, fetchImpl = globalThis.fetch } = {}) {
@@ -64,7 +64,7 @@ function createChatRooms({ config, fetchImpl = globalThis.fetch } = {}) {
};
};
}
}
if (r.status === 404) fail(404, 'chat_room.not_found', 'No such chat room (or it is private and you are not in it)');
if (r.status === 404) fail(404, 'chat_room.not_found', 'No such chat room (or it is private and you are not in it)');
- if (r.status === 403) fail(403, 'chat_room.not_owner', 'Only the chat room\'s owner can attach it to a space');
+ if (r.status === 403) fail(403, 'chat_room.not_owner', 'Only the chat room\'s owner can attach it to a forum');
if (r.status === 401) fail(401, 'auth.required', 'Sign in again to attach a chat room');
if (r.status === 401) fail(401, 'auth.required', 'Sign in again to attach a chat room');
if (r.status === 422) fail(400, 'chat_room.invalid', (r.data && r.data.error) || 'Chat refused that attachment');
if (r.status === 422) fail(400, 'chat_room.invalid', (r.data && r.data.error) || 'Chat refused that attachment');
fail(502, 'chat_room.unavailable', 'OpenVibe.Chat could not attach the room right now');
fail(502, 'chat_room.unavailable', 'OpenVibe.Chat could not attach the room right now');
@@ -13,7 +13,7 @@ const { isoDate, clean } = require('./render/jsonld');
@@ -13,7 +13,7 @@ const { isoDate, clean } = require('./render/jsonld');
const SITEMAP_TTL_MS = 60 * 60 * 1000;
const SITEMAP_TTL_MS = 60 * 60 * 1000;
const ROBOTS_DISALLOW = ['/api/', '/auth/', '/my', '/new', '/s/*/new', '/*?sso='];
const ROBOTS_DISALLOW = ['/api/', '/auth/', '/my', '/new', '/s/*/new', '/*?sso='];
-const SUMMARY = 'The discussion side of the OpenVibe network: pastes (text and screenshots), spaces with threads, and the Pulse feed of what is happening across the network.';
+const SUMMARY = 'The discussion side of the OpenVibe network: pastes (text and screenshots), forums with threads, and the Pulse feed of what is happening across the network.';
const CONTENT_LABELS = 'Pastes made by the OpenVibe AI (moments caught from live streams) carry origin "ai" and are labelled as AI-generated; everything else was written by people.';
const CONTENT_LABELS = 'Pastes made by the OpenVibe AI (moments caught from live streams) carry origin "ai" and are labelled as AI-generated; everything else was written by people.';
// The forum service (server/forum/service.js), when the app has one: threads join the sitemap,
// The forum service (server/forum/service.js), when the app has one: threads join the sitemap,
@@ -27,7 +27,7 @@ function llmsSections() {
@@ -27,7 +27,7 @@ function llmsSections() {
return [
return [
{ title: 'Browse', links: [
{ title: 'Browse', links: [
{ title: 'Pastes', url: `${u}/pastes`, note: 'public pastes, newest first (?type=pastes|images|all, ?sort=views, ?lang=<language>)' },
{ title: 'Pastes', url: `${u}/pastes`, note: 'public pastes, newest first (?type=pastes|images|all, ?sort=views, ?lang=<language>)' },
- { title: 'Spaces', url: `${u}/s`, note: 'open discussion spaces and their threads' },
+ { title: 'Forum', url: `${u}/s`, note: 'open discussion forums and their threads' },
{ title: 'Pulse', url: `${u}/pulse`, note: 'network activity' },
{ title: 'Pulse', url: `${u}/pulse`, note: 'network activity' },
] },
] },
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
@@ -64,7 +64,7 @@ function createSpacesApi({ forum, viewers, limits }) {
@@ -64,7 +64,7 @@ function createSpacesApi({ forum, viewers, limits }) {
const v = req.viewer;
const v = req.viewer;
if (v && v.kind === 'service') return serviceCap(MOD)(req, res, next);
if (v && v.kind === 'service') return serviceCap(MOD)(req, res, next);
return forum.canModerate(v, p(req).space)
return forum.canModerate(v, p(req).space)
- .then((ok) => (ok ? next() : contracts.http.sendProblem(res, 403, 'capability.denied', { detail: "Only this space's moderators", ctx: req.ov })))
+ .then((ok) => (ok ? next() : contracts.http.sendProblem(res, 403, 'capability.denied', { detail: "Only this forum's moderators", ctx: req.ov })))
.catch(next);
.catch(next);
};
};
// Per-actor limits (server/actor-limits.js): reads take the defaults. The person limits in service.js
// Per-actor limits (server/actor-limits.js): reads take the defaults. The person limits in service.js
@@ -3,7 +3,8 @@
@@ -3,7 +3,8 @@
/**
/**
* Server-rendered forum routes (the pages are in render/forum.js, the rules in service.js):
* Server-rendered forum routes (the pages are in render/forum.js, the rules in service.js):
*
*
- * GET /s spaces
+ * GET /s the Forum's board index (a forum is a `space` in code and the API)
+ * GET /forum, /forum/<rest> 301 → /s, /s/<rest> (query kept)
* GET /s/feed.xml RSS: latest threads in public spaces
* GET /s/feed.xml RSS: latest threads in public spaces
* GET /s/:space threads (?sort=hot|new|top&page=)
* GET /s/:space threads (?sort=hot|new|top&page=)
* GET /s/:space/feed.xml RSS: latest threads of one public space
* GET /s/:space/feed.xml RSS: latest threads of one public space
@@ -71,6 +72,13 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -71,6 +72,13 @@ function createForumRoutes({ forum, viewers, config }) {
next();
next();
}
}
+ // People call it the Forum (spaces are OpenVibe.Space's), so /forum and /forum/<rest> answer too: a 301 to the
+ // /s address every link, sitemap entry and Search document already uses.
+ router.use('/forum', (req, res, next) => {
+ if (req.method !== 'GET' && req.method !== 'HEAD') return next();
+ res.redirect(301, `/s${req.url === '/' || req.url.startsWith('/?') ? req.url.slice(1) : req.url}`);
+ });
+
// The board index (vBulletin/SMF style): every space, grouped, with topics, posts and the last post.
// The board index (vBulletin/SMF style): every space, grouped, with topics, posts and the last post.
router.get('/s', withViewer, wrap(async (req, res, next) => {
router.get('/s', withViewer, wrap(async (req, res, next) => {
try { html(res, boardPages.boardIndexPage({ ...(await forum.listSpaces(req.viewer)), user: req.user, canModerate: forum.isModerator(req.viewer) })); } catch (err) { failPage(req, res, err, next); }
try { html(res, boardPages.boardIndexPage({ ...(await forum.listSpaces(req.viewer)), user: req.user, canModerate: forum.isModerator(req.viewer) })); } catch (err) { failPage(req, res, err, next); }
@@ -80,7 +88,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -80,7 +88,7 @@ function createForumRoutes({ forum, viewers, config }) {
router.get('/s/new-space', withViewer, wrap(async (req, res, next) => {
router.get('/s/new-space', withViewer, wrap(async (req, res, next) => {
try {
try {
if (!req.user) return login(res, '/s/new-space');
if (!req.user) return login(res, '/s/new-space');
- if (!forum.isModerator(req.viewer)) throw new ApiError(403, 'capability.denied', 'Only moderators create spaces');
+ if (!forum.isModerator(req.viewer)) throw new ApiError(403, 'capability.denied', 'Only moderators create forums');
html(res, boardPages.newSpacePage({ groups: await forum.groups() }));
html(res, boardPages.newSpacePage({ groups: await forum.groups() }));
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
@@ -110,7 +118,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -110,7 +118,7 @@ function createForumRoutes({ forum, viewers, config }) {
const threadFeed = async (res, space) => res.type('application/rss+xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(seo.feedXml({
const threadFeed = async (res, space) => res.type('application/rss+xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(seo.feedXml({
title: space ? `${SITE_NAME} — ${space.name}` : `${SITE_NAME} — latest threads`,
title: space ? `${SITE_NAME} — ${space.name}` : `${SITE_NAME} — latest threads`,
link: `${config.baseUrl}${space ? `/s/${space.slug}` : '/s'}`,
link: `${config.baseUrl}${space ? `/s/${space.slug}` : '/s'}`,
- description: space ? (space.description || space.name) : 'New threads in the public spaces of OpenVibe.Community.',
+ description: space ? (space.description || space.name) : 'New threads in the public forums of OpenVibe.Community.',
language: 'en',
language: 'en',
selfUrl: `${config.baseUrl}${space ? `/s/${space.slug}` : '/s'}/feed.xml`,
selfUrl: `${config.baseUrl}${space ? `/s/${space.slug}` : '/s'}/feed.xml`,
items: discovery.threadFeedItems(await forum.recentPublic({ limit: 30, ...(space ? { space: space.slug } : {}) })),
items: discovery.threadFeedItems(await forum.recentPublic({ limit: 30, ...(space ? { space: space.slug } : {}) })),
@@ -188,8 +188,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -188,8 +188,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
/** The space, or 404 (staff spaces look missing) / 401 (members-only, signed out). */
/** The space, or 404 (staff spaces look missing) / 401 (members-only, signed out). */
async function spaceFor(v, slug) {
async function spaceFor(v, slug) {
const space = /^[a-z0-9-]{1,40}$/.test(String(slug)) ? await store.getSpace(db, slug) : null;
const space = /^[a-z0-9-]{1,40}$/.test(String(slug)) ? await store.getSpace(db, slug) : null;
- if (!space || (space.visibility === 'staff' && !moderator(v))) fail(404, 'space.not_found', 'No such space');
- if (!canRead(v, space)) fail(401, 'auth.required', 'Sign in to read this space');
+ if (!space || (space.visibility === 'staff' && !moderator(v))) fail(404, 'space.not_found', 'No such forum');
+ if (!canRead(v, space)) fail(401, 'auth.required', 'Sign in to read this forum');
return space;
return space;
}
}
@@ -427,16 +427,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -427,16 +427,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
/** The space, when the viewer moderates it: 404 (no such space), else 403. */
/** The space, when the viewer moderates it: 404 (no such space), else 403. */
async function moderatedSpace(v, slug, what) {
async function moderatedSpace(v, slug, what) {
const space = await spaceFor(v, slug);
const space = await spaceFor(v, slug);
- if (!await moderates(v, space)) fail(403, 'capability.denied', `Only this space's moderators ${what}`);
+ if (!await moderates(v, space)) fail(403, 'capability.denied', `Only this forum's moderators ${what}`);
return space;
return space;
}
}
/** Validated space fields from a settings body (only what was sent). */
/** Validated space fields from a settings body (only what was sent). */
async function spaceFields(body, space) {
async function spaceFields(body, space) {
const f = {};
const f = {};
- if (body.name !== undefined) { const n = cleanTitle(body.name); if (n.length < 2 || n.length > 60) fail(400, 'space.invalid_name', 'Space names are 2 to 60 characters'); f.name = n; }
+ if (body.name !== undefined) { const n = cleanTitle(body.name); if (n.length < 2 || n.length > 60) fail(400, 'space.invalid_name', 'Forum names are 2 to 60 characters'); f.name = n; }
if (body.description !== undefined) f.description = body.description == null ? null : cleanTitle(body.description).slice(0, 300) || null;
if (body.description !== undefined) f.description = body.description == null ? null : cleanTitle(body.description).slice(0, 300) || null;
- if (body.style !== undefined) { if (!STYLES.includes(body.style)) fail(400, 'space.invalid_style', 'style is feed or forum'); f.style = body.style; }
+ if (body.style !== undefined) { if (!STYLES.includes(body.style)) fail(400, 'space.invalid_style', 'style is feed or forum (classic)'); f.style = body.style; }
for (const k of ['votes', 'reactions']) if (body[k] !== undefined) f[k] = (body[k] === true || body[k] === 1 || body[k] === '1' || body[k] === 'true') ? 1 : 0;
for (const k of ['votes', 'reactions']) if (body[k] !== undefined) f[k] = (body[k] === true || body[k] === 1 || body[k] === '1' || body[k] === 'true') ? 1 : 0;
if (body.position !== undefined) f.position = Number.isInteger(Number(body.position)) ? Number(body.position) : 0;
if (body.position !== undefined) f.position = Number.isInteger(Number(body.position)) ? Number(body.position) : 0;
if (body.kind !== undefined) { if (!['discussion', 'request', 'roadmap'].includes(body.kind)) fail(400, 'space.invalid_kind', 'kind is discussion, request or roadmap'); f.thread_kind = body.kind; }
if (body.kind !== undefined) { if (!['discussion', 'request', 'roadmap'].includes(body.kind)) fail(400, 'space.invalid_kind', 'kind is discussion, request or roadmap'); f.thread_kind = body.kind; }
@@ -447,8 +447,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -447,8 +447,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
}
}
if (body.parent !== undefined) {
if (body.parent !== undefined) {
const p = body.parent ? await store.getSpace(db, String(body.parent)) : null;
const p = body.parent ? await store.getSpace(db, String(body.parent)) : null;
- if (body.parent && !p) fail(404, 'space.not_found', 'No such parent space');
- if (p && space && (p.id === space.id || p.parent_id === space.id)) fail(400, 'space.invalid_parent', 'A space cannot sit under itself or its own child');
+ if (body.parent && !p) fail(404, 'space.not_found', 'No such parent forum');
+ if (p && space && (p.id === space.id || p.parent_id === space.id)) fail(400, 'space.invalid_parent', 'A forum cannot sit under itself or its own child');
if (p && p.parent_id) fail(400, 'space.invalid_parent', 'Child boards go one level deep');
if (p && p.parent_id) fail(400, 'space.invalid_parent', 'Child boards go one level deep');
f.parent_id = p ? p.id : null;
f.parent_id = p ? p.id : null;
}
}
@@ -537,7 +537,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -537,7 +537,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const groups = (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name, description: g.description || null, spaces: top.filter((sp) => sp.group && sp.group.slug === g.slug) }))
const groups = (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name, description: g.description || null, spaces: top.filter((sp) => sp.group && sp.group.slug === g.slug) }))
.filter((g) => g.spaces.length);
.filter((g) => g.spaces.length);
const other = top.filter((sp) => !sp.group);
const other = top.filter((sp) => !sp.group);
- if (other.length) groups.push({ slug: null, name: 'More spaces', description: null, spaces: other });
+ if (other.length) groups.push({ slug: null, name: 'More forums', description: null, spaces: other });
return { spaces: shaped, groups };
return { spaces: shaped, groups };
},
},
@@ -560,7 +560,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -560,7 +560,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
async updateSpaceSettings(v, spaceSlug, body = {}) {
async updateSpaceSettings(v, spaceSlug, body = {}) {
const space = await moderatedSpace(v, spaceSlug, 'change it');
const space = await moderatedSpace(v, spaceSlug, 'change it');
// Where a space sits on the board index (its group, parent board and position) is the board index's: staff.
// Where a space sits on the board index (its group, parent board and position) is the board index's: staff.
- if (!moderator(v) && ['group', 'parent', 'position'].some((k) => body[k] !== undefined)) fail(403, 'capability.denied', 'Only moderators of the board index move a space on it');
+ if (!moderator(v) && ['group', 'parent', 'position'].some((k) => body[k] !== undefined)) fail(403, 'capability.denied', 'Only moderators of the board index move a forum on it');
const fields = await spaceFields(body, space);
const fields = await spaceFields(body, space);
const next = await store.updateSpace(db, space.id, fields);
const next = await store.updateSpace(db, space.id, fields);
const row = (await store.listSpaces(db, ['public', 'members', 'staff'])).find((r) => r.id === next.id) || next;
const row = (await store.listSpaces(db, ['public', 'members', 'staff'])).find((r) => r.id === next.id) || next;
@@ -571,13 +571,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -571,13 +571,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
/** A new space — moderators. { slug, name, description?, style?, votes?, reactions?, group?, parent?, visibility?, kind? } */
/** A new space — moderators. { slug, name, description?, style?, votes?, reactions?, group?, parent?, visibility?, kind? } */
async createSpace(v, body = {}) {
async createSpace(v, body = {}) {
- if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators create spaces');
+ if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators create forums');
const slug = String(body.slug || '').trim().toLowerCase();
const slug = String(body.slug || '').trim().toLowerCase();
- if (!SPACE_SLUG.test(slug) || ['new-space', 'discuss', 'feed', 'new'].includes(slug)) fail(400, 'space.invalid_slug', 'A space slug is 2 to 40 lowercase letters, digits and dashes (not new-space, discuss, feed or new)');
+ if (!SPACE_SLUG.test(slug) || ['new-space', 'discuss', 'feed', 'new'].includes(slug)) fail(400, 'space.invalid_slug', 'A forum address is 2 to 40 lowercase letters, digits and dashes (not new-space, discuss, feed or new)');
if (await store.getSpace(db, slug)) fail(409, 'space.slug_taken', 'That address is taken');
if (await store.getSpace(db, slug)) fail(409, 'space.slug_taken', 'That address is taken');
const visibility = ['public', 'members', 'staff'].includes(body.visibility) ? body.visibility : 'public';
const visibility = ['public', 'members', 'staff'].includes(body.visibility) ? body.visibility : 'public';
const fields = await spaceFields({ ...body, name: body.name }, null);
const fields = await spaceFields({ ...body, name: body.name }, null);
- if (!fields.name) fail(400, 'space.invalid_name', 'Name the space');
+ if (!fields.name) fail(400, 'space.invalid_name', 'Name the forum');
const style = fields.style || 'feed';
const style = fields.style || 'feed';
await store.createSpace(db, { slug, visibility, created_by: v.subject || v.service || 'staff', style, votes: fields.votes != null ? fields.votes : (style === 'forum' ? 0 : 1),
await store.createSpace(db, { slug, visibility, created_by: v.subject || v.service || 'staff', style, votes: fields.votes != null ? fields.votes : (style === 'forum' ? 0 : 1),
reactions: fields.reactions != null ? fields.reactions : 1, name: fields.name, description: fields.description || null, group_id: fields.group_id || null,
reactions: fields.reactions != null ? fields.reactions : 1, name: fields.name, description: fields.description || null, group_id: fields.group_id || null,
@@ -600,7 +600,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -600,7 +600,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
async attachChatRoom(v, spaceSlug, body = {}) {
async attachChatRoom(v, spaceSlug, body = {}) {
const space = await spaceFor(v, spaceSlug);
const space = await spaceFor(v, spaceSlug);
if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');
if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');
- if (!await canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff attach a chat room');
+ if (!await canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the forum\'s owner or staff attach a chat room');
if (!v || v.kind !== 'user' || !v.token) fail(403, 'chat_room.person_only', 'Attach a chat room while signed in with your own account');
if (!v || v.kind !== 'user' || !v.token) fail(403, 'chat_room.person_only', 'Attach a chat room while signed in with your own account');
if (!chatRooms) fail(503, 'chat_room.unavailable', 'Chat rooms cannot be attached right now');
if (!chatRooms) fail(503, 'chat_room.unavailable', 'Chat rooms cannot be attached right now');
const slug = chatRooms.parseRoomRef(body.room);
const slug = chatRooms.parseRoomRef(body.room);
@@ -623,7 +623,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -623,7 +623,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
async detachChatRoom(v, spaceSlug) {
async detachChatRoom(v, spaceSlug) {
const space = await spaceFor(v, spaceSlug);
const space = await spaceFor(v, spaceSlug);
if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');
if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');
- if (!await canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff detach its chat room');
+ if (!await canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the forum\'s owner or staff detach its chat room');
const before = await chatRoomRow(space.id);
const before = await chatRoomRow(space.id);
if (!before) return { detached: false, chat: null };
if (!before) return { detached: false, chat: null };
await db.prepare('DELETE FROM space_chat_rooms WHERE space_id = ?').run(space.id);
await db.prepare('DELETE FROM space_chat_rooms WHERE space_id = ?').run(space.id);
@@ -643,9 +643,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -643,9 +643,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const page = Math.max(parseInt(q.page, 10) || 1, 1);
const page = Math.max(parseInt(q.page, 10) || 1, 1);
const perPage = Math.min(Math.max(parseInt(q.limit, 10) || THREADS_PER_PAGE, 1), 100);
const perPage = Math.min(Math.max(parseInt(q.limit, 10) || THREADS_PER_PAGE, 1), 100);
const category = q.category ? await store.getCategory(db, space.id, q.category) : null;
const category = q.category ? await store.getCategory(db, space.id, q.category) : null;
- if (q.category && !category) fail(404, 'category.not_found', 'No such category in this space');
+ if (q.category && !category) fail(404, 'category.not_found', 'No such category in this forum');
const statuses = STATUSES[space.thread_kind] || [];
const statuses = STATUSES[space.thread_kind] || [];
- if (q.status && !statuses.includes(q.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'Threads in this space have no status');
+ if (q.status && !statuses.includes(q.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'Threads in this forum have no status');
const { rows, total } = await store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date(), categoryId: category ? category.id : null, status: q.status || null });
const { rows, total } = await store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date(), categoryId: category ? category.id : null, status: q.status || null });
const index = await this.listSpaces(v);
const index = await this.listSpaces(v);
const self = index.spaces.find((x) => x.slug === space.slug) || {};
const self = index.spaces.find((x) => x.slug === space.slug) || {};
@@ -699,7 +699,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -699,7 +699,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
async react(v, postId, body = {}) {
async react(v, postId, body = {}) {
if (!person(v)) fail(401, 'auth.required', 'Sign in to rate posts');
if (!person(v)) fail(401, 'auth.required', 'Sign in to rate posts');
const { post, thread, space } = await postFor(v, postId);
const { post, thread, space } = await postFor(v, postId);
- if (space.reactions === 0) fail(403, 'space.reactions_off', 'Ratings are off in this space');
+ if (space.reactions === 0) fail(403, 'space.reactions_off', 'Ratings are off in this forum');
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
if (thread.locked && !await moderates(v, space)) fail(403, 'thread.locked', 'This thread is locked');
if (thread.locked && !await moderates(v, space)) fail(403, 'thread.locked', 'This thread is locked');
if (post.author_subject === v.subject) fail(403, 'reaction.own_post', 'You cannot rate your own post');
if (post.author_subject === v.subject) fail(403, 'reaction.own_post', 'You cannot rate your own post');
@@ -723,7 +723,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -723,7 +723,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
if (thread.members_only_owner || space.members_only_owner) fail(403, 'crosspost.members_only', 'Members-only threads stay where they are');
if (thread.members_only_owner || space.members_only_owner) fail(403, 'crosspost.members_only', 'Members-only threads stay where they are');
const target = await spaceFor(v, String(body.to || ''));
const target = await spaceFor(v, String(body.to || ''));
- if (target.id === space.id) fail(400, 'crosspost.same_space', 'Pick another space');
+ if (target.id === space.id) fail(400, 'crosspost.same_space', 'Pick another forum');
mayPostIn(v, target);
mayPostIn(v, target);
await requireMembership(v, target);
await requireMembership(v, target);
const kind = target.thread_kind || 'discussion';
const kind = target.thread_kind || 'discussion';
@@ -786,7 +786,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -786,7 +786,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
/** Delete a category — moderators. Its threads stay, without a category. */
/** Delete a category — moderators. Its threads stay, without a category. */
async deleteCategory(v, spaceSlug, slug) {
async deleteCategory(v, spaceSlug, slug) {
const space = await moderatedSpace(v, spaceSlug, 'manage categories');
const space = await moderatedSpace(v, spaceSlug, 'manage categories');
- if (!await store.deleteCategory(db, space.id, slug)) fail(404, 'category.not_found', 'No such category in this space');
+ if (!await store.deleteCategory(db, space.id, slug)) fail(404, 'category.not_found', 'No such category in this forum');
pingSpace(space);
pingSpace(space);
return { ok: true };
return { ok: true };
},
},
@@ -797,7 +797,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -797,7 +797,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (!(person(v) && thread.author_subject === v.subject) && !await moderates(v, space)) fail(403, 'thread.not_yours', 'Only the author or a moderator changes the category');
if (!(person(v) && thread.author_subject === v.subject) && !await moderates(v, space)) fail(403, 'thread.not_yours', 'Only the author or a moderator changes the category');
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
const category = body.category ? await store.getCategory(db, space.id, body.category) : null;
const category = body.category ? await store.getCategory(db, space.id, body.category) : null;
- if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');
+ if (body.category && !category) fail(404, 'category.not_found', 'No such category in this forum');
const next = await store.setThreadCategory(db, thread.id, category ? category.id : null);
const next = await store.setThreadCategory(db, thread.id, category ? category.id : null);
const projections = await authors.projectionsFor([next.author_subject]);
const projections = await authors.projectionsFor([next.author_subject]);
pingThread(space, next);
pingThread(space, next);
@@ -874,7 +874,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -874,7 +874,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const kind = space.thread_kind || 'discussion';
const kind = space.thread_kind || 'discussion';
if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');
if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');
const category = body.category ? await store.getCategory(db, space.id, body.category) : null;
const category = body.category ? await store.getCategory(db, space.id, body.category) : null;
- if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');
+ if (body.category && !category) fail(404, 'category.not_found', 'No such category in this forum');
const title = cleanTitle(body.title);
const title = cleanTitle(body.title);
if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);
if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
@@ -974,7 +974,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -974,7 +974,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (value === null) fail(400, 'vote.invalid', 'value must be 1, -1 or 0');
if (value === null) fail(400, 'vote.invalid', 'value must be 1, -1 or 0');
if (!person(v)) fail(401, 'auth.required', 'Sign in to vote');
if (!person(v)) fail(401, 'auth.required', 'Sign in to vote');
const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
- if (space.votes === 0) fail(403, 'space.votes_off', 'Votes are off in this space');
+ if (space.votes === 0) fail(403, 'space.votes_off', 'Votes are off in this forum');
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
if (thread.locked) fail(403, 'thread.locked', 'This thread is locked');
if (thread.locked) fail(403, 'thread.locked', 'This thread is locked');
voteLimiter.check(`s:${v.subject}`);
voteLimiter.check(`s:${v.subject}`);
@@ -79,7 +79,7 @@ function createRelayApi({ relay, db, viewers, inbound = null, limits }) {
@@ -79,7 +79,7 @@ function createRelayApi({ relay, db, viewers, inbound = null, limits }) {
router.post('/mappings', manage, jsonBody, run(async (req) => {
router.post('/mappings', manage, jsonBody, run(async (req) => {
const b = req.body || {};
const b = req.body || {};
const space = await forumStore.getSpace(db, String(b.space || ''));
const space = await forumStore.getSpace(db, String(b.space || ''));
- if (!space) fail(400, 'relay.invalid_space', 'Unknown space');
+ if (!space) fail(400, 'relay.invalid_space', 'Unknown forum');
const ref = String(b.webhook_url_ref || '');
const ref = String(b.webhook_url_ref || '');
if (!relay.ENV_NAME.test(ref)) fail(400, 'relay.invalid_ref', 'webhook_url_ref is the NAME of an environment variable (A-Z, 0-9, _), never the URL');
if (!relay.ENV_NAME.test(ref)) fail(400, 'relay.invalid_ref', 'webhook_url_ref is the NAME of an environment variable (A-Z, 0-9, _), never the URL');
if (!relay.refAllowed(ref)) fail(400, 'relay.ref_not_allowed', 'webhook_url_ref must be an allowed webhook variable (DISCORD_RELAY_WEBHOOK_VARS, else DISCORD_WEBHOOK_*)');
if (!relay.refAllowed(ref)) fail(400, 'relay.ref_not_allowed', 'webhook_url_ref must be an allowed webhook variable (DISCORD_RELAY_WEBHOOK_VARS, else DISCORD_WEBHOOK_*)');
@@ -89,7 +89,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -89,7 +89,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
/** Why this thread (and a post of it) may not leave the site, or null. */
/** Why this thread (and a post of it) may not leave the site, or null. */
function refusal(thread, space, post = null) {
function refusal(thread, space, post = null) {
if (thread.origin === 'discord' || (post && post.origin === 'discord')) return `loop prevention: ${post && post.origin === 'discord' ? 'post' : 'thread'} came from Discord`;
if (thread.origin === 'discord' || (post && post.origin === 'discord')) return `loop prevention: ${post && post.origin === 'discord' ? 'post' : 'thread'} came from Discord`;
- if (space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return 'the thread is no longer public (members-only or a restricted space)';
+ if (space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return 'the thread is no longer public (members-only or a restricted forum)';
return null;
return null;
}
}
/** Queue-time filter: the same rules, quietly. */
/** Queue-time filter: the same rules, quietly. */
@@ -18,7 +18,7 @@ const f = require('./forum');
@@ -18,7 +18,7 @@ const f = require('./forum');
const STYLE_LABELS = { forum: 'Forum', feed: 'Feed' };
const STYLE_LABELS = { forum: 'Forum', feed: 'Feed' };
const monthYear = (iso) => { const d = iso ? new Date(iso) : null; return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' }) : null; };
const monthYear = (iso) => { const d = iso ? new Date(iso) : null; return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' }) : null; };
const crumbs = (items) => `<nav class="crumbs" aria-label="Breadcrumb">${items.map(([label, href], i) => (i === items.length - 1 ? `<span aria-current="page">${esc(label)}</span>` : `<a href="${esc(href)}">${esc(label)}</a>`)).join(' › ')}</nav>`;
const crumbs = (items) => `<nav class="crumbs" aria-label="Breadcrumb">${items.map(([label, href], i) => (i === items.length - 1 ? `<span aria-current="page">${esc(label)}</span>` : `<a href="${esc(href)}">${esc(label)}</a>`)).join(' › ')}</nav>`;
-const trail = (space) => [['Home', '/'], ['Spaces', '/s'], ...(space.group ? [[space.group.name, `/s#g-${space.group.slug}`]] : []), ...(space.parent ? [[space.parent.name, `/s/${space.parent.slug}`]] : [])];
+const trail = (space) => [['Home', '/'], ['Forum', '/s'], ...(space.group ? [[space.group.name, `/s#g-${space.group.slug}`]] : []), ...(space.parent ? [[space.parent.name, `/s/${space.parent.slug}`]] : [])];
const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;
const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;
/** One space as a board-index row: icon, name, description, child boards, topics, posts, last post. */
/** One space as a board-index row: icon, name, description, child boards, topics, posts, last post. */
@@ -41,7 +41,7 @@ function boardRow(sp) {
@@ -41,7 +41,7 @@ function boardRow(sp) {
function boardTable(spaces, caption) {
function boardTable(spaces, caption) {
return `<table class="board-table"><caption class="sr-only">${esc(caption)}</caption>
return `<table class="board-table"><caption class="sr-only">${esc(caption)}</caption>
- <thead><tr><th scope="col">Space</th><th scope="col" class="num">Topics</th><th scope="col" class="num">Posts</th><th scope="col">Last post</th></tr></thead>
+ <thead><tr><th scope="col">Forum</th><th scope="col" class="num">Topics</th><th scope="col" class="num">Posts</th><th scope="col">Last post</th></tr></thead>
<tbody>${spaces.map(boardRow).join('')}</tbody></table>`;
<tbody>${spaces.map(boardRow).join('')}</tbody></table>`;
}
}
@@ -49,24 +49,24 @@ function boardTable(spaces, caption) {
@@ -49,24 +49,24 @@ function boardTable(spaces, caption) {
function boardIndexPage({ groups = [], user, canModerate = false }) {
function boardIndexPage({ groups = [], user, canModerate = false }) {
const body = `
const body = `
<header class="page-head">
<header class="page-head">
- ${crumbs([['Home', '/'], ['Spaces', '/s']])}
- <h1>Spaces</h1>
- <p class="muted">Forums and feeds for the people of OpenVibe. Forum boards read like a classic forum, newest reply on top. Feeds rank posts by votes. Read without an account; sign in to post, rate and vote. See what is happening across the network on <a href="/pulse">Pulse</a>.</p>
- ${canModerate ? '<p><a class="btn btn-sm" href="/s/new-space"><i class="fa-solid fa-plus" aria-hidden="true"></i> New space</a></p>' : ''}
+ ${crumbs([['Home', '/'], ['Forum', '/s']])}
+ <h1>Forum</h1>
+ <p class="muted">Forums for the people of OpenVibe. Classic forums read like a message board, newest reply on top. Feed forums rank posts by votes. Read without an account; sign in to post, rate and vote. See what is happening across the network on <a href="/pulse">Pulse</a>.</p>
+ ${canModerate ? '<p><a class="btn btn-sm" href="/s/new-space"><i class="fa-solid fa-plus" aria-hidden="true"></i> New forum</a></p>' : ''}
</header>
</header>
${groups.length ? groups.map((g) => `<section class="board-group" id="g-${esc(g.slug || 'more')}" aria-labelledby="gh-${esc(g.slug || 'more')}">
${groups.length ? groups.map((g) => `<section class="board-group" id="g-${esc(g.slug || 'more')}" aria-labelledby="gh-${esc(g.slug || 'more')}">
<h2 class="board-group-h" id="gh-${esc(g.slug || 'more')}">${esc(g.name)}</h2>
<h2 class="board-group-h" id="gh-${esc(g.slug || 'more')}">${esc(g.name)}</h2>
${g.description ? `<p class="muted small board-group-p">${esc(g.description)}</p>` : ''}
${g.description ? `<p class="muted small board-group-p">${esc(g.description)}</p>` : ''}
${boardTable(g.spaces, g.name)}
${boardTable(g.spaces, g.name)}
-</section>`).join('') : '<p class="empty">No spaces yet.</p>'}
+</section>`).join('') : '<p class="empty">No forums yet.</p>'}
${user ? '' : '<p class="muted small">Sign in with your OpenVibe account to post.</p>'}`;
${user ? '' : '<p class="muted small">Sign in with your OpenVibe account to post.</p>'}`;
return renderPage({
return renderPage({
- title: 'Spaces',
- description: 'Forums and feeds on OpenVibe.Community: general talk, help, feedback and feature requests, the roadmap, a showcase of what people make, and off-topic.',
+ title: 'Forum',
+ description: 'Forums on OpenVibe.Community: general talk, help, feedback and feature requests, the roadmap, a showcase of what people make, and off-topic.',
canonicalPath: '/s',
canonicalPath: '/s',
active: 'spaces',
active: 'spaces',
feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],
feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],
- jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }])],
+ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }])],
body,
body,
});
});
}
}
@@ -96,11 +96,11 @@ function topicRow(t, space) {
@@ -96,11 +96,11 @@ function topicRow(t, space) {
/** Moderators: the space's settings (style, votes, ratings, group, description), no JS. */
/** Moderators: the space's settings (style, votes, ratings, group, description), no JS. */
function settingsForm(space, groups = []) {
function settingsForm(space, groups = []) {
const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;
const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;
- return `<details class="space-settings"><summary><i class="fa-solid fa-gear" aria-hidden="true"></i> Space settings</summary>
+ return `<details class="space-settings"><summary><i class="fa-solid fa-gear" aria-hidden="true"></i> Forum settings</summary>
<form class="paste-form" method="post" action="/s/${esc(space.slug)}/settings">
<form class="paste-form" method="post" action="/s/${esc(space.slug)}/settings">
<label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(space.name)}"></label>
<label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(space.name)}"></label>
<label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(space.description || '')}"></label>
<label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(space.description || '')}"></label>
- <label class="field"><span>Style</span><select name="style">${opt('forum', 'Forum: newest reply first, author panels, quotes', space.style === 'forum')}${opt('feed', 'Feed: ranked by votes, hot/new/top', space.style !== 'forum')}</select></label>
+ <label class="field"><span>Style</span><select name="style">${opt('forum', 'Classic: newest reply first, author panels, quotes', space.style === 'forum')}${opt('feed', 'Feed: ranked by votes, hot/new/top', space.style !== 'forum')}</select></label>
<label class="check"><input type="checkbox" name="votes" value="1"${space.votes ? ' checked' : ''}> Up and down votes</label>
<label class="check"><input type="checkbox" name="votes" value="1"${space.votes ? ' checked' : ''}> Up and down votes</label>
<label class="check"><input type="checkbox" name="reactions" value="1"${space.reactions ? ' checked' : ''}> Ratings on posts (Agree, Winner, Funny…)</label>
<label class="check"><input type="checkbox" name="reactions" value="1"${space.reactions ? ' checked' : ''}> Ratings on posts (Agree, Winner, Funny…)</label>
<label class="field"><span>Group on the board index</span><select name="group">${opt('', 'None', !space.group)}${groups.map((g) => opt(g.slug, g.name, space.group && space.group.slug === g.slug)).join('')}</select></label>
<label class="field"><span>Group on the board index</span><select name="group">${opt('', 'None', !space.group)}${groups.map((g) => opt(g.slug, g.name, space.group && space.group.slug === g.slug)).join('')}</select></label>
@@ -122,7 +122,7 @@ function moderatorsBox(space, moderators = [], { me = null, error = null } = {})
@@ -122,7 +122,7 @@ function moderatorsBox(space, moderators = [], { me = null, error = null } = {})
}).join('');
}).join('');
return `<details class="space-settings" id="moderators"${error ? ' open' : ''}><summary><i class="fa-solid fa-user-shield" aria-hidden="true"></i> Moderators (${moderators.length})</summary>
return `<details class="space-settings" id="moderators"${error ? ' open' : ''}><summary><i class="fa-solid fa-user-shield" aria-hidden="true"></i> Moderators (${moderators.length})</summary>
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
-${rows ? `<ul class="mod-list">${rows}</ul>` : '<p class="muted small">No one moderates this space yet; discussion staff do.</p>'}
+${rows ? `<ul class="mod-list">${rows}</ul>` : '<p class="muted small">No one moderates this forum yet; discussion staff do.</p>'}
<form class="paste-form" method="post" action="/s/${slug}/moderators">
<form class="paste-form" method="post" action="/s/${slug}/moderators">
<label class="field"><span>Add a moderator</span><input name="subject" required maxlength="41" placeholder="@username" autocomplete="off" autocapitalize="none" spellcheck="false"></label>
<label class="field"><span>Add a moderator</span><input name="subject" required maxlength="41" placeholder="@username" autocomplete="off" autocapitalize="none" spellcheck="false"></label>
<div class="form-actions"><button class="btn btn-primary" type="submit">Add moderator</button></div>
<div class="form-actions"><button class="btn btn-primary" type="submit">Add moderator</button></div>
@@ -165,7 +165,7 @@ ${viewer.can_moderate ? settingsForm(space, groups) + moderatorsBox(space, moder
@@ -165,7 +165,7 @@ ${viewer.can_moderate ? settingsForm(space, groups) + moderatorsBox(space, moder
robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',
robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',
active: 'spaces',
active: 'spaces',
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
- jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
+ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
body,
body,
});
});
}
}
@@ -255,7 +255,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
@@ -255,7 +255,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
jsonLd: [
jsonLd: [
...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),
...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),
- ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
+ ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
],
],
body,
body,
});
});
@@ -266,22 +266,22 @@ function newSpacePage({ groups = [], error = null, values = {} }) {
@@ -266,22 +266,22 @@ function newSpacePage({ groups = [], error = null, values = {} }) {
const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;
const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;
const body = `
const body = `
<header class="page-head">
<header class="page-head">
- ${crumbs([['Home', '/'], ['Spaces', '/s'], ['New space', '/s/new-space']])}
- <h1>New space</h1>
- <p class="muted">A forum board reads like a classic forum: the newest reply on top, author panels and quotes. A feed ranks posts by votes, like a subreddit. You can change the style later.</p>
+ ${crumbs([['Home', '/'], ['Forum', '/s'], ['New forum', '/s/new-space']])}
+ <h1>New forum</h1>
+ <p class="muted">A classic forum reads like a message board: the newest reply on top, author panels and quotes. A feed forum ranks posts by votes, like a subreddit. You can change the style later.</p>
</header>
</header>
<form class="paste-form" method="post" action="/s/new-space">
<form class="paste-form" method="post" action="/s/new-space">
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
<label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(values.name || '')}"></label>
<label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(values.name || '')}"></label>
<label class="field"><span>Address (/s/…)</span><input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,39}" maxlength="40" value="${esc(values.slug || '')}" placeholder="music"></label>
<label class="field"><span>Address (/s/…)</span><input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,39}" maxlength="40" value="${esc(values.slug || '')}" placeholder="music"></label>
<label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(values.description || '')}"></label>
<label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(values.description || '')}"></label>
- <label class="field"><span>Style</span><select name="style">${opt('forum', 'Forum', values.style !== 'feed')}${opt('feed', 'Feed', values.style === 'feed')}</select></label>
+ <label class="field"><span>Style</span><select name="style">${opt('forum', 'Classic', values.style !== 'feed')}${opt('feed', 'Feed', values.style === 'feed')}</select></label>
<label class="check"><input type="checkbox" name="votes" value="1"${values.votes ? ' checked' : ''}> Up and down votes</label>
<label class="check"><input type="checkbox" name="votes" value="1"${values.votes ? ' checked' : ''}> Up and down votes</label>
<label class="check"><input type="checkbox" name="reactions" value="1"${values.reactions !== false ? ' checked' : ''}> Ratings on posts</label>
<label class="check"><input type="checkbox" name="reactions" value="1"${values.reactions !== false ? ' checked' : ''}> Ratings on posts</label>
<label class="field"><span>Group on the board index</span><select name="group">${opt('', 'None', !values.group)}${groups.map((g) => opt(g.slug, g.name, values.group === g.slug)).join('')}</select></label>
<label class="field"><span>Group on the board index</span><select name="group">${opt('', 'None', !values.group)}${groups.map((g) => opt(g.slug, g.name, values.group === g.slug)).join('')}</select></label>
- <div class="form-actions"><button class="btn btn-primary" type="submit">Create space</button></div>
+ <div class="form-actions"><button class="btn btn-primary" type="submit">Create forum</button></div>
</form>`;
</form>`;
- return renderPage({ title: 'New space', canonicalPath: '/s/new-space', robots: 'noindex,nofollow', active: 'spaces', footerVariant: 'compact', body });
+ return renderPage({ title: 'New forum', canonicalPath: '/s/new-space', robots: 'noindex,nofollow', active: 'spaces', footerVariant: 'compact', body });
}
}
/** "Discuss in a space": pick where a paste should be talked about; each space's new-topic form attaches it. */
/** "Discuss in a space": pick where a paste should be talked about; each space's new-topic form attaches it. */
@@ -289,13 +289,13 @@ function discussPage({ groups = [], paste = '', user }) {
@@ -289,13 +289,13 @@ function discussPage({ groups = [], paste = '', user }) {
const q = paste ? `?paste=${encodeURIComponent(paste)}` : '';
const q = paste ? `?paste=${encodeURIComponent(paste)}` : '';
const body = `
const body = `
<header class="page-head">
<header class="page-head">
- ${crumbs([['Home', '/'], ['Spaces', '/s'], ['Discuss', '/s/discuss']])}
- <h1>Discuss${paste ? ' this paste' : ''} in a space</h1>
- <p class="muted">Pick a space. Its new-topic form opens with ${paste ? `the paste <a href="/p/${esc(paste)}">${esc(paste)}</a>` : 'your paste'} attached.</p>
+ ${crumbs([['Home', '/'], ['Forum', '/s'], ['Discuss', '/s/discuss']])}
+ <h1>Discuss${paste ? ' this paste' : ''} in the forum</h1>
+ <p class="muted">Pick a forum. Its new-topic form opens with ${paste ? `the paste <a href="/p/${esc(paste)}">${esc(paste)}</a>` : 'your paste'} attached.</p>
${user ? '' : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/s/discuss${q}`)}">Sign in with your OpenVibe account</a> to start a topic.</p>`}
${user ? '' : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/s/discuss${q}`)}">Sign in with your OpenVibe account</a> to start a topic.</p>`}
</header>
</header>
${groups.map((g) => `<section class="board-group"><h2 class="board-group-h">${esc(g.name)}</h2><ul class="discuss-list">${g.spaces.filter((sp) => sp.thread_kind !== 'roadmap').map((sp) => `<li><a class="btn" href="/s/${esc(sp.slug)}/new${q}">${esc(sp.name)}</a> <span class="muted small">${esc(sp.description || '')}</span></li>`).join('')}</ul></section>`).join('')}`;
${groups.map((g) => `<section class="board-group"><h2 class="board-group-h">${esc(g.name)}</h2><ul class="discuss-list">${g.spaces.filter((sp) => sp.thread_kind !== 'roadmap').map((sp) => `<li><a class="btn" href="/s/${esc(sp.slug)}/new${q}">${esc(sp.name)}</a> <span class="muted small">${esc(sp.description || '')}</span></li>`).join('')}</ul></section>`).join('')}`;
- return renderPage({ title: 'Discuss in a space', canonicalPath: '/s/discuss', robots: 'noindex,follow', active: 'spaces', footerVariant: 'compact', body });
+ return renderPage({ title: 'Discuss in the forum', canonicalPath: '/s/discuss', robots: 'noindex,follow', active: 'spaces', footerVariant: 'compact', body });
}
}
module.exports = { boardIndexPage, forumSpacePage, forumTopicPage, newSpacePage, settingsForm, moderatorsBox, discussPage };
module.exports = { boardIndexPage, forumSpacePage, forumTopicPage, newSpacePage, settingsForm, moderatorsBox, discussPage };
@@ -59,21 +59,21 @@ function spacesPage({ spaces }) {
@@ -59,21 +59,21 @@ function spacesPage({ spaces }) {
${s.description ? `<p class="muted">${esc(s.description)}</p>` : ''}
${s.description ? `<p class="muted">${esc(s.description)}</p>` : ''}
<p class="small muted">${num(s.thread_count)} ${s.thread_count === 1 ? 'thread' : 'threads'}${s.last_activity_at ? ` · last activity ${timeTag(s.last_activity_at)}` : ''}</p>
<p class="small muted">${num(s.thread_count)} ${s.thread_count === 1 ? 'thread' : 'threads'}${s.last_activity_at ? ` · last activity ${timeTag(s.last_activity_at)}` : ''}</p>
</li>`).join('')}
</li>`).join('')}
- </ul>` : '<p class="empty">No spaces yet.</p>';
+ </ul>` : '<p class="empty">No forums yet.</p>';
const body = `
const body = `
<header class="page-head">
<header class="page-head">
- <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <span aria-current="page">Spaces</span></nav>
- <h1>Spaces</h1>
+ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <span aria-current="page">Forum</span></nav>
+ <h1>Forum</h1>
<p class="muted">Long-form discussion for the people of OpenVibe. Read without an account; sign in to start a thread or reply. See what is happening across the network on <a href="/pulse">Pulse</a>.</p>
<p class="muted">Long-form discussion for the people of OpenVibe. Read without an account; sign in to start a thread or reply. See what is happening across the network on <a href="/pulse">Pulse</a>.</p>
</header>
</header>
${list}`;
${list}`;
return renderPage({
return renderPage({
- title: 'Spaces',
- description: 'Spaces on OpenVibe.Community: general talk, feedback and feature requests, and a showcase of what the people of OpenVibe make.',
+ title: 'Forum',
+ description: 'Forums on OpenVibe.Community: general talk, feedback and feature requests, and a showcase of what the people of OpenVibe make.',
canonicalPath: '/s',
canonicalPath: '/s',
active: 'spaces',
active: 'spaces',
feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],
feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],
- jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }])],
+ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }])],
body,
body,
});
});
}
}
@@ -124,7 +124,7 @@ function spacePage({ space, threads, sort, page, pages, total, user, categories
@@ -124,7 +124,7 @@ function spacePage({ space, threads, sort, page, pages, total, user, categories
const categoryName = category ? (categories.find((c) => c.slug === category) || {}).name : null;
const categoryName = category ? (categories.find((c) => c.slug === category) || {}).name : null;
const body = `
const body = `
<header class="page-head">
<header class="page-head">
- <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › <span aria-current="page">${esc(space.name)}</span></nav>
+ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › <span aria-current="page">${esc(space.name)}</span></nav>
<h1>${esc(space.name)}${visBadge(space)}</h1>
<h1>${esc(space.name)}${visBadge(space)}</h1>
${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
${start}
${start}
@@ -144,7 +144,7 @@ ${footer}`;
@@ -144,7 +144,7 @@ ${footer}`;
robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',
robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',
active: 'spaces',
active: 'spaces',
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
- jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
+ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
body,
body,
});
});
}
}
@@ -253,7 +253,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -253,7 +253,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
const body = `
const body = `
<article class="thread">
<article class="thread">
- <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span></nav>
+ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span></nav>
<header class="thread-head">
<header class="thread-head">
${space.votes === false ? '' : voteForm(base, thread, viewer)}
${space.votes === false ? '' : voteForm(base, thread, viewer)}
<div>
<div>
@@ -284,7 +284,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -284,7 +284,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
jsonLd: [
jsonLd: [
...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),
...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),
- ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
+ ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
],
],
body,
body,
});
});
@@ -309,7 +309,7 @@ function newThreadPage({ space, user, values = {}, error = null, categories = []
@@ -309,7 +309,7 @@ function newThreadPage({ space, user, values = {}, error = null, categories = []
</form>` : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/s/${space.slug}/new`)}">Sign in with your OpenVibe account</a> to start a thread. Reading needs no account.</p>`;
</form>` : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/s/${space.slug}/new`)}">Sign in with your OpenVibe account</a> to start a thread. Reading needs no account.</p>`;
const body = `
const body = `
<header class="page-head">
<header class="page-head">
- <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">New thread</span></nav>
+ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">New thread</span></nav>
<h1>New thread in ${esc(space.name)}</h1>
<h1>New thread in ${esc(space.name)}</h1>
</header>
</header>
${form}`;
${form}`;
@@ -336,7 +336,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
@@ -336,7 +336,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
: reason === 'vip_unavailable' || reason === 'entitlement_unknown'
: reason === 'vip_unavailable' || reason === 'entitlement_unknown'
? '<p class="muted">We could not confirm your membership just now. Try again in a moment.</p>'
? '<p class="muted">We could not confirm your membership just now. Try again in a moment.</p>'
: '<p class="muted">Your account does not have an active membership for this.</p>';
: '<p class="muted">Your account does not have an active membership for this.</p>';
- const crumbs = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › ${thread ? `<a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span>` : `<span aria-current="page">${esc(space.name)}</span>`}</nav>`;
+ const crumbs = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › ${thread ? `<a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span>` : `<span aria-current="page">${esc(space.name)}</span>`}</nav>`;
const body = `
const body = `
<header class="page-head">
<header class="page-head">
${crumbs}
${crumbs}
@@ -344,7 +344,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
@@ -344,7 +344,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
${!thread && space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
${!thread && space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
</header>
</header>
<section class="members-only">
<section class="members-only">
- <p>This ${thread ? 'thread' : 'space'} is for members of ${esc(creator)} on OpenVibe.VIP. Members read and reply; everyone else sees this page.</p>
+ <p>This ${thread ? 'thread' : 'forum'} is for members of ${esc(creator)} on OpenVibe.VIP. Members read and reply; everyone else sees this page.</p>
<p>${vipJoin(mo)}</p>
<p>${vipJoin(mo)}</p>
${why}
${why}
</section>`;
</section>`;
@@ -18,7 +18,7 @@ const { escapeHtml } = require('./highlight');
@@ -18,7 +18,7 @@ const { escapeHtml } = require('./highlight');
const SITE_NAME = 'OpenVibe.Community';
const SITE_NAME = 'OpenVibe.Community';
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
-const DEFAULT_DESCRIPTION = 'The people of OpenVibe — a community-run, open source home for pastes, spaces and threads, and submissions. Free speech within the rules.';
+const DEFAULT_DESCRIPTION = 'The people of OpenVibe — a community-run, open source home for pastes, forums and threads, and submissions. Free speech within the rules.';
const DEFAULT_OG_IMAGE = `${config.baseUrl}/og-default.png`;
const DEFAULT_OG_IMAGE = `${config.baseUrl}/og-default.png`;
// Content-hashed asset URLs so browsers and nginx can cache them for a year and still pick up
// Content-hashed asset URLs so browsers and nginx can cache them for a year and still pick up
@@ -61,7 +61,7 @@ function navbarInit(opts) {
@@ -61,7 +61,7 @@ function navbarInit(opts) {
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
links: [
links: [
{ label: 'Pastes', href: '/pastes', active: opts.active === 'pastes' },
{ label: 'Pastes', href: '/pastes', active: opts.active === 'pastes' },
- { label: 'Spaces', href: '/s', active: opts.active === 'spaces' },
+ { label: 'Forum', href: '/s', active: opts.active === 'spaces' },
{ label: 'Pulse', href: '/pulse', active: opts.active === 'pulse' },
{ label: 'Pulse', href: '/pulse', active: opts.active === 'pulse' },
{ label: 'Search', href: '/search', icon: 'fa-magnifying-glass', active: opts.active === 'search' },
{ label: 'Search', href: '/search', icon: 'fa-magnifying-glass', active: opts.active === 'search' },
{ label: 'New paste', href: '/new', icon: 'fa-plus', active: opts.active === 'new' },
{ label: 'New paste', href: '/new', icon: 'fa-plus', active: opts.active === 'new' },
@@ -90,7 +90,7 @@ function footerInit(opts) {
@@ -90,7 +90,7 @@ function footerInit(opts) {
heading: 'Community',
heading: 'Community',
items: [
items: [
{ name: 'Pastes', url: '/pastes' },
{ name: 'Pastes', url: '/pastes' },
- { name: 'Spaces', url: '/s' },
+ { name: 'Forum', url: '/s' },
{ name: 'Pulse', url: '/pulse' },
{ name: 'Pulse', url: '/pulse' },
{ name: 'New paste', url: '/new' },
{ name: 'New paste', url: '/new' },
{ name: 'My pastes', url: '/my' },
{ name: 'My pastes', url: '/my' },
@@ -153,8 +153,8 @@ function homePage({ latest, trending, languages, user }) {
@@ -153,8 +153,8 @@ function homePage({ latest, trending, languages, user }) {
items: [
items: [
{ icon: 'fa-paste', title: 'Pastes', text: 'Code, logs and configs with highlighting for every common language, a raw link and a download.', href: '/new' },
{ icon: 'fa-paste', title: 'Pastes', text: 'Code, logs and configs with highlighting for every common language, a raw link and a download.', href: '/new' },
{ icon: 'fa-image', title: 'Screenshots', text: 'Drop an image and share it with a link. Location data is stripped before it is stored.', href: '/new' },
{ icon: 'fa-image', title: 'Screenshots', text: 'Drop an image and share it with a link. Location data is stripped before it is stored.', href: '/new' },
- { icon: 'fa-layer-group', title: 'Spaces', text: 'General, Feedback and Showcase: places to talk, share and get help.', href: '/s' },
- { icon: 'fa-comments', title: 'Threads', text: 'Long-form discussion that outlives a chat scrollback. Start one in any open space.', href: '/s/general' },
+ { icon: 'fa-layer-group', title: 'Forum', text: 'General, Feedback and Showcase: places to talk, share and get help.', href: '/s' },
+ { icon: 'fa-comments', title: 'Threads', text: 'Long-form discussion that outlives a chat scrollback. Start one in any open forum.', href: '/s/general' },
{ icon: 'fa-wave-square', title: 'Pulse', text: 'What is happening across the OpenVibe network, in one feed.', href: '/pulse' },
{ icon: 'fa-wave-square', title: 'Pulse', text: 'What is happening across the OpenVibe network, in one feed.', href: '/pulse' },
{ icon: 'fa-code-branch', title: 'Open source', text: 'Every line that runs this site is public. Read it, file an issue, send a fix.', href: 'https://github.com/OpenVibers/OpenVibe.Community' },
{ icon: 'fa-code-branch', title: 'Open source', text: 'Every line that runs this site is public. Read it, file an issue, send a fix.', href: 'https://github.com/OpenVibers/OpenVibe.Community' },
],
],
@@ -175,12 +175,12 @@ const { url } = await res.json(); // "/p/<slug>"` },
@@ -175,12 +175,12 @@ const { url } = await res.json(); // "/p/<slug>"` },
}) + showcase.cta({
}) + showcase.cta({
title: 'Start a paste',
title: 'Start a paste',
text: 'Paste it, get a link, share it anywhere. Sign in to keep your pastes together under your OpenVibe account, or post without one.',
text: 'Paste it, get a link, share it anywhere. Sign in to keep your pastes together under your OpenVibe account, or post without one.',
- actions: [{ label: 'New paste', href: '/new', primary: true, icon: 'fa-plus' }, { label: 'Visit the Spaces', href: '/s' }],
+ actions: [{ label: 'New paste', href: '/new', primary: true, icon: 'fa-plus' }, { label: 'Visit the Forum', href: '/s' }],
}) + `
}) + `
<section class="section">${frame.shipped({ service: 'community', title: 'Recently shipped on OpenVibe.Community' })}</section>`;
<section class="section">${frame.shipped({ service: 'community', title: 'Recently shipped on OpenVibe.Community' })}</section>`;
return renderPage({
return renderPage({
title: null,
title: null,
- description: 'The people of OpenVibe. A community-run, open source home for pastes, spaces and threads. Free speech within the rules.',
+ description: 'The people of OpenVibe. A community-run, open source home for pastes, forums and threads. Free speech within the rules.',
canonicalPath: '/',
canonicalPath: '/',
active: 'home',
active: 'home',
jsonLd: [ld.websiteLd()],
jsonLd: [ld.websiteLd()],
@@ -302,7 +302,7 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
@@ -302,7 +302,7 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
${!isShot ? `<a class="btn btn-sm" href="/p/${esc(p.slug)}/raw"><i class="fa-solid fa-file-lines" aria-hidden="true"></i> Raw</a>` : `<a class="btn btn-sm" href="/p/${esc(p.slug)}/screenshot"><i class="fa-solid fa-image" aria-hidden="true"></i> Full image</a>`}
${!isShot ? `<a class="btn btn-sm" href="/p/${esc(p.slug)}/raw"><i class="fa-solid fa-file-lines" aria-hidden="true"></i> Raw</a>` : `<a class="btn btn-sm" href="/p/${esc(p.slug)}/screenshot"><i class="fa-solid fa-image" aria-hidden="true"></i> Full image</a>`}
<a class="btn btn-sm" href="/p/${esc(p.slug)}/download" download="${esc(p.slug)}.${esc(isShot ? 'png' : ext)}"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</a>
<a class="btn btn-sm" href="/p/${esc(p.slug)}/download" download="${esc(p.slug)}.${esc(isShot ? 'png' : ext)}"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</a>
${!isShot ? `<a class="btn btn-sm" href="/new?fork=${esc(p.slug)}"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Fork</a>` : ''}
${!isShot ? `<a class="btn btn-sm" href="/new?fork=${esc(p.slug)}"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Fork</a>` : ''}
- ${p.visibility !== 'private' && !p.burn_after_read ? `<a class="btn btn-sm" href="/s/discuss?paste=${encodeURIComponent(p.slug)}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in a space</a>` : ''}
+ ${p.visibility !== 'private' && !p.burn_after_read ? `<a class="btn btn-sm" href="/s/discuss?paste=${encodeURIComponent(p.slug)}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in the forum</a>` : ''}
${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>` : ''}
${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>` : ''}
</div>`;
</div>`;
@@ -15,7 +15,21 @@ const { boot, check, done } = require('./helpers/app');
@@ -15,7 +15,21 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(r.headers.get('location'), null, path);
assert.strictEqual(r.headers.get('location'), null, path);
}
}
const board = await t.get('/s');
const board = await t.get('/s');
- assert.ok(board.text.includes('Spaces · OpenVibe.Community'), 'the board index is Community\'s');
+ assert.ok(board.text.includes('Forum · OpenVibe.Community'), 'the board index is Community\'s');
+ });
+ await check('the boards are called the Forum (openvibe.space is a different product); /forum answers with a 301 to /s', async () => {
+ const board = await t.get('/s');
+ assert.ok(board.text.includes('<h1>Forum</h1>') && !/<h1>Spaces<\/h1>|>New space<|No spaces yet/.test(board.text), 'the page says Forum');
+ const cfg = JSON.parse(board.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
+ assert.strictEqual(cfg.navbar.links.find((l) => l.href === '/s').label, 'Forum', 'the nav says Forum');
+ assert.ok(!cfg.navbar.links.some((l) => /space/i.test(l.label)), 'no nav link says Spaces');
+ for (const [from, to] of [['/forum', '/s'], ['/forum/', '/s'], ['/forum?x=1', '/s?x=1'], ['/forum/general?sort=new', '/s/general?sort=new'], ['/forum/general/t/hello', '/s/general/t/hello']]) {
+ const r = await t.get(from, { redirect: 'manual' });
+ assert.strictEqual(r.status, 301, from);
+ assert.strictEqual(r.headers.get('location'), to, from);
+ }
+ const nope = await t.get('/forums', { redirect: 'manual' });
+ assert.notStrictEqual(nope.status, 301, '/forums is not /forum');
});
});
await t.close();
await t.close();
done();
done();
@@ -28,7 +28,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
@@ -28,7 +28,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
await check('/s lists the public spaces with SEO head, breadcrumbs and the threads feed', async () => {
await check('/s lists the public spaces with SEO head, breadcrumbs and the threads feed', async () => {
const r = await t.get('/s');
const r = await t.get('/s');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- has(r.text, '<title>Spaces · OpenVibe.Community</title>');
+ has(r.text, '<title>Forum · OpenVibe.Community</title>');
has(r.text, '<link rel="canonical" href="https://openvibe.community/s">');
has(r.text, '<link rel="canonical" href="https://openvibe.community/s">');
has(r.text, '<meta name="robots" content="index,follow">');
has(r.text, '<meta name="robots" content="index,follow">');
has(r.text, 'href="/s/feed.xml"');
has(r.text, 'href="/s/feed.xml"');