openvibers/openvibe.chat Commits
openvibe.chat/vibe: what Vibe did, as chips you can undo (#293)
Changes
@@@ -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); }@@@ -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', '');@@@ -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} notry { 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);nnew file mode 100644@@@ -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(); });
@@ -350,3 +350,16 @@ body.oc-app-body { overflow: hidden; }
@@ -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; }
.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)); }
.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; }
.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); }
@@ -134,6 +134,31 @@
@@ -134,6 +134,31 @@
var md = el('div', 'vb-md'); md.innerHTML = MD ? MD.render(m.message) : '';
var md = el('div', 'vb-md'); md.innerHTML = MD ? MD.render(m.message) : '';
if (!MD) md.textContent = m.message;
if (!MD) md.textContent = m.message;
body.appendChild(md); li.appendChild(body);
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).
// 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 row = el('div', 'vb-acts');
var copy = el('button', 'vb-act', 'Copy'); copy.type = 'button'; copy.setAttribute('data-vb-copy', '');
var copy = el('button', 'vb-act', 'Copy'); copy.type = 'button'; copy.setAttribute('data-vb-copy', '');
@@ -123,11 +123,34 @@ function acts(slug) {
@@ -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>`;
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;
const ai = !!m.ai_member_id;
if (ai) {
if (ai) {
const info = hows.get(Number(m.id));
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);
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>`;
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}
@@ -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>
<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>`;
</div>`;
const thread = room
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">
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>
<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
@@ -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.' }); }
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) => {
router.post('/vibe/:slug/delete', formLimit, sameSite, async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const room = await vibe.ownChat(req.params.slug, actor.user);
const room = await vibe.ownChat(req.params.slug, actor.user);
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -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(); });