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)

OP OpenVibers committed 27 days ago · 2c521c7842 · 16 files, +112 −87

Changes

ddiff --git a/README.md b/README.md
iindex fe71e2ae..1294726 100644
---- a/README.md
++++ b/README.md
@@@ -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({ v
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:
`general`, `feedback` (feature requests), `showcase`. Space visibility: `public` (anyone
ddiff --git a/STATUS.json b/STATUS.json
iindex d267228..5f65029 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/docs/roadmap/public.json b/docs/roadmap/public.json
iindex d5fc796..a50a071 100644
---- a/docs/roadmap/public.json
++++ b/docs/roadmap/public.json
@@@ -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",
ddiff --git a/server/chat-rooms.js b/server/chat-rooms.js
iindex bd1eac0..f55e222 100644
---- a/server/chat-rooms.js
++++ b/server/chat-rooms.js
@@@ -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');
ddiff --git a/server/discovery.js b/server/discovery.js
iindex 3882ade..85f0a4f 100644
---- a/server/discovery.js
++++ b/server/discovery.js
@@@ -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: [
ddiff --git a/server/forum/api.js b/server/forum/api.js
iindex 49f9ac5..af3572e 100644
---- a/server/forum/api.js
++++ b/server/forum/api.js
@@@ -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
ddiff --git a/server/forum/routes.js b/server/forum/routes.js
iindex 43e3a47..b8f27b9 100644
---- a/server/forum/routes.js
++++ b/server/forum/routes.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 } : {}) })),
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex 921631f..55da387 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -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, 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) }))
.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, vi
async 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, vi
async 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, vi
async 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, vi
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 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, vi
async 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, vi
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');
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, 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');
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, vi
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');
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, vi
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');
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}`);
ddiff --git a/server/relay/api.js b/server/relay/api.js
iindex fd6739e..3b16373 100644
---- a/server/relay/api.js
++++ b/server/relay/api.js
@@@ -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_*)');
ddiff --git a/server/relay/discord.js b/server/relay/discord.js
iindex b5d7088..a9d4401 100644
---- a/server/relay/discord.js
++++ b/server/relay/discord.js
@@@ -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. */
ddiff --git a/server/render/board.js b/server/render/board.js
iindex 320f2a5..4b98918 100644
---- a/server/render/board.js
++++ b/server/render/board.js
@@@ -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, moder
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,
});
}
@@@ -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` }] : [],
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 };
ddiff --git a/server/render/forum.js b/server/render/forum.js
iindex 9eff9a1..18a31cc 100644
---- a/server/render/forum.js
++++ b/server/render/forum.js
@@@ -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, categories
const 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, u
const 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, u
feeds: 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>`;
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 3d4566b..bbb793b 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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' },
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 690eefe..538674d 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -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>`;
ddiff --git a/test/forum-home.test.js b/test/forum-home.test.js
iindex b116d20..5a38003 100644
---- a/test/forum-home.test.js
++++ b/test/forum-home.test.js
@@@ -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();
ddiff --git a/test/forum-ssr.test.js b/test/forum-ssr.test.js
iindex eac0dc2..72cbb78 100644
---- a/test/forum-ssr.test.js
++++ b/test/forum-ssr.test.js
@@@ -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 () => {
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"');