aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-14 19:25:06 -0700
committerAdam Soltys <[email protected]>2026-09-14 19:25:06 -0700
commitb363da843ffcaea89a9ec11ce0cacc180a824f00 (patch)
treecc50a312c77f19ba98f78f0cddcc85ab82d8e552
parent2b63cdbd2eacbcbd031de6616147d467da8a7c3c (diff)
downloadcoinosv3-b363da843ffcaea89a9ec11ce0cacc180a824f00.tar.gz
coinosv3-b363da843ffcaea89a9ec11ce0cacc180a824f00.zip
A picture can be sent to a channel or a DM, encrypted, and read by Vector.
The composer grows a paper clip. A picked file is encrypted under a fresh AES-GCM key and 16-byte nonce, the ciphertext uploaded to a public Blossom host that takes anonymous blobs (blossom.ditto.pub, then nostr.download as a fallback copy), and the pointer posted the way Vector posts it: a kind-9 with empty content and a NIP-92 imeta tag carrying url, mime, key, nonce, both hashes, size and dimensions. In a DM it is a NIP-17 kind-15 file message with the same fields as flat tags and the url as content, wrapped to the peer and to ourselves. Kind-15 file messages are now accepted on receive too, rendered like any attachment, and previewed as a photo in the thread list. Co-Authored-By: Claude Fable 5.1 <[email protected]>
-rw-r--r--src/dm.js2
-rw-r--r--src/features/messages.js161
-rw-r--r--src/i18n.js4
-rw-r--r--src/style.css1
4 files changed, 160 insertions, 8 deletions
diff --git a/src/dm.js b/src/dm.js
index 46789e8..1e568a7 100644
--- a/src/dm.js
+++ b/src/dm.js
@@ -108,7 +108,7 @@ export async function unwrapDM(wrap, skOrSigner) {
const rumor = JSON.parse(await signer.decryptFrom(seal.pubkey, seal.content));
if (rumor.pubkey !== seal.pubkey) return null;
if (rumor.id !== getEventHash({ ...rumor, id: undefined })) return null;
- if (rumor.kind !== 14) return { rumor, author: seal.pubkey, peer: null };
+ if (rumor.kind !== 14 && rumor.kind !== 15) return { rumor, author: seal.pubkey, peer: null };
const to = rumor.tags?.find((t) => t[0] === 'p')?.[1];
const mine = rumor.pubkey === signer.pubkey;
return { rumor, author: seal.pubkey, peer: mine ? to : rumor.pubkey };
diff --git a/src/features/messages.js b/src/features/messages.js
index 6f55c02..0d4933e 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -28,6 +28,7 @@ import { makeSearcher, resultRows, fallbackAvatar, warmSearch } from '../recipie
import { getNetwork } from '../api.js';
import { decodeBolt11 } from '../ark/lightning.js';
import { hexToBytes, bytesToHex } from '@noble/hashes/utils';
+import { sha256 } from '@noble/hashes/sha256';
import { t } from '../i18n.js';
import { SIGNER_SILENT } from '../dm.js';
@@ -1813,7 +1814,7 @@ export function messagesFeature(ctx) {
let got;
try { got = await unwrapDMAny(wrap, d); } catch { return 'later'; }
if (!got) continue;
- if (got.rumor.kind === 14) {
+ if (got.rumor.kind === 14 || got.rumor.kind === 15) {
// unwrapDM judges "mine" against the key that DECRYPTED, but this
// wallet can hold several identities (wallet key + nostr login). A
// sent-copy authored by any of them must thread under the RECIPIENT,
@@ -3287,7 +3288,20 @@ export function messagesFeature(ctx) {
}
return a.url ? a : null;
};
- const attachmentsOf = (rumor) => (rumor.tags || []).filter((t) => t[0] === 'imeta').map(parseImeta).filter(Boolean);
+ const attachmentsOf = (rumor) => {
+ const out = (rumor.tags || []).filter((t) => t[0] === 'imeta').map(parseImeta).filter(Boolean);
+ // a NIP-17 file message: the same fields as flat tags, the url as content
+ if (rumor.kind === 15 && /^https?:\/\//.test(rumor.content || '')) {
+ const a = { url: rumor.content.trim(), fallback: [] };
+ for (const [k, v] of rumor.tags || []) {
+ if (k === 'file-type') a.m = v;
+ else if (k === 'fallback') a.fallback.push(v);
+ else if (['encryption-algorithm', 'decryption-key', 'decryption-nonce', 'ox', 'x', 'size', 'dim', 'name'].includes(k)) a[k] = v;
+ }
+ out.push(a);
+ }
+ return out;
+ };
async function loadAttachment(a) {
const entry = { state: 'loading' };
attachCache.set(a.url, entry);
@@ -3351,6 +3365,129 @@ export function messagesFeature(ctx) {
});
}
+ // ---- sending a picture: encrypt it, put the blob on Blossom, post the
+ // pointer. Vector's shape exactly, so its users see it: a fresh AES-GCM
+ // key and 16-byte nonce per file, the ciphertext uploaded as an opaque
+ // blob, the key riding inside the (already encrypted) message. The media
+ // host never sees a picture. coinos's own Blossom server stores only sync
+ // envelopes, so these go to public hosts that take anonymous blobs.
+ const MEDIA_SERVERS = ['https://blossom.ditto.pub', 'https://nostr.download'];
+ const MEDIA_MAX = 20 * 1024 * 1024;
+ async function encryptFile(file) {
+ if (file.size > MEDIA_MAX) throw new Error(t('msgAttachTooBig'));
+ const plain = new Uint8Array(await file.arrayBuffer());
+ const key = crypto.getRandomValues(new Uint8Array(32));
+ const nonce = crypto.getRandomValues(new Uint8Array(16));
+ const k = await crypto.subtle.importKey('raw', key, 'AES-GCM', false, ['encrypt']);
+ const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, k, plain));
+ let dim = '';
+ if (/^image\//.test(file.type)) {
+ try { const bmp = await createImageBitmap(file); dim = `${bmp.width}x${bmp.height}`; bmp.close(); } catch {}
+ }
+ return {
+ cipher, key: bytesToHex(key), nonce: bytesToHex(nonce), ox: bytesToHex(sha256(plain)), x: bytesToHex(sha256(cipher)),
+ m: file.type || 'application/octet-stream', name: file.name || 'file', size: String(cipher.length), dim,
+ };
+ }
+ // BUD-01 upload with a kind-24242 auth signed by whoever is sending.
+ async function uploadEncrypted(id, enc) {
+ const now = Math.floor(Date.now() / 1000);
+ const evt = { kind: 24242, pubkey: id.pubkey, created_at: now - 5, content: 'upload',
+ tags: [['t', 'upload'], ['x', enc.x], ['expiration', String(now + 600)]] };
+ const auth = id.signer instanceof Uint8Array ? finalizeEvent(evt, id.signer) : await id.signer.signEvent(evt);
+ const header = 'Nostr ' + btoa(JSON.stringify(auth));
+ const urls = [];
+ for (const server of MEDIA_SERVERS) {
+ try {
+ const r = await fetch(server + '/upload', {
+ method: 'PUT', body: enc.cipher, signal: AbortSignal.timeout(60_000),
+ headers: { authorization: header, 'content-type': 'application/octet-stream', 'x-sha-256': enc.x },
+ });
+ if (!r.ok) continue;
+ const j = await r.json();
+ if (j && j.url) urls.push(j.url);
+ } catch {}
+ if (urls.length && enc.cipher.length > 4 * 1024 * 1024) break; // one copy is enough for a big file
+ }
+ if (!urls.length) throw new Error(t('msgUploadFailed'));
+ return urls;
+ }
+ const imetaTag = (enc, urls) => ['imeta',
+ 'url ' + urls[0], 'm ' + enc.m, 'encryption-algorithm aes-gcm', 'decryption-key ' + enc.key, 'decryption-nonce ' + enc.nonce,
+ 'size ' + enc.size, 'ox ' + enc.ox, 'x ' + enc.x, 'name ' + enc.name, ...(enc.dim ? ['dim ' + enc.dim] : []),
+ ...urls.slice(1).map((u) => 'fallback ' + u)];
+ // the local copy shows at once, from the plaintext we still hold
+ const cacheLocal = (enc, urls, file) => {
+ const blob = new Blob([file], { type: enc.m });
+ for (const u of urls) attachCache.set(u, { state: 'ready', blob, src: URL.createObjectURL(blob) });
+ };
+ async function sendAttachment(room, chId, file) {
+ const id = await identity();
+ if (!id) { noIdToast(); return; }
+ ui.msgUploading = true; render();
+ try {
+ const enc = await encryptFile(file);
+ const urls = await uploadEncrypted(id, enc);
+ cacheLocal(enc, urls, file);
+ const { created_at, ms } = msTags(Date.now());
+ const replyTo = ui.msgReplyTo && room.byChannel.get(chId)?.has(ui.msgReplyTo) ? ui.msgReplyTo : null;
+ ui.msgReplyTo = null;
+ const rumor = rumorWithId({
+ kind: 9, pubkey: id.pubkey, content: '',
+ tags: [['channel', chId], ['epoch', String(room.chEpoch(chId))], imetaTag(enc, urls), ...(replyTo ? [['e', replyTo]] : []), ms], created_at,
+ });
+ const msgs = room.byChannel.get(chId) || room.byChannel.set(chId, new Map()).get(chId);
+ const entry = { rumor, author: id.pubkey, pending: true };
+ msgs.set(rumor.id, entry);
+ ui.msgStick = true; ui.msgUploading = false; render();
+ const wrap = await wrapRumor(rumor, id.signer, room.chStream(chId));
+ seenWraps.add(wrap.id);
+ const ok = await publishOn(room.relays, wrap);
+ if (!ok) { msgs.delete(rumor.id); toast(t('msgSendFailed')); render(); return; }
+ delete entry.pending;
+ ensureJoined(room, id).catch(() => {});
+ persistCache(room);
+ } catch (e) { toast(e.message || String(e)); }
+ ui.msgUploading = false; render();
+ }
+ // A DM picture is a NIP-17 kind-15 file message: url as content, the
+ // same fields as flat tags, wrapped to the peer and to ourselves.
+ async function sendDMFile(peer, file) {
+ const id = await identity();
+ if (!id) { noIdToast(); return; }
+ if (!(id.signer instanceof Uint8Array) && !id.signer.encryptTo) { toast(t('msgSignerNoDm')); return; }
+ ui.msgUploading = true; render();
+ try {
+ const enc = await encryptFile(file);
+ const urls = await uploadEncrypted(id, enc);
+ cacheLocal(enc, urls, file);
+ const replyTo = ui.msgReplyTo && threadOf(peer).has(ui.msgReplyTo) ? ui.msgReplyTo : null;
+ ui.msgReplyTo = null;
+ const rumor = rumorWithId({
+ kind: 15, pubkey: id.pubkey, content: urls[0], created_at: Math.floor(Date.now() / 1000),
+ tags: [['p', peer], ...(replyTo ? [['e', replyTo]] : []),
+ ['file-type', enc.m], ['encryption-algorithm', 'aes-gcm'], ['decryption-key', enc.key], ['decryption-nonce', enc.nonce],
+ ['x', enc.x], ['ox', enc.ox], ['size', enc.size], ['name', enc.name], ...(enc.dim ? [['dim', enc.dim]] : []),
+ ...urls.slice(1).map((u) => ['fallback', u])],
+ });
+ const entry = { rumor, mine: true, pending: true };
+ threadOf(peer).set(rumor.id, entry);
+ ui.msgStick = true; ui.msgUploading = false; render();
+ const toPeer = await wrapDM(id.signer, peer, rumor);
+ const toSelf = await wrapDM(id.signer, id.pubkey, rumor);
+ const ok = await publishOn(DM_RELAYS, toPeer);
+ publishOn(DM_RELAYS, toSelf);
+ fetchInboxRelays(peer).then((inbox) => {
+ const extra = inbox.slice(0, 4).filter((r) => !DM_RELAYS.includes(r));
+ if (extra.length) publishOn(extra, toPeer);
+ }).catch(() => {});
+ if (!ok) { threadOf(peer).delete(rumor.id); toast(t('msgSendFailed')); render(); return; }
+ delete entry.pending;
+ persistDms();
+ } catch (e) { toast(e.message || String(e)); }
+ ui.msgUploading = false; render();
+ }
+
function noteBody(text, depth = 0) {
const out = [];
for (const part of String(text || '').split(NOTE_SPLIT)) {
@@ -4789,10 +4926,19 @@ export function messagesFeature(ctx) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
};
- const composer = (placeholder, onSend, onType, draftKey) =>
+ const composer = (placeholder, onSend, onType, draftKey, onAttach = null) =>
h('div', { class: 'col', style: 'gap:6px' },
signerNotice(),
h('div', { class: 'chat-compose' },
+ onAttach ? h('button', {
+ class: 'iconbtn chat-clip', title: t('msgAttach'), 'aria-label': t('msgAttach'),
+ disabled: !!ui.msgUploading,
+ onClick: () => document.getElementById('msg-file')?.click(),
+ }, ui.msgUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null,
+ onAttach ? h('input', {
+ type: 'file', id: 'msg-file', accept: 'image/*,video/*', style: 'display:none',
+ onChange: (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; if (f) onAttach(f); },
+ }) : null,
h('textarea', {
class: 'grow', id: 'msg-draft', placeholder, rows: String(Math.min(5, draftFor(draftKey).split('\n').length)),
value: draftFor(draftKey), maxlength: '2000',
@@ -4968,7 +5114,7 @@ export function messagesFeature(ctx) {
h('span', { class: 'chat-time thread-when' },
timeLabel(last.rumor.created_at * 1000),
unread ? h('i', { class: 'thread-dot' }) : null)),
- h('div', { class: 'muted small chat-preview' }, (last.mine ? t('msgYouPrefix') + ' ' : '') + last.rumor.content)))))
+ h('div', { class: 'muted small chat-preview' }, (last.mine ? t('msgYouPrefix') + ' ' : '') + (last.rumor.kind === 15 ? '📎 ' + t('msgPhoto') : last.rumor.content))))))
: h('div', { class: 'muted small' }, t('msgNoDms')));
if (shownDms.length < dmRows.length)
kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllDms = true; render(); } },
@@ -5192,7 +5338,8 @@ export function messagesFeature(ctx) {
chans.length > 1 ? t('msgPlaceholder', { channel: ch ? ch.name : '' }) : t('msgPlaceholderPlain'),
() => ch && sendMessage(room, ch.id),
() => ch && ping(room, ch.id, TYPING),
- ch ? 'ch:' + ch.id : 'ch:'),
+ ch ? 'ch:' + ch.id : 'ch:',
+ ch ? (f) => sendAttachment(room, ch.id, f) : null),
ch && ui.msgSheet ? messageSheet(room, ch.id) : null);
}
@@ -5383,11 +5530,11 @@ export function messagesFeature(ctx) {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
},
- }, dmQuote(m), ...noteBody(m.rumor.content), ...attachmentNodes(m.rumor), dmChips(m)),
+ }, dmQuote(m), ...(m.rumor.kind === 15 ? [] : 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(),
- composer(t('msgDmPlaceholder'), () => sendDM(peer), null, 'dm:' + peer),
+ composer(t('msgDmPlaceholder'), () => sendDM(peer), null, 'dm:' + peer, (f) => sendDMFile(peer, f)),
ui.msgSheet ? dmSheet(peer) : null);
}
diff --git a/src/i18n.js b/src/i18n.js
index 7948c97..a44f5c4 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -194,6 +194,10 @@ const STR = {
msgNewChannel: 'New channel',
msgAdmin: 'admin',
msgAttachFailed: 'Attachment unavailable',
+ msgAttach: 'Attach a picture',
+ msgAttachTooBig: 'That file is too big (20 MB max)',
+ msgUploadFailed: 'Upload failed — try again',
+ msgPhoto: 'Photo',
msgEdited: '(edited)',
msgReact: 'React',
msgZap: 'Zap',
diff --git a/src/style.css b/src/style.css
index 1ce01ad..13704f6 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1007,3 +1007,4 @@ body::after {
/* 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; }
+.chat-clip { flex: none; align-self: flex-end; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; }