Add DM attachment upload and preview flow

This commit is contained in:
thePR0M3TH3AN committed 2026-01-19 12:51:34 -05:00
1 parent fdf0c185be
commit 9ea9fe2f08
8 files changed
+1309 -87

No files matched your search

+54
View File
@@ -1174,6 +1174,21 @@
aria-live="polite"
aria-label="Direct message threads"
></ul>
<div
id="profileMessagesConversation"
class="hidden flex flex-col gap-3"
role="log"
aria-live="polite"
aria-label="Direct message conversation"
></div>
<p
id="profileMessagesConversationEmpty"
class="hidden text-sm text-muted"
role="status"
aria-live="polite"
>
Select a thread to view messages and attachments.
</p>
<div class="flex flex-col gap-3 border-t border-border/60 pt-4">
<label class="sr-only" for="profileMessageInput">Message</label>
<textarea
@@ -1182,6 +1197,45 @@
rows="3"
placeholder="Write a message…"
></textarea>
<div class="flex flex-col gap-2">
<input
id="profileMessageAttachmentInput"
type="file"
class="sr-only"
multiple
/>
<div class="flex flex-wrap items-center gap-2">
<button
id="profileMessageAttachmentButton"
type="button"
class="btn-ghost focus-ring inline-flex items-center gap-2"
data-size="sm"
>
Attach file
</button>
<label class="inline-flex items-center gap-2 text-xs text-muted">
<input
id="profileMessageAttachmentEncrypt"
type="checkbox"
class="checkbox"
/>
Encrypt attachments
</label>
<button
id="profileMessageAttachmentClearCache"
type="button"
class="btn-ghost focus-ring inline-flex items-center gap-2"
data-size="sm"
>
Clear attachment cache
</button>
</div>
<div
id="profileMessageAttachmentList"
class="grid-stack"
data-orientation="vertical"
></div>
</div>
<div class="flex flex-wrap items-center justify-between gap-2">
<p
id="profileMessagesComposerHelper"
+1
View File
@@ -145,6 +145,7 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
| NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description |
| Repost (`NOTE_TYPES.REPOST`) | `6` | `['e', <event id>, <relay?>]` with optional address pointer `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <pubkey>]` 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`, <pointer>, <relay?>?], `['K', <root kind>]`, and `['P', <root author pubkey>, <relay?>?]` plus parent metadata `['E'\|`I`, <parent pointer>, <relay?>?, <author?>], `['K', <parent kind>]`, `['P', <parent author>, <relay?>?]`; inherits schema append tags | Plain text body sanitized to valid UTF-8 |
| DM attachment (`NOTE_TYPES.DM_ATTACHMENT`) | `15` | `['p', <recipient pubkey>]`, `['x', <sha256 of uploaded bytes>]`, `['url', <download url>]`, optional `['name', <filename>]`, `['type', <mime>]`, `['size', <bytes>]`, optional `['k', <base64 key>]`; 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).
+162
View File
@@ -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 };
+179 -83
View File
@@ -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 };
+68
View File
@@ -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,
+393
View File
@@ -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,
};
}
+11 -1
View File
@@ -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) {
+441 -3
View File
@@ -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) {