openvibers/openvibe.chat Commits

Chat: direct messages respect both people's link preview switch (#500)

OP OpenVibers committed 3 days ago · 7c56b5a28f · 9 files, +155 −2

* Chat: direct messages respect both people's link preview switch

* Merge remote-tracking branch 'origin/main' into chat-dm-preview-privacy

* Chat: include the direct-message preview choice in account export and erasure

* Chat: number the direct-message preview migration 0070

* Merge remote-tracking branch 'origin/main' into chat-dm-preview-privacy

Changes

ddiff --git a/migrations/0070_dm_link_preview_settings.sql b/migrations/0070_dm_link_preview_settings.sql
nnew file mode 100644
iindex 0000000..ce2354c
---- /dev/null
++++ b/migrations/0070_dm_link_preview_settings.sql
@@@ -0,0 +1,11 @@
++-- phase: expand
++-- Link previews in direct messages (server/chat/dm-preview-settings.js, server/chat/dm.js): a person's switch,
++-- on by default. A DM address is fetched (and its stored previews shown) only when everyone in the conversation
++-- has it on; put off, the message gains no preview and an earlier one is hidden on every read.
++--
++-- Expand only: one new table. No row means on, so older releases and every existing query keep working.
++CREATE TABLE IF NOT EXISTS dm_link_preview_settings (
++ user_id bigint PRIMARY KEY, -- the Chat user id (dm_participants.user_id)
++ enabled boolean NOT NULL DEFAULT true,
++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now()
++);
ddiff --git a/server/chat/account-data.js b/server/chat/account-data.js
iindex 5496e70..64c93a1 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -81,6 +81,7 @@ const EXPORTS = [
['dm_contacts.json', `SELECT user_id, contact_id, status, created_at FROM dm_contacts WHERE user_id = ANY(@ids) OR contact_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['dm_view_defaults.json', `SELECT kind, view FROM dm_view_defaults WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
['dm_view_preferences.json', `SELECT conversation_id, view, updated_at FROM dm_view_preferences WHERE user_id = ANY(@ids) ORDER BY updated_at DESC LIMIT ${LIMIT}`],
++ ['dm_link_preview_settings.json', `SELECT enabled, updated_at FROM dm_link_preview_settings WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
['inboxes_owned.json', `SELECT * FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['inbox_memberships.json', `SELECT * FROM inbox_members WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
['inbox_replies.json', `SELECT * FROM inbox_messages WHERE author = 'agent' AND author_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
@@@ -154,6 +155,7 @@ const ERASE_AFTER_ROOMS = [
['dm_contacts', 'DELETE FROM dm_contacts WHERE user_id = ANY(@ids) OR contact_id = ANY(@ids)'],
['dm_view_defaults', 'DELETE FROM dm_view_defaults WHERE user_id = ANY(@ids)'],
['dm_view_preferences', 'DELETE FROM dm_view_preferences WHERE user_id = ANY(@ids)'],
++ ['dm_link_preview_settings', 'DELETE FROM dm_link_preview_settings WHERE user_id = ANY(@ids)'],
// Support inboxes (server/inbox/inbox.js): the inboxes they own go with every conversation in them; in others,
// their replies stay for the visitor without their name, and their place on the team goes.
[null, 'DELETE FROM inbox_translations WHERE message_id IN (SELECT m.id FROM inbox_messages m JOIN inbox_conversations c ON c.id = m.conversation_id JOIN inboxes i ON i.id = c.inbox_id WHERE i.owner_subject = ANY(@s) OR i.owner_id = ANY(@ids))'],
ddiff --git a/server/chat/dm-preview-settings.js b/server/chat/dm-preview-settings.js
nnew file mode 100644
iindex 0000000..245e1f3
---- /dev/null
++++ b/server/chat/dm-preview-settings.js
@@@ -0,0 +1,46 @@
++'use strict';
++/**
++ * Link previews in direct messages (plan "embeds", part 4): a person's own switch, on by default, kept in Chat
++ * because it only governs what Chat does with a DM address (it is not a portable Network preference).
++ *
++ * Privacy rule: a DM address is fetched only when every person in the conversation has the switch on. Off on one
++ * side and the message gains no preview at all; the stored previews of earlier messages are hidden on every read
++ * too, so turning it off also stops showing what was already fetched. The setting is per person, not per
++ * conversation: it covers every DM they are in, including a group.
++ *
++ * Enforcement lives in server/chat/dm.js (send and read), the checkbox in the Chat settings page.
++ */
++const db = require('../db/database');
++
++/** True for a person with the switch on, including someone who never touched it (no row = the default). */
++async function enabledFor(userId) {
++ const id = Number(userId) || 0;
++ if (!(id > 0)) return false;
++ const row = await db.get('SELECT enabled FROM dm_link_preview_settings WHERE user_id = ?', [id]).catch(() => ({ enabled: false }));
++ if (!row) return true;
++ return !(row.enabled === false || row.enabled === 0 || row.enabled === '0');
++}
++
++/** Save a person's choice: true is the default, so it stores the on state too rather than deleting the row. */
++async function setEnabled(userId, enabled) {
++ const id = Number(userId) || 0;
++ if (!(id > 0)) return false;
++ await db.run(`INSERT INTO dm_link_preview_settings (user_id, enabled) VALUES (?, ?)
++ ON CONFLICT (user_id) DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = ov_now()`, [id, enabled !== false]);
++ return true;
++}
++
++/**
++ * Whether this conversation may fetch or show previews: false when anyone in it has the switch off. One query,
++ * so a DM read never waits on one Network module per person.
++ */
++async function allowedIn(conversationId) {
++ const id = Number(conversationId) || 0;
++ if (!(id > 0)) return false;
++ const row = await db.get(`SELECT COUNT(*) AS off FROM dm_participants p
++ JOIN dm_link_preview_settings s ON s.user_id = p.user_id
++ WHERE p.conversation_id = ? AND s.enabled = false`, [id]).catch(() => ({ off: 1 }));
++ return !(row && Number(row.off) > 0);
++}
++
++module.exports = { enabledFor, setEnabled, allowedIn };
ddiff --git a/server/chat/dm.js b/server/chat/dm.js
iindex 587df03..e2f9c55 100644
---- a/server/chat/dm.js
++++ b/server/chat/dm.js
@@@ -20,6 +20,8 @@ const db = require('../db/database');
const outbox = require('../events/service-outbox');
const networkBlocks = require('./network-blocks');
const linkCards = require('./link-cards');
++const linkPreviews = require('./link-previews');
++const previewSettings = require('./dm-preview-settings');
// ── Schema & Migrations ──────────────────────────────────────
@@@ -202,7 +204,13 @@ async function sendMessage(conversationId, senderId, text, { reply_to_id = null
});
if (row) {
row.card = card;
++ row.embeds = []; // the job fills them; the send answer starts empty, as a room's does
if (pending) linkCards.fillLater('dm_messages', row.id, pending);
++ // Address previews run after the line is out (server/chat/link-previews.js), and only when every person in
++ // the conversation left link previews on (part 4 privacy: one side off fetches nothing).
++ if (await previewSettings.allowedIn(conversationId)) {
++ linkPreviews.schedule({ scope: 'dm', messageId: row.id, text: trimmed });
++ }
// Activity: @mentions in the conversation, and the person whose message this answers (server/chat/activity.js).
const activity = require('./activity');
const sender = senderId ? await require('../live-context').getUserById(senderId) : null;
@@@ -248,7 +256,11 @@ async function getMessages(conversationId, limit = 50, before = null, after = nu
LIMIT ?
`, [conversationId, limit]);
}
-- return await linkCards.hydrate(rows, 'dm_messages');
++ const messages = await linkCards.hydrate(rows, 'dm_messages');
++ // A preview is shown only while everyone in the conversation has the switch on: turning it off hides what an
++ // earlier message already had (server/chat/dm-preview-settings.js).
++ await linkPreviews.attach(messages, 'dm', { enabled: await previewSettings.allowedIn(conversationId) });
++ return messages;
}
async function getMessagesPage(conversationId, { before = null, limit = 50 } = {}) {
@@@ -263,6 +275,7 @@ async function getMessagesPage(conversationId, { before = null, limit = 50 } = {
const more = rows.length > lim;
rows.length = Math.min(rows.length, lim);
await linkCards.hydrate(rows, 'dm_messages');
++ await linkPreviews.attach(rows, 'dm', { enabled: await previewSettings.allowedIn(conversationId) });
return { messages: rows, more };
}
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 28af955..e350d36 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -714,6 +714,7 @@ ${composer}`,
const actor = await needUser(req, res); if (!actor) return;
const sid = actor.user.subject_id;
const [p, tts, dmSet, pres, viewDefaults] = await Promise.all([prefsOf(actor), prefStores.settingsOf(prefStores.tts, sid), prefStores.settingsOf(prefStores.dm, sid), prefStores.settingsOf(prefStores.presence, sid), require('../chat/view-defaults').of(actor.user.id)]);
++ const dmPreviews = await require('../chat/dm-preview-settings').enabledFor(actor.user.id);
const src = tts.sources || {};
// A settings row: what it does on the left (with one muted line when it needs saying), the switch on the right.
const box = (name, label, on, hint = '') => `<label class="oc-set-row"><span class="oc-set-text">${label}${hint ? `<small>${hint}</small>` : ''}</span><input type="checkbox" class="oc-switch" name="${name}" value="1"${on ? ' checked' : ''}></label>`;
@@@ -753,6 +754,7 @@ ${[['one_to_one','One-to-one chats','bubbles'],['ai','Chats with Vibe','bubbles'
<label class="oc-set-row"><span class="oc-set-text">Who can start one with you</span><select name="new_conversations"><option value="everyone"${dmSet.new_conversations !== 'nobody' ? ' selected' : ''}>Everyone</option><option value="nobody"${dmSet.new_conversations === 'nobody' ? ' selected' : ''}>Nobody</option></select></label>
${box('group_invites', 'People can add me to groups', dmSet.group_invites !== false)}
${box('previews', 'Message text in notifications', dmSet.previews !== false)}
++${box('dm_link_previews', 'Link previews in direct messages', dmPreviews, 'Only when everyone in the conversation has them on')}
${box('show_in_user_list', 'My name in chat user lists', pres.show_in_user_list !== false, 'Off: you are counted, not named')}
</section>
<section class="oc-set" aria-labelledby="oc-set-agents"><h2 id="oc-set-agents">AI agents</h2>
@@@ -848,6 +850,7 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
const sources = Object.fromEntries(TTS_SOURCES.filter(([k]) => !on(`tts_src_${k}`)).map(([k]) => [k, false]));
const sid = actor.user.subject_id;
try {
++ await require('../chat/dm-preview-settings').setEnabled(actor.user.id, on('dm_link_previews'));
await prefs.update(sid, patch);
// The view defaults are Chat's own (server/chat/view-defaults.js): the Network module has no field for
// them yet, and a refused patch would lose the whole save.
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 84100e5..dc4b638 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -35,7 +35,7 @@ t('export, then erase and confirm', async () => {
const missing = await uncovered(d, {
taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_app_callbacks', 'chat_app_link_tokens', 'chat_messages', 'chat_server_invites', 'chat_server_members',
'chat_stage_speakers',
-- 'ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_contacts', 'dm_participants', 'dm_view_defaults', 'dm_view_preferences', 'emotes', 'inbox_actions',
++ 'ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_contacts', 'dm_participants', 'dm_view_defaults', 'dm_view_preferences', 'dm_link_preview_settings', 'emotes', 'inbox_actions',
'inbox_members', 'inbox_saved_replies', 'inbox_webhooks', 'pending_ip_messages', 'room_hooks', 'room_members', 'room_messages', 'agent_room_poll_votes', 'room_message_reactions', 'chat_activity',
'user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_canvases', 'vibe_canvas_versions', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage', 'vibe_model_preferences', 'vibe_import_notices'],
exempt: {
@@@ -58,6 +58,7 @@ t('export, then erase and confirm', async () => {
await d.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(dana.subject_id, otto.subject_id, otto.subject_id, dana.subject_id);
await d.prepare("INSERT INTO moderation_actions (action_type, actor_user_id, target_user_id, actor_subject_id) VALUES ('timeout', ?, ?, ?)").run(otto.id, dana.id, otto.subject_id);
await d.prepare("INSERT INTO user_tags (user_id, tag_id) VALUES (?, 'veteran')").run(dana.id);
++ await d.prepare('INSERT INTO dm_link_preview_settings (user_id, enabled) VALUES (?, false)').run(dana.id);
// Chat-app rows (migrations/0037_chat_apps.sql): her linked chat account, a button and a queued delivery.
const future = new Date(Date.now() + 600000).toISOString();
await d.prepare("INSERT INTO chat_app_link_tokens (token_hash, app, service_user_id, service_username, state, subject, expires_at) VALUES ('h-dana', 'telegram', '123', 'dana_tg', 'used', ?, ?)").run(dana.subject_id, future);
@@@ -80,6 +81,7 @@ t('export, then erase and confirm', async () => {
assert.ok(files['moderation_on_you.json'][0] && !('actor_user_id' in files['moderation_on_you.json'][0]), "moderation on them, without who acted");
assert.deepStrictEqual(files['chat_app_links.json'].map((l) => l.service_user_id), ['123'], 'her linked chat account');
assert.deepStrictEqual(files['chat_app_approvals.json'].map((c) => c.approval_id), ['apr1'], 'the button made for her');
++ assert.deepStrictEqual(files['dm_link_preview_settings.json'].map((r) => Boolean(r.enabled)), [false], 'her direct-message preview choice');
assert.strictEqual(await accountData.apply(exp, { send }), 'unchanged');
assert.strictEqual(sent.length, 1);
@@@ -94,6 +96,7 @@ t('export, then erase and confirm', async () => {
assert.strictEqual((await q('SELECT * FROM network_blocks')).length, 0, 'blocks both ways');
assert.strictEqual((await q('SELECT * FROM moderation_actions')).length, 1, 'moderation history stays');
assert.strictEqual((await q('SELECT * FROM user_tags')).length, 0, 'the tags table is Chat’s and goes');
++ assert.strictEqual((await q('SELECT * FROM dm_link_preview_settings')).length, 0, 'her preview choice goes');
assert.strictEqual((await q('SELECT * FROM chat_app_link_tokens')).length, 0, 'her chat-app link goes');
assert.strictEqual((await q('SELECT * FROM chat_app_callbacks')).length, 0, 'her approval buttons go');
assert.strictEqual((await q('SELECT * FROM chat_app_deliveries')).length, 0, 'her queued chat text goes');
ddiff --git a/test/link-previews.test.js b/test/link-previews.test.js
iindex 9317b0d..3cb7641 100644
---- a/test/link-previews.test.js
++++ b/test/link-previews.test.js
@@@ -281,3 +281,61 @@ t('a moderator removes any preview; another member may not', async () => {
});
t.run(async () => { previews._reset(); await h.close(); });
++
++// ── Part 4: privacy in direct messages ───────────────────────────────────────────────────────────────────────
++
++t('a DM with link previews on for both people gains a preview', async () => {
++ previews._setLookup((host, options, callback) => callback(null, [{ address: '93.184.216.34', family: 4 }]));
++ previews._setRequest(async () => type(page('<meta property="og:title" content="A DM story">')));
++ const made = await h.http('POST', '/api/dm/conversations', { token: alice.token, body: { user_ids: [bob.id] } });
++ assert.strictEqual(made.status, 200, made.text);
++ const sent = await h.http('POST', `/api/dm/conversations/${made.body.conversation.id}/messages`, { token: alice.token, body: { message: 'look https://93.184.216.34/dm-both-on' } });
++ assert.strictEqual(sent.status, 200, sent.text);
++ assert.deepStrictEqual(sent.body.message.embeds, [], 'the send answer starts empty; the job fills it');
++ await previews.idle();
++ const got = await h.http('GET', `/api/dm/conversations/${made.body.conversation.id}/messages`, { token: bob.token });
++ const message = got.body.messages.find((m) => m.id === sent.body.message.id);
++ assert.strictEqual(message.embeds.length, 1, got.text);
++ assert.strictEqual(message.embeds[0].title, 'A DM story');
++});
++
++t('a DM with link previews off on one side fetches nothing and gains none', async () => {
++ const previewSettings = require('../server/chat/dm-preview-settings');
++ const made = await h.http('POST', '/api/dm/conversations', { token: alice.token, body: { user_ids: [bob.id] } });
++ const id = made.body.conversation.id;
++ assert.strictEqual(await previewSettings.enabledFor(bob.id), true, 'on by default');
++ await previewSettings.setEnabled(bob.id, false);
++ let called = false;
++ previews._setRequest(async () => { called = true; return type(page('<meta property="og:title" content="Must not fetch">')); });
++ const sent = await h.http('POST', `/api/dm/conversations/${id}/messages`, { token: alice.token, body: { message: 'https://93.184.216.34/dm-one-off' } });
++ assert.strictEqual(sent.status, 200, sent.text);
++ await previews.idle();
++ assert.strictEqual(called, false, 'one side off: the address is never fetched');
++ const got = await h.http('GET', `/api/dm/conversations/${id}/messages`, { token: alice.token });
++ assert.deepStrictEqual(got.body.messages.find((m) => m.id === sent.body.message.id).embeds, []);
++ await previewSettings.setEnabled(bob.id, true);
++});
++
++t('a private address in a DM is refused before any fetch', async () => {
++ previews._setLookup((host, options, callback) => callback(null, [{ address: '10.0.0.1', family: 4 }]));
++ let called = false;
++ previews._setRequest(async () => { called = true; return type(page('<meta property="og:title" content="Private">')); });
++ const made = await h.http('POST', '/api/dm/conversations', { token: alice.token, body: { user_ids: [bob.id] } });
++ const sent = await h.http('POST', `/api/dm/conversations/${made.body.conversation.id}/messages`, { token: alice.token, body: { message: 'https://private.example/dm-secret' } });
++ await previews.idle();
++ assert.strictEqual(called, false, 'the egress check refuses it');
++ const got = await h.http('GET', `/api/dm/conversations/${made.body.conversation.id}/messages`, { token: alice.token });
++ assert.deepStrictEqual(got.body.messages.find((m) => m.id === sent.body.message.id).embeds, []);
++ previews._setLookup(null);
++});
++
++t('markup in a DM preview title stays text', async () => {
++ previews._setLookup((host, options, callback) => callback(null, [{ address: '93.184.216.34', family: 4 }]));
++ previews._setRequest(async () => type(page('<meta property="og:title" content="&lt;b&gt;not bold&lt;/b&gt;">')));
++ const made = await h.http('POST', '/api/dm/conversations', { token: alice.token, body: { user_ids: [bob.id] } });
++ const sent = await h.http('POST', `/api/dm/conversations/${made.body.conversation.id}/messages`, { token: alice.token, body: { message: 'https://93.184.216.34/dm-markup' } });
++ await previews.idle();
++ const got = await h.http('GET', `/api/dm/conversations/${made.body.conversation.id}/messages`, { token: alice.token });
++ const embed = got.body.messages.find((m) => m.id === sent.body.message.id).embeds[0];
++ assert.strictEqual(embed.title, '<b>not bold</b>', 'the words are data; a renderer escapes them');
++});
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex ecdd68f..1dd96dc 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -38,6 +38,7 @@ const TABLES = [
'agent_play_requests', 'agent_play_votes', 'agent_play_log',
'party_pool_settings', 'party_pool_machines', 'party_pool_holds', // 0064: OpenVibe's own computers for parties
'link_previews', 'link_preview_cache', // 0065: address previews beside the message, and the address cache
++ 'dm_link_preview_settings', // 0070: a person's link previews in DMs (on unless they say otherwise)
];
const INDEXES = [
'idx_chat_ts_deleted', 'idx_chat_page_live', 'idx_chat_channel_user_ts', 'idx_chat_stream_ts', 'idx_emotes_channel_code', 'idx_chat_tl_dedup',
ddiff --git a/test/web.test.js b/test/web.test.js
iindex 29e11ef..565441f 100644
---- a/test/web.test.js
++++ b/test/web.test.js
@@@ -125,6 +125,7 @@ t('settings save to the chat.preferences record', async () => {
assert.strictEqual(r.status, 200);
assert.match(r.text, /name="timestamps"/);
assert.match(r.text, /name="agents_read_messages"/, 'the agents-don\'t-read setting is on the page');
++ assert.match(r.text, /name="dm_link_previews" value="1" checked/, 'link previews in DMs are on by default');
r = await req('POST', '/settings', form(alice, { timestamps: '1', compact: '1', font_size: 'large' }));
assert.strictEqual(r.headers.get('location'), '/settings?saved=1');
const rec = h.netModules.records.get(h.netModules.key('chat.preferences', alice.subject_id));
@@@ -133,6 +134,21 @@ t('settings save to the chat.preferences record', async () => {
assert.match(page.text, /<body class="oc-compact oc-times oc-nobadges" style="--oc-scale:1.18">/, 'the site follows them too');
});
++t('the direct-message link preview switch is on by default, saves off, and shows its state', async () => {
++ const settings = require('../server/chat/dm-preview-settings');
++ await settings.setEnabled(alice.id, true);
++ assert.strictEqual(await settings.enabledFor(alice.id), true);
++ let r = await req('GET', '/settings', { headers: cookie(alice) });
++ assert.match(r.text, /name="dm_link_previews" value="1" checked/, 'the switch is on on the page');
++ r = await req('POST', '/settings', form(alice, { timestamps: '1' }));
++ assert.strictEqual(r.headers.get('location'), '/settings?saved=1');
++ assert.strictEqual(await settings.enabledFor(alice.id), false, 'absent from the form means off');
++ r = await req('GET', '/settings', { headers: cookie(alice) });
++ assert.ok(!/name="dm_link_previews" value="1" checked/.test(r.text), 'the page shows it off');
++ r = await req('POST', '/settings', form(alice, { timestamps: '1', dm_link_previews: '1' }));
++ assert.strictEqual(await settings.enabledFor(alice.id), true, 'and on again');
++});
++
t('rooms: start one, post and join without JavaScript, private rooms stay private, owners manage', async () => {
let r = await req('GET', '/rooms');
assert.strictEqual(r.status, 200);