openvibers/openvibe.chat Commits
Chat cards: hide one message's cards, a room switch, and the page's picture through Media (#575)
Changes
nnew file mode 100644@@@ -0,0 +1,9 @@++-- phase: expand++-- Taking a message's cards back whole (server/chat/link-previews.js, server/rooms/routes.js):++--++-- room_messages.embeds_hidden the author (or the room's owner or a moderator) took the previews of this++-- message back. The cards are not sent on a read while the flag is set, and the++-- flag can be cleared, so the cards can come back.++--++-- Expand only: one defaulted column; older releases never name it and keep showing the cards.++ALTER TABLE room_messages ADD COLUMN IF NOT EXISTS embeds_hidden boolean NOT NULL DEFAULT false;@@@ -853,3 +853,12 @@ div.oc-set-row { cursor: default; }.oc-edits-sheet li { padding: 10px 12px; border-radius: 12px; background: var(--oc-panel-2); }.oc-edits-sheet time { display: block; font-size: 12px; color: var(--oc-muted); }.oc-edits-sheet p { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }++++/* The small × that takes a message's cards back, and the note under a message whose cards are gone++ (server/rooms/routes.js): the message's author and the room's moderators only. */++.oc-card { position: relative; }++.oc-card > .oc-card-hide, .oc-card > .oc-card-hide-btn { position: absolute; top: 4px; right: 4px; margin: 0; }++.oc-card-hide-btn { width: 24px; height: 24px; padding: 0; border-radius: 999px; border: 1px solid var(--oc-line); background: var(--oc-panel); color: var(--oc-muted); font-size: 15px; line-height: 1; cursor: pointer; }++.oc-card-hide-btn:hover, .oc-card-hide-btn:focus-visible { color: var(--oc-text); border-color: var(--oc-accent); }++.oc-card-hidden-note { display: inline-flex; align-items: center; gap: 6px; margin: 4px 0 0; color: var(--oc-muted); font-size: .86em; }++.oc-card-show { margin: 0; display: inline-flex; }@@@ -234,6 +234,33 @@}if (last < text.length) parent.appendChild(document.createTextNode(text.slice(last)));}++ // A card's picture that does not load is dropped (server/web/ui.js draws the same markup): the picture goes,++ // the card stays (its title, its description, its link). A picture that never loaded (it failed before this++ // file ran) is dropped as well.++ function dropCardPicture(img) {++ if (!img) return;++ var wrap = img.closest ? img.closest('.oc-card-pic') : null;++ var node = wrap || img;++ if (node.parentNode) node.parentNode.removeChild(node);++ else if (node.remove) node.remove();++ }++ function watchCardPicture(img) {++ if (!img || img.__ovCardPic) return;++ img.__ovCardPic = true;++ img.addEventListener('error', function () { dropCardPicture(img); });++ if (img.complete && img.naturalWidth === 0) dropCardPicture(img);++ }++ // The server draws the cards too, so every picture on the page is watched, not only the ones made here.++ if (document.querySelectorAll) {++ Array.prototype.forEach.call(document.querySelectorAll('img[data-oc-card-pic]'), watchCardPicture);++ }++ if (document.addEventListener) {++ document.addEventListener('error', function (ev) {++ var t = ev && ev.target;++ if (t && t.tagName === 'IMG' && t.hasAttribute && t.hasAttribute('data-oc-card-pic')) dropCardPicture(t);++ }, true);++ }++ window.OpenVibeChatCards = { dropPicture: dropCardPicture, watchPicture: watchCardPicture };// A known OpenVibe link's card under its message (server/chat/link-cards.js, server/web/ui.js draws the same):// the picture when there is one, the title, one line and the one move as a button. Every field is data: set with// textContent, and a link or picture only when it is https (openvibe.media for the picture).@@@ -249,7 +276,9 @@var href = url || img;var a = el('a', 'oc-card-pic'); a.href = href; a.target = '_blank'; a.rel = 'noopener nofollow';var picture = document.createElement('img'); picture.src = img; picture.alt = ''; picture.loading = 'lazy'; picture.decoding = 'async';++ picture.setAttribute('data-oc-card-pic', '1');a.appendChild(picture); box.appendChild(a);++ watchCardPicture(picture);}var body = el('div', 'oc-card-body');if (url) {@@@ -269,7 +298,21 @@// description, and by/LIVE for OpenVibe's own kinds. Every field is data: textContent, and https only for links// and pictures (openvibe.media for the picture).var EMBED_KINDS = ['link', 'video', 'image', 'live', 'picture', 'design', 'post', 'profile', 'game'];-- function embedCard(e) {++ // The cards of a message can be taken back by its author or by a room moderator (server/rooms/routes.js):++ // a small × on each card, for those who may, and for nobody else.++ function mayHideCards(userId) {++ if (!cfg || cfg.view !== 'room') return false;++ if (cfg.moderate) return true;++ return !!(cfg.me && Number(userId) === Number(cfg.me));++ }++ function hideButton(messageId, label) {++ var b = el('button', 'oc-card-hide-btn'); b.type = 'button';++ b.setAttribute('aria-label', label || 'Hide the preview'); b.title = label || 'Hide the preview';++ b.textContent = '×';++ b.addEventListener('click', function () { setHidden(messageId, true); });++ return b;++ }++ function embedCard(e, mayHide, messageId) {if (!e || typeof e !== 'object' || typeof e.title !== 'string' || !e.title) return null;if (e.kind === 'agent-run') {var run = String(e.run_url || ''), events = String(e.events_url || '');@@@ -284,7 +327,9 @@if (img) {var picLink = el('a', 'oc-card-pic'); picLink.href = url || img; picLink.target = '_blank'; picLink.rel = 'noopener nofollow';var picture = document.createElement('img'); picture.src = img; picture.alt = ''; picture.loading = 'lazy'; picture.decoding = 'async';++ picture.setAttribute('data-oc-card-pic', '1');picLink.appendChild(picture); box.appendChild(picLink);++ watchCardPicture(picture);}var body = el('div', 'oc-card-body');if (url) { var title = el('a', 'oc-card-title', e.title); title.href = url; title.target = '_blank'; title.rel = 'noopener nofollow'; body.appendChild(title); }@@@ -293,24 +338,54 @@if (typeof e.by === 'string' && e.by) body.appendChild(el('p', 'oc-card-line', 'by ' + e.by));if (e.live === true) { var live = el('p', 'oc-card-note'); live.appendChild(el('span', 'oc-badge oc-badge-mod', 'LIVE')); body.appendChild(live); }box.appendChild(body);++ if (mayHide && messageId) box.appendChild(hideButton(messageId));return box;}function addCard(li, m) {++ var mayHide = mayHideCards(li ? li.dataset.user : 0);if (m && Array.isArray(m.embeds) && m.embeds.length) {-- m.embeds.slice(0, 3).forEach(function (e) { var c = embedCard(e); if (c) li.appendChild(c); });++ var mid = Number(m.id || (li && li.dataset.id) || 0);++ m.embeds.slice(0, 3).forEach(function (e) {++ var c = embedCard(e, mayHide, mid);++ if (c) li.appendChild(c);++ });mountRunCards(li);return;}var c = m && m.card && m.card.kind === 'agent-run' ? embedCard(m.card) : linkCard(m && m.card); if (c) li.appendChild(c);mountRunCards(li);}-- // A preview that landed after its message: replace the line's cards with the finished list.-- function updateEmbeds(id, embeds) {++ // A preview that landed after its message: replace the line's cards with the finished list. A message whose++ // cards were taken back shows the note instead, and the ones who may put them back get the button.++ function updateEmbeds(id, embeds, hidden) {if (!feed || !Array.isArray(embeds)) return;var li = feed.querySelector('[data-id="' + Number(id) + '"]');if (!li) return;Array.prototype.forEach.call(li.querySelectorAll('.oc-card, .oc-agent-run'), function (c) { c.remove(); });-- addCard(li, { embeds: embeds });++ var note = li.querySelector('.oc-card-hidden-note');++ if (note) note.remove();++ if (hidden) { li.appendChild(hiddenNote(li, Number(id))); return; }++ addCard(li, { embeds: embeds, id: Number(id) });++ }++ // The note under a message whose cards were taken back.++ function hiddenNote(li, id) {++ var box = el('span', 'oc-card-hidden-note');++ box.appendChild(el('span', '', 'Previews hidden'));++ if (mayHideCards(li ? li.dataset.user : 0)) {++ var show = el('button', 'oc-link', 'Show previews'); show.type = 'button';++ show.addEventListener('click', function () { setHidden(id, false); });++ box.appendChild(show);++ }++ return box;++ }++ // Take a message's cards back, or put them back (server/rooms/routes.js).++ function setHidden(id, hidden) {++ if (!cfg || !cfg.room) return;++ var api = '/api/chat/rooms/' + encodeURIComponent(cfg.room) + '/messages/' + Number(id) + '/embeds/hidden';++ fetch(api, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hidden: !!hidden }) })++ .then(function (r) { return r.ok ? r.json() : null; })++ .then(function (out) { if (out) updateEmbeds(id, out.embeds || [], !!out.hidden); })++ .catch(function () {});}function mountRunCards(root) { if (window.OpenVibeAgentRunCards) window.OpenVibeAgentRunCards.mountAll(root); }@@@ -712,7 +787,9 @@if (m.edited_at && window.OVMessages) window.OVMessages.edited(t, m.edited_at);if (cfg.moderate && m.edited_at) { var versions = el('button', 'ovk-btn ovk-btn--quiet oc-edits', 'Earlier versions'); versions.type = 'button'; li.appendChild(versions); }if (!ai && cfg.pics) { var pw = pictureThumbs(m.message); if (pw) li.appendChild(pw); }-- addCard(li, m);++ // Cards that were taken back are not sent with the message: the line says so instead.++ if (m.embeds_hidden) li.appendChild(hiddenNote(li, Number(m.id) || 0));++ else addCard(li, m);var mine = !ai && Number(m.user_id) === Number(cfg.me);if (cfg.moderate || mine) {var f = el('form', 'oc-del'); f.method = 'post'; f.action = '/r/' + encodeURIComponent(cfg.room) + '/delete/' + m.id;@@@ -797,7 +874,7 @@} else if (m.type === 'room_ai_typing' && m.room === cfg.room) {showThinking(m.name);} else if (m.type === 'room_message_embeds' && m.room === cfg.room) {-- updateEmbeds(m.id, m.embeds);++ updateEmbeds(m.id, m.embeds, !!m.hidden);} else if (m.type === 'room_message_edited' && m.room === cfg.room && m.message) {applyEdit(m.message.id, m.message);} else if (m.type === 'room_message_deleted' && m.room === cfg.room) {@@@ -9,18 +9,24 @@* The job reads the page through openvibe-shared/egress: public addresses only (DNS is checked again at connect, so* a name that answers a private address is refused), https only, at most two redirects each checked on its own,* 4 s and 512 KiB per request, no cookies, a plain user agent. YouTube and Vimeo are read from their oEmbed endpoint-- * on a fixed origin instead of their page. A picture is only kept when it already lives on openvibe.media: a page's-- * own image is not hot-linked (a Media proxy path can add those later).++ * on a fixed origin instead of their page. A picture is never hot-linked: a page's own image is brought into++ * OpenVibe.Media first (server/chat/media-import.js), and the card shows the Media address; a picture that cannot++ * be brought in is left off the card.** Previews live apart from the message (link_previews) and are cached by address for a day (link_preview_cache), so* sending, and every later send of the same address, never waits on another site. The fields are data, never markup:* control characters are gone, whitespace folded, sizes capped, and the API/renderer escape them as text.*++ * A card's picture is kept with the message it is shown under: once the preview is stored, the picture is kept in++ * OpenVibe.Media (server/chat/media-import.js) for that message, so it lives in Media's kept window (30 days)++ * instead of the day-long one, and a picture the cache shows again for a later message is kept again for it.++ ** The room wiring (server/rooms/rooms.js) attaches the rows on every read as `embeds` ([] before the job is done),* and the job announces `room_message_embeds` when it has something.*/const { assertPublicUrl, safeLookup } = require('openvibe-shared/egress');const db = require('../db/database');++const mediaImport = require('./media-import');const MAX_URLS = 3;const MAX_URL = 2048;@@@ -131,6 +137,19 @@ function imageOf(value) {} catch { return null; }}++/**++ * A page's own picture address (https only): the one the job may bring into Media, so that a card shows a++ * picture from our own host and never a hot-linked one.++ */++function httpsAddress(value) {++ if (typeof value !== 'string' || !value) return null;++ try {++ const u = new URL(value);++ if (u.protocol !== 'https:' || u.username || u.password) return null;++ return u.href;++ } catch { return null; }++}++const intOf = (value) => {const n = parseInt(value, 10);return Number.isFinite(n) && n > 0 && n <= 100000 ? n : 0;@@@ -168,7 +187,11 @@ function previewFromResponse(url, res) {const f = pageFields(html);const title = textOf(f['og:title'] || f['twitter:title'] || f['html:title'], MAX_TITLE);const description = textOf(f['og:description'] || f['twitter:description'], MAX_DESCRIPTION);-- const image = imageOf(f['og:image'] || f['og:image:url'] || f['twitter:image']);++ const raw = String(f['og:image'] || f['og:image:url'] || f['twitter:image'] || '').trim();++ const image = imageOf(raw);++ // The page's own picture: the job brings it into Media (server/chat/media-import.js) and keeps the Media++ // address. A picture that is already on Media is kept as it is; one that cannot be brought in is left off.++ const source_image = image ? null : httpsAddress(raw);const ogType = String(f['og:type'] || '').toLowerCase();const card = String(f['twitter:card'] || '').toLowerCase();const kind = ogType.startsWith('video') || card === 'player' ? 'video' : ogType.startsWith('image') ? 'image' : 'link';@@@ -182,6 +205,7 @@ function previewFromResponse(url, res) {kind,width: intOf(f['og:image:width'] || f['twitter:image:width']),height: intOf(f['og:image:height'] || f['twitter:image:height']),++ ...(source_image ? { source_image } : {}),};}@@@ -512,6 +536,9 @@ function parsePreview(value) {};if (p.by != null) preview.by = textOf(p.by, 80);if (p.live === true || p.live === false) preview.live = p.live;++ // The Media attachment behind the picture (the id a keep names). It travels with the preview, so a++ // picture that is shown again (the cache serves it a day later) can be kept for that message too.++ if (preview.image && typeof p.image_id === 'string' && /^att_[0-9A-HJKMNP-TV-Z]{26}$/.test(p.image_id)) preview.image_id = p.image_id;return preview;}@@@ -548,6 +575,13 @@ async function previewOf(value) {if (hit.hit) return hit.value;let preview = null;try { preview = await readUrl(url); } catch { preview = null; }++ // The page's own picture is brought into Media before the preview is kept: a card shows a picture from our++ // own host, never a hot-linked one. A picture that cannot be brought in is left off the card. The id of the++ // attachment travels with the preview, so the picture can be kept with the message that shows it.++ if (preview && preview.source_image && !preview.image) {++ const brought = await mediaImport.importPicture(preview.source_image).catch(() => null);++ if (brought) preview = { ...preview, image: brought.url, image_id: brought.id };++ }const checked = preview ? parsePreview({ ...preview, url }) : null;await remember(url, checked);return checked;@@@ -573,11 +607,17 @@ async function store(job, position, preview) {const table = job.scope === 'room' ? 'room_messages' : job.scope === 'dm' ? 'dm_messages' : 'chat_messages';const current = await db.get(`SELECT message FROM ${table} WHERE id = ?`, [job.messageId]);if (!current || addresses(current.message)[position] !== job.urls[position]) return;-- await db.run(`INSERT INTO link_previews (message_scope, message_id, position, url, data) VALUES (?, ?, ?, ?, ?)++ const written = await db.run(`INSERT INTO link_previews (message_scope, message_id, position, url, data) VALUES (?, ?, ?, ?, ?)ON CONFLICT (message_scope, message_id, position)DO UPDATE SET url = EXCLUDED.url, data = EXCLUDED.dataWHERE link_previews.removed_at IS NULL`,[job.scope, job.messageId, position, preview.url, JSON.stringify(preview)]);++ // The card's picture is kept with the message it is shown under: the picture leaves its day-long life (an++ // attachment nobody sent to a message) and lives in Media's kept window instead, so it is still there long++ // after the day. A picture the cache shows again for another message is kept again for that message too.++ if (written && written.changes && preview.image_id) {++ await mediaImport.keepPicture(preview.image_id, job.messageId).catch(() => false);++ }}async function replaceForEdit(scope, messageId) {@@@ -671,13 +711,34 @@ async function attach(rows, scope = 'room', { enabled = true } = {}) {if (!byId.has(key)) byId.set(key, []);byId.get(key).push(preview);}-- for (const row of list) row.embeds = byId.get(Number(row.id)) || [];++ for (const row of list) {++ // Cards that were taken back are not sent on a read, even when the rows are still there.++ if (isHidden(row)) continue;++ row.embeds = byId.get(Number(row.id)) || [];++ }return rows;}++/** A message whose cards were taken back (room_messages.embeds_hidden). */++function isHidden(row) {++ const v = row && row.embeds_hidden;++ return v === true || v === 1 || v === '1' || v === 't' || v === 'true';++}++++/**++ * Take a message's cards back, or put them back. The flag lives on the message, so the cards can come back;++ * the preview rows stay in the table (they are not deleted), so a job that is still running cannot change the++ * answer. The author or a room moderator decides; the route checks that.++ */++async function hide(scope, messageId, hidden) {++ const id = Number(messageId) || 0;++ if (scope === 'room') await db.run('UPDATE room_messages SET embeds_hidden = ? WHERE id = ?', [hidden ? true : false, id]);++ return { hidden: !!hidden, embeds: hidden ? [] : await embedsOf(scope, id) };++}++module.exports = {-- addresses, normalizeUrl, textOf, decodeEntities, pageFields, previewFromResponse, previewFromOembed, imageOf,-- previewOf, schedule, embedsOf, attach, remove, replaceForEdit, idle, ownLink,++ addresses, normalizeUrl, textOf, decodeEntities, pageFields, previewFromResponse, previewFromOembed, imageOf, httpsAddress,++ previewOf, schedule, embedsOf, attach, remove, hide, isHidden, replaceForEdit, idle, ownLink,CACHE_MS, MISS_MS, MAX_URLS, MAX_BYTES, FETCH_TIMEOUT_MS, MAX_REDIRECTS, PREVIEWS_ON,_setRequest(fn) { _request = fn || null; },_setLookup(fn) { _lookup = fn || null; },nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++/**++ * Bringing a page's own picture into OpenVibe.Media (server/chat/link-previews.js).++ *++ * A page's og:image lives on somebody else's host, and a card must never hot-link it: the reader's browser++ * would tell that host who is reading. So the link preview job asks Media to fetch the picture once and keep++ * it: Media fetches it through its own egress rules (https only, public addresses only, at most 2 MB, images++ * only) and stores it as an unlisted attachment with a short life. The card then shows the Media address.++ *++ * The picture belongs to Chat (Chat brought it in), so Chat may keep it with the message it is shown under++ * (keepPicture): it then lives in Media's kept window (30 days) instead of the day-long one, and it is kept++ * again for every message that shows it. A card's picture is therefore still there long after the day.++ *++ * The call is Chat's own service token (client `chat`, audience openvibe.media, scope media.object.upload) —++ * the same one that stores an emote's image. A picture that cannot be brought in is simply left off the card:++ * this never throws, and a message never waits for it.++ */++const config = require('../config');++const serviceAuth = require('../net/service-auth');++++const AUDIENCE = 'openvibe.media';++const TIMEOUT_MS = 8000;++let _fetch = null;++++/** Media is asked through its internal address; the caller needs a service token to do that. */++function enabled() {++ return !!(config.media && config.media.baseUrl && config.oauth.clientSecret);++}++++/**++ * The Media picture of one https address: { id, url } — the address Media serves it from and the id of the++ * attachment behind it (the id is what a keep names). Null when there is no picture for it (no address, a++ * picture Media refuses, a picture that is not an image, Media down). Never throws.++ */++async function importPicture(url) {++ const address = String(url || '').trim();++ if (!/^https:\/\/[^\s]+$/i.test(address)) return null;++ if (!enabled()) return null;++ // A test that does not stub the network never makes one.++ if (process.env.NODE_ENV === 'test' && !_fetch) return null;++ try {++ const headers = await serviceAuth.headers(AUDIENCE, config.media.scope);++ const send = _fetch || globalThis.fetch;++ const res = await send(`${config.media.baseUrl}/api/v1/attachments/import`, {++ method: 'POST',++ headers: { ...headers, 'content-type': 'application/json', accept: 'application/json' },++ body: JSON.stringify({ url: address }),++ signal: AbortSignal.timeout(TIMEOUT_MS),++ });++ if (!res || (Number(res.status) !== 200 && Number(res.status) !== 201)) return null;++ const body = await res.json().catch(() => null);++ const out = body && typeof body.url === 'string' ? body.url : null;++ if (!out || !/^https:\/\//i.test(out)) return null;++ const id = body && typeof body.id === 'string' ? body.id : null;++ return id ? { id, url: out } : null;++ } catch {++ return null;++ }++}++++/**++ * Keep the picture with the message it is shown under: Media's POST /api/v1/attachments/:id/keep with that++ * message's id. The picture leaves its day-long life (an attachment that was never sent to a message lives++ * 24 hours) and lives in the kept window instead (Media's keepDays: 30 days), so a card's picture is still++ * there a month later. A picture that is shown by a second message is kept again for that message.++ *++ * The picture belongs to Chat (Chat brought it in), so Chat is the one who may keep it. Never throws: a++ * picture that cannot be kept is left as it is, and the page drops it if it ever stops loading.++ */++async function keepPicture(id, messageId) {++ const attachment = String(id || '').trim();++ const message = String(messageId == null ? '' : messageId).trim();++ if (!/^att_[A-Za-z0-9_-]{1,64}$/.test(attachment)) return false;++ if (!message || message.length > 200) return false;++ if (!enabled()) return false;++ // A test that does not stub the network never makes one.++ if (process.env.NODE_ENV === 'test' && !_fetch) return false;++ try {++ const headers = await serviceAuth.headers(AUDIENCE, config.media.scope);++ const send = _fetch || globalThis.fetch;++ const res = await send(`${config.media.baseUrl}/api/v1/attachments/${encodeURIComponent(attachment)}/keep`, {++ method: 'POST',++ headers: { ...headers, 'content-type': 'application/json', accept: 'application/json' },++ body: JSON.stringify({ message_id: message }),++ signal: AbortSignal.timeout(TIMEOUT_MS),++ });++ return !!(res && Number(res.status) === 200);++ } catch {++ return false;++ }++}++++module.exports = { importPicture, keepPicture, AUDIENCE, enabled, _setFetch(fn) { _fetch = fn || null; } };@@@ -416,7 +416,7 @@ async function postAsAi(room, member, text, { now = Date.now(), how = null, card}// A message an AI member wrote (ai_member_id; user_id 0) takes the member's name and handle.--const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.card, m.files, m.reply_to_id, m.created_at, m.edited_at, m.is_deleted, m.ai_member_id, m.agent_stream, m.how, m.results, m.vibe_model,++const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.card, m.files, m.reply_to_id, m.created_at, m.edited_at, m.is_deleted, m.ai_member_id, m.agent_stream, m.how, m.results, m.vibe_model, m.embeds_hidden,CASE WHEN m.ai_member_id IS NOT NULL THEN a.handle WHEN m.agent_stream THEN 'aiagent' ELSE u.username END AS username,CASE WHEN m.ai_member_id IS NOT NULL THEN a.name WHEN m.agent_stream THEN 'AI agent' ELSE COALESCE((SELECT sm.nickname FROM chat_server_members sm JOIN rooms sr ON sr.server_id = sm.server_id@@@ -578,6 +578,19 @@ async function update(room, actor, { name, topic, visibility, slow_seconds, joinreturn await publicRoom(await db.get('SELECT * FROM rooms WHERE id = ?', [room.id]));}++/**++ * The room's link previews switch: its owner and its moderators set it (server/rooms/routes.js). Off: no new++ * address in the room is fetched, and the reads show no previews. The room's own setting, not the PATCH above++ * (which only the owner may use).++ */++async function setLinkPreviews(room, actor, on) {++ const a = await access(room, actor);++ if (!a.moderate) fail(403, 'rooms.not_mod', 'Only the room\'s owner and moderators change this');++ const value = on === true || on === 1 || on === '1' || on === 'on';++ await db.run('UPDATE rooms SET link_previews = ? WHERE id = ?', [value, room.id]);++ return await publicRoom(await db.get('SELECT * FROM rooms WHERE id = ?', [room.id]));++}++/** The room's people, by role. `seen` adds last_seen_at (moderators only: it is presence). */async function members(room, { seen = false, subjects = false } = {}) {// A server's channel: the server's people, with the role each has in the channel.@@@ -647,7 +660,7 @@ module.exports = {LIMITS,isWorkspace, isStaffRoom,postAsAi,-- ensureSchema, create, list, bySlug, access, join, leave, post, editMessage, history, historyPage, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,++ ensureSchema, create, list, bySlug, access, join, leave, post, editMessage, history, historyPage, markRead, deleteMessage, setRole, update, setLinkPreviews, members, publicRoom, messageById,attach, detach, attachments, joinRoleOf, unreadTotal, isStaff, siteBanned, insightOn, POSTERS, TALKERS,RoomError, SLUG_RE, KINDS, ROLES, KIND_ROLES, JOIN_ROLE, JOIN_ROLES_CALL, ATTACH_SERVICES, _reset,};@@@ -12,6 +12,8 @@* GET /:slug/messages ?before=<id> | ?after=<id>, &limit* POST /:slug/messages { message }* DELETE /:slug/messages/:id the author, a room moderator or chat staff++ * POST /:slug/messages/:id/embeds/hidden the author, a moderator: the message's cards are taken back (or put back)++ * PATCH /:slug/link-previews the owner and the moderators: the room's link previews on or off* POST /:slug/join | /leave | /read { last_id? }* GET /:slug/members (moderators also get last_seen_at)* POST /:slug/members owner/mods: { username, role } — one of the kind's roles (rooms.KIND_ROLES) or none@@@ -305,6 +307,41 @@ router.get('/:slug/messages/:id/edits', requireAuth, async (req, res) => {return res.set('Cache-Control', 'private, no-store').json({ versions: row.edit_history || [] });});++/**++ * The cards of a room message, taken back (server/chat/link-previews.js): the message's author, the room's owner++ * or its moderators decide. The message keeps the flag, so the cards can be put back; while it is set, a read++ * sends no cards for that message. The room hears it at once (room_message_embeds with hidden: true).++ */++router.post('/:slug/messages/:id/embeds/hidden', requireAuth, deleteLimit, async (req, res) => {++ const room = await readable(req, res); if (!room) return;++ try {++ const id = parseInt(req.params.id, 10);++ const message = await db.get('SELECT id, user_id FROM room_messages WHERE id = ? AND room_id = ?', [id, room.id]);++ if (!message) throw new rooms.RoomError(404, 'rooms.no_message', 'No such message');++ const a = await rooms.access(room, req.user);++ if (Number(message.user_id) !== Number(req.user.id) && !a.moderate) throw new rooms.RoomError(403, 'rooms.embeds', 'Only the author or a room moderator hides a preview');++ const b = req.body || {};++ const hidden = b.hidden === undefined ? true++ : !(b.hidden === false || b.hidden === 0 || b.hidden === '0' || b.hidden === 'false' || b.hidden === 'off' || b.hidden === 'no');++ const out = await linkPreviews.hide('room', id, hidden);++ chatServer().broadcastToRoom(room.id, { type: 'room_message_embeds', room: room.slug, id, embeds: out.embeds, hidden: out.hidden });++ res.json({ ok: true, id, hidden: out.hidden, embeds: out.embeds });++ } catch (err) { send(res, err); }++});++++/**++ * The room's link previews switch (server/rooms/rooms.js): its owner and its moderators set it. Off: no new++ * address in the room is fetched, and the reads show no previews.++ */++router.patch('/:slug/link-previews', requireAuth, manageLimit, async (req, res) => {++ const room = await readable(req, res); if (!room) return;++ try {++ const b = req.body || {};++ const on = b.link_previews === undefined ? true : (b.link_previews === true || b.link_previews === 1 || b.link_previews === '1' || b.link_previews === 'on');++ res.json({ room: await rooms.setLinkPreviews(room, req.user, on) });++ } catch (err) { send(res, err); }++});++/*** A preview on a room message, taken back by its author or a room moderator (server/chat/link-previews.js): with a* position, that one address; without, every preview on the line. The row becomes a tombstone, so a job still@@@ -937,8 +937,14 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a? `<span class="oc-name oc-ai-name">${esc(m.display_name || 'AI')}</span> <span class="oc-badge oc-ai" title="An AI member of this room">AI</span>`: `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)}`;// An AI member writes Markdown (lists, bold, links): vibe-md.js escapes every character first, then marks it up.++ // The cards of this message: the author and the room's moderators may take them back, and put them back.++ const mayHide = !!(can.moderate || (me && !ai && !agent && Number(m.user_id) === Number(me.id)));++ const hideAction = mayHide ? `/r/${esc(slug)}/embeds/${Number(m.id) || 0}/hidden` : null;++ const cards = m.embeds_hidden++ ? `<span class="oc-card-hidden-note">Previews hidden</span>${mayHide ? ui.showForm(hideAction) : ''}`++ : ui.messageCards(m, { hide: hideAction });const text = ai ? `<div class="oc-text vb-md">${md.render(m.message)}</div>` : `<span class="oc-text">${emojis ? require('../chat/emoji-text').render(m.message, emojis, linkify, esc) : linkify(m.message)}</span>`;-- return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}${agent ? ' oc-msg-agent' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} ${text}${m.edited_at ? ` <span class="oc-muted oc-edited" data-edited-at="${esc(m.edited_at)}">edited · ${esc(hhmm(m.edited_at).text)}</span>` : ''}${ai ? '' : pictures.pageHtml(m.message)}${ui.messageCards(m)}${can.moderate && m.edited_at ? '<button type="button" class="ovk-btn ovk-btn--quiet oc-edits">Earlier versions</button>' : ''}${del}</li>`;++ return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}${agent ? ' oc-msg-agent' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} ${text}${m.edited_at ? ` <span class="oc-muted oc-edited" data-edited-at="${esc(m.edited_at)}">edited · ${esc(hhmm(m.edited_at).text)}</span>` : ''}${ai ? '' : pictures.pageHtml(m.message)}${cards}${can.moderate && m.edited_at ? '<button type="button" class="ovk-btn ovk-btn--quiet oc-edits">Earlier versions</button>' : ''}${del}</li>`;};const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);@@@ -1211,6 +1217,35 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,res.redirect(303, `/r/${room.slug}`);}));++ // The cards of a message, taken back or put back: the same checks, the same answer and the same event as the++ // API route (server/rooms/routes.js), so a form and the JavaScript client behave the same way.++ router.post('/r/:slug/embeds/:id/hidden', formLimit, sameSite, roomsOrError(async (req, res, next) => {++ const actor = await needUser(req, res); if (!actor) return;++ const room = await rooms.bySlug(req.params.slug);++ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');++ req.roomBack = `/r/${room.slug}`;++ const id = parseInt(req.params.id, 10);++ const raw = String((req.body && req.body.hidden) === undefined ? '1' : (req.body && req.body.hidden));++ const hidden = !(raw === '0' || raw === 'off' || raw === 'false' || raw === 'no');++ return apiBridge(roomRoutes, `/${encodeURIComponent(room.slug)}/messages/${Number(id) || 0}/embeds/hidden`, {++ body: () => ({ hidden }),++ ok: (r, s) => s.redirect(303, `/r/${room.slug}`),++ fail: (r, s, status, error) => s.redirect(303, `/r/${room.slug}?error=${encodeURIComponent(error || 'That did not work')}`),++ })(req, res, next);++ }));++++ // The room's link previews switch: its owner and its moderators set it (server/rooms/rooms.js).++ router.post('/r/:slug/link-previews', formLimit, sameSite, roomsOrError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const room = await rooms.bySlug(req.params.slug);++ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');++ req.roomBack = `/r/${room.slug}/settings`;++ const b = req.body || {};++ const on = b.has_link_previews ? (b.link_previews === '1' || b.link_previews === 'on' || b.link_previews === 'true') : true;++ await rooms.setLinkPreviews(room, actor.user, on);++ res.redirect(303, `/r/${room.slug}/settings?saved=1`);++ }));++router.get('/r/:slug/settings', async (req, res) => {const x = await loadRoom(req, res); if (!x) return;const { actor, room, can } = x;@@@ -1310,6 +1345,9 @@ ${room.slug !== 'staff' && (room.kind || 'community') === 'community' && Number(${rooms.isWorkspace(room) ? `<label>Shared screen <input name="screen_url" type="url" value="${esc(room.screen_url || '')}" placeholder="https://openvibe.bot/screen/…"> <span class="oc-muted oc-small">A screen share link from OpenVibe.Bot (a computer's owner makes it on its screen page). Members see the screen beside the conversation; with a "use" link they can click and type on it. Empty: no screen.</span></label>` : ''}<p><button type="submit">Save</button></p></form><p><a href="/r/${esc(room.slug)}/memory">What this room remembers</a> <span class="oc-muted oc-small">— the notes the AI keeps between summaries; you can correct or remove them.</span></p></section>` : ''}++${can.moderate ? `<section class="oc-set"><h2>Link previews</h2><form class="oc-form oc-set-form" method="post" action="/r/${esc(room.slug)}/link-previews">++<input type="hidden" name="has_link_previews" value="1"><label class="oc-set-row oc-set-inline"><span class="oc-set-text">Cards under a message<small>When a message carries a web address, a card under it shows the page. Off: nothing is fetched for this room, and no cards are shown.</small></span><input type="checkbox" class="oc-switch" name="link_previews" value="1"${room.link_previews !== false ? ' checked' : ''}></label>++<p><button type="submit">Save</button></p></form></section>` : ''}<section class="oc-set" aria-labelledby="oc-members-h"><h2 id="oc-members-h">Members <span class="oc-count">${members.length}</span></h2><p class="oc-muted oc-set-note">${esc(roleHelp(room.kind))}</p><form class="oc-find oc-find-inset" method="post" action="/r/${esc(room.slug)}/members">${IC.plus}<label for="oc-invite" class="oc-sr">Add someone by their username</label><input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="Add by username" autocomplete="off"><select name="role" aria-label="Their role">${choices.filter((r) => r !== 'none' && r !== 'blocked').map((r) => roleOption(r, addRole)).join('')}</select><button type="submit" class="oc-send" aria-label="Add" title="Add">${IC.send}</button></form>@@@ -112,7 +112,11 @@ function cardHref(value) {return u.protocol === 'https:' ? u.href : null;} catch { return null; }}--/** A card's picture: only https on openvibe.media, as the resolver also enforces. */++/**++ * A card's picture: only https on openvibe.media, as the resolver also enforces. The picture carries++ * data-oc-card-pic, so the client (public/web/chat.js) can drop this one when it does not load: a card never++ * shows a broken image, and the card itself (its title, its description, its link) stays.++ */function cardImage(value) {try {const u = new URL(String(value || ''));@@@ -136,16 +140,29 @@ function linkCard(card) {const head = href? `<a class="oc-card-title" href="${esc(href)}" rel="noopener nofollow" target="_blank">${esc(title)}</a>`: `<span class="oc-card-title">${esc(title)}</span>`;-- const pic = image ? `<a class="oc-card-pic" href="${esc(href || image)}" rel="noopener nofollow" target="_blank"><img src="${esc(image)}" alt="" loading="lazy" decoding="async"></a>` : '';++ const pic = image ? `<a class="oc-card-pic" href="${esc(href || image)}" rel="noopener nofollow" target="_blank"><img src="${esc(image)}" alt="" loading="lazy" decoding="async" data-oc-card-pic="1"></a>` : '';const go = actionHref ? `<a class="oc-card-go" href="${esc(actionHref)}" rel="noopener nofollow" target="_blank">${esc(action.label)}</a>` : '';return `<div class="oc-card" data-kind="${esc(typeof card.kind === 'string' ? card.kind : 'link')}">${pic}<div class="oc-card-body">${head}${line ? `<p class="oc-card-line">${esc(line)}</p>` : ''}${note ? `<p class="oc-card-note">${esc(note)}</p>` : ''}${go}</div></div>`;}++/**++ * The small × that takes a message's cards back, and the button that puts them back (server/rooms/routes.js).++ * Only the message's author and the room's moderators get them: the page decides who may (server/web/pages.js).++ */++function hideForm(action) {++ if (!action) return '';++ return `<form class="oc-card-hide" method="post" action="${esc(action)}"><input type="hidden" name="hidden" value="1"><button type="submit" class="oc-card-hide-btn" aria-label="Hide the preview" title="Hide the preview">×</button></form>`;++}++function showForm(action) {++ if (!action) return '';++ return `<form class="oc-inline oc-card-show" method="post" action="${esc(action)}"><input type="hidden" name="hidden" value="0"><button type="submit" class="oc-link">Show previews</button></form>`;++}++/*** An address preview under a message (server/chat/link-previews.js): the picture when there is one, the address's* title as the link, its description, and "by" and LIVE for OpenVibe's own kinds. Every field is data: escaped here.*/--function embedCard(e) {++function embedCard(e, opts) {if (!e || typeof e !== 'object' || typeof e.title !== 'string' || !e.title) return '';if (e.kind === 'agent-run') {const run = String(e.run_url || ''), events = String(e.events_url || '');@@@ -160,17 +177,18 @@ function embedCard(e) {const head = href? `<a class="oc-card-title" href="${esc(href)}" rel="noopener nofollow" target="_blank">${esc(e.title)}</a>`: `<span class="oc-card-title">${esc(e.title)}</span>`;-- const pic = image ? `<a class="oc-card-pic" href="${esc(href || image)}" rel="noopener nofollow" target="_blank"><img src="${esc(image)}" alt="" loading="lazy" decoding="async"></a>` : '';++ const pic = image ? `<a class="oc-card-pic" href="${esc(href || image)}" rel="noopener nofollow" target="_blank"><img src="${esc(image)}" alt="" loading="lazy" decoding="async" data-oc-card-pic="1"></a>` : '';const description = typeof e.description === 'string' && e.description ? `<p class="oc-card-line">${esc(e.description)}</p>` : '';const by = typeof e.by === 'string' && e.by ? `<p class="oc-card-line">by ${esc(e.by)}</p>` : '';const live = e.live === true ? '<p class="oc-card-note"><span class="oc-badge oc-badge-mod">LIVE</span></p>' : '';-- return `<div class="oc-card" data-kind="${esc(kind)}">${pic}<div class="oc-card-body">${head}${description}${by}${live}</div></div>`;++ const hide = opts && opts.hide ? hideForm(opts.hide) : '';++ return `<div class="oc-card" data-kind="${esc(kind)}">${pic}<div class="oc-card-body">${head}${description}${by}${live}</div>${hide}</div>`;}/** A message's previews (up to three, in the order the addresses appeared), or its one known-link card when it has none. */--function messageCards(m) {++function messageCards(m, opts) {if (!m || typeof m !== 'object') return '';-- if (Array.isArray(m.embeds) && m.embeds.length) return m.embeds.slice(0, 3).map(embedCard).filter(Boolean).join('');++ if (Array.isArray(m.embeds) && m.embeds.length) return m.embeds.slice(0, 3).map((e) => embedCard(e, opts)).filter(Boolean).join('');return m.card && m.card.kind === 'agent-run' ? embedCard(m.card) : linkCard(m.card);}@@@ -178,4 +196,4 @@ const hueOf = (slug) => { let h = 0; for (const c of String(slug)) h = (h * 31 +const serverLetters = (name) => String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase() || '?';const serverMark = (s) => `<span class="oc-srv-icon oc-srv-icon--row" style="--oc-hue:${hueOf(s.slug)}" aria-hidden="true">${esc(serverLetters(s.name))}</span>`;--module.exports = { head, when, initials, presenceState, presenceDot, presenceOrder, presenceLine, hueOf, serverLetters, serverMark, linkCard, embedCard, messageCards };++module.exports = { head, when, initials, presenceState, presenceDot, presenceOrder, presenceLine, hueOf, serverLetters, serverMark, linkCard, embedCard, messageCards, hideForm, showForm };nnew file mode 100644@@@ -0,0 +1,125 @@++'use strict';++/**++ * A card's picture that does not load is dropped (public/web/chat.js, the markup from server/web/ui.js): the++ * picture goes, the card stays (its title, its description). A card never shows a broken image.++ *++ * The client is run here with a small DOM of its own, so the rule is checked as it runs: the picture the server++ * drew, the picture the client drew, and a picture that never loaded at all.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++// A node of the little DOM: a card, the link that holds its picture, the picture itself.++function node(tag, cls) {++ return {++ tagName: String(tag || '').toUpperCase(), className: cls || '', attrs: {}, children: [], parentNode: null, listeners: {},++ textContent: '',++ setAttribute(k, v) { this.attrs[k] = String(v); },++ getAttribute(k) { return this.attrs[k] == null ? null : this.attrs[k]; },++ hasAttribute(k) { return this.attrs[k] != null; },++ appendChild(c) { c.parentNode = this; this.children.push(c); return c; },++ removeChild(c) { this.children = this.children.filter((x) => x !== c); c.parentNode = null; return c; },++ remove() { if (this.parentNode) this.parentNode.removeChild(this); },++ addEventListener(ev, fn) { (this.listeners[ev] = this.listeners[ev] || []).push(fn); },++ closest(sel) {++ const cls = String(sel || '').replace(/^\./, '');++ let cur = this;++ while (cur) {++ if (String(cur.className || '').split(/\s+/).includes(cls)) return cur;++ cur = cur.parentNode;++ }++ return null;++ },++ querySelectorAll() { return []; },++ querySelector() { return null; },++ };++}++++/** A card with its picture: the card, the link that holds the picture, the picture itself. */++function cardWithPicture({ broken = false } = {}) {++ const card = node('div', 'oc-card');++ const body = node('div', 'oc-card-body');++ const title = node('span', 'oc-card-title');++ title.textContent = 'A card';++ body.appendChild(title);++ const link = node('a', 'oc-card-pic');++ const picture = node('img');++ picture.setAttribute('data-oc-card-pic', '1');++ picture.src = 'https://openvibe.media/a/att_01J/og.png';++ picture.complete = broken;++ picture.naturalWidth = broken ? 0 : 640;++ link.appendChild(picture);++ card.appendChild(link);++ card.appendChild(body);++ return { card, body, link, picture };++}++++/** The client, run with a DOM of its own. */++function runClient(pictures) {++ const docHandlers = {};++ const document = {++ documentElement: { classList: { add() {}, remove() {} } },++ hidden: false,++ cookie: '',++ getElementById: () => null,++ createElement: (tag) => node(tag),++ createTextNode: (text) => Object.assign(node('#text'), { textContent: text }),++ querySelector: () => null,++ querySelectorAll: (sel) => (String(sel).includes('data-oc-card-pic') ? pictures : []),++ addEventListener(type, fn) { (docHandlers[type] = docHandlers[type] || []).push(fn); },++ body: node('body'),++ };++ const ctx = {++ window: { __OV_PAGE: { chat: { view: 'none' } }, addEventListener() {} },++ document,++ navigator: {},++ console,++ URL,++ setTimeout: () => 0,++ clearTimeout: () => {},++ setInterval: () => 0,++ clearInterval: () => {},++ fetch: () => Promise.reject(new Error('no network here')),++ };++ vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'public', 'web', 'chat.js'), 'utf8'), ctx);++ return { document, docHandlers, window: ctx.window };++}++++// ── The picture that fails to load is dropped, and the card stays ──++{++ const { card, body, link, picture } = cardWithPicture();++ const { docHandlers } = runClient([picture]);++ assert.strictEqual(picture.listeners.error && picture.listeners.error.length, 1, 'the client watches the picture');++ picture.listeners.error[0]();++ assert.strictEqual(card.children.includes(link), false, 'the picture is gone');++ assert.strictEqual(card.children.includes(body), true, 'the card stays');++ assert.strictEqual(body.children.length, 1, 'with its title');++ // The same picture, failing later (the page's own handler catches it too).++ const second = cardWithPicture();++ const client = runClient([second.picture]);++ client.docHandlers.error.forEach((fn) => fn({ target: second.picture }));++ assert.strictEqual(second.card.children.includes(second.link), false, 'a picture that fails later is dropped as well');++ assert.strictEqual(second.card.children.includes(second.body), true, 'and the card stays');++}++++// ── A picture that never loaded is dropped too ──++{++ const { card, body, link, picture } = cardWithPicture({ broken: true });++ runClient([picture]);++ assert.strictEqual(card.children.includes(link), false, 'a picture that never loaded is dropped');++ assert.strictEqual(card.children.includes(body), true, 'and the card stays');++}++++// ── The server draws the same picture, marked so the client can drop it ──++{++ const ui = require('../server/web/ui');++ const html = ui.embedCard({ url: 'https://example.com/page', title: 'A card', kind: 'link', image: 'https://openvibe.media/a/att_01J/og.png' });++ assert.ok(html.includes('data-oc-card-pic'), 'the server marks the picture it draws');++ assert.ok(html.includes('class="oc-card-pic"'), 'in the card picture');++ const known = ui.linkCard({ url: 'https://example.com/page', title: 'A card', image: 'https://openvibe.media/a/att_01J/og.png' });++ assert.ok(known.includes('data-oc-card-pic'), 'the known-link card marks its picture too');++}++++console.log('card picture: a picture that does not load is dropped, the card stays');nnew file mode 100644@@@ -0,0 +1,263 @@++'use strict';++/**++ * The cards of a message (server/chat/link-previews.js, server/rooms/routes.js):++ *++ * - taking them back: the message's author, the room's owner and its moderators may hide them, nobody else++ * may; a hidden card is not sent on a read, the room is told at once, and the cards can be put back;++ * - the room's switch: its owner and its moderators turn the cards off and on again;++ * - the page's own picture: it is brought into OpenVibe.Media (server/chat/media-import.js) and the card++ * shows the Media address, never a hot-linked one.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++const t = suite('link preview cards');++let h, alice, mod, bob, carol, previews, mediaImport;++++const page = (head) => `<html><head>${head}</head><body>body</body></html>`;++/** The picture Media brings in: its id (the one a keep names) and the address the card shows. */++const PICTURE = 'att_01JZ0M0Q8F3K2V7YB6N4T5R9WQ';++/** Media is stubbed here: the import answers with the picture, the keep answers that it is kept. */++const mediaStub = (calls) => async (url, opts) => {++ calls.push({ url: String(url), auth: String((opts && opts.headers && (opts.headers.Authorization || opts.headers.authorization)) || ''), body: JSON.parse(opts.body) });++ const keep = String(url).endsWith('/keep');++ return {++ status: keep ? 200 : 201,++ json: async () => (keep++ ? { id: PICTURE, message_id: (JSON.parse(opts.body) || {}).message_id, kept_for: [] }++ : { id: PICTURE, kind: 'image', mime: 'image/png', url: `https://openvibe.media/a/${PICTURE}/picture.png` }),++ };++};++const type = (body) => ({ status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body });++const room = (method, path, user, body) => h.http(method, `/api/chat/rooms${path}`, { token: user && user.token, body });++/** A form the site's own pages post (a cookie, not a bearer). */++const form = async (p, body, token) => {++ const res = await fetch(`${h.base}${p}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: `ov_token=${token}` }, body: new URLSearchParams(body).toString(), redirect: 'manual' });++ return { status: res.status, location: res.headers.get('location') };++};++/** A room of its own, so one test's messages never count against another's posting budget. */++const newRoom = async (name) => {++ const made = await room('POST', '/', alice, { name });++ assert.strictEqual(made.status, 201, made.text);++ return made.body.room;++};++/**++ * A room of bob's own, with alice in it: the room budget is per caller, and this file already makes as many++ * rooms as alice may make in a minute.++ */++const newRoomFor = async (name) => {++ const made = await room('POST', '/', bob, { name });++ assert.strictEqual(made.status, 201, made.text);++ await room('POST', `/${made.body.room.slug}/join`, alice);++ return made.body.room;++};++const withPreview = async (slug, text, html) => {++ previews._setRequest(async () => type(html));++ const sent = await room('POST', `/${slug}/messages`, alice, { message: text });++ assert.strictEqual(sent.status, 201, sent.text);++ await previews.idle();++ return sent.body.message;++};++++t('boot', async () => {++ h = await boot();++ previews = require('../server/chat/link-previews');++ mediaImport = require('../server/chat/media-import');++ alice = h.addUser('alice', { subject: ids.newId('user') });++ mod = h.addUser('mod', { subject: ids.newId('user'), role: 'global_mod' });++ bob = h.addUser('bob', { subject: ids.newId('user') });++ carol = h.addUser('carol', { subject: ids.newId('user') });++ for (const u of [alice, mod, bob, carol]) {++ await h.ctx.upsertUser(h.live.users.get(u.id));++ h.netModules.subjects.add(u.subject_id);++ }++ await h.ctx.sync();++});++++t('the author takes the cards back: they are not sent on a read, and the room is told at once', async () => {++ const r = await newRoom('Taken back');++ const message = await withPreview(r.slug, 'https://93.184.216.34/take-me-back', page('<meta property="og:title" content="Take me back">'));++ const before = await room('GET', `/${r.slug}/messages`, alice);++ assert.strictEqual(before.body.messages.find((m) => m.id === message.id).embeds.length, 1, 'the card is there to begin with');++++ const ws = await h.ws({ token: bob.token });++ ws.sendJson({ type: 'join_room', room: r.slug, token: bob.token });++ await ws.next((m) => m.type === 'room_joined');++++ const hidden = await room('POST', `/${r.slug}/messages/${message.id}/embeds/hidden`, alice, { hidden: true });++ assert.strictEqual(hidden.status, 200, hidden.text);++ assert.strictEqual(hidden.body.hidden, true);++ assert.deepStrictEqual(hidden.body.embeds, [], 'the answer carries no cards');++ const event = await ws.next((m) => m.type === 'room_message_embeds' && Number(m.id) === message.id);++ assert.strictEqual(event.hidden, true, 'open clients are told at once');++ assert.deepStrictEqual(event.embeds, []);++++ const after = await room('GET', `/${r.slug}/messages`, alice);++ const row = after.body.messages.find((m) => m.id === message.id);++ assert.deepStrictEqual(row.embeds, [], 'a hidden card is not sent on a read');++ assert.ok(row.embeds_hidden, 'the message says its cards are gone');++ ws.close();++});++++t('the cards come back when the author says so', async () => {++ const r = await newRoom('Put back');++ const message = await withPreview(r.slug, 'https://93.184.216.34/put-me-back', page('<meta property="og:title" content="Put me back">'));++ await room('POST', `/${r.slug}/messages/${message.id}/embeds/hidden`, alice, { hidden: true });++ const back = await room('POST', `/${r.slug}/messages/${message.id}/embeds/hidden`, alice, { hidden: false });++ assert.strictEqual(back.status, 200, back.text);++ assert.strictEqual(back.body.hidden, false);++ assert.strictEqual(back.body.embeds.length, 1, 'the cards are back');++ const got = await room('GET', `/${r.slug}/messages`, alice);++ const row = got.body.messages.find((m) => m.id === message.id);++ assert.strictEqual(row.embeds.length, 1, 'and they are sent on a read again');++ assert.ok(!row.embeds_hidden);++});++++t('a room moderator may take them back; another member may not', async () => {++ const r = await newRoom('Moderated cards');++ const message = await withPreview(r.slug, 'https://93.184.216.34/moderated-cards', page('<meta property="og:title" content="Moderated">'));++ await room('POST', `/${r.slug}/join`, carol);++ const refused = await room('POST', `/${r.slug}/messages/${message.id}/embeds/hidden`, carol, { hidden: true });++ assert.strictEqual(refused.status, 403, 'a member is not the author and not a moderator');++ const done = await room('POST', `/${r.slug}/messages/${message.id}/embeds/hidden`, mod, { hidden: true });++ assert.strictEqual(done.status, 200, done.text);++ assert.strictEqual(done.body.hidden, true);++ const got = await room('GET', `/${r.slug}/messages`, alice);++ assert.deepStrictEqual(got.body.messages.find((m) => m.id === message.id).embeds, []);++});++++t('the owner and the moderators turn the cards off and on again', async () => {++ const r = await newRoom('Switch room');++ const off = await room('PATCH', `/${r.slug}/link-previews`, mod, { link_previews: 'off' });++ assert.strictEqual(off.status, 200, off.text);++ assert.strictEqual(off.body.room.link_previews, false, 'a moderator may switch them off');++ const refused = await room('PATCH', `/${r.slug}/link-previews`, carol, { link_previews: 'off' });++ assert.strictEqual(refused.status, 403, 'a member may not');++++ previews._setRequest(async () => { throw new Error('off must not fetch'); });++ const sent = await room('POST', `/${r.slug}/messages`, alice, { message: 'https://93.184.216.34/while-off' });++ assert.strictEqual(sent.status, 201, sent.text);++ await previews.idle();++ const got = await room('GET', `/${r.slug}/messages`, alice);++ assert.deepStrictEqual(got.body.messages.find((m) => m.id === sent.body.message.id).embeds, [], 'no fetch and no cards while off');++++ const on = await room('PATCH', `/${r.slug}/link-previews`, alice, { link_previews: 'on' });++ assert.strictEqual(on.status, 200, on.text);++ assert.strictEqual(on.body.room.link_previews, true, 'the owner may switch them on');++ const again = await withPreview(r.slug, 'https://93.184.216.34/after-switch', page('<meta property="og:title" content="Back on">'));++ const after = await room('GET', `/${r.slug}/messages`, alice);++ assert.strictEqual(after.body.messages.find((m) => m.id === again.id).embeds[0].title, 'Back on', 'new messages get their cards again');++});++++t('the picture of a page is brought into Media, and the card never hot-links it', async () => {++ const r = await newRoom('Picture room');++ const calls = [];++ mediaImport._setFetch(mediaStub(calls));++ const message = await withPreview(r.slug, 'https://93.184.216.34/with-a-picture',++ page('<meta property="og:title" content="With a picture"><meta property="og:image" content="https://pictures.example/og.png">'));++ const got = await room('GET', `/${r.slug}/messages`, alice);++ const embed = got.body.messages.find((m) => m.id === message.id).embeds[0];++ assert.strictEqual(embed.image, `https://openvibe.media/a/${PICTURE}/picture.png`, 'the card shows the Media address');++ assert.ok(!JSON.stringify(embed).includes('pictures.example'), 'the other host is nowhere on the card');++ const imports = calls.filter((c) => c.url.endsWith('/attachments/import'));++ assert.strictEqual(imports.length, 1, 'Media is asked once');++ assert.match(imports[0].auth, /^Bearer /, 'the call carries a service token');++ assert.deepStrictEqual(imports[0].body, { url: 'https://pictures.example/og.png' }, 'Media is asked for that picture');++ const asked = h.tokenRequests.find((t2) => t2.audience === 'openvibe.media');++ assert.ok(asked, 'a service token for Media was asked for');++ assert.match(asked.scope, /media\.object\.upload/);++});++++t('the picture of a stored preview is kept with the message it is shown under', async () => {++ const r = await newRoomFor('Kept picture room');++ const calls = [];++ mediaImport._setFetch(mediaStub(calls));++ const message = await withPreview(r.slug, 'https://93.184.216.34/kept-picture',++ page('<meta property="og:title" content="Kept"><meta property="og:image" content="https://pictures.example/kept.png">'));++ const keeps = calls.filter((c) => c.url.endsWith('/keep'));++ assert.strictEqual(keeps.length, 1, 'the picture is kept once');++ assert.ok(keeps[0].url.includes(`/attachments/${PICTURE}/keep`), 'Media is asked to keep that picture');++ assert.deepStrictEqual(keeps[0].body, { message_id: String(message.id) }, 'and it is kept with the message it is shown under');++ assert.match(keeps[0].auth, /^Bearer /, 'the keep carries Chat\'s own service token');++});++++t('a picture the cache shows again for a second message is kept again for it', async () => {++ const r = await newRoomFor('Kept twice room');++ const calls = [];++ mediaImport._setFetch(mediaStub(calls));++ const address = 'https://93.184.216.34/kept-twice';++ const html = page('<meta property="og:title" content="Kept twice"><meta property="og:image" content="https://pictures.example/twice.png">');++ const first = await withPreview(r.slug, address, html);++ const second = await withPreview(r.slug, address, html);++ assert.strictEqual(calls.filter((c) => c.url.endsWith('/attachments/import')).length, 1, 'the picture is brought in once');++ const keeps = calls.filter((c) => c.url.endsWith('/keep'));++ assert.strictEqual(keeps.length, 2, 'and it is kept twice');++ assert.deepStrictEqual(keeps.map((c) => c.body.message_id), [String(first.id), String(second.id)], 'once for each message that shows it');++});++++t('a picture Media refuses leaves the card without one', async () => {++ const r = await newRoom('No picture room');++ mediaImport._setFetch(async () => ({ status: 422, json: async () => ({ error: 'that address is not a picture' }) }));++ const message = await withPreview(r.slug, 'https://93.184.216.34/not-a-picture',++ page('<meta property="og:title" content="No picture"><meta property="og:image" content="https://pictures.example/page.html">'));++ const got = await room('GET', `/${r.slug}/messages`, alice);++ const embed = got.body.messages.find((m) => m.id === message.id).embeds[0];++ assert.strictEqual(embed.image, null, 'no picture on the card');++ assert.ok(!JSON.stringify(embed).includes('pictures.example'), 'and no address from the other host');++});++++t('a picture that is too large leaves the card without one', async () => {++ const r = await newRoom('Big picture room');++ mediaImport._setFetch(async () => ({ status: 413, json: async () => ({ error: 'that picture is too large (2 MB at most)' }) }));++ const message = await withPreview(r.slug, 'https://93.184.216.34/big-picture',++ page('<meta property="og:title" content="Big picture"><meta property="og:image" content="https://pictures.example/big.png">'));++ const got = await room('GET', `/${r.slug}/messages`, alice);++ const embed = got.body.messages.find((m) => m.id === message.id).embeds[0];++ assert.strictEqual(embed.image, null, 'no picture on the card');++ assert.ok(!JSON.stringify(embed).includes('pictures.example'));++});++++t('the room page draws the × for those who may hide the cards, and for nobody else', async () => {++ const r = await newRoom('Drawn room');++ const message = await withPreview(r.slug, 'https://93.184.216.34/drawn', page('<meta property="og:title" content="Drawn">'));++ const mine = await (await fetch(`${h.base}/r/${r.slug}`, { headers: { cookie: `ov_token=${alice.token}` } })).text();++ assert.ok(mine.includes('/web/chat.js'), 'the room page loads the client that drops a picture that does not load');++ assert.ok(mine.includes('aria-label="Hide the preview"'), 'the author gets the ×');++ assert.ok(mine.includes(`/r/${r.slug}/embeds/${message.id}/hidden`), 'and the form that hides them');++ const other = await (await fetch(`${h.base}/r/${r.slug}`, { headers: { cookie: `ov_token=${carol.token}` } })).text();++ assert.ok(!other.includes('aria-label="Hide the preview"'), 'a member gets no ×');++ const modPage = await (await fetch(`${h.base}/r/${r.slug}`, { headers: { cookie: `ov_token=${mod.token}` } })).text();++ assert.ok(modPage.includes('aria-label="Hide the preview"'), 'a moderator does');++});++++t('the settings page shows the switch to the moderators, and its form saves it', async () => {++ const r = await newRoom('Settings room');++ const settings = await (await fetch(`${h.base}/r/${r.slug}/settings`, { headers: { cookie: `ov_token=${mod.token}` } })).text();++ assert.ok(settings.includes('Link previews'), 'the switch is in the room settings');++ assert.ok(settings.includes('name="link_previews"'), 'and it is a switch');++ const refused = await form(`/r/${r.slug}/link-previews`, { has_link_previews: '1' }, carol.token);++ assert.strictEqual(refused.status, 303, 'a member is sent back with the reason');++ assert.strictEqual((await room('GET', `/${r.slug}`, mod)).body.room.link_previews, true, 'and the switch stays on');++ const saved = await form(`/r/${r.slug}/link-previews`, { has_link_previews: '1' }, mod.token);++ assert.strictEqual(saved.status, 303, 'a moderator saves it');++ assert.strictEqual((await room('GET', `/${r.slug}`, mod)).body.room.link_previews, false, 'the switch is off');++});++++t('the pages own form takes the cards back and puts them back', async () => {++ const r = await newRoom('Form room');++ const message = await withPreview(r.slug, 'https://93.184.216.34/by-form', page('<meta property="og:title" content="By form">'));++ const hidden = await form(`/r/${r.slug}/embeds/${message.id}/hidden`, { hidden: '1' }, alice.token);++ assert.strictEqual(hidden.status, 303, 'the form answers with a redirect');++ const got = await room('GET', `/${r.slug}/messages`, alice);++ assert.deepStrictEqual(got.body.messages.find((m) => m.id === message.id).embeds, [], 'the cards are gone');++ const back = await form(`/r/${r.slug}/embeds/${message.id}/hidden`, { hidden: '0' }, alice.token);++ assert.strictEqual(back.status, 303);++ const again = await room('GET', `/${r.slug}/messages`, alice);++ assert.strictEqual(again.body.messages.find((m) => m.id === message.id).embeds.length, 1, 'and they are back');++});++++t.run(async () => {++ previews._reset();++ mediaImport._setFetch(null);++ await h.close();++});
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+-- phase: expand
+-- Taking a message's cards back whole (server/chat/link-previews.js, server/rooms/routes.js):
+--
+-- room_messages.embeds_hidden the author (or the room's owner or a moderator) took the previews of this
+-- message back. The cards are not sent on a read while the flag is set, and the
+-- flag can be cleared, so the cards can come back.
+--
+-- Expand only: one defaulted column; older releases never name it and keep showing the cards.
+ALTER TABLE room_messages ADD COLUMN IF NOT EXISTS embeds_hidden boolean NOT NULL DEFAULT false;
@@ -853,3 +853,12 @@ div.oc-set-row { cursor: default; }
@@ -853,3 +853,12 @@ div.oc-set-row { cursor: default; }
.oc-edits-sheet li { padding: 10px 12px; border-radius: 12px; background: var(--oc-panel-2); }
.oc-edits-sheet li { padding: 10px 12px; border-radius: 12px; background: var(--oc-panel-2); }
.oc-edits-sheet time { display: block; font-size: 12px; color: var(--oc-muted); }
.oc-edits-sheet time { display: block; font-size: 12px; color: var(--oc-muted); }
.oc-edits-sheet p { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.oc-edits-sheet p { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
+
+/* The small × that takes a message's cards back, and the note under a message whose cards are gone
+ (server/rooms/routes.js): the message's author and the room's moderators only. */
+.oc-card { position: relative; }
+.oc-card > .oc-card-hide, .oc-card > .oc-card-hide-btn { position: absolute; top: 4px; right: 4px; margin: 0; }
+.oc-card-hide-btn { width: 24px; height: 24px; padding: 0; border-radius: 999px; border: 1px solid var(--oc-line); background: var(--oc-panel); color: var(--oc-muted); font-size: 15px; line-height: 1; cursor: pointer; }
+.oc-card-hide-btn:hover, .oc-card-hide-btn:focus-visible { color: var(--oc-text); border-color: var(--oc-accent); }
+.oc-card-hidden-note { display: inline-flex; align-items: center; gap: 6px; margin: 4px 0 0; color: var(--oc-muted); font-size: .86em; }
+.oc-card-show { margin: 0; display: inline-flex; }
@@ -234,6 +234,33 @@
@@ -234,6 +234,33 @@
}
}
if (last < text.length) parent.appendChild(document.createTextNode(text.slice(last)));
if (last < text.length) parent.appendChild(document.createTextNode(text.slice(last)));
}
}
+ // A card's picture that does not load is dropped (server/web/ui.js draws the same markup): the picture goes,
+ // the card stays (its title, its description, its link). A picture that never loaded (it failed before this
+ // file ran) is dropped as well.
+ function dropCardPicture(img) {
+ if (!img) return;
+ var wrap = img.closest ? img.closest('.oc-card-pic') : null;
+ var node = wrap || img;
+ if (node.parentNode) node.parentNode.removeChild(node);
+ else if (node.remove) node.remove();
+ }
+ function watchCardPicture(img) {
+ if (!img || img.__ovCardPic) return;
+ img.__ovCardPic = true;
+ img.addEventListener('error', function () { dropCardPicture(img); });
+ if (img.complete && img.naturalWidth === 0) dropCardPicture(img);
+ }
+ // The server draws the cards too, so every picture on the page is watched, not only the ones made here.
+ if (document.querySelectorAll) {
+ Array.prototype.forEach.call(document.querySelectorAll('img[data-oc-card-pic]'), watchCardPicture);
+ }
+ if (document.addEventListener) {
+ document.addEventListener('error', function (ev) {
+ var t = ev && ev.target;
+ if (t && t.tagName === 'IMG' && t.hasAttribute && t.hasAttribute('data-oc-card-pic')) dropCardPicture(t);
+ }, true);
+ }
+ window.OpenVibeChatCards = { dropPicture: dropCardPicture, watchPicture: watchCardPicture };
// A known OpenVibe link's card under its message (server/chat/link-cards.js, server/web/ui.js draws the same):
// A known OpenVibe link's card under its message (server/chat/link-cards.js, server/web/ui.js draws the same):
// the picture when there is one, the title, one line and the one move as a button. Every field is data: set with
// the picture when there is one, the title, one line and the one move as a button. Every field is data: set with
// textContent, and a link or picture only when it is https (openvibe.media for the picture).
// textContent, and a link or picture only when it is https (openvibe.media for the picture).
@@ -249,7 +276,9 @@
@@ -249,7 +276,9 @@
var href = url || img;
var href = url || img;
var a = el('a', 'oc-card-pic'); a.href = href; a.target = '_blank'; a.rel = 'noopener nofollow';
var a = el('a', 'oc-card-pic'); a.href = href; a.target = '_blank'; a.rel = 'noopener nofollow';
var picture = document.createElement('img'); picture.src = img; picture.alt = ''; picture.loading = 'lazy'; picture.decoding = 'async';
var picture = document.createElement('img'); picture.src = img; picture.alt = ''; picture.loading = 'lazy'; picture.decoding = 'async';
+ picture.setAttribute('data-oc-card-pic', '1');
a.appendChild(picture); box.appendChild(a);
a.appendChild(picture); box.appendChild(a);
+ watchCardPicture(picture);
}
}
var body = el('div', 'oc-card-body');
var body = el('div', 'oc-card-body');
if (url) {
if (url) {
@@ -269,7 +298,21 @@
@@ -269,7 +298,21 @@
// description, and by/LIVE for OpenVibe's own kinds. Every field is data: textContent, and https only for links
// description, and by/LIVE for OpenVibe's own kinds. Every field is data: textContent, and https only for links
// and pictures (openvibe.media for the picture).
// and pictures (openvibe.media for the picture).
var EMBED_KINDS = ['link', 'video', 'image', 'live', 'picture', 'design', 'post', 'profile', 'game'];
var EMBED_KINDS = ['link', 'video', 'image', 'live', 'picture', 'design', 'post', 'profile', 'game'];
- function embedCard(e) {
+ // The cards of a message can be taken back by its author or by a room moderator (server/rooms/routes.js):
+ // a small × on each card, for those who may, and for nobody else.
+ function mayHideCards(userId) {
+ if (!cfg || cfg.view !== 'room') return false;
+ if (cfg.moderate) return true;
+ return !!(cfg.me && Number(userId) === Number(cfg.me));
+ }
+ function hideButton(messageId, label) {
+ var b = el('button', 'oc-card-hide-btn'); b.type = 'button';
+ b.setAttribute('aria-label', label || 'Hide the preview'); b.title = label || 'Hide the preview';
+ b.textContent = '×';
+ b.addEventListener('click', function () { setHidden(messageId, true); });
+ return b;
+ }
+ function embedCard(e, mayHide, messageId) {
if (!e || typeof e !== 'object' || typeof e.title !== 'string' || !e.title) return null;
if (!e || typeof e !== 'object' || typeof e.title !== 'string' || !e.title) return null;
if (e.kind === 'agent-run') {
if (e.kind === 'agent-run') {
var run = String(e.run_url || ''), events = String(e.events_url || '');
var run = String(e.run_url || ''), events = String(e.events_url || '');
@@ -284,7 +327,9 @@
@@ -284,7 +327,9 @@
if (img) {
if (img) {
var picLink = el('a', 'oc-card-pic'); picLink.href = url || img; picLink.target = '_blank'; picLink.rel = 'noopener nofollow';
var picLink = el('a', 'oc-card-pic'); picLink.href = url || img; picLink.target = '_blank'; picLink.rel = 'noopener nofollow';
var picture = document.createElement('img'); picture.src = img; picture.alt = ''; picture.loading = 'lazy'; picture.decoding = 'async';
var picture = document.createElement('img'); picture.src = img; picture.alt = ''; picture.loading = 'lazy'; picture.decoding = 'async';
+ picture.setAttribute('data-oc-card-pic', '1');
picLink.appendChild(picture); box.appendChild(picLink);
picLink.appendChild(picture); box.appendChild(picLink);
+ watchCardPicture(picture);
}
}
var body = el('div', 'oc-card-body');
var body = el('div', 'oc-card-body');
if (url) { var title = el('a', 'oc-card-title', e.title); title.href = url; title.target = '_blank'; title.rel = 'noopener nofollow'; body.appendChild(title); }
if (url) { var title = el('a', 'oc-card-title', e.title); title.href = url; title.target = '_blank'; title.rel = 'noopener nofollow'; body.appendChild(title); }
@@ -293,24 +338,54 @@
@@ -293,24 +338,54 @@
if (typeof e.by === 'string' && e.by) body.appendChild(el('p', 'oc-card-line', 'by ' + e.by));
if (typeof e.by === 'string' && e.by) body.appendChild(el('p', 'oc-card-line', 'by ' + e.by));
if (e.live === true) { var live = el('p', 'oc-card-note'); live.appendChild(el('span', 'oc-badge oc-badge-mod', 'LIVE')); body.appendChild(live); }
if (e.live === true) { var live = el('p', 'oc-card-note'); live.appendChild(el('span', 'oc-badge oc-badge-mod', 'LIVE')); body.appendChild(live); }
box.appendChild(body);
box.appendChild(body);
+ if (mayHide && messageId) box.appendChild(hideButton(messageId));
return box;
return box;
}
}
function addCard(li, m) {
function addCard(li, m) {
+ var mayHide = mayHideCards(li ? li.dataset.user : 0);
if (m && Array.isArray(m.embeds) && m.embeds.length) {
if (m && Array.isArray(m.embeds) && m.embeds.length) {
- m.embeds.slice(0, 3).forEach(function (e) { var c = embedCard(e); if (c) li.appendChild(c); });
+ var mid = Number(m.id || (li && li.dataset.id) || 0);
+ m.embeds.slice(0, 3).forEach(function (e) {
+ var c = embedCard(e, mayHide, mid);
+ if (c) li.appendChild(c);
+ });
mountRunCards(li);
mountRunCards(li);
return;
return;
}
}
var c = m && m.card && m.card.kind === 'agent-run' ? embedCard(m.card) : linkCard(m && m.card); if (c) li.appendChild(c);
var c = m && m.card && m.card.kind === 'agent-run' ? embedCard(m.card) : linkCard(m && m.card); if (c) li.appendChild(c);
mountRunCards(li);
mountRunCards(li);
}
}
- // A preview that landed after its message: replace the line's cards with the finished list.
- function updateEmbeds(id, embeds) {
+ // A preview that landed after its message: replace the line's cards with the finished list. A message whose
+ // cards were taken back shows the note instead, and the ones who may put them back get the button.
+ function updateEmbeds(id, embeds, hidden) {
if (!feed || !Array.isArray(embeds)) return;
if (!feed || !Array.isArray(embeds)) return;
var li = feed.querySelector('[data-id="' + Number(id) + '"]');
var li = feed.querySelector('[data-id="' + Number(id) + '"]');
if (!li) return;
if (!li) return;
Array.prototype.forEach.call(li.querySelectorAll('.oc-card, .oc-agent-run'), function (c) { c.remove(); });
Array.prototype.forEach.call(li.querySelectorAll('.oc-card, .oc-agent-run'), function (c) { c.remove(); });
- addCard(li, { embeds: embeds });
+ var note = li.querySelector('.oc-card-hidden-note');
+ if (note) note.remove();
+ if (hidden) { li.appendChild(hiddenNote(li, Number(id))); return; }
+ addCard(li, { embeds: embeds, id: Number(id) });
+ }
+ // The note under a message whose cards were taken back.
+ function hiddenNote(li, id) {
+ var box = el('span', 'oc-card-hidden-note');
+ box.appendChild(el('span', '', 'Previews hidden'));
+ if (mayHideCards(li ? li.dataset.user : 0)) {
+ var show = el('button', 'oc-link', 'Show previews'); show.type = 'button';
+ show.addEventListener('click', function () { setHidden(id, false); });
+ box.appendChild(show);
+ }
+ return box;
+ }
+ // Take a message's cards back, or put them back (server/rooms/routes.js).
+ function setHidden(id, hidden) {
+ if (!cfg || !cfg.room) return;
+ var api = '/api/chat/rooms/' + encodeURIComponent(cfg.room) + '/messages/' + Number(id) + '/embeds/hidden';
+ fetch(api, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hidden: !!hidden }) })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (out) { if (out) updateEmbeds(id, out.embeds || [], !!out.hidden); })
+ .catch(function () {});
}
}
function mountRunCards(root) { if (window.OpenVibeAgentRunCards) window.OpenVibeAgentRunCards.mountAll(root); }
function mountRunCards(root) { if (window.OpenVibeAgentRunCards) window.OpenVibeAgentRunCards.mountAll(root); }
@@ -712,7 +787,9 @@
@@ -712,7 +787,9 @@
if (m.edited_at && window.OVMessages) window.OVMessages.edited(t, m.edited_at);
if (m.edited_at && window.OVMessages) window.OVMessages.edited(t, m.edited_at);
if (cfg.moderate && m.edited_at) { var versions = el('button', 'ovk-btn ovk-btn--quiet oc-edits', 'Earlier versions'); versions.type = 'button'; li.appendChild(versions); }
if (cfg.moderate && m.edited_at) { var versions = el('button', 'ovk-btn ovk-btn--quiet oc-edits', 'Earlier versions'); versions.type = 'button'; li.appendChild(versions); }
if (!ai && cfg.pics) { var pw = pictureThumbs(m.message); if (pw) li.appendChild(pw); }
if (!ai && cfg.pics) { var pw = pictureThumbs(m.message); if (pw) li.appendChild(pw); }
- addCard(li, m);
+ // Cards that were taken back are not sent with the message: the line says so instead.
+ if (m.embeds_hidden) li.appendChild(hiddenNote(li, Number(m.id) || 0));
+ else addCard(li, m);
var mine = !ai && Number(m.user_id) === Number(cfg.me);
var mine = !ai && Number(m.user_id) === Number(cfg.me);
if (cfg.moderate || mine) {
if (cfg.moderate || mine) {
var f = el('form', 'oc-del'); f.method = 'post'; f.action = '/r/' + encodeURIComponent(cfg.room) + '/delete/' + m.id;
var f = el('form', 'oc-del'); f.method = 'post'; f.action = '/r/' + encodeURIComponent(cfg.room) + '/delete/' + m.id;
@@ -797,7 +874,7 @@
@@ -797,7 +874,7 @@
} else if (m.type === 'room_ai_typing' && m.room === cfg.room) {
} else if (m.type === 'room_ai_typing' && m.room === cfg.room) {
showThinking(m.name);
showThinking(m.name);
} else if (m.type === 'room_message_embeds' && m.room === cfg.room) {
} else if (m.type === 'room_message_embeds' && m.room === cfg.room) {
- updateEmbeds(m.id, m.embeds);
+ updateEmbeds(m.id, m.embeds, !!m.hidden);
} else if (m.type === 'room_message_edited' && m.room === cfg.room && m.message) {
} else if (m.type === 'room_message_edited' && m.room === cfg.room && m.message) {
applyEdit(m.message.id, m.message);
applyEdit(m.message.id, m.message);
} else if (m.type === 'room_message_deleted' && m.room === cfg.room) {
} else if (m.type === 'room_message_deleted' && m.room === cfg.room) {
@@ -9,18 +9,24 @@
@@ -9,18 +9,24 @@
* The job reads the page through openvibe-shared/egress: public addresses only (DNS is checked again at connect, so
* The job reads the page through openvibe-shared/egress: public addresses only (DNS is checked again at connect, so
* a name that answers a private address is refused), https only, at most two redirects each checked on its own,
* a name that answers a private address is refused), https only, at most two redirects each checked on its own,
* 4 s and 512 KiB per request, no cookies, a plain user agent. YouTube and Vimeo are read from their oEmbed endpoint
* 4 s and 512 KiB per request, no cookies, a plain user agent. YouTube and Vimeo are read from their oEmbed endpoint
- * on a fixed origin instead of their page. A picture is only kept when it already lives on openvibe.media: a page's
- * own image is not hot-linked (a Media proxy path can add those later).
+ * on a fixed origin instead of their page. A picture is never hot-linked: a page's own image is brought into
+ * OpenVibe.Media first (server/chat/media-import.js), and the card shows the Media address; a picture that cannot
+ * be brought in is left off the card.
*
*
* Previews live apart from the message (link_previews) and are cached by address for a day (link_preview_cache), so
* Previews live apart from the message (link_previews) and are cached by address for a day (link_preview_cache), so
* sending, and every later send of the same address, never waits on another site. The fields are data, never markup:
* sending, and every later send of the same address, never waits on another site. The fields are data, never markup:
* control characters are gone, whitespace folded, sizes capped, and the API/renderer escape them as text.
* control characters are gone, whitespace folded, sizes capped, and the API/renderer escape them as text.
*
*
+ * A card's picture is kept with the message it is shown under: once the preview is stored, the picture is kept in
+ * OpenVibe.Media (server/chat/media-import.js) for that message, so it lives in Media's kept window (30 days)
+ * instead of the day-long one, and a picture the cache shows again for a later message is kept again for it.
+ *
* The room wiring (server/rooms/rooms.js) attaches the rows on every read as `embeds` ([] before the job is done),
* The room wiring (server/rooms/rooms.js) attaches the rows on every read as `embeds` ([] before the job is done),
* and the job announces `room_message_embeds` when it has something.
* and the job announces `room_message_embeds` when it has something.
*/
*/
const { assertPublicUrl, safeLookup } = require('openvibe-shared/egress');
const { assertPublicUrl, safeLookup } = require('openvibe-shared/egress');
const db = require('../db/database');
const db = require('../db/database');
+const mediaImport = require('./media-import');
const MAX_URLS = 3;
const MAX_URLS = 3;
const MAX_URL = 2048;
const MAX_URL = 2048;
@@ -131,6 +137,19 @@ function imageOf(value) {
@@ -131,6 +137,19 @@ function imageOf(value) {
} catch { return null; }
} catch { return null; }
}
}
+/**
+ * A page's own picture address (https only): the one the job may bring into Media, so that a card shows a
+ * picture from our own host and never a hot-linked one.
+ */
+function httpsAddress(value) {
+ if (typeof value !== 'string' || !value) return null;
+ try {
+ const u = new URL(value);
+ if (u.protocol !== 'https:' || u.username || u.password) return null;
+ return u.href;
+ } catch { return null; }
+}
+
const intOf = (value) => {
const intOf = (value) => {
const n = parseInt(value, 10);
const n = parseInt(value, 10);
return Number.isFinite(n) && n > 0 && n <= 100000 ? n : 0;
return Number.isFinite(n) && n > 0 && n <= 100000 ? n : 0;
@@ -168,7 +187,11 @@ function previewFromResponse(url, res) {
@@ -168,7 +187,11 @@ function previewFromResponse(url, res) {
const f = pageFields(html);
const f = pageFields(html);
const title = textOf(f['og:title'] || f['twitter:title'] || f['html:title'], MAX_TITLE);
const title = textOf(f['og:title'] || f['twitter:title'] || f['html:title'], MAX_TITLE);
const description = textOf(f['og:description'] || f['twitter:description'], MAX_DESCRIPTION);
const description = textOf(f['og:description'] || f['twitter:description'], MAX_DESCRIPTION);
- const image = imageOf(f['og:image'] || f['og:image:url'] || f['twitter:image']);
+ const raw = String(f['og:image'] || f['og:image:url'] || f['twitter:image'] || '').trim();
+ const image = imageOf(raw);
+ // The page's own picture: the job brings it into Media (server/chat/media-import.js) and keeps the Media
+ // address. A picture that is already on Media is kept as it is; one that cannot be brought in is left off.
+ const source_image = image ? null : httpsAddress(raw);
const ogType = String(f['og:type'] || '').toLowerCase();
const ogType = String(f['og:type'] || '').toLowerCase();
const card = String(f['twitter:card'] || '').toLowerCase();
const card = String(f['twitter:card'] || '').toLowerCase();
const kind = ogType.startsWith('video') || card === 'player' ? 'video' : ogType.startsWith('image') ? 'image' : 'link';
const kind = ogType.startsWith('video') || card === 'player' ? 'video' : ogType.startsWith('image') ? 'image' : 'link';
@@ -182,6 +205,7 @@ function previewFromResponse(url, res) {
@@ -182,6 +205,7 @@ function previewFromResponse(url, res) {
kind,
kind,
width: intOf(f['og:image:width'] || f['twitter:image:width']),
width: intOf(f['og:image:width'] || f['twitter:image:width']),
height: intOf(f['og:image:height'] || f['twitter:image:height']),
height: intOf(f['og:image:height'] || f['twitter:image:height']),
+ ...(source_image ? { source_image } : {}),
};
};
}
}
@@ -512,6 +536,9 @@ function parsePreview(value) {
@@ -512,6 +536,9 @@ function parsePreview(value) {
};
};
if (p.by != null) preview.by = textOf(p.by, 80);
if (p.by != null) preview.by = textOf(p.by, 80);
if (p.live === true || p.live === false) preview.live = p.live;
if (p.live === true || p.live === false) preview.live = p.live;
+ // The Media attachment behind the picture (the id a keep names). It travels with the preview, so a
+ // picture that is shown again (the cache serves it a day later) can be kept for that message too.
+ if (preview.image && typeof p.image_id === 'string' && /^att_[0-9A-HJKMNP-TV-Z]{26}$/.test(p.image_id)) preview.image_id = p.image_id;
return preview;
return preview;
}
}
@@ -548,6 +575,13 @@ async function previewOf(value) {
@@ -548,6 +575,13 @@ async function previewOf(value) {
if (hit.hit) return hit.value;
if (hit.hit) return hit.value;
let preview = null;
let preview = null;
try { preview = await readUrl(url); } catch { preview = null; }
try { preview = await readUrl(url); } catch { preview = null; }
+ // The page's own picture is brought into Media before the preview is kept: a card shows a picture from our
+ // own host, never a hot-linked one. A picture that cannot be brought in is left off the card. The id of the
+ // attachment travels with the preview, so the picture can be kept with the message that shows it.
+ if (preview && preview.source_image && !preview.image) {
+ const brought = await mediaImport.importPicture(preview.source_image).catch(() => null);
+ if (brought) preview = { ...preview, image: brought.url, image_id: brought.id };
+ }
const checked = preview ? parsePreview({ ...preview, url }) : null;
const checked = preview ? parsePreview({ ...preview, url }) : null;
await remember(url, checked);
await remember(url, checked);
return checked;
return checked;
@@ -573,11 +607,17 @@ async function store(job, position, preview) {
@@ -573,11 +607,17 @@ async function store(job, position, preview) {
const table = job.scope === 'room' ? 'room_messages' : job.scope === 'dm' ? 'dm_messages' : 'chat_messages';
const table = job.scope === 'room' ? 'room_messages' : job.scope === 'dm' ? 'dm_messages' : 'chat_messages';
const current = await db.get(`SELECT message FROM ${table} WHERE id = ?`, [job.messageId]);
const current = await db.get(`SELECT message FROM ${table} WHERE id = ?`, [job.messageId]);
if (!current || addresses(current.message)[position] !== job.urls[position]) return;
if (!current || addresses(current.message)[position] !== job.urls[position]) return;
- await db.run(`INSERT INTO link_previews (message_scope, message_id, position, url, data) VALUES (?, ?, ?, ?, ?)
+ const written = await db.run(`INSERT INTO link_previews (message_scope, message_id, position, url, data) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (message_scope, message_id, position)
ON CONFLICT (message_scope, message_id, position)
DO UPDATE SET url = EXCLUDED.url, data = EXCLUDED.data
DO UPDATE SET url = EXCLUDED.url, data = EXCLUDED.data
WHERE link_previews.removed_at IS NULL`,
WHERE link_previews.removed_at IS NULL`,
[job.scope, job.messageId, position, preview.url, JSON.stringify(preview)]);
[job.scope, job.messageId, position, preview.url, JSON.stringify(preview)]);
+ // The card's picture is kept with the message it is shown under: the picture leaves its day-long life (an
+ // attachment nobody sent to a message) and lives in Media's kept window instead, so it is still there long
+ // after the day. A picture the cache shows again for another message is kept again for that message too.
+ if (written && written.changes && preview.image_id) {
+ await mediaImport.keepPicture(preview.image_id, job.messageId).catch(() => false);
+ }
}
}
async function replaceForEdit(scope, messageId) {
async function replaceForEdit(scope, messageId) {
@@ -671,13 +711,34 @@ async function attach(rows, scope = 'room', { enabled = true } = {}) {
@@ -671,13 +711,34 @@ async function attach(rows, scope = 'room', { enabled = true } = {}) {
if (!byId.has(key)) byId.set(key, []);
if (!byId.has(key)) byId.set(key, []);
byId.get(key).push(preview);
byId.get(key).push(preview);
}
}
- for (const row of list) row.embeds = byId.get(Number(row.id)) || [];
+ for (const row of list) {
+ // Cards that were taken back are not sent on a read, even when the rows are still there.
+ if (isHidden(row)) continue;
+ row.embeds = byId.get(Number(row.id)) || [];
+ }
return rows;
return rows;
}
}
+/** A message whose cards were taken back (room_messages.embeds_hidden). */
+function isHidden(row) {
+ const v = row && row.embeds_hidden;
+ return v === true || v === 1 || v === '1' || v === 't' || v === 'true';
+}
+
+/**
+ * Take a message's cards back, or put them back. The flag lives on the message, so the cards can come back;
+ * the preview rows stay in the table (they are not deleted), so a job that is still running cannot change the
+ * answer. The author or a room moderator decides; the route checks that.
+ */
+async function hide(scope, messageId, hidden) {
+ const id = Number(messageId) || 0;
+ if (scope === 'room') await db.run('UPDATE room_messages SET embeds_hidden = ? WHERE id = ?', [hidden ? true : false, id]);
+ return { hidden: !!hidden, embeds: hidden ? [] : await embedsOf(scope, id) };
+}
+
module.exports = {
module.exports = {
- addresses, normalizeUrl, textOf, decodeEntities, pageFields, previewFromResponse, previewFromOembed, imageOf,
- previewOf, schedule, embedsOf, attach, remove, replaceForEdit, idle, ownLink,
+ addresses, normalizeUrl, textOf, decodeEntities, pageFields, previewFromResponse, previewFromOembed, imageOf, httpsAddress,
+ previewOf, schedule, embedsOf, attach, remove, hide, isHidden, replaceForEdit, idle, ownLink,
CACHE_MS, MISS_MS, MAX_URLS, MAX_BYTES, FETCH_TIMEOUT_MS, MAX_REDIRECTS, PREVIEWS_ON,
CACHE_MS, MISS_MS, MAX_URLS, MAX_BYTES, FETCH_TIMEOUT_MS, MAX_REDIRECTS, PREVIEWS_ON,
_setRequest(fn) { _request = fn || null; },
_setRequest(fn) { _request = fn || null; },
_setLookup(fn) { _lookup = fn || null; },
_setLookup(fn) { _lookup = fn || null; },
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+/**
+ * Bringing a page's own picture into OpenVibe.Media (server/chat/link-previews.js).
+ *
+ * A page's og:image lives on somebody else's host, and a card must never hot-link it: the reader's browser
+ * would tell that host who is reading. So the link preview job asks Media to fetch the picture once and keep
+ * it: Media fetches it through its own egress rules (https only, public addresses only, at most 2 MB, images
+ * only) and stores it as an unlisted attachment with a short life. The card then shows the Media address.
+ *
+ * The picture belongs to Chat (Chat brought it in), so Chat may keep it with the message it is shown under
+ * (keepPicture): it then lives in Media's kept window (30 days) instead of the day-long one, and it is kept
+ * again for every message that shows it. A card's picture is therefore still there long after the day.
+ *
+ * The call is Chat's own service token (client `chat`, audience openvibe.media, scope media.object.upload) —
+ * the same one that stores an emote's image. A picture that cannot be brought in is simply left off the card:
+ * this never throws, and a message never waits for it.
+ */
+const config = require('../config');
+const serviceAuth = require('../net/service-auth');
+
+const AUDIENCE = 'openvibe.media';
+const TIMEOUT_MS = 8000;
+let _fetch = null;
+
+/** Media is asked through its internal address; the caller needs a service token to do that. */
+function enabled() {
+ return !!(config.media && config.media.baseUrl && config.oauth.clientSecret);
+}
+
+/**
+ * The Media picture of one https address: { id, url } — the address Media serves it from and the id of the
+ * attachment behind it (the id is what a keep names). Null when there is no picture for it (no address, a
+ * picture Media refuses, a picture that is not an image, Media down). Never throws.
+ */
+async function importPicture(url) {
+ const address = String(url || '').trim();
+ if (!/^https:\/\/[^\s]+$/i.test(address)) return null;
+ if (!enabled()) return null;
+ // A test that does not stub the network never makes one.
+ if (process.env.NODE_ENV === 'test' && !_fetch) return null;
+ try {
+ const headers = await serviceAuth.headers(AUDIENCE, config.media.scope);
+ const send = _fetch || globalThis.fetch;
+ const res = await send(`${config.media.baseUrl}/api/v1/attachments/import`, {
+ method: 'POST',
+ headers: { ...headers, 'content-type': 'application/json', accept: 'application/json' },
+ body: JSON.stringify({ url: address }),
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ if (!res || (Number(res.status) !== 200 && Number(res.status) !== 201)) return null;
+ const body = await res.json().catch(() => null);
+ const out = body && typeof body.url === 'string' ? body.url : null;
+ if (!out || !/^https:\/\//i.test(out)) return null;
+ const id = body && typeof body.id === 'string' ? body.id : null;
+ return id ? { id, url: out } : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Keep the picture with the message it is shown under: Media's POST /api/v1/attachments/:id/keep with that
+ * message's id. The picture leaves its day-long life (an attachment that was never sent to a message lives
+ * 24 hours) and lives in the kept window instead (Media's keepDays: 30 days), so a card's picture is still
+ * there a month later. A picture that is shown by a second message is kept again for that message.
+ *
+ * The picture belongs to Chat (Chat brought it in), so Chat is the one who may keep it. Never throws: a
+ * picture that cannot be kept is left as it is, and the page drops it if it ever stops loading.
+ */
+async function keepPicture(id, messageId) {
+ const attachment = String(id || '').trim();
+ const message = String(messageId == null ? '' : messageId).trim();
+ if (!/^att_[A-Za-z0-9_-]{1,64}$/.test(attachment)) return false;
+ if (!message || message.length > 200) return false;
+ if (!enabled()) return false;
+ // A test that does not stub the network never makes one.
+ if (process.env.NODE_ENV === 'test' && !_fetch) return false;
+ try {
+ const headers = await serviceAuth.headers(AUDIENCE, config.media.scope);
+ const send = _fetch || globalThis.fetch;
+ const res = await send(`${config.media.baseUrl}/api/v1/attachments/${encodeURIComponent(attachment)}/keep`, {
+ method: 'POST',
+ headers: { ...headers, 'content-type': 'application/json', accept: 'application/json' },
+ body: JSON.stringify({ message_id: message }),
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ return !!(res && Number(res.status) === 200);
+ } catch {
+ return false;
+ }
+}
+
+module.exports = { importPicture, keepPicture, AUDIENCE, enabled, _setFetch(fn) { _fetch = fn || null; } };
@@ -416,7 +416,7 @@ async function postAsAi(room, member, text, { now = Date.now(), how = null, card
@@ -416,7 +416,7 @@ async function postAsAi(room, member, text, { now = Date.now(), how = null, card
}
}
// A message an AI member wrote (ai_member_id; user_id 0) takes the member's name and handle.
// A message an AI member wrote (ai_member_id; user_id 0) takes the member's name and handle.
-const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.card, m.files, m.reply_to_id, m.created_at, m.edited_at, m.is_deleted, m.ai_member_id, m.agent_stream, m.how, m.results, m.vibe_model,
+const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.card, m.files, m.reply_to_id, m.created_at, m.edited_at, m.is_deleted, m.ai_member_id, m.agent_stream, m.how, m.results, m.vibe_model, m.embeds_hidden,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.handle WHEN m.agent_stream THEN 'aiagent' ELSE u.username END AS username,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.handle WHEN m.agent_stream THEN 'aiagent' ELSE u.username END AS username,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.name WHEN m.agent_stream THEN 'AI agent' ELSE COALESCE((
CASE WHEN m.ai_member_id IS NOT NULL THEN a.name WHEN m.agent_stream THEN 'AI agent' ELSE COALESCE((
SELECT sm.nickname FROM chat_server_members sm JOIN rooms sr ON sr.server_id = sm.server_id
SELECT sm.nickname FROM chat_server_members sm JOIN rooms sr ON sr.server_id = sm.server_id
@@ -578,6 +578,19 @@ async function update(room, actor, { name, topic, visibility, slow_seconds, join
@@ -578,6 +578,19 @@ async function update(room, actor, { name, topic, visibility, slow_seconds, join
return await publicRoom(await db.get('SELECT * FROM rooms WHERE id = ?', [room.id]));
return await publicRoom(await db.get('SELECT * FROM rooms WHERE id = ?', [room.id]));
}
}
+/**
+ * The room's link previews switch: its owner and its moderators set it (server/rooms/routes.js). Off: no new
+ * address in the room is fetched, and the reads show no previews. The room's own setting, not the PATCH above
+ * (which only the owner may use).
+ */
+async function setLinkPreviews(room, actor, on) {
+ const a = await access(room, actor);
+ if (!a.moderate) fail(403, 'rooms.not_mod', 'Only the room\'s owner and moderators change this');
+ const value = on === true || on === 1 || on === '1' || on === 'on';
+ await db.run('UPDATE rooms SET link_previews = ? WHERE id = ?', [value, room.id]);
+ return await publicRoom(await db.get('SELECT * FROM rooms WHERE id = ?', [room.id]));
+}
+
/** The room's people, by role. `seen` adds last_seen_at (moderators only: it is presence). */
/** The room's people, by role. `seen` adds last_seen_at (moderators only: it is presence). */
async function members(room, { seen = false, subjects = false } = {}) {
async function members(room, { seen = false, subjects = false } = {}) {
// A server's channel: the server's people, with the role each has in the channel.
// A server's channel: the server's people, with the role each has in the channel.
@@ -647,7 +660,7 @@ module.exports = {
@@ -647,7 +660,7 @@ module.exports = {
LIMITS,
LIMITS,
isWorkspace, isStaffRoom,
isWorkspace, isStaffRoom,
postAsAi,
postAsAi,
- ensureSchema, create, list, bySlug, access, join, leave, post, editMessage, history, historyPage, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,
+ ensureSchema, create, list, bySlug, access, join, leave, post, editMessage, history, historyPage, markRead, deleteMessage, setRole, update, setLinkPreviews, members, publicRoom, messageById,
attach, detach, attachments, joinRoleOf, unreadTotal, isStaff, siteBanned, insightOn, POSTERS, TALKERS,
attach, detach, attachments, joinRoleOf, unreadTotal, isStaff, siteBanned, insightOn, POSTERS, TALKERS,
RoomError, SLUG_RE, KINDS, ROLES, KIND_ROLES, JOIN_ROLE, JOIN_ROLES_CALL, ATTACH_SERVICES, _reset,
RoomError, SLUG_RE, KINDS, ROLES, KIND_ROLES, JOIN_ROLE, JOIN_ROLES_CALL, ATTACH_SERVICES, _reset,
};
};
@@ -12,6 +12,8 @@
@@ -12,6 +12,8 @@
* GET /:slug/messages ?before=<id> | ?after=<id>, &limit
* GET /:slug/messages ?before=<id> | ?after=<id>, &limit
* POST /:slug/messages { message }
* POST /:slug/messages { message }
* DELETE /:slug/messages/:id the author, a room moderator or chat staff
* DELETE /:slug/messages/:id the author, a room moderator or chat staff
+ * POST /:slug/messages/:id/embeds/hidden the author, a moderator: the message's cards are taken back (or put back)
+ * PATCH /:slug/link-previews the owner and the moderators: the room's link previews on or off
* POST /:slug/join | /leave | /read { last_id? }
* POST /:slug/join | /leave | /read { last_id? }
* GET /:slug/members (moderators also get last_seen_at)
* GET /:slug/members (moderators also get last_seen_at)
* POST /:slug/members owner/mods: { username, role } — one of the kind's roles (rooms.KIND_ROLES) or none
* POST /:slug/members owner/mods: { username, role } — one of the kind's roles (rooms.KIND_ROLES) or none
@@ -305,6 +307,41 @@ router.get('/:slug/messages/:id/edits', requireAuth, async (req, res) => {
@@ -305,6 +307,41 @@ router.get('/:slug/messages/:id/edits', requireAuth, async (req, res) => {
return res.set('Cache-Control', 'private, no-store').json({ versions: row.edit_history || [] });
return res.set('Cache-Control', 'private, no-store').json({ versions: row.edit_history || [] });
});
});
+/**
+ * The cards of a room message, taken back (server/chat/link-previews.js): the message's author, the room's owner
+ * or its moderators decide. The message keeps the flag, so the cards can be put back; while it is set, a read
+ * sends no cards for that message. The room hears it at once (room_message_embeds with hidden: true).
+ */
+router.post('/:slug/messages/:id/embeds/hidden', requireAuth, deleteLimit, async (req, res) => {
+ const room = await readable(req, res); if (!room) return;
+ try {
+ const id = parseInt(req.params.id, 10);
+ const message = await db.get('SELECT id, user_id FROM room_messages WHERE id = ? AND room_id = ?', [id, room.id]);
+ if (!message) throw new rooms.RoomError(404, 'rooms.no_message', 'No such message');
+ const a = await rooms.access(room, req.user);
+ if (Number(message.user_id) !== Number(req.user.id) && !a.moderate) throw new rooms.RoomError(403, 'rooms.embeds', 'Only the author or a room moderator hides a preview');
+ const b = req.body || {};
+ const hidden = b.hidden === undefined ? true
+ : !(b.hidden === false || b.hidden === 0 || b.hidden === '0' || b.hidden === 'false' || b.hidden === 'off' || b.hidden === 'no');
+ const out = await linkPreviews.hide('room', id, hidden);
+ chatServer().broadcastToRoom(room.id, { type: 'room_message_embeds', room: room.slug, id, embeds: out.embeds, hidden: out.hidden });
+ res.json({ ok: true, id, hidden: out.hidden, embeds: out.embeds });
+ } catch (err) { send(res, err); }
+});
+
+/**
+ * The room's link previews switch (server/rooms/rooms.js): its owner and its moderators set it. Off: no new
+ * address in the room is fetched, and the reads show no previews.
+ */
+router.patch('/:slug/link-previews', requireAuth, manageLimit, async (req, res) => {
+ const room = await readable(req, res); if (!room) return;
+ try {
+ const b = req.body || {};
+ const on = b.link_previews === undefined ? true : (b.link_previews === true || b.link_previews === 1 || b.link_previews === '1' || b.link_previews === 'on');
+ res.json({ room: await rooms.setLinkPreviews(room, req.user, on) });
+ } catch (err) { send(res, err); }
+});
+
/**
/**
* A preview on a room message, taken back by its author or a room moderator (server/chat/link-previews.js): with a
* A preview on a room message, taken back by its author or a room moderator (server/chat/link-previews.js): with a
* position, that one address; without, every preview on the line. The row becomes a tombstone, so a job still
* position, that one address; without, every preview on the line. The row becomes a tombstone, so a job still
@@ -937,8 +937,14 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
@@ -937,8 +937,14 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
? `<span class="oc-name oc-ai-name">${esc(m.display_name || 'AI')}</span> <span class="oc-badge oc-ai" title="An AI member of this room">AI</span>`
? `<span class="oc-name oc-ai-name">${esc(m.display_name || 'AI')}</span> <span class="oc-badge oc-ai" title="An AI member of this room">AI</span>`
: `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)}`;
: `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)}`;
// An AI member writes Markdown (lists, bold, links): vibe-md.js escapes every character first, then marks it up.
// An AI member writes Markdown (lists, bold, links): vibe-md.js escapes every character first, then marks it up.
+ // The cards of this message: the author and the room's moderators may take them back, and put them back.
+ const mayHide = !!(can.moderate || (me && !ai && !agent && Number(m.user_id) === Number(me.id)));
+ const hideAction = mayHide ? `/r/${esc(slug)}/embeds/${Number(m.id) || 0}/hidden` : null;
+ const cards = m.embeds_hidden
+ ? `<span class="oc-card-hidden-note">Previews hidden</span>${mayHide ? ui.showForm(hideAction) : ''}`
+ : ui.messageCards(m, { hide: hideAction });
const text = ai ? `<div class="oc-text vb-md">${md.render(m.message)}</div>` : `<span class="oc-text">${emojis ? require('../chat/emoji-text').render(m.message, emojis, linkify, esc) : linkify(m.message)}</span>`;
const text = ai ? `<div class="oc-text vb-md">${md.render(m.message)}</div>` : `<span class="oc-text">${emojis ? require('../chat/emoji-text').render(m.message, emojis, linkify, esc) : linkify(m.message)}</span>`;
- return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}${agent ? ' oc-msg-agent' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} ${text}${m.edited_at ? ` <span class="oc-muted oc-edited" data-edited-at="${esc(m.edited_at)}">edited · ${esc(hhmm(m.edited_at).text)}</span>` : ''}${ai ? '' : pictures.pageHtml(m.message)}${ui.messageCards(m)}${can.moderate && m.edited_at ? '<button type="button" class="ovk-btn ovk-btn--quiet oc-edits">Earlier versions</button>' : ''}${del}</li>`;
+ return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}${agent ? ' oc-msg-agent' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} ${text}${m.edited_at ? ` <span class="oc-muted oc-edited" data-edited-at="${esc(m.edited_at)}">edited · ${esc(hhmm(m.edited_at).text)}</span>` : ''}${ai ? '' : pictures.pageHtml(m.message)}${cards}${can.moderate && m.edited_at ? '<button type="button" class="ovk-btn ovk-btn--quiet oc-edits">Earlier versions</button>' : ''}${del}</li>`;
};
};
const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
@@ -1211,6 +1217,35 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
@@ -1211,6 +1217,35 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
res.redirect(303, `/r/${room.slug}`);
res.redirect(303, `/r/${room.slug}`);
}));
}));
+ // The cards of a message, taken back or put back: the same checks, the same answer and the same event as the
+ // API route (server/rooms/routes.js), so a form and the JavaScript client behave the same way.
+ router.post('/r/:slug/embeds/:id/hidden', formLimit, sameSite, roomsOrError(async (req, res, next) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const room = await rooms.bySlug(req.params.slug);
+ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
+ req.roomBack = `/r/${room.slug}`;
+ const id = parseInt(req.params.id, 10);
+ const raw = String((req.body && req.body.hidden) === undefined ? '1' : (req.body && req.body.hidden));
+ const hidden = !(raw === '0' || raw === 'off' || raw === 'false' || raw === 'no');
+ return apiBridge(roomRoutes, `/${encodeURIComponent(room.slug)}/messages/${Number(id) || 0}/embeds/hidden`, {
+ body: () => ({ hidden }),
+ ok: (r, s) => s.redirect(303, `/r/${room.slug}`),
+ fail: (r, s, status, error) => s.redirect(303, `/r/${room.slug}?error=${encodeURIComponent(error || 'That did not work')}`),
+ })(req, res, next);
+ }));
+
+ // The room's link previews switch: its owner and its moderators set it (server/rooms/rooms.js).
+ router.post('/r/:slug/link-previews', formLimit, sameSite, roomsOrError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const room = await rooms.bySlug(req.params.slug);
+ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
+ req.roomBack = `/r/${room.slug}/settings`;
+ const b = req.body || {};
+ const on = b.has_link_previews ? (b.link_previews === '1' || b.link_previews === 'on' || b.link_previews === 'true') : true;
+ await rooms.setLinkPreviews(room, actor.user, on);
+ res.redirect(303, `/r/${room.slug}/settings?saved=1`);
+ }));
+
router.get('/r/:slug/settings', async (req, res) => {
router.get('/r/:slug/settings', async (req, res) => {
const x = await loadRoom(req, res); if (!x) return;
const x = await loadRoom(req, res); if (!x) return;
const { actor, room, can } = x;
const { actor, room, can } = x;
@@ -1310,6 +1345,9 @@ ${room.slug !== 'staff' && (room.kind || 'community') === 'community' && Number(
@@ -1310,6 +1345,9 @@ ${room.slug !== 'staff' && (room.kind || 'community') === 'community' && Number(
${rooms.isWorkspace(room) ? `<label>Shared screen <input name="screen_url" type="url" value="${esc(room.screen_url || '')}" placeholder="https://openvibe.bot/screen/…"> <span class="oc-muted oc-small">A screen share link from OpenVibe.Bot (a computer's owner makes it on its screen page). Members see the screen beside the conversation; with a "use" link they can click and type on it. Empty: no screen.</span></label>` : ''}
${rooms.isWorkspace(room) ? `<label>Shared screen <input name="screen_url" type="url" value="${esc(room.screen_url || '')}" placeholder="https://openvibe.bot/screen/…"> <span class="oc-muted oc-small">A screen share link from OpenVibe.Bot (a computer's owner makes it on its screen page). Members see the screen beside the conversation; with a "use" link they can click and type on it. Empty: no screen.</span></label>` : ''}
<p><button type="submit">Save</button></p></form>
<p><button type="submit">Save</button></p></form>
<p><a href="/r/${esc(room.slug)}/memory">What this room remembers</a> <span class="oc-muted oc-small">— the notes the AI keeps between summaries; you can correct or remove them.</span></p></section>` : ''}
<p><a href="/r/${esc(room.slug)}/memory">What this room remembers</a> <span class="oc-muted oc-small">— the notes the AI keeps between summaries; you can correct or remove them.</span></p></section>` : ''}
+${can.moderate ? `<section class="oc-set"><h2>Link previews</h2><form class="oc-form oc-set-form" method="post" action="/r/${esc(room.slug)}/link-previews">
+<input type="hidden" name="has_link_previews" value="1"><label class="oc-set-row oc-set-inline"><span class="oc-set-text">Cards under a message<small>When a message carries a web address, a card under it shows the page. Off: nothing is fetched for this room, and no cards are shown.</small></span><input type="checkbox" class="oc-switch" name="link_previews" value="1"${room.link_previews !== false ? ' checked' : ''}></label>
+<p><button type="submit">Save</button></p></form></section>` : ''}
<section class="oc-set" aria-labelledby="oc-members-h"><h2 id="oc-members-h">Members <span class="oc-count">${members.length}</span></h2>
<section class="oc-set" aria-labelledby="oc-members-h"><h2 id="oc-members-h">Members <span class="oc-count">${members.length}</span></h2>
<p class="oc-muted oc-set-note">${esc(roleHelp(room.kind))}</p>
<p class="oc-muted oc-set-note">${esc(roleHelp(room.kind))}</p>
<form class="oc-find oc-find-inset" method="post" action="/r/${esc(room.slug)}/members">${IC.plus}<label for="oc-invite" class="oc-sr">Add someone by their username</label><input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="Add by username" autocomplete="off"><select name="role" aria-label="Their role">${choices.filter((r) => r !== 'none' && r !== 'blocked').map((r) => roleOption(r, addRole)).join('')}</select><button type="submit" class="oc-send" aria-label="Add" title="Add">${IC.send}</button></form>
<form class="oc-find oc-find-inset" method="post" action="/r/${esc(room.slug)}/members">${IC.plus}<label for="oc-invite" class="oc-sr">Add someone by their username</label><input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="Add by username" autocomplete="off"><select name="role" aria-label="Their role">${choices.filter((r) => r !== 'none' && r !== 'blocked').map((r) => roleOption(r, addRole)).join('')}</select><button type="submit" class="oc-send" aria-label="Add" title="Add">${IC.send}</button></form>
@@ -112,7 +112,11 @@ function cardHref(value) {
@@ -112,7 +112,11 @@ function cardHref(value) {
return u.protocol === 'https:' ? u.href : null;
return u.protocol === 'https:' ? u.href : null;
} catch { return null; }
} catch { return null; }
}
}
-/** A card's picture: only https on openvibe.media, as the resolver also enforces. */
+/**
+ * A card's picture: only https on openvibe.media, as the resolver also enforces. The picture carries
+ * data-oc-card-pic, so the client (public/web/chat.js) can drop this one when it does not load: a card never
+ * shows a broken image, and the card itself (its title, its description, its link) stays.
+ */
function cardImage(value) {
function cardImage(value) {
try {
try {
const u = new URL(String(value || ''));
const u = new URL(String(value || ''));
@@ -136,16 +140,29 @@ function linkCard(card) {
@@ -136,16 +140,29 @@ function linkCard(card) {
const head = href
const head = href
? `<a class="oc-card-title" href="${esc(href)}" rel="noopener nofollow" target="_blank">${esc(title)}</a>`
? `<a class="oc-card-title" href="${esc(href)}" rel="noopener nofollow" target="_blank">${esc(title)}</a>`
: `<span class="oc-card-title">${esc(title)}</span>`;
: `<span class="oc-card-title">${esc(title)}</span>`;
- const pic = image ? `<a class="oc-card-pic" href="${esc(href || image)}" rel="noopener nofollow" target="_blank"><img src="${esc(image)}" alt="" loading="lazy" decoding="async"></a>` : '';
+ const pic = image ? `<a class="oc-card-pic" href="${esc(href || image)}" rel="noopener nofollow" target="_blank"><img src="${esc(image)}" alt="" loading="lazy" decoding="async" data-oc-card-pic="1"></a>` : '';
const go = actionHref ? `<a class="oc-card-go" href="${esc(actionHref)}" rel="noopener nofollow" target="_blank">${esc(action.label)}</a>` : '';
const go = actionHref ? `<a class="oc-card-go" href="${esc(actionHref)}" rel="noopener nofollow" target="_blank">${esc(action.label)}</a>` : '';
return `<div class="oc-card" data-kind="${esc(typeof card.kind === 'string' ? card.kind : 'link')}">${pic}<div class="oc-card-body">${head}${line ? `<p class="oc-card-line">${esc(line)}</p>` : ''}${note ? `<p class="oc-card-note">${esc(note)}</p>` : ''}${go}</div></div>`;
return `<div class="oc-card" data-kind="${esc(typeof card.kind === 'string' ? card.kind : 'link')}">${pic}<div class="oc-card-body">${head}${line ? `<p class="oc-card-line">${esc(line)}</p>` : ''}${note ? `<p class="oc-card-note">${esc(note)}</p>` : ''}${go}</div></div>`;
}
}
+/**
+ * The small × that takes a message's cards back, and the button that puts them back (server/rooms/routes.js).
+ * Only the message's author and the room's moderators get them: the page decides who may (server/web/pages.js).
+ */
+function hideForm(action) {
+ if (!action) return '';
+ return `<form class="oc-card-hide" method="post" action="${esc(action)}"><input type="hidden" name="hidden" value="1"><button type="submit" class="oc-card-hide-btn" aria-label="Hide the preview" title="Hide the preview">×</button></form>`;
+}
+function showForm(action) {
+ if (!action) return '';
+ return `<form class="oc-inline oc-card-show" method="post" action="${esc(action)}"><input type="hidden" name="hidden" value="0"><button type="submit" class="oc-link">Show previews</button></form>`;
+}
+
/**
/**
* An address preview under a message (server/chat/link-previews.js): the picture when there is one, the address's
* An address preview under a message (server/chat/link-previews.js): the picture when there is one, the address's
* title as the link, its description, and "by" and LIVE for OpenVibe's own kinds. Every field is data: escaped here.
* title as the link, its description, and "by" and LIVE for OpenVibe's own kinds. Every field is data: escaped here.
*/
*/
-function embedCard(e) {
+function embedCard(e, opts) {
if (!e || typeof e !== 'object' || typeof e.title !== 'string' || !e.title) return '';
if (!e || typeof e !== 'object' || typeof e.title !== 'string' || !e.title) return '';
if (e.kind === 'agent-run') {
if (e.kind === 'agent-run') {
const run = String(e.run_url || ''), events = String(e.events_url || '');
const run = String(e.run_url || ''), events = String(e.events_url || '');
@@ -160,17 +177,18 @@ function embedCard(e) {
@@ -160,17 +177,18 @@ function embedCard(e) {
const head = href
const head = href
? `<a class="oc-card-title" href="${esc(href)}" rel="noopener nofollow" target="_blank">${esc(e.title)}</a>`
? `<a class="oc-card-title" href="${esc(href)}" rel="noopener nofollow" target="_blank">${esc(e.title)}</a>`
: `<span class="oc-card-title">${esc(e.title)}</span>`;
: `<span class="oc-card-title">${esc(e.title)}</span>`;
- const pic = image ? `<a class="oc-card-pic" href="${esc(href || image)}" rel="noopener nofollow" target="_blank"><img src="${esc(image)}" alt="" loading="lazy" decoding="async"></a>` : '';
+ const pic = image ? `<a class="oc-card-pic" href="${esc(href || image)}" rel="noopener nofollow" target="_blank"><img src="${esc(image)}" alt="" loading="lazy" decoding="async" data-oc-card-pic="1"></a>` : '';
const description = typeof e.description === 'string' && e.description ? `<p class="oc-card-line">${esc(e.description)}</p>` : '';
const description = typeof e.description === 'string' && e.description ? `<p class="oc-card-line">${esc(e.description)}</p>` : '';
const by = typeof e.by === 'string' && e.by ? `<p class="oc-card-line">by ${esc(e.by)}</p>` : '';
const by = typeof e.by === 'string' && e.by ? `<p class="oc-card-line">by ${esc(e.by)}</p>` : '';
const live = e.live === true ? '<p class="oc-card-note"><span class="oc-badge oc-badge-mod">LIVE</span></p>' : '';
const live = e.live === true ? '<p class="oc-card-note"><span class="oc-badge oc-badge-mod">LIVE</span></p>' : '';
- return `<div class="oc-card" data-kind="${esc(kind)}">${pic}<div class="oc-card-body">${head}${description}${by}${live}</div></div>`;
+ const hide = opts && opts.hide ? hideForm(opts.hide) : '';
+ return `<div class="oc-card" data-kind="${esc(kind)}">${pic}<div class="oc-card-body">${head}${description}${by}${live}</div>${hide}</div>`;
}
}
/** A message's previews (up to three, in the order the addresses appeared), or its one known-link card when it has none. */
/** A message's previews (up to three, in the order the addresses appeared), or its one known-link card when it has none. */
-function messageCards(m) {
+function messageCards(m, opts) {
if (!m || typeof m !== 'object') return '';
if (!m || typeof m !== 'object') return '';
- if (Array.isArray(m.embeds) && m.embeds.length) return m.embeds.slice(0, 3).map(embedCard).filter(Boolean).join('');
+ if (Array.isArray(m.embeds) && m.embeds.length) return m.embeds.slice(0, 3).map((e) => embedCard(e, opts)).filter(Boolean).join('');
return m.card && m.card.kind === 'agent-run' ? embedCard(m.card) : linkCard(m.card);
return m.card && m.card.kind === 'agent-run' ? embedCard(m.card) : linkCard(m.card);
}
}
@@ -178,4 +196,4 @@ const hueOf = (slug) => { let h = 0; for (const c of String(slug)) h = (h * 31 +
@@ -178,4 +196,4 @@ const hueOf = (slug) => { let h = 0; for (const c of String(slug)) h = (h * 31 +
const serverLetters = (name) => String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase() || '?';
const serverLetters = (name) => String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase() || '?';
const serverMark = (s) => `<span class="oc-srv-icon oc-srv-icon--row" style="--oc-hue:${hueOf(s.slug)}" aria-hidden="true">${esc(serverLetters(s.name))}</span>`;
const serverMark = (s) => `<span class="oc-srv-icon oc-srv-icon--row" style="--oc-hue:${hueOf(s.slug)}" aria-hidden="true">${esc(serverLetters(s.name))}</span>`;
-module.exports = { head, when, initials, presenceState, presenceDot, presenceOrder, presenceLine, hueOf, serverLetters, serverMark, linkCard, embedCard, messageCards };
+module.exports = { head, when, initials, presenceState, presenceDot, presenceOrder, presenceLine, hueOf, serverLetters, serverMark, linkCard, embedCard, messageCards, hideForm, showForm };
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
+'use strict';
+/**
+ * A card's picture that does not load is dropped (public/web/chat.js, the markup from server/web/ui.js): the
+ * picture goes, the card stays (its title, its description). A card never shows a broken image.
+ *
+ * The client is run here with a small DOM of its own, so the rule is checked as it runs: the picture the server
+ * drew, the picture the client drew, and a picture that never loaded at all.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+// A node of the little DOM: a card, the link that holds its picture, the picture itself.
+function node(tag, cls) {
+ return {
+ tagName: String(tag || '').toUpperCase(), className: cls || '', attrs: {}, children: [], parentNode: null, listeners: {},
+ textContent: '',
+ setAttribute(k, v) { this.attrs[k] = String(v); },
+ getAttribute(k) { return this.attrs[k] == null ? null : this.attrs[k]; },
+ hasAttribute(k) { return this.attrs[k] != null; },
+ appendChild(c) { c.parentNode = this; this.children.push(c); return c; },
+ removeChild(c) { this.children = this.children.filter((x) => x !== c); c.parentNode = null; return c; },
+ remove() { if (this.parentNode) this.parentNode.removeChild(this); },
+ addEventListener(ev, fn) { (this.listeners[ev] = this.listeners[ev] || []).push(fn); },
+ closest(sel) {
+ const cls = String(sel || '').replace(/^\./, '');
+ let cur = this;
+ while (cur) {
+ if (String(cur.className || '').split(/\s+/).includes(cls)) return cur;
+ cur = cur.parentNode;
+ }
+ return null;
+ },
+ querySelectorAll() { return []; },
+ querySelector() { return null; },
+ };
+}
+
+/** A card with its picture: the card, the link that holds the picture, the picture itself. */
+function cardWithPicture({ broken = false } = {}) {
+ const card = node('div', 'oc-card');
+ const body = node('div', 'oc-card-body');
+ const title = node('span', 'oc-card-title');
+ title.textContent = 'A card';
+ body.appendChild(title);
+ const link = node('a', 'oc-card-pic');
+ const picture = node('img');
+ picture.setAttribute('data-oc-card-pic', '1');
+ picture.src = 'https://openvibe.media/a/att_01J/og.png';
+ picture.complete = broken;
+ picture.naturalWidth = broken ? 0 : 640;
+ link.appendChild(picture);
+ card.appendChild(link);
+ card.appendChild(body);
+ return { card, body, link, picture };
+}
+
+/** The client, run with a DOM of its own. */
+function runClient(pictures) {
+ const docHandlers = {};
+ const document = {
+ documentElement: { classList: { add() {}, remove() {} } },
+ hidden: false,
+ cookie: '',
+ getElementById: () => null,
+ createElement: (tag) => node(tag),
+ createTextNode: (text) => Object.assign(node('#text'), { textContent: text }),
+ querySelector: () => null,
+ querySelectorAll: (sel) => (String(sel).includes('data-oc-card-pic') ? pictures : []),
+ addEventListener(type, fn) { (docHandlers[type] = docHandlers[type] || []).push(fn); },
+ body: node('body'),
+ };
+ const ctx = {
+ window: { __OV_PAGE: { chat: { view: 'none' } }, addEventListener() {} },
+ document,
+ navigator: {},
+ console,
+ URL,
+ setTimeout: () => 0,
+ clearTimeout: () => {},
+ setInterval: () => 0,
+ clearInterval: () => {},
+ fetch: () => Promise.reject(new Error('no network here')),
+ };
+ vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'public', 'web', 'chat.js'), 'utf8'), ctx);
+ return { document, docHandlers, window: ctx.window };
+}
+
+// ── The picture that fails to load is dropped, and the card stays ──
+{
+ const { card, body, link, picture } = cardWithPicture();
+ const { docHandlers } = runClient([picture]);
+ assert.strictEqual(picture.listeners.error && picture.listeners.error.length, 1, 'the client watches the picture');
+ picture.listeners.error[0]();
+ assert.strictEqual(card.children.includes(link), false, 'the picture is gone');
+ assert.strictEqual(card.children.includes(body), true, 'the card stays');
+ assert.strictEqual(body.children.length, 1, 'with its title');
+ // The same picture, failing later (the page's own handler catches it too).
+ const second = cardWithPicture();
+ const client = runClient([second.picture]);
+ client.docHandlers.error.forEach((fn) => fn({ target: second.picture }));
+ assert.strictEqual(second.card.children.includes(second.link), false, 'a picture that fails later is dropped as well');
+ assert.strictEqual(second.card.children.includes(second.body), true, 'and the card stays');
+}
+
+// ── A picture that never loaded is dropped too ──
+{
+ const { card, body, link, picture } = cardWithPicture({ broken: true });
+ runClient([picture]);
+ assert.strictEqual(card.children.includes(link), false, 'a picture that never loaded is dropped');
+ assert.strictEqual(card.children.includes(body), true, 'and the card stays');
+}
+
+// ── The server draws the same picture, marked so the client can drop it ──
+{
+ const ui = require('../server/web/ui');
+ const html = ui.embedCard({ url: 'https://example.com/page', title: 'A card', kind: 'link', image: 'https://openvibe.media/a/att_01J/og.png' });
+ assert.ok(html.includes('data-oc-card-pic'), 'the server marks the picture it draws');
+ assert.ok(html.includes('class="oc-card-pic"'), 'in the card picture');
+ const known = ui.linkCard({ url: 'https://example.com/page', title: 'A card', image: 'https://openvibe.media/a/att_01J/og.png' });
+ assert.ok(known.includes('data-oc-card-pic'), 'the known-link card marks its picture too');
+}
+
+console.log('card picture: a picture that does not load is dropped, the card stays');
new file mode 100644
new file mode 100644
@@ -0,0 +1,263 @@
@@ -0,0 +1,263 @@
+'use strict';
+/**
+ * The cards of a message (server/chat/link-previews.js, server/rooms/routes.js):
+ *
+ * - taking them back: the message's author, the room's owner and its moderators may hide them, nobody else
+ * may; a hidden card is not sent on a read, the room is told at once, and the cards can be put back;
+ * - the room's switch: its owner and its moderators turn the cards off and on again;
+ * - the page's own picture: it is brought into OpenVibe.Media (server/chat/media-import.js) and the card
+ * shows the Media address, never a hot-linked one.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+const t = suite('link preview cards');
+let h, alice, mod, bob, carol, previews, mediaImport;
+
+const page = (head) => `<html><head>${head}</head><body>body</body></html>`;
+/** The picture Media brings in: its id (the one a keep names) and the address the card shows. */
+const PICTURE = 'att_01JZ0M0Q8F3K2V7YB6N4T5R9WQ';
+/** Media is stubbed here: the import answers with the picture, the keep answers that it is kept. */
+const mediaStub = (calls) => async (url, opts) => {
+ calls.push({ url: String(url), auth: String((opts && opts.headers && (opts.headers.Authorization || opts.headers.authorization)) || ''), body: JSON.parse(opts.body) });
+ const keep = String(url).endsWith('/keep');
+ return {
+ status: keep ? 200 : 201,
+ json: async () => (keep
+ ? { id: PICTURE, message_id: (JSON.parse(opts.body) || {}).message_id, kept_for: [] }
+ : { id: PICTURE, kind: 'image', mime: 'image/png', url: `https://openvibe.media/a/${PICTURE}/picture.png` }),
+ };
+};
+const type = (body) => ({ status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body });
+const room = (method, path, user, body) => h.http(method, `/api/chat/rooms${path}`, { token: user && user.token, body });
+/** A form the site's own pages post (a cookie, not a bearer). */
+const form = async (p, body, token) => {
+ const res = await fetch(`${h.base}${p}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: `ov_token=${token}` }, body: new URLSearchParams(body).toString(), redirect: 'manual' });
+ return { status: res.status, location: res.headers.get('location') };
+};
+/** A room of its own, so one test's messages never count against another's posting budget. */
+const newRoom = async (name) => {
+ const made = await room('POST', '/', alice, { name });
+ assert.strictEqual(made.status, 201, made.text);
+ return made.body.room;
+};
+/**
+ * A room of bob's own, with alice in it: the room budget is per caller, and this file already makes as many
+ * rooms as alice may make in a minute.
+ */
+const newRoomFor = async (name) => {
+ const made = await room('POST', '/', bob, { name });
+ assert.strictEqual(made.status, 201, made.text);
+ await room('POST', `/${made.body.room.slug}/join`, alice);
+ return made.body.room;
+};
+const withPreview = async (slug, text, html) => {
+ previews._setRequest(async () => type(html));
+ const sent = await room('POST', `/${slug}/messages`, alice, { message: text });
+ assert.strictEqual(sent.status, 201, sent.text);
+ await previews.idle();
+ return sent.body.message;
+};
+
+t('boot', async () => {
+ h = await boot();
+ previews = require('../server/chat/link-previews');
+ mediaImport = require('../server/chat/media-import');
+ alice = h.addUser('alice', { subject: ids.newId('user') });
+ mod = h.addUser('mod', { subject: ids.newId('user'), role: 'global_mod' });
+ bob = h.addUser('bob', { subject: ids.newId('user') });
+ carol = h.addUser('carol', { subject: ids.newId('user') });
+ for (const u of [alice, mod, bob, carol]) {
+ await h.ctx.upsertUser(h.live.users.get(u.id));
+ h.netModules.subjects.add(u.subject_id);
+ }
+ await h.ctx.sync();
+});
+
+t('the author takes the cards back: they are not sent on a read, and the room is told at once', async () => {
+ const r = await newRoom('Taken back');
+ const message = await withPreview(r.slug, 'https://93.184.216.34/take-me-back', page('<meta property="og:title" content="Take me back">'));
+ const before = await room('GET', `/${r.slug}/messages`, alice);
+ assert.strictEqual(before.body.messages.find((m) => m.id === message.id).embeds.length, 1, 'the card is there to begin with');
+
+ const ws = await h.ws({ token: bob.token });
+ ws.sendJson({ type: 'join_room', room: r.slug, token: bob.token });
+ await ws.next((m) => m.type === 'room_joined');
+
+ const hidden = await room('POST', `/${r.slug}/messages/${message.id}/embeds/hidden`, alice, { hidden: true });
+ assert.strictEqual(hidden.status, 200, hidden.text);
+ assert.strictEqual(hidden.body.hidden, true);
+ assert.deepStrictEqual(hidden.body.embeds, [], 'the answer carries no cards');
+ const event = await ws.next((m) => m.type === 'room_message_embeds' && Number(m.id) === message.id);
+ assert.strictEqual(event.hidden, true, 'open clients are told at once');
+ assert.deepStrictEqual(event.embeds, []);
+
+ const after = await room('GET', `/${r.slug}/messages`, alice);
+ const row = after.body.messages.find((m) => m.id === message.id);
+ assert.deepStrictEqual(row.embeds, [], 'a hidden card is not sent on a read');
+ assert.ok(row.embeds_hidden, 'the message says its cards are gone');
+ ws.close();
+});
+
+t('the cards come back when the author says so', async () => {
+ const r = await newRoom('Put back');
+ const message = await withPreview(r.slug, 'https://93.184.216.34/put-me-back', page('<meta property="og:title" content="Put me back">'));
+ await room('POST', `/${r.slug}/messages/${message.id}/embeds/hidden`, alice, { hidden: true });
+ const back = await room('POST', `/${r.slug}/messages/${message.id}/embeds/hidden`, alice, { hidden: false });
+ assert.strictEqual(back.status, 200, back.text);
+ assert.strictEqual(back.body.hidden, false);
+ assert.strictEqual(back.body.embeds.length, 1, 'the cards are back');
+ const got = await room('GET', `/${r.slug}/messages`, alice);
+ const row = got.body.messages.find((m) => m.id === message.id);
+ assert.strictEqual(row.embeds.length, 1, 'and they are sent on a read again');
+ assert.ok(!row.embeds_hidden);
+});
+
+t('a room moderator may take them back; another member may not', async () => {
+ const r = await newRoom('Moderated cards');
+ const message = await withPreview(r.slug, 'https://93.184.216.34/moderated-cards', page('<meta property="og:title" content="Moderated">'));
+ await room('POST', `/${r.slug}/join`, carol);
+ const refused = await room('POST', `/${r.slug}/messages/${message.id}/embeds/hidden`, carol, { hidden: true });
+ assert.strictEqual(refused.status, 403, 'a member is not the author and not a moderator');
+ const done = await room('POST', `/${r.slug}/messages/${message.id}/embeds/hidden`, mod, { hidden: true });
+ assert.strictEqual(done.status, 200, done.text);
+ assert.strictEqual(done.body.hidden, true);
+ const got = await room('GET', `/${r.slug}/messages`, alice);
+ assert.deepStrictEqual(got.body.messages.find((m) => m.id === message.id).embeds, []);
+});
+
+t('the owner and the moderators turn the cards off and on again', async () => {
+ const r = await newRoom('Switch room');
+ const off = await room('PATCH', `/${r.slug}/link-previews`, mod, { link_previews: 'off' });
+ assert.strictEqual(off.status, 200, off.text);
+ assert.strictEqual(off.body.room.link_previews, false, 'a moderator may switch them off');
+ const refused = await room('PATCH', `/${r.slug}/link-previews`, carol, { link_previews: 'off' });
+ assert.strictEqual(refused.status, 403, 'a member may not');
+
+ previews._setRequest(async () => { throw new Error('off must not fetch'); });
+ const sent = await room('POST', `/${r.slug}/messages`, alice, { message: 'https://93.184.216.34/while-off' });
+ assert.strictEqual(sent.status, 201, sent.text);
+ await previews.idle();
+ const got = await room('GET', `/${r.slug}/messages`, alice);
+ assert.deepStrictEqual(got.body.messages.find((m) => m.id === sent.body.message.id).embeds, [], 'no fetch and no cards while off');
+
+ const on = await room('PATCH', `/${r.slug}/link-previews`, alice, { link_previews: 'on' });
+ assert.strictEqual(on.status, 200, on.text);
+ assert.strictEqual(on.body.room.link_previews, true, 'the owner may switch them on');
+ const again = await withPreview(r.slug, 'https://93.184.216.34/after-switch', page('<meta property="og:title" content="Back on">'));
+ const after = await room('GET', `/${r.slug}/messages`, alice);
+ assert.strictEqual(after.body.messages.find((m) => m.id === again.id).embeds[0].title, 'Back on', 'new messages get their cards again');
+});
+
+t('the picture of a page is brought into Media, and the card never hot-links it', async () => {
+ const r = await newRoom('Picture room');
+ const calls = [];
+ mediaImport._setFetch(mediaStub(calls));
+ const message = await withPreview(r.slug, 'https://93.184.216.34/with-a-picture',
+ page('<meta property="og:title" content="With a picture"><meta property="og:image" content="https://pictures.example/og.png">'));
+ const got = await room('GET', `/${r.slug}/messages`, alice);
+ const embed = got.body.messages.find((m) => m.id === message.id).embeds[0];
+ assert.strictEqual(embed.image, `https://openvibe.media/a/${PICTURE}/picture.png`, 'the card shows the Media address');
+ assert.ok(!JSON.stringify(embed).includes('pictures.example'), 'the other host is nowhere on the card');
+ const imports = calls.filter((c) => c.url.endsWith('/attachments/import'));
+ assert.strictEqual(imports.length, 1, 'Media is asked once');
+ assert.match(imports[0].auth, /^Bearer /, 'the call carries a service token');
+ assert.deepStrictEqual(imports[0].body, { url: 'https://pictures.example/og.png' }, 'Media is asked for that picture');
+ const asked = h.tokenRequests.find((t2) => t2.audience === 'openvibe.media');
+ assert.ok(asked, 'a service token for Media was asked for');
+ assert.match(asked.scope, /media\.object\.upload/);
+});
+
+t('the picture of a stored preview is kept with the message it is shown under', async () => {
+ const r = await newRoomFor('Kept picture room');
+ const calls = [];
+ mediaImport._setFetch(mediaStub(calls));
+ const message = await withPreview(r.slug, 'https://93.184.216.34/kept-picture',
+ page('<meta property="og:title" content="Kept"><meta property="og:image" content="https://pictures.example/kept.png">'));
+ const keeps = calls.filter((c) => c.url.endsWith('/keep'));
+ assert.strictEqual(keeps.length, 1, 'the picture is kept once');
+ assert.ok(keeps[0].url.includes(`/attachments/${PICTURE}/keep`), 'Media is asked to keep that picture');
+ assert.deepStrictEqual(keeps[0].body, { message_id: String(message.id) }, 'and it is kept with the message it is shown under');
+ assert.match(keeps[0].auth, /^Bearer /, 'the keep carries Chat\'s own service token');
+});
+
+t('a picture the cache shows again for a second message is kept again for it', async () => {
+ const r = await newRoomFor('Kept twice room');
+ const calls = [];
+ mediaImport._setFetch(mediaStub(calls));
+ const address = 'https://93.184.216.34/kept-twice';
+ const html = page('<meta property="og:title" content="Kept twice"><meta property="og:image" content="https://pictures.example/twice.png">');
+ const first = await withPreview(r.slug, address, html);
+ const second = await withPreview(r.slug, address, html);
+ assert.strictEqual(calls.filter((c) => c.url.endsWith('/attachments/import')).length, 1, 'the picture is brought in once');
+ const keeps = calls.filter((c) => c.url.endsWith('/keep'));
+ assert.strictEqual(keeps.length, 2, 'and it is kept twice');
+ assert.deepStrictEqual(keeps.map((c) => c.body.message_id), [String(first.id), String(second.id)], 'once for each message that shows it');
+});
+
+t('a picture Media refuses leaves the card without one', async () => {
+ const r = await newRoom('No picture room');
+ mediaImport._setFetch(async () => ({ status: 422, json: async () => ({ error: 'that address is not a picture' }) }));
+ const message = await withPreview(r.slug, 'https://93.184.216.34/not-a-picture',
+ page('<meta property="og:title" content="No picture"><meta property="og:image" content="https://pictures.example/page.html">'));
+ const got = await room('GET', `/${r.slug}/messages`, alice);
+ const embed = got.body.messages.find((m) => m.id === message.id).embeds[0];
+ assert.strictEqual(embed.image, null, 'no picture on the card');
+ assert.ok(!JSON.stringify(embed).includes('pictures.example'), 'and no address from the other host');
+});
+
+t('a picture that is too large leaves the card without one', async () => {
+ const r = await newRoom('Big picture room');
+ mediaImport._setFetch(async () => ({ status: 413, json: async () => ({ error: 'that picture is too large (2 MB at most)' }) }));
+ const message = await withPreview(r.slug, 'https://93.184.216.34/big-picture',
+ page('<meta property="og:title" content="Big picture"><meta property="og:image" content="https://pictures.example/big.png">'));
+ const got = await room('GET', `/${r.slug}/messages`, alice);
+ const embed = got.body.messages.find((m) => m.id === message.id).embeds[0];
+ assert.strictEqual(embed.image, null, 'no picture on the card');
+ assert.ok(!JSON.stringify(embed).includes('pictures.example'));
+});
+
+t('the room page draws the × for those who may hide the cards, and for nobody else', async () => {
+ const r = await newRoom('Drawn room');
+ const message = await withPreview(r.slug, 'https://93.184.216.34/drawn', page('<meta property="og:title" content="Drawn">'));
+ const mine = await (await fetch(`${h.base}/r/${r.slug}`, { headers: { cookie: `ov_token=${alice.token}` } })).text();
+ assert.ok(mine.includes('/web/chat.js'), 'the room page loads the client that drops a picture that does not load');
+ assert.ok(mine.includes('aria-label="Hide the preview"'), 'the author gets the ×');
+ assert.ok(mine.includes(`/r/${r.slug}/embeds/${message.id}/hidden`), 'and the form that hides them');
+ const other = await (await fetch(`${h.base}/r/${r.slug}`, { headers: { cookie: `ov_token=${carol.token}` } })).text();
+ assert.ok(!other.includes('aria-label="Hide the preview"'), 'a member gets no ×');
+ const modPage = await (await fetch(`${h.base}/r/${r.slug}`, { headers: { cookie: `ov_token=${mod.token}` } })).text();
+ assert.ok(modPage.includes('aria-label="Hide the preview"'), 'a moderator does');
+});
+
+t('the settings page shows the switch to the moderators, and its form saves it', async () => {
+ const r = await newRoom('Settings room');
+ const settings = await (await fetch(`${h.base}/r/${r.slug}/settings`, { headers: { cookie: `ov_token=${mod.token}` } })).text();
+ assert.ok(settings.includes('Link previews'), 'the switch is in the room settings');
+ assert.ok(settings.includes('name="link_previews"'), 'and it is a switch');
+ const refused = await form(`/r/${r.slug}/link-previews`, { has_link_previews: '1' }, carol.token);
+ assert.strictEqual(refused.status, 303, 'a member is sent back with the reason');
+ assert.strictEqual((await room('GET', `/${r.slug}`, mod)).body.room.link_previews, true, 'and the switch stays on');
+ const saved = await form(`/r/${r.slug}/link-previews`, { has_link_previews: '1' }, mod.token);
+ assert.strictEqual(saved.status, 303, 'a moderator saves it');
+ assert.strictEqual((await room('GET', `/${r.slug}`, mod)).body.room.link_previews, false, 'the switch is off');
+});
+
+t('the pages own form takes the cards back and puts them back', async () => {
+ const r = await newRoom('Form room');
+ const message = await withPreview(r.slug, 'https://93.184.216.34/by-form', page('<meta property="og:title" content="By form">'));
+ const hidden = await form(`/r/${r.slug}/embeds/${message.id}/hidden`, { hidden: '1' }, alice.token);
+ assert.strictEqual(hidden.status, 303, 'the form answers with a redirect');
+ const got = await room('GET', `/${r.slug}/messages`, alice);
+ assert.deepStrictEqual(got.body.messages.find((m) => m.id === message.id).embeds, [], 'the cards are gone');
+ const back = await form(`/r/${r.slug}/embeds/${message.id}/hidden`, { hidden: '0' }, alice.token);
+ assert.strictEqual(back.status, 303);
+ const again = await room('GET', `/${r.slug}/messages`, alice);
+ assert.strictEqual(again.body.messages.find((m) => m.id === message.id).embeds.length, 1, 'and they are back');
+});
+
+t.run(async () => {
+ previews._reset();
+ mediaImport._setFetch(null);
+ await h.close();
+});