openvibers/openvibe.chat Commits

Chat: pictures use the shared upload kit (#455)

OP OpenVibers committed 4 days ago · 22b09042e7 · 16 files, +251 −50

* Chat: pictures use the shared upload kit

Rooms, server channels, the new-server sheet, the Vibe composer and the
support inbox and its widget load openvibe-shared/ov-upload.js and
ov-upload.css. Each hidden file input is replaced by the shared picker in
a sheet, and each send shows the shared progress meter with bytes and time
left. The same server routes still store pictures in OpenVibe.Pics.

* Chat: the Vibe picture test checks the shared picker

The page no longer has the hidden vb-file input; assert the kit picker and
its shared assets instead.

Changes

ddiff --git a/public/web/embed.css b/public/web/embed.css
iindex 73b37b6..189f4c1 100644
---- a/public/web/embed.css
++++ b/public/web/embed.css
@@@ -88,3 +88,11 @@ a { color: var(--accent); }
.em-me .em-pics { justify-content: flex-end; }
.em-pic { display: block; line-height: 0; border-radius: 12px; }
.em-pic img { display: block; width: 240px; max-width: 100%; height: auto; max-height: 200px; border-radius: 12px; border: 1px solid var(--line); object-fit: cover; } /* a definite width: the link is as wide as the picture, so it lines up under its bubble */
++
++/* The shared picker in a sheet: the panel's own colours, the kit's card and meter inside. */
++dialog.em-upload { width: min(420px, 94vw); padding: 14px; border: 1px solid var(--line); border-radius: 16px; background: var(--bg); color: var(--text); }
++dialog.em-upload::backdrop { background: rgba(0, 0, 0, .45); }
++.em-upload-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
++.em-upload-head h2 { margin: 0; font-size: 1rem; }
++.em-upload-head button { border: 0; background: transparent; color: inherit; font: inherit; font-size: 1.2rem; cursor: pointer; padding: 4px 8px; }
++[data-em-progress] { margin-top: 8px; }
ddiff --git a/public/web/embed.js b/public/web/embed.js
iindex 1d45317..dca90e4 100644
---- a/public/web/embed.js
++++ b/public/web/embed.js
@@@ -13,7 +13,11 @@
var form = document.getElementById('em-form');
var input = form.querySelector('textarea');
var sendBtn = form.querySelector('button[type=submit]');
-- var attachBtn = document.getElementById('em-attach'), fileIn = document.getElementById('em-file');
++ var attachBtn = document.getElementById('em-attach');
++ var uploadDialog = document.querySelector('[data-em-upload-dialog]');
++ var uploadRoot = document.querySelector('[data-em-upload]');
++ var progressEl = document.querySelector('[data-em-progress]');
++ var uploadMeter = progressEl && window.OVUpload ? window.OVUpload.progress(progressEl) : null;
var personBtn = document.getElementById('em-person');
var stateEl = document.getElementById('em-state');
var page = new URLSearchParams(location.search).get('page') || '';
@@@ -231,11 +235,27 @@
attachBtn.disabled = true; errorLine('');
var caption = input.value.trim();
var pending = el('li', 'em-msg em-me em-sending'); pending.appendChild(el('div', 'em-bubble', 'Sending a picture…')); thread.appendChild(pending); bottom();
-- fetch(api + '/picture', { method: 'POST', headers: headers({ 'Content-Type': file.type || 'application/octet-stream', 'X-Visitor-Token': token, 'X-Caption': encodeURIComponent(caption) }), body: file })
-- .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }, function () { return { ok: r.ok, d: {} }; }); })
++ if (progressEl) progressEl.hidden = false;
++ if (uploadMeter) uploadMeter.update({ state: 'sending', done: 0, total: file.size });
++ new Promise(function (resolve, reject) {
++ var xhr = new XMLHttpRequest();
++ xhr.open('POST', api + '/picture');
++ xhr.withCredentials = true;
++ var h = headers({ 'Content-Type': file.type || 'application/octet-stream', 'X-Visitor-Token': token, 'X-Caption': encodeURIComponent(caption) });
++ Object.keys(h).forEach(function (k) { xhr.setRequestHeader(k, h[k]); });
++ xhr.upload.onprogress = function (e) { if (e.lengthComputable && uploadMeter) uploadMeter.update({ state: 'sending', done: e.loaded, total: e.total }); };
++ xhr.onload = function () {
++ var d = null; try { d = JSON.parse(xhr.responseText || '{}'); } catch (_) { d = {}; }
++ resolve({ ok: xhr.status >= 200 && xhr.status < 300, d: d });
++ };
++ xhr.onerror = function () { reject(new Error('offline')); };
++ xhr.send(file);
++ })
.then(function (x) {
pending.remove();
-- if (!x.ok) { errorLine((x.d && x.d.error) || 'That picture did not send. Try again.'); return; }
++ if (!x.ok) { if (uploadMeter) uploadMeter.update({ state: 'failed', message: (x.d && x.d.error) || 'That picture did not send.' }); errorLine((x.d && x.d.error) || 'That picture did not send. Try again.'); return; }
++ if (uploadMeter) uploadMeter.update({ state: 'done', done: file.size, total: file.size });
++ if (progressEl) setTimeout(function () { progressEl.hidden = true; }, 800);
if (caption) { input.value = ''; grow(); }
render(x.d.message);
var fresh = !conv || conv.id !== x.d.conversation.id;
@@@ -243,15 +263,17 @@
if (fresh) watch();
if (conv.status === 'bot') showTyping('Vibe');
})
-- .catch(function () { pending.remove(); errorLine('You seem to be offline.'); })
++ .catch(function () { pending.remove(); if (uploadMeter) uploadMeter.update({ state: 'failed', message: 'You seem to be offline.' }); errorLine('You seem to be offline.'); })
.then(function () { attachBtn.disabled = false; });
}
-- if (attachBtn && fileIn) {
++ if (attachBtn && uploadDialog && uploadRoot && window.OVUpload) {
++ window.OVUpload.picker(uploadRoot, { label: 'Choose a picture', accept: uploadRoot.getAttribute('data-accept') || 'image/*', onFile: function (file) { uploadDialog.close(); sendPicture(file); } });
attachBtn.addEventListener('click', function () {
if (!acctName) { errorLine('Sign in to send a picture: it goes to OpenVibe.Pics as you.'); if (!polling) signIn(); return; }
-- fileIn.click();
++ uploadDialog.showModal();
});
-- fileIn.addEventListener('change', function () { var f = fileIn.files && fileIn.files[0]; fileIn.value = ''; sendPicture(f); });
++ var closeUpload = uploadDialog.querySelector('[data-em-upload-close]');
++ if (closeUpload) closeUpload.addEventListener('click', function () { uploadDialog.close(); });
}
// A voice call one of the team started (server/inbox/inbox.js startCall): a card in the thread to join it, peer to
// peer through support-call.js with this visitor's token; "Not now" or hanging up ends it for everyone.
ddiff --git a/public/web/inbox.css b/public/web/inbox.css
iindex 9e73a46..c986d6f 100644
---- a/public/web/inbox.css
++++ b/public/web/inbox.css
@@@ -85,3 +85,11 @@
.ib-screen figcaption { color: #e5e7eb; font-size: .85rem; font-weight: 600; }
.ib-screen video { width: 100%; max-height: 50vh; border-radius: 8px; background: #000; object-fit: contain; }
.ib-screen button { justify-self: end; padding: 4px 10px; border-radius: 999px; border: 1px solid #374151; background: transparent; color: #e5e7eb; font: inherit; font-size: .8rem; cursor: pointer; }
++
++/* The shared picker in a sheet for the inbox's reply box. */
++dialog.ib-upload { width: min(420px, 94vw); padding: 16px; border: 1px solid var(--oc-line); border-radius: 16px; background: var(--oc-panel); color: var(--oc-text); }
++dialog.ib-upload::backdrop { background: rgba(0, 0, 0, .45); }
++.ib-upload-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
++.ib-upload-head h2 { margin: 0; font-size: 1rem; }
++.ib-upload-head button { border: 0; background: transparent; color: inherit; font: inherit; font-size: 1.2rem; cursor: pointer; padding: 4px 8px; }
++[data-ib-progress] { margin-top: 8px; }
ddiff --git a/public/web/inbox.js b/public/web/inbox.js
iindex 4e8562c..ca40cfe 100644
---- a/public/web/inbox.js
++++ b/public/web/inbox.js
@@@ -205,20 +205,42 @@
body.appendChild(b);
}
if (thread) Array.prototype.forEach.call(thread.querySelectorAll('[data-author="visitor"]'), addTranslate);
-- var picIn = document.getElementById('ib-pic');
-- if (picIn && input) {
-- picIn.addEventListener('change', function () {
-- var f = picIn.files && picIn.files[0]; picIn.value = '';
-- if (!f) return;
-- var caption = input.value.trim();
-- fetch('/api/chat/inboxes/' + cfg.inbox + '/conversations/' + cfg.conversation + '/picture', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': f.type, 'X-Caption': encodeURIComponent(caption) }, body: f })
-- .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
-- .then(function (x) {
-- if (!x.ok) { window.alert((x.d && x.d.error) || 'That picture did not send.'); return; }
-- input.value = ''; input.dispatchEvent(new Event('input'));
-- appendToThread(x.d.message); touchList(cfg.conversation, x.d.message.body, 'open');
-- }).catch(function () { window.alert('You seem to be offline.'); });
-- });
++ var picBtn = document.getElementById('ib-pic');
++ var uploadDialog = document.querySelector('[data-ib-upload-dialog]');
++ var uploadRoot = document.querySelector('[data-ib-upload]');
++ var progressEl = document.querySelector('[data-ib-progress]');
++ var uploadMeter = progressEl && window.OVUpload ? window.OVUpload.progress(progressEl) : null;
++ function sendInboxPicture(f) {
++ if (!f) return;
++ var caption = input.value.trim();
++ if (progressEl) progressEl.hidden = false;
++ if (uploadMeter) uploadMeter.update({ state: 'sending', done: 0, total: f.size });
++ new Promise(function (resolve, reject) {
++ var xhr = new XMLHttpRequest();
++ xhr.open('POST', '/api/chat/inboxes/' + cfg.inbox + '/conversations/' + cfg.conversation + '/picture');
++ xhr.withCredentials = true;
++ xhr.setRequestHeader('Content-Type', f.type);
++ xhr.setRequestHeader('X-Caption', encodeURIComponent(caption));
++ xhr.upload.onprogress = function (e) { if (e.lengthComputable && uploadMeter) uploadMeter.update({ state: 'sending', done: e.loaded, total: e.total }); };
++ xhr.onload = function () {
++ var d = null; try { d = JSON.parse(xhr.responseText || 'null'); } catch (_) { d = null; }
++ resolve({ ok: xhr.status >= 200 && xhr.status < 300, d: d });
++ };
++ xhr.onerror = function () { reject(new Error('offline')); };
++ xhr.send(f);
++ }).then(function (x) {
++ if (!x.ok) { if (uploadMeter) uploadMeter.update({ state: 'failed', message: (x.d && x.d.error) || 'That picture did not send.' }); window.alert((x.d && x.d.error) || 'That picture did not send.'); return; }
++ if (uploadMeter) uploadMeter.update({ state: 'done', done: f.size, total: f.size });
++ if (progressEl) setTimeout(function () { progressEl.hidden = true; }, 800);
++ input.value = ''; input.dispatchEvent(new Event('input'));
++ appendToThread(x.d.message); touchList(cfg.conversation, x.d.message.body, 'open');
++ }).catch(function () { if (uploadMeter) uploadMeter.update({ state: 'failed', message: 'You seem to be offline.' }); window.alert('You seem to be offline.'); });
++ }
++ if (picBtn && input && uploadDialog && uploadRoot && window.OVUpload) {
++ window.OVUpload.picker(uploadRoot, { label: 'Choose a picture', accept: uploadRoot.getAttribute('data-accept') || 'image/*', onFile: function (f) { uploadDialog.close(); sendInboxPicture(f); } });
++ picBtn.addEventListener('click', function () { uploadDialog.showModal(); });
++ var closeUpload = uploadDialog.querySelector('[data-ib-upload-close]');
++ if (closeUpload) closeUpload.addEventListener('click', function () { uploadDialog.close(); });
}
var trTo = document.getElementById('ib-tr-to');
if (trTo && input) {
ddiff --git a/public/web/picture-upload.js b/public/web/picture-upload.js
nnew file mode 100644
iindex 0000000..da2db62
---- /dev/null
++++ b/public/web/picture-upload.js
@@@ -0,0 +1,70 @@
++/* Chat's pictures: the shared picker and progress meter, then the form's own endpoint with the file.
++ The page loads openvibe-shared/ov-upload.js and ov-upload.css. A room/channel picture, a new server's
++ picture: each form names its root with [data-picture-upload]; the request is still multipart to the same
++ server route that stores the picture in OpenVibe.Pics. */
++(function () {
++ 'use strict';
++ function load(root) {
++ if (root.getAttribute('data-ready')) return;
++ var form = root.closest('form');
++ if (!form || !window.OVUpload) return;
++ root.setAttribute('data-ready', '1');
++ var status = form.querySelector('[data-picture-status]');
++ var progressEl = form.querySelector('[data-picture-progress]');
++ var meter = progressEl ? window.OVUpload.progress(progressEl, { onRetry: function () { send(); } }) : null;
++ var file = null, busy = false;
++ function say(text, bad) { if (status) { status.textContent = text || ''; status.className = 'oc-muted' + (bad ? ' oc-error' : ''); } }
++ function pick(f) {
++ file = f;
++ if (progressEl) progressEl.hidden = true;
++ say('Ready to send ' + f.name + '.');
++ }
++ window.OVUpload.picker(root, { label: 'Choose a picture', accept: root.getAttribute('data-accept') || 'image/*', onFile: pick, onRemove: function () { file = null; say(''); } });
++ function send() {
++ if (!file || busy) return;
++ busy = true;
++ var button = form.querySelector('button[type=submit]');
++ if (button) button.disabled = true;
++ var fd = new FormData(form);
++ fd.append('picture', file, file.name);
++ if (progressEl) progressEl.hidden = false;
++ if (meter) meter.update({ state: 'sending', done: 0, total: file.size });
++ say('Sending ' + file.name + '…');
++ var xhr = new XMLHttpRequest();
++ xhr.open('POST', form.getAttribute('action') || location.pathname);
++ xhr.withCredentials = true;
++ xhr.upload.onprogress = function (e) { if (e.lengthComputable && meter) meter.update({ state: 'sending', done: e.loaded, total: e.total }); };
++ xhr.onload = function () {
++ busy = false;
++ if (button) button.disabled = false;
++ if (xhr.status >= 200 && xhr.status < 400) {
++ if (meter) meter.update({ state: 'done', done: file.size, total: file.size });
++ say('Sent.');
++ location.assign(xhr.responseURL || (form.getAttribute('action') || location.href));
++ return;
++ }
++ var message = 'The picture did not send (' + xhr.status + ').';
++ if (meter) meter.update({ state: 'failed', message: message });
++ say(message, true);
++ };
++ xhr.onerror = function () {
++ busy = false;
++ if (button) button.disabled = false;
++ var message = 'The connection dropped. Try again.';
++ if (meter) meter.update({ state: 'failed', message: message });
++ say(message, true);
++ };
++ xhr.send(fd);
++ }
++ form.addEventListener('submit', function (e) {
++ if (!file) { e.preventDefault(); say('Choose a picture first.', true); return; }
++ e.preventDefault();
++ send();
++ });
++ }
++ function wire() {
++ Array.prototype.forEach.call(document.querySelectorAll('[data-picture-upload]'), load);
++ }
++ wire();
++ document.addEventListener('ov:boost:load', wire);
++})();
ddiff --git a/public/web/vibe.css b/public/web/vibe.css
iindex c5b08f4..12321d0 100644
---- a/public/web/vibe.css
++++ b/public/web/vibe.css
@@@ -272,3 +272,11 @@
.vbk .vbc-phone .vbc-body { max-height: min(45vh, 420px); overflow: auto; overscroll-behavior: contain; padding: 0 10px 10px; }
@media (min-width: 1180px) { .vbk .vbc-phone { display: none !important; } }
@media (max-width: 1179px) { .vbk .vbc-wide { display: none !important; } }
++
++/* The shared picker in a sheet over the Vibe composer. */
++dialog.vb-upload { width: min(440px, 94vw); padding: 16px; border: 1px solid var(--oc-line); border-radius: 16px; background: var(--oc-panel); color: var(--oc-text); }
++dialog.vb-upload::backdrop { background: rgba(0, 0, 0, .45); }
++.vb-upload-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
++.vb-upload-head h2 { margin: 0; font-size: 1rem; }
++.vb-upload-head button { border: 0; background: transparent; color: inherit; font: inherit; font-size: 1.2rem; cursor: pointer; padding: 4px 8px; }
++[data-vb-progress] { max-width: var(--vb-col); margin: 0 auto 8px; }
ddiff --git a/public/web/vibe.js b/public/web/vibe.js
iindex c5b656e..35d8209 100644
---- a/public/web/vibe.js
++++ b/public/web/vibe.js
@@@ -22,8 +22,10 @@
var room = cfg.room || null;
var latest = Number(cfg.latest) || 0;
var ws = null, delay = 1000, sending = false, waitTimer = null;
-- var fileInput = document.getElementById('vb-file');
var previewEl = document.getElementById('vb-preview');
++ var progressEl = document.querySelector('[data-vb-progress]');
++ var uploadDialog = document.querySelector('[data-vb-upload-dialog]');
++ var uploadRoot = document.querySelector('[data-vb-upload]');
var picture = null; // { file, url }: a picture waiting to go with the next message
var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var chosenModels = Array.isArray(cfg.models) ? cfg.models.slice(0, 3) : [];
@@@ -134,7 +136,6 @@
if (picture && picture.url) URL.revokeObjectURL(picture.url);
picture = null;
if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; }
-- if (fileInput) fileInput.value = '';
if (input) input.required = true;
changed();
}
@@@ -161,8 +162,8 @@
}
function choosePicture(file) {
if (!file) return;
-- if (!PIC_TYPES.test(file.type) && (/^text\//.test(file.type) || file.type === 'application/json' || TEXT_EXT.test(file.name || ''))) { readText(file); if (fileInput) fileInput.value = ''; return; }
-- if (!PIC_TYPES.test(file.type) || !fileInput || !/image\//.test(fileInput.accept)) { notice('Send a JPEG, PNG, GIF, WebP or AVIF picture, or a text file.'); return; }
++ if (!PIC_TYPES.test(file.type) && (/^text\//.test(file.type) || file.type === 'application/json' || TEXT_EXT.test(file.name || ''))) { readText(file); return; }
++ if (!PIC_TYPES.test(file.type) || !uploadRoot || !/image\//.test(uploadRoot.getAttribute('data-accept') || '')) { notice('Send a JPEG, PNG, GIF, WebP or AVIF picture, or a text file.'); return; }
if (file.size > 20 * 1024 * 1024) { notice('A picture can be up to 20 MB.'); return; }
clearPicture();
picture = { file: file, url: URL.createObjectURL(file) };
@@@ -177,14 +178,37 @@
input.focus();
}
var attachBtn = app.querySelector('[data-vb-attach]');
-- if (attachBtn && fileInput) {
-- attachBtn.addEventListener('click', function () { fileInput.click(); });
-- fileInput.addEventListener('change', function () { choosePicture(fileInput.files && fileInput.files[0]); });
++ if (attachBtn && uploadDialog && uploadRoot && window.OVUpload) {
++ var meter = progressEl ? window.OVUpload.progress(progressEl) : null;
++ window.OVUpload.picker(uploadRoot, {
++ label: 'Choose a picture or a text file', accept: uploadRoot.getAttribute('data-accept') || '', multiple: false,
++ onFile: function (file) { uploadDialog.close(); choosePicture(file); }
++ });
++ attachBtn.addEventListener('click', function () { uploadDialog.showModal(); });
++ var closeUpload = uploadDialog.querySelector('[data-vb-upload-close]');
++ if (closeUpload) closeUpload.addEventListener('click', function () { uploadDialog.close(); });
// A picture pasted into the composer goes the same way.
input.addEventListener('paste', function (e) {
var items = (e.clipboardData && e.clipboardData.files) || [];
if (items.length && PIC_TYPES.test(items[0].type)) { e.preventDefault(); choosePicture(items[0]); }
});
++ } else if (attachBtn) {
++ attachBtn.hidden = true;
++ }
++ function pictureRequest(url, file, headers) {
++ return new Promise(function (resolve, reject) {
++ var xhr = new XMLHttpRequest();
++ xhr.open('POST', url);
++ xhr.withCredentials = true;
++ Object.keys(headers || {}).forEach(function (k) { xhr.setRequestHeader(k, headers[k]); });
++ xhr.upload.onprogress = function (e) { if (e.lengthComputable && meter) meter.update({ state: 'sending', done: e.loaded, total: e.total }); };
++ xhr.onload = function () {
++ var body = null; try { body = JSON.parse(xhr.responseText || 'null'); } catch (_) { body = null; }
++ resolve({ ok: xhr.status >= 200 && xhr.status < 300, d: body });
++ };
++ xhr.onerror = function () { reject(new Error('The connection dropped.')); };
++ xhr.send(file);
++ });
}
// ── The thread (the kit's markup: a turn is the person's message and the answer under it) ──
@@@ -653,7 +677,7 @@
var mine = meTurn(text, null, sendingPicture && sendingPicture.url);
threadEl().appendChild(mine);
showWait(); toBottom();
-- input.value = ''; picture = null; if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; } if (fileInput) fileInput.value = ''; input.required = true; changed();
++ input.value = ''; picture = null; if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; } input.required = true; changed();
var url, init;
var modeEl = form.querySelector('input[name="mode"]:checked'), mode = modeEl ? modeEl.value : 'chat';
if (mode !== 'chat' && !sendingPicture) {
@@@ -668,9 +692,16 @@
url = room ? '/api/chat/rooms/' + encodeURIComponent(room) + '/messages' : '/api/chat/vibe';
init = { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text, timezone: timezone() }) };
}
-- function putBack(msg) { mine.remove(); clearWait(); input.value = text; if (sendingPicture) choosePicture(sendingPicture.file); changed(); notice(msg); }
-- fetch(url, init)
-- .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }, function () { return { ok: r.ok, d: null }; }); })
++ function putBack(msg) { mine.remove(); clearWait(); input.value = text; if (sendingPicture) { choosePicture(sendingPicture.file); if (meter) meter.update({ state: 'failed', message: msg }); } changed(); notice(msg); }
++ var request;
++ if (sendingPicture) {
++ if (progressEl) progressEl.hidden = false;
++ if (meter) meter.update({ state: 'sending', done: 0, total: sendingPicture.file.size });
++ request = pictureRequest(url, sendingPicture.file, init.headers);
++ } else {
++ request = fetch(url, init).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }, function () { return { ok: r.ok, d: null }; }); });
++ }
++ request
.then(function (x) {
if (!x.ok) { putBack((x.d && x.d.error) || 'That did not send. Try again.'); return; }
if (x.d.message && x.d.message.id) { mine.setAttribute('data-me', String(x.d.message.id)); latest = Math.max(latest, Number(x.d.message.id)); }
@@@ -688,7 +719,13 @@
}
})
.catch(function () { putBack('You seem to be offline. Your message is back in the box.'); })
-- .then(function () { sending = false; });
++ .then(function () {
++ sending = false;
++ if (sendingPicture && meter) {
++ meter.update({ state: 'done', done: sendingPicture.file.size, total: sendingPicture.file.size });
++ if (progressEl) setTimeout(function () { progressEl.hidden = true; }, 900);
++ }
++ });
});
// Try again: Vibe's last answer goes and a new one is written (POST /api/chat/vibe/:slug/retry).
ddiff --git a/server/web/inbox-pages.js b/server/web/inbox-pages.js
iindex 406047d..dfde06d 100644
---- a/server/web/inbox-pages.js
++++ b/server/web/inbox-pages.js
@@@ -52,6 +52,8 @@ function embedHtml(inbox) {
<title>Chat with ${esc(inbox.name)}</title>
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="${asset('embed.css')}">
++<link rel="stylesheet" href="/shared/ov-upload.css">
++<script src="/shared/ov-upload.js" defer></script>
<script src="${asset('vibe-md.js')}" defer></script>
<script src="${asset('support-call.js')}" defer></script>
<script src="${asset('embed.js')}" defer></script>
@@@ -64,9 +66,10 @@ function embedHtml(inbox) {
<ol class="em-thread" id="em-thread" aria-live="polite"><li class="em-msg em-them"><small>${esc(inbox.name)}</small><div class="em-bubble">${esc(inbox.greeting)}</div></li></ol>
<div class="em-handoff"><button type="button" id="em-person" hidden>Talk to a person</button></div>
<form class="em-composer" id="em-form"><label class="em-sr" for="em-input" hidden>Your message</label>
--${pictures.enabled() ? '<button type="button" class="em-attach" id="em-attach" aria-label="Send a picture" title="Send a picture"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="3"/><circle cx="9" cy="10" r="2"/><path d="m21 16-5-5-9 9"/></svg></button><input type="file" id="em-file" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" hidden>' : ''}
++${pictures.enabled() ? '<button type="button" class="em-attach" id="em-attach" aria-label="Send a picture" title="Send a picture"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="3"/><circle cx="9" cy="10" r="2"/><path d="m21 16-5-5-9 9"/></svg></button>' : ''}
<textarea id="em-input" rows="1" maxlength="4000" placeholder="Write a message" aria-label="Your message"></textarea>
<button type="submit" aria-label="Send">&#8593;</button></form>
++${pictures.enabled() ? `<dialog class="em-upload" data-em-upload-dialog aria-labelledby="em-upload-h"><div class="em-upload-head"><h2 id="em-upload-h">Send a picture</h2><button type="button" data-em-upload-close aria-label="Close">✕</button></div><div data-em-upload data-accept="image/jpeg,image/png,image/gif,image/webp,image/avif"></div><div data-em-progress hidden></div></dialog>` : ''}
<p class="em-foot">Chat by <a href="https://openvibe.chat" target="_blank" rel="noopener">OpenVibe</a>${Number(inbox.ai_enabled) ? ' · Vibe can get things wrong' : ''}<span id="em-account"></span></p>
</div>
<noscript><p style="padding:16px">This chat needs JavaScript.</p></noscript>
@@@ -172,7 +175,7 @@ ${list}
mainLabel: current ? `Conversation with ${current.visitor_name || 'a visitor'}` : inbox.name,
})}</div>`;
await page(req, res, 200, {
-- actor, shell: true, kit: true, appStyle: 'inbox.css', scripts: ['support-call.js', 'inbox.js'], active: 'inbox', path: `/inbox/${inbox.id}`, title: `${current ? `${current.visitor_name || 'Visitor'} · ` : ''}${inbox.name} · Inbox`, robots: 'noindex, nofollow', footer: 'none', body,
++ actor, shell: true, kit: true, upload: pictures.enabled(), appStyle: 'inbox.css', scripts: ['support-call.js', 'inbox.js'], active: 'inbox', path: `/inbox/${inbox.id}`, title: `${current ? `${current.visitor_name || 'Visitor'} · ` : ''}${inbox.name} · Inbox`, robots: 'noindex, nofollow', footer: 'none', body,
page: { view: 'inbox', inbox: inbox.id, conversation: current ? current.id : null, call: !!(current && inboxes.convShape(current).call), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me, filter, saved: saved.map((r) => ({ shortcut: r.shortcut, body: r.body })) },
});
});
@@@ -248,7 +251,7 @@ ${list}
// The reply box: the kit's composer, with a picture, a translation and the call above it while one is open.
function replyBox({ inbox, current, actor, saved }) {
-- const pic = pictures.enabled() ? `<label class="ovt-btn ib-pic-btn" title="Send a picture"><input type="file" id="ib-pic" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" hidden>${thread.icon('attach')}<span class="ovt-sr">Send a picture</span></label>` : '';
++ const pic = pictures.enabled() ? `<button type="button" class="ovt-btn ib-pic-btn" id="ib-pic" title="Send a picture" aria-label="Send a picture">${thread.icon('attach')}<span class="ovt-sr">Send a picture</span></button><dialog class="ib-upload" data-ib-upload-dialog aria-labelledby="ib-upload-h"><div class="ib-upload-head"><h2 id="ib-upload-h">Send a picture</h2><button type="button" data-ib-upload-close aria-label="Close">✕</button></div><div data-ib-upload data-accept="image/jpeg,image/png,image/gif,image/webp,image/avif"></div><div data-ib-progress hidden></div></dialog>` : '';
const tr = `<label class="ib-tr-pick" hidden><span>Translate my reply into</span> <select id="ib-tr-to"><option value="">a language…</option>${TR_LANGS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select></label>`;
const call = `<figure class="ib-screen" id="ib-screen" hidden><figcaption id="ib-screen-who"></figcaption><video id="ib-screen-video" autoplay playsinline muted></video><button type="button" id="ib-screen-full">Full screen</button></figure><div class="ib-callbar" id="ib-callbar" role="group" aria-label="Voice call" hidden><span class="ib-call-text" id="ib-call-text"></span><button type="button" id="ib-call-join" hidden>Join the call</button><button type="button" id="ib-call-mute" aria-pressed="false" hidden>Mute</button><button type="button" class="ib-call-end" id="ib-call-end">End call</button></div>`;
return call + thread.composer({
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 0b874cc..bd1ddf5 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -162,6 +162,7 @@ ${INSTALL_LINKS}
<link rel="stylesheet" href="${asset('chat.css')}">
${o.shell ? `<link rel="stylesheet" href="${asset('chat-shell.css')}">` : ''}
<link rel="stylesheet" href="${ovServe.url('items.css')}">
++${o.upload ? `<link rel="stylesheet" href="${ovServe.url('ov-upload.css')}">` : ''}
${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}
${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
${o.kit ? `<link rel="stylesheet" href="${ovServe.url('kit.css')}">\n<link rel="stylesheet" href="${ovServe.url('thread.css')}">` : ''}
@@@ -169,6 +170,7 @@ ${o.app || o.appStyle ? `<link rel="stylesheet" href="${asset(o.appStyle || 'vib
${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}
${o.partyHome ? `<link rel="stylesheet" href="${asset('party-home.css')}">` : ''}
${o.party ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<link rel="stylesheet" href="${asset('party.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n<script src="${ovServe.url('ov-sync.js')}" defer></script>\n<script src="${asset('party-providers.js')}" defer></script>\n<script src="${asset('party-provider-sync.js')}" defer></script>\n<script src="${asset('party.js')}" defer></script>` : ''}
++${o.upload ? `<script src="${ovServe.url('ov-upload.js')}" defer></script>` : ''}
${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join('\n')}
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 2df0ed4..38504be 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -788,9 +788,13 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
const pictureForm = require('multer')({ storage: require('multer').memoryStorage(), limits: { fileSize: (require('../config').pics.maxBytes || 20 * 1024 * 1024), files: 1, fields: 4 } }).single('picture');
/** The "Add a picture" form under a room's or channel's composer (rooms where the person may post). */
const pictureComposer = (room, back) => (pictures.enabled() ? `<details class="oc-pic-compose"><summary class="oc-icon-btn" aria-label="Add a picture" title="Add a picture">${ICONS.image}</summary>
--<form class="oc-form oc-pic-form" method="post" action="/r/${esc(room.slug)}/picture" enctype="multipart/form-data" data-room="${esc(room.slug)}">
++<form class="oc-form oc-pic-form" method="post" action="/r/${esc(room.slug)}/picture" data-room="${esc(room.slug)}" data-picture-upload-form>
<input type="hidden" name="back" value="${esc(back)}">
--<label>Picture <input type="file" name="picture" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" required></label>
++<div class="oc-field oc-field--wide"><span>Picture</span>
++<div data-picture-upload data-accept="image/jpeg,image/png,image/gif,image/webp,image/avif"></div>
++<p class="oc-muted" data-picture-status role="status" aria-live="polite"></p>
++<div data-picture-progress hidden></div>
++</div>
<label>Caption <input name="caption" maxlength="500" placeholder="Say something about it (optional)"></label>
<p class="oc-muted">It goes to <a href="${esc(pictures.publicUrl())}">OpenVibe.Pics</a> as yours (location and camera details removed), where you can delete it, and the room gets its link.</p>
<p><button type="submit">Send picture</button></p></form></details>` : '');
@@@ -1000,7 +1004,7 @@ ${people}
const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;
await page(req, res, 200, {
actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady, feedView: !!feed,
-- footer: 'none', shell: true, scripts: ais.length ? ['vibe-md.js'] : [],
++ footer: 'none', shell: true, upload: pictures.enabled(), scripts: [...(ais.length ? ['vibe-md.js'] : []), ...(pictures.enabled() ? ['picture-upload.js'] : [])],
description,
page: { view: 'room', room: room.slug, kind: room.kind, pics: pictures.enabled() ? pictures.publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,
...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },
ddiff --git a/server/web/server-pages.js b/server/web/server-pages.js
iindex f76e496..35c42b4 100644
---- a/server/web/server-pages.js
++++ b/server/web/server-pages.js
@@@ -100,7 +100,7 @@ ${join}</article>`;
const featuredRow = featuredServers.length ? `<section class="oc-featured" aria-labelledby="oc-featured-h"><h2 class="oc-list-head" id="oc-featured-h">Featured</h2><div class="oc-cards oc-featured-cards">${featuredServers.map(card).join('')}</div></section>` : '';
// The sheet: a template first, then a name and a picture, then public or private.
const sheet = me ? `<details class="oc-sheet" id="oc-new-server"><summary class="oc-button">Start a server</summary>
--<form class="oc-form oc-sheet-form" method="post" action="/servers/new" enctype="multipart/form-data">
++<form class="oc-form oc-sheet-form" method="post" action="/servers/new" data-picture-upload-form>
<h2>Start a server</h2>
<fieldset class="oc-sheet-step"><legend>1. What is it for?</legend>
<div class="oc-templates">${templates.TEMPLATES.map((t, i) => `<label class="oc-template"><input type="radio" name="template" value="${esc(t.id)}"${i === 0 ? ' checked' : ''}><span class="oc-template-body"><strong>${esc(t.name)}</strong><span class="oc-muted">${esc(t.description)}</span><span class="oc-template-channels">${t.channels.map((c) => esc(c.kind === 'voice' ? '🔊 ' : '# ') + esc(c.name)).join(' · ')}</span></span></label>`).join('')}</div>
@@@ -110,7 +110,12 @@ ${join}</article>`;
<label class="oc-field"><span>Name</span><input name="name" required minlength="2" maxlength="40" placeholder="Saturday Raiders"></label>
<label class="oc-field"><span>Category</span><select name="category"><option value="">No category</option>${templates.CATEGORIES.map((c) => `<option value="${esc(c)}">${esc(c)}</option>`).join('')}</select></label>
<label class="oc-field oc-field--wide"><span>Description</span><input name="description" maxlength="300" placeholder="What it is for (optional)"></label>
--<label class="oc-field oc-field--wide"><span>Picture</span><input type="file" name="picture" accept="image/png,image/jpeg,image/webp"><small class="oc-muted">PNG, JPEG or WebP. Without one, its initials on its colour.</small></label>
++<div class="oc-field oc-field--wide"><span>Picture</span>
++<div data-picture-upload data-accept="image/png,image/jpeg,image/webp"></div>
++<small class="oc-muted">PNG, JPEG or WebP. Without one, its initials on its colour.</small>
++<p class="oc-muted" data-picture-status role="status" aria-live="polite"></p>
++<div data-picture-progress hidden></div>
++</div>
</div>
</fieldset>
<fieldset class="oc-sheet-step"><legend>3. Who may find it?</legend>
@@@ -124,7 +129,7 @@ ${join}</article>`;
${mine.length ? `<ul class="oc-side-list">${mine.map((sv) => `<li class="oc-row"><a href="/s/${esc(sv.slug)}">${ui.serverMark(sv)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(sv.name)}</strong></span><span class="oc-row-sub">${Number(sv.members) || 0} member${Number(sv.members) === 1 ? '' : 's'}</span></span></a></li>`).join('')}</ul>` : `<p class="oc-side-empty oc-muted">You are in no servers yet.</p>`}
</section>`;
await page(req, res, 200, {
-- actor, shell: true, title: 'Explore servers', path: '/servers', active: 'servers', robots: 'index, follow', footer: 'none',
++ actor, shell: true, title: 'Explore servers', path: '/servers', active: 'servers', robots: 'index, follow', footer: 'none', upload: require('../rooms/pictures').enabled(), scripts: require('../rooms/pictures').enabled() ? ['picture-upload.js'] : [],
description: 'Servers on OpenVibe.Chat: a place for a group, with text and voice channels, roles and invites.',
body: shell.shell({
rail: shell.rail({ explore: true, dmUnread: me ? await require('../chat/dm').getTotalUnread(me.id) : 0, servers: mine.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
@@@ -246,7 +251,7 @@ ${sheet}`,
// and the sign-in link post to (POST /s/:slug/:channel).
const canonicalPath = req.params.channel ? back : `/s/${server.slug}`;
await page(req, res, 200, {
-- actor, shell: true, footer: 'none', title: `${isVoice ? '' : '#'}${room.name} · ${server.name}`, path: canonicalPath, active: 'servers',
++ actor, shell: true, footer: 'none', title: `${isVoice ? '' : '#'}${room.name} · ${server.name}`, path: canonicalPath, active: 'servers', upload: require('../rooms/pictures').enabled(), scripts: require('../rooms/pictures').enabled() ? ['picture-upload.js'] : [],
robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
description: server.description || `${server.name}, a server on OpenVibe.Chat with ${server.member_count} member${Number(server.member_count) === 1 ? '' : 's'}.`,
page: {
ddiff --git a/server/web/vibe-pages.js b/server/web/vibe-pages.js
iindex f660ea2..4975ca8 100644
---- a/server/web/vibe-pages.js
++++ b/server/web/vibe-pages.js
@@@ -206,7 +206,8 @@ function composer({ slug, signedIn, enabled, prefill = '', engine = null, models
const pics = pictures.enabled();
const label = pics ? 'Add a picture or a text file' : 'Add a text file';
const attach = signedIn && enabled
-- ? `<input type="file" id="vb-file" accept="${pics ? 'image/jpeg,image/png,image/gif,image/webp,image/avif,' : ''}${TEXT_FILES}" hidden><button class="ovt-btn vb-attach" type="button" data-vb-attach aria-label="${label}" title="${label}">${ICON.attach}</button>`
++ ? `<button class="ovt-btn vb-attach" type="button" data-vb-attach aria-label="${label}" title="${label}">${ICON.attach}</button>
++<dialog class="vb-upload" data-vb-upload-dialog aria-labelledby="vb-upload-h"><div class="vb-upload-head"><h2 id="vb-upload-h">${pics ? 'Add a picture or a text file' : 'Add a text file'}</h2><button type="button" class="vb-upload-close" data-vb-upload-close aria-label="Close">✕</button></div><div data-vb-upload data-accept="${pics ? 'image/jpeg,image/png,image/gif,image/webp,image/avif,' : ''}${TEXT_FILES}"></div></dialog>`
: '';
const modes = `<fieldset class="vb-modes" aria-label="What Vibe does with it">${MODES.map(([value, label, hint, placeholder], i) => `<label title="${esc(hint)}"><input type="radio" name="mode" value="${value}" data-placeholder="${esc(placeholder)}"${i ? '' : ' checked'}><span>${label}</span></label>`).join('')}</fieldset>`;
const fine = !enabled ? 'Vibe is resting right now. Try again later.' : engine && engine.own ? `Vibe thinks with ${esc(engine.you)} (<a href="/settings#oc-ai-key">change</a>). It can get things wrong: check what matters.` : 'Vibe can get things wrong. Check what matters.';
@@@ -214,7 +215,7 @@ function composer({ slug, signedIn, enabled, prefill = '', engine = null, models
action, method: signedIn ? 'post' : 'get', name: 'message', id: 'vb-composer', attrs: signedIn ? {} : { 'data-signin': '1' },
label: 'Message Vibe', placeholder: signedIn ? 'Message Vibe' : 'Message Vibe (you will sign in first)', value: prefill, maxlength: 12000,
hidden: signedIn ? {} : { next: slug ? `/vibe/${slug}` : (prefill ? `/vibe?ask=${encodeURIComponent(prefill)}` : '/vibe') },
-- top: `<div class="vb-model-picker"><button type="button" class="vb-model-chip" id="vb-model-chip" aria-haspopup="dialog" aria-expanded="false">${models.length > 1 ? `${models.length} models` : models[0] ? esc(models[0]) : 'Auto'} ▾</button><div class="vb-model-sheet" id="vb-model-sheet" role="dialog" aria-label="Choose models" hidden><div class="vb-model-sheet-head"><strong>Choose a model</strong><button type="button" id="vb-model-close" aria-label="Close model list">✕</button></div><label class="vb-model-compare"><input type="checkbox" id="vb-model-compare"> Compare models</label><div id="vb-model-list"><p>Loading models…</p></div><details><summary>Settings</summary><label>System prompt<textarea id="vb-system-prompt" maxlength="4000"></textarea></label><label>Temperature<input id="vb-temperature" type="number" min="0" max="2" step="0.1" value="1"></label></details><p class="vb-model-floor">The floor holds on every model: <a href="https://openvibe.network/rules">the rules</a></p></div></div><div class="vb-preview" id="vb-preview" hidden></div>`, tools: attach + modes,
++ top: `<div class="vb-model-picker"><button type="button" class="vb-model-chip" id="vb-model-chip" aria-haspopup="dialog" aria-expanded="false">${models.length > 1 ? `${models.length} models` : models[0] ? esc(models[0]) : 'Auto'} ▾</button><div class="vb-model-sheet" id="vb-model-sheet" role="dialog" aria-label="Choose models" hidden><div class="vb-model-sheet-head"><strong>Choose a model</strong><button type="button" id="vb-model-close" aria-label="Close model list">✕</button></div><label class="vb-model-compare"><input type="checkbox" id="vb-model-compare"> Compare models</label><div id="vb-model-list"><p>Loading models…</p></div><details><summary>Settings</summary><label>System prompt<textarea id="vb-system-prompt" maxlength="4000"></textarea></label><label>Temperature<input id="vb-temperature" type="number" min="0" max="2" step="0.1" value="1"></label></details><p class="vb-model-floor">The floor holds on every model: <a href="https://openvibe.network/rules">the rules</a></p></div></div><div class="vb-preview" id="vb-preview" hidden></div><div class="vb-progress" data-vb-progress hidden></div>`, tools: attach + modes,
// Stop is drawn hidden: vibe.js shows it while Vibe writes, and it keeps what was written so far.
stopAction: signedIn ? '' : null,
foot: `<p class="vb-fine">${fine}</p>`,
@@@ -296,7 +297,7 @@ ${MARK}
center: !room, label: room ? 'Your chat with Vibe' : 'A new chat with Vibe',
})}</div>`;
await page(req, res, status, {
-- actor, app: true, kit: true, canvas: !!room, active: 'vibe', path: room ? `/vibe/${room.slug}` : '/vibe',
++ actor, app: true, kit: true, upload: true, canvas: !!room, active: 'vibe', path: room ? `/vibe/${room.slug}` : '/vibe',
title: room ? `${room.name} · Vibe` : 'Vibe, OpenVibe\'s assistant',
fullTitle: room ? undefined : 'Vibe: chat with OpenVibe\'s assistant · OpenVibe.Chat',
description: 'Chat with Vibe, OpenVibe\'s free assistant: it searches news, deals, jobs, rentals and videos across OpenVibe, reads pages you link and helps you think.',
ddiff --git a/test/inbox.test.js b/test/inbox.test.js
iindex 333691b..bd566f4 100644
---- a/test/inbox.test.js
++++ b/test/inbox.test.js
@@@ -392,6 +392,7 @@ t('pictures: one of the people sends a picture (through OpenVibe.Pics as them);
assert.strictEqual((await send(outsider)).status, 404);
const app = await fetch(`${h.base}/inbox/${inbox.id}?c=${cid}`, { headers: { cookie: `ov_token=${bob.token}` } }).then((x) => x.text());
assert.ok(app.includes('class="oc-pic"') && app.includes('/thumb.webp') && app.includes('id="ib-pic"'), 'the people see it inline, and the reply box has a picture button');
++ assert.ok(app.includes('/shared/ov-upload.js') && app.includes('/shared/ov-upload.css') && app.includes('data-ib-upload'), 'the picture button opens the shared picker');
const panel = await fetch(`${h.base}/embed/inbox/${inbox.id}`).then((x) => x.text());
assert.ok(panel.includes('data-pics="https://openvibe.pics"'), 'the visitor\'s panel knows where pictures come from');
});
@@@ -462,7 +463,9 @@ t('a signed-in visitor sends a picture from the panel (through OpenVibe.Pics as
assert.ok((await page(`/inbox/${inbox.id}?c=${cid}`, bob)).includes('class="oc-pic"'), 'the people see it inline');
assert.ok(!(await page(`/inbox/${inbox.id}?c=${pasted.body.conversation.id}`, bob)).includes('class="oc-pic"'), 'and the pasted link as text');
assert.ok((await page(`/support/${cid}`, eve)).includes('class="oc-pic"'), 'the visitor sees it in their Messages too');
-- assert.ok((await fetch(`${h.base}/embed/inbox/${inbox.id}`).then((x) => x.text())).includes('id="em-attach"'), 'the panel has the picture button');
++ const panelHtml = await fetch(`${h.base}/embed/inbox/${inbox.id}`).then((x) => x.text());
++ assert.ok(panelHtml.includes('id="em-attach"'), 'the panel has the picture button');
++ assert.ok(panelHtml.includes('/shared/ov-upload.js') && panelHtml.includes('data-em-upload'), 'the panel uses the shared picker');
});
t('records: finished conversations go after the owner\'s retention, waiting and open ones never; the owner exports CSV and JSON', async () => {
ddiff --git a/test/room-pictures.test.js b/test/room-pictures.test.js
iindex 920e7ec..d65c6e1 100644
---- a/test/room-pictures.test.js
++++ b/test/room-pictures.test.js
@@@ -85,6 +85,10 @@ t('the page: Pics links show as thumbnails linked to the picture\'s page; other
assert.match(r.text, /<a class="oc-pic" href="https:\/\/openvibe\.pics\/i\/[2-9a-km-zA-HJ-NP-Z]{8}"[^>]*><img src="https:\/\/openvibe\.pics\/i\/[2-9a-km-zA-HJ-NP-Z]{8}\/thumb\.webp"/);
assert.strictEqual((r.text.match(/class="oc-pic"/g) || []).length, 1, 'only the Pics picture');
assert.match(r.text, /Add a picture/);
++ assert.match(r.text, /data-picture-upload/);
++ assert.match(r.text, /\/shared\/ov-upload\.js/);
++ assert.match(r.text, /\/shared\/ov-upload\.css/);
++ assert.doesNotMatch(r.text, /input type="file" name="picture"/, 'the hand-rolled file input is gone');
assert.match(r.text, /"pics":"https:\/\/openvibe\.pics"/, 'chat.js gets the Pics address for live messages');
assert.doesNotMatch((await page('/r/photo-walk', cat)).text, /Add a picture/, 'only people who may post get the form');
});
ddiff --git a/test/vibe-long-text.test.js b/test/vibe-long-text.test.js
iindex 17e3f89..97d08b4 100644
---- a/test/vibe-long-text.test.js
++++ b/test/vibe-long-text.test.js
@@@ -49,7 +49,10 @@ t('everywhere else the limits stay: 2000 characters, spam refused', async () =>
t('the composer takes 12000 characters and the attach button takes text files', async () => {
const page = await fetch(`${h.base}/vibe`, { headers: { cookie: `ov_token=${ann.token}` } }).then((x) => x.text());
assert.match(page, /<textarea id="ovt-text" name="message" rows="1"[^>]*maxlength="12000"/);
-- assert.match(page, /<input type="file" id="vb-file" accept="[^"]*text\/plain[^"]*\.csv[^"]*\.py[^"]*" hidden>/);
++ assert.match(page, /data-vb-upload data-accept="[^"]*text\/plain[^"]*\.csv[^"]*\.py[^"]*"/);
++ assert.match(page, /\/shared\/ov-upload\.js/);
++ assert.match(page, /\/shared\/ov-upload\.css/);
++ assert.doesNotMatch(page, /id="vb-file"/, "the hand-rolled file input is gone");
assert.match(page, /aria-label="Add (a picture or )?a text file"/);
});
ddiff --git a/test/vibe-pictures.test.js b/test/vibe-pictures.test.js
iindex 8b712c8..60b3526 100644
---- a/test/vibe-pictures.test.js
++++ b/test/vibe-pictures.test.js
@@@ -102,7 +102,8 @@ t('a picture Pics refuses leaves no chat behind; another type is refused before
t('the composer offers the picture button when pictures are on', async () => {
const page = await (await fetch(`${h.base}/vibe`, { headers: { cookie: `ov_token=${ann.token}` } })).text();
-- assert.ok(page.includes('id="vb-file"') && page.includes('data-vb-attach'));
++ assert.ok(page.includes('data-vb-attach') && page.includes('data-vb-upload') && page.includes('/shared/ov-upload.js') && page.includes('/shared/ov-upload.css'));
++ assert.ok(!page.includes('id="vb-file"'), 'the hand-rolled file input is gone');
});
t.run(async () => { if (aiClient) aiClient._setClient(null); if (pics) await pics.close(); if (h && h.close) await h.close(); });