openvibers/openvibe.community Commits

Forum and comments: the shared composer v2 (#398)

OP OpenVibers committed 1 days ago · 8cb997aa37 · 11 files, +139 −14

* Forum and comments: the shared composer v2

* openvibe-shared v4.215.0 (the composer v2 release)

* Merge origin/main

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 131756b..bcb9b32 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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 that
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
ddiff --git a/STATUS.json b/STATUS.json
iindex b69a033..94c4bd1 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/package-lock.json b/package-lock.json
iindex 23134ac..24683bd 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.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",
ddiff --git a/package.json b/package.json
iindex 60045b9..799af20 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/public/js/composer.js b/public/js/composer.js
nnew file mode 100644
iindex 0000000..91a444e
---- /dev/null
++++ b/public/js/composer.js
@@@ -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 };
++})();
ddiff --git a/server/render/board.js b/server/render/board.js
iindex 2d5940e..6b9bd69 100644
---- a/server/render/board.js
++++ b/server/render/board.js
@@@ -220,7 +220,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
const indexable = space.visibility === 'public' && !gated;
const first = (page - 1) * perPage;
const replyBlock = viewer.can_reply
-- ? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply">
++ ? `<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, viewe
historyTitle: 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` }] : []),
ddiff --git a/server/render/comments.js b/server/render/comments.js
iindex 3ace4f5..c0e6e38 100644
---- a/server/render/comments.js
++++ b/server/render/comments.js
@@@ -110,6 +110,7 @@ function threadPage({ accessId, page, user, after = null, sort = 'top', replyId
robots: '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'],
});
}
ddiff --git a/server/render/forum.js b/server/render/forum.js
iindex 90744cb..f969a0b 100644
---- a/server/render/forum.js
++++ b/server/render/forum.js
@@@ -285,7 +285,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
const indexable = space.visibility === 'public' && !gated;
const first = (page - 1) * perPage;
const replyBlock = viewer.can_reply
-- ? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply">
++ ? `<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, u
historyTitle: 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,
});
}
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 3a537ba..91795e1 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex ea404ed..36cb1c1 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -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>`,
});
}
ddiff --git a/test/composer.test.js b/test/composer.test.js
nnew file mode 100644
iindex 0000000..915bee9
---- /dev/null
++++ b/test/composer.test.js
@@@ -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); });