, ]` 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) {