openvibers/openvibe.chat Commits
Chat: direct messages respect both people's link preview switch (#500)
Changes
nnew file mode 100644@@@ -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()++);@@@ -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))'],nnew file mode 100644@@@ -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 };@@@ -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 doesif (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 = nuLIMIT ?`, [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 };}@@@ -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/aconst 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.@@@ -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');@@@ -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="<b>not bold</b>">')));++ 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');++});@@@ -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',@@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -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()
+);
@@ -81,6 +81,7 @@ const EXPORTS = [
@@ -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_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_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_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}`],
['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_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}`],
['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 = [
@@ -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_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_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_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,
// 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.
// 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))'],
[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))'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -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 };
@@ -20,6 +20,8 @@ const db = require('../db/database');
@@ -20,6 +20,8 @@ const db = require('../db/database');
const outbox = require('../events/service-outbox');
const outbox = require('../events/service-outbox');
const networkBlocks = require('./network-blocks');
const networkBlocks = require('./network-blocks');
const linkCards = require('./link-cards');
const linkCards = require('./link-cards');
+const linkPreviews = require('./link-previews');
+const previewSettings = require('./dm-preview-settings');
// ── Schema & Migrations ──────────────────────────────────────
// ── Schema & Migrations ──────────────────────────────────────
@@ -202,7 +204,13 @@ async function sendMessage(conversationId, senderId, text, { reply_to_id = null
@@ -202,7 +204,13 @@ async function sendMessage(conversationId, senderId, text, { reply_to_id = null
});
});
if (row) {
if (row) {
row.card = card;
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);
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).
// Activity: @mentions in the conversation, and the person whose message this answers (server/chat/activity.js).
const activity = require('./activity');
const activity = require('./activity');
const sender = senderId ? await require('../live-context').getUserById(senderId) : null;
const sender = senderId ? await require('../live-context').getUserById(senderId) : null;
@@ -248,7 +256,11 @@ async function getMessages(conversationId, limit = 50, before = null, after = nu
@@ -248,7 +256,11 @@ async function getMessages(conversationId, limit = 50, before = null, after = nu
LIMIT ?
LIMIT ?
`, [conversationId, 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 } = {}) {
async function getMessagesPage(conversationId, { before = null, limit = 50 } = {}) {
@@ -263,6 +275,7 @@ 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;
const more = rows.length > lim;
rows.length = Math.min(rows.length, lim);
rows.length = Math.min(rows.length, lim);
await linkCards.hydrate(rows, 'dm_messages');
await linkCards.hydrate(rows, 'dm_messages');
+ await linkPreviews.attach(rows, 'dm', { enabled: await previewSettings.allowedIn(conversationId) });
return { messages: rows, more };
return { messages: rows, more };
}
}
@@ -714,6 +714,7 @@ ${composer}`,
@@ -714,6 +714,7 @@ ${composer}`,
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const sid = actor.user.subject_id;
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 [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 || {};
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.
// 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>`;
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'
@@ -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>
<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('group_invites', 'People can add me to groups', dmSet.group_invites !== false)}
${box('previews', 'Message text in notifications', dmSet.previews !== 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')}
${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>
<section class="oc-set" aria-labelledby="oc-set-agents"><h2 id="oc-set-agents">AI agents</h2>
<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
@@ -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 sources = Object.fromEntries(TTS_SOURCES.filter(([k]) => !on(`tts_src_${k}`)).map(([k]) => [k, false]));
const sid = actor.user.subject_id;
const sid = actor.user.subject_id;
try {
try {
+ await require('../chat/dm-preview-settings').setEnabled(actor.user.id, on('dm_link_previews'));
await prefs.update(sid, patch);
await prefs.update(sid, patch);
// The view defaults are Chat's own (server/chat/view-defaults.js): the Network module has no field for
// 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.
// them yet, and a refused patch would lose the whole save.
@@ -35,7 +35,7 @@ t('export, then erase and confirm', async () => {
@@ -35,7 +35,7 @@ t('export, then erase and confirm', async () => {
const missing = await uncovered(d, {
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',
taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_app_callbacks', 'chat_app_link_tokens', 'chat_messages', 'chat_server_invites', 'chat_server_members',
'chat_stage_speakers',
'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',
'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'],
'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: {
exempt: {
@@ -58,6 +58,7 @@ t('export, then erase and confirm', async () => {
@@ -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 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 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 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.
// 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();
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);
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 () => {
@@ -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.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_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['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(await accountData.apply(exp, { send }), 'unchanged');
assert.strictEqual(sent.length, 1);
assert.strictEqual(sent.length, 1);
@@ -94,6 +96,7 @@ t('export, then erase and confirm', async () => {
@@ -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 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 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 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_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_callbacks')).length, 0, 'her approval buttons go');
assert.strictEqual((await q('SELECT * FROM chat_app_deliveries')).length, 0, 'her queued chat text goes');
assert.strictEqual((await q('SELECT * FROM chat_app_deliveries')).length, 0, 'her queued chat text goes');
@@ -281,3 +281,61 @@ t('a moderator removes any preview; another member may not', async () => {
@@ -281,3 +281,61 @@ t('a moderator removes any preview; another member may not', async () => {
});
});
t.run(async () => { previews._reset(); await h.close(); });
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="<b>not bold</b>">')));
+ 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');
+});
@@ -38,6 +38,7 @@ const TABLES = [
@@ -38,6 +38,7 @@ const TABLES = [
'agent_play_requests', 'agent_play_votes', 'agent_play_log',
'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
'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
'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 = [
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',
'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',
@@ -125,6 +125,7 @@ t('settings save to the chat.preferences record', async () => {
@@ -125,6 +125,7 @@ t('settings save to the chat.preferences record', async () => {
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.text, /name="timestamps"/);
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="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' }));
r = await req('POST', '/settings', form(alice, { timestamps: '1', compact: '1', font_size: 'large' }));
assert.strictEqual(r.headers.get('location'), '/settings?saved=1');
assert.strictEqual(r.headers.get('location'), '/settings?saved=1');
const rec = h.netModules.records.get(h.netModules.key('chat.preferences', alice.subject_id));
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 () => {
@@ -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');
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 () => {
t('rooms: start one, post and join without JavaScript, private rooms stay private, owners manage', async () => {
let r = await req('GET', '/rooms');
let r = await req('GET', '/rooms');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);