summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-14 19:18:28 -0700
committerAdam Soltys <[email protected]>2026-09-14 19:18:28 -0700
commit2b63cdbd2eacbcbd031de6616147d467da8a7c3c (patch)
treecb68e9e91b87ffba1508f675c3286ef1e7aceb57 /src
parent4409629ab28372def6afb07c2fb1e124a58ab171 (diff)
downloadcoinosv3-2b63cdbd2eacbcbd031de6616147d467da8a7c3c.tar.gz
coinosv3-2b63cdbd2eacbcbd031de6616147d467da8a7c3c.zip
A picture sent to a channel shows up as a picture, not an empty bubble.
Vector attaches files as NIP-92 imeta tags: the blob is encrypted with a one-off AES-GCM key before it reaches Blossom, and the tag carries the url, mime, key, nonce, plaintext sha256 and dimensions. Such a message has no text, so the bubble rendered blank. The blob is now fetched (fallback hosts too), decrypted in the browser, checked against the hash, and shown from an object URL โ€” a grey box its shape while it loads, tap to view full size, other file types as a download by name. The same tags render in DMs. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js87
-rw-r--r--src/i18n.js1
-rw-r--r--src/style.css4
3 files changed, 91 insertions, 1 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index e2d09b6..6f55c02 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -3267,6 +3267,90 @@ export function messagesFeature(ctx) {
return h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, shown);
}
+ // ---- encrypted attachments (NIP-92 imeta + NIP-17 file fields) ----
+ // A picture in a Concord channel never reaches the media server in the
+ // clear: the sender encrypts it under a one-off key, uploads the blob to
+ // Blossom, and puts the pointer in an imeta tag โ€” url, mime, aes-gcm key
+ // and nonce, the plaintext's sha256 (ox), a size and dimensions. The bubble
+ // used to show nothing for such a message (its content is empty). Now the
+ // blob is fetched, decrypted here, checked against the hash, and shown
+ // from an object URL. A blob that fails the check fails closed.
+ const ATTACH_MAX = 25 * 1024 * 1024;
+ const attachCache = new Map(); // url -> { state: 'loading'|'ready'|'error', src, blob }
+ const parseImeta = (tag) => {
+ const a = { fallback: [] };
+ for (const f of tag.slice(1)) {
+ const i = f.indexOf(' ');
+ if (i < 0) continue;
+ const k = f.slice(0, i), v = f.slice(i + 1);
+ if (k === 'fallback') a.fallback.push(v); else a[k] = v;
+ }
+ return a.url ? a : null;
+ };
+ const attachmentsOf = (rumor) => (rumor.tags || []).filter((t) => t[0] === 'imeta').map(parseImeta).filter(Boolean);
+ async function loadAttachment(a) {
+ const entry = { state: 'loading' };
+ attachCache.set(a.url, entry);
+ try {
+ if (+a.size > ATTACH_MAX) throw new Error('too big');
+ let buf = null;
+ for (const url of [a.url, ...a.fallback]) {
+ try {
+ const res = await fetch(url, { mode: 'cors', signal: AbortSignal.timeout(30_000) });
+ if (!res.ok) continue;
+ buf = await res.arrayBuffer();
+ if (buf.byteLength > ATTACH_MAX) throw new Error('too big');
+ break;
+ } catch (e) { if (String(e.message).includes('too big')) throw e; }
+ }
+ if (!buf) throw new Error('unreachable');
+ let bytes = new Uint8Array(buf);
+ const alg = (a['encryption-algorithm'] || '').toLowerCase();
+ if (alg) {
+ if (alg !== 'aes-gcm' || !a['decryption-key'] || !a['decryption-nonce']) throw new Error('unsupported');
+ const key = await crypto.subtle.importKey('raw', hexToBytes(a['decryption-key']), 'AES-GCM', false, ['decrypt']);
+ bytes = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: hexToBytes(a['decryption-nonce']) }, key, bytes));
+ }
+ if (a.ox) {
+ const digest = bytesToHex(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)));
+ if (digest !== a.ox.toLowerCase()) throw new Error('hash mismatch');
+ }
+ const blob = new Blob([bytes], { type: a.m || 'application/octet-stream' });
+ Object.assign(entry, { state: 'ready', blob, src: URL.createObjectURL(blob) });
+ } catch (e) {
+ entry.state = 'error';
+ console.warn('attachment:', a.url, e.message);
+ }
+ scheduleRepaint();
+ }
+ // The nodes for a rumor's attachments: a picture inline (tap to view),
+ // any other file as a download by name, a grey box the picture's shape
+ // while it loads, and a quiet note when it can't be had.
+ function attachmentNodes(rumor) {
+ return attachmentsOf(rumor).map((a) => {
+ const isImg = /^image\//i.test(a.m || '') || /\.(png|jpe?g|gif|webp|avif)$/i.test(a.name || a.url.split('?')[0]);
+ const encrypted = !!a['encryption-algorithm'];
+ if (!encrypted && isImg) return urlNode(a.url, { isImage: true });
+ let entry = attachCache.get(a.url);
+ if (!entry && isImg) { loadAttachment(a); entry = attachCache.get(a.url); }
+ const [w, hgt] = String(a.dim || '').split('x').map(Number);
+ const ratio = w > 0 && hgt > 0 ? `${w}/${hgt}` : '4/3';
+ if (entry && entry.state === 'ready') {
+ if (isImg) return h('img', {
+ src: entry.src, class: 'note-img clickable', alt: a.name || '',
+ onClick: (e) => { e.stopPropagation(); ctx.openImage && ctx.openImage(entry.src); },
+ });
+ return h('a', { class: 'chat-attach', href: entry.src, download: a.name || 'file', onClick: (e) => e.stopPropagation() },
+ '๐Ÿ“Ž ' + (a.name || 'file') + (a.size ? ` ยท ${Math.round(+a.size / 1024)} KB` : ''));
+ }
+ if (entry && entry.state === 'error') return h('div', { class: 'small muted' }, t('msgAttachFailed'));
+ if (!isImg) return h('button', {
+ class: 'chat-attach linklike', onClick: (e) => { e.stopPropagation(); if (!attachCache.has(a.url)) loadAttachment(a); },
+ }, '๐Ÿ“Ž ' + (a.name || 'file') + (a.size ? ` ยท ${Math.round(+a.size / 1024)} KB` : ''));
+ return h('div', { class: 'chat-attach-ph', style: `aspect-ratio:${ratio}` });
+ });
+ }
+
function noteBody(text, depth = 0) {
const out = [];
for (const part of String(text || '').split(NOTE_SPLIT)) {
@@ -5026,6 +5110,7 @@ export function messagesFeature(ctx) {
},
replyQuote(room, chId, m),
...noteBody(text),
+ ...attachmentNodes(m.rumor),
edit ? h('span', { class: 'chat-edited' }, ' ', t('msgEdited')) : null,
mine
? h('button', { class: 'chat-del', title: t('msgDelete'), onClick: () => deleteMessage(room, chId, m) }, 'ร—')
@@ -5298,7 +5383,7 @@ export function messagesFeature(ctx) {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
},
- }, dmQuote(m), ...noteBody(m.rumor.content), dmChips(m)),
+ }, dmQuote(m), ...noteBody(m.rumor.content), ...attachmentNodes(m.rumor), dmChips(m)),
h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000)))))
: [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgNoDmsYet'))])),
dmReplyBar(),
diff --git a/src/i18n.js b/src/i18n.js
index bc3cf14..7948c97 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -193,6 +193,7 @@ const STR = {
msgAddPerson: 'Add person',
msgNewChannel: 'New channel',
msgAdmin: 'admin',
+ msgAttachFailed: 'Attachment unavailable',
msgEdited: '(edited)',
msgReact: 'React',
msgZap: 'Zap',
diff --git a/src/style.css b/src/style.css
index 7b16e16..1ce01ad 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1003,3 +1003,7 @@ body::after {
.footer { padding-top: 14px; padding-bottom: 0; }
.tabs { margin: 10px 0; }
}
+
+/* an encrypted picture on its way: a box the picture's shape, then the picture */
+.chat-attach-ph { display: block; width: 240px; max-width: 100%; max-height: 320px; margin-top: 6px; border-radius: 10px; background: var(--line); }
+.chat-attach { display: inline-block; margin-top: 6px; }