openvibers/openvibe.chat Commits

openvibe.chat/vibe: what Vibe did, as chips you can undo (#293)

OP OpenVibers committed 13 days ago · 05b6685fd4 · 4 files, +140 −3

* openvibe.chat/vibe: what Vibe did, as chips you can undo

Under an answer that set or stopped a reminder or a timer (the results on the message), the Vibe
page shows a chip: Undo for a day as a plain form (POST /vibe/:slug/undo, the person's own chat,
same-site), Open for a reminder; a stopped one says so. Answers arriving live get the same chips.

* Merge branch 'main' into vibe-page-chips

* Merge branch 'main' into vibe-page-chips

Changes

ddiff --git a/public/web/vibe.css b/public/web/vibe.css
iindex 63b8735..89bb8b0 100644
---- a/public/web/vibe.css
++++ b/public/web/vibe.css
@@@ -350,3 +350,16 @@ body.oc-app-body { overflow: hidden; }
.ib-av { flex: none; width: 30px; height: 30px; border-radius: 50%; display: inline-grid; place-items: center; background: var(--oc-panel-2); color: var(--oc-text); font-weight: 700; }
.vb-bubble.ib-ai { background: color-mix(in srgb, var(--oc-accent) 14%, var(--oc-panel-2)); }
.ib-page { max-width: var(--vb-col); margin: 4px auto 0; font-size: .8rem; color: var(--oc-muted); overflow-wrap: anywhere; }
++
++/* What Vibe did (plan L48 V2): a chip for each reminder or timer it set or stopped, under its answer */
++.vb-res { display: grid; gap: 6px; margin: 10px 0 2px; max-width: 560px; }
++.vb-res-c { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 4px 12px; padding: 9px 12px; border-radius: 14px;
++ border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 35%, var(--border, rgba(255,255,255,.1))); background: color-mix(in srgb, var(--accent, #3b82f6) 8%, transparent); }
++.vb-res-c > svg { width: 18px; height: 18px; fill: none; stroke: var(--accent, #3b82f6); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
++.vb-res-t { min-width: 0; font-size: .9rem; line-height: 1.4; }
++.vb-res-t small { display: block; color: var(--text-muted, #8b8b96); font-size: .8rem; }
++.vb-res-a { display: flex; gap: 6px; align-items: center; }
++.vb-res-a form { margin: 0; }
++.vb-res-c.is-stopped { border-color: var(--border, rgba(255,255,255,.1)); background: transparent; }
++.vb-res-c.is-stopped > svg { stroke: var(--text-muted, #8b8b96); }
++.vb-res-c.is-stopped .vb-res-t { color: var(--text-muted, #8b8b96); }
ddiff --git a/public/web/vibe.js b/public/web/vibe.js
iindex 822dbcc..29618df 100644
---- a/public/web/vibe.js
++++ b/public/web/vibe.js
@@@ -134,6 +134,31 @@
var md = el('div', 'vb-md'); md.innerHTML = MD ? MD.render(m.message) : '';
if (!MD) md.textContent = m.message;
body.appendChild(md); li.appendChild(body);
++ // What Vibe did, as on the server's page (resultChips in server/web/vibe-pages.js): Undo is the same plain form.
++ var res = Array.isArray(m.results) ? m.results : [];
++ if (res.length && room) {
++ var box = el('div', 'vb-res');
++ res.slice(0, 4).forEach(function (r, i) {
++ if (!r || (r.kind !== 'reminder' && r.kind !== 'timer')) return;
++ var set = r.action === 'set';
++ var c = el('div', 'vb-res-c' + (set ? '' : ' is-stopped'));
++ var t = el('div', 'vb-res-t');
++ var b = el('b', '', (r.kind === 'timer' ? (set ? 'Timer' : 'Timer stopped') : (set ? 'Reminder set' : 'Reminder stopped')) + ':');
++ t.appendChild(b); t.appendChild(document.createTextNode(' ' + String(r.title || '')));
++ if (r.when && set) t.appendChild(el('small', '', String(r.when)));
++ c.appendChild(t);
++ var a = el('div', 'vb-res-a');
++ if (set) {
++ var f = el('form'); f.method = 'post'; f.action = '/vibe/' + room + '/undo';
++ [['message_id', String(m.id)], ['index', String(i)]].forEach(function (kv) { var h = el('input'); h.type = 'hidden'; h.name = kv[0]; h.value = kv[1]; f.appendChild(h); });
++ var u = el('button', 'vb-act', 'Undo'); u.type = 'submit'; f.appendChild(u); a.appendChild(f);
++ }
++ if (r.url && /^https:\/\//.test(r.url)) { var o = el('a', 'vb-act', 'Open'); o.href = r.url; a.appendChild(o); }
++ if (a.childNodes.length) c.appendChild(a);
++ box.appendChild(c);
++ });
++ body.appendChild(box);
++ }
// Copy, and Try again (it moves to this, the newest answer, when the person's message came just before).
var row = el('div', 'vb-acts');
var copy = el('button', 'vb-act', 'Copy'); copy.type = 'button'; copy.setAttribute('data-vb-copy', '');
ddiff --git a/server/web/vibe-pages.js b/server/web/vibe-pages.js
iindex 3f3a5b9..04541b2 100644
---- a/server/web/vibe-pages.js
++++ b/server/web/vibe-pages.js
@@@ -123,11 +123,34 @@ function acts(slug) {
return `<div class="vb-acts"><button type="button" class="vb-act" data-vb-copy hidden>Copy</button>${slug ? `<form method="post" action="/vibe/${esc(slug)}/retry" class="vb-retry"><button type="submit" class="vb-act">Try again</button></form>` : ''}</div>`;
}
--function turn(m, me, hows = new Map(), retrySlug = null) {
++const RES_ICON = {
++ reminder: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>',
++ timer: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5M9 2h6"/></svg>',
++};
++/**
++ * What Vibe did in this answer (plan L48 V2; Chat's results on the message, rooms/vibe-results.js): a chip for each
++ * reminder or timer it set or stopped. Undo is a plain form (it works without JavaScript) for a day; Open goes to
++ * the person's reminders on Today. An undone one says "stopped" and offers nothing more.
++ */
++function resultChips(m, slug) {
++ const list = require('../rooms/vibe-results').list(m.results);
++ if (!list.length) return '';
++ const young = Date.now() - atOf(m.created_at) < DAY;
++ return `<div class="vb-res">${list.slice(0, 4).map((r, i) => {
++ if (r.kind !== 'reminder' && r.kind !== 'timer') return '';
++ const set = r.action === 'set';
++ const what = r.kind === 'timer' ? (set ? 'Timer' : 'Timer stopped') : (set ? 'Reminder set' : 'Reminder stopped');
++ const undo = set && young && slug ? `<form method="post" action="/vibe/${esc(slug)}/undo"><input type="hidden" name="message_id" value="${Number(m.id) || 0}"><input type="hidden" name="index" value="${i}"><button type="submit" class="vb-act">Undo</button></form>` : '';
++ const open = r.url && /^https:\/\//.test(r.url) ? `<a class="vb-act" href="${esc(r.url)}">Open</a>` : '';
++ return `<div class="vb-res-c${set ? '' : ' is-stopped'}">${RES_ICON[r.kind]}<div class="vb-res-t"><b>${what}:</b> ${esc(r.title || '')}${r.when && set ? `<small>${esc(r.when)}</small>` : ''}</div>${undo || open ? `<div class="vb-res-a">${undo}${open}</div>` : ''}</div>`;
++ }).join('')}</div>`;
++}
++
++function turn(m, me, hows = new Map(), retrySlug = null, slug = null) {
const ai = !!m.ai_member_id;
if (ai) {
const info = hows.get(Number(m.id));
-- return `<li class="vb-turn vb-ai" data-id="${Number(m.id) || 0}">${MARK}<div class="vb-body"><p class="oc-sr">Vibe said:</p><div class="vb-md">${md.render(m.message)}</div>${how.chips(info, m.id)}${how.html(info, m.id)}${acts(retrySlug)}</div></li>`;
++ return `<li class="vb-turn vb-ai" data-id="${Number(m.id) || 0}">${MARK}<div class="vb-body"><p class="oc-sr">Vibe said:</p><div class="vb-md">${md.render(m.message)}</div>${resultChips(m, slug)}${how.chips(info, m.id)}${how.html(info, m.id)}${acts(retrySlug)}</div></li>`;
}
const mine = me && Number(m.user_id) === Number(me.id);
return `<li class="vb-turn vb-me" data-id="${Number(m.id) || 0}"><div class="vb-bubble"><p class="oc-sr">${mine ? 'You said:' : `${esc(m.display_name || m.username)} said:`}</p>${esc(m.message).replace(/\n/g, '<br>')}${pictures.pageHtml(m.message)}</div></li>`;
@@@ -210,7 +233,7 @@ ${MARK}
<ul class="vb-suggest">${SUGGESTIONS.map((s) => `<li><button type="button" class="vb-chip" data-prompt="${esc(s.prompt)}"><b>${esc(s.title)}</b><span>${esc(s.text)}</span></button></li>`).join('')}</ul>
</div>`;
const thread = room
-- ? `<ol class="vb-thread" id="vb-thread">${messages.map((m, i) => turn(m, user, hows, i === messages.length - 1 && retryable ? room.slug : null)).join('')}${waiting ? `<li class="vb-turn vb-ai vb-wait" id="vb-wait">${MARK}<div class="vb-body"><span class="vb-dots" aria-label="Vibe is writing"><i></i><i></i><i></i></span><noscript><a href="/vibe/${esc(room.slug)}">Refresh to see Vibe's answer</a></noscript></div></li>` : ''}</ol>`
++ ? `<ol class="vb-thread" id="vb-thread">${messages.map((m, i) => turn(m, user, hows, i === messages.length - 1 && retryable ? room.slug : null, room.slug)).join('')}${waiting ? `<li class="vb-turn vb-ai vb-wait" id="vb-wait">${MARK}<div class="vb-body"><span class="vb-dots" aria-label="Vibe is writing"><i></i><i></i><i></i></span><noscript><a href="/vibe/${esc(room.slug)}">Refresh to see Vibe's answer</a></noscript></div></li>` : ''}</ol>`
: '';
const head = `<header class="vb-head">
<button class="vb-icon vb-menu" type="button" data-vb-open aria-controls="vb-rail" aria-expanded="false" aria-label="Show your chats">${ICON.menu}</button>
@@@ -371,6 +394,21 @@ ${notes.length ? `<h2 class="vb-group">${notes.length} of ${memory.MAX_NOTES} no
try { await vibe.retry(room, actor.user); res.redirect(303, `/vibe/${room.slug}`); } catch (err) { await render(req, res, { room, status: err.status || 500, error: err.status ? err.message : 'That did not work. Try again in a minute.' }); }
});
++ // Undo what Vibe did (a reminder or a timer it set), from the chip under its answer: the person's own chat only, a
++ // day at most (rooms/vibe-results.js undo checks both, and that it was not undone already).
++ router.post('/vibe/:slug/undo', formLimit, sameSite, async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const room = await vibe.ownChat(req.params.slug, actor.user);
++ if (!room) return res.redirect(303, '/vibe');
++ const b = req.body || {};
++ try {
++ await require('../rooms/vibe-results').undo({ messageId: Number(b.message_id), index: Number(b.index), user: actor.user });
++ res.redirect(303, `/vibe/${room.slug}`);
++ } catch (err) {
++ await render(req, res, { room, status: err.status || 500, error: err.status ? err.message : 'That could not be undone. Try again in a moment.' });
++ }
++ });
++
router.post('/vibe/:slug/delete', formLimit, sameSite, async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const room = await vibe.ownChat(req.params.slug, actor.user);
ddiff --git a/test/vibe-page-chips.test.js b/test/vibe-page-chips.test.js
nnew file mode 100644
iindex 0000000..425418d
---- /dev/null
++++ b/test/vibe-page-chips.test.js
@@@ -0,0 +1,61 @@
++'use strict';
++/**
++ * What Vibe did, on openvibe.chat/vibe (plan L48 V2; rooms/vibe-results.js): under an answer that set a reminder or a
++ * timer, a chip with Undo (a plain form, for a day) and Open; a stopped one says so and offers nothing; the Undo form
++ * is the person's own and same-site only.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('vibe page: result chips');
++const SITE = 'https://openvibe.chat';
++let h, ann, bob, slug, answerId;
++const web = async (method, path, u, fields, origin = SITE) => {
++ const headers = { ...(u ? { cookie: `ov_token=${u.token}` } : {}), ...(fields ? { 'content-type': 'application/x-www-form-urlencoded', origin } : {}) };
++ const r = await fetch(`${h.base}${path}`, { method, headers, body: fields ? new URLSearchParams(fields).toString() : undefined, redirect: 'manual' });
++ return { status: r.status, text: await r.text(), location: r.headers.get('location') };
++};
++
++t('boot', async () => {
++ h = await boot({ env: { CHAT_WEB_URL: SITE } });
++ ann = h.addUser('ann', { subject: ids.newId('user') });
++ bob = h.addUser('bob', { subject: ids.newId('user') });
++ for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));
++ const vibe = require('../server/rooms/vibe');
++ const room = await vibe.createRoom(h.live.users.get(ann.id), 'Plans');
++ slug = room.slug || (await require('../server/db/database').get('SELECT slug FROM rooms WHERE id = ?', [room.id || room])).slug;
++ const db = require('../server/db/database');
++ const r = await db.get('SELECT id FROM rooms WHERE slug = ?', [slug]);
++ const ai = await db.get('SELECT id FROM room_ai_members WHERE room_id = ?', [r.id]);
++ await db.run('INSERT INTO room_messages (room_id, user_id, message) VALUES (?, ?, ?)', [r.id, ann.id, 'remind me to stretch every 2 hours']);
++ const ins = await db.run('INSERT INTO room_messages (room_id, user_id, ai_member_id, message, results) VALUES (?, ?, ?, ?, ?) RETURNING id',
++ [r.id, ann.id, ai.id, 'Reminder set: Stretch, every 2 hours.', JSON.stringify([
++ { kind: 'reminder', action: 'set', id: 'rem_01J8Z3V9Q6N1X2Y3Z4A5B6C7E1', title: 'Stretch', when: 'Every 2 hours, 9 am–5 pm', url: 'https://openvibe.today/me#reminders' },
++ { kind: 'timer', action: 'stopped', title: '25 min focus' },
++ ])]);
++ answerId = Number(ins.lastInsertRowid || (ins.rows && ins.rows[0] && ins.rows[0].id));
++});
++
++t('the answer carries its chips: Undo and Open for what is set, "stopped" for what is not', async () => {
++ const r = await web('GET', `/vibe/${slug}`, ann);
++ assert.strictEqual(r.status, 200);
++ const box = (/<div class="vb-res">[\s\S]*?<\/div><\/div><\/div>(?=<)/.exec(r.text) || [''])[0] || r.text.slice(r.text.indexOf('<div class="vb-res">'), r.text.indexOf('<div class="vb-res">') + 2000);
++ assert.match(box, /<div class="vb-res-c"><svg[^>]*>[\s\S]*?<\/svg><div class="vb-res-t"><b>Reminder set:<\/b> Stretch<small>Every 2 hours, 9 am–5 pm<\/small><\/div>/);
++ assert.match(box, new RegExp(`<form method="post" action="/vibe/${slug}/undo"><input type="hidden" name="message_id" value="${answerId}"><input type="hidden" name="index" value="0"><button type="submit" class="vb-act">Undo</button></form>`));
++ assert.match(box, /<a class="vb-act" href="https:\/\/openvibe\.today\/me#reminders">Open<\/a>/);
++ assert.match(box, /<div class="vb-res-c is-stopped">[\s\S]*?<b>Timer stopped:<\/b> 25 min focus<\/div><\/div>/, 'a stopped one offers nothing');
++});
++
++t('the Undo form is the person\'s own, and same-site only', async () => {
++ const other = await web('POST', `/vibe/${slug}/undo`, bob, { message_id: String(answerId), index: '0' });
++ assert.strictEqual(other.status, 303);
++ assert.strictEqual(other.location, '/vibe', 'someone else\'s chat: nothing happens');
++ const cross = await web('POST', `/vibe/${slug}/undo`, ann, { message_id: String(answerId), index: '0' }, 'https://evil.example');
++ assert.strictEqual(cross.status, 403);
++ const db = require('../server/db/database');
++ const row = await db.get('SELECT results FROM room_messages WHERE id = ?', [answerId]);
++ assert.strictEqual(require('../server/rooms/vibe-results').list(row.results)[0].action, 'set', 'still set');
++});
++
++t.run(async () => { if (h && h.close) await h.close(); });