openvibers/openvibe.chat Commits
Messages, conversations, Feeds, a feed and Rooms reworked: one compact header with icon actions, phone-style lists, a one-line new-message box, setup and settings folded away, copyable short snippets, Block in a more menu (#223)
Changes
@@@ -494,3 +494,73 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }@media (max-width: 560px) {.oc-room-feed { height: max(300px, calc(100svh - 250px)); }}++++/* Working pages (server/web/ui.js): one compact header (back, title, one muted line, icon actions), lists that read like++ a phone's (a mark, the name and time on one line, the last thing under it), a search-style row for "start one",++ and cards that fold away what is only needed once (setup, settings). */++.oc-page-head { display: flex; align-items: center; gap: 6px; margin: 0 0 12px; min-height: 44px; }++.oc-page-head > .oc-back { margin-left: -8px; }++.oc-page-id { flex: 1; min-width: 0; }++.oc-page-id h1 { margin: 0; font-size: clamp(1.15rem, 4vw, 1.45rem); line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.oc-page-sub { margin: 2px 0 0; color: var(--oc-muted); font-size: .86rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.oc-page-tools { display: flex; gap: 2px; margin-right: -8px; }++.oc-page-tools form { margin: 0; }++.oc-list { list-style: none; margin: 0 0 14px; padding: 0; border: 1px solid var(--oc-line); border-radius: 14px; background: var(--oc-panel); overflow: hidden; display: grid; grid-template-columns: minmax(0, 1fr); }++.oc-list-head { margin: 18px 0 8px; font-size: .78rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--oc-muted); }++.oc-row + .oc-row { border-top: 1px solid var(--oc-line); }++.oc-row > a { display: flex; align-items: center; gap: 12px; padding: 10px 14px; color: var(--oc-text); text-decoration: none; min-width: 0; }++.oc-row > a:hover { background: var(--oc-panel-2); }++.oc-row-main { flex: 1; min-width: 0; display: grid; gap: 1px; }++.oc-row-top { display: flex; align-items: baseline; gap: 8px; min-width: 0; }++.oc-row-top strong { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 650; }++.oc-row-top time { flex: none; color: var(--oc-muted); font-size: .78rem; font-variant-numeric: tabular-nums; }++.oc-row-top .oc-badge { flex: none; margin-left: 0; }++.oc-row-sub { display: block; min-width: 0; color: var(--oc-muted); font-size: .9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.oc-row-sub .oc-dot { float: right; margin-left: 8px; }++.oc-unread .oc-row-top strong { font-weight: 800; }++.oc-unread .oc-row-sub { color: var(--oc-text); }++.oc-dot { display: inline-grid; place-items: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; background: var(--accent-strong, var(--oc-accent)); color: var(--on-accent-strong, var(--oc-on-accent)); font-size: .72rem; font-weight: 800; }++.oc-row-empty { padding: 16px 14px; }++.oc-av { flex: none; display: inline-grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; font-size: .85rem; font-weight: 800; letter-spacing: .02em; color: #fff; background: hsl(var(--av, 210) 45% 38%); }++.oc-av-feed { background: var(--oc-panel-2); color: var(--oc-muted); border: 1px solid var(--oc-line); }++.oc-av-feed.is-on { color: var(--oc-ok); border-color: color-mix(in srgb, var(--oc-ok) 45%, transparent); }++.oc-find { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; padding: 4px 4px 4px 14px; border: 1px solid var(--oc-line); border-radius: 999px; background: var(--oc-panel); }++.oc-find:focus-within { border-color: var(--oc-accent); }++.oc-find > .oc-i { color: var(--oc-muted); }++.oc-find input { flex: 1; min-width: 0; border: 0; background: transparent; padding: 8px 0; outline: none; }++.oc-find .oc-send { width: 38px; height: 38px; }++.oc-card { margin: 0 0 10px; padding: 0; border: 1px solid var(--oc-line); border-radius: 14px; background: var(--oc-panel); }++.oc-card > summary { display: flex; align-items: center; gap: 10px; padding: 12px 14px; cursor: pointer; list-style: none; font-weight: 650; }++.oc-card > summary::-webkit-details-marker { display: none; }++.oc-card > summary > span { flex: 1; }++.oc-fold > summary > .oc-i { color: var(--oc-muted); transition: transform .15s; }++.oc-fold[open] > summary > .oc-i { transform: rotate(90deg); }++.oc-card[open] > summary { border-bottom: 1px solid var(--oc-line); }++.oc-card > :not(summary) { margin-left: 14px; margin-right: 14px; }++.oc-card > :not(summary):last-child { margin-bottom: 14px; }++.oc-card > p:first-of-type, .oc-card > form:first-of-type, .oc-card > pre:first-of-type { margin-top: 12px; }++.oc-make > summary { color: var(--oc-accent); }++.oc-make-how { font-size: .9rem; }++.oc-grid-form { max-width: none; }++.oc-grid-form label { display: grid; gap: 4px; font-size: .9rem; color: var(--oc-muted); }++.oc-grid-form label.oc-check { display: flex; color: var(--oc-text); }++.oc-grid-form input:not([type=checkbox]):not([type=radio]), .oc-grid-form select { width: 100%; margin-left: 0; max-width: none; color: var(--oc-text); }++.oc-keyrow { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin: 4px 0 12px; }++.oc-keyrow .oc-i { vertical-align: -.2em; }++.oc-danger-row { display: block; margin: 0 14px 14px; }++@media (prefers-reduced-motion: reduce) { .oc-fold > summary > .oc-i { transition: none; } }++/* A code block people copy (a feed's setup): its copy button in the corner, and never taller than a screenful. */++.oc-snip { position: relative; }++.oc-snip > .oc-icon-btn { position: absolute; top: 6px; right: 6px; z-index: 1; width: 34px; height: 34px; background: var(--oc-panel); border-color: var(--oc-line); }++.oc-snip > pre { max-height: 50vh; overflow: auto; padding-right: 48px; }++/* A header's "more" menu (a conversation's Block): a small panel under its button. */++.oc-more { position: relative; }++.oc-more > summary { list-style: none; }++.oc-more > summary::-webkit-details-marker { display: none; }++.oc-pop { position: absolute; right: 0; top: calc(100% + 4px); z-index: 20; min-width: 200px; padding: 6px; border-radius: 12px; border: 1px solid var(--oc-line); background: var(--oc-panel); box-shadow: 0 12px 28px rgba(0, 0, 0, .35); }++.oc-pop form { margin: 0; }++.oc-pop-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 10px; border-radius: 8px; background: transparent; color: var(--oc-text); font-weight: 600; text-align: left; }++.oc-pop-item:hover { background: var(--oc-panel-2); }++.oc-pop-item.oc-danger { color: var(--oc-red); }++.oc-row-lock { display: inline-flex; color: var(--oc-muted); flex: none; }++.oc-row-lock .oc-i { width: .9em; height: .9em; }@@@ -370,18 +370,42 @@}// ── The inbox: conversations and unread counts, refreshed while the page is open ──++ // When a message was, at a glance (server/web/ui.js when()): 14:05 today, Yesterday, a weekday this week, Oct 20, or the date.++ var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];++ var WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];++ function whenText(d) {++ var now = new Date(), DAY = 86400000;++ var startToday = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), t = d.getTime();++ if (t >= startToday) return d.toISOString().slice(11, 16);++ if (t >= startToday - DAY) return 'Yesterday';++ if (t >= startToday - 6 * DAY) return WEEKDAYS[d.getUTCDay()];++ if (d.getUTCFullYear() === now.getUTCFullYear()) return MONTHS[d.getUTCMonth()] + ' ' + d.getUTCDate();++ return d.toISOString().slice(0, 10);++ }++ // A person's initials in a round mark, its colour from the name (server/web/ui.js initials()).++ function initialsMark(name) {++ var words = String(name || '?').replace(/[^\p{L}\p{N} ]/gu, ' ').trim().split(/\s+/).filter(Boolean);++ var letters = (words.length > 1 ? words[0][0] + words[1][0] : (words[0] || '?').slice(0, 2)).toUpperCase();++ var h = 0;++ for (var ch of String(name || '')) h = (h * 31 + ch.codePointAt(0)) % 360;++ var m = el('span', 'oc-av', letters); m.style.setProperty('--av', String(h)); m.setAttribute('aria-hidden', 'true');++ return m;++ }function convItem(c, me) {-- var li = el('li', 'oc-conv' + (c.unread_count ? ' oc-unread' : ''));++ var unread = Number(c.unread_count) || 0;++ var li = el('li', 'oc-row' + (unread ? ' oc-unread' : ''));var a = el('a'); a.href = '/messages/' + encodeURIComponent(c.id);var others = (c.participants || []).filter(function (p) { return p.id !== me; });-- a.appendChild(el('strong', null, c.name || others.map(function (p) { return p.display_name || p.username; }).join(', ') || 'Just you'));-- if (c.unread_count) { a.appendChild(document.createTextNode(' ')); a.appendChild(el('span', 'oc-badge', c.unread_count + ' new')); }-- a.appendChild(el('span', 'oc-muted oc-last', c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet'));-- li.appendChild(a);++ var title = c.name || others.map(function (p) { return p.display_name || p.username; }).join(', ') || 'Just you';++ a.appendChild(initialsMark(title));++ var main = el('span', 'oc-row-main'), top = el('span', 'oc-row-top'), sub = el('span', 'oc-row-sub', c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet');++ top.appendChild(el('strong', null, title));if (c.last_message_at) {var d = new Date(String(c.last_message_at).indexOf('T') < 0 ? String(c.last_message_at).replace(' ', 'T') + 'Z' : c.last_message_at);-- if (!isNaN(d)) { var t = el('time', 'oc-time', d.toISOString().slice(0, 10)); t.dateTime = d.toISOString(); li.appendChild(t); }++ if (!isNaN(d)) { var t = el('time', null, whenText(d)); t.dateTime = d.toISOString(); top.appendChild(t); }}++ if (unread) { var dot = el('span', 'oc-dot', unread > 9 ? '9+' : String(unread)); dot.setAttribute('aria-label', unread + ' new'); sub.appendChild(dot); }++ main.appendChild(top); main.appendChild(sub); a.appendChild(main); li.appendChild(a);return li;}function startInbox() {@@@ -397,7 +421,7 @@var unread = 0;list.textContent = '';d.conversations.forEach(function (c) { unread += Number(c.unread_count) || 0; list.appendChild(convItem(c, me)); });-- if (!d.conversations.length) list.appendChild(el('li', 'oc-muted', 'No conversations yet.'));++ if (!d.conversations.length) list.appendChild(el('li', 'oc-row-empty oc-muted', 'No conversations yet. Write a username above to start one.'));if (status) status.textContent = unread ? unread + ' unread' : 'All caught up';}).catch(function () {});}@@@ -14,6 +14,8 @@ const feeds = require('../feeds/feeds');const ctx = require('../live-context');const db = require('../db/database');const { esc, asset } = require('./layout');++const ui = require('./ui');++const { ICONS } = require('./icons');const THEMES = {light: '--oc-bg:#ffffff;--oc-panel:#ffffff;--oc-line:#e3e7ef;--oc-text:#101828;--oc-muted:#5b6577;--oc-accent:#2563eb;--oc-ok:#16a34a;color-scheme:light',@@@ -38,10 +40,15 @@ function embedHtml(feed, theme) {}const hookFor = (base, id) => ({ type: 'http', url: `${base}/api/chat/feeds/${id}/hooks/claude-code`, headers: { Authorization: 'Bearer $OPENVIBE_FEED_KEY' }, allowedEnvVars: ['OPENVIBE_FEED_KEY'], timeout: 5 });--/** The block for .claude/settings.json: the events a viewer may see, sent as they happen; never blocks Claude Code. */++/**++ * The block for .claude/settings.json: the events a viewer may see, sent as they happen; never blocks Claude Code. One++ * line per event (the same hook each time), so it reads at a glance and pastes as valid JSON.++ */function claudeCodeSnippet(base, id) {-- const h = [hookFor(base, id)];-- return JSON.stringify({ hooks: { SessionStart: [{ hooks: h }], UserPromptSubmit: [{ hooks: h }], PostToolUse: [{ matcher: '*', hooks: h }], Stop: [{ hooks: h }], SessionEnd: [{ hooks: h }] } }, null, 2);++ const h = JSON.stringify([hookFor(base, id)]);++ const events = [['SessionStart', `[{ "hooks": ${h} }]`], ['UserPromptSubmit', `[{ "hooks": ${h} }]`], ['PostToolUse', `[{ "matcher": "*", "hooks": ${h} }]`],++ ['Stop', `[{ "hooks": ${h} }]`], ['SessionEnd', `[{ "hooks": ${h} }]`]];++ return `{\n "hooks": {\n${events.map(([e, v]) => ` "${e}": ${v}`).join(',\n')}\n }\n}`;}const curlSnippet = (base, id) => `curl -X POST ${base}/api/chat/feeds/${id}/events \\-H "Authorization: Bearer $OPENVIBE_FEED_KEY" -H "Content-Type: application/json" \\@@@ -80,21 +87,21 @@ function mountFeedPages(router, { page, needUser, sameSite, formLimit, notice, crouter.get('/feeds', async (req, res) => {const actor = await needUser(req, res); if (!actor) return;const mine = await feeds.mine(actor.user);++ const where = (f) => [f.room_id ? 'in a room' : '', f.managed_stream_id ? 'on Live' : ''].filter(Boolean).join(' · ');const list = mine.length-- ? `<ul class="ib-list">${mine.map((f) => `<li><a href="/feeds/${esc(f.id)}"><span class="ib-dot fd-dot${f.settings.enabled && !f.settings.paused ? ' fd-on' : ''}"></span><b>${esc(f.title)}</b>--<span class="oc-muted">${f.last_event_at ? `last step ${esc(f.last_event_at.slice(0, 16).replace('T', ' '))}` : 'nothing published yet'}${f.room_id ? ' · in a room' : ''}${f.managed_stream_id ? ' · on Live' : ''}</span></a></li>`).join('')}</ul>`-- : '<p class="oc-muted">No feeds yet.</p>';++ ? `<ul class="oc-list">${mine.map((f) => { const t = ui.when(f.last_event_at); const on = f.settings.enabled && !f.settings.paused; return `<li class="oc-row"><a href="/feeds/${esc(f.id)}"><span class="oc-av oc-av-feed${on ? ' is-on' : ''}" aria-hidden="true">${ICONS.code}</span><span class="oc-row-main"><span class="oc-row-top"><strong>${esc(f.title)}</strong>${t.text ? `<time datetime="${t.iso}">${esc(t.text)}</time>` : ''}</span><span class="oc-row-sub">${on ? '' : f.settings.paused ? 'Paused · ' : 'Hidden · '}${f.last_event_at ? 'last step published' : 'nothing published yet'}${where(f) ? ` · ${where(f)}` : ''}</span></span></a></li>`; }).join('')}</ul>`++ : '';await page(req, res, 200, {-- actor, title: 'Feeds', path: '/feeds', active: 'rooms', robots: 'noindex, nofollow',-- body: `<h1>Feeds</h1>--<p class="oc-muted">Show what you are building, live. Claude Code, Copilot or any tool sends each step (a prompt, a command, a file edited, the answer) and your viewers follow along in a card: in your room, on your Live channel while you stream, or embedded on your own site. You decide what they see; secrets and home folders are wiped before anything is shown.</p>++ actor, title: 'Feeds', path: '/feeds', active: 'rooms', robots: 'noindex, nofollow', footer: 'compact',++ body: `${ui.head({ title: 'Feeds', sub: 'Your coding session, live: in a room, on your Live channel, or on your site' })}${req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : ''}${list}--<h2>Make a feed</h2>--<form class="oc-form" method="post" action="/feeds">++<details class="oc-card oc-make"${mine.length ? '' : ' open'}><summary>${ICONS.plus}<span>New feed</span></summary>++<p class="oc-muted oc-make-how">Claude Code, Copilot or any tool sends each step (a prompt, a command, a file edited, the answer), and your viewers follow along in a card. You decide what they see; secrets and home folders are wiped before anything is shown.</p>++<form class="oc-form oc-grid-form" method="post" action="/feeds"><label>Title <input name="title" required minlength="2" maxlength="80" value="Vibe coding"></label>${placeFields(await places(actor.user))}--<p><button type="submit">Make the feed</button></p></form>`,++<p><button type="submit">Make the feed</button></p></form></details>`,});});@@@ -103,7 +110,7 @@ ${placeFields(await places(actor.user))}const b = req.body || {};try {const { feed, key } = await feeds.create(actor.user, { title: b.title, room: b.room || null, slot: b.slot || null });-- await page(req, res, 201, { actor, title: `${feed.title}: key`, path: `/feeds/${feed.id}`, active: 'rooms', robots: 'noindex, nofollow', body: `<p><a href="/feeds">← Feeds</a></p><h1>${esc(feed.title)}</h1>${keyPanel(feed, key)}` });++ await page(req, res, 201, { actor, title: `${feed.title}: key`, path: `/feeds/${feed.id}`, active: 'rooms', robots: 'noindex, nofollow', footer: 'compact', body: `${ui.head({ title: esc(feed.title), sub: 'Its key, shown this once', back: { href: `/feeds/${feed.id}`, label: 'The feed' } })}${keyPanel(feed, key)}` });} catch (err) { res.redirect(303, `/feeds?error=${encodeURIComponent(err.message || 'That did not work')}`); }});@@@ -119,33 +126,46 @@ ${placeFields(await places(actor.user))}const { actor, feed } = a;const s = feed.settings;const check = (name, label) => `<label class="oc-check"><input type="checkbox" name="${name}" value="1"${s[name] ? ' checked' : ''}> ${label}</label>`;++ const t = ui.when(feed.last_event_at);++ const on = s.enabled && !s.paused;++ const where = [feed.room_id ? 'in a room' : '', feed.managed_stream_id ? 'on Live while you stream' : ''].filter(Boolean).join(' · ');++ // How to connect: one tool at a time, folded; the first one open while nothing has been published.++ const snip = (id, text) => `<div class="oc-snip"><button type="button" class="oc-icon-btn" data-oc-copy="#${id}" aria-label="Copy" title="Copy" hidden>${ICONS.copy}</button><pre class="ib-snippet" tabindex="0" id="${id}"><code>${esc(text)}</code></pre></div>`;++ const connect = (id, label, inner, open) => `<details class="oc-card oc-fold" id="${id}"${open ? ' open' : ''}><summary><span>${label}</span>${ICONS.chevron}</summary>${inner}</details>`;await page(req, res, 200, {-- actor, title: `${feed.title}: feed`, path: `/feeds/${feed.id}`, active: 'rooms', robots: 'noindex, nofollow', feedView: true,-- body: `<p><a href="/feeds">← Feeds</a></p><h1>${esc(feed.title)}</h1>++ actor, title: `${feed.title}: feed`, path: `/feeds/${feed.id}`, active: 'rooms', robots: 'noindex, nofollow', feedView: true, footer: 'compact', script: true, page: { view: 'feed' },++ body: `${ui.head({++ title: esc(feed.title),++ sub: `${on ? 'Visible' : s.paused ? 'Paused' : 'Hidden'}${t.text ? ` · last step ${esc(t.text)}` : ' · nothing published yet'}${where ? ` · ${where}` : ''}`,++ back: { href: '/feeds', label: 'All feeds' },++ actions: [`<a class="oc-icon-btn" href="#oc-feed-settings" aria-label="Where it shows and what viewers see" title="Settings">${ICONS.gear}</a>`],++ })}${req.query.saved ? notice('ok', 'Saved.') : ''}${req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : ''}<div class="fv fd-preview" data-feed="${esc(feed.id)}"></div>--<h2>Publish from Claude Code</h2>--<p>Add this to <code>.claude/settings.json</code> in a project (or <code>~/.claude/settings.json</code> for every project), and set <code>OPENVIBE_FEED_KEY</code> in the shell you start Claude Code from. Each prompt, tool used and answer becomes a step; nothing is read from your files or transcript.</p>--<pre class="ib-snippet" tabindex="0"><code>${esc(claudeCodeSnippet(base, feed.id))}</code></pre>--<h2>Publish from anything else</h2>--<p>Send events to the API with the key; <code>eventType</code> is one of ${feeds.TYPES.map((t) => `<code>${t}</code>`).join(', ')} (up to 50 at once as <code>{ "events": [...] }</code>). The OpenVibe Copilot companion speaks the same events.</p>--<pre class="ib-snippet" tabindex="0"><code>${esc(curlSnippet(base, feed.id))}</code></pre>--<p class="oc-muted">Key ending in <code>${esc(feed.key_hint || '????')}</code>.</p>--<form method="post" action="/feeds/${esc(feed.id)}/key" class="oc-inline"><button type="submit">Make a new key</button></form> <span class="oc-muted">(the old one stops working)</span>--<h2>Show it on your site</h2>--<pre class="ib-snippet" tabindex="0"><code>${esc(iframeSnippet(base, feed.id))}</code></pre>--<h2>Where it shows and what viewers see</h2>--<form class="oc-form" method="post" action="/feeds/${esc(feed.id)}/settings">++<h2 class="oc-list-head">Connect</h2>++${connect('oc-connect-claude', 'Claude Code', `<p>Add this to <code>.claude/settings.json</code> in a project (or <code>~/.claude/settings.json</code> for every project), and set <code>OPENVIBE_FEED_KEY</code> in the shell you start Claude Code from. Each prompt, tool used and answer becomes a step; nothing is read from your files or transcript.</p>++${snip('oc-snip-claude', claudeCodeSnippet(base, feed.id))}`, !feed.last_event_at)}++${connect('oc-connect-api', 'Any other tool (the API)', `<p>Send events with the key; <code>eventType</code> is one of ${feeds.TYPES.map((x) => `<code>${x}</code>`).join(', ')} (up to 50 at once as <code>{ "events": [...] }</code>). The OpenVibe Copilot companion speaks the same events.</p>++${snip('oc-snip-api', curlSnippet(base, feed.id))}`, false)}++${connect('oc-connect-embed', 'Show it on your site', snip('oc-snip-embed', iframeSnippet(base, feed.id)), false)}++<div class="oc-keyrow"><span class="oc-muted">${ICONS.key} Key ending in <code>${esc(feed.key_hint || '????')}</code></span><form method="post" action="/feeds/${esc(feed.id)}/key" class="oc-inline"><button type="submit" class="oc-pill">${ICONS.retry}<span>New key</span></button></form></div>++<details class="oc-card oc-fold" id="oc-feed-settings"${req.query.saved || req.query.error ? ' open' : ''}><summary><span>Where it shows and what viewers see</span>${ICONS.chevron}</summary>++<form class="oc-form oc-grid-form" method="post" action="/feeds/${esc(feed.id)}/settings"><label>Title <input name="title" required minlength="2" maxlength="80" value="${esc(feed.title)}"></label>${placeFields(await places(actor.user), feed)}++<fieldset class="oc-fieldset"><legend>Showing</legend>${check('enabled', 'Viewers see this feed')}${check('paused', 'Paused (steps are kept, nobody sees them until you unpause)')}++</fieldset>++<fieldset class="oc-fieldset"><legend>What viewers see</legend>${check('show_prompts', 'Prompts')}${check('show_responses', 'Answers')}${check('show_tool_calls', 'Tools used')}${check('show_tool_arguments', 'What the tools were given (commands, searches)')}${check('show_file_events', 'Files changed')}${check('redact_file_paths', 'File names only, not their folders')}${check('show_thinking', 'Thinking, when the tool sends it')}++</fieldset><label>Detail <select name="viewer_depth">${[['headline', 'Headlines'], ['standard', 'Standard'], ['deep', 'More text']].map(([v, l]) => `<option value="${v}"${s.viewer_depth === v ? ' selected' : ''}>${l}</option>`).join('')}</select></label><label>Delay before viewers see a step <select name="delay_ms">${[[0, 'None'], [10000, '10 seconds'], [30000, '30 seconds'], [60000, '1 minute'], [120000, '2 minutes']].map(([v, l]) => `<option value="${v}"${s.delay_ms === v ? ' selected' : ''}>${l}</option>`).join('')}</select></label><p><button type="submit">Save</button></p></form>--<form method="post" action="/feeds/${esc(feed.id)}/delete" class="oc-inline"><button type="submit" class="oc-link oc-danger">Delete this feed and its history</button></form>`,++<form method="post" action="/feeds/${esc(feed.id)}/delete" class="oc-inline oc-danger-row"><button type="submit" class="oc-pill oc-pill-danger">${ICONS.trash}<span>Delete this feed and its history</span></button></form>++</details>`,});});@@@ -163,7 +183,7 @@ ${check('show_file_events', 'Files changed')}${check('redact_file_paths', 'Filerouter.post('/feeds/:id/key', formLimit, sameSite, async (req, res) => {const a = await ownFeed(req, res); if (!a) return;const key = await feeds.rotateKey(a.feed);-- await page(req, res, 200, { actor: a.actor, title: `${a.feed.title}: key`, path: `/feeds/${a.feed.id}`, active: 'rooms', robots: 'noindex, nofollow', body: `<p><a href="/feeds">← Feeds</a></p><h1>${esc(a.feed.title)}</h1>${keyPanel(a.feed, key)}` });++ await page(req, res, 200, { actor: a.actor, title: `${a.feed.title}: key`, path: `/feeds/${a.feed.id}`, active: 'rooms', robots: 'noindex, nofollow', footer: 'compact', body: `${ui.head({ title: esc(a.feed.title), sub: 'Its new key, shown this once', back: { href: `/feeds/${a.feed.id}`, label: 'The feed' } })}${keyPanel(a.feed, key)}` });});router.post('/feeds/:id/delete', formLimit, sameSite, async (req, res) => {const a = await ownFeed(req, res); if (!a) return;@@@ -30,6 +30,14 @@ const ICONS = {chat: svg('<path d="M4 5h16v11H8l-4 4Z"/>'),task: svg('<rect x="4" y="4" width="16" height="16" rx="3"/><path d="m8 12 3 3 5-6"/>'),retry: svg('<path d="M4 12a8 8 0 0 1 13.7-5.7L20 8.5M20 4v4.5h-4.5M20 12a8 8 0 0 1-13.7 5.7L4 15.5M4 20v-4.5h4.5"/>'),++ pencil: svg('<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>'),++ plus: svg('<path d="M12 5v14M5 12h14"/>'),++ phone: svg('<path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2"/>'),++ ban: svg('<circle cx="12" cy="12" r="8.5"/><path d="m6 6 12 12"/>'),++ key: svg('<circle cx="8" cy="15" r="4"/><path d="m10.8 12.2 8.7-8.7M16 7l3 3M14 9l2 2"/>'),++ help: svg('<circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.8M12 17h.01"/>'),++ more: svg('<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>'),++ chevron: svg('<path d="m9 6 6 6-6 6"/>'),hand: svg('<path d="M8 13V5.5a1.5 1.5 0 0 1 3 0V11M11 10V4.5a1.5 1.5 0 0 1 3 0V11M14 10.5V6a1.5 1.5 0 0 1 3 0v7c0 4-2.5 7-6.5 7-2.6 0-4.2-1.3-5.7-3.6L3 13.6a1.5 1.5 0 0 1 2.4-1.8L8 15"/>'),};@@@ -287,6 +287,14 @@ ${parts.more}`,// ── Direct messages ──const dmApi = require('../chat/dm-routes');++ const ui = require('./ui');++ const { ICONS: IC } = require('./icons');++ /** One conversation in a list: initials, name and time on one line, the last message under it, unread marked. */++ const convRow = ({ href, title, preview, at, unread = 0, badge = '' }) => {++ const t = ui.when(at);++ return `<li class="oc-row${unread ? ' oc-unread' : ''}"><a href="${esc(href)}">${ui.initials(title)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(title)}</strong>${badge}${t.text ? `<time datetime="${t.iso}">${esc(t.text)}</time>` : ''}</span>`++ + `<span class="oc-row-sub">${esc(preview)}${unread ? `<span class="oc-dot" aria-label="${unread} new">${unread > 9 ? '9+' : unread}</span>` : ''}</span></span></a></li>`;++ };router.get('/messages', async (req, res) => {const actor = await needUser(req, res); if (!actor) return;const me = actor.user;@@@ -294,8 +302,7 @@ ${parts.more}`,const rows = (await Promise.all(convs.map(async (c) => {const others = (await dm.getParticipants(c.id)).filter((p) => p.id !== me.id);const title = c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you';-- const t = hhmm(c.last_message_at);-- return `<li class="oc-conv${c.unread_count ? ' oc-unread' : ''}"><a href="/messages/${c.id}"><strong>${esc(title)}</strong>${c.unread_count ? ` <span class="oc-badge">${c.unread_count} new</span>` : ''}<span class="oc-muted oc-last">${c.last_message ? esc(String(c.last_message).slice(0, 120)) : 'No messages yet'}</span></a>${t.text ? `<time class="oc-time" datetime="${t.iso}">${t.iso.slice(0, 10)}</time>` : ''}</li>`;++ return convRow({ href: `/messages/${c.id}`, title, preview: c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet', at: c.last_message_at, unread: Number(c.unread_count) || 0 });})));const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';const done = req.query.unblocked ? notice('ok', 'Unblocked. They can message you again, and you them.') : '';@@@ -304,14 +311,14 @@ ${parts.more}`,const unread = convs.reduce((n, c) => n + (Number(c.unread_count) || 0), 0);// Support conversations with sites' chats, where they signed in to OpenVibe (server/inbox/: theirs to follow up).const support = await require('../inbox/inbox').mySupport(me, { limit: 20 }).catch(() => []);-- const supportList = support.length ? `<h2>Support</h2><ul class="oc-convs">${support.map((c) => { const t = hhmm(c.last_message_at); return `<li class="oc-conv"><a href="/support/${esc(c.id)}"><strong>${esc(c.inbox.name)} <span class="oc-badge">${esc({ bot: 'Vibe', waiting: 'Waiting', open: 'Open', resolved: 'Resolved' }[c.status] || c.status)}</span></strong><span class="oc-muted oc-last">${esc(c.preview || '')}</span></a>${t.text ? `<time class="oc-time" datetime="${t.iso}">${t.iso.slice(0, 10)}</time>` : ''}</li>`; }).join('')}</ul>` : '';++ const STATUS = { bot: 'Vibe', waiting: 'Waiting', open: 'Open', resolved: 'Resolved' };++ const supportList = support.length ? `<h2 class="oc-list-head">Support</h2><ul class="oc-list">${support.map((c) => convRow({ href: `/support/${c.id}`, title: c.inbox.name, preview: c.preview || '', at: c.last_message_at, badge: ` <span class="oc-badge">${esc(STATUS[c.status] || c.status)}</span>` })).join('')}</ul>` : '';await page(req, res, 200, {actor, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'compact',page: { view: 'inbox', unread, me: me.id },-- body: `<h1>Messages</h1>${error}${done}--<p class="oc-muted" id="oc-inbox-status" aria-live="polite">${unread ? `${unread} unread` : 'All caught up'}</p>--<form class="oc-new" method="post" action="/messages/new"><label for="oc-to">Message someone</label> <input id="oc-to" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username" autocomplete="off"> <button type="submit">Start</button></form>--<ul class="oc-convs" id="oc-convs">${rows.join('\n') || '<li class="oc-muted">No conversations yet.</li>'}</ul>++ body: `${ui.head({ title: 'Messages', sub: `<span id="oc-inbox-status" aria-live="polite">${unread ? `${unread} unread` : 'All caught up'}</span>` })}${error}${done}++<form class="oc-find" method="post" action="/messages/new" role="search">${IC.pencil}<label for="oc-to" class="oc-sr">New message: their username</label><input id="oc-to" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="New message: their username" autocomplete="off" enterkeyhint="go"><button type="submit" class="oc-send" aria-label="Start the conversation" title="Start">${IC.send}</button></form>++<ul class="oc-list" id="oc-convs">${rows.join('\n') || '<li class="oc-row-empty oc-muted">No conversations yet. Write a username above to start one.</li>'}</ul>${supportList}${blockedList}`,});@@@ -355,19 +362,20 @@ ${blockedList}`,const iBlocked = other ? await dm.hasBlocked(me.id, other.id) : false;const closed = other ? await dm.isBlockedEither(me.id, other.id) : false;const otherName = other ? esc(other.display_name || other.username) : '';-- let composer = `<form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Write a message"></textarea><button type="submit">Send</button></form>`;++ let composer = `<div class="oc-dock"><form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="1" required placeholder="Write a message"></textarea><button type="submit" class="oc-send" aria-label="Send" title="Send">${IC.send}</button></form></div>`;if (iBlocked) composer = `${notice('error', `You blocked ${other.display_name || other.username}. Neither of you can message the other.`)}<form class="oc-inline" method="post" action="/messages/unblock/${Number(other.id)}"><input type="hidden" name="back" value="/messages/${id}"><button type="submit">Unblock ${otherName}</button></form>`;else if (closed) composer = notice('error', 'You cannot message this person.');-- const tools = other && !iBlocked ? `<form class="oc-inline oc-tools" method="post" action="/messages/${id}/block"><button type="submit" class="oc-link">Block ${otherName}</button></form>` : '';++ // Blocking is in the conversation's "more" menu: one tap on an icon in the header should never block someone.++ const tools = other && !iBlocked ? `<details class="oc-more"><summary class="oc-icon-btn" aria-label="More" title="More">${IC.more}</summary><div class="oc-pop"><form method="post" action="/messages/${id}/block"><button type="submit" class="oc-pop-item oc-danger">${IC.ban}<span>Block ${otherName}</span></button></form></div></details>` : '';// A 1:1 conversation nobody blocked can become a call (POST /messages/:id/call rings the other person).const callForm = other && !closed && require('../config').calls.enabled-- ? `<form class="oc-inline oc-dm-call" method="post" action="/messages/${id}/call"><button type="submit" class="oc-button--quiet">Call ${otherName}</button></form>` : '';++ ? `<form class="oc-inline oc-dm-call" method="post" action="/messages/${id}/call"><button type="submit" class="oc-icon-btn" aria-label="Call ${otherName}" title="Call ${otherName}">${IC.phone}</button></form>` : '';await page(req, res, 200, {actor, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'compact',page: { view: 'dm', conversation: id, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me.id },-- body: `<p><a href="/messages">← Messages</a></p><div class="oc-dm-head"><h1>${esc(title)}</h1>${callForm}</div>${error}--<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => dmMessage(m, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>--${composer}${tools}`,++ body: `${ui.head({ title: esc(title), sub: other ? `@${esc(other.username || '')}` : `${others.length + 1} people`, back: { href: '/messages', label: 'All messages' }, actions: [callForm, tools].filter(Boolean) })}${error}++<ol class="oc-feed oc-room-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => dmMessage(m, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>++${composer}`,});});@@@ -599,7 +607,9 @@ ${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete">const KIND_LABEL = { community: '', call: 'call', system: 'announcements' };const ROLE_LABEL = { owner: 'owner', mod: 'moderator', speaker: 'speaker', participant: 'participant', member: 'member', viewer: 'viewer', blocked: 'blocked' };const kindBadge = (r) => (KIND_LABEL[r.kind] ? ` <span class="oc-badge oc-kind-${esc(r.kind)}">${KIND_LABEL[r.kind]}</span>` : '');-- const roomCard = (r) => `<li class="oc-conv${r.unread ? ' oc-unread' : ''}"><a href="/r/${esc(r.slug)}"><strong>${esc(r.name)}</strong>${kindBadge(r)}${r.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}${r.unread ? ` <span class="oc-badge">${r.unread} new</span>` : ''}<span class="oc-muted oc-last">${esc(r.topic || `${r.members} member${r.members === 1 ? '' : 's'}`)}</span></a></li>`;++ // One room in a list: its initials, name with its kind and a lock when private, the topic or the members under it.++ const roomCard = (r) => `<li class="oc-row${r.unread ? ' oc-unread' : ''}"><a href="/r/${esc(r.slug)}">${ui.initials(r.name)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(r.name)}</strong>${kindBadge(r)}${r.visibility === 'private' ? `<span class="oc-row-lock" title="Private">${IC.lock}<span class="oc-sr">private</span></span>` : ''}</span>`++ + `<span class="oc-row-sub">${esc(r.topic || `${r.members} member${r.members === 1 ? '' : 's'}`)}${r.unread ? `<span class="oc-dot" aria-label="${r.unread} new">${r.unread > 9 ? '9+' : r.unread}</span>` : ''}</span></span></a></li>`;const roomMessage = (m, can, slug, me) => {const t = hhmm(m.created_at);const ai = m.ai_member_id != null;@@@ -637,11 +647,11 @@ ${staff ? '<label class="oc-check"><input type="radio" name="kind" value="system<p><button type="submit">Create room</button></p></form>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${wantWork ? '%2Frooms%3Fnew%3Dworkspace' : '%2Frooms'}">Sign in with OpenVibe</a> to start or join a room.</p>`;await page(req, res, 200, {-- actor, title: 'Rooms', path: '/rooms', active: 'rooms', robots: 'index, follow',++ actor, title: 'Rooms', path: '/rooms', active: 'rooms', robots: 'index, follow', footer: 'compact',description: 'Chat and call rooms on OpenVibe: start one about anything, invite people, or join a public room.',-- body: `<h1>Rooms</h1>${error}--${mine.length ? `<h2>Your rooms</h2><ul class="oc-convs">${mine.map(roomCard).join('')}</ul>` : ''}--<h2>Public rooms</h2>${pub.length ? `<ul class="oc-convs">${pub.map(roomCard).join('')}</ul>` : `<div class="oc-rooms-empty">++ body: `${ui.head({ title: 'Rooms', sub: 'Group chats, calls and workspaces with Vibe', actions: me ? [`<a class="oc-icon-btn" href="#oc-new-room" aria-label="Start a room" title="Start a room">${IC.plus}</a>`] : [] })}${error}++${mine.length ? `<h2 class="oc-list-head">Your rooms</h2><ul class="oc-list">${mine.map(roomCard).join('')}</ul>` : ''}++<h2 class="oc-list-head">Public rooms</h2>${pub.length ? `<ul class="oc-list">${pub.map(roomCard).join('')}</ul>` : `<div class="oc-rooms-empty"><p><strong>No public room is open right now: start the first one below.</strong> A room can be about anything, private to the people you invite, a voice call, or a group chat with AI members who know the context.</p><p>Meanwhile the conversation is here:</p><ul class="oc-goto"><li><a href="/">Global chat</a>: everyone on OpenVibe, readable without an account</li>nnew file mode 100644@@@ -0,0 +1,47 @@++'use strict';++/**++ * Pieces every working page on openvibe.chat shares (the owner, 2026-10-28: "icon buttons … so much wasted space … the++ * alignment's kind of bad … not a focus on the real content"): one compact page header with its actions as icons, times++ * people read at a glance, and a person's initials when there is no picture.++ *++ * head({ title, sub, back, actions }) <header class="oc-page-head">: back icon, title and one muted line, icon actions++ * when(ts, now) { iso, text }: 14:05 today, Yesterday, Mon this week, Oct 20 this year, else 2025-10-20++ * initials(name) a round mark with up to two letters, its colour from the name++ */++const { esc } = require('./layout');++const { ICONS } = require('./icons');++++function head({ title, sub = '', back = null, actions = [] }) {++ return `<header class="oc-page-head">++${back ? `<a class="oc-icon-btn oc-back" href="${esc(back.href)}" aria-label="${esc(back.label)}" title="${esc(back.label)}">${ICONS.back}</a>` : ''}<div class="oc-page-id"><h1>${title}</h1>${sub ? `<p class="oc-page-sub">${sub}</p>` : ''}</div>++${actions.length ? `<div class="oc-page-tools">${actions.join('')}</div>` : ''}++</header>`;++}++++const DAY = 86_400_000;++const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];++const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];++function when(ts, now = Date.now()) {++ const s = String(ts || '');++ const t = Date.parse(s.includes('T') ? s : `${s.replace(' ', 'T')}Z`);++ if (!Number.isFinite(t)) return { iso: '', text: '' };++ const d = new Date(t);++ const startToday = Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate());++ let text;++ if (t >= startToday) text = d.toISOString().slice(11, 16);++ else if (t >= startToday - DAY) text = 'Yesterday';++ else if (t >= startToday - 6 * DAY) text = WEEKDAYS[d.getUTCDay()];++ else if (d.getUTCFullYear() === new Date(now).getUTCFullYear()) text = `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`;++ else text = d.toISOString().slice(0, 10);++ return { iso: d.toISOString(), text };++}++++function initials(name) {++ const words = String(name || '?').replace(/[^\p{L}\p{N} ]/gu, ' ').trim().split(/\s+/).filter(Boolean);++ const letters = (words.length > 1 ? words[0][0] + words[1][0] : (words[0] || '?').slice(0, 2)).toUpperCase();++ let h = 0;++ for (const ch of String(name || '')) h = (h * 31 + ch.codePointAt(0)) % 360;++ return `<span class="oc-av" style="--av:${h}" aria-hidden="true">${esc(letters)}</span>`;++}++++module.exports = { head, when, initials };@@@ -72,7 +72,7 @@ t('a 1:1 conversation has a Call button; a group or a blocked one has none; signconv = await dm.getOrCreateDirect(alice.id, bob.id);const page = await req('GET', `/messages/${conv}`, { headers: cookie(alice) });assert.strictEqual(page.status, 200);-- assert.match(page.text, new RegExp(`<form class="oc-inline oc-dm-call" method="post" action="/messages/${conv}/call"><button type="submit" class="oc-button--quiet">Call Bob</button></form>`, 'i'));++ assert.match(page.text, new RegExp(`<form class="oc-inline oc-dm-call" method="post" action="/messages/${conv}/call"><button type="submit" class="oc-icon-btn" aria-label="Call Bob"`, 'i'));assert.match(page.text, /<script src="\/web\/ring\.js\?v=[^"]+" defer><\/script>/);assert.doesNotMatch((await req('GET', '/')).text, /ring\.js/, 'no ring listener for a guest');const group = await dm.createConversation(alice.id, [alice.id, bob.id, carol.id], 'Trio');@@@ -41,7 +41,7 @@ t('the inbox: your conversations only, unread counts in the list and the navigatawait req('POST', `/messages/${conv}`, form(alice, { message: 'are you there?' }));let inbox = await req('GET', '/messages', { headers: cookie(bob) });assert.strictEqual(inbox.status, 200);-- assert.match(inbox.text, /<span class="oc-badge">2 new<\/span>/);++ assert.match(inbox.text, /<span class="oc-dot" aria-label="2 new">2<\/span>/);assert.match(inbox.text, /id="oc-inbox-status"[^>]*>2 unread</);assert.strictEqual(navLabel(inbox.text, '/messages'), 'Messages (2)');assert.ok(inbox.text.includes('are you there?'), 'the last message as a preview');@@@ -82,7 +82,7 @@ t('participants only: a guessed conversation looks like no conversation', asynct('block: the conversation closes both ways; the inbox lists who you blocked; unblock opens it again', async () => {assert.strictEqual((await req('POST', `/messages/${conv}/block`, form(bob, {}, { origin: 'https://evil.example' }))).status, 403, 'forms from elsewhere are refused');let page = await req('GET', `/messages/${conv}`, { headers: cookie(bob) });-- assert.match(page.text, /Block Alice<\/button>/);++ assert.match(page.text, /<button type="submit" class="oc-pop-item oc-danger">.*<span>Block Alice<\/span><\/button>/);let r = await req('POST', `/messages/${conv}/block`, form(bob, {}));assert.strictEqual(r.headers.get('location'), `/messages/${conv}`);@@@ -412,7 +412,7 @@ t('a visitor signed in to OpenVibe: their conversation is theirs, in their Messaassert.strictEqual((await h.db.get('SELECT visitor_user_id FROM inbox_conversations WHERE id = ?', [spoof.body.conversation.id])).visitor_user_id, null, 'an account in the body counts for nothing');const get = (path, u) => fetch(`${h.base}${path}`, { headers: { cookie: `ov_token=${u.token}` }, redirect: 'manual' }).then(async (r) => ({ status: r.status, text: await r.text() }));const messages = await get('/messages', carol);-- assert.ok(messages.text.includes('<h2>Support</h2>') && messages.text.includes(`href="/support/${cid}"`) && messages.text.includes('Acme'));++ assert.ok(messages.text.includes('<h2 class="oc-list-head">Support</h2>') && messages.text.includes(`href="/support/${cid}"`) && messages.text.includes('Acme'));const sp = await get(`/support/${cid}`, carol);assert.ok(sp.status === 200 && sp.text.includes('Before signing in') && sp.text.includes('support.js'));assert.strictEqual((await get(`/support/${cid}`, bob)).status, 404, 'only theirs');
@@ -494,3 +494,73 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -494,3 +494,73 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@media (max-width: 560px) {
@media (max-width: 560px) {
.oc-room-feed { height: max(300px, calc(100svh - 250px)); }
.oc-room-feed { height: max(300px, calc(100svh - 250px)); }
}
}
+
+/* Working pages (server/web/ui.js): one compact header (back, title, one muted line, icon actions), lists that read like
+ a phone's (a mark, the name and time on one line, the last thing under it), a search-style row for "start one",
+ and cards that fold away what is only needed once (setup, settings). */
+.oc-page-head { display: flex; align-items: center; gap: 6px; margin: 0 0 12px; min-height: 44px; }
+.oc-page-head > .oc-back { margin-left: -8px; }
+.oc-page-id { flex: 1; min-width: 0; }
+.oc-page-id h1 { margin: 0; font-size: clamp(1.15rem, 4vw, 1.45rem); line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.oc-page-sub { margin: 2px 0 0; color: var(--oc-muted); font-size: .86rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.oc-page-tools { display: flex; gap: 2px; margin-right: -8px; }
+.oc-page-tools form { margin: 0; }
+.oc-list { list-style: none; margin: 0 0 14px; padding: 0; border: 1px solid var(--oc-line); border-radius: 14px; background: var(--oc-panel); overflow: hidden; display: grid; grid-template-columns: minmax(0, 1fr); }
+.oc-list-head { margin: 18px 0 8px; font-size: .78rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--oc-muted); }
+.oc-row + .oc-row { border-top: 1px solid var(--oc-line); }
+.oc-row > a { display: flex; align-items: center; gap: 12px; padding: 10px 14px; color: var(--oc-text); text-decoration: none; min-width: 0; }
+.oc-row > a:hover { background: var(--oc-panel-2); }
+.oc-row-main { flex: 1; min-width: 0; display: grid; gap: 1px; }
+.oc-row-top { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
+.oc-row-top strong { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 650; }
+.oc-row-top time { flex: none; color: var(--oc-muted); font-size: .78rem; font-variant-numeric: tabular-nums; }
+.oc-row-top .oc-badge { flex: none; margin-left: 0; }
+.oc-row-sub { display: block; min-width: 0; color: var(--oc-muted); font-size: .9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.oc-row-sub .oc-dot { float: right; margin-left: 8px; }
+.oc-unread .oc-row-top strong { font-weight: 800; }
+.oc-unread .oc-row-sub { color: var(--oc-text); }
+.oc-dot { display: inline-grid; place-items: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; background: var(--accent-strong, var(--oc-accent)); color: var(--on-accent-strong, var(--oc-on-accent)); font-size: .72rem; font-weight: 800; }
+.oc-row-empty { padding: 16px 14px; }
+.oc-av { flex: none; display: inline-grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; font-size: .85rem; font-weight: 800; letter-spacing: .02em; color: #fff; background: hsl(var(--av, 210) 45% 38%); }
+.oc-av-feed { background: var(--oc-panel-2); color: var(--oc-muted); border: 1px solid var(--oc-line); }
+.oc-av-feed.is-on { color: var(--oc-ok); border-color: color-mix(in srgb, var(--oc-ok) 45%, transparent); }
+.oc-find { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; padding: 4px 4px 4px 14px; border: 1px solid var(--oc-line); border-radius: 999px; background: var(--oc-panel); }
+.oc-find:focus-within { border-color: var(--oc-accent); }
+.oc-find > .oc-i { color: var(--oc-muted); }
+.oc-find input { flex: 1; min-width: 0; border: 0; background: transparent; padding: 8px 0; outline: none; }
+.oc-find .oc-send { width: 38px; height: 38px; }
+.oc-card { margin: 0 0 10px; padding: 0; border: 1px solid var(--oc-line); border-radius: 14px; background: var(--oc-panel); }
+.oc-card > summary { display: flex; align-items: center; gap: 10px; padding: 12px 14px; cursor: pointer; list-style: none; font-weight: 650; }
+.oc-card > summary::-webkit-details-marker { display: none; }
+.oc-card > summary > span { flex: 1; }
+.oc-fold > summary > .oc-i { color: var(--oc-muted); transition: transform .15s; }
+.oc-fold[open] > summary > .oc-i { transform: rotate(90deg); }
+.oc-card[open] > summary { border-bottom: 1px solid var(--oc-line); }
+.oc-card > :not(summary) { margin-left: 14px; margin-right: 14px; }
+.oc-card > :not(summary):last-child { margin-bottom: 14px; }
+.oc-card > p:first-of-type, .oc-card > form:first-of-type, .oc-card > pre:first-of-type { margin-top: 12px; }
+.oc-make > summary { color: var(--oc-accent); }
+.oc-make-how { font-size: .9rem; }
+.oc-grid-form { max-width: none; }
+.oc-grid-form label { display: grid; gap: 4px; font-size: .9rem; color: var(--oc-muted); }
+.oc-grid-form label.oc-check { display: flex; color: var(--oc-text); }
+.oc-grid-form input:not([type=checkbox]):not([type=radio]), .oc-grid-form select { width: 100%; margin-left: 0; max-width: none; color: var(--oc-text); }
+.oc-keyrow { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin: 4px 0 12px; }
+.oc-keyrow .oc-i { vertical-align: -.2em; }
+.oc-danger-row { display: block; margin: 0 14px 14px; }
+@media (prefers-reduced-motion: reduce) { .oc-fold > summary > .oc-i { transition: none; } }
+/* A code block people copy (a feed's setup): its copy button in the corner, and never taller than a screenful. */
+.oc-snip { position: relative; }
+.oc-snip > .oc-icon-btn { position: absolute; top: 6px; right: 6px; z-index: 1; width: 34px; height: 34px; background: var(--oc-panel); border-color: var(--oc-line); }
+.oc-snip > pre { max-height: 50vh; overflow: auto; padding-right: 48px; }
+/* A header's "more" menu (a conversation's Block): a small panel under its button. */
+.oc-more { position: relative; }
+.oc-more > summary { list-style: none; }
+.oc-more > summary::-webkit-details-marker { display: none; }
+.oc-pop { position: absolute; right: 0; top: calc(100% + 4px); z-index: 20; min-width: 200px; padding: 6px; border-radius: 12px; border: 1px solid var(--oc-line); background: var(--oc-panel); box-shadow: 0 12px 28px rgba(0, 0, 0, .35); }
+.oc-pop form { margin: 0; }
+.oc-pop-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 10px; border-radius: 8px; background: transparent; color: var(--oc-text); font-weight: 600; text-align: left; }
+.oc-pop-item:hover { background: var(--oc-panel-2); }
+.oc-pop-item.oc-danger { color: var(--oc-red); }
+.oc-row-lock { display: inline-flex; color: var(--oc-muted); flex: none; }
+.oc-row-lock .oc-i { width: .9em; height: .9em; }
@@ -370,18 +370,42 @@
@@ -370,18 +370,42 @@
}
}
// ── The inbox: conversations and unread counts, refreshed while the page is open ──
// ── The inbox: conversations and unread counts, refreshed while the page is open ──
+ // When a message was, at a glance (server/web/ui.js when()): 14:05 today, Yesterday, a weekday this week, Oct 20, or the date.
+ var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+ var WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+ function whenText(d) {
+ var now = new Date(), DAY = 86400000;
+ var startToday = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), t = d.getTime();
+ if (t >= startToday) return d.toISOString().slice(11, 16);
+ if (t >= startToday - DAY) return 'Yesterday';
+ if (t >= startToday - 6 * DAY) return WEEKDAYS[d.getUTCDay()];
+ if (d.getUTCFullYear() === now.getUTCFullYear()) return MONTHS[d.getUTCMonth()] + ' ' + d.getUTCDate();
+ return d.toISOString().slice(0, 10);
+ }
+ // A person's initials in a round mark, its colour from the name (server/web/ui.js initials()).
+ function initialsMark(name) {
+ var words = String(name || '?').replace(/[^\p{L}\p{N} ]/gu, ' ').trim().split(/\s+/).filter(Boolean);
+ var letters = (words.length > 1 ? words[0][0] + words[1][0] : (words[0] || '?').slice(0, 2)).toUpperCase();
+ var h = 0;
+ for (var ch of String(name || '')) h = (h * 31 + ch.codePointAt(0)) % 360;
+ var m = el('span', 'oc-av', letters); m.style.setProperty('--av', String(h)); m.setAttribute('aria-hidden', 'true');
+ return m;
+ }
function convItem(c, me) {
function convItem(c, me) {
- var li = el('li', 'oc-conv' + (c.unread_count ? ' oc-unread' : ''));
+ var unread = Number(c.unread_count) || 0;
+ var li = el('li', 'oc-row' + (unread ? ' oc-unread' : ''));
var a = el('a'); a.href = '/messages/' + encodeURIComponent(c.id);
var a = el('a'); a.href = '/messages/' + encodeURIComponent(c.id);
var others = (c.participants || []).filter(function (p) { return p.id !== me; });
var others = (c.participants || []).filter(function (p) { return p.id !== me; });
- a.appendChild(el('strong', null, c.name || others.map(function (p) { return p.display_name || p.username; }).join(', ') || 'Just you'));
- if (c.unread_count) { a.appendChild(document.createTextNode(' ')); a.appendChild(el('span', 'oc-badge', c.unread_count + ' new')); }
- a.appendChild(el('span', 'oc-muted oc-last', c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet'));
- li.appendChild(a);
+ var title = c.name || others.map(function (p) { return p.display_name || p.username; }).join(', ') || 'Just you';
+ a.appendChild(initialsMark(title));
+ var main = el('span', 'oc-row-main'), top = el('span', 'oc-row-top'), sub = el('span', 'oc-row-sub', c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet');
+ top.appendChild(el('strong', null, title));
if (c.last_message_at) {
if (c.last_message_at) {
var d = new Date(String(c.last_message_at).indexOf('T') < 0 ? String(c.last_message_at).replace(' ', 'T') + 'Z' : c.last_message_at);
var d = new Date(String(c.last_message_at).indexOf('T') < 0 ? String(c.last_message_at).replace(' ', 'T') + 'Z' : c.last_message_at);
- if (!isNaN(d)) { var t = el('time', 'oc-time', d.toISOString().slice(0, 10)); t.dateTime = d.toISOString(); li.appendChild(t); }
+ if (!isNaN(d)) { var t = el('time', null, whenText(d)); t.dateTime = d.toISOString(); top.appendChild(t); }
}
}
+ if (unread) { var dot = el('span', 'oc-dot', unread > 9 ? '9+' : String(unread)); dot.setAttribute('aria-label', unread + ' new'); sub.appendChild(dot); }
+ main.appendChild(top); main.appendChild(sub); a.appendChild(main); li.appendChild(a);
return li;
return li;
}
}
function startInbox() {
function startInbox() {
@@ -397,7 +421,7 @@
@@ -397,7 +421,7 @@
var unread = 0;
var unread = 0;
list.textContent = '';
list.textContent = '';
d.conversations.forEach(function (c) { unread += Number(c.unread_count) || 0; list.appendChild(convItem(c, me)); });
d.conversations.forEach(function (c) { unread += Number(c.unread_count) || 0; list.appendChild(convItem(c, me)); });
- if (!d.conversations.length) list.appendChild(el('li', 'oc-muted', 'No conversations yet.'));
+ if (!d.conversations.length) list.appendChild(el('li', 'oc-row-empty oc-muted', 'No conversations yet. Write a username above to start one.'));
if (status) status.textContent = unread ? unread + ' unread' : 'All caught up';
if (status) status.textContent = unread ? unread + ' unread' : 'All caught up';
}).catch(function () {});
}).catch(function () {});
}
}
@@ -14,6 +14,8 @@ const feeds = require('../feeds/feeds');
@@ -14,6 +14,8 @@ const feeds = require('../feeds/feeds');
const ctx = require('../live-context');
const ctx = require('../live-context');
const db = require('../db/database');
const db = require('../db/database');
const { esc, asset } = require('./layout');
const { esc, asset } = require('./layout');
+const ui = require('./ui');
+const { ICONS } = require('./icons');
const THEMES = {
const THEMES = {
light: '--oc-bg:#ffffff;--oc-panel:#ffffff;--oc-line:#e3e7ef;--oc-text:#101828;--oc-muted:#5b6577;--oc-accent:#2563eb;--oc-ok:#16a34a;color-scheme:light',
light: '--oc-bg:#ffffff;--oc-panel:#ffffff;--oc-line:#e3e7ef;--oc-text:#101828;--oc-muted:#5b6577;--oc-accent:#2563eb;--oc-ok:#16a34a;color-scheme:light',
@@ -38,10 +40,15 @@ function embedHtml(feed, theme) {
@@ -38,10 +40,15 @@ function embedHtml(feed, theme) {
}
}
const hookFor = (base, id) => ({ type: 'http', url: `${base}/api/chat/feeds/${id}/hooks/claude-code`, headers: { Authorization: 'Bearer $OPENVIBE_FEED_KEY' }, allowedEnvVars: ['OPENVIBE_FEED_KEY'], timeout: 5 });
const hookFor = (base, id) => ({ type: 'http', url: `${base}/api/chat/feeds/${id}/hooks/claude-code`, headers: { Authorization: 'Bearer $OPENVIBE_FEED_KEY' }, allowedEnvVars: ['OPENVIBE_FEED_KEY'], timeout: 5 });
-/** The block for .claude/settings.json: the events a viewer may see, sent as they happen; never blocks Claude Code. */
+/**
+ * The block for .claude/settings.json: the events a viewer may see, sent as they happen; never blocks Claude Code. One
+ * line per event (the same hook each time), so it reads at a glance and pastes as valid JSON.
+ */
function claudeCodeSnippet(base, id) {
function claudeCodeSnippet(base, id) {
- const h = [hookFor(base, id)];
- return JSON.stringify({ hooks: { SessionStart: [{ hooks: h }], UserPromptSubmit: [{ hooks: h }], PostToolUse: [{ matcher: '*', hooks: h }], Stop: [{ hooks: h }], SessionEnd: [{ hooks: h }] } }, null, 2);
+ const h = JSON.stringify([hookFor(base, id)]);
+ const events = [['SessionStart', `[{ "hooks": ${h} }]`], ['UserPromptSubmit', `[{ "hooks": ${h} }]`], ['PostToolUse', `[{ "matcher": "*", "hooks": ${h} }]`],
+ ['Stop', `[{ "hooks": ${h} }]`], ['SessionEnd', `[{ "hooks": ${h} }]`]];
+ return `{\n "hooks": {\n${events.map(([e, v]) => ` "${e}": ${v}`).join(',\n')}\n }\n}`;
}
}
const curlSnippet = (base, id) => `curl -X POST ${base}/api/chat/feeds/${id}/events \\
const curlSnippet = (base, id) => `curl -X POST ${base}/api/chat/feeds/${id}/events \\
-H "Authorization: Bearer $OPENVIBE_FEED_KEY" -H "Content-Type: application/json" \\
-H "Authorization: Bearer $OPENVIBE_FEED_KEY" -H "Content-Type: application/json" \\
@@ -80,21 +87,21 @@ function mountFeedPages(router, { page, needUser, sameSite, formLimit, notice, c
@@ -80,21 +87,21 @@ function mountFeedPages(router, { page, needUser, sameSite, formLimit, notice, c
router.get('/feeds', async (req, res) => {
router.get('/feeds', async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const mine = await feeds.mine(actor.user);
const mine = await feeds.mine(actor.user);
+ const where = (f) => [f.room_id ? 'in a room' : '', f.managed_stream_id ? 'on Live' : ''].filter(Boolean).join(' · ');
const list = mine.length
const list = mine.length
- ? `<ul class="ib-list">${mine.map((f) => `<li><a href="/feeds/${esc(f.id)}"><span class="ib-dot fd-dot${f.settings.enabled && !f.settings.paused ? ' fd-on' : ''}"></span><b>${esc(f.title)}</b>
-<span class="oc-muted">${f.last_event_at ? `last step ${esc(f.last_event_at.slice(0, 16).replace('T', ' '))}` : 'nothing published yet'}${f.room_id ? ' · in a room' : ''}${f.managed_stream_id ? ' · on Live' : ''}</span></a></li>`).join('')}</ul>`
- : '<p class="oc-muted">No feeds yet.</p>';
+ ? `<ul class="oc-list">${mine.map((f) => { const t = ui.when(f.last_event_at); const on = f.settings.enabled && !f.settings.paused; return `<li class="oc-row"><a href="/feeds/${esc(f.id)}"><span class="oc-av oc-av-feed${on ? ' is-on' : ''}" aria-hidden="true">${ICONS.code}</span><span class="oc-row-main"><span class="oc-row-top"><strong>${esc(f.title)}</strong>${t.text ? `<time datetime="${t.iso}">${esc(t.text)}</time>` : ''}</span><span class="oc-row-sub">${on ? '' : f.settings.paused ? 'Paused · ' : 'Hidden · '}${f.last_event_at ? 'last step published' : 'nothing published yet'}${where(f) ? ` · ${where(f)}` : ''}</span></span></a></li>`; }).join('')}</ul>`
+ : '';
await page(req, res, 200, {
await page(req, res, 200, {
- actor, title: 'Feeds', path: '/feeds', active: 'rooms', robots: 'noindex, nofollow',
- body: `<h1>Feeds</h1>
-<p class="oc-muted">Show what you are building, live. Claude Code, Copilot or any tool sends each step (a prompt, a command, a file edited, the answer) and your viewers follow along in a card: in your room, on your Live channel while you stream, or embedded on your own site. You decide what they see; secrets and home folders are wiped before anything is shown.</p>
+ actor, title: 'Feeds', path: '/feeds', active: 'rooms', robots: 'noindex, nofollow', footer: 'compact',
+ body: `${ui.head({ title: 'Feeds', sub: 'Your coding session, live: in a room, on your Live channel, or on your site' })}
${req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : ''}
${req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : ''}
${list}
${list}
-<h2>Make a feed</h2>
-<form class="oc-form" method="post" action="/feeds">
+<details class="oc-card oc-make"${mine.length ? '' : ' open'}><summary>${ICONS.plus}<span>New feed</span></summary>
+<p class="oc-muted oc-make-how">Claude Code, Copilot or any tool sends each step (a prompt, a command, a file edited, the answer), and your viewers follow along in a card. You decide what they see; secrets and home folders are wiped before anything is shown.</p>
+<form class="oc-form oc-grid-form" method="post" action="/feeds">
<label>Title <input name="title" required minlength="2" maxlength="80" value="Vibe coding"></label>
<label>Title <input name="title" required minlength="2" maxlength="80" value="Vibe coding"></label>
${placeFields(await places(actor.user))}
${placeFields(await places(actor.user))}
-<p><button type="submit">Make the feed</button></p></form>`,
+<p><button type="submit">Make the feed</button></p></form></details>`,
});
});
});
});
@@ -103,7 +110,7 @@ ${placeFields(await places(actor.user))}
@@ -103,7 +110,7 @@ ${placeFields(await places(actor.user))}
const b = req.body || {};
const b = req.body || {};
try {
try {
const { feed, key } = await feeds.create(actor.user, { title: b.title, room: b.room || null, slot: b.slot || null });
const { feed, key } = await feeds.create(actor.user, { title: b.title, room: b.room || null, slot: b.slot || null });
- await page(req, res, 201, { actor, title: `${feed.title}: key`, path: `/feeds/${feed.id}`, active: 'rooms', robots: 'noindex, nofollow', body: `<p><a href="/feeds">← Feeds</a></p><h1>${esc(feed.title)}</h1>${keyPanel(feed, key)}` });
+ await page(req, res, 201, { actor, title: `${feed.title}: key`, path: `/feeds/${feed.id}`, active: 'rooms', robots: 'noindex, nofollow', footer: 'compact', body: `${ui.head({ title: esc(feed.title), sub: 'Its key, shown this once', back: { href: `/feeds/${feed.id}`, label: 'The feed' } })}${keyPanel(feed, key)}` });
} catch (err) { res.redirect(303, `/feeds?error=${encodeURIComponent(err.message || 'That did not work')}`); }
} catch (err) { res.redirect(303, `/feeds?error=${encodeURIComponent(err.message || 'That did not work')}`); }
});
});
@@ -119,33 +126,46 @@ ${placeFields(await places(actor.user))}
@@ -119,33 +126,46 @@ ${placeFields(await places(actor.user))}
const { actor, feed } = a;
const { actor, feed } = a;
const s = feed.settings;
const s = feed.settings;
const check = (name, label) => `<label class="oc-check"><input type="checkbox" name="${name}" value="1"${s[name] ? ' checked' : ''}> ${label}</label>`;
const check = (name, label) => `<label class="oc-check"><input type="checkbox" name="${name}" value="1"${s[name] ? ' checked' : ''}> ${label}</label>`;
+ const t = ui.when(feed.last_event_at);
+ const on = s.enabled && !s.paused;
+ const where = [feed.room_id ? 'in a room' : '', feed.managed_stream_id ? 'on Live while you stream' : ''].filter(Boolean).join(' · ');
+ // How to connect: one tool at a time, folded; the first one open while nothing has been published.
+ const snip = (id, text) => `<div class="oc-snip"><button type="button" class="oc-icon-btn" data-oc-copy="#${id}" aria-label="Copy" title="Copy" hidden>${ICONS.copy}</button><pre class="ib-snippet" tabindex="0" id="${id}"><code>${esc(text)}</code></pre></div>`;
+ const connect = (id, label, inner, open) => `<details class="oc-card oc-fold" id="${id}"${open ? ' open' : ''}><summary><span>${label}</span>${ICONS.chevron}</summary>${inner}</details>`;
await page(req, res, 200, {
await page(req, res, 200, {
- actor, title: `${feed.title}: feed`, path: `/feeds/${feed.id}`, active: 'rooms', robots: 'noindex, nofollow', feedView: true,
- body: `<p><a href="/feeds">← Feeds</a></p><h1>${esc(feed.title)}</h1>
+ actor, title: `${feed.title}: feed`, path: `/feeds/${feed.id}`, active: 'rooms', robots: 'noindex, nofollow', feedView: true, footer: 'compact', script: true, page: { view: 'feed' },
+ body: `${ui.head({
+ title: esc(feed.title),
+ sub: `${on ? 'Visible' : s.paused ? 'Paused' : 'Hidden'}${t.text ? ` · last step ${esc(t.text)}` : ' · nothing published yet'}${where ? ` · ${where}` : ''}`,
+ back: { href: '/feeds', label: 'All feeds' },
+ actions: [`<a class="oc-icon-btn" href="#oc-feed-settings" aria-label="Where it shows and what viewers see" title="Settings">${ICONS.gear}</a>`],
+ })}
${req.query.saved ? notice('ok', 'Saved.') : ''}${req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : ''}
${req.query.saved ? notice('ok', 'Saved.') : ''}${req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : ''}
<div class="fv fd-preview" data-feed="${esc(feed.id)}"></div>
<div class="fv fd-preview" data-feed="${esc(feed.id)}"></div>
-<h2>Publish from Claude Code</h2>
-<p>Add this to <code>.claude/settings.json</code> in a project (or <code>~/.claude/settings.json</code> for every project), and set <code>OPENVIBE_FEED_KEY</code> in the shell you start Claude Code from. Each prompt, tool used and answer becomes a step; nothing is read from your files or transcript.</p>
-<pre class="ib-snippet" tabindex="0"><code>${esc(claudeCodeSnippet(base, feed.id))}</code></pre>
-<h2>Publish from anything else</h2>
-<p>Send events to the API with the key; <code>eventType</code> is one of ${feeds.TYPES.map((t) => `<code>${t}</code>`).join(', ')} (up to 50 at once as <code>{ "events": [...] }</code>). The OpenVibe Copilot companion speaks the same events.</p>
-<pre class="ib-snippet" tabindex="0"><code>${esc(curlSnippet(base, feed.id))}</code></pre>
-<p class="oc-muted">Key ending in <code>${esc(feed.key_hint || '????')}</code>.</p>
-<form method="post" action="/feeds/${esc(feed.id)}/key" class="oc-inline"><button type="submit">Make a new key</button></form> <span class="oc-muted">(the old one stops working)</span>
-<h2>Show it on your site</h2>
-<pre class="ib-snippet" tabindex="0"><code>${esc(iframeSnippet(base, feed.id))}</code></pre>
-<h2>Where it shows and what viewers see</h2>
-<form class="oc-form" method="post" action="/feeds/${esc(feed.id)}/settings">
+<h2 class="oc-list-head">Connect</h2>
+${connect('oc-connect-claude', 'Claude Code', `<p>Add this to <code>.claude/settings.json</code> in a project (or <code>~/.claude/settings.json</code> for every project), and set <code>OPENVIBE_FEED_KEY</code> in the shell you start Claude Code from. Each prompt, tool used and answer becomes a step; nothing is read from your files or transcript.</p>
+${snip('oc-snip-claude', claudeCodeSnippet(base, feed.id))}`, !feed.last_event_at)}
+${connect('oc-connect-api', 'Any other tool (the API)', `<p>Send events with the key; <code>eventType</code> is one of ${feeds.TYPES.map((x) => `<code>${x}</code>`).join(', ')} (up to 50 at once as <code>{ "events": [...] }</code>). The OpenVibe Copilot companion speaks the same events.</p>
+${snip('oc-snip-api', curlSnippet(base, feed.id))}`, false)}
+${connect('oc-connect-embed', 'Show it on your site', snip('oc-snip-embed', iframeSnippet(base, feed.id)), false)}
+<div class="oc-keyrow"><span class="oc-muted">${ICONS.key} Key ending in <code>${esc(feed.key_hint || '????')}</code></span><form method="post" action="/feeds/${esc(feed.id)}/key" class="oc-inline"><button type="submit" class="oc-pill">${ICONS.retry}<span>New key</span></button></form></div>
+<details class="oc-card oc-fold" id="oc-feed-settings"${req.query.saved || req.query.error ? ' open' : ''}><summary><span>Where it shows and what viewers see</span>${ICONS.chevron}</summary>
+<form class="oc-form oc-grid-form" method="post" action="/feeds/${esc(feed.id)}/settings">
<label>Title <input name="title" required minlength="2" maxlength="80" value="${esc(feed.title)}"></label>
<label>Title <input name="title" required minlength="2" maxlength="80" value="${esc(feed.title)}"></label>
${placeFields(await places(actor.user), feed)}
${placeFields(await places(actor.user), feed)}
+<fieldset class="oc-fieldset"><legend>Showing</legend>
${check('enabled', 'Viewers see this feed')}
${check('enabled', 'Viewers see this feed')}
${check('paused', 'Paused (steps are kept, nobody sees them until you unpause)')}
${check('paused', 'Paused (steps are kept, nobody sees them until you unpause)')}
+</fieldset>
+<fieldset class="oc-fieldset"><legend>What viewers see</legend>
${check('show_prompts', 'Prompts')}${check('show_responses', 'Answers')}${check('show_tool_calls', 'Tools used')}${check('show_tool_arguments', 'What the tools were given (commands, searches)')}
${check('show_prompts', 'Prompts')}${check('show_responses', 'Answers')}${check('show_tool_calls', 'Tools used')}${check('show_tool_arguments', 'What the tools were given (commands, searches)')}
${check('show_file_events', 'Files changed')}${check('redact_file_paths', 'File names only, not their folders')}${check('show_thinking', 'Thinking, when the tool sends it')}
${check('show_file_events', 'Files changed')}${check('redact_file_paths', 'File names only, not their folders')}${check('show_thinking', 'Thinking, when the tool sends it')}
+</fieldset>
<label>Detail <select name="viewer_depth">${[['headline', 'Headlines'], ['standard', 'Standard'], ['deep', 'More text']].map(([v, l]) => `<option value="${v}"${s.viewer_depth === v ? ' selected' : ''}>${l}</option>`).join('')}</select></label>
<label>Detail <select name="viewer_depth">${[['headline', 'Headlines'], ['standard', 'Standard'], ['deep', 'More text']].map(([v, l]) => `<option value="${v}"${s.viewer_depth === v ? ' selected' : ''}>${l}</option>`).join('')}</select></label>
<label>Delay before viewers see a step <select name="delay_ms">${[[0, 'None'], [10000, '10 seconds'], [30000, '30 seconds'], [60000, '1 minute'], [120000, '2 minutes']].map(([v, l]) => `<option value="${v}"${s.delay_ms === v ? ' selected' : ''}>${l}</option>`).join('')}</select></label>
<label>Delay before viewers see a step <select name="delay_ms">${[[0, 'None'], [10000, '10 seconds'], [30000, '30 seconds'], [60000, '1 minute'], [120000, '2 minutes']].map(([v, l]) => `<option value="${v}"${s.delay_ms === v ? ' selected' : ''}>${l}</option>`).join('')}</select></label>
<p><button type="submit">Save</button></p></form>
<p><button type="submit">Save</button></p></form>
-<form method="post" action="/feeds/${esc(feed.id)}/delete" class="oc-inline"><button type="submit" class="oc-link oc-danger">Delete this feed and its history</button></form>`,
+<form method="post" action="/feeds/${esc(feed.id)}/delete" class="oc-inline oc-danger-row"><button type="submit" class="oc-pill oc-pill-danger">${ICONS.trash}<span>Delete this feed and its history</span></button></form>
+</details>`,
});
});
});
});
@@ -163,7 +183,7 @@ ${check('show_file_events', 'Files changed')}${check('redact_file_paths', 'File
@@ -163,7 +183,7 @@ ${check('show_file_events', 'Files changed')}${check('redact_file_paths', 'File
router.post('/feeds/:id/key', formLimit, sameSite, async (req, res) => {
router.post('/feeds/:id/key', formLimit, sameSite, async (req, res) => {
const a = await ownFeed(req, res); if (!a) return;
const a = await ownFeed(req, res); if (!a) return;
const key = await feeds.rotateKey(a.feed);
const key = await feeds.rotateKey(a.feed);
- await page(req, res, 200, { actor: a.actor, title: `${a.feed.title}: key`, path: `/feeds/${a.feed.id}`, active: 'rooms', robots: 'noindex, nofollow', body: `<p><a href="/feeds">← Feeds</a></p><h1>${esc(a.feed.title)}</h1>${keyPanel(a.feed, key)}` });
+ await page(req, res, 200, { actor: a.actor, title: `${a.feed.title}: key`, path: `/feeds/${a.feed.id}`, active: 'rooms', robots: 'noindex, nofollow', footer: 'compact', body: `${ui.head({ title: esc(a.feed.title), sub: 'Its new key, shown this once', back: { href: `/feeds/${a.feed.id}`, label: 'The feed' } })}${keyPanel(a.feed, key)}` });
});
});
router.post('/feeds/:id/delete', formLimit, sameSite, async (req, res) => {
router.post('/feeds/:id/delete', formLimit, sameSite, async (req, res) => {
const a = await ownFeed(req, res); if (!a) return;
const a = await ownFeed(req, res); if (!a) return;
@@ -30,6 +30,14 @@ const ICONS = {
@@ -30,6 +30,14 @@ const ICONS = {
chat: svg('<path d="M4 5h16v11H8l-4 4Z"/>'),
chat: svg('<path d="M4 5h16v11H8l-4 4Z"/>'),
task: svg('<rect x="4" y="4" width="16" height="16" rx="3"/><path d="m8 12 3 3 5-6"/>'),
task: svg('<rect x="4" y="4" width="16" height="16" rx="3"/><path d="m8 12 3 3 5-6"/>'),
retry: svg('<path d="M4 12a8 8 0 0 1 13.7-5.7L20 8.5M20 4v4.5h-4.5M20 12a8 8 0 0 1-13.7 5.7L4 15.5M4 20v-4.5h4.5"/>'),
retry: svg('<path d="M4 12a8 8 0 0 1 13.7-5.7L20 8.5M20 4v4.5h-4.5M20 12a8 8 0 0 1-13.7 5.7L4 15.5M4 20v-4.5h4.5"/>'),
+ pencil: svg('<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>'),
+ plus: svg('<path d="M12 5v14M5 12h14"/>'),
+ phone: svg('<path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2"/>'),
+ ban: svg('<circle cx="12" cy="12" r="8.5"/><path d="m6 6 12 12"/>'),
+ key: svg('<circle cx="8" cy="15" r="4"/><path d="m10.8 12.2 8.7-8.7M16 7l3 3M14 9l2 2"/>'),
+ help: svg('<circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.8M12 17h.01"/>'),
+ more: svg('<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>'),
+ chevron: svg('<path d="m9 6 6 6-6 6"/>'),
hand: svg('<path d="M8 13V5.5a1.5 1.5 0 0 1 3 0V11M11 10V4.5a1.5 1.5 0 0 1 3 0V11M14 10.5V6a1.5 1.5 0 0 1 3 0v7c0 4-2.5 7-6.5 7-2.6 0-4.2-1.3-5.7-3.6L3 13.6a1.5 1.5 0 0 1 2.4-1.8L8 15"/>'),
hand: svg('<path d="M8 13V5.5a1.5 1.5 0 0 1 3 0V11M11 10V4.5a1.5 1.5 0 0 1 3 0V11M14 10.5V6a1.5 1.5 0 0 1 3 0v7c0 4-2.5 7-6.5 7-2.6 0-4.2-1.3-5.7-3.6L3 13.6a1.5 1.5 0 0 1 2.4-1.8L8 15"/>'),
};
};
@@ -287,6 +287,14 @@ ${parts.more}`,
@@ -287,6 +287,14 @@ ${parts.more}`,
// ── Direct messages ──
// ── Direct messages ──
const dmApi = require('../chat/dm-routes');
const dmApi = require('../chat/dm-routes');
+ const ui = require('./ui');
+ const { ICONS: IC } = require('./icons');
+ /** One conversation in a list: initials, name and time on one line, the last message under it, unread marked. */
+ const convRow = ({ href, title, preview, at, unread = 0, badge = '' }) => {
+ const t = ui.when(at);
+ return `<li class="oc-row${unread ? ' oc-unread' : ''}"><a href="${esc(href)}">${ui.initials(title)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(title)}</strong>${badge}${t.text ? `<time datetime="${t.iso}">${esc(t.text)}</time>` : ''}</span>`
+ + `<span class="oc-row-sub">${esc(preview)}${unread ? `<span class="oc-dot" aria-label="${unread} new">${unread > 9 ? '9+' : unread}</span>` : ''}</span></span></a></li>`;
+ };
router.get('/messages', async (req, res) => {
router.get('/messages', async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const me = actor.user;
const me = actor.user;
@@ -294,8 +302,7 @@ ${parts.more}`,
@@ -294,8 +302,7 @@ ${parts.more}`,
const rows = (await Promise.all(convs.map(async (c) => {
const rows = (await Promise.all(convs.map(async (c) => {
const others = (await dm.getParticipants(c.id)).filter((p) => p.id !== me.id);
const others = (await dm.getParticipants(c.id)).filter((p) => p.id !== me.id);
const title = c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you';
const title = c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you';
- const t = hhmm(c.last_message_at);
- return `<li class="oc-conv${c.unread_count ? ' oc-unread' : ''}"><a href="/messages/${c.id}"><strong>${esc(title)}</strong>${c.unread_count ? ` <span class="oc-badge">${c.unread_count} new</span>` : ''}<span class="oc-muted oc-last">${c.last_message ? esc(String(c.last_message).slice(0, 120)) : 'No messages yet'}</span></a>${t.text ? `<time class="oc-time" datetime="${t.iso}">${t.iso.slice(0, 10)}</time>` : ''}</li>`;
+ return convRow({ href: `/messages/${c.id}`, title, preview: c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet', at: c.last_message_at, unread: Number(c.unread_count) || 0 });
})));
})));
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const done = req.query.unblocked ? notice('ok', 'Unblocked. They can message you again, and you them.') : '';
const done = req.query.unblocked ? notice('ok', 'Unblocked. They can message you again, and you them.') : '';
@@ -304,14 +311,14 @@ ${parts.more}`,
@@ -304,14 +311,14 @@ ${parts.more}`,
const unread = convs.reduce((n, c) => n + (Number(c.unread_count) || 0), 0);
const unread = convs.reduce((n, c) => n + (Number(c.unread_count) || 0), 0);
// Support conversations with sites' chats, where they signed in to OpenVibe (server/inbox/: theirs to follow up).
// Support conversations with sites' chats, where they signed in to OpenVibe (server/inbox/: theirs to follow up).
const support = await require('../inbox/inbox').mySupport(me, { limit: 20 }).catch(() => []);
const support = await require('../inbox/inbox').mySupport(me, { limit: 20 }).catch(() => []);
- const supportList = support.length ? `<h2>Support</h2><ul class="oc-convs">${support.map((c) => { const t = hhmm(c.last_message_at); return `<li class="oc-conv"><a href="/support/${esc(c.id)}"><strong>${esc(c.inbox.name)} <span class="oc-badge">${esc({ bot: 'Vibe', waiting: 'Waiting', open: 'Open', resolved: 'Resolved' }[c.status] || c.status)}</span></strong><span class="oc-muted oc-last">${esc(c.preview || '')}</span></a>${t.text ? `<time class="oc-time" datetime="${t.iso}">${t.iso.slice(0, 10)}</time>` : ''}</li>`; }).join('')}</ul>` : '';
+ const STATUS = { bot: 'Vibe', waiting: 'Waiting', open: 'Open', resolved: 'Resolved' };
+ const supportList = support.length ? `<h2 class="oc-list-head">Support</h2><ul class="oc-list">${support.map((c) => convRow({ href: `/support/${c.id}`, title: c.inbox.name, preview: c.preview || '', at: c.last_message_at, badge: ` <span class="oc-badge">${esc(STATUS[c.status] || c.status)}</span>` })).join('')}</ul>` : '';
await page(req, res, 200, {
await page(req, res, 200, {
actor, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'compact',
actor, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'compact',
page: { view: 'inbox', unread, me: me.id },
page: { view: 'inbox', unread, me: me.id },
- body: `<h1>Messages</h1>${error}${done}
-<p class="oc-muted" id="oc-inbox-status" aria-live="polite">${unread ? `${unread} unread` : 'All caught up'}</p>
-<form class="oc-new" method="post" action="/messages/new"><label for="oc-to">Message someone</label> <input id="oc-to" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username" autocomplete="off"> <button type="submit">Start</button></form>
-<ul class="oc-convs" id="oc-convs">${rows.join('\n') || '<li class="oc-muted">No conversations yet.</li>'}</ul>
+ body: `${ui.head({ title: 'Messages', sub: `<span id="oc-inbox-status" aria-live="polite">${unread ? `${unread} unread` : 'All caught up'}</span>` })}${error}${done}
+<form class="oc-find" method="post" action="/messages/new" role="search">${IC.pencil}<label for="oc-to" class="oc-sr">New message: their username</label><input id="oc-to" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="New message: their username" autocomplete="off" enterkeyhint="go"><button type="submit" class="oc-send" aria-label="Start the conversation" title="Start">${IC.send}</button></form>
+<ul class="oc-list" id="oc-convs">${rows.join('\n') || '<li class="oc-row-empty oc-muted">No conversations yet. Write a username above to start one.</li>'}</ul>
${supportList}
${supportList}
${blockedList}`,
${blockedList}`,
});
});
@@ -355,19 +362,20 @@ ${blockedList}`,
@@ -355,19 +362,20 @@ ${blockedList}`,
const iBlocked = other ? await dm.hasBlocked(me.id, other.id) : false;
const iBlocked = other ? await dm.hasBlocked(me.id, other.id) : false;
const closed = other ? await dm.isBlockedEither(me.id, other.id) : false;
const closed = other ? await dm.isBlockedEither(me.id, other.id) : false;
const otherName = other ? esc(other.display_name || other.username) : '';
const otherName = other ? esc(other.display_name || other.username) : '';
- let composer = `<form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Write a message"></textarea><button type="submit">Send</button></form>`;
+ let composer = `<div class="oc-dock"><form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="1" required placeholder="Write a message"></textarea><button type="submit" class="oc-send" aria-label="Send" title="Send">${IC.send}</button></form></div>`;
if (iBlocked) composer = `${notice('error', `You blocked ${other.display_name || other.username}. Neither of you can message the other.`)}<form class="oc-inline" method="post" action="/messages/unblock/${Number(other.id)}"><input type="hidden" name="back" value="/messages/${id}"><button type="submit">Unblock ${otherName}</button></form>`;
if (iBlocked) composer = `${notice('error', `You blocked ${other.display_name || other.username}. Neither of you can message the other.`)}<form class="oc-inline" method="post" action="/messages/unblock/${Number(other.id)}"><input type="hidden" name="back" value="/messages/${id}"><button type="submit">Unblock ${otherName}</button></form>`;
else if (closed) composer = notice('error', 'You cannot message this person.');
else if (closed) composer = notice('error', 'You cannot message this person.');
- const tools = other && !iBlocked ? `<form class="oc-inline oc-tools" method="post" action="/messages/${id}/block"><button type="submit" class="oc-link">Block ${otherName}</button></form>` : '';
+ // Blocking is in the conversation's "more" menu: one tap on an icon in the header should never block someone.
+ const tools = other && !iBlocked ? `<details class="oc-more"><summary class="oc-icon-btn" aria-label="More" title="More">${IC.more}</summary><div class="oc-pop"><form method="post" action="/messages/${id}/block"><button type="submit" class="oc-pop-item oc-danger">${IC.ban}<span>Block ${otherName}</span></button></form></div></details>` : '';
// A 1:1 conversation nobody blocked can become a call (POST /messages/:id/call rings the other person).
// A 1:1 conversation nobody blocked can become a call (POST /messages/:id/call rings the other person).
const callForm = other && !closed && require('../config').calls.enabled
const callForm = other && !closed && require('../config').calls.enabled
- ? `<form class="oc-inline oc-dm-call" method="post" action="/messages/${id}/call"><button type="submit" class="oc-button--quiet">Call ${otherName}</button></form>` : '';
+ ? `<form class="oc-inline oc-dm-call" method="post" action="/messages/${id}/call"><button type="submit" class="oc-icon-btn" aria-label="Call ${otherName}" title="Call ${otherName}">${IC.phone}</button></form>` : '';
await page(req, res, 200, {
await page(req, res, 200, {
actor, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'compact',
actor, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'compact',
page: { view: 'dm', conversation: id, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me.id },
page: { view: 'dm', conversation: id, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me.id },
- body: `<p><a href="/messages">← Messages</a></p><div class="oc-dm-head"><h1>${esc(title)}</h1>${callForm}</div>${error}
-<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => dmMessage(m, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>
-${composer}${tools}`,
+ body: `${ui.head({ title: esc(title), sub: other ? `@${esc(other.username || '')}` : `${others.length + 1} people`, back: { href: '/messages', label: 'All messages' }, actions: [callForm, tools].filter(Boolean) })}${error}
+<ol class="oc-feed oc-room-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => dmMessage(m, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>
+${composer}`,
});
});
});
});
@@ -599,7 +607,9 @@ ${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete">
@@ -599,7 +607,9 @@ ${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete">
const KIND_LABEL = { community: '', call: 'call', system: 'announcements' };
const KIND_LABEL = { community: '', call: 'call', system: 'announcements' };
const ROLE_LABEL = { owner: 'owner', mod: 'moderator', speaker: 'speaker', participant: 'participant', member: 'member', viewer: 'viewer', blocked: 'blocked' };
const ROLE_LABEL = { owner: 'owner', mod: 'moderator', speaker: 'speaker', participant: 'participant', member: 'member', viewer: 'viewer', blocked: 'blocked' };
const kindBadge = (r) => (KIND_LABEL[r.kind] ? ` <span class="oc-badge oc-kind-${esc(r.kind)}">${KIND_LABEL[r.kind]}</span>` : '');
const kindBadge = (r) => (KIND_LABEL[r.kind] ? ` <span class="oc-badge oc-kind-${esc(r.kind)}">${KIND_LABEL[r.kind]}</span>` : '');
- const roomCard = (r) => `<li class="oc-conv${r.unread ? ' oc-unread' : ''}"><a href="/r/${esc(r.slug)}"><strong>${esc(r.name)}</strong>${kindBadge(r)}${r.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}${r.unread ? ` <span class="oc-badge">${r.unread} new</span>` : ''}<span class="oc-muted oc-last">${esc(r.topic || `${r.members} member${r.members === 1 ? '' : 's'}`)}</span></a></li>`;
+ // One room in a list: its initials, name with its kind and a lock when private, the topic or the members under it.
+ const roomCard = (r) => `<li class="oc-row${r.unread ? ' oc-unread' : ''}"><a href="/r/${esc(r.slug)}">${ui.initials(r.name)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(r.name)}</strong>${kindBadge(r)}${r.visibility === 'private' ? `<span class="oc-row-lock" title="Private">${IC.lock}<span class="oc-sr">private</span></span>` : ''}</span>`
+ + `<span class="oc-row-sub">${esc(r.topic || `${r.members} member${r.members === 1 ? '' : 's'}`)}${r.unread ? `<span class="oc-dot" aria-label="${r.unread} new">${r.unread > 9 ? '9+' : r.unread}</span>` : ''}</span></span></a></li>`;
const roomMessage = (m, can, slug, me) => {
const roomMessage = (m, can, slug, me) => {
const t = hhmm(m.created_at);
const t = hhmm(m.created_at);
const ai = m.ai_member_id != null;
const ai = m.ai_member_id != null;
@@ -637,11 +647,11 @@ ${staff ? '<label class="oc-check"><input type="radio" name="kind" value="system
@@ -637,11 +647,11 @@ ${staff ? '<label class="oc-check"><input type="radio" name="kind" value="system
<p><button type="submit">Create room</button></p>
<p><button type="submit">Create room</button></p>
</form>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${wantWork ? '%2Frooms%3Fnew%3Dworkspace' : '%2Frooms'}">Sign in with OpenVibe</a> to start or join a room.</p>`;
</form>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${wantWork ? '%2Frooms%3Fnew%3Dworkspace' : '%2Frooms'}">Sign in with OpenVibe</a> to start or join a room.</p>`;
await page(req, res, 200, {
await page(req, res, 200, {
- actor, title: 'Rooms', path: '/rooms', active: 'rooms', robots: 'index, follow',
+ actor, title: 'Rooms', path: '/rooms', active: 'rooms', robots: 'index, follow', footer: 'compact',
description: 'Chat and call rooms on OpenVibe: start one about anything, invite people, or join a public room.',
description: 'Chat and call rooms on OpenVibe: start one about anything, invite people, or join a public room.',
- body: `<h1>Rooms</h1>${error}
-${mine.length ? `<h2>Your rooms</h2><ul class="oc-convs">${mine.map(roomCard).join('')}</ul>` : ''}
-<h2>Public rooms</h2>${pub.length ? `<ul class="oc-convs">${pub.map(roomCard).join('')}</ul>` : `<div class="oc-rooms-empty">
+ body: `${ui.head({ title: 'Rooms', sub: 'Group chats, calls and workspaces with Vibe', actions: me ? [`<a class="oc-icon-btn" href="#oc-new-room" aria-label="Start a room" title="Start a room">${IC.plus}</a>`] : [] })}${error}
+${mine.length ? `<h2 class="oc-list-head">Your rooms</h2><ul class="oc-list">${mine.map(roomCard).join('')}</ul>` : ''}
+<h2 class="oc-list-head">Public rooms</h2>${pub.length ? `<ul class="oc-list">${pub.map(roomCard).join('')}</ul>` : `<div class="oc-rooms-empty">
<p><strong>No public room is open right now: start the first one below.</strong> A room can be about anything, private to the people you invite, a voice call, or a group chat with AI members who know the context.</p>
<p><strong>No public room is open right now: start the first one below.</strong> A room can be about anything, private to the people you invite, a voice call, or a group chat with AI members who know the context.</p>
<p>Meanwhile the conversation is here:</p>
<p>Meanwhile the conversation is here:</p>
<ul class="oc-goto"><li><a href="/">Global chat</a>: everyone on OpenVibe, readable without an account</li>
<ul class="oc-goto"><li><a href="/">Global chat</a>: everyone on OpenVibe, readable without an account</li>
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+/**
+ * Pieces every working page on openvibe.chat shares (the owner, 2026-10-28: "icon buttons … so much wasted space … the
+ * alignment's kind of bad … not a focus on the real content"): one compact page header with its actions as icons, times
+ * people read at a glance, and a person's initials when there is no picture.
+ *
+ * head({ title, sub, back, actions }) <header class="oc-page-head">: back icon, title and one muted line, icon actions
+ * when(ts, now) { iso, text }: 14:05 today, Yesterday, Mon this week, Oct 20 this year, else 2025-10-20
+ * initials(name) a round mark with up to two letters, its colour from the name
+ */
+const { esc } = require('./layout');
+const { ICONS } = require('./icons');
+
+function head({ title, sub = '', back = null, actions = [] }) {
+ return `<header class="oc-page-head">
+${back ? `<a class="oc-icon-btn oc-back" href="${esc(back.href)}" aria-label="${esc(back.label)}" title="${esc(back.label)}">${ICONS.back}</a>` : ''}<div class="oc-page-id"><h1>${title}</h1>${sub ? `<p class="oc-page-sub">${sub}</p>` : ''}</div>
+${actions.length ? `<div class="oc-page-tools">${actions.join('')}</div>` : ''}
+</header>`;
+}
+
+const DAY = 86_400_000;
+const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+function when(ts, now = Date.now()) {
+ const s = String(ts || '');
+ const t = Date.parse(s.includes('T') ? s : `${s.replace(' ', 'T')}Z`);
+ if (!Number.isFinite(t)) return { iso: '', text: '' };
+ const d = new Date(t);
+ const startToday = Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate());
+ let text;
+ if (t >= startToday) text = d.toISOString().slice(11, 16);
+ else if (t >= startToday - DAY) text = 'Yesterday';
+ else if (t >= startToday - 6 * DAY) text = WEEKDAYS[d.getUTCDay()];
+ else if (d.getUTCFullYear() === new Date(now).getUTCFullYear()) text = `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`;
+ else text = d.toISOString().slice(0, 10);
+ return { iso: d.toISOString(), text };
+}
+
+function initials(name) {
+ const words = String(name || '?').replace(/[^\p{L}\p{N} ]/gu, ' ').trim().split(/\s+/).filter(Boolean);
+ const letters = (words.length > 1 ? words[0][0] + words[1][0] : (words[0] || '?').slice(0, 2)).toUpperCase();
+ let h = 0;
+ for (const ch of String(name || '')) h = (h * 31 + ch.codePointAt(0)) % 360;
+ return `<span class="oc-av" style="--av:${h}" aria-hidden="true">${esc(letters)}</span>`;
+}
+
+module.exports = { head, when, initials };
@@ -72,7 +72,7 @@ t('a 1:1 conversation has a Call button; a group or a blocked one has none; sign
@@ -72,7 +72,7 @@ t('a 1:1 conversation has a Call button; a group or a blocked one has none; sign
conv = await dm.getOrCreateDirect(alice.id, bob.id);
conv = await dm.getOrCreateDirect(alice.id, bob.id);
const page = await req('GET', `/messages/${conv}`, { headers: cookie(alice) });
const page = await req('GET', `/messages/${conv}`, { headers: cookie(alice) });
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
- assert.match(page.text, new RegExp(`<form class="oc-inline oc-dm-call" method="post" action="/messages/${conv}/call"><button type="submit" class="oc-button--quiet">Call Bob</button></form>`, 'i'));
+ assert.match(page.text, new RegExp(`<form class="oc-inline oc-dm-call" method="post" action="/messages/${conv}/call"><button type="submit" class="oc-icon-btn" aria-label="Call Bob"`, 'i'));
assert.match(page.text, /<script src="\/web\/ring\.js\?v=[^"]+" defer><\/script>/);
assert.match(page.text, /<script src="\/web\/ring\.js\?v=[^"]+" defer><\/script>/);
assert.doesNotMatch((await req('GET', '/')).text, /ring\.js/, 'no ring listener for a guest');
assert.doesNotMatch((await req('GET', '/')).text, /ring\.js/, 'no ring listener for a guest');
const group = await dm.createConversation(alice.id, [alice.id, bob.id, carol.id], 'Trio');
const group = await dm.createConversation(alice.id, [alice.id, bob.id, carol.id], 'Trio');
@@ -41,7 +41,7 @@ t('the inbox: your conversations only, unread counts in the list and the navigat
@@ -41,7 +41,7 @@ t('the inbox: your conversations only, unread counts in the list and the navigat
await req('POST', `/messages/${conv}`, form(alice, { message: 'are you there?' }));
await req('POST', `/messages/${conv}`, form(alice, { message: 'are you there?' }));
let inbox = await req('GET', '/messages', { headers: cookie(bob) });
let inbox = await req('GET', '/messages', { headers: cookie(bob) });
assert.strictEqual(inbox.status, 200);
assert.strictEqual(inbox.status, 200);
- assert.match(inbox.text, /<span class="oc-badge">2 new<\/span>/);
+ assert.match(inbox.text, /<span class="oc-dot" aria-label="2 new">2<\/span>/);
assert.match(inbox.text, /id="oc-inbox-status"[^>]*>2 unread</);
assert.match(inbox.text, /id="oc-inbox-status"[^>]*>2 unread</);
assert.strictEqual(navLabel(inbox.text, '/messages'), 'Messages (2)');
assert.strictEqual(navLabel(inbox.text, '/messages'), 'Messages (2)');
assert.ok(inbox.text.includes('are you there?'), 'the last message as a preview');
assert.ok(inbox.text.includes('are you there?'), 'the last message as a preview');
@@ -82,7 +82,7 @@ t('participants only: a guessed conversation looks like no conversation', async
@@ -82,7 +82,7 @@ t('participants only: a guessed conversation looks like no conversation', async
t('block: the conversation closes both ways; the inbox lists who you blocked; unblock opens it again', async () => {
t('block: the conversation closes both ways; the inbox lists who you blocked; unblock opens it again', async () => {
assert.strictEqual((await req('POST', `/messages/${conv}/block`, form(bob, {}, { origin: 'https://evil.example' }))).status, 403, 'forms from elsewhere are refused');
assert.strictEqual((await req('POST', `/messages/${conv}/block`, form(bob, {}, { origin: 'https://evil.example' }))).status, 403, 'forms from elsewhere are refused');
let page = await req('GET', `/messages/${conv}`, { headers: cookie(bob) });
let page = await req('GET', `/messages/${conv}`, { headers: cookie(bob) });
- assert.match(page.text, /Block Alice<\/button>/);
+ assert.match(page.text, /<button type="submit" class="oc-pop-item oc-danger">.*<span>Block Alice<\/span><\/button>/);
let r = await req('POST', `/messages/${conv}/block`, form(bob, {}));
let r = await req('POST', `/messages/${conv}/block`, form(bob, {}));
assert.strictEqual(r.headers.get('location'), `/messages/${conv}`);
assert.strictEqual(r.headers.get('location'), `/messages/${conv}`);
@@ -412,7 +412,7 @@ t('a visitor signed in to OpenVibe: their conversation is theirs, in their Messa
@@ -412,7 +412,7 @@ t('a visitor signed in to OpenVibe: their conversation is theirs, in their Messa
assert.strictEqual((await h.db.get('SELECT visitor_user_id FROM inbox_conversations WHERE id = ?', [spoof.body.conversation.id])).visitor_user_id, null, 'an account in the body counts for nothing');
assert.strictEqual((await h.db.get('SELECT visitor_user_id FROM inbox_conversations WHERE id = ?', [spoof.body.conversation.id])).visitor_user_id, null, 'an account in the body counts for nothing');
const get = (path, u) => fetch(`${h.base}${path}`, { headers: { cookie: `ov_token=${u.token}` }, redirect: 'manual' }).then(async (r) => ({ status: r.status, text: await r.text() }));
const get = (path, u) => fetch(`${h.base}${path}`, { headers: { cookie: `ov_token=${u.token}` }, redirect: 'manual' }).then(async (r) => ({ status: r.status, text: await r.text() }));
const messages = await get('/messages', carol);
const messages = await get('/messages', carol);
- assert.ok(messages.text.includes('<h2>Support</h2>') && messages.text.includes(`href="/support/${cid}"`) && messages.text.includes('Acme'));
+ assert.ok(messages.text.includes('<h2 class="oc-list-head">Support</h2>') && messages.text.includes(`href="/support/${cid}"`) && messages.text.includes('Acme'));
const sp = await get(`/support/${cid}`, carol);
const sp = await get(`/support/${cid}`, carol);
assert.ok(sp.status === 200 && sp.text.includes('Before signing in') && sp.text.includes('support.js'));
assert.ok(sp.status === 200 && sp.text.includes('Before signing in') && sp.text.includes('support.js'));
assert.strictEqual((await get(`/support/${cid}`, bob)).status, 404, 'only theirs');
assert.strictEqual((await get(`/support/${cid}`, bob)).status, 404, 'only theirs');