openvibers/openvibe.community Commits
Forum and comments: the shared composer v2 (#398)
Changes
@@@ -2,6 +2,11 @@## Unreleased++- The forum's reply and new thread boxes and the inline comment thread are the shared composer v2 (openvibe-shared++ 4.215.0, `OpenVibeComposer.mount`): the reply and a comment use the comment preset, a new thread the post preset, and++ each keeps its form's action, a reply's parent and its no-JS post. Tests: `test/composer.test.js` at 390 px and the++ shared `test/inline-composer-chrome.test.js`.++- A Live recording, clip or "went live" card names its channel's person: the one subject Community knows by thatusername, none when two share it. 70 of 112 Live recordings and clips since 11-01 had no subject, so the Followingfeed never showed them and one streamer's evening folded as two runs. An update fills a card that had no person; a@@@ -9,7 +9,7 @@"domain": "openvibe.community","contracts": "openvibe-contracts v0.410.0","sdk": "openvibe-sdk v0.150.0",-- "shared": "openvibe-shared v4.214.0",++ "shared": "openvibe-shared v4.215.0","capabilities": ["community.paste.create","community.paste.write",@@@ -19,7 +19,7 @@"multer": "^2.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0","pg": "^8.23.0"},"devDependencies": {@@@ -1317,9 +1317,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.214.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",-- "integrity": "sha512-HvaqQOZLLFEgwDaCkrhJFfwQfsMIlOmV05PhO796JFmC7A1NScxOqp86djT6sYsK1pARMqeo0kTua5mAdXiQFw==",++ "version": "4.215.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",++ "integrity": "sha512-2hXCSsMQieDuYtdcr4KEhBmrM0rzw10T8TT/edGSsoXXQCf4bmqMWp7iPBR3NYaJgiGATcnNFdW931AbtGhfAw==","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -31,7 +31,7 @@"multer": "^2.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0","pg": "^8.23.0"},"engines": {nnew file mode 100644@@@ -0,0 +1,49 @@++/* OpenVibe.Community's composer on the shared one (openvibe-shared 4.215.0, OpenVibeComposer.mount): a marked++ server form keeps its action, its fields (a title, a category, images, pastes, a reply's parent) and its no-JS++ submit; the words and tools inside it become the shared composer. The page loads ov-composer.js or ov-thread.js++ first; without either the plain form stays. */++(function () {++ 'use strict';++ function labelOf(form) {++ var b = form.querySelector('.form-actions button[type="submit"], button[type="submit"]');++ var t = b ? b.textContent.replace(/\s+/g, ' ').trim() : '';++ return t || (form.getAttribute('data-ovc-preset') === 'comment' ? 'Reply' : 'Post');++ }++ function upgrade(form) {++ if (!form || form.__ovcComposer || !window.OpenVibeComposer || !window.OpenVibeComposer.mount) return;++ var ta = form.querySelector('textarea[name="body"], textarea[name="message"], textarea');++ if (!ta) return;++ var user = null;++ try { user = JSON.parse(form.getAttribute('data-ovc-user') || 'null'); } catch (e) { user = null; }++ var preset = form.getAttribute('data-ovc-preset') || 'post';++ var hint = form.querySelector('.form-actions .muted');++ var note = hint ? hint.textContent.replace(/\s+/g, ' ').trim() : '';++ var host = document.createElement('div');++ var c;++ try {++ c = window.OpenVibeComposer.mount(host, {++ preset: preset, user: user, name: ta.name || 'body', placeholder: ta.placeholder || '',++ maxLength: Number(ta.getAttribute('maxlength')) || 40000, submitLabel: labelOf(form),++ note: note, tools: ['emoji'], label: ta.getAttribute('aria-label') || ta.placeholder || 'Write',++ });++ } catch (e) { return; }++ if (!c) return;++ if (ta.value) c.text.value = ta.value;++ var anchor = ta.closest('label') || ta;++ var parent = anchor.parentNode;++ while (c.form.firstChild) parent.insertBefore(c.form.firstChild, anchor);++ anchor.remove();++ var actions = form.querySelector('.form-actions');++ if (actions) actions.remove();++ form.classList.add('ovt-composer', 'ovc-v2');++ form.dataset.preset = preset;++ form.setAttribute('data-ovc', '');++ form.__ovcComposer = c;++ }++ function scan() {++ Array.prototype.forEach.call(document.querySelectorAll('form[data-ovc-compose]'), upgrade);++ }++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', scan, { once: true }); else scan();++ document.addEventListener('ov:boost:load', scan);++ window.OVCommunityComposer = { scan: scan, upgrade: upgrade };++})();@@@ -220,7 +220,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, vieweconst 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">++ ? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply" data-ovc-compose data-ovc-preset="comment" data-ovc-user="${esc(JSON.stringify({ display_name: user.display_name || user.username || 'you' }))}"><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>@@@ -267,7 +267,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewehistoryTitle: thread.title,footerVariant: 'compact',styles: ['kit.css', ...(attachmentsEnabled ? ['ov-upload.css'] : [])],-- ...(attachmentsEnabled ? { scripts: ['ov-upload.js'] } : {}),++ scripts: ['ov-composer.js', ...(attachmentsEnabled ? ['ov-upload.js'] : [])],...(labelBox ? { pageCss: ['labels.css'] } : {}),feeds: [...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : []),@@@ -110,6 +110,7 @@ function threadPage({ accessId, page, user, after = null, sort = 'top', replyIdrobots: 'noindex,nofollow',active: 'comments',footerVariant: 'compact',++ scripts: ['ov-thread.js'],body: `${body}<script src="${asset('js/inline-comments.js')}" defer></script>`, styles: ['kit.css', 'thread.css'], pageCss: ['inline-comments.css'],});}@@@ -285,7 +285,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, uconst 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">++ ? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply" data-ovc-compose data-ovc-preset="comment" data-ovc-user="${esc(JSON.stringify({ display_name: user.display_name || user.username || 'you' }))}">${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, space)}@@@ -332,7 +332,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, uhistoryTitle: thread.title,footerVariant: 'compact',styles: ['kit.css', ...(attachmentsEnabled ? ['ov-upload.css'] : [])],-- ...(attachmentsEnabled ? { scripts: ['ov-upload.js'] } : {}),++ scripts: ['ov-composer.js', ...(attachmentsEnabled ? ['ov-upload.js'] : [])],...(labelBox ? { pageCss: ['labels.css'] } : {}),feeds: [...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : []),@@@ -349,7 +349,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u// ── /forum/:space/new ────────────────────────────────────────────function newThreadPage({ space, user, values = {}, error = null, categories = [], attachments = false }) {const categoryField = categories.length ? `<label class="field"><span>Category</span><select name="category"><option value="">None</option>${categories.map((c) => `<option value="${esc(c.slug)}"${values.category === c.slug ? ' selected' : ''}>${esc(c.name)}${c.description ? ` — ${esc(c.description)}` : ''}</option>`).join('')}</select></label>` : '';-- const form = user ? `<form class="paste-form" method="post" action="/forum/${esc(space.slug)}/new">++ const form = user ? `<form class="paste-form" method="post" action="/forum/${esc(space.slug)}/new" data-ovc-compose data-ovc-preset="post" data-ovc-user="${esc(JSON.stringify({ display_name: user.display_name || user.username || 'you' }))}">${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}${space.thread_kind === 'request' ? '<p class="muted small">A request is open to votes. Search first: if it is already here, vote for it instead.</p>' : ''}<label class="field"><span>Title</span><input type="text" name="title" minlength="3" maxlength="200" required value="${esc(values.title || '')}" placeholder="What is it about?"></label>@@@ -378,7 +378,8 @@ ${form}`;active: 'spaces',footerVariant: 'compact',...(user ? { pageCss: ['labels.css'] } : {}),-- ...(attachments ? { styles: ['ov-upload.css'], scripts: ['ov-upload.js'] } : {}),++ scripts: ['ov-composer.js', ...(attachments ? ['ov-upload.js'] : [])],++ ...(attachments ? { styles: ['ov-upload.css'] } : {}),body,});}@@@ -152,7 +152,8 @@ function renderPage(o) {`<link rel="stylesheet" href="${asset('css/community.css')}">`,...(o.pageCss || []).map((name) => `<link rel="stylesheet" href="${asset(`css/${name}`)}">`),...(o.scripts || []).map((name) => `<script src="${ovServe.url(name)}" defer></script>`),-- `<script src="${asset('js/community.js')}" defer></script>`,++ `<script src="${asset('js/community.js')}" defer></script>++ <script src="${asset('js/composer.js')}" defer></script>`,// openvibe-shared items.js + items.css: community.js loads them only on a page that shows someone.`<meta name="ov-items" content="${escapeHtml(ovServe.url('items.js'))} ${escapeHtml(ovServe.url('items.css'))}">`,// openvibe-shared kit-web.js (rails' arrows, card menus, copy buttons): community.js loads it only on a page@@@ -632,7 +632,7 @@ window.addEventListener('DOMContentLoaded', function () {ld.pasteLd(p, { description, image: shot || DEFAULT_OG_IMAGE }),ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: `/p/${pageSlug}` }]),],-- styles: ['kit.css', 'thread.css'], pageCss: ['inline-comments.css'],++ styles: ['kit.css', 'thread.css'], pageCss: ['inline-comments.css'], scripts: ['ov-thread.js'],body: `${body}<script src="${asset('js/inline-comments.js')}" defer></script>`,});}nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++// The Community composer on the shared one (openvibe-shared 4.215.0, OpenVibeComposer.mount): a marked server form++// keeps its action, title, paste field and no-JS submit; its words become form.ovc-v2[data-preset] and the form++// still posts. Real Chrome at 390 px; skipped without Chrome.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const h = require('openvibe-shared/browser-harness');++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('composer: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }++const SHARED = process.env.OV_SHARED_DIR || path.join(__dirname, '..', 'node_modules', 'openvibe-shared');++const SITE = path.join(__dirname, '..');++const reply = `<form class="paste-form reply-form" method="post" action="/forum/general/t/reply" data-ovc-compose data-ovc-preset="comment" data-ovc-user='{"display_name":"Alex"}'>++<p class="alert alert-error" role="alert">A problem</p>++<label class="field"><span>Reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it."></textarea></label>++<label class="field"><span>Pastes</span><input type="text" name="pastes" value="https://openvibe.community/p/x"></label>++<div class="form-actions"><button class="btn btn-primary" type="submit">Post reply</button><span class="muted small">**bold**, *italic*</span></div>++</form>`;++const fresh = `<form class="paste-form" method="post" action="/forum/general/new" data-ovc-compose data-ovc-preset="post" data-ovc-user='{"display_name":"Alex"}'>++<label class="field"><span>Title</span><input type="text" name="title" value="A title" required></label>++<label class="field"><span>Post (Markdown)</span><textarea name="body" rows="14" maxlength="40000" required placeholder="Say it, own it."></textarea></label>++<div class="form-actions"><button class="btn btn-primary" type="submit">Post thread</button></div>++</form>`;++let posted = '';++const server = http.createServer((req, res) => {++ if (req.method === 'POST') {++ let body = '';++ req.on('data', (d) => { body += d; });++ req.on('end', () => { posted = body; res.writeHead(200, { 'content-type': 'text/html' }); res.end('<!doctype html><p id="posted">posted</p>'); });++ return;++ }++ if (req.url === '/') {++ res.writeHead(200, { 'content-type': 'text/html' });++ return res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Composer</title><link rel="icon" href="data:,"></head><body style="margin:0;padding:16px;background:#0b1220;color:#e6edf7;font-family:sans-serif">${reply}${fresh}<script src="/ov-composer.js"></script><script src="/composer.js"></script></body></html>`);++ }++ if (req.url === '/ov-composer.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(SHARED, 'ov-composer.js'))); }++ if (req.url === '/composer.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(SITE, 'public/js/composer.js'))); }++ res.writeHead(404); res.end();++});++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'community-composer-'));++ const browser = await h.launch({ tmpDir });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ const page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 900 });++ try {++ await page.goto(base + '/', { maxMs: 6000 });++ for (let i = 0; i < 100 && !(await page.evaluate(`!!document.querySelector('form.ovc-v2')`)); i++) await wait(40);++ const shape = await page.evaluate(`(() => [...document.querySelectorAll('form[data-ovc-compose]')].map((f) => ({ cls: f.className, preset: f.dataset.preset, action: f.getAttribute('action'), text: !!f.querySelector('[data-ovt-text]'), old: !!f.querySelector('label.field textarea'), title: (f.querySelector('input[name=title]') || {}).value || null, send: f.querySelector('[data-ovt-send]').textContent.trim() })))()`);++ assert.deepEqual(shape.map((s) => [s.preset, s.action, s.text, s.old]), [['comment', '/forum/general/t/reply', true, false], ['post', '/forum/general/new', true, false]]);++ assert.ok(/ovc-v2/.test(shape[0].cls) && /ovt-composer/.test(shape[0].cls));++ assert.equal(shape[0].send, 'Post reply');++ assert.equal(shape[1].title, 'A title');++ assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390, 'nothing is wider than a phone');++ await page.evaluate(`(() => { const f = document.querySelector('form[data-ovc-preset="comment"]'); const t = f.querySelector('textarea'); t.value = 'Hello from the shared box'; t.dispatchEvent(new Event('input', { bubbles: true })); f.querySelector('[data-ovt-send]').click(); })()`);++ for (let i = 0; i < 100 && !posted; i++) await wait(40);++ assert.ok(/body=Hello\+from\+the\+shared\+box/.test(posted) || /name="body"\r?\n\r?\nHello from the shared box/.test(posted), 'the words post under the form\'s own name');++ assert.ok(/pastes=/.test(posted), 'the form\'s other fields stay');++ console.log('composer: the reply and the new thread are the shared composer, keep their fields, send, 390 px');++ } finally {++ await page.close().catch(() => {});++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });
@@ -2,6 +2,11 @@
@@ -2,6 +2,11 @@
## Unreleased
## Unreleased
+- The forum's reply and new thread boxes and the inline comment thread are the shared composer v2 (openvibe-shared
+ 4.215.0, `OpenVibeComposer.mount`): the reply and a comment use the comment preset, a new thread the post preset, and
+ each keeps its form's action, a reply's parent and its no-JS post. Tests: `test/composer.test.js` at 390 px and the
+ shared `test/inline-composer-chrome.test.js`.
+
- A Live recording, clip or "went live" card names its channel's person: the one subject Community knows by that
- A Live recording, clip or "went live" card names its channel's person: the one subject Community knows by that
username, none when two share it. 70 of 112 Live recordings and clips since 11-01 had no subject, so the Following
username, none when two share it. 70 of 112 Live recordings and clips since 11-01 had no subject, so the Following
feed never showed them and one streamer's evening folded as two runs. An update fills a card that had no person; a
feed never showed them and one streamer's evening folded as two runs. An update fills a card that had no person; a
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
"domain": "openvibe.community",
"domain": "openvibe.community",
"contracts": "openvibe-contracts v0.410.0",
"contracts": "openvibe-contracts v0.410.0",
"sdk": "openvibe-sdk v0.150.0",
"sdk": "openvibe-sdk v0.150.0",
- "shared": "openvibe-shared v4.214.0",
+ "shared": "openvibe-shared v4.215.0",
"capabilities": [
"capabilities": [
"community.paste.create",
"community.paste.create",
"community.paste.write",
"community.paste.write",
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"multer": "^2.4.0",
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -1317,9 +1317,9 @@
@@ -1317,9 +1317,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.214.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",
- "integrity": "sha512-HvaqQOZLLFEgwDaCkrhJFfwQfsMIlOmV05PhO796JFmC7A1NScxOqp86djT6sYsK1pARMqeo0kTua5mAdXiQFw==",
+ "version": "4.215.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",
+ "integrity": "sha512-2hXCSsMQieDuYtdcr4KEhBmrM0rzw10T8TT/edGSsoXXQCf4bmqMWp7iPBR3NYaJgiGATcnNFdW931AbtGhfAw==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -31,7 +31,7 @@
@@ -31,7 +31,7 @@
"multer": "^2.4.0",
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+/* OpenVibe.Community's composer on the shared one (openvibe-shared 4.215.0, OpenVibeComposer.mount): a marked
+ server form keeps its action, its fields (a title, a category, images, pastes, a reply's parent) and its no-JS
+ submit; the words and tools inside it become the shared composer. The page loads ov-composer.js or ov-thread.js
+ first; without either the plain form stays. */
+(function () {
+ 'use strict';
+ function labelOf(form) {
+ var b = form.querySelector('.form-actions button[type="submit"], button[type="submit"]');
+ var t = b ? b.textContent.replace(/\s+/g, ' ').trim() : '';
+ return t || (form.getAttribute('data-ovc-preset') === 'comment' ? 'Reply' : 'Post');
+ }
+ function upgrade(form) {
+ if (!form || form.__ovcComposer || !window.OpenVibeComposer || !window.OpenVibeComposer.mount) return;
+ var ta = form.querySelector('textarea[name="body"], textarea[name="message"], textarea');
+ if (!ta) return;
+ var user = null;
+ try { user = JSON.parse(form.getAttribute('data-ovc-user') || 'null'); } catch (e) { user = null; }
+ var preset = form.getAttribute('data-ovc-preset') || 'post';
+ var hint = form.querySelector('.form-actions .muted');
+ var note = hint ? hint.textContent.replace(/\s+/g, ' ').trim() : '';
+ var host = document.createElement('div');
+ var c;
+ try {
+ c = window.OpenVibeComposer.mount(host, {
+ preset: preset, user: user, name: ta.name || 'body', placeholder: ta.placeholder || '',
+ maxLength: Number(ta.getAttribute('maxlength')) || 40000, submitLabel: labelOf(form),
+ note: note, tools: ['emoji'], label: ta.getAttribute('aria-label') || ta.placeholder || 'Write',
+ });
+ } catch (e) { return; }
+ if (!c) return;
+ if (ta.value) c.text.value = ta.value;
+ var anchor = ta.closest('label') || ta;
+ var parent = anchor.parentNode;
+ while (c.form.firstChild) parent.insertBefore(c.form.firstChild, anchor);
+ anchor.remove();
+ var actions = form.querySelector('.form-actions');
+ if (actions) actions.remove();
+ form.classList.add('ovt-composer', 'ovc-v2');
+ form.dataset.preset = preset;
+ form.setAttribute('data-ovc', '');
+ form.__ovcComposer = c;
+ }
+ function scan() {
+ Array.prototype.forEach.call(document.querySelectorAll('form[data-ovc-compose]'), upgrade);
+ }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', scan, { once: true }); else scan();
+ document.addEventListener('ov:boost:load', scan);
+ window.OVCommunityComposer = { scan: scan, upgrade: upgrade };
+})();
@@ -220,7 +220,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
@@ -220,7 +220,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
const indexable = space.visibility === 'public' && !gated;
const indexable = space.visibility === 'public' && !gated;
const first = (page - 1) * perPage;
const first = (page - 1) * perPage;
const replyBlock = viewer.can_reply
const replyBlock = viewer.can_reply
- ? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply">
+ ? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply" data-ovc-compose data-ovc-preset="comment" data-ovc-user="${esc(JSON.stringify({ display_name: user.display_name || user.username || 'you' }))}">
<h2 class="h3">Reply</h2>
<h2 class="h3">Reply</h2>
${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 class="sr-only">Your reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>
<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>
@@ -267,7 +267,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
@@ -267,7 +267,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
historyTitle: thread.title,
historyTitle: thread.title,
footerVariant: 'compact',
footerVariant: 'compact',
styles: ['kit.css', ...(attachmentsEnabled ? ['ov-upload.css'] : [])],
styles: ['kit.css', ...(attachmentsEnabled ? ['ov-upload.css'] : [])],
- ...(attachmentsEnabled ? { scripts: ['ov-upload.js'] } : {}),
+ scripts: ['ov-composer.js', ...(attachmentsEnabled ? ['ov-upload.js'] : [])],
...(labelBox ? { pageCss: ['labels.css'] } : {}),
...(labelBox ? { pageCss: ['labels.css'] } : {}),
feeds: [
feeds: [
...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : []),
...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : []),
@@ -110,6 +110,7 @@ function threadPage({ accessId, page, user, after = null, sort = 'top', replyId
@@ -110,6 +110,7 @@ function threadPage({ accessId, page, user, after = null, sort = 'top', replyId
robots: 'noindex,nofollow',
robots: 'noindex,nofollow',
active: 'comments',
active: 'comments',
footerVariant: 'compact',
footerVariant: 'compact',
+ scripts: ['ov-thread.js'],
body: `${body}<script src="${asset('js/inline-comments.js')}" defer></script>`, styles: ['kit.css', 'thread.css'], pageCss: ['inline-comments.css'],
body: `${body}<script src="${asset('js/inline-comments.js')}" defer></script>`, styles: ['kit.css', 'thread.css'], pageCss: ['inline-comments.css'],
});
});
}
}
@@ -285,7 +285,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -285,7 +285,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
const indexable = space.visibility === 'public' && !gated;
const indexable = space.visibility === 'public' && !gated;
const first = (page - 1) * perPage;
const first = (page - 1) * perPage;
const replyBlock = viewer.can_reply
const replyBlock = viewer.can_reply
- ? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply">
+ ? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply" data-ovc-compose data-ovc-preset="comment" data-ovc-user="${esc(JSON.stringify({ display_name: user.display_name || user.username || 'you' }))}">
${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, space)}
${imageField(attachmentsEnabled, space)}
@@ -332,7 +332,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -332,7 +332,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
historyTitle: thread.title,
historyTitle: thread.title,
footerVariant: 'compact',
footerVariant: 'compact',
styles: ['kit.css', ...(attachmentsEnabled ? ['ov-upload.css'] : [])],
styles: ['kit.css', ...(attachmentsEnabled ? ['ov-upload.css'] : [])],
- ...(attachmentsEnabled ? { scripts: ['ov-upload.js'] } : {}),
+ scripts: ['ov-composer.js', ...(attachmentsEnabled ? ['ov-upload.js'] : [])],
...(labelBox ? { pageCss: ['labels.css'] } : {}),
...(labelBox ? { pageCss: ['labels.css'] } : {}),
feeds: [
feeds: [
...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : []),
...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : []),
@@ -349,7 +349,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -349,7 +349,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
// ── /forum/:space/new ────────────────────────────────────────────
// ── /forum/:space/new ────────────────────────────────────────────
function newThreadPage({ space, user, values = {}, error = null, categories = [], attachments = false }) {
function newThreadPage({ space, user, values = {}, error = null, categories = [], attachments = false }) {
const categoryField = categories.length ? `<label class="field"><span>Category</span><select name="category"><option value="">None</option>${categories.map((c) => `<option value="${esc(c.slug)}"${values.category === c.slug ? ' selected' : ''}>${esc(c.name)}${c.description ? ` — ${esc(c.description)}` : ''}</option>`).join('')}</select></label>` : '';
const categoryField = categories.length ? `<label class="field"><span>Category</span><select name="category"><option value="">None</option>${categories.map((c) => `<option value="${esc(c.slug)}"${values.category === c.slug ? ' selected' : ''}>${esc(c.name)}${c.description ? ` — ${esc(c.description)}` : ''}</option>`).join('')}</select></label>` : '';
- const form = user ? `<form class="paste-form" method="post" action="/forum/${esc(space.slug)}/new">
+ const form = user ? `<form class="paste-form" method="post" action="/forum/${esc(space.slug)}/new" data-ovc-compose data-ovc-preset="post" data-ovc-user="${esc(JSON.stringify({ display_name: user.display_name || user.username || 'you' }))}">
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${space.thread_kind === 'request' ? '<p class="muted small">A request is open to votes. Search first: if it is already here, vote for it instead.</p>' : ''}
${space.thread_kind === 'request' ? '<p class="muted small">A request is open to votes. Search first: if it is already here, vote for it instead.</p>' : ''}
<label class="field"><span>Title</span><input type="text" name="title" minlength="3" maxlength="200" required value="${esc(values.title || '')}" placeholder="What is it about?"></label>
<label class="field"><span>Title</span><input type="text" name="title" minlength="3" maxlength="200" required value="${esc(values.title || '')}" placeholder="What is it about?"></label>
@@ -378,7 +378,8 @@ ${form}`;
@@ -378,7 +378,8 @@ ${form}`;
active: 'spaces',
active: 'spaces',
footerVariant: 'compact',
footerVariant: 'compact',
...(user ? { pageCss: ['labels.css'] } : {}),
...(user ? { pageCss: ['labels.css'] } : {}),
- ...(attachments ? { styles: ['ov-upload.css'], scripts: ['ov-upload.js'] } : {}),
+ scripts: ['ov-composer.js', ...(attachments ? ['ov-upload.js'] : [])],
+ ...(attachments ? { styles: ['ov-upload.css'] } : {}),
body,
body,
});
});
}
}
@@ -152,7 +152,8 @@ function renderPage(o) {
@@ -152,7 +152,8 @@ function renderPage(o) {
`<link rel="stylesheet" href="${asset('css/community.css')}">`,
`<link rel="stylesheet" href="${asset('css/community.css')}">`,
...(o.pageCss || []).map((name) => `<link rel="stylesheet" href="${asset(`css/${name}`)}">`),
...(o.pageCss || []).map((name) => `<link rel="stylesheet" href="${asset(`css/${name}`)}">`),
...(o.scripts || []).map((name) => `<script src="${ovServe.url(name)}" defer></script>`),
...(o.scripts || []).map((name) => `<script src="${ovServe.url(name)}" defer></script>`),
- `<script src="${asset('js/community.js')}" defer></script>`,
+ `<script src="${asset('js/community.js')}" defer></script>
+ <script src="${asset('js/composer.js')}" defer></script>`,
// openvibe-shared items.js + items.css: community.js loads them only on a page that shows someone.
// openvibe-shared items.js + items.css: community.js loads them only on a page that shows someone.
`<meta name="ov-items" content="${escapeHtml(ovServe.url('items.js'))} ${escapeHtml(ovServe.url('items.css'))}">`,
`<meta name="ov-items" content="${escapeHtml(ovServe.url('items.js'))} ${escapeHtml(ovServe.url('items.css'))}">`,
// openvibe-shared kit-web.js (rails' arrows, card menus, copy buttons): community.js loads it only on a page
// openvibe-shared kit-web.js (rails' arrows, card menus, copy buttons): community.js loads it only on a page
@@ -632,7 +632,7 @@ window.addEventListener('DOMContentLoaded', function () {
@@ -632,7 +632,7 @@ window.addEventListener('DOMContentLoaded', function () {
ld.pasteLd(p, { description, image: shot || DEFAULT_OG_IMAGE }),
ld.pasteLd(p, { description, image: shot || DEFAULT_OG_IMAGE }),
ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: `/p/${pageSlug}` }]),
ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: `/p/${pageSlug}` }]),
],
],
- styles: ['kit.css', 'thread.css'], pageCss: ['inline-comments.css'],
+ styles: ['kit.css', 'thread.css'], pageCss: ['inline-comments.css'], scripts: ['ov-thread.js'],
body: `${body}<script src="${asset('js/inline-comments.js')}" defer></script>`,
body: `${body}<script src="${asset('js/inline-comments.js')}" defer></script>`,
});
});
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+// The Community composer on the shared one (openvibe-shared 4.215.0, OpenVibeComposer.mount): a marked server form
+// keeps its action, title, paste field and no-JS submit; its words become form.ovc-v2[data-preset] and the form
+// still posts. Real Chrome at 390 px; skipped without Chrome.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const h = require('openvibe-shared/browser-harness');
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('composer: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
+const SHARED = process.env.OV_SHARED_DIR || path.join(__dirname, '..', 'node_modules', 'openvibe-shared');
+const SITE = path.join(__dirname, '..');
+const reply = `<form class="paste-form reply-form" method="post" action="/forum/general/t/reply" data-ovc-compose data-ovc-preset="comment" data-ovc-user='{"display_name":"Alex"}'>
+<p class="alert alert-error" role="alert">A problem</p>
+<label class="field"><span>Reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it."></textarea></label>
+<label class="field"><span>Pastes</span><input type="text" name="pastes" value="https://openvibe.community/p/x"></label>
+<div class="form-actions"><button class="btn btn-primary" type="submit">Post reply</button><span class="muted small">**bold**, *italic*</span></div>
+</form>`;
+const fresh = `<form class="paste-form" method="post" action="/forum/general/new" data-ovc-compose data-ovc-preset="post" data-ovc-user='{"display_name":"Alex"}'>
+<label class="field"><span>Title</span><input type="text" name="title" value="A title" required></label>
+<label class="field"><span>Post (Markdown)</span><textarea name="body" rows="14" maxlength="40000" required placeholder="Say it, own it."></textarea></label>
+<div class="form-actions"><button class="btn btn-primary" type="submit">Post thread</button></div>
+</form>`;
+let posted = '';
+const server = http.createServer((req, res) => {
+ if (req.method === 'POST') {
+ let body = '';
+ req.on('data', (d) => { body += d; });
+ req.on('end', () => { posted = body; res.writeHead(200, { 'content-type': 'text/html' }); res.end('<!doctype html><p id="posted">posted</p>'); });
+ return;
+ }
+ if (req.url === '/') {
+ res.writeHead(200, { 'content-type': 'text/html' });
+ return res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Composer</title><link rel="icon" href="data:,"></head><body style="margin:0;padding:16px;background:#0b1220;color:#e6edf7;font-family:sans-serif">${reply}${fresh}<script src="/ov-composer.js"></script><script src="/composer.js"></script></body></html>`);
+ }
+ if (req.url === '/ov-composer.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(SHARED, 'ov-composer.js'))); }
+ if (req.url === '/composer.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(SITE, 'public/js/composer.js'))); }
+ res.writeHead(404); res.end();
+});
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'community-composer-'));
+ const browser = await h.launch({ tmpDir });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ const page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 900 });
+ try {
+ await page.goto(base + '/', { maxMs: 6000 });
+ for (let i = 0; i < 100 && !(await page.evaluate(`!!document.querySelector('form.ovc-v2')`)); i++) await wait(40);
+ const shape = await page.evaluate(`(() => [...document.querySelectorAll('form[data-ovc-compose]')].map((f) => ({ cls: f.className, preset: f.dataset.preset, action: f.getAttribute('action'), text: !!f.querySelector('[data-ovt-text]'), old: !!f.querySelector('label.field textarea'), title: (f.querySelector('input[name=title]') || {}).value || null, send: f.querySelector('[data-ovt-send]').textContent.trim() })))()`);
+ assert.deepEqual(shape.map((s) => [s.preset, s.action, s.text, s.old]), [['comment', '/forum/general/t/reply', true, false], ['post', '/forum/general/new', true, false]]);
+ assert.ok(/ovc-v2/.test(shape[0].cls) && /ovt-composer/.test(shape[0].cls));
+ assert.equal(shape[0].send, 'Post reply');
+ assert.equal(shape[1].title, 'A title');
+ assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390, 'nothing is wider than a phone');
+ await page.evaluate(`(() => { const f = document.querySelector('form[data-ovc-preset="comment"]'); const t = f.querySelector('textarea'); t.value = 'Hello from the shared box'; t.dispatchEvent(new Event('input', { bubbles: true })); f.querySelector('[data-ovt-send]').click(); })()`);
+ for (let i = 0; i < 100 && !posted; i++) await wait(40);
+ assert.ok(/body=Hello\+from\+the\+shared\+box/.test(posted) || /name="body"\r?\n\r?\nHello from the shared box/.test(posted), 'the words post under the form\'s own name');
+ assert.ok(/pastes=/.test(posted), 'the form\'s other fields stay');
+ console.log('composer: the reply and the new thread are the shared composer, keep their fields, send, 390 px');
+ } finally {
+ await page.close().catch(() => {});
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });