openvibers/openvibe.community Commits
Forum boards next to feeds: each space is a forum (vBulletin/SMF style: a grouped board index with topics, posts and the last post; topic tables with replies, views and the last post, sticky first, bumped by replies; posts beside the author's panel with Quote) or a feed (votes, hot/new/top); votes and Facepunch-style ratings (Agree, Winner, Funny, Informative, Friendly, Sympathy, Dumb, Disgusting, Bad reading, Late; one per person per post) switch per space; pastes attach to posts as cards and every paste has Discuss in a space; threads crosspost between spaces; moderators create spaces, groups and child boards and change styles; General, Help and Off-topic are forums, Feedback, Roadmap and Showcase feeds
Changes
@@@ -322,6 +322,22 @@ member for now), `staff` (moderators only; looks missing to everyone else).thread). Moderators (admin/global_mod browsers; services with `community.comment.moderate`)pin, lock and delete; locked threads take no replies or votes.++- **Two styles, one system.** Each space is a **forum** or a **feed**. A forum reads like vBulletin or SMF:++ - the board index at `/s` lists every space under its group, with topics, posts and the last post;++ - a board is a topic table (Replies, Views, Last post) with sticky topics first and replies bumping a topic;++ - a topic shows each post beside its author's panel: picture, posts, since when, the ratings they get most.++ Quote fills the reply box. A feed is subreddit-like: votes and hot/new/top. Moderators switch per space:++ the style, up/down votes and ratings. They also set the group, a parent (one level of child boards),++ the name and description, and create spaces (`/s/new-space`).++ Both styles share threads, ratings, images, pastes and crossposts: a thread can be crossposted to another++ space, and both link to each other.++- **Ratings** (after Facepunch), one per person per post, never your own:++ - positive: Agree ✅, Winner 🏆, Funny 😂, Informative 💡, Friendly 😊, Sympathy ❤️;++ - negative: Dumb 📦, Disgusting 🤢;++ - utility: Bad reading 📖, Late 🕒.++ The same rating again takes it back, and another replaces it. Hovering shows who gave it.++- **Pastes on posts.** Up to 4 public or unlisted pastes per post, shown as cards with their first lines or++ the screenshot. A paste made private or deleted drops out. Every paste page has "Discuss in a space".- **Categories, requests and the roadmap.** A space's `thread_kind` decides what a new thread is. In a`discussion` space it is a plain thread. In `request` (Feedback, with Ideas, Bugs and Questionscategories) it is open to votes and starts `open`; staff move it through its statuses. In `roadmap`@@@ -343,6 +359,9 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):| `POST /spaces/:space/threads` `{ title, body, category?, members_only? }` | New thread (`community.post.create` for services); `members_only: true` gates it to the author's VIP members || `POST /spaces/:space/attachments` (multipart `file`) | An image for a new thread or reply → `{ attachment: { media_id, url, … } }`; name it in `attachments: [media_id]` when posting || `GET /spaces/:space/categories` · `PUT`/`DELETE /spaces/:space/categories/:category` `{ name, description?, position? }` | Categories · moderators manage them (a deleted category's threads stay, uncategorised) |++| `POST /spaces` · `PUT /spaces/:space/settings` `{ style, votes, reactions, group, parent, name, description, kind }` | Moderators: new space · settings. `GET/PUT /api/v1/space-groups[/:group]` manages the board index's groups |++| `POST /spaces/:space/threads/:slug/crosspost` `{ to }` | Crosspost to another space |++| `POST /posts/:id/reactions` `{ reaction \| null }` | Rate a post || `PUT /spaces/:space/threads/:slug/category` `{ category: slug \| null }` | The author or moderators || `PUT /spaces/:space/threads/:slug/status` `{ status }` | Moderators: requests `open`/`planned`/`in_progress`/`done`/`declined`, roadmap items `planned`/`in_progress`/`done`/`paused` || `GET /spaces/:space/threads/:slug?page=` | Thread + posts (`body_markdown` and rendered `body_html`) |@@@ -21,6 +21,8 @@"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." },++ { "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." },{ "key": "your-data", "category": "features", "status": "in_progress", "title": "See, download and delete your data","summary": "The AI & Data tab on your account lists what OpenVibe sites keep with your account, and lets you download it or delete any of it. Next: one export with everything from every site, and account deletion with a grace period." },{ "key": "ai-preferences", "category": "features", "status": "done", "title": "AI that writes the way you ask",@@@ -229,3 +229,92 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-.comment-text { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }.comment-replies { display: flex; flex-direction: column; gap: 8px; margin: 10px 0 0 18px; }.comment-replies .post { padding: 10px 14px; }++++/* ── Forum style: board index, topic tables, author panels (render/board.js) ── */++.board-group { margin: 22px 0; }++.board-group-h { font-size: 1rem; text-transform: uppercase; letter-spacing: .06em; color: var(--accent-light); margin: 0 0 6px; }++.board-group-p { margin: 0 0 8px; }++.board-table { width: 100%; table-layout: fixed; border-collapse: separate; border-spacing: 0; background: var(--bg-card); border: 1px solid var(--border-light); border-radius: 12px; overflow: hidden; }++.board-table th.num { width: 6.5em; }++.board-table th:last-child { width: 16em; }++.topic-table th.topic-icon { width: 2.6em; }++.board-table th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-secondary); padding: 8px 12px; border-bottom: 1px solid var(--border-light); background: var(--bg-hover); }++.board-table td { padding: 10px 12px; border-top: 1px solid var(--border-light); vertical-align: top; }++.board-table tbody tr:first-child td { border-top: 0; }++.board-table .num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; width: 5.5em; }++.board-name { display: flex; gap: 12px; align-items: flex-start; }++.board-name > div { min-width: 0; }++.board-name p { margin: 2px 0 0; }++.board-icon { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; background: rgba(var(--accent-rgb), .12); color: var(--accent-light); }++.board-title, .topic-title { font-weight: 700; }++.board-last { width: 15em; font-size: .85rem; }++.board-last > a, .board-last > .small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 15em; }++.board-last .small a { display: inline; }++.badge-style { font-size: .65rem; vertical-align: middle; }++.board-children { margin-top: 4px; }++.topic-table .topic-icon { width: 2.2em; text-align: center; color: var(--text-secondary); }++.topic-table tr.sticky td { background: rgba(var(--accent-rgb), .06); }++.topic-by { display: block; margin-top: 3px; }++.topic-pages a { margin-left: 3px; }++.badge-sticky { font-size: .65rem; border-color: var(--accent); color: var(--accent-light); }++.board-toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 8px 0 12px; }++.space-settings, .crosspost-form { margin: 18px 0; }++.space-settings summary, .crosspost-form summary { cursor: pointer; color: var(--text-secondary); font-weight: 600; }++.discuss-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }++.postbits { display: grid; gap: 12px; margin: 12px 0; }++.postbit { display: grid; grid-template-columns: 170px 1fr; background: var(--bg-card); border: 1px solid var(--border-light); border-radius: 12px; scroll-margin-top: 80px; }++.postbit-author { padding: 14px; background: var(--bg-hover); border-right: 1px solid var(--border-light); border-radius: 12px 0 0 12px; display: flex; flex-direction: column; align-items: center; text-align: center; gap: 6px; }++.postbit-avatar { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; }++.postbit-letter { display: grid; place-items: center; background: var(--accent); color: var(--on-accent); font-weight: 700; font-size: 1.5rem; }++.postbit-name { font-weight: 700; word-break: break-word; }++.postbit-stats { display: grid; gap: 2px; margin: 0; font-size: .8rem; color: var(--text-secondary); }++.postbit-stats div { display: flex; gap: 6px; justify-content: center; }++.postbit-stats dt { font-weight: 600; }++.postbit-stats dd { margin: 0; }++.postbit-ratings { margin: 0; font-size: .8rem; display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }++.postbit-main { padding: 12px 16px; min-width: 0; }++.postbit-head { display: flex; flex-wrap: wrap; gap: 4px 8px; margin-bottom: 8px; padding-bottom: 6px; border-bottom: 1px solid var(--border-light); }++.postbit.post-deleted { grid-template-columns: 1fr; }++@media (max-width: 720px) {++ .postbit { grid-template-columns: 1fr; }++ .postbit-author { flex-direction: row; flex-wrap: wrap; text-align: left; border-right: 0; border-bottom: 1px solid var(--border-light); border-radius: 12px 12px 0 0; padding: 10px 12px; }++ .postbit-avatar { width: 40px; height: 40px; }++ .postbit-stats { grid-auto-flow: column; gap: 10px; }++ .board-table thead { display: none; }++ .board-table, .board-table tbody, .board-table tr, .board-table td { display: block; width: 100%; }++ .board-table tr { border-top: 1px solid var(--border-light); padding: 8px 0; }++ .board-table tbody tr:first-child { border-top: 0; }++ .board-table td { border-top: 0; padding: 2px 12px; }++ .board-table td.num { display: inline-block; width: auto; text-align: left; }++ .board-table td.num::before { content: attr(data-label) ': '; color: var(--text-secondary); font-size: .8rem; }++ .board-table td.topic-icon { display: none; }++ .board-last, .board-last a, .board-last .small { width: auto; max-width: 100%; }++}++++/* ── Ratings (Facepunch style) ── */++.reactions { position: relative; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 10px 0 0; }++.rx { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border-light); background: transparent; color: var(--text-primary, inherit); font: inherit; font-size: .82rem; cursor: default; }++button.rx { cursor: pointer; }++button.rx:hover { border-color: var(--accent); }++.rx.mine { border-color: var(--accent); background: rgba(var(--accent-rgb), .15); }++.rx-negative { border-style: dashed; }++.rx-pick { position: static; }++.rx-pick summary { list-style: none; cursor: pointer; font-size: .82rem; color: var(--text-secondary); padding: 2px 8px; border-radius: 999px; border: 1px solid transparent; }++.rx-pick summary::-webkit-details-marker { display: none; }++.rx-pick summary:hover, .rx-pick[open] summary { border-color: var(--border-light); }++.rx-menu { position: absolute; left: 0; top: 100%; z-index: 20; margin-top: 6px; padding: 8px; background: var(--bg-card); border: 1px solid var(--border-light); border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,.35); display: grid; gap: 6px; width: max-content; max-width: min(440px, 100%); }++.rx-row { display: flex; flex-wrap: wrap; gap: 4px; }++.rx-btn { display: inline-flex; align-items: center; gap: 4px; padding: 4px 8px; border-radius: 8px; border: 1px solid var(--border-light); background: transparent; color: inherit; font: inherit; font-size: .8rem; cursor: pointer; }++.rx-btn:hover, .rx-btn:focus-visible { border-color: var(--accent); background: var(--bg-hover); }++.rx-btn.rx-negative { color: var(--text-secondary); }++++/* ── Pastes on posts, crossposts ── */++.paste-embeds { display: grid; gap: 8px; margin-top: 10px; }++.paste-embed { margin: 0; border: 1px solid var(--border-light); border-radius: 10px; overflow: hidden; background: var(--bg-hover); }++.paste-embed figcaption { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; padding: 6px 10px; border-bottom: 1px solid var(--border-light); }++.paste-embed figcaption a { font-weight: 700; }++.paste-embed-code { margin: 0; max-height: 18em; overflow: auto; font-size: .8rem; padding: 8px 10px; }++.paste-embed > a.small { display: block; padding: 4px 10px 8px; }++.paste-embed img { display: block; max-width: 100%; max-height: 360px; margin: 0 auto; }++.crosspost { margin: 8px 0; }++.crosspost-note { margin: 4px 0; }@@@ -50,7 +50,7 @@ const { createCommentsApi } = require('./comments/api');const { createCommentPages } = require('./comments/routes');const { createForumService } = require('./forum/service');const { createVipGate } = require('./vip');--const { createSpacesApi, createPostsApi } = require('./forum/api');++const { createSpacesApi, createPostsApi, createGroupsApi } = require('./forum/api');const { createForumRoutes } = require('./forum/routes');const { createPulse } = require('./pulse/service');const { createPulseApi } = require('./pulse/api');@@@ -176,6 +176,7 @@ function createApp(opts = {}) {app.use('/internal/events', pulseConsumer.router);app.use('/api/v1/spaces', createSpacesApi({ forum, viewers }));app.use('/api/v1/posts', createPostsApi({ forum, viewers }));++ app.use('/api/v1/space-groups', createGroupsApi({ forum, viewers }));app.use('/api/v1/relay', createRelayApi({ relay, db, viewers }));app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));@@@ -260,6 +260,33 @@ CREATE TABLE IF NOT EXISTS attachments ();CREATE INDEX IF NOT EXISTS idx_attachments_post ON attachments(post_id, position) WHERE post_id IS NOT NULL;++-- The board index (forum style): spaces are listed under groups, like a vBulletin/SMF board index.++CREATE TABLE IF NOT EXISTS space_groups (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ slug TEXT UNIQUE NOT NULL,++ name TEXT NOT NULL,++ description TEXT,++ position INTEGER NOT NULL DEFAULT 0++);++++-- Facepunch-style ratings on posts: one per person per post (forum/reactions.js lists them).++CREATE TABLE IF NOT EXISTS post_reactions (++ post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,++ subject_id TEXT NOT NULL,++ reaction TEXT NOT NULL,++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,++ PRIMARY KEY (post_id, subject_id)++);++CREATE INDEX IF NOT EXISTS idx_post_reactions_post ON post_reactions(post_id, reaction);++++-- Pastes attached to posts (a card with the paste's first lines, or its screenshot).++CREATE TABLE IF NOT EXISTS post_pastes (++ post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,++ paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,++ position INTEGER NOT NULL DEFAULT 0,++ PRIMARY KEY (post_id, paste_id)++);++-- Categories inside a space (WS-J task 1): threads may carry one; the space page filters by them.CREATE TABLE IF NOT EXISTS categories (id INTEGER PRIMARY KEY AUTOINCREMENT,@@@ -403,6 +430,29 @@ function migrate(db) {('roadmap', 'Roadmap', 'What OpenVibe is building next and where each piece stands. Every item has its own thread: ask about it or argue for it there.', 'public', 'system', 'roadmap');UPDATE spaces SET thread_kind = 'request', description = 'Feature requests, bugs and ideas for every OpenVibe site. Vote for what matters to you; staff mark what is planned and done.'WHERE slug = 'feedback' AND thread_kind = 'discussion' AND created_by = 'system';`);++ // Space styles (forum/service.js): 'feed' (subreddit-like: votes, hot/new/top) or 'forum' (vBulletin/SMF-like:++ // board index, topics by last post, author panels, quotes). Votes and ratings are per-space switches;++ // group_id and position place a space on the board index, parent_id makes it a child board.++ for (const [col, ddl] of [['style', "TEXT NOT NULL DEFAULT 'feed'"], ['votes', 'INTEGER NOT NULL DEFAULT 1'], ['reactions', 'INTEGER NOT NULL DEFAULT 1'],++ ['group_id', 'INTEGER REFERENCES space_groups(id) ON DELETE SET NULL'], ['parent_id', 'INTEGER REFERENCES spaces(id) ON DELETE SET NULL'], ['position', 'INTEGER NOT NULL DEFAULT 0']]) {++ if (!spaceCols.has(col)) db.exec(`ALTER TABLE spaces ADD COLUMN ${col} ${ddl}`);++ }++ if (!threadCols.has('views')) db.exec('ALTER TABLE threads ADD COLUMN views INTEGER NOT NULL DEFAULT 0');++ if (!threadCols.has('crosspost_of')) db.exec('ALTER TABLE threads ADD COLUMN crosspost_of INTEGER REFERENCES threads(id) ON DELETE SET NULL');++ // The first board index: two groups, the four original spaces placed once (a space staff moved is left alone),++ // and two new forum boards.++ db.exec(`INSERT OR IGNORE INTO space_groups (slug, name, description, position) VALUES++ ('openvibe', 'OpenVibe', 'The network itself: talk, help, requests and what is coming next.', 1),++ ('community', 'Community', 'What the people of OpenVibe make, and everything else.', 2);++ INSERT OR IGNORE INTO spaces (slug, name, description, visibility, created_by, style, votes) VALUES++ ('help', 'Help', 'Stuck on something? Ask here: streaming, tools, your account, anything on OpenVibe.', 'public', 'system', 'forum', 0),++ ('off-topic', 'Off-topic', 'Anything that is not about OpenVibe.', 'public', 'system', 'forum', 0);`);++ const place = db.prepare(`UPDATE spaces SET group_id = (SELECT id FROM space_groups WHERE slug = ?), position = ?, style = ?, votes = ?++ WHERE slug = ? AND created_by = 'system' AND group_id IS NULL`);++ for (const [group, position, style, votes, slug] of [++ ['openvibe', 1, 'forum', 0, 'general'], ['openvibe', 2, 'forum', 0, 'help'], ['openvibe', 3, 'feed', 1, 'feedback'], ['openvibe', 4, 'feed', 1, 'roadmap'],++ ['community', 1, 'feed', 1, 'showcase'], ['community', 2, 'forum', 0, 'off-topic'],++ ]) place.run(group, position, style, votes, slug);const seedCategory = db.prepare(`INSERT OR IGNORE INTO categories (space_id, slug, name, description, position)SELECT id, ?, ?, ?, ? FROM spaces WHERE slug = ?`);for (const [space, slug, name, description, position] of [@@@ -24,6 +24,10 @@* PUT /spaces/:space/threads/:slug/category { category: slug | null } the author or moderators* PUT /spaces/:space/threads/:slug/status { status } moderators: requests open|planned|in_progress|done|declined,* roadmap items planned|in_progress|done|paused (?status= filters)++ * POST /spaces { slug, name, description?, style?: feed|forum, votes?, reactions?, group?, parent?, visibility?, kind? } moderators++ * PUT /spaces/:space/settings { name?, description?, style?, votes?, reactions?, group?, parent?, position?, kind? } moderators++ * POST /spaces/:space/threads/:slug/crosspost { to: slug } another space gets a thread linking back (people)++ * POST /posts/:id/reactions { reaction: agree|winner|funny|informative|friendly|sympathy|dumb|disgusting|bad_reading|late|null }* PUT /posts/:id { body } DELETE /posts/:id GET /posts/:id/versions** Services write with community.post.create (as X-OV-Subject, or as AI with X-OV-Origin: ai) and@@@ -48,6 +52,8 @@ function createSpacesApi({ forum, viewers }) {const p = (req) => req.params;router.get('/', run((req) => forum.listSpaces(req.viewer)));++ router.post('/', serviceCap(MOD), jsonBody, run((req) => forum.createSpace(req.viewer, req.body || {}), 201));++ router.put('/:space/settings', serviceCap(MOD), jsonBody, run((req) => forum.updateSpaceSettings(req.viewer, p(req).space, req.body || {})));router.get('/:space', run((req) => forum.space(req.viewer, p(req).space)));router.get('/:space/threads', run((req) => forum.listThreads(req.viewer, p(req).space, req.query)));router.get('/:space/categories', run((req) => forum.categories(req.viewer, p(req).space)));@@@ -61,6 +67,7 @@ function createSpacesApi({ forum, viewers }) {router.delete('/:space/categories/:category', serviceCap(MOD), run((req) => forum.deleteCategory(req.viewer, p(req).space, p(req).category)));router.put('/:space/threads/:slug/category', writeOrMod, jsonBody, run((req) => forum.setThreadCategory(req.viewer, p(req).space, p(req).slug, req.body || {})));router.put('/:space/threads/:slug/status', serviceCap(MOD), jsonBody, run((req) => forum.setThreadStatus(req.viewer, p(req).space, p(req).slug, req.body || {})));++ router.post('/:space/threads/:slug/crosspost', write, jsonBody, run((req) => forum.crosspost(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));router.post('/:space/threads', write, jsonBody, run((req) => forum.createThread(req.viewer, p(req).space, req.body || {}), 201));router.get('/:space/threads/:slug', run((req) => forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));router.delete('/:space/threads/:slug', writeOrMod, run((req) => forum.deleteThread(req.viewer, p(req).space, p(req).slug)));@@@ -81,6 +88,7 @@ function createPostsApi({ forum, viewers }) {const writeOrMod = serviceAnyCap([POST, MOD]);router.put('/:id', writeOrMod, jsonBody, run((req) => forum.editPost(req.viewer, req.params.id, req.body || {})));++ router.post('/:id/reactions', serviceCap(POST), jsonBody, run((req) => forum.react(req.viewer, req.params.id, req.body || {})));router.delete('/:id', writeOrMod, run((req) => forum.deletePost(req.viewer, req.params.id)));router.get('/:id/versions', writeOrMod, run((req) => forum.postVersions(req.viewer, req.params.id)));@@@ -88,4 +96,15 @@ function createPostsApi({ forum, viewers }) {return router;}--module.exports = { createSpacesApi, createPostsApi };++/** /api/v1/space-groups — the board index's groups: GET (everyone), PUT /:group { name, description?, position? } (moderators). */++function createGroupsApi({ forum, viewers }) {++ const router = express.Router();++ router.use(contracts.http.middleware());++ router.use(viewers.middleware());++ router.get('/', run(async (req) => ({ groups: (await forum.listSpaces(req.viewer)).groups.map(({ spaces, ...g }) => ({ ...g, spaces: spaces.map((sp) => sp.slug) })) })));++ router.put('/:group', serviceCap(MOD), jsonBody, run((req) => forum.putGroup(req.viewer, req.params.group, req.body || {})));++ router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));++ return router;++}++++module.exports = { createSpacesApi, createPostsApi, createGroupsApi };nnew file mode 100644@@@ -0,0 +1,76 @@++'use strict';++++/**++ * Ratings on forum posts, after Facepunch: instead of a plain like, a row of specific community judgments.++ * One rating per person per post (rating again with the same one takes it back; another replaces it);++ * nobody rates their own post. A space can switch ratings off (spaces.reactions).++ */++const REACTIONS = Object.freeze([++ { key: 'agree', emoji: '✅', label: 'Agree', group: 'positive' },++ { key: 'winner', emoji: '🏆', label: 'Winner', group: 'positive' },++ { key: 'funny', emoji: '😂', label: 'Funny', group: 'positive' },++ { key: 'informative', emoji: '💡', label: 'Informative', group: 'positive' },++ { key: 'friendly', emoji: '😊', label: 'Friendly', group: 'positive' },++ { key: 'sympathy', emoji: '❤️', label: 'Sympathy', group: 'positive' },++ { key: 'dumb', emoji: '📦', label: 'Dumb', group: 'negative' },++ { key: 'disgusting', emoji: '🤢', label: 'Disgusting', group: 'negative' },++ { key: 'bad_reading', emoji: '📖', label: 'Bad reading', group: 'utility' },++ { key: 'late', emoji: '🕒', label: 'Late', group: 'utility' },++]);++const BY_KEY = new Map(REACTIONS.map((r) => [r.key, r]));++const ORDER = new Map(REACTIONS.map((r, i) => [r.key, i]));++++/** Set (or with null, remove) a person's rating of a post. → the rating now held, or null */++function setReaction(db, postId, subject, reaction) {++ const cur = db.prepare('SELECT reaction FROM post_reactions WHERE post_id = ? AND subject_id = ?').get(postId, subject);++ if (!reaction || (cur && cur.reaction === reaction)) {++ db.prepare('DELETE FROM post_reactions WHERE post_id = ? AND subject_id = ?').run(postId, subject);++ return null;++ }++ db.prepare(`INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (?, ?, ?)++ ON CONFLICT (post_id, subject_id) DO UPDATE SET reaction = excluded.reaction, created_at = CURRENT_TIMESTAMP`).run(postId, subject, reaction);++ return reaction;++}++++const placeholders = (n) => Array.from({ length: n }, () => '?').join(', ');++++/**++ * Ratings of these posts. → Map postId → [{ key, emoji, label, group, count, raters: [subject…] (first 8), mine }]++ * in the fixed order, only ratings someone gave.++ */++function reactionsFor(db, postIds, viewerSubject = null) {++ const out = new Map();++ if (!postIds.length) return out;++ const rows = db.prepare(`SELECT post_id, reaction, subject_id FROM post_reactions WHERE post_id IN (${placeholders(postIds.length)}) ORDER BY created_at, subject_id`).all(...postIds);++ for (const r of rows) {++ const def = BY_KEY.get(r.reaction);++ if (!def) continue;++ if (!out.has(r.post_id)) out.set(r.post_id, new Map());++ const m = out.get(r.post_id);++ if (!m.has(r.reaction)) m.set(r.reaction, { ...def, count: 0, raters: [], mine: false });++ const e = m.get(r.reaction);++ e.count++;++ if (e.raters.length < 8) e.raters.push(r.subject_id);++ if (viewerSubject && r.subject_id === viewerSubject) e.mine = true;++ }++ return new Map([...out].map(([id, m]) => [id, [...m.values()].sort((a, b) => ORDER.get(a.key) - ORDER.get(b.key))]));++}++++/** The ratings each author has received, most given first (top 3). → Map subject → [{ key, emoji, label, count }] */++function receivedBy(db, subjects) {++ const out = new Map();++ const list = [...new Set(subjects.filter(Boolean))];++ if (!list.length) return out;++ const rows = db.prepare(`SELECT p.author_subject AS s, r.reaction, COUNT(*) AS n FROM post_reactions r JOIN posts p ON p.id = r.post_id++ WHERE p.author_subject IN (${placeholders(list.length)}) AND p.deleted_at IS NULL GROUP BY p.author_subject, r.reaction`).all(...list);++ for (const r of rows) {++ const def = BY_KEY.get(r.reaction);++ if (!def) continue;++ if (!out.has(r.s)) out.set(r.s, []);++ out.get(r.s).push({ key: def.key, emoji: def.emoji, label: def.label, count: r.n });++ }++ for (const [s, l] of out) out.set(s, l.sort((a, b) => b.count - a.count || ORDER.get(a.key) - ORDER.get(b.key)).slice(0, 3));++ return out;++}++++module.exports = { REACTIONS, BY_KEY, setReaction, reactionsFor, receivedBy };@@@ -22,6 +22,7 @@ const multer = require('multer');const seo = require('../seo');const pages = require('../render/pages');const forumPages = require('../render/forum');++const boardPages = require('../render/board');const { ApiError } = require('../http/v1');function createForumRoutes({ forum, viewers, config }) {@@@ -52,7 +53,7 @@ function createForumRoutes({ forum, viewers, config }) {/** Render an ApiError as a page: sign-in for 401, the error page for the rest. */function failPage(req, res, err, next) {if (!(err instanceof ApiError)) return next(err);-- if (err.status === 401) return login(res, req.originalUrl.replace(/\/(reply|vote|state|delete|members-only)$/, ''));++ if (err.status === 401) return login(res, req.originalUrl.replace(/\/(reply|vote|state|delete|members-only|react|crosspost|settings|status|category)$/, ''));if (err.code === 'vip.members_only' && err.extra) {return html(res, forumPages.membersOnlyPage({ ...err.extra, user: req.user, next: req.originalUrl.replace(/\/(reply|vote|state|delete|members-only|new)$/, '') }), 403);}@@@ -66,8 +67,39 @@ function createForumRoutes({ forum, viewers, config }) {next();}++ // 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, forumPages.spacesPage({ ...(await forum.listSpaces(req.viewer)), user: req.user })); } 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); }++ }));++++ // Moderators: a new space, either style.++ 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');++ html(res, boardPages.newSpacePage({ groups: forum.groups() }));++ } catch (err) { failPage(req, res, err, next); }++ }));++ router.post('/s/new-space', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ const b = req.body || {};++ const values = { name: b.name, slug: b.slug, description: b.description, style: b.style === 'feed' ? 'feed' : 'forum', votes: b.votes === '1', reactions: b.reactions === '1', group: b.group || null };++ try {++ const out = await forum.createSpace(req.viewer, values);++ seo.resetCaches();++ res.redirect(303, `/s/${out.space.slug}`);++ } catch (err) {++ if (!(err instanceof ApiError) || err.status === 401 || err.status === 403) return failPage(req, res, err, next);++ html(res, boardPages.newSpacePage({ groups: forum.groups(), error: err.message, values }), err.status);++ }++ }));++++ // "Discuss in a space" from a paste: pick the space, then its new-topic form with the paste attached.++ router.get('/s/discuss', withViewer, wrap(async (req, res, next) => {++ try {++ const paste = String(req.query.paste || '').slice(0, 80);++ const { groups } = await forum.listSpaces(req.viewer);++ html(res, boardPages.discussPage({ groups, paste, user: req.user }));++ } catch (err) { failPage(req, res, err, next); }}));router.get('/s/feed.xml', (_req, res) => {@@@ -84,7 +116,8 @@ function createForumRoutes({ forum, viewers, config }) {try {const out = await forum.listThreads(req.viewer, req.params.space, { sort: req.query.sort, page: req.query.page, category: req.query.category, status: req.query.status });if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');-- html(res, forumPages.spacePage({ ...out, user: req.user }));++ if (out.space.style === 'forum') return html(res, boardPages.forumSpacePage({ ...out, user: req.user }));++ html(res, forumPages.spacePage({ ...out, user: req.user, footer: out.viewer.can_moderate ? boardPages.settingsForm(out.space, out.groups) : '' }));} catch (err) { failPage(req, res, err, next); }}));@@@ -94,7 +127,8 @@ function createForumRoutes({ forum, viewers, config }) {const probe = await forum.listThreads(req.viewer, req.params.space, { limit: 1 });if (!probe.viewer.can_start) throw new ApiError(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');const { space } = await forum.space(req.viewer, req.params.space);-- html(res, forumPages.newThreadPage({ space, user: req.user, categories: probe.categories, attachments: forum.attachmentsEnabled() }));++ const paste = /^[A-Za-z0-9_-]{3,80}$/.test(String(req.query.paste || '')) ? String(req.query.paste) : '';++ html(res, forumPages.newThreadPage({ space, user: req.user, categories: probe.categories, attachments: forum.attachmentsEnabled(), values: paste ? { pastes: paste, title: forum.pasteTitle(paste) || '' } : {} }));} catch (err) { failPage(req, res, err, next); }}));@@@ -102,6 +136,7 @@ function createForumRoutes({ forum, viewers, config }) {const values = { title: String((req.body || {}).title || '').slice(0, 200), body: String((req.body || {}).body || '').slice(0, 40_000) };if ((req.body || {}).members_only === '1') values.members_only = true;if ((req.body || {}).category) values.category = String(req.body.category).slice(0, 40);++ if ((req.body || {}).pastes) values.pastes = String(req.body.pastes).slice(0, 400);try {// Images go to Media only when the rest can be saved (no strays from an empty form).if (worthUploading(values.title, values.body)) { const ids = await uploadImages(req); if (ids.length) values.attachments = ids; }@@@ -121,7 +156,13 @@ function createForumRoutes({ forum, viewers, config }) {async function renderThread(req, res, { status = 200, error = null, draft = '' } = {}) {const out = await forum.getThread(req.viewer, req.params.space, req.params.slug, { page: req.query.page });if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');-- html(res, forumPages.threadPage({ ...out, perPage: out.per_page, user: req.user, error, draft, attachmentsEnabled: forum.attachmentsEnabled() }), status);++ if (status === 200 && forum.recordView(out.thread.id, req.user && req.user.subject_id ? `s:${req.user.subject_id}` : `ip:${req.ip}`)) out.thread.views += 1;++ // Quote (no JS): ?quote=<post id> fills the reply box with the quoted post.++ if (!draft && /^\d{1,15}$/.test(String(req.query.quote || '')) && out.viewer.can_reply) {++ try { draft = (await forum.quote(req.viewer, req.query.quote)).markdown; } catch { /* a post that is gone: an empty box */ }++ }++ const view = { ...out, perPage: out.per_page, user: req.user, error, draft, attachmentsEnabled: forum.attachmentsEnabled() };++ html(res, out.space.style === 'forum' ? boardPages.forumTopicPage(view) : forumPages.threadPage(view), status);}router.get('/s/:space/t/:slug', withViewer, wrap(async (req, res, next) => {@@@ -132,7 +173,8 @@ function createForumRoutes({ forum, viewers, config }) {const draft = String((req.body || {}).body || '').slice(0, 40_000);try {const attachments = worthUploading(null, draft) ? await uploadImages(req) : [];-- const out = await forum.reply(req.viewer, req.params.space, req.params.slug, { body: draft, attachments: attachments.length ? attachments : undefined });++ const pastes = String((req.body || {}).pastes || '').slice(0, 400) || undefined;++ const out = await forum.reply(req.viewer, req.params.space, req.params.slug, { body: draft, attachments: attachments.length ? attachments : undefined, pastes });res.redirect(303, out.url);} catch (err) {if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);@@@ -174,6 +216,35 @@ function createForumRoutes({ forum, viewers, config }) {} catch (err) { failPage(req, res, err, next); }}));++ // Rate a post (no JS): the rating's button in the post's form; back to the post.++ router.post('/s/:space/t/:slug/react', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ const b = req.body || {};++ try {++ await forum.react(req.viewer, String(b.post || ''), { reaction: String(b.reaction || '') || null });++ const page = Math.max(parseInt(b.page, 10) || 1, 1);++ res.redirect(303, `${back(req)}${page > 1 ? `?page=${page}` : ''}#post-${encodeURIComponent(String(b.post || ''))}`);++ } catch (err) { failPage(req, res, err, next); }++ }));++++ // Crosspost to another space (no JS).++ router.post('/s/:space/t/:slug/crosspost', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ try {++ const out = await forum.crosspost(req.viewer, req.params.space, req.params.slug, { to: String((req.body || {}).to || '') });++ seo.resetCaches();++ res.redirect(303, out.thread.url);++ } catch (err) { failPage(req, res, err, next); }++ }));++++ // Moderators: a space's settings (no JS).++ router.post('/s/:space/settings', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ const b = req.body || {};++ try {++ await forum.updateSpaceSettings(req.viewer, req.params.space, { name: b.name, description: b.description, style: b.style, votes: b.votes === '1', reactions: b.reactions === '1', group: b.group || null });++ seo.resetCaches();++ res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);++ } catch (err) { failPage(req, res, err, next); }++ }));++// Status (moderators) and category (the author or moderators), no JS.router.post('/s/:space/t/:slug/status', withViewer, sameOrigin, form, wrap(async (req, res, next) => {try {@@@ -34,9 +34,12 @@ const { fail, isoTime } = require('../http/v1');const { createAuthors } = require('../identity/authors');const { createPersonLimiter } = require('../limits');const { discussionModerator } = require('../identity/capabilities');--const { renderMarkdown } = require('../render/markdown');++const { renderMarkdown, markdownToText } = require('../render/markdown');const { isUserSubject } = require('../vip');const { stripImageMetadata } = require('../media/strip-metadata');++const reactions = require('./reactions');++const STYLES = ['feed', 'forum'];++const SPACE_SLUG = /^[a-z0-9][a-z0-9-]{1,39}$/;const THREADS_PER_PAGE = 25;/**@@@ -55,6 +58,22 @@ const CATEGORY_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;const ATTACH_MAX = 4;const ATTACH_BYTES = 8 * 1024 * 1024;const MED_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;++// Pastes on posts: a card with the paste's first lines (or its screenshot). Public and unlisted pastes only.++const PASTE_MAX = 4;++const PASTE_SLUG = /^[A-Za-z0-9_-]{3,80}$/;++/** Paste codes from links (…/p/<code>) or bare codes, in a list or one string. → unique codes */++function parsePasteRefs(input) {++ if (input === undefined || input === null || input === '') return [];++ const parts = (Array.isArray(input) ? input : String(input).split(/[\s,]+/)).map((x) => String(x).trim()).filter(Boolean);++ const out = [];++ for (const x of parts) {++ const m = x.match(/\/p\/([A-Za-z0-9_-]{3,80})(?:[/?#]|$)/);++ const slug = m ? m[1] : x;++ if (!PASTE_SLUG.test(slug)) fail(400, 'pastes.invalid', `"${x.slice(0, 60)}" is not a paste link`);++ if (!out.includes(slug)) out.push(slug);++ }++ return out;++}/** The image type from its first bytes (never the name or the declared type), or null. */function sniffImage(b) {if (!Buffer.isBuffer(b) || b.length < 12) return null;@@@ -75,6 +94,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst postLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 6, noun: 'posts', ...(limits.posts || {}) });const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });const uploadLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 12, duplicate: false, noun: 'uploads', ...(limits.uploads || {}) });++ const reactLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'ratings', ...(limits.reactions || {}) });++ // Views: one per viewer (person, or address) per thread per 30 minutes; memory only.++ const viewSeen = new Map();const threadsPerDay = limits.threadsPerDay != null ? limits.threadsPerDay : THREADS_PER_DAY;const hook = (fn) => { try { fn(); } catch (err) { console.warn('[Forum] side effect failed:', err.message); } };@@@ -186,7 +208,10 @@ function createForumService({ db, network = null, pulse = null, relay = null, vicategory: t.category_id ? shapeCategory(store.getCategoryById(db, t.category_id)) : null,members_only: mo ? { owner: mo, owner_username: p && p.username ? p.username : null, join_url: vip ? vip.joinUrl(mo, p && p.username) : null } : null,author: authors.author(t.author_subject, t.origin, projections), origin: t.origin,-- pinned: !!t.pinned, locked: !!t.locked, score: t.score, reply_count: t.reply_count,++ pinned: !!t.pinned, locked: !!t.locked, score: t.score, reply_count: t.reply_count, views: t.views || 0,++ pages: Math.max(Math.ceil(((t.reply_count || 0) + 1) / POSTS_PER_PAGE), 1),++ last_post: t.last_post_id ? { id: t.last_post_id, author: authors.author(t.last_author_subject, t.last_origin, projections || new Map()) } : null,++ crosspost_of: t.crosspost_of || null,last_activity_at: isoTime(t.last_activity_at), created_at: isoTime(t.created_at),my_vote: (votes && votes.get(t.id)) || 0,};@@@ -212,16 +237,48 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (rows.some((r) => !r || r.owner_subject !== w.author || r.post_id != null)) fail(400, 'attachments.invalid', 'Attach images you uploaded for this post');return rows;}++ function claimPastes(refs) {++ const slugs = parsePasteRefs(refs);++ if (slugs.length > PASTE_MAX) fail(400, 'pastes.too_many', `At most ${PASTE_MAX} pastes per post`);++ return slugs.map((slug) => {++ const r = db.prepare('SELECT id, slug, visibility, burn_after_read, deleted_at FROM pastes WHERE slug = ?').get(slug);++ if (!r || r.deleted_at) fail(404, 'pastes.not_found', `No paste ${slug}`);++ if (r.visibility === 'private' || r.burn_after_read) fail(400, 'pastes.not_shareable', `Paste ${slug} is private or burns after reading: it cannot be attached`);++ return r;++ });++ }++ function attachPastes(rows, postId) {++ const ins = db.prepare('INSERT OR IGNORE INTO post_pastes (post_id, paste_id, position) VALUES (?, ?, ?)');++ rows.forEach((r, i) => ins.run(postId, r.id, i));++ }++ /** post id → [paste card], live shareable pastes only (a paste made private or deleted drops out). */++ function pastesOf(postIds) {++ const out = new Map();++ if (!postIds.length) return out;++ const rows = db.prepare(`SELECT pp.post_id, p.slug, p.title, p.language, p.type, p.screenshot_url, p.visibility, p.content++ FROM post_pastes pp JOIN pastes p ON p.id = pp.paste_id++ WHERE pp.post_id IN (${postIds.map(() => '?').join(', ')}) AND p.deleted_at IS NULL AND p.visibility != 'private' AND p.burn_after_read = 0++ ORDER BY pp.post_id, pp.position`).all(...postIds);++ for (const r of rows) {++ const lines = String(r.content || '').split('\n');++ if (!out.has(r.post_id)) out.set(r.post_id, []);++ out.get(r.post_id).push({ slug: r.slug, title: r.title, language: r.language || 'text', type: r.type, url: `/p/${r.slug}`, screenshot_url: r.type === 'screenshot' ? r.screenshot_url : null,++ lines: r.content ? lines.length : 0, excerpt: r.type === 'screenshot' ? null : lines.slice(0, 12).join('\n').slice(0, 1500) });++ }++ return out;++ }++function attach(rows, postId) {const set = db.prepare('UPDATE attachments SET post_id = ?, position = ? WHERE media_id = ? AND post_id IS NULL');rows.forEach((r, i) => set.run(postId, i, r.media_id));}-- function shapePost(p, v, projections, attachments = null) {++ function shapePost(p, v, projections, attachments = null, pastes = null) {const deleted = !!p.deleted_at;const mine = person(v) && p.author_subject === v.subject;return {attachments: deleted ? [] : ((attachments && attachments.get(p.id)) || []),++ pastes: deleted ? [] : ((pastes && pastes.get(p.id)) || []),id: p.id, thread_id: p.thread_id, is_opening: !!p.is_opening, origin: p.origin,author: deleted ? null : authors.author(p.author_subject, p.origin, projections),body_markdown: deleted ? null : p.body_markdown,@@@ -233,7 +290,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi}async function shapeThreads(rows, spaceOf, v) {-- const projections = await authors.projectionsFor([...rows.map((r) => r.author_subject), ...rows.map((r) => r.members_only_owner)]);++ const projections = await authors.projectionsFor([...rows.map((r) => r.author_subject), ...rows.map((r) => r.members_only_owner), ...rows.map((r) => r.last_author_subject)]);const votes = myVotes(db, 'thread', rows.map((r) => r.id), v && v.subject);return rows.map((r) => shapeThread(r, spaceOf(r), v, projections, votes));}@@@ -242,6 +299,10 @@ function createForumService({ db, network = null, pulse = null, relay = null, vireturn {slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: `/s/${s.slug}`,thread_kind: s.thread_kind || 'discussion', statuses: STATUSES[s.thread_kind] || [],++ style: STYLES.includes(s.style) ? s.style : 'feed', votes: s.votes !== 0, reactions: s.reactions !== 0,++ group: s.group_slug ? { slug: s.group_slug, name: s.group_name } : null,++ parent: s.parent_slug ? { slug: s.parent_slug, name: s.parent_name } : null,++ post_count: s.post_count != null ? s.post_count : undefined,members_only: s.members_only_owner ? (mo || { owner: s.members_only_owner, owner_username: null, join_url: vip ? vip.joinUrl(s.members_only_owner) : null }) : null,thread_count: s.thread_count != null ? s.thread_count : undefined,last_activity_at: s.last_activity_at !== undefined ? isoTime(s.last_activity_at) : undefined,@@@ -269,6 +330,59 @@ function createForumService({ db, network = null, pulse = null, relay = null, vireturn { ok: true, id: thread.id, deleted: 'thread' };}++ /**++ * Where a thread came from and where else it was crossposted, as far as the viewer may see.++ * → { from: { space, title, url } | null, to: [{ space, title, url }], targets: [{ slug, name }] (spaces the viewer may crosspost to) }++ */++ function crosspostInfo(v, thread) {++ const visible = (sp) => sp && !sp.members_only_owner && canRead(v, sp) && (sp.visibility !== 'staff' || moderator(v));++ let from = null;++ if (thread.crosspost_of) {++ const t = store.getThread(db, thread.crosspost_of);++ const sp = t ? store.getSpaceById(db, t.space_id) : null;++ if (t && visible(sp) && !t.members_only_owner) from = { space: sp.name, title: t.title, url: `/s/${sp.slug}/t/${t.slug}` };++ }++ const to = db.prepare('SELECT t.slug, t.title, s.slug AS space_slug FROM threads t JOIN spaces s ON s.id = t.space_id WHERE t.crosspost_of = ? AND t.deleted_at IS NULL ORDER BY t.id').all(thread.id)++ .map((r) => ({ r, sp: store.getSpace(db, r.space_slug) })).filter(({ sp }) => visible(sp))++ .map(({ r, sp }) => ({ space: sp.name, title: r.title, url: `/s/${sp.slug}/t/${r.slug}` }));++ const targets = person(v) && !thread.members_only_owner++ ? store.listSpaces(db, moderator(v) ? ['public', 'members', 'staff'] : ['public', 'members']).filter((sp) => sp.id !== thread.space_id && !sp.members_only_owner && (sp.thread_kind !== 'roadmap' || moderator(v)))++ .map((sp) => ({ slug: sp.slug, name: sp.name }))++ : [];++ return { from, to, targets };++ }++++ /** Where a space sits on the board index (breadcrumbs). → { group, parent } */++ function placeOf(space) {++ const g = space.group_id ? db.prepare('SELECT slug, name FROM space_groups WHERE id = ?').get(space.group_id) : null;++ const p = space.parent_id ? db.prepare('SELECT slug, name FROM spaces WHERE id = ?').get(space.parent_id) : null;++ return { group: g || null, parent: p || null };++ }++++ /** Validated space fields from a settings body (only what was sent). */++ 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.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; }++ 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; }++ if (body.group !== undefined) {++ const g = body.group ? db.prepare('SELECT id FROM space_groups WHERE slug = ?').get(String(body.group)) : null;++ if (body.group && !g) fail(404, 'group.not_found', 'No such group on the board index');++ f.group_id = g ? g.id : null;++ }++ if (body.parent !== undefined) {++ const p = body.parent ? 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 (p && p.parent_id) fail(400, 'space.invalid_parent', 'Child boards go one level deep');++ f.parent_id = p ? p.id : null;++ }++ return f;++ }++// ═════════════════════════════════════════════════════════return {SORTS: store.SORTS,@@@ -276,6 +390,15 @@ function createForumService({ db, network = null, pulse = null, relay = null, viPOSTS_PER_PAGE,isModerator: moderator,++ /** The board index's groups (for the settings and new-space forms). */++ groups: () => store.listGroups(db).map((g) => ({ slug: g.slug, name: g.name })),++++ /** A shareable paste's title (the new-topic form's "Discuss in a space"), or null. */++ pasteTitle(slug) {++ const r = db.prepare("SELECT title FROM pastes WHERE slug = ? AND deleted_at IS NULL AND visibility != 'private' AND burn_after_read = 0").get(String(slug));++ return r ? r.title : null;++ },++/** Whether images can be attached (Media and the service principal are configured). */attachmentsEnabled: () => !!(media && media.configured),@@@ -285,8 +408,65 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (person(v) || moderator(v)) vis.push('members');if (moderator(v)) vis.push('staff');const rows = store.listSpaces(db, vis);-- const projections = await authors.projectionsFor(rows.map((r) => r.members_only_owner));-- return { spaces: await Promise.all(rows.map(async (r) => shapeSpace(r, await membersOnly(r.members_only_owner, projections)))) };++ const last = store.lastPosts(db, rows.filter((r) => !r.members_only_owner).map((r) => r.id));++ const projections = await authors.projectionsFor([...rows.map((r) => r.members_only_owner), ...[...last.values()].map((l) => l.author_subject)]);++ const shaped = await Promise.all(rows.map(async (r) => {++ const sp = shapeSpace(r, await membersOnly(r.members_only_owner, projections));++ const l = last.get(r.id);++ sp.last_post = l ? { id: l.post_id, thread: { slug: l.thread_slug, title: l.thread_title, url: `/s/${r.slug}/t/${l.thread_slug}` }, author: authors.author(l.author_subject, l.origin, projections), created_at: isoTime(l.created_at) } : null;++ return sp;++ }));++ // The board index: groups in order, each with its top-level spaces; child boards under their parent.++ const bySlug = new Map(shaped.map((sp) => [sp.slug, { ...sp, children: [] }]));++ for (const sp of bySlug.values()) if (sp.parent && bySlug.has(sp.parent.slug)) bySlug.get(sp.parent.slug).children.push(sp);++ const top = [...bySlug.values()].filter((sp) => !(sp.parent && bySlug.has(sp.parent.slug)));++ const groups = 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 });++ return { spaces: shaped, groups };++ },++++ /** The board index's groups — moderators create or change one { name, description?, position? }. */++ putGroup(v, slug, body = {}) {++ if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage the board index');++ if (!CATEGORY_SLUG.test(String(slug || ''))) fail(400, 'group.invalid_slug', 'A group slug is 1 to 40 lowercase letters, digits and dashes');++ const name = cleanTitle(body.name);++ if (name.length < 2 || name.length > 60) fail(400, 'group.invalid_name', 'Group names are 2 to 60 characters');++ db.prepare(`INSERT INTO space_groups (slug, name, description, position) VALUES (?, ?, ?, ?)++ ON CONFLICT (slug) DO UPDATE SET name = excluded.name, description = excluded.description, position = excluded.position`)++ .run(slug, name, body.description == null ? null : cleanTitle(body.description).slice(0, 200) || null, Number.isInteger(Number(body.position)) ? Number(body.position) : 0);++ return { group: db.prepare('SELECT slug, name, description, position FROM space_groups WHERE slug = ?').get(slug) };++ },++++ /**++ * A space's settings — moderators. { name?, description?, style?: feed|forum, votes?, reactions?, group?: slug|null,++ * parent?: slug|null, position?, kind?: discussion|request|roadmap }++ */++ async updateSpaceSettings(v, spaceSlug, body = {}) {++ if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a space');++ const space = spaceFor(v, spaceSlug);++ const fields = spaceFields(body, space);++ const next = store.updateSpace(db, space.id, fields);++ const row = store.listSpaces(db, ['public', 'members', 'staff']).find((r) => r.id === next.id) || next;++ return { space: shapeSpace(row, await membersOnly(row.members_only_owner)) };++ },++++ /** 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');++ 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 (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 = spaceFields({ ...body, name: body.name }, null);++ if (!fields.name) fail(400, 'space.invalid_name', 'Name the space');++ const style = fields.style || 'feed';++ 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,++ parent_id: fields.parent_id || null, position: fields.position || 0, thread_kind: fields.thread_kind || 'discussion' });++ const row = store.listSpaces(db, ['public', 'members', 'staff']).find((r) => r.slug === slug);++ return { space: shapeSpace(row) };},async space(v, slug) {@@@ -298,7 +478,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, viasync listThreads(v, spaceSlug, q = {}) {const space = spaceFor(v, spaceSlug);await requireMembership(v, space);-- const sort = store.SORTS.includes(q.sort) ? q.sort : 'hot';++ const forumStyle = space.style === 'forum';++ let sort = store.SORTS.includes(q.sort) ? q.sort : (forumStyle ? 'active' : 'hot');++ if (sort === 'top' && space.votes === 0) sort = forumStyle ? 'active' : 'hot';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 ? store.getCategory(db, space.id, q.category) : null;@@@ -306,10 +488,15 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst 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');const { rows, total } = 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) || {};return {-- space: shapeSpace(space, await membersOnly(space.members_only_owner)), sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),++ space: { ...shapeSpace(space, await membersOnly(space.members_only_owner)), group: self.group || null, parent: self.parent || null },++ sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),categories: store.listCategories(db, space.id).map(shapeCategory), category: category ? category.slug : null, status: q.status || null,-- viewer: { can_start: space.thread_kind !== 'roadmap' || moderator(v), can_moderate: moderator(v) },++ viewer: { can_start: space.thread_kind !== 'roadmap' || moderator(v), can_moderate: moderator(v), signed_in: person(v) },++ children: index.spaces.filter((c) => c.parent && c.parent.slug === space.slug),++ groups: moderator(v) ? store.listGroups(db).map((g) => ({ slug: g.slug, name: g.name })) : undefined,threads: await shapeThreads(rows, () => space, v),};},@@@ -343,6 +530,78 @@ function createForumService({ db, network = null, pulse = null, relay = null, vireturn { attachment: shapeAttachment({ media_id: stored.id, url: stored.url, filename, mime, size_bytes: buffer.length }) };},++ /**++ * Rate a post { reaction: agree|winner|funny|informative|friendly|sympathy|dumb|disgusting|bad_reading|late | null }.++ * One rating per person per post: the same one again takes it back, another replaces it. Not your own post,++ * not in a space with ratings off, not on a locked thread. → { post_id, mine, reactions }++ */++ async react(v, postId, body = {}) {++ if (!person(v)) fail(401, 'auth.required', 'Sign in to rate posts');++ const { post, thread, space } = postFor(v, postId);++ if (space.reactions === 0) fail(403, 'space.reactions_off', 'Ratings are off in this space');++ await requireMembership(v, space, thread);++ if (thread.locked && !moderator(v)) 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');++ const reaction = body.reaction == null || body.reaction === '' ? null : String(body.reaction);++ if (reaction && !reactions.BY_KEY.has(reaction)) fail(400, 'reaction.invalid', `reaction is one of ${reactions.REACTIONS.map((r) => r.key).join(', ')}`);++ reactLimiter.check(`s:${v.subject}`);++ const mine = reactions.setReaction(db, post.id, v.subject, reaction);++ reactLimiter.record(`s:${v.subject}`);++ const list = reactions.reactionsFor(db, [post.id], v.subject).get(post.id) || [];++ return { post_id: post.id, mine, reactions: list.map((e) => ({ key: e.key, emoji: e.emoji, label: e.label, count: e.count, mine: e.mine })) };++ },++++ /**++ * Crosspost a thread to another space { to: slug } — people. The new thread links back to the original and++ * quotes its opening; both show the link. Not members-only threads; not into roadmap spaces (staff excepted).++ */++ async crosspost(v, spaceSlug, threadSlug, body = {}) {++ const { space, thread } = threadFor(v, spaceSlug, threadSlug);++ const w = writer(v);++ if (!w.author) fail(403, 'crosspost.person_only', 'Only people crosspost');++ 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 = spaceFor(v, String(body.to || ''));++ if (target.id === space.id) fail(400, 'crosspost.same_space', 'Pick another space');++ mayPostIn(v, target);++ await requireMembership(v, target);++ const kind = target.thread_kind || 'discussion';++ if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff');++ if (store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay && threadsPerDay > 0) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);++ const opening = db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id);++ const excerpt = markdownToText(opening ? opening.body_markdown : '', 400).split('\n').map((l) => `> ${l}`).join('\n');++ const text = `Crossposted from **${space.name}**: [${thread.title.replace(/[[\]]/g, '')}](/s/${space.slug}/t/${thread.slug})${excerpt.trim() !== '>' ? `\n\n${excerpt}` : ''}`;++ const { thread: created } = store.createThread(db, { space_id: target.id, title: thread.title, author_subject: w.author, origin: 'user', body_markdown: text,++ kind, status: FIRST_STATUS[kind] || null, crosspost_of: thread.id });++ if (pulse) hook(() => pulse.threadCreated(created, target));++ if (relay) hook(() => relay.enqueueThread(created, target));++ const projections = await authors.projectionsFor([created.author_subject]);++ return { thread: shapeThread(created, target, v, projections, null) };++ },++++ /** Quote a post into a reply: "**name** wrote:" and the post as a Markdown quote. → { markdown } */++ async quote(v, postId) {++ const { post, thread, space } = postFor(v, postId);++ await requireMembership(v, space, thread);++ const projections = await authors.projectionsFor([post.author_subject]);++ const a = authors.author(post.author_subject, post.origin, projections);++ const name = a ? (a.username ? `@${a.username}` : a.display_name || 'Someone') : 'Someone';++ const quoted = String(post.body_markdown || '').split('\n').slice(0, 40).map((l) => `> ${l}`).join('\n');++ return { markdown: `**${name}** wrote:\n${quoted}\n\n` };++ },++++ /** A page view of a thread (the forum's Views): once per viewer per 30 minutes. */++ recordView(threadId, viewerKey) {++ const key = `${threadId}|${viewerKey || '?'}`;++ const now = Date.now();++ const seen = viewSeen.get(key);++ if (seen && now - seen < 30 * 60 * 1000) return false;++ if (viewSeen.size > 50000) viewSeen.clear();++ viewSeen.set(key, now);++ store.bumpViews(db, threadId);++ return true;++ },++/** The categories of a space. */categories(v, spaceSlug) {const space = spaceFor(v, spaceSlug);@@@ -400,17 +659,35 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst { rows, total } = store.listPosts(db, thread.id, { limit: POSTS_PER_PAGE, offset: (page - 1) * POSTS_PER_PAGE });const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner, space.members_only_owner, ...rows.map((p) => p.author_subject)]);const votes = myVotes(db, 'thread', [thread.id], v && v.subject);++ const ids = rows.map((p) => p.id);++ const att = attachmentsOf(ids);++ const pasted = pastesOf(ids);++ const rated = space.reactions !== 0 ? reactions.reactionsFor(db, ids, person(v) ? v.subject : null) : new Map();++ const authorSubjects = rows.map((p) => p.author_subject);++ const stats = store.authorStats(db, authorSubjects);++ const received = space.reactions !== 0 ? reactions.receivedBy(db, authorSubjects) : new Map();++ const raterProjections = await authors.projectionsFor([...rated.values()].flatMap((l) => l.flatMap((e) => e.raters)));return {-- space: shapeSpace(space, await membersOnly(space.members_only_owner, projections)),++ space: { ...shapeSpace(space, await membersOnly(space.members_only_owner, projections)), ...placeOf(space) },thread: shapeThread(thread, space, v, projections, votes),-- posts: (() => { const att = attachmentsOf(rows.map((p) => p.id)); return rows.map((p) => shapePost(p, v, projections, att)); })(),++ posts: rows.map((p) => {++ const sp = shapePost(p, v, projections, att, pasted);++ const st = p.author_subject ? stats.get(p.author_subject) : null;++ sp.author_stats = st ? { posts: st.posts, first_post_at: isoTime(st.first_post_at), ratings: received.get(p.author_subject) || [] } : null;++ sp.reactions = p.deleted_at ? [] : (rated.get(p.id) || []).map((e) => ({ key: e.key, emoji: e.emoji, label: e.label, group: e.group, count: e.count, mine: e.mine,++ raters: e.raters.map((sub) => { const pr = raterProjections.get(sub); return pr ? (pr.display_name || pr.username) : null; }).filter(Boolean) }));++ sp.can_react = space.reactions !== 0 && !p.deleted_at && person(v) && p.author_subject !== v.subject && (!thread.locked || moderator(v));++ return sp;++ }),++ reactions: space.reactions !== 0 ? reactions.REACTIONS : [],++ crosspost: crosspostInfo(v, thread),categories: store.listCategories(db, space.id).map(shapeCategory),attachments: { enabled: !!(media && media.configured), max: ATTACH_MAX, max_bytes: ATTACH_BYTES },page, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,viewer: {signed_in: person(v),can_reply: (person(v) || (v && v.origin === 'ai' && v.kind === 'service')) && (!thread.locked || moderator(v)) && (space.visibility !== 'staff' || moderator(v)),-- can_vote: person(v) && !thread.locked,++ can_vote: person(v) && !thread.locked && space.votes !== 0,can_moderate: moderator(v),can_delete: moderator(v) || (person(v) && thread.author_subject === v.subject),can_gate: moderator(v) || (person(v) && thread.author_subject === v.subject),@@@ -436,6 +713,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (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 images = claimable(w, body.attachments);++ const pasteRows = claimPastes(body.pastes);if (w.key) {threadLimiter.check(w.key, title);if (threadsPerDay > 0 && store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);@@@ -444,10 +722,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vikind, status: FIRST_STATUS[kind] || null, category_id: category ? category.id : null });threadLimiter.record(w.key, title);attach(images, post.id);++ attachPastes(pasteRows, post.id);if (pulse) hook(() => pulse.threadCreated(thread, space));if (relay) hook(() => relay.enqueueThread(thread, space));const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner]);-- return { thread: shapeThread(thread, space, v, projections, null), post: shapePost(post, v, projections, attachmentsOf([post.id])) };++ return { thread: shapeThread(thread, space, v, projections, null), post: shapePost(post, v, projections, attachmentsOf([post.id]), pastesOf([post.id])) };},/** Reply { body } → { post } */@@@ -459,16 +738,18 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');const text = cleanBody(body.body != null ? body.body : body.body_markdown);const images = claimable(w, body.attachments);++ const pasteRows = claimPastes(body.pastes);postLimiter.check(w.key, text);const post = store.addPost(db, { thread_id: thread.id, author_subject: w.author, origin: w.origin, body_markdown: text });postLimiter.record(w.key, text);attach(images, post.id);++ attachPastes(pasteRows, post.id);if (pulse) hook(() => pulse.postCreated(post, thread, space));const projections = await authors.projectionsFor([post.author_subject]);// Where the new post lands: its page in the thread (posts are numbered in id order).const position = db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id).c;const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);-- return { post: shapePost(post, v, projections, attachmentsOf([post.id])), page, url: `${threadUrl(space, thread)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}` };++ return { post: shapePost(post, v, projections, attachmentsOf([post.id]), pastesOf([post.id])), page, url: `${threadUrl(space, thread)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}` };},/** Edit { body } — the author (not on a locked thread) or a moderator. */@@@ -517,6 +798,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 } = threadFor(v, spaceSlug, threadSlug);++ if (space.votes === 0) fail(403, 'space.votes_off', 'Votes are off in this space');await requireMembership(v, space, thread);if (thread.locked) fail(403, 'thread.locked', 'This thread is locked');voteLimiter.check(`s:${v.subject}`);@@@ -584,4 +866,4 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi};}--module.exports = { createForumService, THREADS_PER_PAGE, POSTS_PER_PAGE, STATUSES, ATTACH_MAX, sniffImage };++module.exports = { createForumService, THREADS_PER_PAGE, POSTS_PER_PAGE, STATUSES, ATTACH_MAX, sniffImage, STYLES, REACTIONS: reactions.REACTIONS };@@@ -9,16 +9,67 @@* after it keep their numbering; a deleted thread disappears from every listing.*/--const SORTS = ['hot', 'new', 'top'];++const SORTS = ['hot', 'new', 'top', 'active'];// ── Spaces ───────────────────────────────────────────────────function listSpaces(db, visibilities) {-- return db.prepare(`SELECT s.*,++ return db.prepare(`SELECT s.*, g.slug AS group_slug, g.name AS group_name, g.description AS group_description, g.position AS group_position,++ ps.slug AS parent_slug, ps.name AS parent_name,(SELECT COUNT(*) FROM threads t WHERE t.space_id = s.id AND t.deleted_at IS NULL) AS thread_count,++ (SELECT COUNT(*) FROM posts p JOIN threads t ON t.id = p.thread_id WHERE t.space_id = s.id AND t.deleted_at IS NULL AND p.deleted_at IS NULL) AS post_count,(SELECT MAX(t.last_activity_at) FROM threads t WHERE t.space_id = s.id AND t.deleted_at IS NULL) AS last_activity_at-- FROM spaces s WHERE s.visibility IN (${visibilities.map(() => '?').join(', ')})-- ORDER BY s.id ASC`).all(...visibilities);++ FROM spaces s LEFT JOIN space_groups g ON g.id = s.group_id LEFT JOIN spaces ps ON ps.id = s.parent_id++ WHERE s.visibility IN (${visibilities.map(() => '?').join(', ')})++ ORDER BY g.position IS NULL, g.position, s.position, s.id ASC`).all(...visibilities);++}++++/**++ * The newest live post in each space (the board index's "Last post"), members-only threads never.++ * → Map spaceId → { thread_slug, thread_title, author_subject, origin, created_at }++ */++function lastPosts(db, spaceIds) {++ const out = new Map();++ const q = db.prepare(`SELECT t.slug AS thread_slug, t.title AS thread_title, p.author_subject, p.origin, p.created_at, p.id AS post_id++ FROM posts p JOIN threads t ON t.id = p.thread_id++ WHERE t.space_id = ? AND t.deleted_at IS NULL AND p.deleted_at IS NULL AND t.members_only_owner IS NULL++ ORDER BY p.id DESC LIMIT 1`);++ for (const id of spaceIds) { const r = q.get(id); if (r) out.set(id, r); }++ return out;++}++++function listGroups(db) {++ return db.prepare('SELECT * FROM space_groups ORDER BY position, name COLLATE NOCASE').all();++}++++/** Space settings (moderators): style, votes, reactions, group, parent, position, name, description. */++function updateSpace(db, id, fields) {++ const allowed = ['style', 'votes', 'reactions', 'group_id', 'parent_id', 'position', 'name', 'description', 'thread_kind'];++ const keys = Object.keys(fields).filter((k) => allowed.includes(k));++ if (keys.length) db.prepare(`UPDATE spaces SET ${keys.map((k) => `${k} = @${k}`).join(', ')} WHERE id = @id`).run({ ...Object.fromEntries(keys.map((k) => [k, fields[k]])), id });++ return getSpaceById(db, id);++}++++function createSpace(db, { slug, name, description = null, visibility = 'public', created_by, style = 'feed', votes = 1, reactions = 1, group_id = null, parent_id = null, position = 0, thread_kind = 'discussion' }) {++ db.prepare(`INSERT INTO spaces (slug, name, description, visibility, created_by, style, votes, reactions, group_id, parent_id, position, thread_kind)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(slug, name, description, visibility, created_by, style, votes, reactions, group_id, parent_id, position, thread_kind);++ return getSpace(db, slug);++}++++/** A person's forum statistics for the author panel. → Map subject → { posts, first_post_at } */++function authorStats(db, subjects) {++ const out = new Map();++ const list = [...new Set(subjects.filter(Boolean))];++ if (!list.length) return out;++ const rows = db.prepare(`SELECT author_subject AS s, COUNT(*) AS posts, MIN(created_at) AS first_post_at FROM posts++ WHERE author_subject IN (${list.map(() => '?').join(', ')}) AND deleted_at IS NULL GROUP BY author_subject`).all(...list);++ for (const r of rows) out.set(r.s, { posts: r.posts, first_post_at: r.first_post_at });++ return out;++}++++/** One more view of a thread (the forum's Views column). */++function bumpViews(db, threadId) {++ db.prepare('UPDATE threads SET views = views + 1 WHERE id = ?').run(threadId);}function getSpace(db, slug) {@@@ -57,11 +108,11 @@ function getThreadBySlug(db, spaceId, slug) {}/** New thread + its opening post, in one transaction. → { thread, post } */--function createThread(db, { space_id, title, author_subject = null, origin = 'user', body_markdown, members_only_owner = null, kind = 'discussion', status = null, category_id = null, external_key = null }) {++function createThread(db, { space_id, title, author_subject = null, origin = 'user', body_markdown, members_only_owner = null, kind = 'discussion', status = null, category_id = null, external_key = null, crosspost_of = null }) {return db.transaction(() => {const slug = uniqueSlug(db, space_id, slugify(title));-- const info = db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')-- .run(space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key);++ const info = db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')++ .run(space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of);const threadId = info.lastInsertRowid;const p = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?)').run(threadId, author_subject, origin, body_markdown);@@@ -85,10 +136,16 @@ function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now =hot: 'pinned DESC, ov_hot(score, (julianday(@now) - julianday(created_at)) * 24) DESC, last_activity_at DESC, id DESC',new: 'pinned DESC, created_at DESC, id DESC',top: 'pinned DESC, score DESC, created_at DESC, id DESC',++ active: 'pinned DESC, last_activity_at DESC, id DESC',}[SORTS.includes(sort) ? sort : 'hot'];const where = 'space_id = @space AND deleted_at IS NULL AND (@category IS NULL OR category_id = @category) AND (@status IS NULL OR status = @status)';const params = { space: spaceId, category: categoryId, status };-- const rows = db.prepare(`SELECT * FROM threads WHERE ${where} ORDER BY ${order} LIMIT @limit OFFSET @offset`)++ // last_* : the newest live post, for the forum's "Last post" column.++ const rows = db.prepare(`SELECT threads.*,++ (SELECT p.author_subject FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_author_subject,++ (SELECT p.origin FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_origin,++ (SELECT p.id FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_post_id++ FROM threads WHERE ${where} ORDER BY ${order} LIMIT @limit OFFSET @offset`).all({ ...params, now: nowSql, limit, offset });const { total } = db.prepare(`SELECT COUNT(*) AS total FROM threads WHERE ${where}`).get(params);return { rows, total };@@@ -246,5 +303,6 @@ module.exports = {listSpaces, getSpace, getSpaceById,getThread, getThreadBySlug, createThread, listThreads, recentThreads, setThreadFlags, setThreadMembersOnly, setSpaceMembersOnly, softDeleteThread, countThreadsSince,listCategories, getCategory, getCategoryById, upsertCategory, deleteCategory, setThreadCategory, setThreadStatus, getThreadByKey,++ lastPosts, listGroups, updateSpace, createSpace, authorStats, bumpViews,getPost, addPost, listPosts, editPost, listPostVersions, softDeletePost, countPostsSince,};nnew file mode 100644@@@ -0,0 +1,279 @@++'use strict';++++/**++ * Forum-style pages, after vBulletin and SMF: the board index at /s (every space, grouped, with topics,++ * posts and the last post), a forum-style space as a topic table (Replies, Views, Last post; sticky topics++ * first; bumped by the newest post) and a topic as posts with the author's panel beside each (picture,++ * posts, since when, the ratings they get most), with Quote. Feed-style spaces keep render/forum.js++ * (subreddit-like cards, votes, hot/new/top); both share threads, posts, ratings, images and pastes.++ * Complete without JavaScript: every action is a link or a form post.++ */++const seo = require('../seo');++const { renderPage } = require('./layout');++const { escapeHtml: esc } = require('./highlight');++const { markdownToText } = require('./markdown');++const { timeTag, fmtDate, num, avatarUrl } = require('./pages');++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 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. */++function boardRow(sp) {++ const icon = sp.style === 'forum' ? 'fa-comments' : 'fa-fire';++ const last = sp.last_post++ ? `<a href="${esc(sp.last_post.thread.url)}#post-${Number(sp.last_post.id)}">${esc(seo.clean(sp.last_post.thread.title, 48))}</a><span class="small muted">by ${esc(sp.last_post.author ? (sp.last_post.author.display_name || sp.last_post.author.username || 'someone') : 'someone')} · ${timeTag(sp.last_post.created_at)}</span>`++ : '<span class="muted small">No posts yet</span>';++ const children = (sp.children || []).length ? `<p class="small board-children">Child boards: ${sp.children.map((c) => `<a href="/s/${esc(c.slug)}">${esc(c.name)}</a>`).join(', ')}</p>` : '';++ return `<tr>++ <td class="board-name"><span class="board-icon" aria-hidden="true"><i class="fa-solid ${icon}"></i></span><div>++ <a class="board-title" href="/s/${esc(sp.slug)}">${esc(sp.name)}</a> <span class="badge badge-style">${STYLE_LABELS[sp.style] || 'Feed'}</span>${f.visBadge(sp)}++ ${sp.description ? `<p class="muted small">${esc(sp.description)}</p>` : ''}${children}++ </div></td>++ <td class="num" data-label="Topics">${num(sp.thread_count)}</td>++ <td class="num" data-label="Posts">${num(sp.post_count)}</td>++ <td class="board-last" data-label="Last post">${last}</td>++ </tr>`;++}++++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>++ <tbody>${spaces.map(boardRow).join('')}</tbody></table>`;++}++++// ── /s ───────────────────────────────────────────────────────++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>' : ''}++</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>'}++${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.',++ canonicalPath: '/s',++ active: 'spaces',++ feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],++ jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }])],++ body,++ });++}++++/** A topic's page links (1 2 3 … last) for the topic table. */++function topicPages(base, pages) {++ if (pages <= 1) return '';++ const n = pages <= 5 ? Array.from({ length: pages }, (_, i) => i + 1) : [1, 2, 3, null, pages];++ return ` <span class="topic-pages">Pages: ${n.map((p) => (p ? `<a href="${esc(base)}${p > 1 ? `?page=${p}` : ''}">${p}</a>` : '…')).join(' ')}</span>`;++}++++function topicRow(t, space) {++ const base = `/s/${space.slug}/t/${t.slug}`;++ const icon = t.locked ? 'fa-lock' : t.pinned ? 'fa-thumbtack' : t.reply_count >= 15 ? 'fa-fire-flame-curved' : 'fa-comment';++ const label = t.locked ? 'Locked' : t.pinned ? 'Sticky' : t.reply_count >= 15 ? 'Hot topic' : 'Topic';++ const who = (a) => esc(a ? (a.display_name || a.username || 'someone') : 'someone');++ return `<tr class="${t.pinned ? 'sticky' : ''}">++ <td class="topic-icon" aria-hidden="true"><i class="fa-solid ${icon}" title="${label}"></i></td>++ <td class="topic-main">${t.pinned ? '<span class="badge badge-sticky">Sticky</span> ' : ''}<a class="topic-title" href="${esc(base)}">${esc(t.title)}</a>${f.statusBadge(t)}${f.categoryBadge(t, space)}${f.vipBadge(t)}++ <span class="small muted topic-by">Started by ${who(t.author)} · ${timeTag(t.created_at)}${topicPages(base, t.pages || 1)}</span></td>++ <td class="num" data-label="Replies">${num(t.reply_count)}</td>++ <td class="num" data-label="Views">${num(t.views)}</td>++ <td class="board-last" data-label="Last post">${timeTag(t.last_activity_at)}<span class="small muted">by ${who(t.last_post ? t.last_post.author : t.author)}${t.last_post ? ` <a href="${esc(base)}${t.pages > 1 ? `?page=${t.pages}` : ''}#post-${Number(t.last_post.id)}" aria-label="Go to the last post">»</a>` : ''}</span></td>++ </tr>`;++}++++/** 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>++<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="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>++ <div class="form-actions"><button class="btn btn-primary" type="submit">Save settings</button></div>++</form></details>`;++}++++// ── /s/:space (forum style) ──────────────────────────────────++function forumSpacePage({ space, threads, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, children = [], groups = [] }) {++ const filtered = !!(category || status);++ const indexable = space.visibility === 'public' && !space.members_only && !filtered;++ const href = (o = {}) => f.spaceHref(space.slug, { sort: 'active', category, status, ...o }).replace(/[?&]sort=active/, '').replace(/\?&/, '?').replace(/\?$/, '');++ const chip = (label, h, on) => `<a class="chip${on ? ' active' : ''}" href="${esc(h)}"${on ? ' aria-current="true"' : ''}>${label}</a>`;++ const tags = categories.length ? `<nav class="chips" aria-label="Categories">${chip('All', href({ category: null }), !category)}${categories.map((c) => chip(esc(c.name), href({ category: c.slug }), c.slug === category)).join('')}</nav>` : '';++ const statuses = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', href({ status: null }), !status)}${space.statuses.map((st) => chip(esc(f.STATUS_LABELS[st] || st), href({ status: st }), st === status)).join('')}</nav>` : '';++ const start = viewer.can_start !== false++ ? `<a class="btn btn-primary" href="/s/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${space.thread_kind === 'request' ? 'New request' : 'New topic'}</a>`++ : '<span class="muted small">Staff add roadmap items; reply to one to discuss it.</span>';++ const pagerHtml = f.pager((n) => href({ page: n }), page, pages, ['Previous', 'Next']);++ const body = `++<header class="page-head">++ ${crumbs([...trail(space), [space.name, `/s/${space.slug}`]])}++ <h1>${esc(space.name)}${f.visBadge(space)}</h1>++ ${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}++</header>++${children.length ? `<section class="board-group" aria-label="Child boards">${boardTable(children, 'Child boards')}</section>` : ''}++<div class="board-toolbar">${start}${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</div>++${tags}${statuses}++<section data-results>++${threads.length ? `<table class="board-table topic-table"><caption class="sr-only">Topics in ${esc(space.name)}</caption>++ <thead><tr><th scope="col" class="topic-icon"><span class="sr-only">Kind</span></th><th scope="col">Topic</th><th scope="col" class="num">Replies</th><th scope="col" class="num">Views</th><th scope="col">Last post</th></tr></thead>++ <tbody>${threads.map((t) => topicRow(t, space)).join('')}</tbody></table>` : `<p class="empty">${filtered ? `Nothing here yet. <a href="${esc(href({ category: null, status: null }))}">See every topic</a>.` : viewer.can_start !== false ? `No topics yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.` : 'Nothing here yet.'}</p>`}++${pagerHtml}++</section>++${viewer.can_moderate ? settingsForm(space, groups) : ''}`;++ return renderPage({++ title: `${space.name}${page > 1 ? ` (page ${page})` : ''}`,++ description: `${space.description || `Topics in ${space.name}`} ${plural(total, 'topic', 'topics')} on OpenVibe.Community.`,++ canonicalPath: href({ page }),++ 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: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],++ body,++ });++}++++/** The author's panel beside a post: picture, name, posts, since when, the ratings they get most. */++function authorPanel(p) {++ const a = p.author;++ if (!a) return '<aside class="postbit-author"><span class="postbit-name">Anonymous</span></aside>';++ const pic = avatarUrl(a, 96);++ const name = a.display_name || a.username || (a.is_ai ? 'AI' : a.is_system ? 'OpenVibe' : 'Someone');++ const initial = esc(String(name).trim()[0] || '?').toUpperCase();++ const st = p.author_stats;++ return `<aside class="postbit-author">++ ${pic ? `<img class="postbit-avatar" src="${esc(pic)}" alt="" width="64" height="64" loading="lazy">` : `<span class="postbit-avatar postbit-letter" aria-hidden="true">${initial}</span>`}++ <span class="postbit-name">${a.username ? `<a href="https://openvibe.live/@${encodeURIComponent(a.username)}" rel="noopener">${esc(name)}</a>` : esc(name)}</span>++ ${a.is_ai ? '<span class="badge badge-ai" title="Written by AI, not by a person">AI</span>' : a.is_system ? '<span class="badge">OpenVibe</span>' : ''}++ ${st ? `<dl class="postbit-stats"><div><dt>Posts</dt><dd>${num(st.posts)}</dd></div>${monthYear(st.first_post_at) ? `<div><dt>Since</dt><dd>${esc(monthYear(st.first_post_at))}</dd></div>` : ''}</dl>++ ${(st.ratings || []).length ? `<p class="postbit-ratings" aria-label="Ratings received">${st.ratings.map((r) => `<span title="${esc(r.label)}">${r.emoji} ${num(r.count)}</span>`).join(' ')}</p>` : ''}` : ''}++ </aside>`;++}++++function postbit(p, n, { base, page, catalogue, canReply }) {++ if (p.deleted) return `<article class="postbit post-deleted" id="post-${p.id}"><div class="postbit-main"><p class="muted small">#${n} · This post was deleted.</p></div></article>`;++ const quote = canReply ? ` <a class="small" href="${esc(base)}?quote=${Number(p.id)}#reply"><i class="fa-solid fa-quote-left" aria-hidden="true"></i> Quote</a>` : '';++ return `<article class="postbit${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">++ ${authorPanel(p)}++ <div class="postbit-main">++ <header class="postbit-head small muted"><a href="#post-${p.id}">${p.is_opening ? 'Topic' : `Reply #${n - 1}`}</a> · ${esc(fmtDate(p.created_at))}${p.revision > 1 ? ` · <span title="Edited ${esc(fmtDate(p.updated_at))}">edited</span>` : ''}${quote}</header>++ <div class="md">${p.body_html}</div>++ ${f.postImages(p)}++ ${f.pasteCards(p)}++ ${f.reactionBar(p, base, page, catalogue)}++ </div>++ </article>`;++}++++// ── /s/:space/t/:slug (forum style) ──────────────────────────++function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [], attachmentsEnabled = false, reactions = [], crosspost = null }) {++ const base = `/s/${space.slug}/t/${thread.slug}`;++ const opening = posts.find((p) => p.is_opening) || null;++ const gated = !!(space.members_only || thread.members_only);++ const indexable = space.visibility === 'public' && !gated;++ const first = (page - 1) * perPage;++ const replyBlock = viewer.can_reply++ ? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply"${attachmentsEnabled ? ' enctype="multipart/form-data"' : ''}>++ <h2 class="h3">Reply</h2>++ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}++ <label class="field"><span class="sr-only">Your reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>++ ${f.imageField(attachmentsEnabled)}++ ${f.pasteField()}++ <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-reply" aria-hidden="true"></i> Post reply</button><span class="muted small">**bold**, *italic*, \`code\`, > quotes, [links](https://…)</span></div>++ </form>`++ : thread.locked ? '<p class="alert">This topic is locked.</p>'++ : !user ? `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to reply.</p>` : '';++ const pagerHtml = f.pager((n) => `${base}${n > 1 ? `?page=${n}` : ''}`, page, pages, ['Previous', 'Next']);++ const body = `++<article class="thread forum-topic">++ ${crumbs([...trail(space), [space.name, `/s/${space.slug}`], [seo.clean(thread.title, 60), base]])}++ <header class="thread-head">++ ${space.votes === false ? '' : f.voteForm(base, thread, viewer)}++ <div>++ <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Sticky" aria-label="Sticky"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${f.vipBadge(thread)}</h1>++ ${thread.status || thread.category ? `<p class="thread-tags">${f.statusBadge(thread)}${f.categoryBadge(thread, space)}</p>` : ''}++ <p class="small muted">${plural(thread.reply_count, 'reply', 'replies')} · ${plural(thread.views, 'view', 'views')}</p>++ </div>++ </header>++ ${f.crosspostBlock(crosspost, base)}++ ${f.modActions({ base, space, thread, viewer, categories })}++ ${pagerHtml}++ <div class="postbits">${posts.map((p, i) => postbit(p, first + i + 1, { base, page, catalogue: reactions, canReply: viewer.can_reply })).join('\n')}</div>++ ${pagerHtml}++ ${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}++</article>`;++ const description = gated ? `A topic for VIP members in ${space.name} on OpenVibe.Community.` : (seo.clean(opening ? markdownToText(opening.body_markdown, 200) : thread.title, 200) || thread.title);++ return renderPage({++ title: `${thread.title}${page > 1 ? ` (page ${page})` : ''}`,++ description,++ canonicalPath: `${base}${page > 1 ? `?page=${page}` : ''}`,++ robots: indexable ? 'index,follow' : 'noindex,nofollow',++ ogType: 'article',++ published: thread.created_at || undefined,++ modified: (opening && opening.updated_at) || undefined,++ active: 'spaces',++ historyType: 'page',++ historyTitle: thread.title,++ footerVariant: 'compact',++ feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],++ jsonLd: [++ ...(gated ? [] : [seo.threadLd({ space, thread, posts, opening, description })]),++ seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),++ ],++ body,++ });++}++++/** Moderators: a new space (either style), no JS. */++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>++</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="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>++</form>`;++ return renderPage({ title: 'New space', 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. */++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>++ ${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 });++}++++module.exports = { boardIndexPage, forumSpacePage, forumTopicPage, newSpacePage, settingsForm, discussPage };@@@ -80,8 +80,8 @@ ${list}`;// ── /s/:space ────────────────────────────────────────────────function threadRow(t, space) {const flags = `${t.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${t.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}`;-- return `<li class="thread-row">-- <span class="thread-score" title="Score">${num(t.score)}</span>++ return `<li class="thread-row${space.votes === false ? ' no-votes' : ''}">++ ${space.votes === false ? '' : `<span class="thread-score" title="Score">${num(t.score)}</span>`}<div class="thread-main"><a class="thread-title" href="/s/${esc(space.slug)}/t/${esc(t.slug)}">${flags}${esc(t.title)}</a>${statusBadge(t)}${categoryBadge(t, space)}${vipBadge(t)}<p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p>@@@ -89,8 +89,8 @@ function threadRow(t, space) {</li>`;}--function spacePage({ space, threads, sort, page, pages, total, user, categories = [], category = null, status = null, viewer = {} }) {-- const tabs = Object.keys(SORT_LABELS).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s, category, status }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');++function spacePage({ space, threads, sort, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, footer = '' }) {++ const tabs = Object.keys(SORT_LABELS).filter((s) => s !== 'top' || space.votes !== false).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s, category, status }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');const filtered = !!(category || status);const indexable = space.visibility === 'public' && !space.members_only && !filtered;const chip = (label, href, on) => `<a class="chip${on ? ' active' : ''}" href="${esc(href)}"${on ? ' aria-current="true"' : ''}>${label}</a>`;@@@ -113,7 +113,8 @@ ${categoryNav}${statusNav}<section data-results>${threads.length ? `<ol class="thread-list">${threads.map((t) => threadRow(t, space)).join('')}</ol>` : filtered ? `<p class="empty">Nothing here yet. <a href="${esc(spaceHref(space.slug))}">See everything in ${esc(space.name)}</a>.</p>` : canStart ? `<p class="empty">No threads yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.</p>` : '<p class="empty">Nothing here yet.</p>'}${pager((n) => spaceHref(space.slug, { sort, page: n, category, status }), page, pages)}--</section>`;++</section>++${footer}`;return renderPage({title: `${space.name}${categoryName ? ` · ${categoryName}` : ''}${status ? ` · ${STATUS_LABELS[status] || status}` : ''} — ${SORT_LABELS[sort].toLowerCase()} threads${page > 1 ? ` (page ${page})` : ''}`,description: `${space.description || `Threads in ${space.name}`} ${num(total)} ${total === 1 ? 'thread' : 'threads'} on OpenVibe.Community.`,@@@ -126,15 +127,79 @@ ${categoryNav}${statusNav}});}++/** The pin / lock / delete / members-only / status / category buttons (no JS). */++function modActions({ base, space, thread, viewer, categories = [] }) {++ return viewer.can_moderate || viewer.can_delete ? `<div class="actions mod-actions">++ ${viewer.can_moderate ? `<form method="post" action="${esc(base)}/state"><input type="hidden" name="pinned" value="${thread.pinned ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> ${thread.pinned ? 'Unpin' : 'Pin'}</button></form>++ <form method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}++ ${viewer.can_delete ? `<form method="post" action="${esc(base)}/delete"><button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}++ ${viewer.can_gate ? `<form method="post" action="${esc(base)}/members-only"><input type="hidden" name="on" value="${thread.members_only ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-star" aria-hidden="true"></i> ${thread.members_only ? 'Open to everyone' : 'VIP members only'}</button></form>` : ''}++ ${viewer.can_moderate && (space.statuses || []).length && thread.status ? `<form class="inline-form" method="post" action="${esc(base)}/status"><label class="sr-only" for="thread-status">Status</label><select id="thread-status" name="status">${space.statuses.map((st) => `<option value="${esc(st)}"${st === thread.status ? ' selected' : ''}>${esc(STATUS_LABELS[st] || st)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set status</button></form>` : ''}++ ${categories.length && (viewer.can_moderate || viewer.can_delete) ? `<form class="inline-form" method="post" action="${esc(base)}/category"><label class="sr-only" for="thread-category">Category</label><select id="thread-category" name="category"><option value="">No category</option>${categories.map((c) => `<option value="${esc(c.slug)}"${thread.category && thread.category.slug === c.slug ? ' selected' : ''}>${esc(c.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set category</button></form>` : ''}++ </div>` : '';++}++// ── /s/:space/t/:slug ────────────────────────────────────────--function postHtml(p, n) {++function postHtml(p, n, { base = '', page = 1, catalogue = [] } = {}) {if (p.deleted) return `<article class="post post-deleted" id="post-${p.id}"><p class="muted small">#${n} · This post was deleted.</p></article>`;return `<article class="post${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}"><header class="post-head">${who(p.author)} <span class="sep">·</span> <a class="muted small" href="#post-${p.id}">${timeTag(p.created_at)}</a>${p.revision > 1 ? ` <span class="sep">·</span> <span class="muted small" title="Edited ${esc(fmtDate(p.updated_at))}">edited</span>` : ''}<span class="post-num muted small">#${n}</span></header><div class="md">${p.body_html}</div>-- ${(p.attachments || []).length ? `<div class="post-images">${p.attachments.map((a) => `<a href="${esc(a.url)}" target="_blank" rel="noopener"><img src="${esc(a.url)}" alt="${esc(a.filename || 'Image')}" loading="lazy" decoding="async"></a>`).join('')}</div>` : ''}++ ${postImages(p)}++ ${pasteCards(p)}++ ${reactionBar(p, base, page, catalogue)}</article>`;}++/** Pastes attached to a post: a card with the first lines (highlighted), or the screenshot. */++function pasteCards(p) {++ const list = p.pastes || [];++ if (!list.length) return '';++ const { highlight, languageLabel } = require('./highlight');++ return `<div class="paste-embeds">${list.map((x) => `<figure class="paste-embed">++ <figcaption><a href="${esc(x.url)}"><i class="fa-solid fa-${x.type === 'screenshot' ? 'image' : 'code'}" aria-hidden="true"></i> ${esc(x.title || 'Untitled')}</a>++ <span class="muted small">${x.type === 'screenshot' ? 'screenshot' : `${esc(languageLabel ? languageLabel(x.language) : x.language)} · ${num(x.lines)} ${x.lines === 1 ? 'line' : 'lines'}`}${x.visibility === 'unlisted' ? ' · unlisted' : ''}</span></figcaption>++ ${x.type === 'screenshot' && x.screenshot_url ? `<a href="${esc(x.url)}"><img src="${esc(x.screenshot_url)}" alt="${esc(x.title || 'Screenshot')}" loading="lazy"></a>` : `<pre class="paste-embed-code"><code class="hljs">${highlight(x.excerpt || '', x.language).html}</code></pre>${x.lines > 12 ? `<a class="small" href="${esc(x.url)}">See all ${num(x.lines)} lines</a>` : ''}`}++ </figure>`).join('')}</div>`;++}++++/**++ * Where a thread came from, where else it lives, and (signed in) a form to crosspost it to another space.++ */++function crosspostBlock(c, base) {++ if (!c) return '';++ const from = c.from ? `<p class="crosspost-note"><i class="fa-solid fa-shuffle" aria-hidden="true"></i> Crossposted from <strong>${esc(c.from.space)}</strong>: <a href="${esc(c.from.url)}">${esc(c.from.title)}</a></p>` : '';++ const to = (c.to || []).length ? `<p class="crosspost-note small muted">Also in ${c.to.map((x) => `<a href="${esc(x.url)}">${esc(x.space)}</a>`).join(', ')}</p>` : '';++ const form = (c.targets || []).length ? `<details class="crosspost-form"><summary><i class="fa-solid fa-shuffle" aria-hidden="true"></i> Crosspost</summary>++ <form class="inline-form" method="post" action="${esc(base)}/crosspost"><label class="sr-only" for="crosspost-to">Crosspost to</label><select id="crosspost-to" name="to">${c.targets.map((t) => `<option value="${esc(t.slug)}">${esc(t.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Crosspost</button></form></details>` : '';++ return from || to || form ? `<div class="crosspost">${from}${to}${form}</div>` : '';++}++++/** A post's images. */++function postImages(p) {++ return `${(p.attachments || []).length ? `<div class="post-images">${p.attachments.map((a) => `<a href="${esc(a.url)}" target="_blank" rel="noopener"><img src="${esc(a.url)}" alt="${esc(a.filename || 'Image')}" loading="lazy" decoding="async"></a>`).join('')}</div>` : ''}`;++}++/**++ * A post's ratings (Facepunch-style): the ratings given, as buttons that take yours back or switch to them,++ * and a picker with every rating (no JS: a <details> and a form). `base` is the thread's path.++ */++function reactionBar(p, base, page, catalogue = []) {++ const given = p.reactions || [];++ if (!given.length && !p.can_react) return '';++ const title = (r) => `${r.label}${r.raters && r.raters.length ? `: ${r.raters.join(', ')}${r.count > r.raters.length ? ` and ${num(r.count - r.raters.length)} more` : ''}` : ''}`;++ const chip = (r) => (p.can_react++ ? `<button class="rx rx-${esc(r.group || 'positive')}${r.mine ? ' mine' : ''}" type="submit" name="reaction" value="${esc(r.key)}" title="${esc(title(r))}" aria-pressed="${r.mine ? 'true' : 'false'}" aria-label="${esc(`${r.label}, ${r.count}`)}"><span aria-hidden="true">${r.emoji}</span> ${num(r.count)}</button>`++ : `<span class="rx rx-${esc(r.group || 'positive')}" title="${esc(title(r))}"><span aria-hidden="true">${r.emoji}</span> ${num(r.count)}<span class="sr-only"> ${esc(r.label)}</span></span>`);++ const picker = p.can_react && catalogue.length ? `<details class="rx-pick"><summary><i class="fa-regular fa-face-smile" aria-hidden="true"></i> Rate</summary><div class="rx-menu">${['positive', 'negative', 'utility'].map((g) => {++ const list = catalogue.filter((r) => r.group === g);++ return list.length ? `<div class="rx-row">${list.map((r) => `<button class="rx-btn rx-${g}" type="submit" name="reaction" value="${esc(r.key)}" title="${esc(r.label)}"><span aria-hidden="true">${r.emoji}</span> ${esc(r.label)}</button>`).join('')}</div>` : '';++ }).join('')}</div></details>` : '';++ const inner = `${given.map(chip).join('')}${picker}`;++ return p.can_react++ ? `<form class="reactions" method="post" action="${esc(base)}/react" aria-label="Ratings"><input type="hidden" name="post" value="${Number(p.id)}"><input type="hidden" name="page" value="${Number(page) || 1}">${inner}</form>`++ : `<div class="reactions" aria-label="Ratings">${inner}</div>`;++}++++const pasteField = (value = '') => `<label class="field"><span>Pastes (optional: up to 4 paste links or codes, separated by spaces)</span><input type="text" name="pastes" maxlength="400" value="${esc(value || '')}" placeholder="https://openvibe.community/p/…"></label>`;const imageField = (on) => (on ? '<label class="field"><span>Images (optional, up to 4; PNG, JPEG, GIF or WebP, 8 MB each)</span><input type="file" name="attachments" accept="image/png,image/jpeg,image/gif,image/webp" multiple></label>' : '');function voteForm(base, thread, viewer) {@@@ -146,7 +211,7 @@ function voteForm(base, thread, viewer) {return `<form class="vote" method="post" action="${esc(base)}/vote">${btn(1, 'fa-arrow-up', 'Upvote')}<span class="vote-score" title="Score">${num(thread.score)}</span>${btn(-1, 'fa-arrow-down', 'Downvote')}</form>`;}--function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [], attachmentsEnabled = false }) {++function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [], attachmentsEnabled = false, reactions = [], crosspost = null }) {const base = `/s/${space.slug}/t/${thread.slug}`;const opening = posts.find((p) => p.is_opening) || null;const gated = !!(space.members_only || thread.members_only);@@@ -157,32 +222,27 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}<label class="field"><span>Reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>${imageField(attachmentsEnabled)}++ ${pasteField()}<div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-reply" aria-hidden="true"></i> Reply</button><span class="muted small">**bold**, *italic*, \`code\`, \`\`\` blocks, [links](https://…), > quotes and lists.</span></div></form>`: thread.locked ? '<p class="alert">This thread is locked.</p>': !user ? `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to reply.</p>` : '';-- const modBlock = viewer.can_moderate || viewer.can_delete ? `<div class="actions mod-actions">-- ${viewer.can_moderate ? `<form method="post" action="${esc(base)}/state"><input type="hidden" name="pinned" value="${thread.pinned ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> ${thread.pinned ? 'Unpin' : 'Pin'}</button></form>-- <form method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}-- ${viewer.can_delete ? `<form method="post" action="${esc(base)}/delete"><button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}-- ${viewer.can_gate ? `<form method="post" action="${esc(base)}/members-only"><input type="hidden" name="on" value="${thread.members_only ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-star" aria-hidden="true"></i> ${thread.members_only ? 'Open to everyone' : 'VIP members only'}</button></form>` : ''}-- ${viewer.can_moderate && (space.statuses || []).length && thread.status ? `<form class="inline-form" method="post" action="${esc(base)}/status"><label class="sr-only" for="thread-status">Status</label><select id="thread-status" name="status">${space.statuses.map((st) => `<option value="${esc(st)}"${st === thread.status ? ' selected' : ''}>${esc(STATUS_LABELS[st] || st)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set status</button></form>` : ''}-- ${categories.length && (viewer.can_moderate || viewer.can_delete) ? `<form class="inline-form" method="post" action="${esc(base)}/category"><label class="sr-only" for="thread-category">Category</label><select id="thread-category" name="category"><option value="">No category</option>${categories.map((c) => `<option value="${esc(c.slug)}"${thread.category && thread.category.slug === c.slug ? ' selected' : ''}>${esc(c.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set category</button></form>` : ''}-- </div>` : '';++ const modBlock = modActions({ base, space, thread, viewer, categories });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(seo.clean(thread.title, 60))}</span></nav><header class="thread-head">-- ${voteForm(base, thread, viewer)}++ ${space.votes === false ? '' : voteForm(base, thread, viewer)}<div><h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>${thread.status || thread.category ? `<p class="thread-tags">${statusBadge(thread)}${categoryBadge(thread, space)}</p>` : ''}<p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p></div></header>++ ${crosspostBlock(crosspost, base)}${modBlock}-- <div class="posts">${posts.map((p, i) => postHtml(p, first + i + 1)).join('\n')}</div>++ <div class="posts">${posts.map((p, i) => postHtml(p, first + i + 1, { base, page, catalogue: reactions })).join('\n')}</div>${pager((n) => `${base}${n > 1 ? `?page=${n}` : ''}`, page, pages, ['Earlier', 'Later'])}${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}</article>`;@@@ -218,6 +278,7 @@ function newThreadPage({ space, user, values = {}, error = null, categories = []${categoryField}<label class="field"><span>Post (Markdown)</span><textarea name="body" rows="14" maxlength="40000" required placeholder="Say it, own it.">${esc(values.body || '')}</textarea></label>${imageField(attachments)}++ ${pasteField(values.pastes)}<label class="check"><input type="checkbox" name="members_only" value="1"${values.members_only ? ' checked' : ''}> Only my OpenVibe.VIP members can read and reply</label><div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Post thread</button>@@@ -276,4 +337,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason});}--module.exports = { spacesPage, spacePage, threadPage, newThreadPage, membersOnlyPage, spaceHref, who };++module.exports = {++ spacesPage, spacePage, threadPage, newThreadPage, membersOnlyPage, spaceHref, who,++ pager, visBadge, vipBadge, statusBadge, categoryBadge, voteForm, modActions, reactionBar, postImages, imageField, pasteField, pasteCards, crosspostBlock, STATUS_LABELS,++};@@@ -38,6 +38,12 @@ function timeTag(v) {}function num(n) { return Number(n || 0).toLocaleString('en-US'); }function authorName(p) { return p.display_name || p.username || 'Anonymous'; }++/** A person's picture at `size` px: their own, else the network's avatar address (a generated initial when none). */++function avatarUrl(p, size = 96) {++ if (!p) return null;++ if (p.avatar_url) return /^https?:\/\//i.test(p.avatar_url) ? p.avatar_url : `${config.liveUrl}${p.avatar_url.startsWith('/') ? '' : '/'}${p.avatar_url}`;++ return p.username ? `${config.networkUrl}/avatar/${encodeURIComponent(p.username)}?s=${size}` : null;++}function authorHtml(p, { link = true } = {}) {const name = authorName(p);const initial = esc(name.trim()[0] || '?').toUpperCase();@@@ -258,6 +264,7 @@ function pastePage({ paste: p, related, user }) {${!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>` : ''}${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>`;@@@ -397,4 +404,4 @@ function errorPage({ status = 500, title = 'Something went wrong', message = '',return renderPage({ title, description: message || title, canonicalPath: '/', robots: 'noindex,nofollow', footerVariant: 'compact', body });}--module.exports = { homePage, updatesPage, browsePage, pastePage, newPage, myPage, errorPage, pasteCard, cardGrid, timeAgo, timeTag, fmtDate, num, authorHtml };++module.exports = { homePage, updatesPage, browsePage, pastePage, newPage, myPage, errorPage, pasteCard, cardGrid, timeAgo, timeTag, fmtDate, num, authorHtml, avatarUrl };@@@ -28,7 +28,7 @@ const { syncRoadmap, syncFromFile } = require('../server/forum/roadmap');await check('seeded: Feedback takes requests with ideas/bugs/questions; Roadmap is public and staff-started', async () => {const spaces = (await call('/api/v1/spaces')).json().spaces;-- assert.deepStrictEqual(spaces.map((s) => [s.slug, s.thread_kind]), [['general', 'discussion'], ['feedback', 'request'], ['showcase', 'discussion'], ['roadmap', 'roadmap']]);++ assert.deepStrictEqual(spaces.map((s) => [s.slug, s.thread_kind]), [['general', 'discussion'], ['help', 'discussion'], ['feedback', 'request'], ['roadmap', 'roadmap'], ['showcase', 'discussion'], ['off-topic', 'discussion']]);assert.deepStrictEqual(spaces.find((s) => s.slug === 'feedback').statuses, ['open', 'planned', 'in_progress', 'done', 'declined']);const cats = (await call('/api/v1/spaces/feedback/categories')).json().categories;assert.deepStrictEqual(cats.map((c) => c.slug), ['ideas', 'bugs', 'questions']);@@@ -15,6 +15,8 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString(async () => {const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });++ // These pages are the feed style (render/forum.js): General is a forum board by default, so it becomes a feed with votes here.++ t.db.prepare("UPDATE spaces SET style = 'feed', votes = 1 WHERE slug = 'general'").run();const net = t.network;const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });nnew file mode 100644@@@ -0,0 +1,157 @@++'use strict';++/**++ * Forum style and the features both styles share: the board index (groups, child boards, topics, posts,++ * last post), a forum-style board (topic table, Replies/Views/Last post, sticky first, bumped by replies),++ * topics with author panels and Quote, per-space votes and ratings, Facepunch-style ratings (one per++ * person per post, toggle, replace, not your own, off per space, totals on the author panel), pastes on++ * posts (cards, private and burn-after-read refused), crossposts between a forum and a feed, "Discuss in++ * a space" from a paste, space settings and new spaces (moderators only).++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });++ const net = t.network;++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });++ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });++ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });++ const modJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'global_mod' });++ const call = (path, { method = 'GET', cookie, json, form } = {}) => {++ const h = {};++ let body;++ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }++ if (form !== undefined) { h['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(form).toString(); }++ return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });++ };++ const pasteRow = (slug, extra = {}) => t.db.prepare('INSERT INTO pastes (slug, owner_subject, title, content, language, visibility, burn_after_read) VALUES (?, ?, ?, ?, ?, ?, ?)')++ .run(slug, alex.subject_id, extra.title || 'Snippet', extra.content || 'const a = 1;\nconsole.log(a);', 'javascript', extra.visibility || 'public', extra.burn ? 1 : 0);++++ let topic;++ await check('a forum topic: votes off, a topic row with replies, views and the last post', async () => {++ let r = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Welcome thread', body: 'Say hi.' } });++ assert.strictEqual(r.status, 201, r.text);++ topic = r.json().thread;++ r = await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'hi from sam' } });++ assert.strictEqual(r.status, 201);++ assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/votes', { method: 'POST', cookie: samJwt, json: { value: 1 } })).json().code, 'space.votes_off');++ await call('/s/general/t/welcome-thread', { cookie: samJwt });++ await call('/s/general/t/welcome-thread', { cookie: samJwt });++ await call('/s/general/t/welcome-thread', { cookie: alexJwt });++ const list = (await call('/api/v1/spaces/general/threads')).json();++ assert.strictEqual(list.sort, 'active', 'forum boards list the newest reply first');++ const row = list.threads[0];++ assert.strictEqual(row.views, 2, 'one view per viewer per half hour');++ assert.strictEqual(row.last_post.author.username, 'sam');++ const page = (await call('/s/general')).text;++ assert.ok(page.includes('class="board-table topic-table"') && page.includes('data-label="Views"') && page.includes('Started by Alex'));++ assert.ok(!page.includes('class="thread-score"'), 'no score column');++ });++++ await check('the board index: groups in order, every space with topics, posts and the last post', async () => {++ const r = (await call('/api/v1/spaces')).json();++ const general = r.groups[0].spaces.find((s) => s.slug === 'general');++ assert.strictEqual(general.thread_count, 1); assert.strictEqual(general.post_count, 2);++ assert.strictEqual(general.last_post.thread.title, 'Welcome thread'); assert.strictEqual(general.last_post.author.username, 'sam');++ const html = (await call('/s')).text;++ assert.ok(html.includes('id="g-openvibe"') && html.includes('id="g-community"') && html.includes('Off-topic') && html.includes('badge-style">Forum') && html.includes('badge-style">Feed'));++ });++++ await check('ratings: one per person per post, toggle, replace, not your own; totals on the author panel', async () => {++ const post = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts[0];++ const rate = (cookie, reaction) => call(`/api/v1/posts/${post.id}/reactions`, { method: 'POST', cookie, json: { reaction } });++ assert.strictEqual((await rate(alexJwt, 'winner')).json().code, 'reaction.own_post');++ let r = await rate(samJwt, 'winner');++ assert.strictEqual(r.status, 200, r.text); assert.strictEqual(r.json().mine, 'winner');++ r = await rate(modJwt, 'winner');++ assert.deepStrictEqual(r.json().reactions.map((x) => [x.key, x.count]), [['winner', 2]]);++ r = await rate(samJwt, 'late');++ assert.deepStrictEqual(r.json().reactions.map((x) => [x.key, x.count]), [['winner', 1], ['late', 1]], 'another rating replaces yours');++ r = await rate(samJwt, 'late');++ assert.strictEqual(r.json().mine, null, 'the same one again takes it back');++ assert.strictEqual((await rate(samJwt, 'amazing')).status, 400);++ assert.strictEqual((await rate(null, 'agree')).status, 401);++ const page = (await call('/api/v1/spaces/general/threads/welcome-thread', { cookie: samJwt })).json();++ assert.deepStrictEqual(page.posts[0].reactions.map((x) => [x.key, x.count, x.raters]), [['winner', 1, ['Boss']]]);++ assert.deepStrictEqual(page.posts[0].author_stats.ratings.map((x) => [x.key, x.count]), [['winner', 1]]);++ assert.strictEqual(page.posts[0].can_react, true); assert.strictEqual(page.posts[1].can_react, false, 'sam cannot rate their own reply');++ const html = (await call('/s/general/t/welcome-thread', { cookie: samJwt })).text;++ assert.ok(html.includes('class="postbit') && html.includes('postbit-author') && html.includes('<dt>Posts</dt>') && html.includes('Rate</summary>') && html.includes('🏆'));++ r = await call('/s/general/t/welcome-thread/react', { method: 'POST', cookie: samJwt, form: { post: String(post.id), reaction: 'funny' } });++ assert.strictEqual(r.status, 303); assert.ok(r.headers.get('location').endsWith(`#post-${post.id}`));++ t.db.prepare("UPDATE spaces SET reactions = 0 WHERE slug = 'general'").run();++ assert.strictEqual((await rate(samJwt, 'agree')).json().code, 'space.reactions_off');++ assert.deepStrictEqual((await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts[0].reactions, []);++ t.db.prepare("UPDATE spaces SET reactions = 1 WHERE slug = 'general'").run();++ });++++ await check('quote: ?quote= fills the reply box with the quoted post', async () => {++ const post = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts[1];++ const html = (await call(`/s/general/t/welcome-thread?quote=${post.id}`, { cookie: alexJwt })).text;++ assert.ok(html.includes('**@sam** wrote:\n> hi from sam'), 'the quote is in the reply box');++ });++++ await check('pastes on posts: public and unlisted as cards; private and burn-after-read refused', async () => {++ pasteRow('snip1'); pasteRow('snip2', { visibility: 'unlisted', title: 'Hidden-ish' }); pasteRow('secret1', { visibility: 'private' }); pasteRow('burn1', { burn: true });++ let r = await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'look at these', pastes: 'https://openvibe.community/p/snip1 snip2' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual(r.json().post.pastes.map((p) => [p.slug, p.lines]), [['snip1', 2], ['snip2', 2]]);++ for (const bad of ['secret1', 'burn1']) assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'x', pastes: [bad] } })).json().code, 'pastes.not_shareable');++ assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'x', pastes: ['nope-nope'] } })).status, 404);++ const html = (await call('/s/general/t/welcome-thread')).text;++ assert.ok(html.includes('class="paste-embed"') && html.includes('href="/p/snip1"') && html.includes('hljs'));++ t.db.prepare("UPDATE pastes SET visibility = 'private' WHERE slug = 'snip2'").run();++ const after = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts.find((p) => p.body_markdown === 'look at these');++ assert.deepStrictEqual(after.pastes.map((p) => p.slug), ['snip1'], 'a paste made private drops out');++ });++++ await check('"Discuss in a space" from a paste: the picker, then a new topic with the paste attached', async () => {++ const paste = (await call('/p/snip1')).text;++ assert.ok(paste.includes('/s/discuss?paste=snip1'));++ const picker = (await call('/s/discuss?paste=snip1', { cookie: alexJwt })).text;++ assert.ok(picker.includes('/s/general/new?paste=snip1') && !picker.includes('/s/roadmap/new'));++ const form = (await call('/s/general/new?paste=snip1', { cookie: alexJwt })).text;++ assert.ok(form.includes('name="pastes"') && form.includes('value="snip1"') && form.includes('value="Snippet"'));++ });++++ await check('crosspost: a forum topic into a feed, both linked; members-only never', async () => {++ const r = await call('/api/v1/spaces/general/threads/welcome-thread/crosspost', { method: 'POST', cookie: samJwt, json: { to: 'showcase' } });++ assert.strictEqual(r.status, 201, r.text);++ const cp = r.json().thread;++ assert.strictEqual(cp.space, 'showcase'); assert.strictEqual(cp.crosspost_of, topic.id);++ const there = (await call(`/api/v1/spaces/showcase/threads/${cp.slug}`)).json();++ assert.strictEqual(there.crosspost.from.url, '/s/general/t/welcome-thread');++ assert.ok(there.posts[0].body_markdown.startsWith('Crossposted from **General**'));++ const here = (await call('/api/v1/spaces/general/threads/welcome-thread', { cookie: samJwt })).json();++ assert.deepStrictEqual(here.crosspost.to.map((x) => x.space), ['Showcase']);++ assert.ok(here.crosspost.targets.some((x) => x.slug === 'showcase') && !here.crosspost.targets.some((x) => x.slug === 'roadmap' || x.slug === 'general'));++ assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/crosspost', { method: 'POST', cookie: samJwt, json: { to: 'roadmap' } })).status, 403);++ const html = (await call('/s/general/t/welcome-thread', { cookie: samJwt })).text;++ assert.ok(html.includes('Also in') && html.includes('Crosspost</summary>'));++ });++++ await check('space settings and new spaces: moderators only; style switches the pages', async () => {++ let r = await call('/api/v1/spaces/showcase/settings', { method: 'PUT', cookie: alexJwt, json: { style: 'forum' } });++ assert.strictEqual(r.status, 403);++ r = await call('/api/v1/spaces/showcase/settings', { method: 'PUT', cookie: modJwt, json: { style: 'forum', votes: false } });++ assert.strictEqual(r.status, 200, r.text); assert.strictEqual(r.json().space.style, 'forum'); assert.strictEqual(r.json().space.votes, false);++ assert.ok((await call('/s/showcase')).text.includes('topic-table'));++ r = await call('/s/showcase/settings', { method: 'POST', cookie: modJwt, form: { name: 'Showcase', style: 'feed', votes: '1', reactions: '1', group: 'community' } });++ assert.strictEqual(r.status, 303);++ assert.ok((await call('/s/showcase')).text.includes('class="thread-list"'));++ assert.strictEqual((await call('/s/new-space', { cookie: alexJwt })).status, 403);++ r = await call('/s/new-space', { method: 'POST', cookie: modJwt, form: { name: 'Music', slug: 'music', description: 'Songs', style: 'forum', reactions: '1', group: 'community' } });++ assert.strictEqual(r.status, 303); assert.strictEqual(r.headers.get('location'), '/s/music');++ const music = (await call('/api/v1/spaces')).json().spaces.find((s) => s.slug === 'music');++ assert.deepStrictEqual([music.style, music.votes, music.reactions, music.group.slug], ['forum', false, true, 'community']);++ r = await call('/api/v1/spaces/music/settings', { method: 'PUT', cookie: modJwt, json: { parent: 'off-topic' } });++ assert.strictEqual(r.status, 200, r.text);++ const idx = (await call('/api/v1/spaces')).json();++ assert.ok(idx.groups.find((g) => g.slug === 'community').spaces.find((s) => s.slug === 'off-topic').children.some((c) => c.slug === 'music'), 'a child board sits under its parent');++ assert.strictEqual((await call('/api/v1/spaces', { method: 'POST', cookie: modJwt, json: { slug: 'discuss', name: 'Nope' } })).status, 400, 'reserved address');++ });++++ await done(t);++})();@@@ -33,10 +33,11 @@ const forumStore = require('../server/forum/store');let first;-- await check('seeded spaces: general, feedback, showcase, roadmap — all public', async () => {++ await check('seeded spaces: on the board index in their groups, all public', async () => {const r = await call('/api/v1/spaces');assert.strictEqual(r.status, 200);-- assert.deepStrictEqual(r.json().spaces.map((s) => [s.slug, s.visibility]), [['general', 'public'], ['feedback', 'public'], ['showcase', 'public'], ['roadmap', 'public']]);++ assert.deepStrictEqual(r.json().spaces.map((s) => [s.slug, s.visibility, s.style]), [['general', 'public', 'forum'], ['help', 'public', 'forum'], ['feedback', 'public', 'feed'], ['roadmap', 'public', 'feed'], ['showcase', 'public', 'feed'], ['off-topic', 'public', 'forum']]);++ assert.deepStrictEqual(r.json().groups.map((g) => [g.slug, g.spaces.map((s) => s.slug)]), [['openvibe', ['general', 'help', 'feedback', 'roadmap']], ['community', ['showcase', 'off-topic']]]);assert.strictEqual((await call('/api/v1/spaces/nope')).status, 404);});@@@ -117,8 +118,11 @@ const forumStore = require('../server/forum/store');assert.strictEqual((await call(`/api/v1/posts/${reply.id}/versions`, { cookie: alexJwt })).status, 403);});-- await check('votes on threads: add/change/remove, score recomputed, my_vote shown', async () => {++ await check('votes on threads: off in a space without them; add/change/remove, score recomputed, my_vote shown', async () => {const vote = (value, cookie) => call('/api/v1/spaces/general/threads/hello-world/votes', { method: 'POST', cookie, json: { value } });++ const off = await vote(1, samJwt);++ assert.strictEqual(off.status, 403, 'General is a forum-style board: votes are off'); assert.strictEqual(off.json().code, 'space.votes_off');++ t.db.prepare("UPDATE spaces SET votes = 1 WHERE slug = 'general'").run();assert.strictEqual((await vote(1, samJwt)).json().score, 1);assert.strictEqual((await vote(1, alexJwt)).json().score, 2);assert.strictEqual((await vote(-1, samJwt)).json().score, 0);
@@ -322,6 +322,22 @@ member for now), `staff` (moderators only; looks missing to everyone else).
@@ -322,6 +322,22 @@ member for now), `staff` (moderators only; looks missing to everyone else).
thread). Moderators (admin/global_mod browsers; services with `community.comment.moderate`)
thread). Moderators (admin/global_mod browsers; services with `community.comment.moderate`)
pin, lock and delete; locked threads take no replies or votes.
pin, lock and delete; locked threads take no replies or votes.
+- **Two styles, one system.** Each space is a **forum** or a **feed**. A forum reads like vBulletin or SMF:
+ - the board index at `/s` lists every space under its group, with topics, posts and the last post;
+ - a board is a topic table (Replies, Views, Last post) with sticky topics first and replies bumping a topic;
+ - a topic shows each post beside its author's panel: picture, posts, since when, the ratings they get most.
+ Quote fills the reply box. A feed is subreddit-like: votes and hot/new/top. Moderators switch per space:
+ the style, up/down votes and ratings. They also set the group, a parent (one level of child boards),
+ the name and description, and create spaces (`/s/new-space`).
+ Both styles share threads, ratings, images, pastes and crossposts: a thread can be crossposted to another
+ space, and both link to each other.
+- **Ratings** (after Facepunch), one per person per post, never your own:
+ - positive: Agree ✅, Winner 🏆, Funny 😂, Informative 💡, Friendly 😊, Sympathy ❤️;
+ - negative: Dumb 📦, Disgusting 🤢;
+ - utility: Bad reading 📖, Late 🕒.
+ The same rating again takes it back, and another replaces it. Hovering shows who gave it.
+- **Pastes on posts.** Up to 4 public or unlisted pastes per post, shown as cards with their first lines or
+ the screenshot. A paste made private or deleted drops out. Every paste page has "Discuss in a space".
- **Categories, requests and the roadmap.** A space's `thread_kind` decides what a new thread is. In a
- **Categories, requests and the roadmap.** A space's `thread_kind` decides what a new thread is. In a
`discussion` space it is a plain thread. In `request` (Feedback, with Ideas, Bugs and Questions
`discussion` space it is a plain thread. In `request` (Feedback, with Ideas, Bugs and Questions
categories) it is open to votes and starts `open`; staff move it through its statuses. In `roadmap`
categories) it is open to votes and starts `open`; staff move it through its statuses. In `roadmap`
@@ -343,6 +359,9 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
@@ -343,6 +359,9 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
| `POST /spaces/:space/threads` `{ title, body, category?, members_only? }` | New thread (`community.post.create` for services); `members_only: true` gates it to the author's VIP members |
| `POST /spaces/:space/threads` `{ title, body, category?, members_only? }` | New thread (`community.post.create` for services); `members_only: true` gates it to the author's VIP members |
| `POST /spaces/:space/attachments` (multipart `file`) | An image for a new thread or reply → `{ attachment: { media_id, url, … } }`; name it in `attachments: [media_id]` when posting |
| `POST /spaces/:space/attachments` (multipart `file`) | An image for a new thread or reply → `{ attachment: { media_id, url, … } }`; name it in `attachments: [media_id]` when posting |
| `GET /spaces/:space/categories` · `PUT`/`DELETE /spaces/:space/categories/:category` `{ name, description?, position? }` | Categories · moderators manage them (a deleted category's threads stay, uncategorised) |
| `GET /spaces/:space/categories` · `PUT`/`DELETE /spaces/:space/categories/:category` `{ name, description?, position? }` | Categories · moderators manage them (a deleted category's threads stay, uncategorised) |
+| `POST /spaces` · `PUT /spaces/:space/settings` `{ style, votes, reactions, group, parent, name, description, kind }` | Moderators: new space · settings. `GET/PUT /api/v1/space-groups[/:group]` manages the board index's groups |
+| `POST /spaces/:space/threads/:slug/crosspost` `{ to }` | Crosspost to another space |
+| `POST /posts/:id/reactions` `{ reaction \| null }` | Rate a post |
| `PUT /spaces/:space/threads/:slug/category` `{ category: slug \| null }` | The author or moderators |
| `PUT /spaces/:space/threads/:slug/category` `{ category: slug \| null }` | The author or moderators |
| `PUT /spaces/:space/threads/:slug/status` `{ status }` | Moderators: requests `open`/`planned`/`in_progress`/`done`/`declined`, roadmap items `planned`/`in_progress`/`done`/`paused` |
| `PUT /spaces/:space/threads/:slug/status` `{ status }` | Moderators: requests `open`/`planned`/`in_progress`/`done`/`declined`, roadmap items `planned`/`in_progress`/`done`/`paused` |
| `GET /spaces/:space/threads/:slug?page=` | Thread + posts (`body_markdown` and rendered `body_html`) |
| `GET /spaces/:space/threads/:slug?page=` | Thread + posts (`body_markdown` and rendered `body_html`) |
@@ -21,6 +21,8 @@
@@ -21,6 +21,8 @@
"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 space 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." },
{ "key": "your-data", "category": "features", "status": "in_progress", "title": "See, download and delete your data",
{ "key": "your-data", "category": "features", "status": "in_progress", "title": "See, download and delete your data",
"summary": "The AI & Data tab on your account lists what OpenVibe sites keep with your account, and lets you download it or delete any of it. Next: one export with everything from every site, and account deletion with a grace period." },
"summary": "The AI & Data tab on your account lists what OpenVibe sites keep with your account, and lets you download it or delete any of it. Next: one export with everything from every site, and account deletion with a grace period." },
{ "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",
@@ -229,3 +229,92 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
@@ -229,3 +229,92 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
.comment-text { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.comment-text { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.comment-replies { display: flex; flex-direction: column; gap: 8px; margin: 10px 0 0 18px; }
.comment-replies { display: flex; flex-direction: column; gap: 8px; margin: 10px 0 0 18px; }
.comment-replies .post { padding: 10px 14px; }
.comment-replies .post { padding: 10px 14px; }
+
+/* ── Forum style: board index, topic tables, author panels (render/board.js) ── */
+.board-group { margin: 22px 0; }
+.board-group-h { font-size: 1rem; text-transform: uppercase; letter-spacing: .06em; color: var(--accent-light); margin: 0 0 6px; }
+.board-group-p { margin: 0 0 8px; }
+.board-table { width: 100%; table-layout: fixed; border-collapse: separate; border-spacing: 0; background: var(--bg-card); border: 1px solid var(--border-light); border-radius: 12px; overflow: hidden; }
+.board-table th.num { width: 6.5em; }
+.board-table th:last-child { width: 16em; }
+.topic-table th.topic-icon { width: 2.6em; }
+.board-table th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-secondary); padding: 8px 12px; border-bottom: 1px solid var(--border-light); background: var(--bg-hover); }
+.board-table td { padding: 10px 12px; border-top: 1px solid var(--border-light); vertical-align: top; }
+.board-table tbody tr:first-child td { border-top: 0; }
+.board-table .num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; width: 5.5em; }
+.board-name { display: flex; gap: 12px; align-items: flex-start; }
+.board-name > div { min-width: 0; }
+.board-name p { margin: 2px 0 0; }
+.board-icon { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; background: rgba(var(--accent-rgb), .12); color: var(--accent-light); }
+.board-title, .topic-title { font-weight: 700; }
+.board-last { width: 15em; font-size: .85rem; }
+.board-last > a, .board-last > .small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 15em; }
+.board-last .small a { display: inline; }
+.badge-style { font-size: .65rem; vertical-align: middle; }
+.board-children { margin-top: 4px; }
+.topic-table .topic-icon { width: 2.2em; text-align: center; color: var(--text-secondary); }
+.topic-table tr.sticky td { background: rgba(var(--accent-rgb), .06); }
+.topic-by { display: block; margin-top: 3px; }
+.topic-pages a { margin-left: 3px; }
+.badge-sticky { font-size: .65rem; border-color: var(--accent); color: var(--accent-light); }
+.board-toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 8px 0 12px; }
+.space-settings, .crosspost-form { margin: 18px 0; }
+.space-settings summary, .crosspost-form summary { cursor: pointer; color: var(--text-secondary); font-weight: 600; }
+.discuss-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
+.postbits { display: grid; gap: 12px; margin: 12px 0; }
+.postbit { display: grid; grid-template-columns: 170px 1fr; background: var(--bg-card); border: 1px solid var(--border-light); border-radius: 12px; scroll-margin-top: 80px; }
+.postbit-author { padding: 14px; background: var(--bg-hover); border-right: 1px solid var(--border-light); border-radius: 12px 0 0 12px; display: flex; flex-direction: column; align-items: center; text-align: center; gap: 6px; }
+.postbit-avatar { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; }
+.postbit-letter { display: grid; place-items: center; background: var(--accent); color: var(--on-accent); font-weight: 700; font-size: 1.5rem; }
+.postbit-name { font-weight: 700; word-break: break-word; }
+.postbit-stats { display: grid; gap: 2px; margin: 0; font-size: .8rem; color: var(--text-secondary); }
+.postbit-stats div { display: flex; gap: 6px; justify-content: center; }
+.postbit-stats dt { font-weight: 600; }
+.postbit-stats dd { margin: 0; }
+.postbit-ratings { margin: 0; font-size: .8rem; display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
+.postbit-main { padding: 12px 16px; min-width: 0; }
+.postbit-head { display: flex; flex-wrap: wrap; gap: 4px 8px; margin-bottom: 8px; padding-bottom: 6px; border-bottom: 1px solid var(--border-light); }
+.postbit.post-deleted { grid-template-columns: 1fr; }
+@media (max-width: 720px) {
+ .postbit { grid-template-columns: 1fr; }
+ .postbit-author { flex-direction: row; flex-wrap: wrap; text-align: left; border-right: 0; border-bottom: 1px solid var(--border-light); border-radius: 12px 12px 0 0; padding: 10px 12px; }
+ .postbit-avatar { width: 40px; height: 40px; }
+ .postbit-stats { grid-auto-flow: column; gap: 10px; }
+ .board-table thead { display: none; }
+ .board-table, .board-table tbody, .board-table tr, .board-table td { display: block; width: 100%; }
+ .board-table tr { border-top: 1px solid var(--border-light); padding: 8px 0; }
+ .board-table tbody tr:first-child { border-top: 0; }
+ .board-table td { border-top: 0; padding: 2px 12px; }
+ .board-table td.num { display: inline-block; width: auto; text-align: left; }
+ .board-table td.num::before { content: attr(data-label) ': '; color: var(--text-secondary); font-size: .8rem; }
+ .board-table td.topic-icon { display: none; }
+ .board-last, .board-last a, .board-last .small { width: auto; max-width: 100%; }
+}
+
+/* ── Ratings (Facepunch style) ── */
+.reactions { position: relative; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 10px 0 0; }
+.rx { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border-light); background: transparent; color: var(--text-primary, inherit); font: inherit; font-size: .82rem; cursor: default; }
+button.rx { cursor: pointer; }
+button.rx:hover { border-color: var(--accent); }
+.rx.mine { border-color: var(--accent); background: rgba(var(--accent-rgb), .15); }
+.rx-negative { border-style: dashed; }
+.rx-pick { position: static; }
+.rx-pick summary { list-style: none; cursor: pointer; font-size: .82rem; color: var(--text-secondary); padding: 2px 8px; border-radius: 999px; border: 1px solid transparent; }
+.rx-pick summary::-webkit-details-marker { display: none; }
+.rx-pick summary:hover, .rx-pick[open] summary { border-color: var(--border-light); }
+.rx-menu { position: absolute; left: 0; top: 100%; z-index: 20; margin-top: 6px; padding: 8px; background: var(--bg-card); border: 1px solid var(--border-light); border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,.35); display: grid; gap: 6px; width: max-content; max-width: min(440px, 100%); }
+.rx-row { display: flex; flex-wrap: wrap; gap: 4px; }
+.rx-btn { display: inline-flex; align-items: center; gap: 4px; padding: 4px 8px; border-radius: 8px; border: 1px solid var(--border-light); background: transparent; color: inherit; font: inherit; font-size: .8rem; cursor: pointer; }
+.rx-btn:hover, .rx-btn:focus-visible { border-color: var(--accent); background: var(--bg-hover); }
+.rx-btn.rx-negative { color: var(--text-secondary); }
+
+/* ── Pastes on posts, crossposts ── */
+.paste-embeds { display: grid; gap: 8px; margin-top: 10px; }
+.paste-embed { margin: 0; border: 1px solid var(--border-light); border-radius: 10px; overflow: hidden; background: var(--bg-hover); }
+.paste-embed figcaption { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; padding: 6px 10px; border-bottom: 1px solid var(--border-light); }
+.paste-embed figcaption a { font-weight: 700; }
+.paste-embed-code { margin: 0; max-height: 18em; overflow: auto; font-size: .8rem; padding: 8px 10px; }
+.paste-embed > a.small { display: block; padding: 4px 10px 8px; }
+.paste-embed img { display: block; max-width: 100%; max-height: 360px; margin: 0 auto; }
+.crosspost { margin: 8px 0; }
+.crosspost-note { margin: 4px 0; }
@@ -50,7 +50,7 @@ const { createCommentsApi } = require('./comments/api');
@@ -50,7 +50,7 @@ const { createCommentsApi } = require('./comments/api');
const { createCommentPages } = require('./comments/routes');
const { createCommentPages } = require('./comments/routes');
const { createForumService } = require('./forum/service');
const { createForumService } = require('./forum/service');
const { createVipGate } = require('./vip');
const { createVipGate } = require('./vip');
-const { createSpacesApi, createPostsApi } = require('./forum/api');
+const { createSpacesApi, createPostsApi, createGroupsApi } = require('./forum/api');
const { createForumRoutes } = require('./forum/routes');
const { createForumRoutes } = require('./forum/routes');
const { createPulse } = require('./pulse/service');
const { createPulse } = require('./pulse/service');
const { createPulseApi } = require('./pulse/api');
const { createPulseApi } = require('./pulse/api');
@@ -176,6 +176,7 @@ function createApp(opts = {}) {
@@ -176,6 +176,7 @@ function createApp(opts = {}) {
app.use('/internal/events', pulseConsumer.router);
app.use('/internal/events', pulseConsumer.router);
app.use('/api/v1/spaces', createSpacesApi({ forum, viewers }));
app.use('/api/v1/spaces', createSpacesApi({ forum, viewers }));
app.use('/api/v1/posts', createPostsApi({ forum, viewers }));
app.use('/api/v1/posts', createPostsApi({ forum, viewers }));
+ app.use('/api/v1/space-groups', createGroupsApi({ forum, viewers }));
app.use('/api/v1/relay', createRelayApi({ relay, db, viewers }));
app.use('/api/v1/relay', createRelayApi({ relay, db, viewers }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));
@@ -260,6 +260,33 @@ CREATE TABLE IF NOT EXISTS attachments (
@@ -260,6 +260,33 @@ CREATE TABLE IF NOT EXISTS attachments (
);
);
CREATE INDEX IF NOT EXISTS idx_attachments_post ON attachments(post_id, position) WHERE post_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_attachments_post ON attachments(post_id, position) WHERE post_id IS NOT NULL;
+-- The board index (forum style): spaces are listed under groups, like a vBulletin/SMF board index.
+CREATE TABLE IF NOT EXISTS space_groups (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ slug TEXT UNIQUE NOT NULL,
+ name TEXT NOT NULL,
+ description TEXT,
+ position INTEGER NOT NULL DEFAULT 0
+);
+
+-- Facepunch-style ratings on posts: one per person per post (forum/reactions.js lists them).
+CREATE TABLE IF NOT EXISTS post_reactions (
+ post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
+ subject_id TEXT NOT NULL,
+ reaction TEXT NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (post_id, subject_id)
+);
+CREATE INDEX IF NOT EXISTS idx_post_reactions_post ON post_reactions(post_id, reaction);
+
+-- Pastes attached to posts (a card with the paste's first lines, or its screenshot).
+CREATE TABLE IF NOT EXISTS post_pastes (
+ post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
+ paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,
+ position INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (post_id, paste_id)
+);
+
-- Categories inside a space (WS-J task 1): threads may carry one; the space page filters by them.
-- Categories inside a space (WS-J task 1): threads may carry one; the space page filters by them.
CREATE TABLE IF NOT EXISTS categories (
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -403,6 +430,29 @@ function migrate(db) {
@@ -403,6 +430,29 @@ function migrate(db) {
('roadmap', 'Roadmap', 'What OpenVibe is building next and where each piece stands. Every item has its own thread: ask about it or argue for it there.', 'public', 'system', 'roadmap');
('roadmap', 'Roadmap', 'What OpenVibe is building next and where each piece stands. Every item has its own thread: ask about it or argue for it there.', 'public', 'system', 'roadmap');
UPDATE spaces SET thread_kind = 'request', description = 'Feature requests, bugs and ideas for every OpenVibe site. Vote for what matters to you; staff mark what is planned and done.'
UPDATE spaces SET thread_kind = 'request', description = 'Feature requests, bugs and ideas for every OpenVibe site. Vote for what matters to you; staff mark what is planned and done.'
WHERE slug = 'feedback' AND thread_kind = 'discussion' AND created_by = 'system';`);
WHERE slug = 'feedback' AND thread_kind = 'discussion' AND created_by = 'system';`);
+ // Space styles (forum/service.js): 'feed' (subreddit-like: votes, hot/new/top) or 'forum' (vBulletin/SMF-like:
+ // board index, topics by last post, author panels, quotes). Votes and ratings are per-space switches;
+ // group_id and position place a space on the board index, parent_id makes it a child board.
+ for (const [col, ddl] of [['style', "TEXT NOT NULL DEFAULT 'feed'"], ['votes', 'INTEGER NOT NULL DEFAULT 1'], ['reactions', 'INTEGER NOT NULL DEFAULT 1'],
+ ['group_id', 'INTEGER REFERENCES space_groups(id) ON DELETE SET NULL'], ['parent_id', 'INTEGER REFERENCES spaces(id) ON DELETE SET NULL'], ['position', 'INTEGER NOT NULL DEFAULT 0']]) {
+ if (!spaceCols.has(col)) db.exec(`ALTER TABLE spaces ADD COLUMN ${col} ${ddl}`);
+ }
+ if (!threadCols.has('views')) db.exec('ALTER TABLE threads ADD COLUMN views INTEGER NOT NULL DEFAULT 0');
+ if (!threadCols.has('crosspost_of')) db.exec('ALTER TABLE threads ADD COLUMN crosspost_of INTEGER REFERENCES threads(id) ON DELETE SET NULL');
+ // The first board index: two groups, the four original spaces placed once (a space staff moved is left alone),
+ // and two new forum boards.
+ db.exec(`INSERT OR IGNORE INTO space_groups (slug, name, description, position) VALUES
+ ('openvibe', 'OpenVibe', 'The network itself: talk, help, requests and what is coming next.', 1),
+ ('community', 'Community', 'What the people of OpenVibe make, and everything else.', 2);
+ INSERT OR IGNORE INTO spaces (slug, name, description, visibility, created_by, style, votes) VALUES
+ ('help', 'Help', 'Stuck on something? Ask here: streaming, tools, your account, anything on OpenVibe.', 'public', 'system', 'forum', 0),
+ ('off-topic', 'Off-topic', 'Anything that is not about OpenVibe.', 'public', 'system', 'forum', 0);`);
+ const place = db.prepare(`UPDATE spaces SET group_id = (SELECT id FROM space_groups WHERE slug = ?), position = ?, style = ?, votes = ?
+ WHERE slug = ? AND created_by = 'system' AND group_id IS NULL`);
+ for (const [group, position, style, votes, slug] of [
+ ['openvibe', 1, 'forum', 0, 'general'], ['openvibe', 2, 'forum', 0, 'help'], ['openvibe', 3, 'feed', 1, 'feedback'], ['openvibe', 4, 'feed', 1, 'roadmap'],
+ ['community', 1, 'feed', 1, 'showcase'], ['community', 2, 'forum', 0, 'off-topic'],
+ ]) place.run(group, position, style, votes, slug);
const seedCategory = db.prepare(`INSERT OR IGNORE INTO categories (space_id, slug, name, description, position)
const seedCategory = db.prepare(`INSERT OR IGNORE INTO categories (space_id, slug, name, description, position)
SELECT id, ?, ?, ?, ? FROM spaces WHERE slug = ?`);
SELECT id, ?, ?, ?, ? FROM spaces WHERE slug = ?`);
for (const [space, slug, name, description, position] of [
for (const [space, slug, name, description, position] of [
@@ -24,6 +24,10 @@
@@ -24,6 +24,10 @@
* PUT /spaces/:space/threads/:slug/category { category: slug | null } the author or moderators
* PUT /spaces/:space/threads/:slug/category { category: slug | null } the author or moderators
* PUT /spaces/:space/threads/:slug/status { status } moderators: requests open|planned|in_progress|done|declined,
* PUT /spaces/:space/threads/:slug/status { status } moderators: requests open|planned|in_progress|done|declined,
* roadmap items planned|in_progress|done|paused (?status= filters)
* roadmap items planned|in_progress|done|paused (?status= filters)
+ * POST /spaces { slug, name, description?, style?: feed|forum, votes?, reactions?, group?, parent?, visibility?, kind? } moderators
+ * PUT /spaces/:space/settings { name?, description?, style?, votes?, reactions?, group?, parent?, position?, kind? } moderators
+ * POST /spaces/:space/threads/:slug/crosspost { to: slug } another space gets a thread linking back (people)
+ * POST /posts/:id/reactions { reaction: agree|winner|funny|informative|friendly|sympathy|dumb|disgusting|bad_reading|late|null }
* PUT /posts/:id { body } DELETE /posts/:id GET /posts/:id/versions
* PUT /posts/:id { body } DELETE /posts/:id GET /posts/:id/versions
*
*
* Services write with community.post.create (as X-OV-Subject, or as AI with X-OV-Origin: ai) and
* Services write with community.post.create (as X-OV-Subject, or as AI with X-OV-Origin: ai) and
@@ -48,6 +52,8 @@ function createSpacesApi({ forum, viewers }) {
@@ -48,6 +52,8 @@ function createSpacesApi({ forum, viewers }) {
const p = (req) => req.params;
const p = (req) => req.params;
router.get('/', run((req) => forum.listSpaces(req.viewer)));
router.get('/', run((req) => forum.listSpaces(req.viewer)));
+ router.post('/', serviceCap(MOD), jsonBody, run((req) => forum.createSpace(req.viewer, req.body || {}), 201));
+ router.put('/:space/settings', serviceCap(MOD), jsonBody, run((req) => forum.updateSpaceSettings(req.viewer, p(req).space, req.body || {})));
router.get('/:space', run((req) => forum.space(req.viewer, p(req).space)));
router.get('/:space', run((req) => forum.space(req.viewer, p(req).space)));
router.get('/:space/threads', run((req) => forum.listThreads(req.viewer, p(req).space, req.query)));
router.get('/:space/threads', run((req) => forum.listThreads(req.viewer, p(req).space, req.query)));
router.get('/:space/categories', run((req) => forum.categories(req.viewer, p(req).space)));
router.get('/:space/categories', run((req) => forum.categories(req.viewer, p(req).space)));
@@ -61,6 +67,7 @@ function createSpacesApi({ forum, viewers }) {
@@ -61,6 +67,7 @@ function createSpacesApi({ forum, viewers }) {
router.delete('/:space/categories/:category', serviceCap(MOD), run((req) => forum.deleteCategory(req.viewer, p(req).space, p(req).category)));
router.delete('/:space/categories/:category', serviceCap(MOD), run((req) => forum.deleteCategory(req.viewer, p(req).space, p(req).category)));
router.put('/:space/threads/:slug/category', writeOrMod, jsonBody, run((req) => forum.setThreadCategory(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.put('/:space/threads/:slug/category', writeOrMod, jsonBody, run((req) => forum.setThreadCategory(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.put('/:space/threads/:slug/status', serviceCap(MOD), jsonBody, run((req) => forum.setThreadStatus(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.put('/:space/threads/:slug/status', serviceCap(MOD), jsonBody, run((req) => forum.setThreadStatus(req.viewer, p(req).space, p(req).slug, req.body || {})));
+ router.post('/:space/threads/:slug/crosspost', write, jsonBody, run((req) => forum.crosspost(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
router.post('/:space/threads', write, jsonBody, run((req) => forum.createThread(req.viewer, p(req).space, req.body || {}), 201));
router.post('/:space/threads', write, jsonBody, run((req) => forum.createThread(req.viewer, p(req).space, req.body || {}), 201));
router.get('/:space/threads/:slug', run((req) => forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));
router.get('/:space/threads/:slug', run((req) => forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));
router.delete('/:space/threads/:slug', writeOrMod, run((req) => forum.deleteThread(req.viewer, p(req).space, p(req).slug)));
router.delete('/:space/threads/:slug', writeOrMod, run((req) => forum.deleteThread(req.viewer, p(req).space, p(req).slug)));
@@ -81,6 +88,7 @@ function createPostsApi({ forum, viewers }) {
@@ -81,6 +88,7 @@ function createPostsApi({ forum, viewers }) {
const writeOrMod = serviceAnyCap([POST, MOD]);
const writeOrMod = serviceAnyCap([POST, MOD]);
router.put('/:id', writeOrMod, jsonBody, run((req) => forum.editPost(req.viewer, req.params.id, req.body || {})));
router.put('/:id', writeOrMod, jsonBody, run((req) => forum.editPost(req.viewer, req.params.id, req.body || {})));
+ router.post('/:id/reactions', serviceCap(POST), jsonBody, run((req) => forum.react(req.viewer, req.params.id, req.body || {})));
router.delete('/:id', writeOrMod, run((req) => forum.deletePost(req.viewer, req.params.id)));
router.delete('/:id', writeOrMod, run((req) => forum.deletePost(req.viewer, req.params.id)));
router.get('/:id/versions', writeOrMod, run((req) => forum.postVersions(req.viewer, req.params.id)));
router.get('/:id/versions', writeOrMod, run((req) => forum.postVersions(req.viewer, req.params.id)));
@@ -88,4 +96,15 @@ function createPostsApi({ forum, viewers }) {
@@ -88,4 +96,15 @@ function createPostsApi({ forum, viewers }) {
return router;
return router;
}
}
-module.exports = { createSpacesApi, createPostsApi };
+/** /api/v1/space-groups — the board index's groups: GET (everyone), PUT /:group { name, description?, position? } (moderators). */
+function createGroupsApi({ forum, viewers }) {
+ const router = express.Router();
+ router.use(contracts.http.middleware());
+ router.use(viewers.middleware());
+ router.get('/', run(async (req) => ({ groups: (await forum.listSpaces(req.viewer)).groups.map(({ spaces, ...g }) => ({ ...g, spaces: spaces.map((sp) => sp.slug) })) })));
+ router.put('/:group', serviceCap(MOD), jsonBody, run((req) => forum.putGroup(req.viewer, req.params.group, req.body || {})));
+ router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
+ return router;
+}
+
+module.exports = { createSpacesApi, createPostsApi, createGroupsApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
+'use strict';
+
+/**
+ * Ratings on forum posts, after Facepunch: instead of a plain like, a row of specific community judgments.
+ * One rating per person per post (rating again with the same one takes it back; another replaces it);
+ * nobody rates their own post. A space can switch ratings off (spaces.reactions).
+ */
+const REACTIONS = Object.freeze([
+ { key: 'agree', emoji: '✅', label: 'Agree', group: 'positive' },
+ { key: 'winner', emoji: '🏆', label: 'Winner', group: 'positive' },
+ { key: 'funny', emoji: '😂', label: 'Funny', group: 'positive' },
+ { key: 'informative', emoji: '💡', label: 'Informative', group: 'positive' },
+ { key: 'friendly', emoji: '😊', label: 'Friendly', group: 'positive' },
+ { key: 'sympathy', emoji: '❤️', label: 'Sympathy', group: 'positive' },
+ { key: 'dumb', emoji: '📦', label: 'Dumb', group: 'negative' },
+ { key: 'disgusting', emoji: '🤢', label: 'Disgusting', group: 'negative' },
+ { key: 'bad_reading', emoji: '📖', label: 'Bad reading', group: 'utility' },
+ { key: 'late', emoji: '🕒', label: 'Late', group: 'utility' },
+]);
+const BY_KEY = new Map(REACTIONS.map((r) => [r.key, r]));
+const ORDER = new Map(REACTIONS.map((r, i) => [r.key, i]));
+
+/** Set (or with null, remove) a person's rating of a post. → the rating now held, or null */
+function setReaction(db, postId, subject, reaction) {
+ const cur = db.prepare('SELECT reaction FROM post_reactions WHERE post_id = ? AND subject_id = ?').get(postId, subject);
+ if (!reaction || (cur && cur.reaction === reaction)) {
+ db.prepare('DELETE FROM post_reactions WHERE post_id = ? AND subject_id = ?').run(postId, subject);
+ return null;
+ }
+ db.prepare(`INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (?, ?, ?)
+ ON CONFLICT (post_id, subject_id) DO UPDATE SET reaction = excluded.reaction, created_at = CURRENT_TIMESTAMP`).run(postId, subject, reaction);
+ return reaction;
+}
+
+const placeholders = (n) => Array.from({ length: n }, () => '?').join(', ');
+
+/**
+ * Ratings of these posts. → Map postId → [{ key, emoji, label, group, count, raters: [subject…] (first 8), mine }]
+ * in the fixed order, only ratings someone gave.
+ */
+function reactionsFor(db, postIds, viewerSubject = null) {
+ const out = new Map();
+ if (!postIds.length) return out;
+ const rows = db.prepare(`SELECT post_id, reaction, subject_id FROM post_reactions WHERE post_id IN (${placeholders(postIds.length)}) ORDER BY created_at, subject_id`).all(...postIds);
+ for (const r of rows) {
+ const def = BY_KEY.get(r.reaction);
+ if (!def) continue;
+ if (!out.has(r.post_id)) out.set(r.post_id, new Map());
+ const m = out.get(r.post_id);
+ if (!m.has(r.reaction)) m.set(r.reaction, { ...def, count: 0, raters: [], mine: false });
+ const e = m.get(r.reaction);
+ e.count++;
+ if (e.raters.length < 8) e.raters.push(r.subject_id);
+ if (viewerSubject && r.subject_id === viewerSubject) e.mine = true;
+ }
+ return new Map([...out].map(([id, m]) => [id, [...m.values()].sort((a, b) => ORDER.get(a.key) - ORDER.get(b.key))]));
+}
+
+/** The ratings each author has received, most given first (top 3). → Map subject → [{ key, emoji, label, count }] */
+function receivedBy(db, subjects) {
+ const out = new Map();
+ const list = [...new Set(subjects.filter(Boolean))];
+ if (!list.length) return out;
+ const rows = db.prepare(`SELECT p.author_subject AS s, r.reaction, COUNT(*) AS n FROM post_reactions r JOIN posts p ON p.id = r.post_id
+ WHERE p.author_subject IN (${placeholders(list.length)}) AND p.deleted_at IS NULL GROUP BY p.author_subject, r.reaction`).all(...list);
+ for (const r of rows) {
+ const def = BY_KEY.get(r.reaction);
+ if (!def) continue;
+ if (!out.has(r.s)) out.set(r.s, []);
+ out.get(r.s).push({ key: def.key, emoji: def.emoji, label: def.label, count: r.n });
+ }
+ for (const [s, l] of out) out.set(s, l.sort((a, b) => b.count - a.count || ORDER.get(a.key) - ORDER.get(b.key)).slice(0, 3));
+ return out;
+}
+
+module.exports = { REACTIONS, BY_KEY, setReaction, reactionsFor, receivedBy };
@@ -22,6 +22,7 @@ const multer = require('multer');
@@ -22,6 +22,7 @@ const multer = require('multer');
const seo = require('../seo');
const seo = require('../seo');
const pages = require('../render/pages');
const pages = require('../render/pages');
const forumPages = require('../render/forum');
const forumPages = require('../render/forum');
+const boardPages = require('../render/board');
const { ApiError } = require('../http/v1');
const { ApiError } = require('../http/v1');
function createForumRoutes({ forum, viewers, config }) {
function createForumRoutes({ forum, viewers, config }) {
@@ -52,7 +53,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -52,7 +53,7 @@ function createForumRoutes({ forum, viewers, config }) {
/** Render an ApiError as a page: sign-in for 401, the error page for the rest. */
/** Render an ApiError as a page: sign-in for 401, the error page for the rest. */
function failPage(req, res, err, next) {
function failPage(req, res, err, next) {
if (!(err instanceof ApiError)) return next(err);
if (!(err instanceof ApiError)) return next(err);
- if (err.status === 401) return login(res, req.originalUrl.replace(/\/(reply|vote|state|delete|members-only)$/, ''));
+ if (err.status === 401) return login(res, req.originalUrl.replace(/\/(reply|vote|state|delete|members-only|react|crosspost|settings|status|category)$/, ''));
if (err.code === 'vip.members_only' && err.extra) {
if (err.code === 'vip.members_only' && err.extra) {
return html(res, forumPages.membersOnlyPage({ ...err.extra, user: req.user, next: req.originalUrl.replace(/\/(reply|vote|state|delete|members-only|new)$/, '') }), 403);
return html(res, forumPages.membersOnlyPage({ ...err.extra, user: req.user, next: req.originalUrl.replace(/\/(reply|vote|state|delete|members-only|new)$/, '') }), 403);
}
}
@@ -66,8 +67,39 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -66,8 +67,39 @@ function createForumRoutes({ forum, viewers, config }) {
next();
next();
}
}
+ // 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, forumPages.spacesPage({ ...(await forum.listSpaces(req.viewer)), user: req.user })); } 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); }
+ }));
+
+ // Moderators: a new space, either style.
+ 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');
+ html(res, boardPages.newSpacePage({ groups: forum.groups() }));
+ } catch (err) { failPage(req, res, err, next); }
+ }));
+ router.post('/s/new-space', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ const b = req.body || {};
+ const values = { name: b.name, slug: b.slug, description: b.description, style: b.style === 'feed' ? 'feed' : 'forum', votes: b.votes === '1', reactions: b.reactions === '1', group: b.group || null };
+ try {
+ const out = await forum.createSpace(req.viewer, values);
+ seo.resetCaches();
+ res.redirect(303, `/s/${out.space.slug}`);
+ } catch (err) {
+ if (!(err instanceof ApiError) || err.status === 401 || err.status === 403) return failPage(req, res, err, next);
+ html(res, boardPages.newSpacePage({ groups: forum.groups(), error: err.message, values }), err.status);
+ }
+ }));
+
+ // "Discuss in a space" from a paste: pick the space, then its new-topic form with the paste attached.
+ router.get('/s/discuss', withViewer, wrap(async (req, res, next) => {
+ try {
+ const paste = String(req.query.paste || '').slice(0, 80);
+ const { groups } = await forum.listSpaces(req.viewer);
+ html(res, boardPages.discussPage({ groups, paste, user: req.user }));
+ } catch (err) { failPage(req, res, err, next); }
}));
}));
router.get('/s/feed.xml', (_req, res) => {
router.get('/s/feed.xml', (_req, res) => {
@@ -84,7 +116,8 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -84,7 +116,8 @@ function createForumRoutes({ forum, viewers, config }) {
try {
try {
const out = await forum.listThreads(req.viewer, req.params.space, { sort: req.query.sort, page: req.query.page, category: req.query.category, status: req.query.status });
const out = await forum.listThreads(req.viewer, req.params.space, { sort: req.query.sort, page: req.query.page, category: req.query.category, status: req.query.status });
if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
- html(res, forumPages.spacePage({ ...out, user: req.user }));
+ if (out.space.style === 'forum') return html(res, boardPages.forumSpacePage({ ...out, user: req.user }));
+ html(res, forumPages.spacePage({ ...out, user: req.user, footer: out.viewer.can_moderate ? boardPages.settingsForm(out.space, out.groups) : '' }));
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
@@ -94,7 +127,8 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -94,7 +127,8 @@ function createForumRoutes({ forum, viewers, config }) {
const probe = await forum.listThreads(req.viewer, req.params.space, { limit: 1 });
const probe = await forum.listThreads(req.viewer, req.params.space, { limit: 1 });
if (!probe.viewer.can_start) throw new ApiError(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');
if (!probe.viewer.can_start) throw new ApiError(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');
const { space } = await forum.space(req.viewer, req.params.space);
const { space } = await forum.space(req.viewer, req.params.space);
- html(res, forumPages.newThreadPage({ space, user: req.user, categories: probe.categories, attachments: forum.attachmentsEnabled() }));
+ const paste = /^[A-Za-z0-9_-]{3,80}$/.test(String(req.query.paste || '')) ? String(req.query.paste) : '';
+ html(res, forumPages.newThreadPage({ space, user: req.user, categories: probe.categories, attachments: forum.attachmentsEnabled(), values: paste ? { pastes: paste, title: forum.pasteTitle(paste) || '' } : {} }));
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
@@ -102,6 +136,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -102,6 +136,7 @@ function createForumRoutes({ forum, viewers, config }) {
const values = { title: String((req.body || {}).title || '').slice(0, 200), body: String((req.body || {}).body || '').slice(0, 40_000) };
const values = { title: String((req.body || {}).title || '').slice(0, 200), body: String((req.body || {}).body || '').slice(0, 40_000) };
if ((req.body || {}).members_only === '1') values.members_only = true;
if ((req.body || {}).members_only === '1') values.members_only = true;
if ((req.body || {}).category) values.category = String(req.body.category).slice(0, 40);
if ((req.body || {}).category) values.category = String(req.body.category).slice(0, 40);
+ if ((req.body || {}).pastes) values.pastes = String(req.body.pastes).slice(0, 400);
try {
try {
// Images go to Media only when the rest can be saved (no strays from an empty form).
// Images go to Media only when the rest can be saved (no strays from an empty form).
if (worthUploading(values.title, values.body)) { const ids = await uploadImages(req); if (ids.length) values.attachments = ids; }
if (worthUploading(values.title, values.body)) { const ids = await uploadImages(req); if (ids.length) values.attachments = ids; }
@@ -121,7 +156,13 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -121,7 +156,13 @@ function createForumRoutes({ forum, viewers, config }) {
async function renderThread(req, res, { status = 200, error = null, draft = '' } = {}) {
async function renderThread(req, res, { status = 200, error = null, draft = '' } = {}) {
const out = await forum.getThread(req.viewer, req.params.space, req.params.slug, { page: req.query.page });
const out = await forum.getThread(req.viewer, req.params.space, req.params.slug, { page: req.query.page });
if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
- html(res, forumPages.threadPage({ ...out, perPage: out.per_page, user: req.user, error, draft, attachmentsEnabled: forum.attachmentsEnabled() }), status);
+ if (status === 200 && forum.recordView(out.thread.id, req.user && req.user.subject_id ? `s:${req.user.subject_id}` : `ip:${req.ip}`)) out.thread.views += 1;
+ // Quote (no JS): ?quote=<post id> fills the reply box with the quoted post.
+ if (!draft && /^\d{1,15}$/.test(String(req.query.quote || '')) && out.viewer.can_reply) {
+ try { draft = (await forum.quote(req.viewer, req.query.quote)).markdown; } catch { /* a post that is gone: an empty box */ }
+ }
+ const view = { ...out, perPage: out.per_page, user: req.user, error, draft, attachmentsEnabled: forum.attachmentsEnabled() };
+ html(res, out.space.style === 'forum' ? boardPages.forumTopicPage(view) : forumPages.threadPage(view), status);
}
}
router.get('/s/:space/t/:slug', withViewer, wrap(async (req, res, next) => {
router.get('/s/:space/t/:slug', withViewer, wrap(async (req, res, next) => {
@@ -132,7 +173,8 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -132,7 +173,8 @@ function createForumRoutes({ forum, viewers, config }) {
const draft = String((req.body || {}).body || '').slice(0, 40_000);
const draft = String((req.body || {}).body || '').slice(0, 40_000);
try {
try {
const attachments = worthUploading(null, draft) ? await uploadImages(req) : [];
const attachments = worthUploading(null, draft) ? await uploadImages(req) : [];
- const out = await forum.reply(req.viewer, req.params.space, req.params.slug, { body: draft, attachments: attachments.length ? attachments : undefined });
+ const pastes = String((req.body || {}).pastes || '').slice(0, 400) || undefined;
+ const out = await forum.reply(req.viewer, req.params.space, req.params.slug, { body: draft, attachments: attachments.length ? attachments : undefined, pastes });
res.redirect(303, out.url);
res.redirect(303, out.url);
} catch (err) {
} catch (err) {
if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);
if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);
@@ -174,6 +216,35 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -174,6 +216,35 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
+ // Rate a post (no JS): the rating's button in the post's form; back to the post.
+ router.post('/s/:space/t/:slug/react', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ const b = req.body || {};
+ try {
+ await forum.react(req.viewer, String(b.post || ''), { reaction: String(b.reaction || '') || null });
+ const page = Math.max(parseInt(b.page, 10) || 1, 1);
+ res.redirect(303, `${back(req)}${page > 1 ? `?page=${page}` : ''}#post-${encodeURIComponent(String(b.post || ''))}`);
+ } catch (err) { failPage(req, res, err, next); }
+ }));
+
+ // Crosspost to another space (no JS).
+ router.post('/s/:space/t/:slug/crosspost', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ try {
+ const out = await forum.crosspost(req.viewer, req.params.space, req.params.slug, { to: String((req.body || {}).to || '') });
+ seo.resetCaches();
+ res.redirect(303, out.thread.url);
+ } catch (err) { failPage(req, res, err, next); }
+ }));
+
+ // Moderators: a space's settings (no JS).
+ router.post('/s/:space/settings', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ const b = req.body || {};
+ try {
+ await forum.updateSpaceSettings(req.viewer, req.params.space, { name: b.name, description: b.description, style: b.style, votes: b.votes === '1', reactions: b.reactions === '1', group: b.group || null });
+ seo.resetCaches();
+ res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
+ } catch (err) { failPage(req, res, err, next); }
+ }));
+
// Status (moderators) and category (the author or moderators), no JS.
// Status (moderators) and category (the author or moderators), no JS.
router.post('/s/:space/t/:slug/status', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
router.post('/s/:space/t/:slug/status', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
try {
@@ -34,9 +34,12 @@ const { fail, isoTime } = require('../http/v1');
@@ -34,9 +34,12 @@ const { fail, isoTime } = require('../http/v1');
const { createAuthors } = require('../identity/authors');
const { createAuthors } = require('../identity/authors');
const { createPersonLimiter } = require('../limits');
const { createPersonLimiter } = require('../limits');
const { discussionModerator } = require('../identity/capabilities');
const { discussionModerator } = require('../identity/capabilities');
-const { renderMarkdown } = require('../render/markdown');
+const { renderMarkdown, markdownToText } = require('../render/markdown');
const { isUserSubject } = require('../vip');
const { isUserSubject } = require('../vip');
const { stripImageMetadata } = require('../media/strip-metadata');
const { stripImageMetadata } = require('../media/strip-metadata');
+const reactions = require('./reactions');
+const STYLES = ['feed', 'forum'];
+const SPACE_SLUG = /^[a-z0-9][a-z0-9-]{1,39}$/;
const THREADS_PER_PAGE = 25;
const THREADS_PER_PAGE = 25;
/**
/**
@@ -55,6 +58,22 @@ const CATEGORY_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
@@ -55,6 +58,22 @@ const CATEGORY_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
const ATTACH_MAX = 4;
const ATTACH_MAX = 4;
const ATTACH_BYTES = 8 * 1024 * 1024;
const ATTACH_BYTES = 8 * 1024 * 1024;
const MED_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
const MED_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
+// Pastes on posts: a card with the paste's first lines (or its screenshot). Public and unlisted pastes only.
+const PASTE_MAX = 4;
+const PASTE_SLUG = /^[A-Za-z0-9_-]{3,80}$/;
+/** Paste codes from links (…/p/<code>) or bare codes, in a list or one string. → unique codes */
+function parsePasteRefs(input) {
+ if (input === undefined || input === null || input === '') return [];
+ const parts = (Array.isArray(input) ? input : String(input).split(/[\s,]+/)).map((x) => String(x).trim()).filter(Boolean);
+ const out = [];
+ for (const x of parts) {
+ const m = x.match(/\/p\/([A-Za-z0-9_-]{3,80})(?:[/?#]|$)/);
+ const slug = m ? m[1] : x;
+ if (!PASTE_SLUG.test(slug)) fail(400, 'pastes.invalid', `"${x.slice(0, 60)}" is not a paste link`);
+ if (!out.includes(slug)) out.push(slug);
+ }
+ return out;
+}
/** The image type from its first bytes (never the name or the declared type), or null. */
/** The image type from its first bytes (never the name or the declared type), or null. */
function sniffImage(b) {
function sniffImage(b) {
if (!Buffer.isBuffer(b) || b.length < 12) return null;
if (!Buffer.isBuffer(b) || b.length < 12) return null;
@@ -75,6 +94,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -75,6 +94,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const postLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 6, noun: 'posts', ...(limits.posts || {}) });
const postLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 6, noun: 'posts', ...(limits.posts || {}) });
const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });
const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });
const uploadLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 12, duplicate: false, noun: 'uploads', ...(limits.uploads || {}) });
const uploadLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 12, duplicate: false, noun: 'uploads', ...(limits.uploads || {}) });
+ const reactLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'ratings', ...(limits.reactions || {}) });
+ // Views: one per viewer (person, or address) per thread per 30 minutes; memory only.
+ const viewSeen = new Map();
const threadsPerDay = limits.threadsPerDay != null ? limits.threadsPerDay : THREADS_PER_DAY;
const threadsPerDay = limits.threadsPerDay != null ? limits.threadsPerDay : THREADS_PER_DAY;
const hook = (fn) => { try { fn(); } catch (err) { console.warn('[Forum] side effect failed:', err.message); } };
const hook = (fn) => { try { fn(); } catch (err) { console.warn('[Forum] side effect failed:', err.message); } };
@@ -186,7 +208,10 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -186,7 +208,10 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
category: t.category_id ? shapeCategory(store.getCategoryById(db, t.category_id)) : null,
category: t.category_id ? shapeCategory(store.getCategoryById(db, t.category_id)) : null,
members_only: mo ? { owner: mo, owner_username: p && p.username ? p.username : null, join_url: vip ? vip.joinUrl(mo, p && p.username) : null } : null,
members_only: mo ? { owner: mo, owner_username: p && p.username ? p.username : null, join_url: vip ? vip.joinUrl(mo, p && p.username) : null } : null,
author: authors.author(t.author_subject, t.origin, projections), origin: t.origin,
author: authors.author(t.author_subject, t.origin, projections), origin: t.origin,
- pinned: !!t.pinned, locked: !!t.locked, score: t.score, reply_count: t.reply_count,
+ pinned: !!t.pinned, locked: !!t.locked, score: t.score, reply_count: t.reply_count, views: t.views || 0,
+ pages: Math.max(Math.ceil(((t.reply_count || 0) + 1) / POSTS_PER_PAGE), 1),
+ last_post: t.last_post_id ? { id: t.last_post_id, author: authors.author(t.last_author_subject, t.last_origin, projections || new Map()) } : null,
+ crosspost_of: t.crosspost_of || null,
last_activity_at: isoTime(t.last_activity_at), created_at: isoTime(t.created_at),
last_activity_at: isoTime(t.last_activity_at), created_at: isoTime(t.created_at),
my_vote: (votes && votes.get(t.id)) || 0,
my_vote: (votes && votes.get(t.id)) || 0,
};
};
@@ -212,16 +237,48 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -212,16 +237,48 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (rows.some((r) => !r || r.owner_subject !== w.author || r.post_id != null)) fail(400, 'attachments.invalid', 'Attach images you uploaded for this post');
if (rows.some((r) => !r || r.owner_subject !== w.author || r.post_id != null)) fail(400, 'attachments.invalid', 'Attach images you uploaded for this post');
return rows;
return rows;
}
}
+ function claimPastes(refs) {
+ const slugs = parsePasteRefs(refs);
+ if (slugs.length > PASTE_MAX) fail(400, 'pastes.too_many', `At most ${PASTE_MAX} pastes per post`);
+ return slugs.map((slug) => {
+ const r = db.prepare('SELECT id, slug, visibility, burn_after_read, deleted_at FROM pastes WHERE slug = ?').get(slug);
+ if (!r || r.deleted_at) fail(404, 'pastes.not_found', `No paste ${slug}`);
+ if (r.visibility === 'private' || r.burn_after_read) fail(400, 'pastes.not_shareable', `Paste ${slug} is private or burns after reading: it cannot be attached`);
+ return r;
+ });
+ }
+ function attachPastes(rows, postId) {
+ const ins = db.prepare('INSERT OR IGNORE INTO post_pastes (post_id, paste_id, position) VALUES (?, ?, ?)');
+ rows.forEach((r, i) => ins.run(postId, r.id, i));
+ }
+ /** post id → [paste card], live shareable pastes only (a paste made private or deleted drops out). */
+ function pastesOf(postIds) {
+ const out = new Map();
+ if (!postIds.length) return out;
+ const rows = db.prepare(`SELECT pp.post_id, p.slug, p.title, p.language, p.type, p.screenshot_url, p.visibility, p.content
+ FROM post_pastes pp JOIN pastes p ON p.id = pp.paste_id
+ WHERE pp.post_id IN (${postIds.map(() => '?').join(', ')}) AND p.deleted_at IS NULL AND p.visibility != 'private' AND p.burn_after_read = 0
+ ORDER BY pp.post_id, pp.position`).all(...postIds);
+ for (const r of rows) {
+ const lines = String(r.content || '').split('\n');
+ if (!out.has(r.post_id)) out.set(r.post_id, []);
+ out.get(r.post_id).push({ slug: r.slug, title: r.title, language: r.language || 'text', type: r.type, url: `/p/${r.slug}`, screenshot_url: r.type === 'screenshot' ? r.screenshot_url : null,
+ lines: r.content ? lines.length : 0, excerpt: r.type === 'screenshot' ? null : lines.slice(0, 12).join('\n').slice(0, 1500) });
+ }
+ return out;
+ }
+
function attach(rows, postId) {
function attach(rows, postId) {
const set = db.prepare('UPDATE attachments SET post_id = ?, position = ? WHERE media_id = ? AND post_id IS NULL');
const set = db.prepare('UPDATE attachments SET post_id = ?, position = ? WHERE media_id = ? AND post_id IS NULL');
rows.forEach((r, i) => set.run(postId, i, r.media_id));
rows.forEach((r, i) => set.run(postId, i, r.media_id));
}
}
- function shapePost(p, v, projections, attachments = null) {
+ function shapePost(p, v, projections, attachments = null, pastes = null) {
const deleted = !!p.deleted_at;
const deleted = !!p.deleted_at;
const mine = person(v) && p.author_subject === v.subject;
const mine = person(v) && p.author_subject === v.subject;
return {
return {
attachments: deleted ? [] : ((attachments && attachments.get(p.id)) || []),
attachments: deleted ? [] : ((attachments && attachments.get(p.id)) || []),
+ pastes: deleted ? [] : ((pastes && pastes.get(p.id)) || []),
id: p.id, thread_id: p.thread_id, is_opening: !!p.is_opening, origin: p.origin,
id: p.id, thread_id: p.thread_id, is_opening: !!p.is_opening, origin: p.origin,
author: deleted ? null : authors.author(p.author_subject, p.origin, projections),
author: deleted ? null : authors.author(p.author_subject, p.origin, projections),
body_markdown: deleted ? null : p.body_markdown,
body_markdown: deleted ? null : p.body_markdown,
@@ -233,7 +290,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -233,7 +290,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
}
}
async function shapeThreads(rows, spaceOf, v) {
async function shapeThreads(rows, spaceOf, v) {
- const projections = await authors.projectionsFor([...rows.map((r) => r.author_subject), ...rows.map((r) => r.members_only_owner)]);
+ const projections = await authors.projectionsFor([...rows.map((r) => r.author_subject), ...rows.map((r) => r.members_only_owner), ...rows.map((r) => r.last_author_subject)]);
const votes = myVotes(db, 'thread', rows.map((r) => r.id), v && v.subject);
const votes = myVotes(db, 'thread', rows.map((r) => r.id), v && v.subject);
return rows.map((r) => shapeThread(r, spaceOf(r), v, projections, votes));
return rows.map((r) => shapeThread(r, spaceOf(r), v, projections, votes));
}
}
@@ -242,6 +299,10 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -242,6 +299,10 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
return {
return {
slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: `/s/${s.slug}`,
slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: `/s/${s.slug}`,
thread_kind: s.thread_kind || 'discussion', statuses: STATUSES[s.thread_kind] || [],
thread_kind: s.thread_kind || 'discussion', statuses: STATUSES[s.thread_kind] || [],
+ style: STYLES.includes(s.style) ? s.style : 'feed', votes: s.votes !== 0, reactions: s.reactions !== 0,
+ group: s.group_slug ? { slug: s.group_slug, name: s.group_name } : null,
+ parent: s.parent_slug ? { slug: s.parent_slug, name: s.parent_name } : null,
+ post_count: s.post_count != null ? s.post_count : undefined,
members_only: s.members_only_owner ? (mo || { owner: s.members_only_owner, owner_username: null, join_url: vip ? vip.joinUrl(s.members_only_owner) : null }) : null,
members_only: s.members_only_owner ? (mo || { owner: s.members_only_owner, owner_username: null, join_url: vip ? vip.joinUrl(s.members_only_owner) : null }) : null,
thread_count: s.thread_count != null ? s.thread_count : undefined,
thread_count: s.thread_count != null ? s.thread_count : undefined,
last_activity_at: s.last_activity_at !== undefined ? isoTime(s.last_activity_at) : undefined,
last_activity_at: s.last_activity_at !== undefined ? isoTime(s.last_activity_at) : undefined,
@@ -269,6 +330,59 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -269,6 +330,59 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
return { ok: true, id: thread.id, deleted: 'thread' };
return { ok: true, id: thread.id, deleted: 'thread' };
}
}
+ /**
+ * Where a thread came from and where else it was crossposted, as far as the viewer may see.
+ * → { from: { space, title, url } | null, to: [{ space, title, url }], targets: [{ slug, name }] (spaces the viewer may crosspost to) }
+ */
+ function crosspostInfo(v, thread) {
+ const visible = (sp) => sp && !sp.members_only_owner && canRead(v, sp) && (sp.visibility !== 'staff' || moderator(v));
+ let from = null;
+ if (thread.crosspost_of) {
+ const t = store.getThread(db, thread.crosspost_of);
+ const sp = t ? store.getSpaceById(db, t.space_id) : null;
+ if (t && visible(sp) && !t.members_only_owner) from = { space: sp.name, title: t.title, url: `/s/${sp.slug}/t/${t.slug}` };
+ }
+ const to = db.prepare('SELECT t.slug, t.title, s.slug AS space_slug FROM threads t JOIN spaces s ON s.id = t.space_id WHERE t.crosspost_of = ? AND t.deleted_at IS NULL ORDER BY t.id').all(thread.id)
+ .map((r) => ({ r, sp: store.getSpace(db, r.space_slug) })).filter(({ sp }) => visible(sp))
+ .map(({ r, sp }) => ({ space: sp.name, title: r.title, url: `/s/${sp.slug}/t/${r.slug}` }));
+ const targets = person(v) && !thread.members_only_owner
+ ? store.listSpaces(db, moderator(v) ? ['public', 'members', 'staff'] : ['public', 'members']).filter((sp) => sp.id !== thread.space_id && !sp.members_only_owner && (sp.thread_kind !== 'roadmap' || moderator(v)))
+ .map((sp) => ({ slug: sp.slug, name: sp.name }))
+ : [];
+ return { from, to, targets };
+ }
+
+ /** Where a space sits on the board index (breadcrumbs). → { group, parent } */
+ function placeOf(space) {
+ const g = space.group_id ? db.prepare('SELECT slug, name FROM space_groups WHERE id = ?').get(space.group_id) : null;
+ const p = space.parent_id ? db.prepare('SELECT slug, name FROM spaces WHERE id = ?').get(space.parent_id) : null;
+ return { group: g || null, parent: p || null };
+ }
+
+ /** Validated space fields from a settings body (only what was sent). */
+ 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.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; }
+ 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; }
+ if (body.group !== undefined) {
+ const g = body.group ? db.prepare('SELECT id FROM space_groups WHERE slug = ?').get(String(body.group)) : null;
+ if (body.group && !g) fail(404, 'group.not_found', 'No such group on the board index');
+ f.group_id = g ? g.id : null;
+ }
+ if (body.parent !== undefined) {
+ const p = body.parent ? 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 (p && p.parent_id) fail(400, 'space.invalid_parent', 'Child boards go one level deep');
+ f.parent_id = p ? p.id : null;
+ }
+ return f;
+ }
+
// ═════════════════════════════════════════════════════════
// ═════════════════════════════════════════════════════════
return {
return {
SORTS: store.SORTS,
SORTS: store.SORTS,
@@ -276,6 +390,15 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -276,6 +390,15 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
POSTS_PER_PAGE,
POSTS_PER_PAGE,
isModerator: moderator,
isModerator: moderator,
+ /** The board index's groups (for the settings and new-space forms). */
+ groups: () => store.listGroups(db).map((g) => ({ slug: g.slug, name: g.name })),
+
+ /** A shareable paste's title (the new-topic form's "Discuss in a space"), or null. */
+ pasteTitle(slug) {
+ const r = db.prepare("SELECT title FROM pastes WHERE slug = ? AND deleted_at IS NULL AND visibility != 'private' AND burn_after_read = 0").get(String(slug));
+ return r ? r.title : null;
+ },
+
/** Whether images can be attached (Media and the service principal are configured). */
/** Whether images can be attached (Media and the service principal are configured). */
attachmentsEnabled: () => !!(media && media.configured),
attachmentsEnabled: () => !!(media && media.configured),
@@ -285,8 +408,65 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -285,8 +408,65 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (person(v) || moderator(v)) vis.push('members');
if (person(v) || moderator(v)) vis.push('members');
if (moderator(v)) vis.push('staff');
if (moderator(v)) vis.push('staff');
const rows = store.listSpaces(db, vis);
const rows = store.listSpaces(db, vis);
- const projections = await authors.projectionsFor(rows.map((r) => r.members_only_owner));
- return { spaces: await Promise.all(rows.map(async (r) => shapeSpace(r, await membersOnly(r.members_only_owner, projections)))) };
+ const last = store.lastPosts(db, rows.filter((r) => !r.members_only_owner).map((r) => r.id));
+ const projections = await authors.projectionsFor([...rows.map((r) => r.members_only_owner), ...[...last.values()].map((l) => l.author_subject)]);
+ const shaped = await Promise.all(rows.map(async (r) => {
+ const sp = shapeSpace(r, await membersOnly(r.members_only_owner, projections));
+ const l = last.get(r.id);
+ sp.last_post = l ? { id: l.post_id, thread: { slug: l.thread_slug, title: l.thread_title, url: `/s/${r.slug}/t/${l.thread_slug}` }, author: authors.author(l.author_subject, l.origin, projections), created_at: isoTime(l.created_at) } : null;
+ return sp;
+ }));
+ // The board index: groups in order, each with its top-level spaces; child boards under their parent.
+ const bySlug = new Map(shaped.map((sp) => [sp.slug, { ...sp, children: [] }]));
+ for (const sp of bySlug.values()) if (sp.parent && bySlug.has(sp.parent.slug)) bySlug.get(sp.parent.slug).children.push(sp);
+ const top = [...bySlug.values()].filter((sp) => !(sp.parent && bySlug.has(sp.parent.slug)));
+ const groups = 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 });
+ return { spaces: shaped, groups };
+ },
+
+ /** The board index's groups — moderators create or change one { name, description?, position? }. */
+ putGroup(v, slug, body = {}) {
+ if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage the board index');
+ if (!CATEGORY_SLUG.test(String(slug || ''))) fail(400, 'group.invalid_slug', 'A group slug is 1 to 40 lowercase letters, digits and dashes');
+ const name = cleanTitle(body.name);
+ if (name.length < 2 || name.length > 60) fail(400, 'group.invalid_name', 'Group names are 2 to 60 characters');
+ db.prepare(`INSERT INTO space_groups (slug, name, description, position) VALUES (?, ?, ?, ?)
+ ON CONFLICT (slug) DO UPDATE SET name = excluded.name, description = excluded.description, position = excluded.position`)
+ .run(slug, name, body.description == null ? null : cleanTitle(body.description).slice(0, 200) || null, Number.isInteger(Number(body.position)) ? Number(body.position) : 0);
+ return { group: db.prepare('SELECT slug, name, description, position FROM space_groups WHERE slug = ?').get(slug) };
+ },
+
+ /**
+ * A space's settings — moderators. { name?, description?, style?: feed|forum, votes?, reactions?, group?: slug|null,
+ * parent?: slug|null, position?, kind?: discussion|request|roadmap }
+ */
+ async updateSpaceSettings(v, spaceSlug, body = {}) {
+ if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a space');
+ const space = spaceFor(v, spaceSlug);
+ const fields = spaceFields(body, space);
+ const next = store.updateSpace(db, space.id, fields);
+ const row = store.listSpaces(db, ['public', 'members', 'staff']).find((r) => r.id === next.id) || next;
+ return { space: shapeSpace(row, await membersOnly(row.members_only_owner)) };
+ },
+
+ /** 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');
+ 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 (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 = spaceFields({ ...body, name: body.name }, null);
+ if (!fields.name) fail(400, 'space.invalid_name', 'Name the space');
+ const style = fields.style || 'feed';
+ 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,
+ parent_id: fields.parent_id || null, position: fields.position || 0, thread_kind: fields.thread_kind || 'discussion' });
+ const row = store.listSpaces(db, ['public', 'members', 'staff']).find((r) => r.slug === slug);
+ return { space: shapeSpace(row) };
},
},
async space(v, slug) {
async space(v, slug) {
@@ -298,7 +478,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -298,7 +478,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
async listThreads(v, spaceSlug, q = {}) {
async listThreads(v, spaceSlug, q = {}) {
const space = spaceFor(v, spaceSlug);
const space = spaceFor(v, spaceSlug);
await requireMembership(v, space);
await requireMembership(v, space);
- const sort = store.SORTS.includes(q.sort) ? q.sort : 'hot';
+ const forumStyle = space.style === 'forum';
+ let sort = store.SORTS.includes(q.sort) ? q.sort : (forumStyle ? 'active' : 'hot');
+ if (sort === 'top' && space.votes === 0) sort = forumStyle ? 'active' : 'hot';
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 ? store.getCategory(db, space.id, q.category) : null;
const category = q.category ? store.getCategory(db, space.id, q.category) : null;
@@ -306,10 +488,15 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -306,10 +488,15 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
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 space have no status');
const { rows, total } = 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 } = 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) || {};
return {
return {
- space: shapeSpace(space, await membersOnly(space.members_only_owner)), sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),
+ space: { ...shapeSpace(space, await membersOnly(space.members_only_owner)), group: self.group || null, parent: self.parent || null },
+ sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),
categories: store.listCategories(db, space.id).map(shapeCategory), category: category ? category.slug : null, status: q.status || null,
categories: store.listCategories(db, space.id).map(shapeCategory), category: category ? category.slug : null, status: q.status || null,
- viewer: { can_start: space.thread_kind !== 'roadmap' || moderator(v), can_moderate: moderator(v) },
+ viewer: { can_start: space.thread_kind !== 'roadmap' || moderator(v), can_moderate: moderator(v), signed_in: person(v) },
+ children: index.spaces.filter((c) => c.parent && c.parent.slug === space.slug),
+ groups: moderator(v) ? store.listGroups(db).map((g) => ({ slug: g.slug, name: g.name })) : undefined,
threads: await shapeThreads(rows, () => space, v),
threads: await shapeThreads(rows, () => space, v),
};
};
},
},
@@ -343,6 +530,78 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -343,6 +530,78 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
return { attachment: shapeAttachment({ media_id: stored.id, url: stored.url, filename, mime, size_bytes: buffer.length }) };
return { attachment: shapeAttachment({ media_id: stored.id, url: stored.url, filename, mime, size_bytes: buffer.length }) };
},
},
+ /**
+ * Rate a post { reaction: agree|winner|funny|informative|friendly|sympathy|dumb|disgusting|bad_reading|late | null }.
+ * One rating per person per post: the same one again takes it back, another replaces it. Not your own post,
+ * not in a space with ratings off, not on a locked thread. → { post_id, mine, reactions }
+ */
+ async react(v, postId, body = {}) {
+ if (!person(v)) fail(401, 'auth.required', 'Sign in to rate posts');
+ const { post, thread, space } = postFor(v, postId);
+ if (space.reactions === 0) fail(403, 'space.reactions_off', 'Ratings are off in this space');
+ await requireMembership(v, space, thread);
+ if (thread.locked && !moderator(v)) 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');
+ const reaction = body.reaction == null || body.reaction === '' ? null : String(body.reaction);
+ if (reaction && !reactions.BY_KEY.has(reaction)) fail(400, 'reaction.invalid', `reaction is one of ${reactions.REACTIONS.map((r) => r.key).join(', ')}`);
+ reactLimiter.check(`s:${v.subject}`);
+ const mine = reactions.setReaction(db, post.id, v.subject, reaction);
+ reactLimiter.record(`s:${v.subject}`);
+ const list = reactions.reactionsFor(db, [post.id], v.subject).get(post.id) || [];
+ return { post_id: post.id, mine, reactions: list.map((e) => ({ key: e.key, emoji: e.emoji, label: e.label, count: e.count, mine: e.mine })) };
+ },
+
+ /**
+ * Crosspost a thread to another space { to: slug } — people. The new thread links back to the original and
+ * quotes its opening; both show the link. Not members-only threads; not into roadmap spaces (staff excepted).
+ */
+ async crosspost(v, spaceSlug, threadSlug, body = {}) {
+ const { space, thread } = threadFor(v, spaceSlug, threadSlug);
+ const w = writer(v);
+ if (!w.author) fail(403, 'crosspost.person_only', 'Only people crosspost');
+ 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 = spaceFor(v, String(body.to || ''));
+ if (target.id === space.id) fail(400, 'crosspost.same_space', 'Pick another space');
+ mayPostIn(v, target);
+ await requireMembership(v, target);
+ const kind = target.thread_kind || 'discussion';
+ if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff');
+ if (store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay && threadsPerDay > 0) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);
+ const opening = db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id);
+ const excerpt = markdownToText(opening ? opening.body_markdown : '', 400).split('\n').map((l) => `> ${l}`).join('\n');
+ const text = `Crossposted from **${space.name}**: [${thread.title.replace(/[[\]]/g, '')}](/s/${space.slug}/t/${thread.slug})${excerpt.trim() !== '>' ? `\n\n${excerpt}` : ''}`;
+ const { thread: created } = store.createThread(db, { space_id: target.id, title: thread.title, author_subject: w.author, origin: 'user', body_markdown: text,
+ kind, status: FIRST_STATUS[kind] || null, crosspost_of: thread.id });
+ if (pulse) hook(() => pulse.threadCreated(created, target));
+ if (relay) hook(() => relay.enqueueThread(created, target));
+ const projections = await authors.projectionsFor([created.author_subject]);
+ return { thread: shapeThread(created, target, v, projections, null) };
+ },
+
+ /** Quote a post into a reply: "**name** wrote:" and the post as a Markdown quote. → { markdown } */
+ async quote(v, postId) {
+ const { post, thread, space } = postFor(v, postId);
+ await requireMembership(v, space, thread);
+ const projections = await authors.projectionsFor([post.author_subject]);
+ const a = authors.author(post.author_subject, post.origin, projections);
+ const name = a ? (a.username ? `@${a.username}` : a.display_name || 'Someone') : 'Someone';
+ const quoted = String(post.body_markdown || '').split('\n').slice(0, 40).map((l) => `> ${l}`).join('\n');
+ return { markdown: `**${name}** wrote:\n${quoted}\n\n` };
+ },
+
+ /** A page view of a thread (the forum's Views): once per viewer per 30 minutes. */
+ recordView(threadId, viewerKey) {
+ const key = `${threadId}|${viewerKey || '?'}`;
+ const now = Date.now();
+ const seen = viewSeen.get(key);
+ if (seen && now - seen < 30 * 60 * 1000) return false;
+ if (viewSeen.size > 50000) viewSeen.clear();
+ viewSeen.set(key, now);
+ store.bumpViews(db, threadId);
+ return true;
+ },
+
/** The categories of a space. */
/** The categories of a space. */
categories(v, spaceSlug) {
categories(v, spaceSlug) {
const space = spaceFor(v, spaceSlug);
const space = spaceFor(v, spaceSlug);
@@ -400,17 +659,35 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -400,17 +659,35 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const { rows, total } = store.listPosts(db, thread.id, { limit: POSTS_PER_PAGE, offset: (page - 1) * POSTS_PER_PAGE });
const { rows, total } = store.listPosts(db, thread.id, { limit: POSTS_PER_PAGE, offset: (page - 1) * POSTS_PER_PAGE });
const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner, space.members_only_owner, ...rows.map((p) => p.author_subject)]);
const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner, space.members_only_owner, ...rows.map((p) => p.author_subject)]);
const votes = myVotes(db, 'thread', [thread.id], v && v.subject);
const votes = myVotes(db, 'thread', [thread.id], v && v.subject);
+ const ids = rows.map((p) => p.id);
+ const att = attachmentsOf(ids);
+ const pasted = pastesOf(ids);
+ const rated = space.reactions !== 0 ? reactions.reactionsFor(db, ids, person(v) ? v.subject : null) : new Map();
+ const authorSubjects = rows.map((p) => p.author_subject);
+ const stats = store.authorStats(db, authorSubjects);
+ const received = space.reactions !== 0 ? reactions.receivedBy(db, authorSubjects) : new Map();
+ const raterProjections = await authors.projectionsFor([...rated.values()].flatMap((l) => l.flatMap((e) => e.raters)));
return {
return {
- space: shapeSpace(space, await membersOnly(space.members_only_owner, projections)),
+ space: { ...shapeSpace(space, await membersOnly(space.members_only_owner, projections)), ...placeOf(space) },
thread: shapeThread(thread, space, v, projections, votes),
thread: shapeThread(thread, space, v, projections, votes),
- posts: (() => { const att = attachmentsOf(rows.map((p) => p.id)); return rows.map((p) => shapePost(p, v, projections, att)); })(),
+ posts: rows.map((p) => {
+ const sp = shapePost(p, v, projections, att, pasted);
+ const st = p.author_subject ? stats.get(p.author_subject) : null;
+ sp.author_stats = st ? { posts: st.posts, first_post_at: isoTime(st.first_post_at), ratings: received.get(p.author_subject) || [] } : null;
+ sp.reactions = p.deleted_at ? [] : (rated.get(p.id) || []).map((e) => ({ key: e.key, emoji: e.emoji, label: e.label, group: e.group, count: e.count, mine: e.mine,
+ raters: e.raters.map((sub) => { const pr = raterProjections.get(sub); return pr ? (pr.display_name || pr.username) : null; }).filter(Boolean) }));
+ sp.can_react = space.reactions !== 0 && !p.deleted_at && person(v) && p.author_subject !== v.subject && (!thread.locked || moderator(v));
+ return sp;
+ }),
+ reactions: space.reactions !== 0 ? reactions.REACTIONS : [],
+ crosspost: crosspostInfo(v, thread),
categories: store.listCategories(db, space.id).map(shapeCategory),
categories: store.listCategories(db, space.id).map(shapeCategory),
attachments: { enabled: !!(media && media.configured), max: ATTACH_MAX, max_bytes: ATTACH_BYTES },
attachments: { enabled: !!(media && media.configured), max: ATTACH_MAX, max_bytes: ATTACH_BYTES },
page, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,
page, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,
viewer: {
viewer: {
signed_in: person(v),
signed_in: person(v),
can_reply: (person(v) || (v && v.origin === 'ai' && v.kind === 'service')) && (!thread.locked || moderator(v)) && (space.visibility !== 'staff' || moderator(v)),
can_reply: (person(v) || (v && v.origin === 'ai' && v.kind === 'service')) && (!thread.locked || moderator(v)) && (space.visibility !== 'staff' || moderator(v)),
- can_vote: person(v) && !thread.locked,
+ can_vote: person(v) && !thread.locked && space.votes !== 0,
can_moderate: moderator(v),
can_moderate: moderator(v),
can_delete: moderator(v) || (person(v) && thread.author_subject === v.subject),
can_delete: moderator(v) || (person(v) && thread.author_subject === v.subject),
can_gate: moderator(v) || (person(v) && thread.author_subject === v.subject),
can_gate: moderator(v) || (person(v) && thread.author_subject === v.subject),
@@ -436,6 +713,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -436,6 +713,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
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);
const images = claimable(w, body.attachments);
const images = claimable(w, body.attachments);
+ const pasteRows = claimPastes(body.pastes);
if (w.key) {
if (w.key) {
threadLimiter.check(w.key, title);
threadLimiter.check(w.key, title);
if (threadsPerDay > 0 && store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);
if (threadsPerDay > 0 && store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);
@@ -444,10 +722,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -444,10 +722,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
kind, status: FIRST_STATUS[kind] || null, category_id: category ? category.id : null });
kind, status: FIRST_STATUS[kind] || null, category_id: category ? category.id : null });
threadLimiter.record(w.key, title);
threadLimiter.record(w.key, title);
attach(images, post.id);
attach(images, post.id);
+ attachPastes(pasteRows, post.id);
if (pulse) hook(() => pulse.threadCreated(thread, space));
if (pulse) hook(() => pulse.threadCreated(thread, space));
if (relay) hook(() => relay.enqueueThread(thread, space));
if (relay) hook(() => relay.enqueueThread(thread, space));
const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner]);
const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner]);
- return { thread: shapeThread(thread, space, v, projections, null), post: shapePost(post, v, projections, attachmentsOf([post.id])) };
+ return { thread: shapeThread(thread, space, v, projections, null), post: shapePost(post, v, projections, attachmentsOf([post.id]), pastesOf([post.id])) };
},
},
/** Reply { body } → { post } */
/** Reply { body } → { post } */
@@ -459,16 +738,18 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -459,16 +738,18 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
const images = claimable(w, body.attachments);
const images = claimable(w, body.attachments);
+ const pasteRows = claimPastes(body.pastes);
postLimiter.check(w.key, text);
postLimiter.check(w.key, text);
const post = store.addPost(db, { thread_id: thread.id, author_subject: w.author, origin: w.origin, body_markdown: text });
const post = store.addPost(db, { thread_id: thread.id, author_subject: w.author, origin: w.origin, body_markdown: text });
postLimiter.record(w.key, text);
postLimiter.record(w.key, text);
attach(images, post.id);
attach(images, post.id);
+ attachPastes(pasteRows, post.id);
if (pulse) hook(() => pulse.postCreated(post, thread, space));
if (pulse) hook(() => pulse.postCreated(post, thread, space));
const projections = await authors.projectionsFor([post.author_subject]);
const projections = await authors.projectionsFor([post.author_subject]);
// Where the new post lands: its page in the thread (posts are numbered in id order).
// Where the new post lands: its page in the thread (posts are numbered in id order).
const position = db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id).c;
const position = db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id).c;
const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);
const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);
- return { post: shapePost(post, v, projections, attachmentsOf([post.id])), page, url: `${threadUrl(space, thread)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}` };
+ return { post: shapePost(post, v, projections, attachmentsOf([post.id]), pastesOf([post.id])), page, url: `${threadUrl(space, thread)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}` };
},
},
/** Edit { body } — the author (not on a locked thread) or a moderator. */
/** Edit { body } — the author (not on a locked thread) or a moderator. */
@@ -517,6 +798,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -517,6 +798,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 } = threadFor(v, spaceSlug, threadSlug);
const { space, thread } = threadFor(v, spaceSlug, threadSlug);
+ if (space.votes === 0) fail(403, 'space.votes_off', 'Votes are off in this space');
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}`);
@@ -584,4 +866,4 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -584,4 +866,4 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
};
};
}
}
-module.exports = { createForumService, THREADS_PER_PAGE, POSTS_PER_PAGE, STATUSES, ATTACH_MAX, sniffImage };
+module.exports = { createForumService, THREADS_PER_PAGE, POSTS_PER_PAGE, STATUSES, ATTACH_MAX, sniffImage, STYLES, REACTIONS: reactions.REACTIONS };
@@ -9,16 +9,67 @@
@@ -9,16 +9,67 @@
* after it keep their numbering; a deleted thread disappears from every listing.
* after it keep their numbering; a deleted thread disappears from every listing.
*/
*/
-const SORTS = ['hot', 'new', 'top'];
+const SORTS = ['hot', 'new', 'top', 'active'];
// ── Spaces ───────────────────────────────────────────────────
// ── Spaces ───────────────────────────────────────────────────
function listSpaces(db, visibilities) {
function listSpaces(db, visibilities) {
- return db.prepare(`SELECT s.*,
+ return db.prepare(`SELECT s.*, g.slug AS group_slug, g.name AS group_name, g.description AS group_description, g.position AS group_position,
+ ps.slug AS parent_slug, ps.name AS parent_name,
(SELECT COUNT(*) FROM threads t WHERE t.space_id = s.id AND t.deleted_at IS NULL) AS thread_count,
(SELECT COUNT(*) FROM threads t WHERE t.space_id = s.id AND t.deleted_at IS NULL) AS thread_count,
+ (SELECT COUNT(*) FROM posts p JOIN threads t ON t.id = p.thread_id WHERE t.space_id = s.id AND t.deleted_at IS NULL AND p.deleted_at IS NULL) AS post_count,
(SELECT MAX(t.last_activity_at) FROM threads t WHERE t.space_id = s.id AND t.deleted_at IS NULL) AS last_activity_at
(SELECT MAX(t.last_activity_at) FROM threads t WHERE t.space_id = s.id AND t.deleted_at IS NULL) AS last_activity_at
- FROM spaces s WHERE s.visibility IN (${visibilities.map(() => '?').join(', ')})
- ORDER BY s.id ASC`).all(...visibilities);
+ FROM spaces s LEFT JOIN space_groups g ON g.id = s.group_id LEFT JOIN spaces ps ON ps.id = s.parent_id
+ WHERE s.visibility IN (${visibilities.map(() => '?').join(', ')})
+ ORDER BY g.position IS NULL, g.position, s.position, s.id ASC`).all(...visibilities);
+}
+
+/**
+ * The newest live post in each space (the board index's "Last post"), members-only threads never.
+ * → Map spaceId → { thread_slug, thread_title, author_subject, origin, created_at }
+ */
+function lastPosts(db, spaceIds) {
+ const out = new Map();
+ const q = db.prepare(`SELECT t.slug AS thread_slug, t.title AS thread_title, p.author_subject, p.origin, p.created_at, p.id AS post_id
+ FROM posts p JOIN threads t ON t.id = p.thread_id
+ WHERE t.space_id = ? AND t.deleted_at IS NULL AND p.deleted_at IS NULL AND t.members_only_owner IS NULL
+ ORDER BY p.id DESC LIMIT 1`);
+ for (const id of spaceIds) { const r = q.get(id); if (r) out.set(id, r); }
+ return out;
+}
+
+function listGroups(db) {
+ return db.prepare('SELECT * FROM space_groups ORDER BY position, name COLLATE NOCASE').all();
+}
+
+/** Space settings (moderators): style, votes, reactions, group, parent, position, name, description. */
+function updateSpace(db, id, fields) {
+ const allowed = ['style', 'votes', 'reactions', 'group_id', 'parent_id', 'position', 'name', 'description', 'thread_kind'];
+ const keys = Object.keys(fields).filter((k) => allowed.includes(k));
+ if (keys.length) db.prepare(`UPDATE spaces SET ${keys.map((k) => `${k} = @${k}`).join(', ')} WHERE id = @id`).run({ ...Object.fromEntries(keys.map((k) => [k, fields[k]])), id });
+ return getSpaceById(db, id);
+}
+
+function createSpace(db, { slug, name, description = null, visibility = 'public', created_by, style = 'feed', votes = 1, reactions = 1, group_id = null, parent_id = null, position = 0, thread_kind = 'discussion' }) {
+ db.prepare(`INSERT INTO spaces (slug, name, description, visibility, created_by, style, votes, reactions, group_id, parent_id, position, thread_kind)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(slug, name, description, visibility, created_by, style, votes, reactions, group_id, parent_id, position, thread_kind);
+ return getSpace(db, slug);
+}
+
+/** A person's forum statistics for the author panel. → Map subject → { posts, first_post_at } */
+function authorStats(db, subjects) {
+ const out = new Map();
+ const list = [...new Set(subjects.filter(Boolean))];
+ if (!list.length) return out;
+ const rows = db.prepare(`SELECT author_subject AS s, COUNT(*) AS posts, MIN(created_at) AS first_post_at FROM posts
+ WHERE author_subject IN (${list.map(() => '?').join(', ')}) AND deleted_at IS NULL GROUP BY author_subject`).all(...list);
+ for (const r of rows) out.set(r.s, { posts: r.posts, first_post_at: r.first_post_at });
+ return out;
+}
+
+/** One more view of a thread (the forum's Views column). */
+function bumpViews(db, threadId) {
+ db.prepare('UPDATE threads SET views = views + 1 WHERE id = ?').run(threadId);
}
}
function getSpace(db, slug) {
function getSpace(db, slug) {
@@ -57,11 +108,11 @@ function getThreadBySlug(db, spaceId, slug) {
@@ -57,11 +108,11 @@ function getThreadBySlug(db, spaceId, slug) {
}
}
/** New thread + its opening post, in one transaction. → { thread, post } */
/** New thread + its opening post, in one transaction. → { thread, post } */
-function createThread(db, { space_id, title, author_subject = null, origin = 'user', body_markdown, members_only_owner = null, kind = 'discussion', status = null, category_id = null, external_key = null }) {
+function createThread(db, { space_id, title, author_subject = null, origin = 'user', body_markdown, members_only_owner = null, kind = 'discussion', status = null, category_id = null, external_key = null, crosspost_of = null }) {
return db.transaction(() => {
return db.transaction(() => {
const slug = uniqueSlug(db, space_id, slugify(title));
const slug = uniqueSlug(db, space_id, slugify(title));
- const info = db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
- .run(space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key);
+ const info = db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
+ .run(space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of);
const threadId = info.lastInsertRowid;
const threadId = info.lastInsertRowid;
const p = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?)')
const p = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?)')
.run(threadId, author_subject, origin, body_markdown);
.run(threadId, author_subject, origin, body_markdown);
@@ -85,10 +136,16 @@ function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now =
@@ -85,10 +136,16 @@ function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now =
hot: 'pinned DESC, ov_hot(score, (julianday(@now) - julianday(created_at)) * 24) DESC, last_activity_at DESC, id DESC',
hot: 'pinned DESC, ov_hot(score, (julianday(@now) - julianday(created_at)) * 24) DESC, last_activity_at DESC, id DESC',
new: 'pinned DESC, created_at DESC, id DESC',
new: 'pinned DESC, created_at DESC, id DESC',
top: 'pinned DESC, score DESC, created_at DESC, id DESC',
top: 'pinned DESC, score DESC, created_at DESC, id DESC',
+ active: 'pinned DESC, last_activity_at DESC, id DESC',
}[SORTS.includes(sort) ? sort : 'hot'];
}[SORTS.includes(sort) ? sort : 'hot'];
const where = 'space_id = @space AND deleted_at IS NULL AND (@category IS NULL OR category_id = @category) AND (@status IS NULL OR status = @status)';
const where = 'space_id = @space AND deleted_at IS NULL AND (@category IS NULL OR category_id = @category) AND (@status IS NULL OR status = @status)';
const params = { space: spaceId, category: categoryId, status };
const params = { space: spaceId, category: categoryId, status };
- const rows = db.prepare(`SELECT * FROM threads WHERE ${where} ORDER BY ${order} LIMIT @limit OFFSET @offset`)
+ // last_* : the newest live post, for the forum's "Last post" column.
+ const rows = db.prepare(`SELECT threads.*,
+ (SELECT p.author_subject FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_author_subject,
+ (SELECT p.origin FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_origin,
+ (SELECT p.id FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_post_id
+ FROM threads WHERE ${where} ORDER BY ${order} LIMIT @limit OFFSET @offset`)
.all({ ...params, now: nowSql, limit, offset });
.all({ ...params, now: nowSql, limit, offset });
const { total } = db.prepare(`SELECT COUNT(*) AS total FROM threads WHERE ${where}`).get(params);
const { total } = db.prepare(`SELECT COUNT(*) AS total FROM threads WHERE ${where}`).get(params);
return { rows, total };
return { rows, total };
@@ -246,5 +303,6 @@ module.exports = {
@@ -246,5 +303,6 @@ module.exports = {
listSpaces, getSpace, getSpaceById,
listSpaces, getSpace, getSpaceById,
getThread, getThreadBySlug, createThread, listThreads, recentThreads, setThreadFlags, setThreadMembersOnly, setSpaceMembersOnly, softDeleteThread, countThreadsSince,
getThread, getThreadBySlug, createThread, listThreads, recentThreads, setThreadFlags, setThreadMembersOnly, setSpaceMembersOnly, softDeleteThread, countThreadsSince,
listCategories, getCategory, getCategoryById, upsertCategory, deleteCategory, setThreadCategory, setThreadStatus, getThreadByKey,
listCategories, getCategory, getCategoryById, upsertCategory, deleteCategory, setThreadCategory, setThreadStatus, getThreadByKey,
+ lastPosts, listGroups, updateSpace, createSpace, authorStats, bumpViews,
getPost, addPost, listPosts, editPost, listPostVersions, softDeletePost, countPostsSince,
getPost, addPost, listPosts, editPost, listPostVersions, softDeletePost, countPostsSince,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,279 @@
@@ -0,0 +1,279 @@
+'use strict';
+
+/**
+ * Forum-style pages, after vBulletin and SMF: the board index at /s (every space, grouped, with topics,
+ * posts and the last post), a forum-style space as a topic table (Replies, Views, Last post; sticky topics
+ * first; bumped by the newest post) and a topic as posts with the author's panel beside each (picture,
+ * posts, since when, the ratings they get most), with Quote. Feed-style spaces keep render/forum.js
+ * (subreddit-like cards, votes, hot/new/top); both share threads, posts, ratings, images and pastes.
+ * Complete without JavaScript: every action is a link or a form post.
+ */
+const seo = require('../seo');
+const { renderPage } = require('./layout');
+const { escapeHtml: esc } = require('./highlight');
+const { markdownToText } = require('./markdown');
+const { timeTag, fmtDate, num, avatarUrl } = require('./pages');
+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 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. */
+function boardRow(sp) {
+ const icon = sp.style === 'forum' ? 'fa-comments' : 'fa-fire';
+ const last = sp.last_post
+ ? `<a href="${esc(sp.last_post.thread.url)}#post-${Number(sp.last_post.id)}">${esc(seo.clean(sp.last_post.thread.title, 48))}</a><span class="small muted">by ${esc(sp.last_post.author ? (sp.last_post.author.display_name || sp.last_post.author.username || 'someone') : 'someone')} · ${timeTag(sp.last_post.created_at)}</span>`
+ : '<span class="muted small">No posts yet</span>';
+ const children = (sp.children || []).length ? `<p class="small board-children">Child boards: ${sp.children.map((c) => `<a href="/s/${esc(c.slug)}">${esc(c.name)}</a>`).join(', ')}</p>` : '';
+ return `<tr>
+ <td class="board-name"><span class="board-icon" aria-hidden="true"><i class="fa-solid ${icon}"></i></span><div>
+ <a class="board-title" href="/s/${esc(sp.slug)}">${esc(sp.name)}</a> <span class="badge badge-style">${STYLE_LABELS[sp.style] || 'Feed'}</span>${f.visBadge(sp)}
+ ${sp.description ? `<p class="muted small">${esc(sp.description)}</p>` : ''}${children}
+ </div></td>
+ <td class="num" data-label="Topics">${num(sp.thread_count)}</td>
+ <td class="num" data-label="Posts">${num(sp.post_count)}</td>
+ <td class="board-last" data-label="Last post">${last}</td>
+ </tr>`;
+}
+
+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>
+ <tbody>${spaces.map(boardRow).join('')}</tbody></table>`;
+}
+
+// ── /s ───────────────────────────────────────────────────────
+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>' : ''}
+</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>'}
+${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.',
+ canonicalPath: '/s',
+ active: 'spaces',
+ feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],
+ jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }])],
+ body,
+ });
+}
+
+/** A topic's page links (1 2 3 … last) for the topic table. */
+function topicPages(base, pages) {
+ if (pages <= 1) return '';
+ const n = pages <= 5 ? Array.from({ length: pages }, (_, i) => i + 1) : [1, 2, 3, null, pages];
+ return ` <span class="topic-pages">Pages: ${n.map((p) => (p ? `<a href="${esc(base)}${p > 1 ? `?page=${p}` : ''}">${p}</a>` : '…')).join(' ')}</span>`;
+}
+
+function topicRow(t, space) {
+ const base = `/s/${space.slug}/t/${t.slug}`;
+ const icon = t.locked ? 'fa-lock' : t.pinned ? 'fa-thumbtack' : t.reply_count >= 15 ? 'fa-fire-flame-curved' : 'fa-comment';
+ const label = t.locked ? 'Locked' : t.pinned ? 'Sticky' : t.reply_count >= 15 ? 'Hot topic' : 'Topic';
+ const who = (a) => esc(a ? (a.display_name || a.username || 'someone') : 'someone');
+ return `<tr class="${t.pinned ? 'sticky' : ''}">
+ <td class="topic-icon" aria-hidden="true"><i class="fa-solid ${icon}" title="${label}"></i></td>
+ <td class="topic-main">${t.pinned ? '<span class="badge badge-sticky">Sticky</span> ' : ''}<a class="topic-title" href="${esc(base)}">${esc(t.title)}</a>${f.statusBadge(t)}${f.categoryBadge(t, space)}${f.vipBadge(t)}
+ <span class="small muted topic-by">Started by ${who(t.author)} · ${timeTag(t.created_at)}${topicPages(base, t.pages || 1)}</span></td>
+ <td class="num" data-label="Replies">${num(t.reply_count)}</td>
+ <td class="num" data-label="Views">${num(t.views)}</td>
+ <td class="board-last" data-label="Last post">${timeTag(t.last_activity_at)}<span class="small muted">by ${who(t.last_post ? t.last_post.author : t.author)}${t.last_post ? ` <a href="${esc(base)}${t.pages > 1 ? `?page=${t.pages}` : ''}#post-${Number(t.last_post.id)}" aria-label="Go to the last post">»</a>` : ''}</span></td>
+ </tr>`;
+}
+
+/** 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>
+<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="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>
+ <div class="form-actions"><button class="btn btn-primary" type="submit">Save settings</button></div>
+</form></details>`;
+}
+
+// ── /s/:space (forum style) ──────────────────────────────────
+function forumSpacePage({ space, threads, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, children = [], groups = [] }) {
+ const filtered = !!(category || status);
+ const indexable = space.visibility === 'public' && !space.members_only && !filtered;
+ const href = (o = {}) => f.spaceHref(space.slug, { sort: 'active', category, status, ...o }).replace(/[?&]sort=active/, '').replace(/\?&/, '?').replace(/\?$/, '');
+ const chip = (label, h, on) => `<a class="chip${on ? ' active' : ''}" href="${esc(h)}"${on ? ' aria-current="true"' : ''}>${label}</a>`;
+ const tags = categories.length ? `<nav class="chips" aria-label="Categories">${chip('All', href({ category: null }), !category)}${categories.map((c) => chip(esc(c.name), href({ category: c.slug }), c.slug === category)).join('')}</nav>` : '';
+ const statuses = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', href({ status: null }), !status)}${space.statuses.map((st) => chip(esc(f.STATUS_LABELS[st] || st), href({ status: st }), st === status)).join('')}</nav>` : '';
+ const start = viewer.can_start !== false
+ ? `<a class="btn btn-primary" href="/s/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${space.thread_kind === 'request' ? 'New request' : 'New topic'}</a>`
+ : '<span class="muted small">Staff add roadmap items; reply to one to discuss it.</span>';
+ const pagerHtml = f.pager((n) => href({ page: n }), page, pages, ['Previous', 'Next']);
+ const body = `
+<header class="page-head">
+ ${crumbs([...trail(space), [space.name, `/s/${space.slug}`]])}
+ <h1>${esc(space.name)}${f.visBadge(space)}</h1>
+ ${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
+</header>
+${children.length ? `<section class="board-group" aria-label="Child boards">${boardTable(children, 'Child boards')}</section>` : ''}
+<div class="board-toolbar">${start}${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</div>
+${tags}${statuses}
+<section data-results>
+${threads.length ? `<table class="board-table topic-table"><caption class="sr-only">Topics in ${esc(space.name)}</caption>
+ <thead><tr><th scope="col" class="topic-icon"><span class="sr-only">Kind</span></th><th scope="col">Topic</th><th scope="col" class="num">Replies</th><th scope="col" class="num">Views</th><th scope="col">Last post</th></tr></thead>
+ <tbody>${threads.map((t) => topicRow(t, space)).join('')}</tbody></table>` : `<p class="empty">${filtered ? `Nothing here yet. <a href="${esc(href({ category: null, status: null }))}">See every topic</a>.` : viewer.can_start !== false ? `No topics yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.` : 'Nothing here yet.'}</p>`}
+${pagerHtml}
+</section>
+${viewer.can_moderate ? settingsForm(space, groups) : ''}`;
+ return renderPage({
+ title: `${space.name}${page > 1 ? ` (page ${page})` : ''}`,
+ description: `${space.description || `Topics in ${space.name}`} ${plural(total, 'topic', 'topics')} on OpenVibe.Community.`,
+ canonicalPath: href({ page }),
+ 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: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
+ body,
+ });
+}
+
+/** The author's panel beside a post: picture, name, posts, since when, the ratings they get most. */
+function authorPanel(p) {
+ const a = p.author;
+ if (!a) return '<aside class="postbit-author"><span class="postbit-name">Anonymous</span></aside>';
+ const pic = avatarUrl(a, 96);
+ const name = a.display_name || a.username || (a.is_ai ? 'AI' : a.is_system ? 'OpenVibe' : 'Someone');
+ const initial = esc(String(name).trim()[0] || '?').toUpperCase();
+ const st = p.author_stats;
+ return `<aside class="postbit-author">
+ ${pic ? `<img class="postbit-avatar" src="${esc(pic)}" alt="" width="64" height="64" loading="lazy">` : `<span class="postbit-avatar postbit-letter" aria-hidden="true">${initial}</span>`}
+ <span class="postbit-name">${a.username ? `<a href="https://openvibe.live/@${encodeURIComponent(a.username)}" rel="noopener">${esc(name)}</a>` : esc(name)}</span>
+ ${a.is_ai ? '<span class="badge badge-ai" title="Written by AI, not by a person">AI</span>' : a.is_system ? '<span class="badge">OpenVibe</span>' : ''}
+ ${st ? `<dl class="postbit-stats"><div><dt>Posts</dt><dd>${num(st.posts)}</dd></div>${monthYear(st.first_post_at) ? `<div><dt>Since</dt><dd>${esc(monthYear(st.first_post_at))}</dd></div>` : ''}</dl>
+ ${(st.ratings || []).length ? `<p class="postbit-ratings" aria-label="Ratings received">${st.ratings.map((r) => `<span title="${esc(r.label)}">${r.emoji} ${num(r.count)}</span>`).join(' ')}</p>` : ''}` : ''}
+ </aside>`;
+}
+
+function postbit(p, n, { base, page, catalogue, canReply }) {
+ if (p.deleted) return `<article class="postbit post-deleted" id="post-${p.id}"><div class="postbit-main"><p class="muted small">#${n} · This post was deleted.</p></div></article>`;
+ const quote = canReply ? ` <a class="small" href="${esc(base)}?quote=${Number(p.id)}#reply"><i class="fa-solid fa-quote-left" aria-hidden="true"></i> Quote</a>` : '';
+ return `<article class="postbit${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">
+ ${authorPanel(p)}
+ <div class="postbit-main">
+ <header class="postbit-head small muted"><a href="#post-${p.id}">${p.is_opening ? 'Topic' : `Reply #${n - 1}`}</a> · ${esc(fmtDate(p.created_at))}${p.revision > 1 ? ` · <span title="Edited ${esc(fmtDate(p.updated_at))}">edited</span>` : ''}${quote}</header>
+ <div class="md">${p.body_html}</div>
+ ${f.postImages(p)}
+ ${f.pasteCards(p)}
+ ${f.reactionBar(p, base, page, catalogue)}
+ </div>
+ </article>`;
+}
+
+// ── /s/:space/t/:slug (forum style) ──────────────────────────
+function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [], attachmentsEnabled = false, reactions = [], crosspost = null }) {
+ const base = `/s/${space.slug}/t/${thread.slug}`;
+ const opening = posts.find((p) => p.is_opening) || null;
+ const gated = !!(space.members_only || thread.members_only);
+ const indexable = space.visibility === 'public' && !gated;
+ const first = (page - 1) * perPage;
+ const replyBlock = viewer.can_reply
+ ? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply"${attachmentsEnabled ? ' enctype="multipart/form-data"' : ''}>
+ <h2 class="h3">Reply</h2>
+ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
+ <label class="field"><span class="sr-only">Your reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>
+ ${f.imageField(attachmentsEnabled)}
+ ${f.pasteField()}
+ <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-reply" aria-hidden="true"></i> Post reply</button><span class="muted small">**bold**, *italic*, \`code\`, > quotes, [links](https://…)</span></div>
+ </form>`
+ : thread.locked ? '<p class="alert">This topic is locked.</p>'
+ : !user ? `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to reply.</p>` : '';
+ const pagerHtml = f.pager((n) => `${base}${n > 1 ? `?page=${n}` : ''}`, page, pages, ['Previous', 'Next']);
+ const body = `
+<article class="thread forum-topic">
+ ${crumbs([...trail(space), [space.name, `/s/${space.slug}`], [seo.clean(thread.title, 60), base]])}
+ <header class="thread-head">
+ ${space.votes === false ? '' : f.voteForm(base, thread, viewer)}
+ <div>
+ <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Sticky" aria-label="Sticky"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${f.vipBadge(thread)}</h1>
+ ${thread.status || thread.category ? `<p class="thread-tags">${f.statusBadge(thread)}${f.categoryBadge(thread, space)}</p>` : ''}
+ <p class="small muted">${plural(thread.reply_count, 'reply', 'replies')} · ${plural(thread.views, 'view', 'views')}</p>
+ </div>
+ </header>
+ ${f.crosspostBlock(crosspost, base)}
+ ${f.modActions({ base, space, thread, viewer, categories })}
+ ${pagerHtml}
+ <div class="postbits">${posts.map((p, i) => postbit(p, first + i + 1, { base, page, catalogue: reactions, canReply: viewer.can_reply })).join('\n')}</div>
+ ${pagerHtml}
+ ${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}
+</article>`;
+ const description = gated ? `A topic for VIP members in ${space.name} on OpenVibe.Community.` : (seo.clean(opening ? markdownToText(opening.body_markdown, 200) : thread.title, 200) || thread.title);
+ return renderPage({
+ title: `${thread.title}${page > 1 ? ` (page ${page})` : ''}`,
+ description,
+ canonicalPath: `${base}${page > 1 ? `?page=${page}` : ''}`,
+ robots: indexable ? 'index,follow' : 'noindex,nofollow',
+ ogType: 'article',
+ published: thread.created_at || undefined,
+ modified: (opening && opening.updated_at) || undefined,
+ active: 'spaces',
+ historyType: 'page',
+ historyTitle: thread.title,
+ footerVariant: 'compact',
+ feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
+ jsonLd: [
+ ...(gated ? [] : [seo.threadLd({ space, thread, posts, opening, description })]),
+ seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
+ ],
+ body,
+ });
+}
+
+/** Moderators: a new space (either style), no JS. */
+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>
+</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="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>
+</form>`;
+ return renderPage({ title: 'New space', 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. */
+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>
+ ${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 });
+}
+
+module.exports = { boardIndexPage, forumSpacePage, forumTopicPage, newSpacePage, settingsForm, discussPage };
@@ -80,8 +80,8 @@ ${list}`;
@@ -80,8 +80,8 @@ ${list}`;
// ── /s/:space ────────────────────────────────────────────────
// ── /s/:space ────────────────────────────────────────────────
function threadRow(t, space) {
function threadRow(t, space) {
const flags = `${t.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${t.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}`;
const flags = `${t.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${t.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}`;
- return `<li class="thread-row">
- <span class="thread-score" title="Score">${num(t.score)}</span>
+ return `<li class="thread-row${space.votes === false ? ' no-votes' : ''}">
+ ${space.votes === false ? '' : `<span class="thread-score" title="Score">${num(t.score)}</span>`}
<div class="thread-main">
<div class="thread-main">
<a class="thread-title" href="/s/${esc(space.slug)}/t/${esc(t.slug)}">${flags}${esc(t.title)}</a>${statusBadge(t)}${categoryBadge(t, space)}${vipBadge(t)}
<a class="thread-title" href="/s/${esc(space.slug)}/t/${esc(t.slug)}">${flags}${esc(t.title)}</a>${statusBadge(t)}${categoryBadge(t, space)}${vipBadge(t)}
<p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p>
<p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p>
@@ -89,8 +89,8 @@ function threadRow(t, space) {
@@ -89,8 +89,8 @@ function threadRow(t, space) {
</li>`;
</li>`;
}
}
-function spacePage({ space, threads, sort, page, pages, total, user, categories = [], category = null, status = null, viewer = {} }) {
- const tabs = Object.keys(SORT_LABELS).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s, category, status }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');
+function spacePage({ space, threads, sort, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, footer = '' }) {
+ const tabs = Object.keys(SORT_LABELS).filter((s) => s !== 'top' || space.votes !== false).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s, category, status }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');
const filtered = !!(category || status);
const filtered = !!(category || status);
const indexable = space.visibility === 'public' && !space.members_only && !filtered;
const indexable = space.visibility === 'public' && !space.members_only && !filtered;
const chip = (label, href, on) => `<a class="chip${on ? ' active' : ''}" href="${esc(href)}"${on ? ' aria-current="true"' : ''}>${label}</a>`;
const chip = (label, href, on) => `<a class="chip${on ? ' active' : ''}" href="${esc(href)}"${on ? ' aria-current="true"' : ''}>${label}</a>`;
@@ -113,7 +113,8 @@ ${categoryNav}${statusNav}
@@ -113,7 +113,8 @@ ${categoryNav}${statusNav}
<section data-results>
<section data-results>
${threads.length ? `<ol class="thread-list">${threads.map((t) => threadRow(t, space)).join('')}</ol>` : filtered ? `<p class="empty">Nothing here yet. <a href="${esc(spaceHref(space.slug))}">See everything in ${esc(space.name)}</a>.</p>` : canStart ? `<p class="empty">No threads yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.</p>` : '<p class="empty">Nothing here yet.</p>'}
${threads.length ? `<ol class="thread-list">${threads.map((t) => threadRow(t, space)).join('')}</ol>` : filtered ? `<p class="empty">Nothing here yet. <a href="${esc(spaceHref(space.slug))}">See everything in ${esc(space.name)}</a>.</p>` : canStart ? `<p class="empty">No threads yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.</p>` : '<p class="empty">Nothing here yet.</p>'}
${pager((n) => spaceHref(space.slug, { sort, page: n, category, status }), page, pages)}
${pager((n) => spaceHref(space.slug, { sort, page: n, category, status }), page, pages)}
-</section>`;
+</section>
+${footer}`;
return renderPage({
return renderPage({
title: `${space.name}${categoryName ? ` · ${categoryName}` : ''}${status ? ` · ${STATUS_LABELS[status] || status}` : ''} — ${SORT_LABELS[sort].toLowerCase()} threads${page > 1 ? ` (page ${page})` : ''}`,
title: `${space.name}${categoryName ? ` · ${categoryName}` : ''}${status ? ` · ${STATUS_LABELS[status] || status}` : ''} — ${SORT_LABELS[sort].toLowerCase()} threads${page > 1 ? ` (page ${page})` : ''}`,
description: `${space.description || `Threads in ${space.name}`} ${num(total)} ${total === 1 ? 'thread' : 'threads'} on OpenVibe.Community.`,
description: `${space.description || `Threads in ${space.name}`} ${num(total)} ${total === 1 ? 'thread' : 'threads'} on OpenVibe.Community.`,
@@ -126,15 +127,79 @@ ${categoryNav}${statusNav}
@@ -126,15 +127,79 @@ ${categoryNav}${statusNav}
});
});
}
}
+/** The pin / lock / delete / members-only / status / category buttons (no JS). */
+function modActions({ base, space, thread, viewer, categories = [] }) {
+ return viewer.can_moderate || viewer.can_delete ? `<div class="actions mod-actions">
+ ${viewer.can_moderate ? `<form method="post" action="${esc(base)}/state"><input type="hidden" name="pinned" value="${thread.pinned ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> ${thread.pinned ? 'Unpin' : 'Pin'}</button></form>
+ <form method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}
+ ${viewer.can_delete ? `<form method="post" action="${esc(base)}/delete"><button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}
+ ${viewer.can_gate ? `<form method="post" action="${esc(base)}/members-only"><input type="hidden" name="on" value="${thread.members_only ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-star" aria-hidden="true"></i> ${thread.members_only ? 'Open to everyone' : 'VIP members only'}</button></form>` : ''}
+ ${viewer.can_moderate && (space.statuses || []).length && thread.status ? `<form class="inline-form" method="post" action="${esc(base)}/status"><label class="sr-only" for="thread-status">Status</label><select id="thread-status" name="status">${space.statuses.map((st) => `<option value="${esc(st)}"${st === thread.status ? ' selected' : ''}>${esc(STATUS_LABELS[st] || st)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set status</button></form>` : ''}
+ ${categories.length && (viewer.can_moderate || viewer.can_delete) ? `<form class="inline-form" method="post" action="${esc(base)}/category"><label class="sr-only" for="thread-category">Category</label><select id="thread-category" name="category"><option value="">No category</option>${categories.map((c) => `<option value="${esc(c.slug)}"${thread.category && thread.category.slug === c.slug ? ' selected' : ''}>${esc(c.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set category</button></form>` : ''}
+ </div>` : '';
+}
+
// ── /s/:space/t/:slug ────────────────────────────────────────
// ── /s/:space/t/:slug ────────────────────────────────────────
-function postHtml(p, n) {
+function postHtml(p, n, { base = '', page = 1, catalogue = [] } = {}) {
if (p.deleted) return `<article class="post post-deleted" id="post-${p.id}"><p class="muted small">#${n} · This post was deleted.</p></article>`;
if (p.deleted) return `<article class="post post-deleted" id="post-${p.id}"><p class="muted small">#${n} · This post was deleted.</p></article>`;
return `<article class="post${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">
return `<article class="post${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">
<header class="post-head">${who(p.author)} <span class="sep">·</span> <a class="muted small" href="#post-${p.id}">${timeTag(p.created_at)}</a>${p.revision > 1 ? ` <span class="sep">·</span> <span class="muted small" title="Edited ${esc(fmtDate(p.updated_at))}">edited</span>` : ''}<span class="post-num muted small">#${n}</span></header>
<header class="post-head">${who(p.author)} <span class="sep">·</span> <a class="muted small" href="#post-${p.id}">${timeTag(p.created_at)}</a>${p.revision > 1 ? ` <span class="sep">·</span> <span class="muted small" title="Edited ${esc(fmtDate(p.updated_at))}">edited</span>` : ''}<span class="post-num muted small">#${n}</span></header>
<div class="md">${p.body_html}</div>
<div class="md">${p.body_html}</div>
- ${(p.attachments || []).length ? `<div class="post-images">${p.attachments.map((a) => `<a href="${esc(a.url)}" target="_blank" rel="noopener"><img src="${esc(a.url)}" alt="${esc(a.filename || 'Image')}" loading="lazy" decoding="async"></a>`).join('')}</div>` : ''}
+ ${postImages(p)}
+ ${pasteCards(p)}
+ ${reactionBar(p, base, page, catalogue)}
</article>`;
</article>`;
}
}
+/** Pastes attached to a post: a card with the first lines (highlighted), or the screenshot. */
+function pasteCards(p) {
+ const list = p.pastes || [];
+ if (!list.length) return '';
+ const { highlight, languageLabel } = require('./highlight');
+ return `<div class="paste-embeds">${list.map((x) => `<figure class="paste-embed">
+ <figcaption><a href="${esc(x.url)}"><i class="fa-solid fa-${x.type === 'screenshot' ? 'image' : 'code'}" aria-hidden="true"></i> ${esc(x.title || 'Untitled')}</a>
+ <span class="muted small">${x.type === 'screenshot' ? 'screenshot' : `${esc(languageLabel ? languageLabel(x.language) : x.language)} · ${num(x.lines)} ${x.lines === 1 ? 'line' : 'lines'}`}${x.visibility === 'unlisted' ? ' · unlisted' : ''}</span></figcaption>
+ ${x.type === 'screenshot' && x.screenshot_url ? `<a href="${esc(x.url)}"><img src="${esc(x.screenshot_url)}" alt="${esc(x.title || 'Screenshot')}" loading="lazy"></a>` : `<pre class="paste-embed-code"><code class="hljs">${highlight(x.excerpt || '', x.language).html}</code></pre>${x.lines > 12 ? `<a class="small" href="${esc(x.url)}">See all ${num(x.lines)} lines</a>` : ''}`}
+ </figure>`).join('')}</div>`;
+}
+
+/**
+ * Where a thread came from, where else it lives, and (signed in) a form to crosspost it to another space.
+ */
+function crosspostBlock(c, base) {
+ if (!c) return '';
+ const from = c.from ? `<p class="crosspost-note"><i class="fa-solid fa-shuffle" aria-hidden="true"></i> Crossposted from <strong>${esc(c.from.space)}</strong>: <a href="${esc(c.from.url)}">${esc(c.from.title)}</a></p>` : '';
+ const to = (c.to || []).length ? `<p class="crosspost-note small muted">Also in ${c.to.map((x) => `<a href="${esc(x.url)}">${esc(x.space)}</a>`).join(', ')}</p>` : '';
+ const form = (c.targets || []).length ? `<details class="crosspost-form"><summary><i class="fa-solid fa-shuffle" aria-hidden="true"></i> Crosspost</summary>
+ <form class="inline-form" method="post" action="${esc(base)}/crosspost"><label class="sr-only" for="crosspost-to">Crosspost to</label><select id="crosspost-to" name="to">${c.targets.map((t) => `<option value="${esc(t.slug)}">${esc(t.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Crosspost</button></form></details>` : '';
+ return from || to || form ? `<div class="crosspost">${from}${to}${form}</div>` : '';
+}
+
+/** A post's images. */
+function postImages(p) {
+ return `${(p.attachments || []).length ? `<div class="post-images">${p.attachments.map((a) => `<a href="${esc(a.url)}" target="_blank" rel="noopener"><img src="${esc(a.url)}" alt="${esc(a.filename || 'Image')}" loading="lazy" decoding="async"></a>`).join('')}</div>` : ''}`;
+}
+/**
+ * A post's ratings (Facepunch-style): the ratings given, as buttons that take yours back or switch to them,
+ * and a picker with every rating (no JS: a <details> and a form). `base` is the thread's path.
+ */
+function reactionBar(p, base, page, catalogue = []) {
+ const given = p.reactions || [];
+ if (!given.length && !p.can_react) return '';
+ const title = (r) => `${r.label}${r.raters && r.raters.length ? `: ${r.raters.join(', ')}${r.count > r.raters.length ? ` and ${num(r.count - r.raters.length)} more` : ''}` : ''}`;
+ const chip = (r) => (p.can_react
+ ? `<button class="rx rx-${esc(r.group || 'positive')}${r.mine ? ' mine' : ''}" type="submit" name="reaction" value="${esc(r.key)}" title="${esc(title(r))}" aria-pressed="${r.mine ? 'true' : 'false'}" aria-label="${esc(`${r.label}, ${r.count}`)}"><span aria-hidden="true">${r.emoji}</span> ${num(r.count)}</button>`
+ : `<span class="rx rx-${esc(r.group || 'positive')}" title="${esc(title(r))}"><span aria-hidden="true">${r.emoji}</span> ${num(r.count)}<span class="sr-only"> ${esc(r.label)}</span></span>`);
+ const picker = p.can_react && catalogue.length ? `<details class="rx-pick"><summary><i class="fa-regular fa-face-smile" aria-hidden="true"></i> Rate</summary><div class="rx-menu">${['positive', 'negative', 'utility'].map((g) => {
+ const list = catalogue.filter((r) => r.group === g);
+ return list.length ? `<div class="rx-row">${list.map((r) => `<button class="rx-btn rx-${g}" type="submit" name="reaction" value="${esc(r.key)}" title="${esc(r.label)}"><span aria-hidden="true">${r.emoji}</span> ${esc(r.label)}</button>`).join('')}</div>` : '';
+ }).join('')}</div></details>` : '';
+ const inner = `${given.map(chip).join('')}${picker}`;
+ return p.can_react
+ ? `<form class="reactions" method="post" action="${esc(base)}/react" aria-label="Ratings"><input type="hidden" name="post" value="${Number(p.id)}"><input type="hidden" name="page" value="${Number(page) || 1}">${inner}</form>`
+ : `<div class="reactions" aria-label="Ratings">${inner}</div>`;
+}
+
+const pasteField = (value = '') => `<label class="field"><span>Pastes (optional: up to 4 paste links or codes, separated by spaces)</span><input type="text" name="pastes" maxlength="400" value="${esc(value || '')}" placeholder="https://openvibe.community/p/…"></label>`;
const imageField = (on) => (on ? '<label class="field"><span>Images (optional, up to 4; PNG, JPEG, GIF or WebP, 8 MB each)</span><input type="file" name="attachments" accept="image/png,image/jpeg,image/gif,image/webp" multiple></label>' : '');
const imageField = (on) => (on ? '<label class="field"><span>Images (optional, up to 4; PNG, JPEG, GIF or WebP, 8 MB each)</span><input type="file" name="attachments" accept="image/png,image/jpeg,image/gif,image/webp" multiple></label>' : '');
function voteForm(base, thread, viewer) {
function voteForm(base, thread, viewer) {
@@ -146,7 +211,7 @@ function voteForm(base, thread, viewer) {
@@ -146,7 +211,7 @@ function voteForm(base, thread, viewer) {
return `<form class="vote" method="post" action="${esc(base)}/vote">${btn(1, 'fa-arrow-up', 'Upvote')}<span class="vote-score" title="Score">${num(thread.score)}</span>${btn(-1, 'fa-arrow-down', 'Downvote')}</form>`;
return `<form class="vote" method="post" action="${esc(base)}/vote">${btn(1, 'fa-arrow-up', 'Upvote')}<span class="vote-score" title="Score">${num(thread.score)}</span>${btn(-1, 'fa-arrow-down', 'Downvote')}</form>`;
}
}
-function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [], attachmentsEnabled = false }) {
+function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [], attachmentsEnabled = false, reactions = [], crosspost = null }) {
const base = `/s/${space.slug}/t/${thread.slug}`;
const base = `/s/${space.slug}/t/${thread.slug}`;
const opening = posts.find((p) => p.is_opening) || null;
const opening = posts.find((p) => p.is_opening) || null;
const gated = !!(space.members_only || thread.members_only);
const gated = !!(space.members_only || thread.members_only);
@@ -157,32 +222,27 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -157,32 +222,27 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
${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>Reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>
<label class="field"><span>Reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>
${imageField(attachmentsEnabled)}
${imageField(attachmentsEnabled)}
+ ${pasteField()}
<div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-reply" aria-hidden="true"></i> Reply</button><span class="muted small">**bold**, *italic*, \`code\`, \`\`\` blocks, [links](https://…), > quotes and lists.</span></div>
<div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-reply" aria-hidden="true"></i> Reply</button><span class="muted small">**bold**, *italic*, \`code\`, \`\`\` blocks, [links](https://…), > quotes and lists.</span></div>
</form>`
</form>`
: thread.locked ? '<p class="alert">This thread is locked.</p>'
: thread.locked ? '<p class="alert">This thread is locked.</p>'
: !user ? `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to reply.</p>` : '';
: !user ? `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to reply.</p>` : '';
- const modBlock = viewer.can_moderate || viewer.can_delete ? `<div class="actions mod-actions">
- ${viewer.can_moderate ? `<form method="post" action="${esc(base)}/state"><input type="hidden" name="pinned" value="${thread.pinned ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> ${thread.pinned ? 'Unpin' : 'Pin'}</button></form>
- <form method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}
- ${viewer.can_delete ? `<form method="post" action="${esc(base)}/delete"><button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}
- ${viewer.can_gate ? `<form method="post" action="${esc(base)}/members-only"><input type="hidden" name="on" value="${thread.members_only ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-star" aria-hidden="true"></i> ${thread.members_only ? 'Open to everyone' : 'VIP members only'}</button></form>` : ''}
- ${viewer.can_moderate && (space.statuses || []).length && thread.status ? `<form class="inline-form" method="post" action="${esc(base)}/status"><label class="sr-only" for="thread-status">Status</label><select id="thread-status" name="status">${space.statuses.map((st) => `<option value="${esc(st)}"${st === thread.status ? ' selected' : ''}>${esc(STATUS_LABELS[st] || st)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set status</button></form>` : ''}
- ${categories.length && (viewer.can_moderate || viewer.can_delete) ? `<form class="inline-form" method="post" action="${esc(base)}/category"><label class="sr-only" for="thread-category">Category</label><select id="thread-category" name="category"><option value="">No category</option>${categories.map((c) => `<option value="${esc(c.slug)}"${thread.category && thread.category.slug === c.slug ? ' selected' : ''}>${esc(c.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set category</button></form>` : ''}
- </div>` : '';
+ const modBlock = modActions({ base, space, thread, viewer, categories });
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(seo.clean(thread.title, 60))}</span></nav>
<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(seo.clean(thread.title, 60))}</span></nav>
<header class="thread-head">
<header class="thread-head">
- ${voteForm(base, thread, viewer)}
+ ${space.votes === false ? '' : voteForm(base, thread, viewer)}
<div>
<div>
<h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>
<h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>
${thread.status || thread.category ? `<p class="thread-tags">${statusBadge(thread)}${categoryBadge(thread, space)}</p>` : ''}
${thread.status || thread.category ? `<p class="thread-tags">${statusBadge(thread)}${categoryBadge(thread, space)}</p>` : ''}
<p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p>
<p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p>
</div>
</div>
</header>
</header>
+ ${crosspostBlock(crosspost, base)}
${modBlock}
${modBlock}
- <div class="posts">${posts.map((p, i) => postHtml(p, first + i + 1)).join('\n')}</div>
+ <div class="posts">${posts.map((p, i) => postHtml(p, first + i + 1, { base, page, catalogue: reactions })).join('\n')}</div>
${pager((n) => `${base}${n > 1 ? `?page=${n}` : ''}`, page, pages, ['Earlier', 'Later'])}
${pager((n) => `${base}${n > 1 ? `?page=${n}` : ''}`, page, pages, ['Earlier', 'Later'])}
${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}
${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}
</article>`;
</article>`;
@@ -218,6 +278,7 @@ function newThreadPage({ space, user, values = {}, error = null, categories = []
@@ -218,6 +278,7 @@ function newThreadPage({ space, user, values = {}, error = null, categories = []
${categoryField}
${categoryField}
<label class="field"><span>Post (Markdown)</span><textarea name="body" rows="14" maxlength="40000" required placeholder="Say it, own it.">${esc(values.body || '')}</textarea></label>
<label class="field"><span>Post (Markdown)</span><textarea name="body" rows="14" maxlength="40000" required placeholder="Say it, own it.">${esc(values.body || '')}</textarea></label>
${imageField(attachments)}
${imageField(attachments)}
+ ${pasteField(values.pastes)}
<label class="check"><input type="checkbox" name="members_only" value="1"${values.members_only ? ' checked' : ''}> Only my OpenVibe.VIP members can read and reply</label>
<label class="check"><input type="checkbox" name="members_only" value="1"${values.members_only ? ' checked' : ''}> Only my OpenVibe.VIP members can read and reply</label>
<div class="form-actions">
<div class="form-actions">
<button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Post thread</button>
<button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Post thread</button>
@@ -276,4 +337,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
@@ -276,4 +337,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
});
});
}
}
-module.exports = { spacesPage, spacePage, threadPage, newThreadPage, membersOnlyPage, spaceHref, who };
+module.exports = {
+ spacesPage, spacePage, threadPage, newThreadPage, membersOnlyPage, spaceHref, who,
+ pager, visBadge, vipBadge, statusBadge, categoryBadge, voteForm, modActions, reactionBar, postImages, imageField, pasteField, pasteCards, crosspostBlock, STATUS_LABELS,
+};
@@ -38,6 +38,12 @@ function timeTag(v) {
@@ -38,6 +38,12 @@ function timeTag(v) {
}
}
function num(n) { return Number(n || 0).toLocaleString('en-US'); }
function num(n) { return Number(n || 0).toLocaleString('en-US'); }
function authorName(p) { return p.display_name || p.username || 'Anonymous'; }
function authorName(p) { return p.display_name || p.username || 'Anonymous'; }
+/** A person's picture at `size` px: their own, else the network's avatar address (a generated initial when none). */
+function avatarUrl(p, size = 96) {
+ if (!p) return null;
+ if (p.avatar_url) return /^https?:\/\//i.test(p.avatar_url) ? p.avatar_url : `${config.liveUrl}${p.avatar_url.startsWith('/') ? '' : '/'}${p.avatar_url}`;
+ return p.username ? `${config.networkUrl}/avatar/${encodeURIComponent(p.username)}?s=${size}` : null;
+}
function authorHtml(p, { link = true } = {}) {
function authorHtml(p, { link = true } = {}) {
const name = authorName(p);
const name = authorName(p);
const initial = esc(name.trim()[0] || '?').toUpperCase();
const initial = esc(name.trim()[0] || '?').toUpperCase();
@@ -258,6 +264,7 @@ function pastePage({ paste: p, related, user }) {
@@ -258,6 +264,7 @@ function pastePage({ paste: p, related, user }) {
${!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>` : ''}
${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>`;
@@ -397,4 +404,4 @@ function errorPage({ status = 500, title = 'Something went wrong', message = '',
@@ -397,4 +404,4 @@ function errorPage({ status = 500, title = 'Something went wrong', message = '',
return renderPage({ title, description: message || title, canonicalPath: '/', robots: 'noindex,nofollow', footerVariant: 'compact', body });
return renderPage({ title, description: message || title, canonicalPath: '/', robots: 'noindex,nofollow', footerVariant: 'compact', body });
}
}
-module.exports = { homePage, updatesPage, browsePage, pastePage, newPage, myPage, errorPage, pasteCard, cardGrid, timeAgo, timeTag, fmtDate, num, authorHtml };
+module.exports = { homePage, updatesPage, browsePage, pastePage, newPage, myPage, errorPage, pasteCard, cardGrid, timeAgo, timeTag, fmtDate, num, authorHtml, avatarUrl };
@@ -28,7 +28,7 @@ const { syncRoadmap, syncFromFile } = require('../server/forum/roadmap');
@@ -28,7 +28,7 @@ const { syncRoadmap, syncFromFile } = require('../server/forum/roadmap');
await check('seeded: Feedback takes requests with ideas/bugs/questions; Roadmap is public and staff-started', async () => {
await check('seeded: Feedback takes requests with ideas/bugs/questions; Roadmap is public and staff-started', async () => {
const spaces = (await call('/api/v1/spaces')).json().spaces;
const spaces = (await call('/api/v1/spaces')).json().spaces;
- assert.deepStrictEqual(spaces.map((s) => [s.slug, s.thread_kind]), [['general', 'discussion'], ['feedback', 'request'], ['showcase', 'discussion'], ['roadmap', 'roadmap']]);
+ assert.deepStrictEqual(spaces.map((s) => [s.slug, s.thread_kind]), [['general', 'discussion'], ['help', 'discussion'], ['feedback', 'request'], ['roadmap', 'roadmap'], ['showcase', 'discussion'], ['off-topic', 'discussion']]);
assert.deepStrictEqual(spaces.find((s) => s.slug === 'feedback').statuses, ['open', 'planned', 'in_progress', 'done', 'declined']);
assert.deepStrictEqual(spaces.find((s) => s.slug === 'feedback').statuses, ['open', 'planned', 'in_progress', 'done', 'declined']);
const cats = (await call('/api/v1/spaces/feedback/categories')).json().categories;
const cats = (await call('/api/v1/spaces/feedback/categories')).json().categories;
assert.deepStrictEqual(cats.map((c) => c.slug), ['ideas', 'bugs', 'questions']);
assert.deepStrictEqual(cats.map((c) => c.slug), ['ideas', 'bugs', 'questions']);
@@ -15,6 +15,8 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
@@ -15,6 +15,8 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
(async () => {
(async () => {
const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
+ // These pages are the feed style (render/forum.js): General is a forum board by default, so it becomes a feed with votes here.
+ t.db.prepare("UPDATE spaces SET style = 'feed', votes = 1 WHERE slug = 'general'").run();
const net = t.network;
const net = t.network;
const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
new file mode 100644
new file mode 100644
@@ -0,0 +1,157 @@
@@ -0,0 +1,157 @@
+'use strict';
+/**
+ * Forum style and the features both styles share: the board index (groups, child boards, topics, posts,
+ * last post), a forum-style board (topic table, Replies/Views/Last post, sticky first, bumped by replies),
+ * topics with author panels and Quote, per-space votes and ratings, Facepunch-style ratings (one per
+ * person per post, toggle, replace, not your own, off per space, totals on the author panel), pastes on
+ * posts (cards, private and burn-after-read refused), crossposts between a forum and a feed, "Discuss in
+ * a space" from a paste, space settings and new spaces (moderators only).
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
+ const net = t.network;
+ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
+ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
+ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
+ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
+ const modJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'global_mod' });
+ const call = (path, { method = 'GET', cookie, json, form } = {}) => {
+ const h = {};
+ let body;
+ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
+ if (form !== undefined) { h['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(form).toString(); }
+ return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
+ };
+ const pasteRow = (slug, extra = {}) => t.db.prepare('INSERT INTO pastes (slug, owner_subject, title, content, language, visibility, burn_after_read) VALUES (?, ?, ?, ?, ?, ?, ?)')
+ .run(slug, alex.subject_id, extra.title || 'Snippet', extra.content || 'const a = 1;\nconsole.log(a);', 'javascript', extra.visibility || 'public', extra.burn ? 1 : 0);
+
+ let topic;
+ await check('a forum topic: votes off, a topic row with replies, views and the last post', async () => {
+ let r = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Welcome thread', body: 'Say hi.' } });
+ assert.strictEqual(r.status, 201, r.text);
+ topic = r.json().thread;
+ r = await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'hi from sam' } });
+ assert.strictEqual(r.status, 201);
+ assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/votes', { method: 'POST', cookie: samJwt, json: { value: 1 } })).json().code, 'space.votes_off');
+ await call('/s/general/t/welcome-thread', { cookie: samJwt });
+ await call('/s/general/t/welcome-thread', { cookie: samJwt });
+ await call('/s/general/t/welcome-thread', { cookie: alexJwt });
+ const list = (await call('/api/v1/spaces/general/threads')).json();
+ assert.strictEqual(list.sort, 'active', 'forum boards list the newest reply first');
+ const row = list.threads[0];
+ assert.strictEqual(row.views, 2, 'one view per viewer per half hour');
+ assert.strictEqual(row.last_post.author.username, 'sam');
+ const page = (await call('/s/general')).text;
+ assert.ok(page.includes('class="board-table topic-table"') && page.includes('data-label="Views"') && page.includes('Started by Alex'));
+ assert.ok(!page.includes('class="thread-score"'), 'no score column');
+ });
+
+ await check('the board index: groups in order, every space with topics, posts and the last post', async () => {
+ const r = (await call('/api/v1/spaces')).json();
+ const general = r.groups[0].spaces.find((s) => s.slug === 'general');
+ assert.strictEqual(general.thread_count, 1); assert.strictEqual(general.post_count, 2);
+ assert.strictEqual(general.last_post.thread.title, 'Welcome thread'); assert.strictEqual(general.last_post.author.username, 'sam');
+ const html = (await call('/s')).text;
+ assert.ok(html.includes('id="g-openvibe"') && html.includes('id="g-community"') && html.includes('Off-topic') && html.includes('badge-style">Forum') && html.includes('badge-style">Feed'));
+ });
+
+ await check('ratings: one per person per post, toggle, replace, not your own; totals on the author panel', async () => {
+ const post = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts[0];
+ const rate = (cookie, reaction) => call(`/api/v1/posts/${post.id}/reactions`, { method: 'POST', cookie, json: { reaction } });
+ assert.strictEqual((await rate(alexJwt, 'winner')).json().code, 'reaction.own_post');
+ let r = await rate(samJwt, 'winner');
+ assert.strictEqual(r.status, 200, r.text); assert.strictEqual(r.json().mine, 'winner');
+ r = await rate(modJwt, 'winner');
+ assert.deepStrictEqual(r.json().reactions.map((x) => [x.key, x.count]), [['winner', 2]]);
+ r = await rate(samJwt, 'late');
+ assert.deepStrictEqual(r.json().reactions.map((x) => [x.key, x.count]), [['winner', 1], ['late', 1]], 'another rating replaces yours');
+ r = await rate(samJwt, 'late');
+ assert.strictEqual(r.json().mine, null, 'the same one again takes it back');
+ assert.strictEqual((await rate(samJwt, 'amazing')).status, 400);
+ assert.strictEqual((await rate(null, 'agree')).status, 401);
+ const page = (await call('/api/v1/spaces/general/threads/welcome-thread', { cookie: samJwt })).json();
+ assert.deepStrictEqual(page.posts[0].reactions.map((x) => [x.key, x.count, x.raters]), [['winner', 1, ['Boss']]]);
+ assert.deepStrictEqual(page.posts[0].author_stats.ratings.map((x) => [x.key, x.count]), [['winner', 1]]);
+ assert.strictEqual(page.posts[0].can_react, true); assert.strictEqual(page.posts[1].can_react, false, 'sam cannot rate their own reply');
+ const html = (await call('/s/general/t/welcome-thread', { cookie: samJwt })).text;
+ assert.ok(html.includes('class="postbit') && html.includes('postbit-author') && html.includes('<dt>Posts</dt>') && html.includes('Rate</summary>') && html.includes('🏆'));
+ r = await call('/s/general/t/welcome-thread/react', { method: 'POST', cookie: samJwt, form: { post: String(post.id), reaction: 'funny' } });
+ assert.strictEqual(r.status, 303); assert.ok(r.headers.get('location').endsWith(`#post-${post.id}`));
+ t.db.prepare("UPDATE spaces SET reactions = 0 WHERE slug = 'general'").run();
+ assert.strictEqual((await rate(samJwt, 'agree')).json().code, 'space.reactions_off');
+ assert.deepStrictEqual((await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts[0].reactions, []);
+ t.db.prepare("UPDATE spaces SET reactions = 1 WHERE slug = 'general'").run();
+ });
+
+ await check('quote: ?quote= fills the reply box with the quoted post', async () => {
+ const post = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts[1];
+ const html = (await call(`/s/general/t/welcome-thread?quote=${post.id}`, { cookie: alexJwt })).text;
+ assert.ok(html.includes('**@sam** wrote:\n> hi from sam'), 'the quote is in the reply box');
+ });
+
+ await check('pastes on posts: public and unlisted as cards; private and burn-after-read refused', async () => {
+ pasteRow('snip1'); pasteRow('snip2', { visibility: 'unlisted', title: 'Hidden-ish' }); pasteRow('secret1', { visibility: 'private' }); pasteRow('burn1', { burn: true });
+ let r = await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'look at these', pastes: 'https://openvibe.community/p/snip1 snip2' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual(r.json().post.pastes.map((p) => [p.slug, p.lines]), [['snip1', 2], ['snip2', 2]]);
+ for (const bad of ['secret1', 'burn1']) assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'x', pastes: [bad] } })).json().code, 'pastes.not_shareable');
+ assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'x', pastes: ['nope-nope'] } })).status, 404);
+ const html = (await call('/s/general/t/welcome-thread')).text;
+ assert.ok(html.includes('class="paste-embed"') && html.includes('href="/p/snip1"') && html.includes('hljs'));
+ t.db.prepare("UPDATE pastes SET visibility = 'private' WHERE slug = 'snip2'").run();
+ const after = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts.find((p) => p.body_markdown === 'look at these');
+ assert.deepStrictEqual(after.pastes.map((p) => p.slug), ['snip1'], 'a paste made private drops out');
+ });
+
+ await check('"Discuss in a space" from a paste: the picker, then a new topic with the paste attached', async () => {
+ const paste = (await call('/p/snip1')).text;
+ assert.ok(paste.includes('/s/discuss?paste=snip1'));
+ const picker = (await call('/s/discuss?paste=snip1', { cookie: alexJwt })).text;
+ assert.ok(picker.includes('/s/general/new?paste=snip1') && !picker.includes('/s/roadmap/new'));
+ const form = (await call('/s/general/new?paste=snip1', { cookie: alexJwt })).text;
+ assert.ok(form.includes('name="pastes"') && form.includes('value="snip1"') && form.includes('value="Snippet"'));
+ });
+
+ await check('crosspost: a forum topic into a feed, both linked; members-only never', async () => {
+ const r = await call('/api/v1/spaces/general/threads/welcome-thread/crosspost', { method: 'POST', cookie: samJwt, json: { to: 'showcase' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const cp = r.json().thread;
+ assert.strictEqual(cp.space, 'showcase'); assert.strictEqual(cp.crosspost_of, topic.id);
+ const there = (await call(`/api/v1/spaces/showcase/threads/${cp.slug}`)).json();
+ assert.strictEqual(there.crosspost.from.url, '/s/general/t/welcome-thread');
+ assert.ok(there.posts[0].body_markdown.startsWith('Crossposted from **General**'));
+ const here = (await call('/api/v1/spaces/general/threads/welcome-thread', { cookie: samJwt })).json();
+ assert.deepStrictEqual(here.crosspost.to.map((x) => x.space), ['Showcase']);
+ assert.ok(here.crosspost.targets.some((x) => x.slug === 'showcase') && !here.crosspost.targets.some((x) => x.slug === 'roadmap' || x.slug === 'general'));
+ assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/crosspost', { method: 'POST', cookie: samJwt, json: { to: 'roadmap' } })).status, 403);
+ const html = (await call('/s/general/t/welcome-thread', { cookie: samJwt })).text;
+ assert.ok(html.includes('Also in') && html.includes('Crosspost</summary>'));
+ });
+
+ await check('space settings and new spaces: moderators only; style switches the pages', async () => {
+ let r = await call('/api/v1/spaces/showcase/settings', { method: 'PUT', cookie: alexJwt, json: { style: 'forum' } });
+ assert.strictEqual(r.status, 403);
+ r = await call('/api/v1/spaces/showcase/settings', { method: 'PUT', cookie: modJwt, json: { style: 'forum', votes: false } });
+ assert.strictEqual(r.status, 200, r.text); assert.strictEqual(r.json().space.style, 'forum'); assert.strictEqual(r.json().space.votes, false);
+ assert.ok((await call('/s/showcase')).text.includes('topic-table'));
+ r = await call('/s/showcase/settings', { method: 'POST', cookie: modJwt, form: { name: 'Showcase', style: 'feed', votes: '1', reactions: '1', group: 'community' } });
+ assert.strictEqual(r.status, 303);
+ assert.ok((await call('/s/showcase')).text.includes('class="thread-list"'));
+ assert.strictEqual((await call('/s/new-space', { cookie: alexJwt })).status, 403);
+ r = await call('/s/new-space', { method: 'POST', cookie: modJwt, form: { name: 'Music', slug: 'music', description: 'Songs', style: 'forum', reactions: '1', group: 'community' } });
+ assert.strictEqual(r.status, 303); assert.strictEqual(r.headers.get('location'), '/s/music');
+ const music = (await call('/api/v1/spaces')).json().spaces.find((s) => s.slug === 'music');
+ assert.deepStrictEqual([music.style, music.votes, music.reactions, music.group.slug], ['forum', false, true, 'community']);
+ r = await call('/api/v1/spaces/music/settings', { method: 'PUT', cookie: modJwt, json: { parent: 'off-topic' } });
+ assert.strictEqual(r.status, 200, r.text);
+ const idx = (await call('/api/v1/spaces')).json();
+ assert.ok(idx.groups.find((g) => g.slug === 'community').spaces.find((s) => s.slug === 'off-topic').children.some((c) => c.slug === 'music'), 'a child board sits under its parent');
+ assert.strictEqual((await call('/api/v1/spaces', { method: 'POST', cookie: modJwt, json: { slug: 'discuss', name: 'Nope' } })).status, 400, 'reserved address');
+ });
+
+ await done(t);
+})();
@@ -33,10 +33,11 @@ const forumStore = require('../server/forum/store');
@@ -33,10 +33,11 @@ const forumStore = require('../server/forum/store');
let first;
let first;
- await check('seeded spaces: general, feedback, showcase, roadmap — all public', async () => {
+ await check('seeded spaces: on the board index in their groups, all public', async () => {
const r = await call('/api/v1/spaces');
const r = await call('/api/v1/spaces');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.deepStrictEqual(r.json().spaces.map((s) => [s.slug, s.visibility]), [['general', 'public'], ['feedback', 'public'], ['showcase', 'public'], ['roadmap', 'public']]);
+ assert.deepStrictEqual(r.json().spaces.map((s) => [s.slug, s.visibility, s.style]), [['general', 'public', 'forum'], ['help', 'public', 'forum'], ['feedback', 'public', 'feed'], ['roadmap', 'public', 'feed'], ['showcase', 'public', 'feed'], ['off-topic', 'public', 'forum']]);
+ assert.deepStrictEqual(r.json().groups.map((g) => [g.slug, g.spaces.map((s) => s.slug)]), [['openvibe', ['general', 'help', 'feedback', 'roadmap']], ['community', ['showcase', 'off-topic']]]);
assert.strictEqual((await call('/api/v1/spaces/nope')).status, 404);
assert.strictEqual((await call('/api/v1/spaces/nope')).status, 404);
});
});
@@ -117,8 +118,11 @@ const forumStore = require('../server/forum/store');
@@ -117,8 +118,11 @@ const forumStore = require('../server/forum/store');
assert.strictEqual((await call(`/api/v1/posts/${reply.id}/versions`, { cookie: alexJwt })).status, 403);
assert.strictEqual((await call(`/api/v1/posts/${reply.id}/versions`, { cookie: alexJwt })).status, 403);
});
});
- await check('votes on threads: add/change/remove, score recomputed, my_vote shown', async () => {
+ await check('votes on threads: off in a space without them; add/change/remove, score recomputed, my_vote shown', async () => {
const vote = (value, cookie) => call('/api/v1/spaces/general/threads/hello-world/votes', { method: 'POST', cookie, json: { value } });
const vote = (value, cookie) => call('/api/v1/spaces/general/threads/hello-world/votes', { method: 'POST', cookie, json: { value } });
+ const off = await vote(1, samJwt);
+ assert.strictEqual(off.status, 403, 'General is a forum-style board: votes are off'); assert.strictEqual(off.json().code, 'space.votes_off');
+ t.db.prepare("UPDATE spaces SET votes = 1 WHERE slug = 'general'").run();
assert.strictEqual((await vote(1, samJwt)).json().score, 1);
assert.strictEqual((await vote(1, samJwt)).json().score, 1);
assert.strictEqual((await vote(1, alexJwt)).json().score, 2);
assert.strictEqual((await vote(1, alexJwt)).json().score, 2);
assert.strictEqual((await vote(-1, samJwt)).json().score, 0);
assert.strictEqual((await vote(-1, samJwt)).json().score, 0);