openvibers/openvibe.chat Commits

Vibe: search your chats (#174)

OP OpenVibers committed 20 days ago · e63177c85a · 5 files, +91 −6

* Vibe: search your chats (the rail's box narrows the list by name as you type; Enter finds a chat by any message in it, with the match marked)

* Search chats never writes the search's own words back into the page (security-private's crawl puts a private room's words in ?q=): the box fills itself from the address, results say 'Chats that match'

Changes

ddiff --git a/public/web/vibe.css b/public/web/vibe.css
iindex 06e458e..1e81bd2 100644
---- a/public/web/vibe.css
++++ b/public/web/vibe.css
@@@ -36,6 +36,17 @@ body.oc-app-body { overflow: hidden; }
.vb-icon:hover { background: var(--oc-panel-2); color: var(--oc-text); }
.vb-icon:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 1px; }
.vb-rail-close, .vb-menu { display: none; }
++.vb-search { position: relative; margin: 0 2px 6px; }
++.vb-search svg { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--oc-muted); pointer-events: none; }
++.vb-search input {
++ box-sizing: border-box; width: 100%; min-height: 38px; padding: 0 12px 0 34px; border-radius: 10px;
++ border: 1px solid var(--oc-line); background: var(--oc-bg); color: var(--oc-text); font: inherit; font-size: .9rem;
++}
++.vb-search input:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 1px; border-color: transparent; }
++.vb-chat.vb-hit { white-space: normal; }
++.vb-hit span { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++.vb-hit small { display: block; margin-top: 2px; color: var(--oc-muted); font-size: .8rem; line-height: 1.35; overflow-wrap: anywhere; }
++.vb-hit mark { background: color-mix(in srgb, var(--oc-accent) 28%, transparent); color: inherit; border-radius: 3px; padding: 0 1px; }
.vb-rail-list { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 0 2px; scrollbar-width: thin; }
.vb-group { margin: 14px 10px 4px; font-size: .72rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--oc-muted); }
.vb-chats { list-style: none; margin: 0; padding: 0; }
ddiff --git a/public/web/vibe.js b/public/web/vibe.js
iindex 561b6d6..3eeabca 100644
---- a/public/web/vibe.js
++++ b/public/web/vibe.js
@@@ -201,6 +201,21 @@
else if (e.target.closest('[data-vb-close]')) setRail(false);
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && app.classList.contains('is-open')) setRail(false); });
++ // ── Search chats: typing narrows the listed chats by name at once; Enter searches inside them too (?q=) ──
++ var q = document.getElementById('vb-q');
++ var searched = null;
++ try { searched = new URLSearchParams(location.search).get('q'); } catch (e) { searched = null; }
++ if (q && searched) q.value = searched.slice(0, 80); // the page never repeats it (server/web/vibe-pages.js)
++ if (q && !searched) q.addEventListener('input', function () {
++ var v = q.value.trim().toLowerCase();
++ var list = document.querySelector('.vb-rail-list'); if (!list) return;
++ [].forEach.call(list.querySelectorAll('.vb-chats'), function (ul) {
++ var shown = 0;
++ [].forEach.call(ul.children, function (li) { var hit = !v || li.textContent.toLowerCase().indexOf(v) >= 0; li.hidden = !hit; if (hit) shown++; });
++ ul.hidden = !shown;
++ var head = ul.previousElementSibling; if (head && head.classList.contains('vb-group')) head.hidden = !shown;
++ });
++ });
var del = app.querySelector('[data-vb-delete]');
if (del) del.addEventListener('submit', function (e) { if (!window.confirm('Delete this chat? Its messages go too.')) e.preventDefault(); });
ddiff --git a/server/rooms/vibe.js b/server/rooms/vibe.js
iindex c92d7ae..06ec0d1 100644
---- a/server/rooms/vibe.js
++++ b/server/rooms/vibe.js
@@@ -10,6 +10,7 @@
* createRoom(user, title) → the new chat's room row, nothing posted yet (a chat that starts with a picture)
* erase(roomId) the chat and everything in it, at once (a first picture Pics refused)
* list(user, { limit }) → the person's chats, the latest conversation first
++ * search(user, q, { limit }) → those whose name or a message holds q (ignoring case), each with its newest hit
* ownChat(slug, user) → the room when it is one of `user`'s Vibe chats, else null
* rename(room, user, name) → the room
* remove(room, user) → the chat and its messages are deleted (not archived: it was the person's own)
@@@ -32,6 +33,7 @@ const rooms = require('./rooms');
const VIBE = { name: 'Vibe', handle: 'vibe' };
const MAX_CHATS = 500;
const TITLE_MAX = 40;
++const SEARCH_MAX = 80; // the longest search the rail's box sends
const DEFAULT_TITLE = 'Chat with Vibe';
class VibeError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }
@@@ -143,6 +145,22 @@ async function list(user, { limit = 100 } = {}) {
return rows.map((r) => ({ slug: r.slug, name: r.name, last_message_at: r.last_message_at || r.created_at, created_at: r.created_at }));
}
++/**
++ * Search chats (the rail's box): the person's own chats whose name or any message holds `q`, ignoring case, newest
++ * first, each with its newest matching message as `hit` (null when only the name matched). Under 2 characters: none.
++ */
++async function search(user, q, { limit = 30 } = {}) {
++ const text = String(q || '').trim().slice(0, SEARCH_MAX);
++ if (!user || text.length < 2) return [];
++ const like = `%${text.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
++ const rows = await db.all(`SELECT r.slug, r.name, r.last_message_at, r.created_at,
++ (SELECT m.message FROM room_messages m WHERE m.room_id = r.id AND m.is_deleted = 0 AND m.message ILIKE ? ORDER BY m.id DESC LIMIT 1) AS hit
++ FROM rooms r WHERE r.owner_id = ? AND r.assistant = 1 AND r.archived_at IS NULL
++ AND (r.name ILIKE ? OR EXISTS (SELECT 1 FROM room_messages m WHERE m.room_id = r.id AND m.is_deleted = 0 AND m.message ILIKE ?))
++ ORDER BY COALESCE(r.last_message_at, r.created_at) DESC LIMIT ?`, [like, user.id, like, like, Math.min(50, Math.max(1, Number(limit) || 30))]);
++ return rows.map((r) => ({ slug: r.slug, name: r.name, last_message_at: r.last_message_at || r.created_at, created_at: r.created_at, hit: r.hit || null }));
++}
++
async function ownChat(slug, user) {
if (!user) return null;
const room = await rooms.bySlug(slug);
@@@ -179,4 +197,4 @@ async function countReply(user) {
ON CONFLICT (user_id, day) DO UPDATE SET replies = vibe_usage.replies + 1`, [user.id, today()]);
}
--module.exports = { VIBE, MAX_CHATS, TITLE_MAX, DEFAULT_TITLE, MODES, VibeError, enabled, isVibeRoom, titleOf, start, startWork, work, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, ownChat, rename, remove, repliesToday, countReply };
++module.exports = { VIBE, MAX_CHATS, TITLE_MAX, SEARCH_MAX, DEFAULT_TITLE, MODES, VibeError, enabled, isVibeRoom, titleOf, start, startWork, work, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, search, ownChat, rename, remove, repliesToday, countReply };
ddiff --git a/server/web/vibe-pages.js b/server/web/vibe-pages.js
iindex 70da0b1..116a52b 100644
---- a/server/web/vibe-pages.js
++++ b/server/web/vibe-pages.js
@@@ -54,24 +54,43 @@ const ICON = {
tasks: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6h11M9 12h11M9 18h11"/><path d="m3 6 1.5 1.5L7 5M3 12l1.5 1.5L7 11M3 18l1.5 1.5L7 17"/></svg>',
computer: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>',
team: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="14" rx="2"/><path d="M7 9h6M7 13h10"/></svg>',
++ search: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/></svg>',
};
++/** A search hit's message, cut to about 90 characters around the first match, with the match marked. */
++function snippet(text, q) {
++ const flat = String(text || '').replace(/\s+/g, ' ').trim();
++ const at = flat.toLowerCase().indexOf(String(q).toLowerCase());
++ if (at < 0) return esc(flat.slice(0, 90));
++ const from = Math.max(0, at - 36);
++ const to = Math.min(flat.length, at + q.length + 54);
++ return `${from > 0 ? '…' : ''}${esc(flat.slice(from, at))}<mark>${esc(flat.slice(at, at + q.length))}</mark>${esc(flat.slice(at + q.length, to))}${to < flat.length ? '…' : ''}`;
++}
++
/** The mark Vibe signs its answers with (the network's V, in the accent). */
const MARK = '<span class="vb-mark" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 5l7 14 7-14"/></svg></span>';
--function rail({ chats, current, signedIn, allowance }) {
++function rail({ chats, current, signedIn, allowance, query = '' }) {
const groups = groupChats(chats);
++ // The search's own words are never written back into the page (a link could carry anyone's words): the box gets
++ // them from the address in the browser, and the results say only what matched in the person's own chats.
++ const found = query
++ ? (chats.length
++ ? `<p class="vb-group">Chats that match</p><ul class="vb-chats">${chats.map((c) => `<li><a class="vb-chat vb-hit" href="/vibe/${esc(c.slug)}"${c.slug === current ? ' aria-current="page"' : ''}><span>${esc(c.name)}</span>${c.hit ? `<small>${snippet(c.hit, query)}</small>` : ''}</a></li>`).join('')}</ul>`
++ : '<p class="vb-rail-note">No chat matches your search.</p>') + '<p class="vb-rail-note"><a href="/vibe">All your chats</a></p>'
++ : null;
const list = !signedIn
? '<p class="vb-rail-note">Sign in to keep your chats. They are private to you.</p>'
-- : groups.length
++ : found || (groups.length
? groups.map((g) => `<p class="vb-group">${esc(g.label)}</p><ul class="vb-chats">${g.chats.map((c) => `<li><a class="vb-chat" href="/vibe/${esc(c.slug)}"${c.slug === current ? ' aria-current="page"' : ''}>${esc(c.name)}</a></li>`).join('')}</ul>`).join('')
-- : '<p class="vb-rail-note">Your chats with Vibe show up here.</p>';
++ : '<p class="vb-rail-note">Your chats with Vibe show up here.</p>');
const left = allowance ? Math.max(0, allowance.per_day - allowance.used_today) : null;
return `<aside class="vb-rail" id="vb-rail" aria-label="Your chats with Vibe">
<div class="vb-rail-top">
<a class="vb-new" href="/vibe">${ICON.new}<span>New chat</span></a>
<button class="vb-icon vb-rail-close" type="button" data-vb-close aria-label="Close the chat list">${ICON.close}</button>
</div>
++${signedIn ? `<form class="vb-search" action="/vibe" method="get" role="search">${ICON.search}<label class="oc-sr" for="vb-q">Search your chats</label><input id="vb-q" name="q" type="search" maxlength="${vibe.SEARCH_MAX}" placeholder="Search chats" autocomplete="off" enterkeyhint="search"></form>` : ''}
<nav class="vb-rail-list" aria-label="Chats with Vibe">${list}</nav>
<nav class="vb-rail-more" aria-label="More of OpenVibe.Chat">
<a href="https://openvibe.actor/tasks" title="Every task you gave an agent, one-off or recurring, with what it cost">${ICON.tasks}<span>Tasks</span></a>
@@@ -139,7 +158,10 @@ function mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit,
async function render(req, res, { room = null, status = 200, error = null } = {}) {
const actor = await viewerOf(req);
const user = actor.kind === 'user' ? actor.user : null;
-- const chats = user ? await vibe.list(user) : [];
++ // ?q= from the rail's search box: the person's chats whose name or messages hold it (2 characters or more).
++ const query = room ? '' : String((req.query && req.query.q) || '').replace(/[\u0000-\u001f]/g, ' ').trim().slice(0, vibe.SEARCH_MAX);
++ const searching = !!user && query.length >= 2;
++ const chats = user ? (searching ? await vibe.search(user, query) : await vibe.list(user)) : [];
const allowance = user ? await allowanceOf(user) : null;
const messages = room ? await rooms.history(room, { limit: 200 }) : [];
const enabled = vibe.enabled();
@@@ -163,7 +185,7 @@ ${room ? `<details class="vb-actions"><summary class="vb-icon" aria-label="Chat
</div></details>` : `<a class="vb-icon vb-head-new" href="/vibe" aria-label="New chat">${ICON.new}</a>`}
</header>`;
const body = `<div class="vb${room ? '' : ' vb-empty'}" id="vb">
--${rail({ chats, current: room && room.slug, signedIn: !!user, allowance })}
++${rail({ chats, current: room && room.slug, signedIn: !!user, allowance, query: searching ? query : '' })}
<div class="vb-scrim" data-vb-close hidden></div>
<section class="vb-main" aria-label="${room ? 'Your chat with Vibe' : 'A new chat with Vibe'}">
${head}
ddiff --git a/test/vibe.test.js b/test/vibe.test.js
iindex ab571eb..31933f6 100644
---- a/test/vibe.test.js
++++ b/test/vibe.test.js
@@@ -150,6 +150,25 @@ t('Ask Vibe from any OpenVibe site: the selection and the page fill a new chat\'
assert.match(r.text, /name="next" value="\/vibe\?ask=hello"/, 'signed out, the question survives the sign-in');
});
++t('search chats: the rail\'s box finds a chat by its name or by any message in it, only among the person\'s own', async () => {
++ const made = await vibeApi('POST', '', ann, { message: 'Find me a cheap blue bicycle near the river' });
++ assert.strictEqual(made.status, 201, made.text);
++ await answers(made.body.room.slug, 1); // Vibe's answer lists rice and lentils
++ const signed = await web('GET', '/vibe', ann);
++ assert.match(signed.text, /<form class="vb-search" action="\/vibe" method="get" role="search">/);
++ assert.ok(!(await web('GET', '/vibe')).text.includes('vb-search'), 'signed out, there is nothing to search');
++ let r = await web('GET', '/vibe?q=BICYCLE', ann);
++ assert.ok(r.text.includes('<p class="vb-group">Chats that match</p>'));
++ assert.ok(!r.text.includes('BICYCLE'), 'the search\'s own words are never written back into the page');
++ assert.match(r.text, new RegExp(`class="vb-chat vb-hit" href="/vibe/${made.body.room.slug}"><span>Find me a cheap blue bicycle near the…</span><small>Find me a cheap blue <mark>bicycle</mark> near the river</small>`));
++ r = await web('GET', '/vibe?q=lentils', ann);
++ assert.ok(r.text.includes('<mark>lentils</mark>'), 'a match in Vibe\'s answer counts');
++ assert.ok((await web('GET', '/vibe?q=100%25', ann)).text.includes('No chat matches your search.'), '% is a character, not a wildcard');
++ const other = await web('GET', '/vibe?q=bicycle', bob);
++ assert.ok(other.text.includes('No chat matches your search.') && !other.text.includes('bicycle'), 'never someone else\'s chats, nor the words searched');
++ assert.ok(!(await web('GET', '/vibe?q=b', ann)).text.includes('Chats that match'), 'one character lists the chats as usual');
++});
++
t('answers render as escaped Markdown with http(s) links only', () => {
assert.strictEqual(md.render('**hi** `<b>`'), '<p><strong>hi</strong> <code>&lt;b&gt;</code></p>');
assert.ok(!md.render('<script>alert(1)</script>').includes('<script'));