diff --git a/components/profile-modal.html b/components/profile-modal.html index cc3c1bdb..a4a8643e 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -1174,6 +1174,21 @@ aria-live="polite" aria-label="Direct message threads" > + +
+
+ +
+ + + +
+
+

, ]` with optional address pointer `['a', , ]`, and `['p', ]` when the origin author is known; inherits schema append tags | JSON-serialized event being reposted | | Video comment (`NOTE_TYPES.VIDEO_COMMENT`) | `1111` | NIP-22 root scope tags `['A'\|`E`\|`I`, , ?], `['K', ]`, and `['P', , ?]` plus parent metadata `['E'\|`I`, , ?, ], `['K', ]`, `['P', , ?]`; inherits schema append tags | Plain text body sanitized to valid UTF-8 | +| DM attachment (`NOTE_TYPES.DM_ATTACHMENT`) | `15` | `['p', ]`, `['x', ]`, `['url', ]`, optional `['name', ]`, `['type', ]`, `['size', ]`, optional `['k', ]`; inherits schema append tags | Empty content; attachment metadata is represented as tags | > **Publishing note:** Session actors only emit passive telemetry (for example, view counters) and must **not** sign video comments. Require a logged-in Nostr signer for comment publishing via [`commentEvents`](../js/nostr/commentEvents.js). diff --git a/js/attachments/attachmentUtils.js b/js/attachments/attachmentUtils.js new file mode 100644 index 00000000..080c162d --- /dev/null +++ b/js/attachments/attachmentUtils.js @@ -0,0 +1,162 @@ +import { devLogger } from "../utils/logger.js"; + +export const ATTACHMENT_KIND = 15; + +const HEX_64_REGEX = /^[0-9a-f]{64}$/i; + +const TAG_NAMES = Object.freeze({ + hash: "x", + url: "url", + name: "name", + type: "type", + size: "size", + key: "k", +}); + +function normalizeTagValue(value) { + if (typeof value !== "string") { + if (value === undefined || value === null) { + return ""; + } + return String(value); + } + return value.trim(); +} + +function normalizeHash(value) { + const normalized = normalizeTagValue(value).toLowerCase(); + if (!normalized) { + return ""; + } + return HEX_64_REGEX.test(normalized) ? normalized : ""; +} + +function normalizeNumber(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 0) { + return null; + } + return Math.floor(numeric); +} + +export function parseAttachmentTags(tags) { + if (!Array.isArray(tags)) { + return null; + } + + const fields = { + x: "", + url: "", + name: "", + type: "", + size: null, + key: "", + }; + + tags.forEach((tag) => { + if (!Array.isArray(tag) || tag.length < 2) { + return; + } + + const name = normalizeTagValue(tag[0]).toLowerCase(); + const value = normalizeTagValue(tag[1]); + + switch (name) { + case TAG_NAMES.hash: + fields.x = normalizeHash(value); + break; + case TAG_NAMES.url: + fields.url = value; + break; + case TAG_NAMES.name: + fields.name = value; + break; + case TAG_NAMES.type: + fields.type = value; + break; + case TAG_NAMES.size: + fields.size = normalizeNumber(value); + break; + case TAG_NAMES.key: + fields.key = value; + break; + default: + break; + } + }); + + if (!fields.x && !fields.url) { + return null; + } + + return { + x: fields.x, + url: fields.url, + name: fields.name, + type: fields.type, + size: fields.size, + key: fields.key, + encrypted: Boolean(fields.key), + }; +} + +export function extractAttachmentsFromMessage(message) { + if (!message || typeof message !== "object") { + return []; + } + + const event = + (message.message && typeof message.message === "object" + ? message.message + : null) || + (message.event && typeof message.event === "object" ? message.event : null); + + if (!event || Number(event.kind) !== ATTACHMENT_KIND) { + return []; + } + + const attachment = parseAttachmentTags(event.tags); + if (!attachment) { + devLogger.warn("[attachments] Attachment event missing tags."); + return []; + } + + return [attachment]; +} + +export function formatAttachmentSize(bytes) { + if (!Number.isFinite(bytes)) { + return ""; + } + + const units = ["B", "KB", "MB", "GB"]; + let size = Math.max(bytes, 0); + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex += 1; + } + + const rounded = size >= 10 || unitIndex === 0 ? Math.round(size) : size.toFixed(1); + return `${rounded} ${units[unitIndex]}`; +} + +export function describeAttachment(attachment) { + if (!attachment || typeof attachment !== "object") { + return "Attachment"; + } + + const name = typeof attachment.name === "string" ? attachment.name.trim() : ""; + if (name) { + return `Attachment: ${name}`; + } + + if (attachment.type) { + return `Attachment (${attachment.type})`; + } + + return "Attachment"; +} + +export { TAG_NAMES as ATTACHMENT_TAG_NAMES }; diff --git a/js/nostr/client.js b/js/nostr/client.js index 176d166b..0dc80874 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -57,6 +57,7 @@ import { buildVideoPostEvent, buildRepostEvent, buildWatchHistoryEvent, + buildDmAttachmentEvent, getNostrEventSchema, NOTE_TYPES, } from "../nostrEventSchemas.js"; @@ -4426,12 +4427,22 @@ export class NostrClient { const trimmedTarget = typeof targetNpub === "string" ? targetNpub.trim() : ""; const trimmedMessage = typeof message === "string" ? message.trim() : ""; + const attachments = Array.isArray(resolvedOptions.attachments) + ? resolvedOptions.attachments.filter( + (attachment) => + attachment && + typeof attachment === "object" && + (typeof attachment.url === "string" || + typeof attachment.x === "string"), + ) + : []; + const hasAttachments = attachments.length > 0; if (!trimmedTarget) { return { ok: false, error: "invalid-target" }; } - if (!trimmedMessage) { + if (!trimmedMessage && !hasAttachments) { return { ok: false, error: "empty-message" }; } @@ -4524,6 +4535,10 @@ export class NostrClient { const signerCapabilities = resolveSignerCapabilities(signer); const useNip17 = Boolean(resolvedOptions.useNip17); + if (hasAttachments && !useNip17) { + return { ok: false, error: "attachments-unsupported" }; + } + const resolveNip17RelaySelection = async (pubkey, relayHints) => { const discoveryRelays = sanitizeRelayList( Array.isArray(this.readRelays) && this.readRelays.length @@ -4651,15 +4666,55 @@ export class NostrClient { return { privateKey: normalizedPrivateKey, pubkey }; }; - const rumorEvent = { - kind: 14, - created_at: Math.floor(Date.now() / 1000), - tags: [["p", targetHex]], - content: trimmedMessage, - pubkey: actorHex, - }; + const rumorEvents = []; + const createdAt = Math.floor(Date.now() / 1000); - const buildGiftWrapForRecipient = async (recipientPubkey, relayHint) => { + if (trimmedMessage) { + rumorEvents.push({ + kind: 14, + created_at: createdAt, + tags: [["p", targetHex]], + content: trimmedMessage, + pubkey: actorHex, + }); + } + + if (hasAttachments) { + attachments.forEach((attachment) => { + const normalizedAttachment = { + x: + typeof attachment.x === "string" + ? attachment.x.trim().toLowerCase() + : "", + url: typeof attachment.url === "string" ? attachment.url.trim() : "", + name: + typeof attachment.name === "string" ? attachment.name.trim() : "", + type: + typeof attachment.type === "string" ? attachment.type.trim() : "", + size: Number.isFinite(attachment.size) ? Math.floor(attachment.size) : null, + key: typeof attachment.key === "string" ? attachment.key.trim() : "", + }; + + rumorEvents.push( + buildDmAttachmentEvent({ + pubkey: actorHex, + created_at: createdAt, + recipientPubkey: targetHex, + attachment: normalizedAttachment, + }), + ); + }); + } + + if (!rumorEvents.length) { + return { ok: false, error: "empty-message" }; + } + + const buildGiftWrapForRecipient = async ( + recipientPubkey, + relayHint, + rumorEvent, + ) => { const sealPayload = { kind: 13, created_at: randomPastTimestamp(), @@ -4710,94 +4765,135 @@ export class NostrClient { }; }; - let recipientGiftWrap; - try { - recipientGiftWrap = await buildGiftWrapForRecipient( - targetHex, - recipientSelection.relays[0] || "", - ); - } catch (error) { - devLogger.warn("[nostr] Failed to build NIP-17 recipient wrap.", error); - const errorCode = - error?.message === "wrapper-keygen-failed" - ? "nip17-keygen-failed" - : "encryption-failed"; - return { ok: false, error: errorCode, details: error }; - } + const resolveRumorPreview = (rumorEvent) => { + const content = + typeof rumorEvent?.content === "string" ? rumorEvent.content.trim() : ""; + if (content) { + return content; + } - const conversationId = `dm:${[actorHex, targetHex].sort().join(":")}`; - const dmRecord = { - id: recipientGiftWrap.wrap.id, - conversation_id: conversationId, - sender_pubkey: actorHex, - receiver_pubkey: targetHex, - created_at: rumorEvent.created_at, - kind: rumorEvent.kind, - content: trimmedMessage, - tags: rumorEvent.tags, - status: "pending", - seen: true, + const tags = Array.isArray(rumorEvent?.tags) ? rumorEvent.tags : []; + const nameTag = tags.find( + (tag) => Array.isArray(tag) && tag[0] === "name" && tag[1], + ); + if (nameTag) { + return `Attachment: ${nameTag[1]}`; + } + + return "Attachment"; }; - try { - await writeMessages(dmRecord); - await updateConversationFromMessage(dmRecord, { - preview: trimmedMessage, - unseenDelta: 0, - }); - } catch (error) { - devLogger.warn("[nostr] Failed to persist outgoing DM.", error); - } - - const recipientPublishResults = await Promise.all( - recipientSelection.relays.map((url) => - publishEventToRelay(this.pool, url, recipientGiftWrap.wrap), - ), - ); - - const recipientSuccess = recipientPublishResults.some( - (result) => result.success, - ); - if (!recipientSuccess) { + const publishRumorEvent = async (rumorEvent) => { + let recipientGiftWrap; try { - await writeMessages({ ...dmRecord, status: "failed" }); + recipientGiftWrap = await buildGiftWrapForRecipient( + targetHex, + recipientSelection.relays[0] || "", + rumorEvent, + ); } catch (error) { - devLogger.warn("[nostr] Failed to persist DM failure state.", error); + devLogger.warn("[nostr] Failed to build NIP-17 recipient wrap.", error); + const errorCode = + error?.message === "wrapper-keygen-failed" + ? "nip17-keygen-failed" + : "encryption-failed"; + return { ok: false, error: errorCode, details: error }; } - return { - ok: false, - error: "publish-failed", - details: recipientPublishResults.filter((result) => !result.success), + + const conversationId = `dm:${[actorHex, targetHex].sort().join(":")}`; + const preview = resolveRumorPreview(rumorEvent); + const dmRecord = { + id: recipientGiftWrap.wrap.id, + conversation_id: conversationId, + sender_pubkey: actorHex, + receiver_pubkey: targetHex, + created_at: rumorEvent.created_at, + kind: rumorEvent.kind, + content: typeof rumorEvent.content === "string" ? rumorEvent.content : "", + tags: rumorEvent.tags, + status: "pending", + seen: true, }; - } - let senderGiftWrap = null; - try { - senderGiftWrap = await buildGiftWrapForRecipient( - actorHex, - senderRelayTargets[0] || "", - ); - } catch (error) { - devLogger.warn("[nostr] Failed to build NIP-17 sender copy.", error); - } + try { + await writeMessages(dmRecord); + await updateConversationFromMessage(dmRecord, { + preview, + unseenDelta: 0, + }); + } catch (error) { + devLogger.warn("[nostr] Failed to persist outgoing DM.", error); + } - if (senderGiftWrap && senderRelayTargets.length) { - const senderPublishResults = await Promise.all( - senderRelayTargets.map((url) => - publishEventToRelay(this.pool, url, senderGiftWrap.wrap), + const recipientPublishResults = await Promise.all( + recipientSelection.relays.map((url) => + publishEventToRelay(this.pool, url, recipientGiftWrap.wrap), ), ); - if (!senderPublishResults.some((result) => result.success)) { - devLogger.warn("[nostr] Failed to publish sender copy of NIP-17 DM.", { - relays: senderRelayTargets, - }); + + const recipientSuccess = recipientPublishResults.some( + (result) => result.success, + ); + if (!recipientSuccess) { + try { + await writeMessages({ ...dmRecord, status: "failed" }); + } catch (error) { + devLogger.warn("[nostr] Failed to persist DM failure state.", error); + } + return { + ok: false, + error: "publish-failed", + details: recipientPublishResults.filter((result) => !result.success), + }; + } + + let senderGiftWrap = null; + try { + senderGiftWrap = await buildGiftWrapForRecipient( + actorHex, + senderRelayTargets[0] || "", + rumorEvent, + ); + } catch (error) { + devLogger.warn("[nostr] Failed to build NIP-17 sender copy.", error); + } + + if (senderGiftWrap && senderRelayTargets.length) { + const senderPublishResults = await Promise.all( + senderRelayTargets.map((url) => + publishEventToRelay(this.pool, url, senderGiftWrap.wrap), + ), + ); + if (!senderPublishResults.some((result) => result.success)) { + devLogger.warn("[nostr] Failed to publish sender copy of NIP-17 DM.", { + relays: senderRelayTargets, + }); + } + } + + try { + await writeMessages({ ...dmRecord, status: "published" }); + } catch (error) { + devLogger.warn("[nostr] Failed to persist DM publish state.", error); + } + + return { ok: true }; + }; + + const failures = []; + for (const rumorEvent of rumorEvents) { + const result = await publishRumorEvent(rumorEvent); + if (!result?.ok) { + failures.push(result); } } - try { - await writeMessages({ ...dmRecord, status: "published" }); - } catch (error) { - devLogger.warn("[nostr] Failed to persist DM publish state.", error); + if (failures.length) { + return { + ok: false, + error: failures[0]?.error || "publish-failed", + details: failures, + }; } return relayWarning ? { ok: true, warning: relayWarning } : { ok: true }; diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 9077aac6..d5f8ef2b 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -20,6 +20,7 @@ export const NOTE_TYPES = Object.freeze({ VIEW_EVENT: "viewEvent", VIDEO_REACTION: "videoReaction", VIDEO_COMMENT: "videoComment", + DM_ATTACHMENT: "dmAttachment", ZAP_REQUEST: "zapRequest", ZAP_RECEIPT: "zapReceipt", WATCH_HISTORY: "watchHistory", @@ -364,6 +365,23 @@ const BASE_SCHEMAS = { description: "Plain text comment body sanitized for UTF-8 compatibility.", }, }, + [NOTE_TYPES.DM_ATTACHMENT]: { + type: NOTE_TYPES.DM_ATTACHMENT, + label: "DM attachment (NIP-17 file rumor)", + kind: 15, + participantTagName: "p", + hashTagName: "x", + urlTagName: "url", + nameTagName: "name", + typeTagName: "type", + sizeTagName: "size", + keyTagName: "k", + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "empty", + description: "Attachment metadata is carried in tags; content is empty.", + }, + }, [NOTE_TYPES.ZAP_REQUEST]: { type: NOTE_TYPES.ZAP_REQUEST, label: "Zap request", @@ -1087,6 +1105,56 @@ export function buildMuteListEvent(params) { }; } +export function buildDmAttachmentEvent(params) { + const { + pubkey, + created_at, + recipientPubkey, + attachment = {}, + additionalTags = [], + } = params || {}; + const schema = getNostrEventSchema(NOTE_TYPES.DM_ATTACHMENT); + const tags = []; + + const participantTagName = schema?.participantTagName || "p"; + if (recipientPubkey && typeof recipientPubkey === "string") { + tags.push([participantTagName, recipientPubkey.trim()]); + } + + if (attachment?.x) { + tags.push([schema?.hashTagName || "x", attachment.x]); + } + if (attachment?.url) { + tags.push([schema?.urlTagName || "url", attachment.url]); + } + if (attachment?.name) { + tags.push([schema?.nameTagName || "name", attachment.name]); + } + if (attachment?.type) { + tags.push([schema?.typeTagName || "type", attachment.type]); + } + if (Number.isFinite(attachment?.size)) { + tags.push([schema?.sizeTagName || "size", String(attachment.size)]); + } + if (attachment?.key) { + tags.push([schema?.keyTagName || "k", attachment.key]); + } + + appendSchemaTags(tags, schema); + const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags); + if (sanitizedAdditionalTags.length) { + tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice())); + } + + return { + kind: schema?.kind ?? 15, + pubkey, + created_at, + tags, + content: "", + }; +} + export function buildViewEvent(params) { const { pubkey, diff --git a/js/services/attachmentService.js b/js/services/attachmentService.js new file mode 100644 index 00000000..267d60c6 --- /dev/null +++ b/js/services/attachmentService.js @@ -0,0 +1,393 @@ +import { bytesToHex, sha256 } from "../../vendor/crypto-helpers.bundle.min.js"; +import { devLogger, userLogger } from "../utils/logger.js"; + +const ATTACHMENT_CACHE_LIMIT = 20; +const AES_GCM_IV_BYTES = 12; + +const cache = new Map(); + +function getCacheEntry(key) { + if (!cache.has(key)) { + return null; + } + const entry = cache.get(key); + cache.delete(key); + cache.set(key, entry); + return entry; +} + +function revokeEntry(entry) { + if (entry?.objectUrl && typeof URL !== "undefined") { + try { + URL.revokeObjectURL(entry.objectUrl); + } catch (error) { + devLogger.warn("[attachments] Failed to revoke object URL", error); + } + } +} + +function setCacheEntry(key, entry) { + if (!key) { + return; + } + + if (cache.has(key)) { + const existing = cache.get(key); + if (existing && existing !== entry) { + revokeEntry(existing); + } + cache.delete(key); + } else if (cache.size >= ATTACHMENT_CACHE_LIMIT) { + const oldestKey = cache.keys().next().value; + const oldest = cache.get(oldestKey); + cache.delete(oldestKey); + revokeEntry(oldest); + } + + cache.set(key, entry); +} + +export function clearAttachmentCache() { + cache.forEach((entry) => revokeEntry(entry)); + cache.clear(); +} + +function normalizeBase64(bytes) { + if (!(bytes instanceof Uint8Array)) { + return ""; + } + let binary = ""; + bytes.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + return btoa(binary); +} + +function decodeBase64(value) { + if (typeof value !== "string" || !value) { + return null; + } + + try { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } catch (error) { + devLogger.warn("[attachments] Failed to decode base64 key", error); + return null; + } +} + +async function readBlobAsUint8Array(blob) { + if (!blob || typeof blob.arrayBuffer !== "function") { + return null; + } + try { + const buffer = await blob.arrayBuffer(); + return new Uint8Array(buffer); + } catch (error) { + devLogger.warn("[attachments] Failed to read attachment bytes", error); + return null; + } +} + +async function computeSha256Hex(bytes) { + if (!(bytes instanceof Uint8Array)) { + return ""; + } + + try { + const digest = sha256(bytes); + const hex = typeof digest === "string" ? digest : bytesToHex(digest); + return hex ? hex.toLowerCase() : ""; + } catch (error) { + devLogger.warn("[attachments] Failed to compute attachment hash", error); + return ""; + } +} + +async function encryptAttachment(blob) { + if (!blob) { + return null; + } + + if (!globalThis.crypto?.subtle) { + throw new Error("Encryption unavailable in this environment."); + } + + const rawBytes = await readBlobAsUint8Array(blob); + if (!rawBytes) { + throw new Error("Failed to read attachment bytes for encryption."); + } + + const keyBytes = new Uint8Array(32); + globalThis.crypto.getRandomValues(keyBytes); + const ivBytes = new Uint8Array(AES_GCM_IV_BYTES); + globalThis.crypto.getRandomValues(ivBytes); + + const key = await globalThis.crypto.subtle.importKey( + "raw", + keyBytes, + "AES-GCM", + false, + ["encrypt", "decrypt"], + ); + + const encrypted = await globalThis.crypto.subtle.encrypt( + { name: "AES-GCM", iv: ivBytes }, + key, + rawBytes, + ); + + const encryptedBytes = new Uint8Array(encrypted); + const payload = new Uint8Array(ivBytes.length + encryptedBytes.length); + payload.set(ivBytes, 0); + payload.set(encryptedBytes, ivBytes.length); + + return { + payload, + key: normalizeBase64(keyBytes), + }; +} + +async function decryptAttachment(bytes, keyBase64) { + if (!globalThis.crypto?.subtle) { + throw new Error("Decryption unavailable in this environment."); + } + + const keyBytes = decodeBase64(keyBase64); + if (!keyBytes) { + throw new Error("Missing decryption key."); + } + + if (!(bytes instanceof Uint8Array) || bytes.length <= AES_GCM_IV_BYTES) { + throw new Error("Encrypted payload is missing IV."); + } + + const iv = bytes.slice(0, AES_GCM_IV_BYTES); + const cipherBytes = bytes.slice(AES_GCM_IV_BYTES); + + const key = await globalThis.crypto.subtle.importKey( + "raw", + keyBytes, + "AES-GCM", + false, + ["decrypt"], + ); + + const decrypted = await globalThis.crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + key, + cipherBytes, + ); + + return new Uint8Array(decrypted); +} + +export async function prepareAttachmentUpload({ file, encrypt = false } = {}) { + if (!file) { + throw new Error("Attachment file missing."); + } + + let payloadBytes = await readBlobAsUint8Array(file); + if (!payloadBytes) { + throw new Error("Attachment could not be read."); + } + + let key = ""; + let uploadBlob = file; + + if (encrypt) { + const encrypted = await encryptAttachment(file); + if (!encrypted?.payload) { + throw new Error("Attachment encryption failed."); + } + + payloadBytes = encrypted.payload; + key = encrypted.key || ""; + uploadBlob = new File([payloadBytes], file.name, { + type: "application/octet-stream", + }); + } + + const sha256 = await computeSha256Hex(payloadBytes); + if (!sha256) { + throw new Error("Failed to compute attachment hash."); + } + + return { + uploadBlob, + sha256, + key, + name: file.name, + type: file.type || "application/octet-stream", + size: file.size, + encrypted: Boolean(key), + }; +} + +async function readResponseWithProgress(response, onProgress) { + if (!response.body || typeof response.body.getReader !== "function") { + const blob = await response.blob(); + if (typeof onProgress === "function") { + onProgress(1); + } + return new Uint8Array(await blob.arrayBuffer()); + } + + const reader = response.body.getReader(); + const contentLength = Number(response.headers.get("content-length")) || 0; + const chunks = []; + let received = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (value) { + chunks.push(value); + received += value.length; + if (contentLength && typeof onProgress === "function") { + onProgress(Math.min(received / contentLength, 1)); + } + } + } + + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const merged = new Uint8Array(totalLength); + let offset = 0; + chunks.forEach((chunk) => { + merged.set(chunk, offset); + offset += chunk.length; + }); + + if (typeof onProgress === "function") { + onProgress(1); + } + + return merged; +} + +export async function downloadAttachment({ + url, + expectedHash, + key, + mimeType, + onProgress, +} = {}) { + if (!url) { + throw new Error("Attachment URL missing."); + } + + const cached = expectedHash ? getCacheEntry(expectedHash) : null; + if (cached) { + return { ...cached, cached: true }; + } + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Attachment download failed (${response.status}).`); + } + + const bytes = await readResponseWithProgress(response, onProgress); + if (expectedHash) { + const actual = await computeSha256Hex(bytes); + if (actual && actual !== expectedHash) { + throw new Error("Attachment hash mismatch."); + } + } + + let payloadBytes = bytes; + if (key) { + payloadBytes = await decryptAttachment(bytes, key); + } + + const blob = new Blob([payloadBytes], { + type: mimeType || "application/octet-stream", + }); + const objectUrl = typeof URL !== "undefined" ? URL.createObjectURL(blob) : ""; + + const entry = { + blob, + objectUrl, + mimeType, + size: blob.size, + }; + + if (expectedHash) { + setCacheEntry(expectedHash, entry); + } + + return entry; +} + +export async function uploadAttachment({ + r2Service, + pubkey, + file, + encrypt = false, + onProgress, + buildKey, + buildUrl, +} = {}) { + if (!r2Service || typeof r2Service.uploadFile !== "function") { + throw new Error("Storage service unavailable."); + } + + if (!file) { + throw new Error("Attachment file missing."); + } + + if (!pubkey) { + throw new Error("Active pubkey required for attachment upload."); + } + + const credentials = + typeof r2Service.resolveConnection === "function" + ? await r2Service.resolveConnection(pubkey) + : null; + + if (!credentials) { + throw new Error("Storage configuration missing."); + } + + const prepared = await prepareAttachmentUpload({ file, encrypt }); + const key = typeof buildKey === "function" ? buildKey(pubkey, file) : ""; + if (!key) { + throw new Error("Unable to derive storage key for attachment."); + } + + await r2Service.uploadFile({ + file: prepared.uploadBlob, + ...credentials, + bucket: credentials.bucket, + key, + onProgress, + }); + + const url = typeof buildUrl === "function" ? buildUrl(credentials.baseDomain, key) : ""; + if (!url) { + userLogger.warn("[attachments] Attachment uploaded without public URL."); + } + + return { + url, + x: prepared.sha256, + key: prepared.key, + name: prepared.name, + type: prepared.type, + size: prepared.size, + encrypted: prepared.encrypted, + }; +} + +export function getAttachmentCacheStats() { + return { + size: cache.size, + maxSize: ATTACHMENT_CACHE_LIMIT, + }; +} diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 31c1bbfc..5216e0d0 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -3,6 +3,7 @@ import { convertEventToVideo } from "../nostr/index.js"; import { accessControl } from "../accessControl.js"; import { ALLOW_NSFW_CONTENT } from "../config.js"; import { devLogger, userLogger } from "../utils/logger.js"; +import { describeAttachment, extractAttachmentsFromMessage } from "../attachments/attachmentUtils.js"; import moderationService from "./moderationService.js"; import { DM_RELAY_WARNING_FALLBACK, @@ -206,7 +207,16 @@ function extractSnapshotPreview(message) { } const selected = candidates.find((candidate) => candidate && candidate.trim()); - return sanitizeSnapshotPreview(selected || ""); + if (selected) { + return sanitizeSnapshotPreview(selected); + } + + const attachments = extractAttachmentsFromMessage(message); + if (attachments.length) { + return sanitizeSnapshotPreview(describeAttachment(attachments[0])); + } + + return ""; } function getActiveKey(video) { diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index cfa4ad1c..b5292bf4 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -24,6 +24,17 @@ import { getActiveSigner } from "../nostr/client.js"; import { sanitizeRelayList } from "../nostr/nip46Client.js"; import { buildPublicUrl, buildR2Key } from "../r2.js"; import { buildProfileMetadataEvent } from "../nostrEventSchemas.js"; +import { + describeAttachment, + extractAttachmentsFromMessage, + formatAttachmentSize, +} from "../attachments/attachmentUtils.js"; +import { + clearAttachmentCache, + downloadAttachment, + uploadAttachment, + getAttachmentCacheStats, +} from "../services/attachmentService.js"; const noop = () => {}; @@ -1205,8 +1216,15 @@ export class ProfileModalController { this.profileMessagesStatus = null; this.profileMessagesReloadButton = null; this.profileMessagesPane = null; + this.profileMessagesConversation = null; + this.profileMessagesConversationEmpty = null; this.profileMessageInput = null; this.profileMessageSendButton = null; + this.profileMessageAttachmentInput = null; + this.profileMessageAttachmentButton = null; + this.profileMessageAttachmentEncrypt = null; + this.profileMessageAttachmentList = null; + this.profileMessageAttachmentClearCache = null; this.profileMessagesComposerHelper = null; this.profileMessagesSendDmButton = null; this.profileMessagesOpenRelaysButton = null; @@ -1287,6 +1305,8 @@ export class ProfileModalController { this.pendingMessagesRender = null; this.messagesStatusClearTimeout = null; this.dmPrivacyToggleTouched = false; + this.dmAttachmentQueue = []; + this.dmAttachmentUploads = new Map(); this.profileHistoryRenderer = null; this.profileHistoryRendererConfig = null; @@ -1473,10 +1493,24 @@ export class ProfileModalController { document.getElementById("profileMessagesStatus") || null; this.profileMessagesReloadButton = document.getElementById("profileMessagesReload") || null; + this.profileMessagesConversation = + document.getElementById("profileMessagesConversation") || null; + this.profileMessagesConversationEmpty = + document.getElementById("profileMessagesConversationEmpty") || null; this.profileMessageInput = document.getElementById("profileMessageInput") || null; this.profileMessageSendButton = document.getElementById("profileMessageSendBtn") || null; + this.profileMessageAttachmentInput = + document.getElementById("profileMessageAttachmentInput") || null; + this.profileMessageAttachmentButton = + document.getElementById("profileMessageAttachmentButton") || null; + this.profileMessageAttachmentEncrypt = + document.getElementById("profileMessageAttachmentEncrypt") || null; + this.profileMessageAttachmentList = + document.getElementById("profileMessageAttachmentList") || null; + this.profileMessageAttachmentClearCache = + document.getElementById("profileMessageAttachmentClearCache") || null; this.profileMessagesComposerHelper = document.getElementById("profileMessagesComposerHelper") || null; this.profileMessagesSendDmButton = @@ -2246,6 +2280,7 @@ export class ProfileModalController { this.setMessagesAnnouncement("Message recipient cleared."); } + void this.renderDirectMessageConversation(); return nextRecipient; } @@ -2361,6 +2396,7 @@ export class ProfileModalController { : this.resolveActiveDmActor(); this.setDirectMessageRecipient(null, { reason: "clear" }); + this.resetAttachmentQueue({ clearInput: true }); if ( this.directMessagesSubscription && @@ -2380,6 +2416,15 @@ export class ProfileModalController { this.profileMessagesList.classList.add("hidden"); this.profileMessagesList.setAttribute("hidden", ""); } + if (this.profileMessagesConversation instanceof HTMLElement) { + this.profileMessagesConversation.innerHTML = ""; + this.profileMessagesConversation.classList.add("hidden"); + this.profileMessagesConversation.setAttribute("hidden", ""); + } + if (this.profileMessagesConversationEmpty instanceof HTMLElement) { + this.profileMessagesConversationEmpty.classList.remove("hidden"); + this.profileMessagesConversationEmpty.removeAttribute("hidden"); + } if (!normalized) { this.setMessagesLoadingState("unauthenticated"); @@ -2518,6 +2563,10 @@ export class ProfileModalController { const input = this.profileMessageInput; const button = this.profileMessageSendButton; const helper = this.profileMessagesComposerHelper; + const attachmentInput = this.profileMessageAttachmentInput; + const attachmentButton = this.profileMessageAttachmentButton; + const attachmentEncrypt = this.profileMessageAttachmentEncrypt; + const attachmentClearCache = this.profileMessageAttachmentClearCache; const shouldDisable = this.messagesLoadingState === "unauthenticated"; const applyDisabledState = (element) => { @@ -2535,6 +2584,10 @@ export class ProfileModalController { applyDisabledState(input); applyDisabledState(button); + applyDisabledState(attachmentInput); + applyDisabledState(attachmentButton); + applyDisabledState(attachmentEncrypt); + applyDisabledState(attachmentClearCache); if (helper instanceof HTMLElement) { if (shouldDisable) { @@ -2641,6 +2694,337 @@ export class ProfileModalController { } } + generateAttachmentId(file) { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + + const name = typeof file?.name === "string" ? file.name : "attachment"; + return `${name}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + } + + resetAttachmentQueue({ clearInput = false } = {}) { + this.dmAttachmentQueue.forEach((entry) => { + if (entry?.previewUrl && typeof URL !== "undefined") { + try { + URL.revokeObjectURL(entry.previewUrl); + } catch (error) { + devLogger.warn("[profileModal] Failed to revoke attachment preview URL.", error); + } + } + }); + this.dmAttachmentQueue = []; + this.dmAttachmentUploads.clear(); + + if (clearInput && this.profileMessageAttachmentInput) { + this.profileMessageAttachmentInput.value = ""; + } + + this.renderAttachmentQueue(); + } + + handleAttachmentSelection() { + const input = this.profileMessageAttachmentInput; + if (!(input instanceof HTMLInputElement) || !input.files) { + return; + } + + const files = Array.from(input.files); + if (!files.length) { + return; + } + + files.forEach((file) => { + const previewUrl = + typeof URL !== "undefined" ? URL.createObjectURL(file) : ""; + this.dmAttachmentQueue.push({ + id: this.generateAttachmentId(file), + file, + name: file.name, + type: file.type || "application/octet-stream", + size: file.size, + previewUrl, + status: "pending", + progress: 0, + }); + }); + + input.value = ""; + this.renderAttachmentQueue(); + } + + renderAttachmentQueue() { + const list = this.profileMessageAttachmentList; + if (!(list instanceof HTMLElement)) { + return; + } + + list.innerHTML = ""; + + if (!this.dmAttachmentQueue.length) { + return; + } + + this.dmAttachmentQueue.forEach((entry) => { + const item = document.createElement("div"); + item.className = "card flex flex-col gap-2 p-3"; + item.dataset.attachmentId = entry.id; + + const header = document.createElement("div"); + header.className = "flex items-center justify-between gap-2"; + const title = document.createElement("div"); + title.className = "text-sm font-semibold text-text"; + title.textContent = entry.name || "Attachment"; + header.appendChild(title); + + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = "btn-ghost focus-ring inline-flex items-center"; + removeButton.dataset.size = "sm"; + removeButton.textContent = "Remove"; + removeButton.addEventListener("click", () => { + this.dmAttachmentQueue = this.dmAttachmentQueue.filter( + (queued) => queued.id !== entry.id, + ); + if (entry.previewUrl && typeof URL !== "undefined") { + URL.revokeObjectURL(entry.previewUrl); + } + this.renderAttachmentQueue(); + }); + header.appendChild(removeButton); + item.appendChild(header); + + const meta = document.createElement("div"); + meta.className = "text-xs text-muted"; + const sizeLabel = formatAttachmentSize(entry.size); + meta.textContent = sizeLabel + ? `${entry.type || "file"} · ${sizeLabel}` + : entry.type || "file"; + item.appendChild(meta); + + if (entry.previewUrl && entry.type?.startsWith("image/")) { + const img = document.createElement("img"); + img.src = entry.previewUrl; + img.alt = entry.name || "Attachment preview"; + img.className = "h-24 w-24 rounded-lg object-cover"; + item.appendChild(img); + } + + const progress = document.createElement("progress"); + progress.className = "progress"; + progress.value = entry.progress || 0; + progress.max = 1; + progress.dataset.variant = "surface"; + item.appendChild(progress); + + const status = document.createElement("div"); + status.className = "text-xs text-muted"; + status.textContent = + entry.status === "uploading" + ? "Uploading…" + : entry.status === "error" + ? entry.error || "Upload failed." + : "Ready to upload."; + item.appendChild(status); + + list.appendChild(item); + }); + } + + async uploadAttachmentQueue(actorPubkey) { + const r2Service = this.services.r2Service; + if (!r2Service) { + throw new Error("Storage service unavailable."); + } + + const encrypt = + this.profileMessageAttachmentEncrypt instanceof HTMLInputElement + ? this.profileMessageAttachmentEncrypt.checked + : false; + + const payloads = []; + + for (const entry of this.dmAttachmentQueue) { + entry.status = "uploading"; + entry.progress = 0; + this.renderAttachmentQueue(); + + try { + const payload = await uploadAttachment({ + r2Service, + pubkey: actorPubkey, + file: entry.file, + encrypt, + buildKey: buildR2Key, + buildUrl: buildPublicUrl, + onProgress: (fraction) => { + entry.progress = Number.isFinite(fraction) ? fraction : entry.progress; + this.renderAttachmentQueue(); + }, + }); + payloads.push(payload); + entry.status = "uploaded"; + entry.progress = 1; + } catch (error) { + entry.status = "error"; + entry.error = + error && typeof error.message === "string" + ? error.message + : "Attachment upload failed."; + this.renderAttachmentQueue(); + throw error; + } + } + + return payloads; + } + + async renderDirectMessageConversation() { + const container = this.profileMessagesConversation; + const emptyState = this.profileMessagesConversationEmpty; + const actor = this.resolveActiveDmActor(); + const recipient = this.resolveActiveDmRecipient(); + + if (!(container instanceof HTMLElement)) { + return; + } + + container.innerHTML = ""; + + if (!actor || !recipient || !this.directMessagesCache.length) { + container.classList.add("hidden"); + container.setAttribute("hidden", ""); + if (emptyState instanceof HTMLElement) { + emptyState.classList.remove("hidden"); + emptyState.removeAttribute("hidden"); + } + return; + } + + const messages = this.directMessagesCache + .filter((entry) => this.resolveDirectMessageRemote(entry, actor) === recipient) + .sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); + + if (!messages.length) { + container.classList.add("hidden"); + container.setAttribute("hidden", ""); + if (emptyState instanceof HTMLElement) { + emptyState.classList.remove("hidden"); + emptyState.removeAttribute("hidden"); + } + return; + } + + if (emptyState instanceof HTMLElement) { + emptyState.classList.add("hidden"); + emptyState.setAttribute("hidden", ""); + } + + messages.forEach((message) => { + const item = document.createElement("div"); + item.className = "card flex flex-col gap-2 p-3"; + + const body = document.createElement("div"); + body.className = "text-sm text-text whitespace-pre-line"; + const text = typeof message.plaintext === "string" ? message.plaintext.trim() : ""; + body.textContent = text || "Attachment"; + item.appendChild(body); + + const attachments = extractAttachmentsFromMessage(message); + attachments.forEach((attachment) => { + const attachmentCard = document.createElement("div"); + attachmentCard.className = "flex flex-col gap-2 rounded-lg border border-border/60 p-3"; + + const title = document.createElement("div"); + title.className = "text-xs font-semibold text-text"; + title.textContent = describeAttachment(attachment); + attachmentCard.appendChild(title); + + const meta = document.createElement("div"); + meta.className = "text-3xs text-muted"; + const sizeLabel = formatAttachmentSize(attachment.size); + const typeLabel = attachment.type || "file"; + meta.textContent = sizeLabel ? `${typeLabel} · ${sizeLabel}` : typeLabel; + attachmentCard.appendChild(meta); + + const progress = document.createElement("progress"); + progress.className = "progress"; + progress.value = 0; + progress.max = 1; + progress.dataset.variant = "surface"; + attachmentCard.appendChild(progress); + + const status = document.createElement("div"); + status.className = "text-3xs text-muted"; + status.textContent = attachment.encrypted + ? "Decrypting attachment…" + : "Downloading attachment…"; + attachmentCard.appendChild(status); + + item.appendChild(attachmentCard); + + downloadAttachment({ + url: attachment.url, + expectedHash: attachment.x, + key: attachment.key, + mimeType: attachment.type, + onProgress: (fraction) => { + progress.value = Number.isFinite(fraction) ? fraction : progress.value; + }, + }) + .then((result) => { + progress.value = 1; + progress.classList.add("hidden"); + progress.setAttribute("hidden", ""); + status.textContent = attachment.encrypted ? "Decrypted." : "Ready."; + + if (!result?.objectUrl) { + return; + } + + if (attachment.type?.startsWith("image/")) { + const img = document.createElement("img"); + img.src = result.objectUrl; + img.alt = attachment.name || "Attachment preview"; + img.className = "h-40 w-full rounded-lg object-cover"; + attachmentCard.appendChild(img); + } else if (attachment.type?.startsWith("video/")) { + const video = document.createElement("video"); + video.src = result.objectUrl; + video.controls = true; + video.className = "w-full rounded-lg"; + attachmentCard.appendChild(video); + } else if (attachment.type?.startsWith("audio/")) { + const audio = document.createElement("audio"); + audio.src = result.objectUrl; + audio.controls = true; + audio.className = "w-full"; + attachmentCard.appendChild(audio); + } else { + const link = document.createElement("a"); + link.href = result.objectUrl; + link.textContent = "Download attachment"; + link.className = "text-xs text-accent underline-offset-2 hover:underline"; + link.download = attachment.name || "attachment"; + attachmentCard.appendChild(link); + } + }) + .catch((error) => { + status.textContent = + error && typeof error.message === "string" + ? error.message + : "Attachment download failed."; + status.classList.add("text-critical"); + }); + }); + + container.appendChild(item); + }); + + container.classList.remove("hidden"); + container.removeAttribute("hidden"); + } + describeDirectMessageSendError(code) { switch (code) { case "sign-event-unavailable": @@ -2670,7 +3054,9 @@ export class ProfileModalController { case "invalid-target": return "Select a valid recipient before sending."; case "empty-message": - return "Please enter a message."; + return "Please enter a message or attach a file."; + case "attachments-unsupported": + return "Attachments require NIP-17 delivery. Enable the privacy toggle to send files."; default: return "Unable to send message. Please try again."; } @@ -2683,8 +3069,9 @@ export class ProfileModalController { } const message = typeof input.value === "string" ? input.value.trim() : ""; - if (!message) { - this.showError("Please enter a message."); + const hasAttachments = this.dmAttachmentQueue.length > 0; + if (!message && !hasAttachments) { + this.showError("Please enter a message or attach a file."); return; } @@ -2708,6 +3095,13 @@ export class ProfileModalController { : false; const senderRelayHints = this.getActiveDmRelayPreferences(); + if (hasAttachments && !useNip17) { + this.showError( + "Attachments require NIP-17 delivery. Enable the privacy toggle to send files.", + ); + return; + } + if (useNip17 && !recipientRelayHints.length) { this.showStatus( "Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.", @@ -2729,6 +3123,22 @@ export class ProfileModalController { } try { + let attachmentPayloads = []; + if (hasAttachments) { + try { + attachmentPayloads = await this.uploadAttachmentQueue( + this.resolveActiveDmActor(), + ); + } catch (error) { + const messageText = + error && typeof error.message === "string" + ? error.message + : "Attachment upload failed."; + this.showError(messageText); + return; + } + } + const result = await this.services.nostrClient.sendDirectMessage( target, message, @@ -2738,12 +3148,14 @@ export class ProfileModalController { useNip17: true, recipientRelayHints, senderRelayHints, + attachments: attachmentPayloads, } : {}, ); if (result?.ok) { input.value = ""; + this.resetAttachmentQueue({ clearInput: true }); this.showSuccess("Message sent."); if (result?.warning === "dm-relays-fallback") { this.showStatus( @@ -3121,6 +3533,7 @@ export class ProfileModalController { if (!threads.length) { this.profileMessagesList.classList.add("hidden"); this.profileMessagesList.setAttribute("hidden", ""); + void this.renderDirectMessageConversation(); return; } @@ -3146,6 +3559,7 @@ export class ProfileModalController { this.profileMessagesList.classList.remove("hidden"); this.profileMessagesList.removeAttribute("hidden"); + void this.renderDirectMessageConversation(); } async populateProfileMessages(options = {}) { @@ -3520,6 +3934,30 @@ export class ProfileModalController { }); } + if (this.profileMessageAttachmentButton instanceof HTMLElement) { + this.profileMessageAttachmentButton.addEventListener("click", () => { + if (this.profileMessageAttachmentInput instanceof HTMLInputElement) { + this.profileMessageAttachmentInput.click(); + } + }); + } + + if (this.profileMessageAttachmentInput instanceof HTMLElement) { + this.profileMessageAttachmentInput.addEventListener("change", () => { + this.handleAttachmentSelection(); + }); + } + + if (this.profileMessageAttachmentClearCache instanceof HTMLElement) { + this.profileMessageAttachmentClearCache.addEventListener("click", () => { + clearAttachmentCache(); + const stats = getAttachmentCacheStats(); + this.showStatus( + `Attachment cache cleared (${stats.size}/${stats.maxSize}).`, + ); + }); + } + if (this.profileMessageInput instanceof HTMLElement) { this.profileMessageInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) {