openvibers/openvibe.community Commits

Threads carry content labels: set by the author, each reader's choice shows, blurs or hides them (#234)

OP OpenVibers committed 11 days ago · 9f86511277 · 18 files, +393 −19

* Threads carry content labels: set by the author, each reader's choice shows, blurs or hides them (L1)

* openvibe-shared 4.94.0 (opened: hide covers a thread's own page); the picker three to a row

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 80b6165..386467d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,12 @@
## Unreleased
++- Threads carry content labels (L1, labels and choices, 2026-11-02): `migrations/0013_thread_content_labels.sql` adds
++ `threads.content_labels` (`{ labels, suggested }`, common.content-labels@1). The author or a moderator sets them on the
++ new-thread form, in a Labels box on the thread, or with `PUT /api/v1/spaces/:space/threads/:slug/labels`. Lists and
++ thread pages in both styles show them in words and carry `data-ov-labels` with the author's subject, so each reader's
++ own choice on openvibe.network (Shared's ov-labels.js) shows, blurs or hides them, and the author always sees their
++ own; a thread's own page is opened, so hide covers it instead of emptying the page. Ticking nothing clears them.
- The forum publishes its moderation (market outcome 5): `migrations/0012_forum_moderation_log.sql` adds `forum_moderation_log`, and every moderator lock, unlock, pin, unpin, post/thread delete and status change writes one row in the same transaction, with the optional rule and reason the forms now take. The row names the actor's role only (staff, moderator, owner, automatic), never their Network subject. `GET /s/:space/log` (and `GET /api/v1/spaces/:space/moderation-log`) shows the newest 100, with a removed thread shown as "a removed thread"; public spaces publish it, private spaces keep the same sign-in rule as their page.
- Each thread has an RSS feed of its newest 50 posts (`GET /s/:space/t/:slug/feed.xml`), the space and thread pages advertise their feeds in the head, and each shows a small RSS link.
ddiff --git a/README.md b/README.md
iindex 7b8f983..2ebb774 100644
---- a/README.md
++++ b/README.md
@@@ -42,7 +42,7 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
(a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)
- OpenVibe.Live only for old VOD/clip comment imports (it reads and writes no pastes here any more)
- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.74.0 (events outbox, per-actor limits, no-looks),
-- `openvibe-shared` v4.93.2, pinned by release tarball
++ `openvibe-shared` v4.94.0, pinned by release tarball
## How it fits the network
@@@ -852,5 +852,5 @@ release after paste comments moved onto the typed comment threads. [Submissions]
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-sdk: v0.74.0
--- openvibe-shared: v4.93.2
++- openvibe-shared: v4.94.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 3491500..9ea2906 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,7 +9,7 @@
"domain": "openvibe.community",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.74.0",
-- "shared": "openvibe-shared v4.93.2",
++ "shared": "openvibe-shared v4.94.0",
"capabilities": [
"community.paste.create",
"community.paste.write",
ddiff --git a/migrations/0013_thread_content_labels.sql b/migrations/0013_thread_content_labels.sql
nnew file mode 100644
iindex 0000000..b8771a6
---- /dev/null
++++ b/migrations/0013_thread_content_labels.sql
@@@ -0,0 +1,7 @@
++-- phase: expand
++-- Content labels on threads (L1, labels and choices; Contracts common.content-labels@1): what a thread carries
++-- (nudity, strong language, graphic, 18+, flashing lights, spoilers), set by its author or a moderator. A label never
++-- hides a thread for anyone: each reader's own choice on openvibe.network shows it, blurs it or hides it in their
++-- page (Shared's ov-labels.js). `suggested` holds what an AI or a reader proposed; it changes nothing until the author
++-- or a moderator confirms it. Old code never reads the column, so the default keeps every insert working (N-1 safe).
++ALTER TABLE threads ADD COLUMN IF NOT EXISTS content_labels jsonb NOT NULL DEFAULT '{"labels": [], "suggested": []}'::jsonb;
ddiff --git a/package-lock.json b/package-lock.json
iindex 294db03..151b948 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -19,7 +19,7 @@
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.93.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.94.0",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -1236,9 +1236,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.93.2",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.93.2",
-- "integrity": "sha512-wprQsl5zBZRwosJePELWGO1fmgFomKTbf1SyKsRVC4a0cpcronW6MLiWAVpAn3xm/IYSpw24/4Gf4f8AZQ0vAw==",
++ "version": "4.94.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.94.0",
++ "integrity": "sha512-iMsgL7a5vxouygXsWxQACJrnRubhpe6tnMAKjiJZ0m6Fn+hFcJjQzrI/fERZB3ToC+xK4zO6b6hsm4zK6x4MYg==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex c811271..8522caa 100644
---- a/package.json
++++ b/package.json
@@@ -30,7 +30,7 @@
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.93.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.94.0",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/public/css/community.css b/public/css/community.css
iindex 21c85bb..625eb9d 100644
---- a/public/css/community.css
++++ b/public/css/community.css
@@@ -448,3 +448,11 @@ button.rx:hover { border-color: var(--accent); }
} }
/* The fold holds the questions, so its list gets the room the card gives it. */
.hm-fold-block .cm-faq { margin: 0; padding: 0 16px 16px; }
++
++/* Content labels (L1): what a thread carries, in words beside its title (as Shared's kit.labels draws them); each
++ reader's own choice (Shared's ov-labels.js) shows, covers or hides it. */
++.ovk-labels { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 0; padding: 0; list-style: none; }
++.ovk-label { display: inline-flex; align-items: center; min-height: 20px; padding: 0 8px; border-radius: 999px; font-size: .7rem; font-weight: 700; letter-spacing: .02em; line-height: 1.2;
++ color: var(--text-secondary, #a8b3c7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); border: 1px solid var(--border, rgba(255,255,255,.1)); }
++.ovk-label--adult { color: var(--warning-text, #fbbf24); border-color: color-mix(in srgb, var(--warning-text, #fbbf24) 45%, transparent); background: color-mix(in srgb, var(--warning-text, #fbbf24) 9%, transparent); }
++.thread-head .ovk-labels { margin: 2px 0 6px; }
ddiff --git a/public/css/labels.css b/public/css/labels.css
nnew file mode 100644
iindex 0000000..a464066
---- /dev/null
++++ b/public/css/labels.css
@@@ -0,0 +1,37 @@
++/* OpenVibe.Community: content labels (L1) in a form: the new-thread form and the author's Labels box on a thread.
++ Loaded only on those pages (layout pageCss). Six toggles, two or three to a row, each saying what it covers. */
++.ovl-pick { border: 0; margin: 4px 0 0; padding: 0; min-width: 0; }
++.ovl-pick legend { padding: 0; margin-bottom: 4px; font-size: .9rem; font-weight: 700; }
++.ovl-help { margin: 0 0 8px; font-size: .82rem; line-height: 1.4; color: var(--text-muted, #7386a3); max-width: 62ch; }
++/* Six toggles: three to a row, two on a phone, so no row is left with one. */
++.ovl-opts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; max-width: 760px; }
++@media (max-width: 560px) { .ovl-opts { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
++.ovl-pick .ovl-opt { position: relative; display: block; cursor: pointer; font-size: 1rem; font-weight: 400; color: inherit; }
++.ovl-opt input { position: absolute; opacity: 0; inset: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; }
++.ovl-card { position: relative; display: grid; align-content: start; gap: 2px; height: 100%; box-sizing: border-box; padding: 10px 12px 10px 36px; border-radius: 12px;
++ border: 1px solid var(--border-light, #2c3d5c); background: color-mix(in srgb, var(--bg-card, #131c2e) 70%, transparent); transition: border-color .15s, background .15s; }
++.ovl-card::before { content: ""; position: absolute; left: 12px; top: 12px; width: 16px; height: 16px; box-sizing: border-box; border-radius: 5px;
++ border: 1.5px solid var(--border-light, #2c3d5c); background: var(--bg-input, var(--bg-primary, #0a0f1c)); }
++.ovl-card b { font-size: .9rem; font-weight: 700; color: var(--text-primary, #e6edf7); }
++.ovl-card small { font-size: .76rem; line-height: 1.3; color: var(--text-muted, #7386a3); }
++.ovl-sug { justify-self: start; margin-top: 3px; padding: 0 7px; border-radius: 999px; font-style: normal; font-size: .7rem; font-weight: 700;
++ color: var(--accent-light, #60a5fa); border: 1px dashed color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); }
++.ovl-opt input:checked + .ovl-card { border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 13%, var(--bg-card, #131c2e)); }
++.ovl-opt input:checked + .ovl-card::before { border-color: var(--accent, #3b82f6); background: var(--accent, #3b82f6)
++ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.2l3 3 6-6.4' fill='none' stroke='%23fff' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 12px no-repeat; }
++.ovl-opt--adult input:checked + .ovl-card { border-color: var(--warning-text, #fbbf24); background: color-mix(in srgb, var(--warning-text, #fbbf24) 10%, var(--bg-card, #131c2e)); }
++.ovl-opt--adult input:checked + .ovl-card::before { border-color: var(--warning-text, #fbbf24); background-color: var(--warning-text, #fbbf24); }
++.ovl-opt input:focus-visible + .ovl-card { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.ovl-opt:hover .ovl-card { border-color: color-mix(in srgb, var(--accent, #3b82f6) 60%, var(--border-light, #2c3d5c)); }
++
++/* The author's Labels box: a fold whose line says what is set. */
++.labels-box { margin: 10px 0 14px; border: 1px solid var(--border, #1f2d47); border-radius: var(--radius, 12px); background: var(--bg-card, #131c2e); }
++.labels-box > summary { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; padding: 10px 14px; cursor: pointer; list-style: none; min-height: 44px; box-sizing: border-box; }
++.labels-box > summary::-webkit-details-marker { display: none; }
++.labels-box > summary::after { content: "+"; margin-left: auto; font-weight: 700; color: var(--text-secondary, #96a7c2); }
++.labels-box[open] > summary::after { content: "\2212"; }
++.labels-box-title { font-weight: 700; }
++.labels-box-title i { color: var(--accent-light, #60a5fa); margin-right: 4px; }
++.labels-box-line { font-size: .85rem; color: var(--text-secondary, #96a7c2); }
++.labels-form { padding: 0 14px 14px; display: grid; gap: 12px; }
++@media (prefers-reduced-motion: reduce) { .ovl-card { transition: none; } }
ddiff --git a/server/forum/api.js b/server/forum/api.js
iindex 61bd61d..6d32e1d 100644
---- a/server/forum/api.js
++++ b/server/forum/api.js
@@@ -16,6 +16,7 @@
* PUT /spaces/:space/threads/:slug/state { pinned?, locked?, rule?, reason? } moderators
* PUT /spaces/:space/members-only { owner: 'usr_…' | null } moderators (OpenVibe.VIP gate)
* PUT /spaces/:space/threads/:slug/members-only { owner | true | null } the author (own members) or moderators
++ * PUT /spaces/:space/threads/:slug/labels { labels: [...] } the author or moderators (content labels, L1)
* POST /spaces/:space/threads { …, members_only: true | { owner } } start a members-only thread
* POST /spaces/:space/attachments (multipart `file`) an image for a new thread or reply → { attachment: { media_id, url, … } };
* then POST …/threads or …/posts with { attachments: [media_id] } (at most 4)
@@@ -101,6 +102,7 @@ function createSpacesApi({ forum, viewers, limits }) {
router.post('/:space/threads/:slug/votes', write, limits('community.thread.vote', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await forum.voteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.put('/:space/threads/:slug/state', configure, spaceMod, jsonBody, run(async (req) => await forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.put('/:space/threads/:slug/members-only', writeOrMod, editThread, jsonBody, run(async (req) => await forum.setThreadMembersOnly(req.viewer, p(req).space, p(req).slug, req.body || {})));
++ router.put('/:space/threads/:slug/labels', writeOrMod, editThread, jsonBody, run(async (req) => await forum.setThreadLabels(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.put('/:space/members-only', configure, spaceMod, jsonBody, run(async (req) => await forum.setSpaceMembersOnly(req.viewer, p(req).space, req.body || {})));
// A chat room (OpenVibe.Chat): people attach with their own token; moderator services may only detach.
router.put('/:space/chat-room', configure, jsonBody, run(async (req) => await forum.attachChatRoom(req.viewer, p(req).space, req.body || {}), (out) => (out.created ? 201 : 200)));
ddiff --git a/server/forum/labels.js b/server/forum/labels.js
nnew file mode 100644
iindex 0000000..028163d
---- /dev/null
++++ b/server/forum/labels.js
@@@ -0,0 +1,41 @@
++'use strict';
++/**
++ * Content labels (L1, labels and choices, 2026-10-31; Contracts 0.257.0 common.content-labels@1).
++ *
++ * The one label list and the shape a thread stores and returns: `{ labels, suggested }`. Its author sets a label;
++ * the AI may suggest one, and a suggestion never applies itself. A label is not a ban: OpenVibe.Community never hides a
++ * thread because of it, and the reader's Network prefs decide what they see.
++ */
++const LABELS = Object.freeze(['nudity', 'language', 'graphic', '18+', 'flashing', 'spoilers']);
++
++const isLabel = (x) => typeof x === 'string' && LABELS.includes(x);
++
++/** A clean label list from a creator's request, or null when it is not a list of known labels. */
++function parseLabels(value) {
++ if (!Array.isArray(value)) return null;
++ const out = [];
++ for (const label of value) {
++ if (!isLabel(label)) return null;
++ if (!out.includes(label)) out.push(label);
++ }
++ return out;
++}
++
++/** The contract's shape from a jsonb column, a JSON string, or nothing: always two arrays of known labels. */
++function labelsOf(row) {
++ let raw = row && typeof row === 'object' && 'content_labels' in row ? row.content_labels : row;
++ if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch { raw = null; } }
++ const pick = (v) => [...new Set((Array.isArray(v) ? v : []).filter(isLabel))];
++ return { labels: pick(raw && raw.labels), suggested: pick(raw && raw.suggested) };
++}
++
++/** The shape to store. */
++const shape = (labels, suggested = []) => ({ labels: [...new Set((labels || []).filter(isLabel))], suggested: [...new Set((suggested || []).filter(isLabel))] });
++
++/** Apply a creator's labels and drop the ones they just confirmed from the suggestions. */
++function confirmed(current, labels) {
++ const now = labelsOf(current);
++ return { labels: [...labels], suggested: now.suggested.filter((l) => !labels.includes(l)) };
++}
++
++module.exports = { LABELS, isLabel, parseLabels, labelsOf, shape, confirmed };
ddiff --git a/server/forum/routes.js b/server/forum/routes.js
iindex 632fc5d..3048cf3 100644
---- a/server/forum/routes.js
++++ b/server/forum/routes.js
@@@ -23,6 +23,7 @@
* visitor); an Origin header from somewhere else is refused as well.
*/
const express = require('express');
++const labelsUi = require('../render/labels');
const multer = require('multer');
const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
@@@ -194,6 +195,8 @@ function createForumRoutes({ forum, viewers, config }) {
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);
++ const labels = labelsUi.fromForm(req.body);
++ if (labels !== undefined) values.labels = labels === null ? ['not-a-label'] : labels;
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; }
@@@ -262,6 +265,16 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
}));
++ // Content labels (L1, no JS): the author or a moderator ticks what the thread carries.
++ router.post('/s/:space/t/:slug/labels', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ try {
++ const labels = labelsUi.fromForm(req.body);
++ await forum.setThreadLabels(req.viewer, req.params.space, req.params.slug, { labels: labels === undefined ? [] : (labels === null ? ['not-a-label'] : labels) });
++ discovery.resetCaches();
++ res.redirect(303, `${back(req)}?labelled=1`);
++ } catch (err) { failPage(req, res, err, next); }
++ }));
++
router.post('/s/:space/t/:slug/state', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
const b = req.body || {};
const flags = { rule: b.rule, reason: b.reason };
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex 219e1eb..a3540f0 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -94,6 +94,8 @@ const TITLE_MIN = 3, TITLE_MAX = 200;
const BODY_MAX = 40_000;
const THREADS_PER_DAY = 20;
++const contentLabels = require('./labels');
++
function createForumService({ db, network = null, pulse = null, relay = null, vip = null, media = null, chatRooms = null, limits = {}, config = {}, indexnow = null } = {}) {
const authors = createAuthors({ db, network });
const threadLimiter = createPersonLimiter({ cooldownSec: 30, perMinute: 3, noun: 'threads', ...(limits.threads || {}) });
@@@ -259,6 +261,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
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,
++ // L1: what the thread carries; each reader's own choice decides what they see (never a ban).
++ content_labels: contentLabels.labelsOf(t.content_labels),
last_activity_at: isoTime(t.last_activity_at), created_at: isoTime(t.created_at),
my_vote: (votes && votes.get(t.id)) || 0,
};
@@@ -914,6 +918,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
can_moderate: mod,
can_delete: mod || (person(v) && thread.author_subject === v.subject),
can_gate: mod || (person(v) && thread.author_subject === v.subject),
++ can_label: mod || (person(v) && thread.author_subject === v.subject),
},
};
},
@@@ -934,6 +939,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (body.category && !category) fail(404, 'category.not_found', 'No such category in this forum');
const title = cleanTitle(body.title);
if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);
++ const labels = body.labels === undefined || body.labels === null ? null : contentLabels.parseLabels(body.labels);
++ if (body.labels !== undefined && body.labels !== null && labels === null) fail(400, 'thread.labels_invalid', `Labels are from this list: ${contentLabels.LABELS.join(', ')}`);
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
const images = await claimable(w, body.attachments);
const pasteRows = await claimPastes(body.pastes);
@@@ -942,7 +949,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (threadsPerDay > 0 && await store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);
}
const { thread, post } = await store.createThread(db, { space_id: space.id, title, author_subject: w.author, origin: w.origin, body_markdown: text, members_only_owner: gate,
-- kind, status: FIRST_STATUS[kind] || null, category_id: category ? category.id : null });
++ kind, status: FIRST_STATUS[kind] || null, category_id: category ? category.id : null, content_labels: labels && labels.length ? contentLabels.shape(labels) : null });
threadLimiter.record(w.key, title);
await attach(images, post.id);
await attachPastes(pasteRows, post.id);
@@@ -1092,6 +1099,24 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
return { thread: await shapeThread(next, space, v, projections, null) };
},
++ /**
++ * A thread's content labels { labels: [...] } (L1), by its author or a moderator. A suggestion they confirm leaves
++ * `suggested`. → { thread }
++ */
++ async setThreadLabels(v, spaceSlug, threadSlug, body = {}) {
++ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
++ const mine = person(v) && thread.author_subject === v.subject;
++ const mod = await moderates(v, space);
++ if (!mine && !mod) fail(403, 'thread.not_yours', 'Only the author or a moderator labels a thread');
++ await requireMembership(v, space, thread);
++ const labels = contentLabels.parseLabels(body.labels);
++ if (labels === null) fail(400, 'thread.labels_invalid', `Labels are from this list: ${contentLabels.LABELS.join(', ')}`);
++ const next = await store.setThreadLabels(db, thread.id, contentLabels.confirmed(thread.content_labels, labels));
++ const projections = await authors.projectionsFor([next.author_subject, next.members_only_owner]);
++ pingThread(space, next);
++ return { thread: await shapeThread(next, space, v, projections, null) };
++ },
++
/** Members-only for a whole space { owner: 'usr_…' | null } — moderators. */
async setSpaceMembersOnly(v, spaceSlug, body = {}) {
const space = await moderatedSpace(v, spaceSlug, 'make it members-only');
ddiff --git a/server/forum/store.js b/server/forum/store.js
iindex 409f32c..b322ced 100644
---- a/server/forum/store.js
++++ b/server/forum/store.js
@@@ -108,11 +108,12 @@ async function getThreadBySlug(db, spaceId, slug) {
}
/** New thread + its opening post, in one transaction. → { thread, post } */
--async 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 }) {
++async 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, content_labels = null }) {
return await db.tx(async () => {
const slug = await uniqueSlug(db, space_id, slugify(title));
-- const info = await db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id')
-- .run(space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of);
++ // content_labels: the column's default (none) unless the author labelled the thread as they posted it.
++ const info = await db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of, content_labels) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?::jsonb, \'{"labels": [], "suggested": []}\'::jsonb)) RETURNING id')
++ .run(space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of, content_labels ? JSON.stringify(content_labels) : null);
const threadId = info.lastInsertRowid;
const p = await db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?) RETURNING id')
.run(threadId, author_subject, origin, body_markdown);
@@@ -216,6 +217,11 @@ async function setThreadFlags(db, id, { pinned, locked }) {
}
/** Members-only for a creator's VIP members (owner = their usr_ subject), or open (null). */
++/** A thread's content labels ({ labels, suggested }, common.content-labels@1). */
++async function setThreadLabels(db, id, contentLabels) {
++ await db.prepare('UPDATE threads SET content_labels = ?::jsonb WHERE id = ?').run(JSON.stringify(contentLabels), id);
++ return await getThread(db, id);
++}
async function setThreadMembersOnly(db, id, owner) {
await db.prepare('UPDATE threads SET members_only_owner = ? WHERE id = ?').run(owner || null, id);
return await getThread(db, id);
@@@ -341,7 +347,7 @@ async function recentPosts(db, threadId, limit = 50) {
module.exports = {
SORTS, slugify,
listSpaces, getSpace, getSpaceById,
-- getThread, getThreadBySlug, createThread, listThreads, recentThreads, setThreadFlags, setThreadMembersOnly, setSpaceMembersOnly, softDeleteThread, countThreadsSince,
++ getThread, getThreadBySlug, createThread, listThreads, recentThreads, setThreadFlags, setThreadMembersOnly, setThreadLabels, setSpaceMembersOnly, softDeleteThread, countThreadsSince,
listCategories, getCategory, getCategoryById, upsertCategory, deleteCategory, setThreadCategory, setThreadStatus, getThreadByKey,
lastPosts, listGroups, updateSpace, createSpace, authorStats, bumpViews,
listModerators, moderatesSpace, addModerator, removeModerator,
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 9155c5c..5960564 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -483,6 +483,13 @@ route('put', '/api/v1/spaces/{space}/threads/{slug}/members-only', {
requestBody: body({ type: 'object', properties: { members_only: { type: 'boolean' }, owner: { type: 'boolean', description: 'The old name of members_only' } } }),
responses: { 200: json('The thread'), ...errors([404, 429]) },
});
++route('put', '/api/v1/spaces/{space}/threads/{slug}/labels', {
++ operationId: 'setThreadLabels', summary: 'Set what a thread carries: its content labels (its author or a moderator)', tags: ['spaces'], security: PERSON,
++ description: 'Content labels (common.content-labels@1): each reader\'s own choice on openvibe.network shows, blurs or hides a labelled thread in their page. A label never hides a thread for anyone else. A suggestion the author confirms leaves `suggested`.',
++ parameters: [path('space', str({ pattern: '^[a-z0-9][a-z0-9-]{1,39}$' })), path('slug', str())],
++ requestBody: body({ type: 'object', required: ['labels'], properties: { labels: { type: 'array', items: { type: 'string', enum: ['nudity', 'language', 'graphic', '18+', 'flashing', 'spoilers'] } } } }),
++ responses: { 200: json('The thread, with content_labels'), ...errors([400, 403, 404, 429]) },
++});
route('put', '/api/v1/spaces/{space}/members-only', {
operationId: 'setSpaceMembersOnly', summary: 'Make a whole space members-only (moderators)', tags: ['spaces'], security: PERSON,
parameters: [path('space', str({ pattern: '^[a-z0-9][a-z0-9-]{1,39}$' }))],
ddiff --git a/server/render/board.js b/server/render/board.js
iindex 9c7c060..aeb8a17 100644
---- a/server/render/board.js
++++ b/server/render/board.js
@@@ -14,6 +14,7 @@ const { escapeHtml: esc } = require('./highlight');
const { markdownToText } = require('./markdown');
const { timeTag, fmtDate, num, avatarUrl, mediaW, authorHtml, wearName } = require('./pages');
const f = require('./forum');
++const labelsUi = require('./labels');
const STYLE_LABELS = { forum: 'Classic', feed: 'Feed' }; // the two styles, as the Forum page and the settings name them
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; };
@@@ -83,9 +84,11 @@ function topicRow(t, space) {
const icon = t.locked ? 'fa-lock' : t.pinned ? 'fa-thumbtack' : t.reply_count >= 15 ? 'fa-fire' : 'fa-comment';
const label = t.locked ? 'Locked' : t.pinned ? 'Sticky' : t.reply_count >= 15 ? 'Hot topic' : 'Topic';
const who = (a) => (a ? wearName(a, a.display_name || a.username || 'someone') : 'someone');
-- return `<tr class="${t.pinned ? 'sticky' : ''}">
++ const labels = labelsUi.inForce(t);
++ return `<tr class="${t.pinned ? 'sticky' : ''}"${labelsUi.attrs(labels, { owner: t.author && t.author.subject })}>
<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)}
++ <td class="topic-main"${labels.length ? ' data-ov-labels-media' : ''}>${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)}
++ ${labelsUi.chips(labels)}
<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>
@@@ -224,6 +227,8 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
: 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 labels = labelsUi.inForce(thread);
++ const labelBox = f.labelsBox(base, thread, viewer);
const body = `
<article class="thread forum-topic">
${crumbs([...trail(space), [space.name, `/s/${space.slug}`], [ld.clean(thread.title, 60), base]])}
@@@ -232,13 +237,15 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
<div>
<h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}${esc(thread.title)}${f.vipBadge(thread)}</h1>
${thread.status || thread.category ? `<p class="thread-tags">${f.statusBadge(thread)}${f.categoryBadge(thread, space)}</p>` : ''}
++ ${labelsUi.chips(labels)}
<p class="small muted">${plural(thread.reply_count, 'reply', 'replies')} · ${plural(thread.views, 'view', 'views')} · <a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="fa-solid fa-rss" aria-hidden="true"></i> Follow this thread by RSS</a></p>
</div>
</header>
${f.crosspostBlock(crosspost, base)}
${f.modActions({ base, space, thread, viewer, categories })}
++ ${labelBox}
${pagerHtml}
-- <div class="postbits">${posts.map((p, i) => postbit(p, first + i + 1, { base, page, catalogue: reactions, canReply: viewer.can_reply, canModerate: viewer.can_moderate })).join('\n')}</div>
++ <div class="postbits"${labelsUi.attrs(labels, { owner: thread.author && thread.author.subject, opened: true })}>${posts.map((p, i) => postbit(p, first + i + 1, { base, page, catalogue: reactions, canReply: viewer.can_reply, canModerate: viewer.can_moderate })).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>`;
@@@ -255,6 +262,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
historyType: 'page',
historyTitle: thread.title,
footerVariant: 'compact',
++ ...(labelBox ? { pageCss: ['labels.css'] } : {}),
feeds: [
...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : []),
{ title: `OpenVibe.Community — ${thread.title}`, href: `${base}/feed.xml` },
ddiff --git a/server/render/forum.js b/server/render/forum.js
iindex abc8eaa..12d86c2 100644
---- a/server/render/forum.js
++++ b/server/render/forum.js
@@@ -10,6 +10,7 @@ const ld = require('./jsonld');
const { renderPage } = require('./layout');
const { escapeHtml: esc } = require('./highlight');
const { markdownToText } = require('./markdown');
++const labelsUi = require('./labels');
const { timeTag, fmtDate, num, authorHtml } = require('./pages');
const { RULES: MODERATION_RULES } = require('../forum/moderation');
@@@ -96,10 +97,12 @@ ${list}`;
// ── /s/:space ────────────────────────────────────────────────
function threadRow(t, space) {
const flags = `${t.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${t.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}`;
-- return `<li class="thread-row${space.votes === false ? ' no-votes' : ''}">
++ const labels = labelsUi.inForce(t);
++ return `<li class="thread-row${space.votes === false ? ' no-votes' : ''}"${labelsUi.attrs(labels, { owner: t.author && t.author.subject })}>
${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)}
++ ${labelsUi.chips(labels)}
<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>
</div>
</li>`;
@@@ -251,6 +254,22 @@ 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>`;
}
++/**
++ * The author's (or a moderator's) Labels box on a thread: a fold whose line says what is set, the picker, and Save.
++ * Empty for anyone else.
++ */
++function labelsBox(base, thread, viewer) {
++ if (!viewer || !viewer.can_label) return '';
++ const now = labelsUi.inForce(thread);
++ const line = now.length ? now.map((l) => labelsUi.NAMES[l]).join(', ') : 'None set';
++ return `<details class="labels-box" id="labels"><summary><span class="labels-box-title"><i class="fa-solid fa-tag" aria-hidden="true"></i> Labels</span> <span class="labels-box-line">${esc(line)}</span></summary>
++ <form method="post" action="${esc(base)}/labels" class="labels-form">
++ ${labelsUi.picker({ current: now, suggested: labelsUi.suggestedOf(thread), id: 'thread-labels' })}
++ <div class="form-actions"><button class="btn btn-primary" type="submit">Save labels</button></div>
++ </form>
++</details>`;
++}
++
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;
@@@ -268,6 +287,8 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
: 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 = modActions({ base, space, thread, viewer, categories });
++ const labels = labelsUi.inForce(thread);
++ const labelBox = labelsBox(base, thread, viewer);
const body = `
<article class="thread">
@@@ -277,13 +298,15 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
<div>
<h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>
${thread.status || thread.category ? `<p class="thread-tags">${statusBadge(thread)}${categoryBadge(thread, space)}</p>` : ''}
++ ${labelsUi.chips(labels)}
<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="thread-follow"><a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="fa-solid fa-rss" aria-hidden="true"></i> Follow this thread by RSS</a></p>
</div>
</header>
${crosspostBlock(crosspost, base)}
${modBlock}
-- <div class="posts">${posts.map((p, i) => postHtml(p, first + i + 1, { base, page, catalogue: reactions, canModerate: viewer.can_moderate })).join('\n')}</div>
++ ${labelBox}
++ <div class="posts"${labelsUi.attrs(labels, { owner: thread.author && thread.author.subject, opened: true })}>${posts.map((p, i) => postHtml(p, first + i + 1, { base, page, catalogue: reactions, canModerate: viewer.can_moderate })).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>`;
@@@ -300,6 +323,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
historyType: 'page',
historyTitle: thread.title,
footerVariant: 'compact',
++ ...(labelBox ? { pageCss: ['labels.css'] } : {}),
feeds: [
...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : []),
{ title: `OpenVibe.Community — ${thread.title}`, href: `${base}/feed.xml` },
@@@ -323,6 +347,7 @@ function newThreadPage({ space, user, values = {}, error = null, categories = []
<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)}
++ ${labelsUi.picker({ current: values.labels || [], id: 'new-labels' })}
<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>
@@@ -342,6 +367,7 @@ ${form}`;
robots: 'noindex,follow',
active: 'spaces',
footerVariant: 'compact',
++ ...(user ? { pageCss: ['labels.css'] } : {}),
body,
});
}
@@@ -427,7 +453,7 @@ ${entries.length ? `<table class="board-table mod-log"><caption class="sr-only">
});
}
--module.exports = {
++module.exports = { labelsBox,
spacesPage, spacePage, threadPage, newThreadPage, membersOnlyPage, moderationLogPage, spaceHref, who, chatRoomBox,
pager, visBadge, vipBadge, statusBadge, categoryBadge, voteForm, modActions, reactionBar, postImages, imageField, pasteField, pasteCards, crosspostBlock, STATUS_LABELS,
postModerationForm,
ddiff --git a/server/render/labels.js b/server/render/labels.js
nnew file mode 100644
iindex 0000000..3e163a5
---- /dev/null
++++ b/server/render/labels.js
@@@ -0,0 +1,69 @@
++'use strict';
++/**
++ * Content labels on the forum's pages (L1, labels and choices; Contracts common.content-labels@1).
++ *
++ * attrs() data-ov-labels on a labelled thread (a row in a list, or a thread's posts). Shared's ov-labels.js, which
++ * the navbar loads when one appears, applies each reader's own choice from openvibe.network: show, blur
++ * behind a "Show" button, or hide. The author always sees their own (data-ov-labels-owner, their subject).
++ * On the thread's own page it is "opened": a hide covers it instead of emptying the page.
++ * chips() the labels in words beside a title, so a reader sees why something is covered.
++ * picker() the labels in a form (new thread, the author's or a moderator's Labels box); AI suggestions are marked
++ * and never ticked for the author.
++ * fromForm() what a submitted form asks for.
++ * A label is a reader's choice, never a ban: nothing here removes a thread for anybody.
++ */
++const { escapeHtml: esc } = require('./highlight');
++const contentLabels = require('../forum/labels');
++
++const NAMES = Object.freeze({ nudity: 'Nudity', language: 'Strong language', graphic: 'Graphic', '18+': '18+', flashing: 'Flashing lights', spoilers: 'Spoilers' });
++const HINTS = Object.freeze({
++ nudity: 'Nudity, art included',
++ language: 'Swearing and slurs',
++ graphic: 'Blood, injury, gore',
++ '18+': 'For adults only',
++ flashing: 'Strobes, fast flashes',
++ spoilers: 'Endings and twists',
++});
++
++/** The labels in force on a thread (its content_labels, as stored or as shaped). */
++const inForce = (t) => (t && t.content_labels !== undefined ? contentLabels.labelsOf(t.content_labels).labels : []);
++const suggestedOf = (t) => (t && t.content_labels !== undefined ? contentLabels.labelsOf(t.content_labels).suggested : []);
++
++/** ' data-ov-labels="…"' and its companions (nothing for no labels). owner: the author's subject. */
++function attrs(labels, { owner = null, opened = false } = {}) {
++ if (!labels || !labels.length) return '';
++ const who = owner && /^[A-Za-z0-9_-]{1,80}$/.test(String(owner)) ? ` data-ov-labels-owner="${esc(owner)}"` : '';
++ return ` data-ov-labels="${esc(labels.join(','))}"${who}${opened ? ' data-ov-labels-opened' : ''}`;
++}
++
++/** The labels in words (18+ in the warning colour), as Shared's kit.labels draws them. */
++function chips(labels) {
++ if (!labels || !labels.length) return '';
++ return `<ul class="ovk-labels" aria-label="Content labels">${labels.map((l) => `<li class="ovk-label${l === '18+' ? ' ovk-label--adult' : ''}" data-label="${esc(l)}">${esc(NAMES[l])}</li>`).join('')}</ul>`;
++}
++
++/**
++ * Six toggles, one per label. `current` is the labels in force, `suggested` the ones an AI or a reader proposed. A hidden
++ * labels_form field says the picker was there, so ticking nothing means no labels.
++ */
++function picker({ current = [], suggested = [], id = 'labels' } = {}) {
++ const now = new Set(current);
++ const sug = new Set(suggested.filter((l) => !now.has(l)));
++ return `<fieldset class="ovl-pick" aria-describedby="${esc(id)}-help"><legend>Content labels</legend>
++<p class="ovl-help" id="${esc(id)}-help">Each reader picks what they see for a label: show it, blur it, or hide it. A label never takes the thread down.${sug.size ? ' Suggested ones are marked; nothing changes until you tick one.' : ''}</p>
++<input type="hidden" name="labels_form" value="1">
++<div class="ovl-opts">${contentLabels.LABELS.map((l) => `<label class="ovl-opt${l === '18+' ? ' ovl-opt--adult' : ''}"><input type="checkbox" name="labels" value="${esc(l)}"${now.has(l) ? ' checked' : ''}><span class="ovl-card"><b>${esc(NAMES[l])}</b><small>${esc(HINTS[l])}</small>${sug.has(l) ? '<em class="ovl-sug">Suggested</em>' : ''}</span></label>`).join('')}</div>
++</fieldset>`;
++}
++
++/**
++ * The labels a form asks for: a clean list, [] when the picker was there with nothing ticked, undefined when the form had
++ * no picker, or null when it named something that is not a label.
++ */
++function fromForm(body) {
++ const b = body || {};
++ if (b.labels === undefined) return b.labels_form ? [] : undefined;
++ return contentLabels.parseLabels([].concat(b.labels));
++}
++
++module.exports = { NAMES, HINTS, inForce, suggestedOf, attrs, chips, picker, fromForm };
ddiff --git a/test/thread-labels.test.js b/test/thread-labels.test.js
nnew file mode 100644
iindex 0000000..14c7263
---- /dev/null
++++ b/test/thread-labels.test.js
@@@ -0,0 +1,119 @@
++'use strict';
++/**
++ * Content labels on threads (L1, labels and choices; Contracts common.content-labels@1): the author labels a thread as
++ * they post it or later, a moderator may too, and nobody else; the lists and the thread page carry data-ov-labels (Shared's
++ * ov-labels.js applies each reader's own choice) with the author named so they always see their own, the thread's own
++ * page is "opened" (a hide covers instead of emptying it), the labels show in words, and ticking nothing clears them.
++ * node test/thread-labels.test.js
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++const ui = require('../server/render/labels');
++
++// ── The render helpers ──────────────────────────────────────────────────────────────────────────────────────────────
++assert.deepStrictEqual(ui.inForce({ content_labels: { labels: ['nudity', '18+', 'gore'], suggested: ['language'] } }), ['nudity', '18+']);
++assert.deepStrictEqual(ui.inForce({ content_labels: '{"labels":["spoilers"],"suggested":[]}' }), ['spoilers'], 'a jsonb string');
++assert.deepStrictEqual(ui.inForce({}), []);
++assert.strictEqual(ui.attrs([]), '');
++assert.strictEqual(ui.attrs(['18+'], { owner: 'usr_01ABC', opened: true }), ' data-ov-labels="18+" data-ov-labels-owner="usr_01ABC" data-ov-labels-opened');
++assert.strictEqual(ui.attrs(['18+'], { owner: 'bad owner' }), ' data-ov-labels="18+"', 'no owner that is not a subject');
++assert.ok(ui.chips(['language', '18+']).includes('<li class="ovk-label ovk-label--adult" data-label="18+">18+</li>'));
++const pick = ui.picker({ current: ['nudity'], suggested: ['flashing', 'nudity'] });
++assert.strictEqual((pick.match(/name="labels" value="/g) || []).length, 6);
++assert.ok(pick.includes('value="nudity" checked>') && !pick.includes('value="flashing" checked'), 'a suggestion is never ticked');
++assert.strictEqual((pick.match(/class="ovl-sug">Suggested</g) || []).length, 1);
++assert.strictEqual(ui.fromForm({}), undefined);
++assert.deepStrictEqual(ui.fromForm({ labels_form: '1' }), []);
++assert.deepStrictEqual(ui.fromForm({ labels_form: '1', labels: 'spoilers' }), ['spoilers']);
++assert.strictEqual(ui.fromForm({ labels_form: '1', labels: ['gore'] }), null);
++
++/** A form body with repeated fields: [['labels', 'nudity'], ['labels', '18+'], …]. */
++const form = (pairs) => ({ method: 'POST', body: new URLSearchParams(pairs).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });
++
++(async () => {
++ const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
++ // Feedback in the feed style (render/forum.js); General stays a forum board (render/board.js).
++ await t.db.prepare("UPDATE spaces SET style = 'feed' WHERE slug = 'feedback'").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' });
++ 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 as = (jwt, opts = {}) => ({ ...opts, cookies: [`ov_token=${jwt}`] });
++ try {
++ let boardUrl;
++ let feedUrl;
++ await check('the new-thread form has the picker, and a thread is posted with its labels', async () => {
++ const page = await t.get('/s/general/new', as(alexJwt));
++ assert.ok(page.text.includes('<legend>Content labels</legend>') && page.text.includes('name="labels_form"'), 'the picker');
++ assert.ok(/<link rel="stylesheet" href="\/css\/labels\.css(\?v=[0-9a-f]+)?">/.test(page.text), 'its sheet');
++ let r = await t.get('/s/general/new', as(alexJwt, form([['title', 'Finale talk'], ['body', 'The last episode.'], ['labels_form', '1'], ['labels', 'spoilers'], ['labels', '18+']])));
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ boardUrl = new URL(r.headers.get('location'), 'https://x').pathname;
++ r = await t.get('/s/feedback/new', as(alexJwt, form([['title', 'Flashing intro'], ['body', 'The intro strobes.'], ['labels_form', '1'], ['labels', 'flashing']])));
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ feedUrl = new URL(r.headers.get('location'), 'https://x').pathname;
++ r = await t.get('/s/general/new', as(alexJwt, form([['title', 'Bad label'], ['body', 'x'], ['labels_form', '1'], ['labels', 'gore']])));
++ assert.strictEqual(r.status, 400, 'not a label');
++ assert.ok(r.text.includes('Labels are from this list'), r.text.slice(0, 200));
++ });
++
++ await check('the board: the row is labelled, its title cell is what a blur covers, the chips under the title', async () => {
++ const r = await t.get('/s/general');
++ assert.ok(r.text.includes(` data-ov-labels="spoilers,18+" data-ov-labels-owner="${alex.subject_id}">`), 'the row');
++ assert.ok(r.text.includes('<td class="topic-main" data-ov-labels-media>'), 'the cover goes on the title cell');
++ assert.ok(r.text.includes('<li class="ovk-label" data-label="spoilers">Spoilers</li><li class="ovk-label ovk-label--adult" data-label="18+">18+</li>'), 'in words');
++ });
++
++ await check('the thread page: chips, the posts labelled and opened, the Labels box for the author only', async () => {
++ let r = await t.get(boardUrl);
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes(`<div class="postbits" data-ov-labels="spoilers,18+" data-ov-labels-owner="${alex.subject_id}" data-ov-labels-opened>`), 'the posts');
++ assert.ok(!r.text.includes('id="labels"') && !r.text.includes('css/labels.css'), 'no box for a reader');
++ r = await t.get(boardUrl, as(samJwt));
++ assert.ok(!r.text.includes('id="labels"'), 'nor for someone else');
++ r = await t.get(boardUrl, as(alexJwt));
++ assert.ok(/<details class="labels-box" id="labels"><summary>[\s\S]*<span class="labels-box-line">Spoilers, 18\+<\/span>/.test(r.text), 'the box says what is set');
++ assert.ok(r.text.includes('css/labels.css'), 'with its sheet');
++ r = await t.get(feedUrl);
++ assert.ok(r.text.includes(`<div class="posts" data-ov-labels="flashing" data-ov-labels-owner="${alex.subject_id}" data-ov-labels-opened>`), 'the feed style too');
++ r = await t.get('/s/feedback');
++ assert.ok(/<li class="thread-row[^"]*" data-ov-labels="flashing" data-ov-labels-owner="/.test(r.text), 'and its list');
++ });
++
++ await check('only the author or a moderator labels; ticking nothing clears them; the API says so too', async () => {
++ let r = await t.get(`${boardUrl}/labels`, as(samJwt, form([['labels_form', '1'], ['labels', 'nudity']])));
++ assert.strictEqual(r.status, 403, 'someone else');
++ r = await t.get(`${boardUrl}/labels`, as(alexJwt, form([['labels_form', '1'], ['labels', 'spoilers']])));
++ assert.strictEqual(r.status, 303);
++ assert.ok((await t.get(boardUrl)).text.includes(' data-ov-labels="spoilers" '), 'now spoilers only');
++ r = await t.get(`${boardUrl}/labels`, as(alexJwt, form([['labels_form', '1']])));
++ assert.strictEqual(r.status, 303);
++ assert.ok(!(await t.get(boardUrl)).text.includes('data-ov-labels='), 'cleared');
++ const slug = boardUrl.split('/').pop();
++ r = await t.get(`/api/v1/spaces/general/threads/${slug}/labels`, as(alexJwt, { method: 'PUT', body: JSON.stringify({ labels: ['graphic'] }), headers: { 'content-type': 'application/json' } }));
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.json().thread.content_labels, { labels: ['graphic'], suggested: [] });
++ r = await t.get(`/api/v1/spaces/general/threads/${slug}/labels`, as(alexJwt, { method: 'PUT', body: JSON.stringify({ labels: ['gore'] }), headers: { 'content-type': 'application/json' } }));
++ assert.strictEqual(r.status, 400);
++ assert.strictEqual(r.json().code, 'thread.labels_invalid');
++ r = await t.get(`/api/v1/spaces/general/threads/${slug}/labels`, as(samJwt, { method: 'PUT', body: JSON.stringify({ labels: [] }), headers: { 'content-type': 'application/json' } }));
++ assert.strictEqual(r.status, 403);
++ });
++
++ await check('a confirmed suggestion leaves the suggestions; an unlabelled thread carries the empty shape', async () => {
++ const slug = feedUrl.split('/').pop();
++ await t.db.prepare(`UPDATE threads SET content_labels = '{"labels":["flashing"],"suggested":["language","graphic"]}'::jsonb WHERE slug = ?`).run(slug);
++ let r = await t.get(feedUrl, as(alexJwt));
++ assert.ok(/value="language"><span class="ovl-card"><b>Strong language<\/b><small>[^<]+<\/small><em class="ovl-sug">Suggested<\/em>/.test(r.text), 'marked, not ticked');
++ r = await t.get(`/api/v1/spaces/feedback/threads/${slug}/labels`, as(alexJwt, { method: 'PUT', body: JSON.stringify({ labels: ['flashing', 'language'] }), headers: { 'content-type': 'application/json' } }));
++ assert.deepStrictEqual(r.json().thread.content_labels, { labels: ['flashing', 'language'], suggested: ['graphic'] });
++ r = await t.get('/api/v1/spaces/general/threads', as(alexJwt));
++ const plain = (r.json().threads || r.json().items || []).find((x) => x.title !== 'Finale talk');
++ if (plain) assert.deepStrictEqual(plain.content_labels, { labels: [], suggested: [] });
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });