openvibers/openvibe.chat Commits
Chat: pictures use the shared upload kit (#455)
Changes
@@@ -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; }@@@ -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.@@@ -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; }@@@ -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) {nnew file mode 100644@@@ -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);++})();@@@ -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; }@@@ -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 messagevar 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).@@@ -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">↑</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({@@@ -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>@@@ -788,9 +788,13 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/aconst 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 } } : {}) },@@@ -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: {@@@ -206,7 +206,8 @@ function composer({ slug, signedIn, enabled, prefill = '', engine = null, modelsconst 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, modelsaction, 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.',@@@ -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 asassert.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 () => {@@@ -85,6 +85,10 @@ t('the page: Pics links show as thumbnails linked to the picture\'s page; otherassert.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');});@@@ -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"/);});@@@ -102,7 +102,8 @@ t('a picture Pics refuses leaves no chat behind; another type is refused beforet('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(); });
@@ -88,3 +88,11 @@ a { color: var(--accent); }
@@ -88,3 +88,11 @@ a { color: var(--accent); }
.em-me .em-pics { justify-content: flex-end; }
.em-me .em-pics { justify-content: flex-end; }
.em-pic { display: block; line-height: 0; border-radius: 12px; }
.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 */
.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; }
@@ -13,7 +13,11 @@
@@ -13,7 +13,11 @@
var form = document.getElementById('em-form');
var form = document.getElementById('em-form');
var input = form.querySelector('textarea');
var input = form.querySelector('textarea');
var sendBtn = form.querySelector('button[type=submit]');
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 personBtn = document.getElementById('em-person');
var stateEl = document.getElementById('em-state');
var stateEl = document.getElementById('em-state');
var page = new URLSearchParams(location.search).get('page') || '';
var page = new URLSearchParams(location.search).get('page') || '';
@@ -231,11 +235,27 @@
@@ -231,11 +235,27 @@
attachBtn.disabled = true; errorLine('');
attachBtn.disabled = true; errorLine('');
var caption = input.value.trim();
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();
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) {
.then(function (x) {
pending.remove();
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(); }
if (caption) { input.value = ''; grow(); }
render(x.d.message);
render(x.d.message);
var fresh = !conv || conv.id !== x.d.conversation.id;
var fresh = !conv || conv.id !== x.d.conversation.id;
@@ -243,15 +263,17 @@
@@ -243,15 +263,17 @@
if (fresh) watch();
if (fresh) watch();
if (conv.status === 'bot') showTyping('Vibe');
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; });
.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 () {
attachBtn.addEventListener('click', function () {
if (!acctName) { errorLine('Sign in to send a picture: it goes to OpenVibe.Pics as you.'); if (!polling) signIn(); return; }
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
// 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.
// peer through support-call.js with this visitor's token; "Not now" or hanging up ends it for everyone.
@@ -85,3 +85,11 @@
@@ -85,3 +85,11 @@
.ib-screen figcaption { color: #e5e7eb; font-size: .85rem; font-weight: 600; }
.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 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; }
.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; }
@@ -205,20 +205,42 @@
@@ -205,20 +205,42 @@
body.appendChild(b);
body.appendChild(b);
}
}
if (thread) Array.prototype.forEach.call(thread.querySelectorAll('[data-author="visitor"]'), addTranslate);
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');
var trTo = document.getElementById('ib-tr-to');
if (trTo && input) {
if (trTo && input) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -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);
+})();
@@ -272,3 +272,11 @@
@@ -272,3 +272,11 @@
.vbk .vbc-phone .vbc-body { max-height: min(45vh, 420px); overflow: auto; overscroll-behavior: contain; padding: 0 10px 10px; }
.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 (min-width: 1180px) { .vbk .vbc-phone { display: none !important; } }
@media (max-width: 1179px) { .vbk .vbc-wide { 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; }
@@ -22,8 +22,10 @@
@@ -22,8 +22,10 @@
var room = cfg.room || null;
var room = cfg.room || null;
var latest = Number(cfg.latest) || 0;
var latest = Number(cfg.latest) || 0;
var ws = null, delay = 1000, sending = false, waitTimer = null;
var ws = null, delay = 1000, sending = false, waitTimer = null;
- var fileInput = document.getElementById('vb-file');
var previewEl = document.getElementById('vb-preview');
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 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 reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var chosenModels = Array.isArray(cfg.models) ? cfg.models.slice(0, 3) : [];
var chosenModels = Array.isArray(cfg.models) ? cfg.models.slice(0, 3) : [];
@@ -134,7 +136,6 @@
@@ -134,7 +136,6 @@
if (picture && picture.url) URL.revokeObjectURL(picture.url);
if (picture && picture.url) URL.revokeObjectURL(picture.url);
picture = null;
picture = null;
if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; }
if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; }
- if (fileInput) fileInput.value = '';
if (input) input.required = true;
if (input) input.required = true;
changed();
changed();
}
}
@@ -161,8 +162,8 @@
@@ -161,8 +162,8 @@
}
}
function choosePicture(file) {
function choosePicture(file) {
if (!file) return;
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; }
if (file.size > 20 * 1024 * 1024) { notice('A picture can be up to 20 MB.'); return; }
clearPicture();
clearPicture();
picture = { file: file, url: URL.createObjectURL(file) };
picture = { file: file, url: URL.createObjectURL(file) };
@@ -177,14 +178,37 @@
@@ -177,14 +178,37 @@
input.focus();
input.focus();
}
}
var attachBtn = app.querySelector('[data-vb-attach]');
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.
// A picture pasted into the composer goes the same way.
input.addEventListener('paste', function (e) {
input.addEventListener('paste', function (e) {
var items = (e.clipboardData && e.clipboardData.files) || [];
var items = (e.clipboardData && e.clipboardData.files) || [];
if (items.length && PIC_TYPES.test(items[0].type)) { e.preventDefault(); choosePicture(items[0]); }
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) ──
// ── The thread (the kit's markup: a turn is the person's message and the answer under it) ──
@@ -653,7 +677,7 @@
@@ -653,7 +677,7 @@
var mine = meTurn(text, null, sendingPicture && sendingPicture.url);
var mine = meTurn(text, null, sendingPicture && sendingPicture.url);
threadEl().appendChild(mine);
threadEl().appendChild(mine);
showWait(); toBottom();
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 url, init;
var modeEl = form.querySelector('input[name="mode"]:checked'), mode = modeEl ? modeEl.value : 'chat';
var modeEl = form.querySelector('input[name="mode"]:checked'), mode = modeEl ? modeEl.value : 'chat';
if (mode !== 'chat' && !sendingPicture) {
if (mode !== 'chat' && !sendingPicture) {
@@ -668,9 +692,16 @@
@@ -668,9 +692,16 @@
url = room ? '/api/chat/rooms/' + encodeURIComponent(room) + '/messages' : '/api/chat/vibe';
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() }) };
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) {
.then(function (x) {
if (!x.ok) { putBack((x.d && x.d.error) || 'That did not send. Try again.'); return; }
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)); }
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 @@
@@ -688,7 +719,13 @@
}
}
})
})
.catch(function () { putBack('You seem to be offline. Your message is back in the box.'); })
.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).
// Try again: Vibe's last answer goes and a new one is written (POST /api/chat/vibe/:slug/retry).
@@ -52,6 +52,8 @@ function embedHtml(inbox) {
@@ -52,6 +52,8 @@ function embedHtml(inbox) {
<title>Chat with ${esc(inbox.name)}</title>
<title>Chat with ${esc(inbox.name)}</title>
<meta name="robots" content="noindex, nofollow">
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="${asset('embed.css')}">
<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('vibe-md.js')}" defer></script>
<script src="${asset('support-call.js')}" defer></script>
<script src="${asset('support-call.js')}" defer></script>
<script src="${asset('embed.js')}" defer></script>
<script src="${asset('embed.js')}" defer></script>
@@ -64,9 +66,10 @@ function embedHtml(inbox) {
@@ -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>
<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>
<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>
<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>
<textarea id="em-input" rows="1" maxlength="4000" placeholder="Write a message" aria-label="Your message"></textarea>
<button type="submit" aria-label="Send">↑</button></form>
<button type="submit" aria-label="Send">↑</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>
<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>
</div>
<noscript><p style="padding:16px">This chat needs JavaScript.</p></noscript>
<noscript><p style="padding:16px">This chat needs JavaScript.</p></noscript>
@@ -172,7 +175,7 @@ ${list}
@@ -172,7 +175,7 @@ ${list}
mainLabel: current ? `Conversation with ${current.visitor_name || 'a visitor'}` : inbox.name,
mainLabel: current ? `Conversation with ${current.visitor_name || 'a visitor'}` : inbox.name,
})}</div>`;
})}</div>`;
await page(req, res, 200, {
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 })) },
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}
@@ -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.
// 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 }) {
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 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>`;
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({
return call + thread.composer({
@@ -162,6 +162,7 @@ ${INSTALL_LINKS}
@@ -162,6 +162,7 @@ ${INSTALL_LINKS}
<link rel="stylesheet" href="${asset('chat.css')}">
<link rel="stylesheet" href="${asset('chat.css')}">
${o.shell ? `<link rel="stylesheet" href="${asset('chat-shell.css')}">` : ''}
${o.shell ? `<link rel="stylesheet" href="${asset('chat-shell.css')}">` : ''}
<link rel="stylesheet" href="${ovServe.url('items.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.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}
${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
${(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')}">` : ''}
${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
@@ -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.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.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.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')}
${(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('theme-loader.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
@@ -788,9 +788,13 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
@@ -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');
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). */
/** 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>
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)}">
<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>
<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 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>` : '');
<p><button type="submit">Send picture</button></p></form></details>` : '');
@@ -1000,7 +1004,7 @@ ${people}
@@ -1000,7 +1004,7 @@ ${people}
const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;
const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;
await page(req, res, 200, {
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,
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,
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,
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 } } : {}) },
...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },
@@ -100,7 +100,7 @@ ${join}</article>`;
@@ -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>` : '';
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.
// 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>
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>
<h2>Start a server</h2>
<fieldset class="oc-sheet-step"><legend>1. What is it for?</legend>
<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>
<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>`;
@@ -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>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"><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>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>
</div>
</fieldset>
</fieldset>
<fieldset class="oc-sheet-step"><legend>3. Who may find it?</legend>
<fieldset class="oc-sheet-step"><legend>3. Who may find it?</legend>
@@ -124,7 +129,7 @@ ${join}</article>`;
@@ -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>`}
${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>`;
</section>`;
await page(req, res, 200, {
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.',
description: 'Servers on OpenVibe.Chat: a place for a group, with text and voice channels, roles and invites.',
body: shell.shell({
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 })) }),
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}`,
@@ -246,7 +251,7 @@ ${sheet}`,
// and the sign-in link post to (POST /s/:slug/:channel).
// and the sign-in link post to (POST /s/:slug/:channel).
const canonicalPath = req.params.channel ? back : `/s/${server.slug}`;
const canonicalPath = req.params.channel ? back : `/s/${server.slug}`;
await page(req, res, 200, {
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,
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'}.`,
description: server.description || `${server.name}, a server on OpenVibe.Chat with ${server.member_count} member${Number(server.member_count) === 1 ? '' : 's'}.`,
page: {
page: {
@@ -206,7 +206,8 @@ function composer({ slug, signedIn, enabled, prefill = '', engine = null, models
@@ -206,7 +206,8 @@ function composer({ slug, signedIn, enabled, prefill = '', engine = null, models
const pics = pictures.enabled();
const pics = pictures.enabled();
const label = pics ? 'Add a picture or a text file' : 'Add a text file';
const label = pics ? 'Add a picture or a text file' : 'Add a text file';
const attach = signedIn && enabled
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 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.';
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
@@ -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' },
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,
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') },
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.
// Stop is drawn hidden: vibe.js shows it while Vibe writes, and it keeps what was written so far.
stopAction: signedIn ? '' : null,
stopAction: signedIn ? '' : null,
foot: `<p class="vb-fine">${fine}</p>`,
foot: `<p class="vb-fine">${fine}</p>`,
@@ -296,7 +297,7 @@ ${MARK}
@@ -296,7 +297,7 @@ ${MARK}
center: !room, label: room ? 'Your chat with Vibe' : 'A new chat with Vibe',
center: !room, label: room ? 'Your chat with Vibe' : 'A new chat with Vibe',
})}</div>`;
})}</div>`;
await page(req, res, status, {
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',
title: room ? `${room.name} · Vibe` : 'Vibe, OpenVibe\'s assistant',
fullTitle: room ? undefined : 'Vibe: chat with OpenVibe\'s assistant · OpenVibe.Chat',
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.',
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.',
@@ -392,6 +392,7 @@ t('pictures: one of the people sends a picture (through OpenVibe.Pics as them);
@@ -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);
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());
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('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());
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');
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
@@ -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=${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(`/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 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 () => {
t('records: finished conversations go after the owner\'s retention, waiting and open ones never; the owner exports CSV and JSON', async () => {
@@ -85,6 +85,10 @@ t('the page: Pics links show as thumbnails linked to the picture\'s page; other
@@ -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.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.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, /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.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');
assert.doesNotMatch((await page('/r/photo-walk', cat)).text, /Add a picture/, 'only people who may post get the form');
});
});
@@ -49,7 +49,10 @@ t('everywhere else the limits stay: 2000 characters, spam refused', async () =>
@@ -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 () => {
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());
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, /<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"/);
assert.match(page, /aria-label="Add (a picture or )?a text file"/);
});
});
@@ -102,7 +102,8 @@ t('a picture Pics refuses leaves no chat behind; another type is refused before
@@ -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 () => {
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();
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(); });
t.run(async () => { if (aiClient) aiClient._setClient(null); if (pics) await pics.close(); if (h && h.close) await h.close(); });