openvibers/openvibe.chat Commits

Chat cards: hide one message's cards, a room switch, and the page's picture through Media (#575)

OP OpenVibers committed 4 h ago · d7098dab0f · 11 files, +768 −25

* Chat cards: hide one message's cards, a room switch, and the page's picture through Media

The author of a message, the room's owner and its moderators can take the cards of
one message back (room_messages.embeds_hidden): a hidden card is not sent on a
read, the room hears it at once (room_message_embeds with hidden: true), and the
cards can be put back. The room page draws a small × (aria-label 'Hide the
preview') on each card for those who may, and for nobody else.

The room's own switch (link_previews) is the owner and the moderators' now, and
it shows in the room settings: off means no fetch for the room's new messages and
no cards on a read.

A page's own picture is no longer left off the card: the link preview job brings
it into OpenVibe.Media (server/chat/media-import.js, Chat's own service token)
and keeps the Media address, so no third-party picture is ever hot-linked.

* Chat cards: the picture is kept with the message, and a broken one is dropped

The page's own picture (brought into OpenVibe.Media by the link preview job) was an unkept attachment
with a 24-hour life, so a card's picture went quiet after a day. Once the preview is stored, Chat keeps
the picture with that message (Media's POST /api/v1/attachments/:id/keep, Chat's own service token), and
a picture the day-long cache shows again for another message is kept again for it: it lives in Media's
kept window (30 days) instead of the day-long one.

A picture that does not load is dropped: the client watches the card pictures the server draws (they
carry data-oc-card-pic) and the ones it draws itself, and removes the picture when the image errors. The
card stays (its title, its description, its link), so a card never shows a broken image.

* Chat cards: check that the room page loads the client that drops a failed picture

Changes

ddiff --git a/migrations/0080_room_message_embeds_hidden.sql b/migrations/0080_room_message_embeds_hidden.sql
nnew file mode 100644
iindex 0000000..fb89d8c
---- /dev/null
++++ b/migrations/0080_room_message_embeds_hidden.sql
@@@ -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;
ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 9319fe2..0d92ae5 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -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; }
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex 717f3e6..65156ca 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -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) {
ddiff --git a/server/chat/link-previews.js b/server/chat/link-previews.js
iindex 4d2b9b1..60a567f 100644
---- a/server/chat/link-previews.js
++++ b/server/chat/link-previews.js
@@@ -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.data
WHERE 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; },
ddiff --git a/server/chat/media-import.js b/server/chat/media-import.js
nnew file mode 100644
iindex 0000000..47e5122
---- /dev/null
++++ b/server/chat/media-import.js
@@@ -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; } };
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex 5b6a9a0..76c08da 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -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, join
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). */
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,
};
ddiff --git a/server/rooms/routes.js b/server/rooms/routes.js
iindex 48da329..6b0dc94 100644
---- a/server/rooms/routes.js
++++ b/server/rooms/routes.js
@@@ -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
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex c29b8db..a267e29 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -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>
ddiff --git a/server/web/ui.js b/server/web/ui.js
iindex 3bc8494..435a98c 100644
---- a/server/web/ui.js
++++ b/server/web/ui.js
@@@ -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 };
ddiff --git a/test/card-picture.test.js b/test/card-picture.test.js
nnew file mode 100644
iindex 0000000..0ef9fe8
---- /dev/null
++++ b/test/card-picture.test.js
@@@ -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');
ddiff --git a/test/link-preview-cards.test.js b/test/link-preview-cards.test.js
nnew file mode 100644
iindex 0000000..91b7017
---- /dev/null
++++ b/test/link-preview-cards.test.js
@@@ -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();
++});