1 parent
5dc9db0b1c
commit
83440f20e2
28 files changed
+1925
-213
No files matched your search
+667
-65
@@ -1,6 +1,25 @@
|
||||
|
||||
function parseRecipients(raw) {
|
||||
return raw.split(",").map((value) => value.trim()).filter(Boolean);
|
||||
const values = [];
|
||||
let current = "";
|
||||
let quoted = false;
|
||||
let escaped = false;
|
||||
let angleDepth = 0;
|
||||
for (const char of String(raw || "")) {
|
||||
if (escaped) { current += char; escaped = false; continue; }
|
||||
if (char === "\\" && quoted) { current += char; escaped = true; continue; }
|
||||
if (char === '"') { quoted = !quoted; current += char; continue; }
|
||||
if (!quoted && char === "<") angleDepth += 1;
|
||||
if (!quoted && char === ">" && angleDepth > 0) angleDepth -= 1;
|
||||
if (char === "," && !quoted && angleDepth === 0) {
|
||||
if (current.trim()) values.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
if (current.trim()) values.push(current.trim());
|
||||
return values;
|
||||
}
|
||||
|
||||
function recipientToken(input) {
|
||||
@@ -137,20 +156,86 @@ function updateComposeMessageSize(extraFiles = []) {
|
||||
return estimated;
|
||||
}
|
||||
|
||||
function mergeComposeAttachments(...lists) {
|
||||
const merged = new Map();
|
||||
for (const list of lists) {
|
||||
for (const attachment of Array.isArray(list) ? list : []) {
|
||||
const id = String(attachment?.id || "");
|
||||
if (!id) continue;
|
||||
merged.set(id, { ...(merged.get(id) || {}), ...attachment, id: attachment.id || id });
|
||||
}
|
||||
}
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
async function refreshComposeAttachmentsFromServer(draftId = state.composeDraftId) {
|
||||
if (!draftId) return state.composeAttachments;
|
||||
try {
|
||||
const draft = await request(`/drafts/${encodeURIComponent(draftId)}`, { method: "GET" });
|
||||
state.composeAttachments = mergeComposeAttachments(state.composeAttachments, draft.attachments);
|
||||
renderComposeAttachments();
|
||||
} catch (_) {
|
||||
// Keep the local list if the draft endpoint is temporarily unavailable.
|
||||
}
|
||||
return state.composeAttachments;
|
||||
}
|
||||
|
||||
function detachComposeAttachmentFromBody(id) {
|
||||
const cid = String(id || "");
|
||||
if (!cid) return false;
|
||||
let changed = false;
|
||||
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]").forEach((node) => {
|
||||
if (String(node.dataset.rustboxCid || "") !== cid) return;
|
||||
if (node === state.composeSelectedImage) setSelectedInlineImage(null);
|
||||
node.remove();
|
||||
changed = true;
|
||||
});
|
||||
if (!changed) return false;
|
||||
syncComposeInlineAttachments();
|
||||
scheduleDraftSave();
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderComposeAttachments() {
|
||||
const box = $("#compose-attachments");
|
||||
box.replaceChildren();
|
||||
const visibleAttachments = state.composeAttachments.filter((attachment) => !state.inlineAttachmentIds.has(String(attachment.id)));
|
||||
box.classList.toggle("hidden", !visibleAttachments.length);
|
||||
for (const attachment of visibleAttachments) {
|
||||
const attachments = mergeComposeAttachments(state.composeAttachments);
|
||||
state.composeAttachments = attachments;
|
||||
box.classList.toggle("hidden", !attachments.length);
|
||||
for (const attachment of attachments) {
|
||||
const id = String(attachment.id || "");
|
||||
const filename = attachment.filename || "attachment";
|
||||
const isImage = composeAttachmentIsImage(attachment);
|
||||
const isInline = state.inlineAttachmentIds.has(id);
|
||||
const item = el("div", "compose-attachment");
|
||||
item.innerHTML = `${svgIcon("paperclip")}<span>${escapeHtml(attachment.filename)} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB</span>`;
|
||||
const remove = el("button", "");
|
||||
item.dataset.attachmentId = id;
|
||||
const kind = isInline ? (isImage ? "Inline image" : "Inline link") : (isImage ? "Image attachment" : "Attachment");
|
||||
item.innerHTML = `${svgIcon("paperclip")}<span class="compose-attachment-kind">${kind}</span><span class="compose-attachment-label">${escapeHtml(filename)} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB</span>`;
|
||||
|
||||
const embed = el("button", "compose-attachment-action");
|
||||
embed.type = "button";
|
||||
if (isInline) {
|
||||
embed.title = `Keep ${filename} as attachment only`;
|
||||
embed.setAttribute("aria-label", embed.title);
|
||||
embed.innerHTML = svgIcon("paperclip");
|
||||
embed.addEventListener("click", () => detachComposeAttachmentFromBody(id));
|
||||
} else {
|
||||
embed.title = isImage ? `Embed ${filename} in message` : `Insert a link to ${filename} in message`;
|
||||
embed.setAttribute("aria-label", embed.title);
|
||||
embed.innerHTML = svgIcon(isImage ? "image" : "link");
|
||||
embed.addEventListener("mousedown", () => { state.composeInsertRange = captureEditorRange(); });
|
||||
embed.addEventListener("click", () => {
|
||||
insertComposeAttachment(attachment, state.composeInsertRange);
|
||||
state.composeInsertRange = null;
|
||||
});
|
||||
}
|
||||
|
||||
const remove = el("button", "compose-attachment-remove");
|
||||
remove.innerHTML = svgIcon("x");
|
||||
remove.type = "button";
|
||||
remove.setAttribute("aria-label", `Remove ${attachment.filename}`);
|
||||
remove.setAttribute("aria-label", `Remove ${filename}`);
|
||||
remove.addEventListener("click", () => deleteComposeAttachment(attachment.id));
|
||||
item.append(remove);
|
||||
item.append(embed, remove);
|
||||
box.append(item);
|
||||
}
|
||||
if ($("#compose-pgp-mode")) updateComposePgpUi();
|
||||
@@ -173,6 +258,120 @@ function composeAttachmentContentUrl(draftId, attachmentId) {
|
||||
return `${apiPath(`/drafts/${encodeURIComponent(draftId)}/attachments/${encodeURIComponent(attachmentId)}/content`)}${query}`;
|
||||
}
|
||||
|
||||
function composeImapAttachmentContentUrl(attachment, source = state.composeRemoteDraft) {
|
||||
if (!source || attachment?.source !== "imap") return "";
|
||||
const index = Number(attachment.source_index);
|
||||
if (!Number.isInteger(index) || index < 0) return "";
|
||||
const query = state.accountId ? `?account_id=${encodeURIComponent(state.accountId)}` : "";
|
||||
return `${apiPath(`/messages/${encodeURIComponent(source.folder)}/${source.uid}/attachments/${index}`)}${query}`;
|
||||
}
|
||||
|
||||
function composeAttachmentUrl(attachment, draftId = state.composeDraftId) {
|
||||
if (attachment?.source === "imap") return composeImapAttachmentContentUrl(attachment);
|
||||
return draftId ? composeAttachmentContentUrl(draftId, attachment.id) : "";
|
||||
}
|
||||
|
||||
function composeAttachmentIsImage(attachment) {
|
||||
const type = String(attachment?.content_type || attachment?.contentType || "").toLowerCase();
|
||||
const filename = String(attachment?.filename || "").toLowerCase();
|
||||
return /^image\/(png|jpeg|gif|webp)$/.test(type) || /\.(png|jpe?g|gif|webp)$/.test(filename);
|
||||
}
|
||||
|
||||
function composeInlineAttachmentIds(editor = $("#compose-editor")) {
|
||||
if (!editor) return new Set();
|
||||
return new Set(
|
||||
[...editor.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]")]
|
||||
.map((node) => String(node.dataset.rustboxCid || ""))
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function syncComposeInlineAttachments() {
|
||||
const next = composeInlineAttachmentIds();
|
||||
const changed = next.size !== state.inlineAttachmentIds.size || [...next].some((id) => !state.inlineAttachmentIds.has(id));
|
||||
state.inlineAttachmentIds = next;
|
||||
if (changed) renderComposeAttachments();
|
||||
}
|
||||
|
||||
function composeAttachmentInsertionRange(range, editor = $("#compose-editor")) {
|
||||
const target = range?.cloneRange?.() || document.createRange();
|
||||
try {
|
||||
if (range) {
|
||||
const container = target.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
|
||||
? target.commonAncestorContainer
|
||||
: target.commonAncestorContainer.parentElement;
|
||||
if (container !== editor && !editor.contains(container)) throw new Error("stale compose range");
|
||||
// Never replace selected text or a previously selected inline image when adding another file.
|
||||
target.collapse(false);
|
||||
} else {
|
||||
target.selectNodeContents(editor);
|
||||
target.collapse(false);
|
||||
}
|
||||
} catch (_) {
|
||||
target.selectNodeContents(editor);
|
||||
target.collapse(false);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function insertComposeAttachment(attachment, insertionRange = null) {
|
||||
if (!attachment?.id) return false;
|
||||
if ($("#compose-mode")?.value !== "html") {
|
||||
toast("Switch to HTML mode to embed attachments in the message", "warning");
|
||||
return false;
|
||||
}
|
||||
const editor = $("#compose-editor");
|
||||
const id = String(attachment.id);
|
||||
const filename = attachment.filename || "attachment";
|
||||
const contentUrl = composeAttachmentUrl(attachment, ensureDraftId());
|
||||
if (!contentUrl) return false;
|
||||
let inserted;
|
||||
|
||||
try {
|
||||
editor.focus();
|
||||
const range = composeAttachmentInsertionRange(insertionRange, editor);
|
||||
if (composeAttachmentIsImage(attachment)) {
|
||||
inserted = document.createElement("img");
|
||||
inserted.src = contentUrl;
|
||||
inserted.dataset.rustboxCid = id;
|
||||
inserted.dataset.rustboxAlign = "inline";
|
||||
inserted.alt = filename;
|
||||
inserted.draggable = true;
|
||||
inserted.style.maxWidth = "100%";
|
||||
inserted.style.height = "auto";
|
||||
} else {
|
||||
inserted = document.createElement("a");
|
||||
inserted.href = contentUrl;
|
||||
inserted.dataset.rustboxCid = id;
|
||||
inserted.dataset.rustboxAttachmentLink = "true";
|
||||
inserted.textContent = filename;
|
||||
}
|
||||
|
||||
range.insertNode(inserted);
|
||||
const spacer = document.createTextNode("\u00a0");
|
||||
inserted.after(spacer);
|
||||
const after = document.createRange();
|
||||
after.setStartAfter(spacer);
|
||||
after.collapse(true);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(after);
|
||||
|
||||
state.inlineAttachmentIds.add(id);
|
||||
if (composeAttachmentIsImage(attachment)) setSelectedInlineImage(inserted);
|
||||
else setSelectedInlineImage(null);
|
||||
} catch (error) {
|
||||
state.inlineAttachmentIds.delete(id);
|
||||
renderComposeAttachments();
|
||||
toast(error?.message || `Could not embed ${filename}`, "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
syncComposeInlineAttachments();
|
||||
scheduleDraftSave();
|
||||
return true;
|
||||
}
|
||||
|
||||
function markdownInlineToSafeHtml(value) {
|
||||
const source = String(value ?? "");
|
||||
const code = [];
|
||||
@@ -277,9 +476,16 @@ function serializeComposeHtml(editor = $("#compose-editor")) {
|
||||
if (cid) image.setAttribute("src", `cid:${cid}`);
|
||||
image.removeAttribute("data-rustbox-cid");
|
||||
image.removeAttribute("data-rustbox-align");
|
||||
image.removeAttribute("draggable");
|
||||
image.classList.remove("compose-inline-image-selected");
|
||||
if (!image.className) image.removeAttribute("class");
|
||||
});
|
||||
clone.querySelectorAll("a[data-rustbox-cid]").forEach((link) => {
|
||||
const cid = link.dataset.rustboxCid || "";
|
||||
if (cid) link.setAttribute("href", `cid:${cid}`);
|
||||
link.removeAttribute("data-rustbox-cid");
|
||||
link.removeAttribute("data-rustbox-attachment-link");
|
||||
});
|
||||
const html = clone.innerHTML.trim();
|
||||
return html ? wrapComposeHtmlFont(html) : null;
|
||||
}
|
||||
@@ -290,24 +496,51 @@ function hydrateComposeHtml(html, attachments = state.composeAttachments, draftI
|
||||
if (host.children.length === 1 && host.firstElementChild?.classList.contains("rustbox-compose-font")) {
|
||||
host.innerHTML = host.firstElementChild.innerHTML;
|
||||
}
|
||||
if (!draftId) return host.innerHTML;
|
||||
const ids = new Set((attachments || []).map((attachment) => String(attachment.id || "")).filter(Boolean));
|
||||
const attachmentList = Array.isArray(attachments) ? attachments : [];
|
||||
const byCid = new Map();
|
||||
for (const attachment of attachmentList) {
|
||||
const id = String(attachment?.id || "");
|
||||
if (!id) continue;
|
||||
byCid.set(id, attachment);
|
||||
const sourceCid = String(attachment?.source_content_id || "");
|
||||
if (sourceCid) byCid.set(sourceCid, attachment);
|
||||
}
|
||||
host.querySelectorAll("img[src]").forEach((image) => {
|
||||
const src = image.getAttribute("src") || "";
|
||||
if (!src.toLowerCase().startsWith("cid:")) return;
|
||||
const cid = src.slice(4).replace(/^<|>$/g, "");
|
||||
if (!ids.has(cid)) return;
|
||||
state.inlineAttachmentIds.add(cid);
|
||||
image.dataset.rustboxCid = cid;
|
||||
image.setAttribute("src", composeAttachmentContentUrl(draftId, cid));
|
||||
const attachment = byCid.get(cid);
|
||||
if (!attachment) return;
|
||||
const id = String(attachment.id);
|
||||
const contentUrl = composeAttachmentUrl(attachment, draftId);
|
||||
if (!contentUrl) return;
|
||||
state.inlineAttachmentIds.add(id);
|
||||
image.dataset.rustboxCid = id;
|
||||
image.draggable = true;
|
||||
image.setAttribute("src", contentUrl);
|
||||
});
|
||||
host.querySelectorAll("a[href]").forEach((link) => {
|
||||
const href = link.getAttribute("href") || "";
|
||||
if (!href.toLowerCase().startsWith("cid:")) return;
|
||||
const cid = href.slice(4).replace(/^<|>$/g, "");
|
||||
const attachment = byCid.get(cid);
|
||||
if (!attachment) return;
|
||||
const id = String(attachment.id);
|
||||
const contentUrl = composeAttachmentUrl(attachment, draftId);
|
||||
if (!contentUrl) return;
|
||||
state.inlineAttachmentIds.add(id);
|
||||
link.dataset.rustboxCid = id;
|
||||
link.dataset.rustboxAttachmentLink = "true";
|
||||
link.setAttribute("href", contentUrl);
|
||||
});
|
||||
return host.innerHTML;
|
||||
}
|
||||
|
||||
function composeDraftPayload() {
|
||||
const editor = $("#compose-editor");
|
||||
return {
|
||||
const payload = {
|
||||
id: ensureDraftId(),
|
||||
from_identity_id: $("#compose-from")?.value ? Number($("#compose-from").value) : null,
|
||||
to: $("#compose-to").value,
|
||||
cc: $("#compose-cc").value,
|
||||
bcc: $("#compose-bcc").value,
|
||||
@@ -324,6 +557,14 @@ function composeDraftPayload() {
|
||||
pgp_attach_public_key: Boolean($("#compose-pgp-attach-key")?.checked),
|
||||
pgp_attach_recipient_keys: Boolean($("#compose-pgp-attach-recipient-keys")?.checked),
|
||||
};
|
||||
if (state.composeRemoteDraft) {
|
||||
payload.imap_source = { ...state.composeRemoteDraft };
|
||||
payload.imap_attachments = state.composeAttachments
|
||||
.filter((attachment) => attachment?.source === "imap")
|
||||
.map((attachment) => ({ id: String(attachment.id), index: Number(attachment.source_index) }))
|
||||
.filter((attachment) => attachment.id && Number.isInteger(attachment.index) && attachment.index >= 0);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function readLocalDraftSnapshots() {
|
||||
@@ -363,24 +604,159 @@ function clearLocalDraftSnapshot(draftId = state.composeDraftId) {
|
||||
writeLocalDraftSnapshots(readLocalDraftSnapshots().filter((draft) => draft.id !== draftId));
|
||||
}
|
||||
|
||||
async function refreshRemoteDraftAttachmentSources(folder, uid, uidValidity, freshAttachments = null) {
|
||||
const remoteAttachments = state.composeAttachments.filter((attachment) => attachment?.source === "imap");
|
||||
state.composeRemoteDraft = { folder, uid: Number(uid), uid_validity: Number(uidValidity) };
|
||||
if (!remoteAttachments.length) return;
|
||||
try {
|
||||
let candidates = Array.isArray(freshAttachments) ? freshAttachments : null;
|
||||
if (!candidates?.length) {
|
||||
const fresh = await request(`/messages/${encodeURIComponent(folder)}/${uid}/draft-compose`, { method: "GET" });
|
||||
candidates = Array.isArray(fresh.attachments) ? fresh.attachments : [];
|
||||
}
|
||||
const used = new Set();
|
||||
for (const attachment of remoteAttachments) {
|
||||
let index = candidates.findIndex((candidate, candidateIndex) =>
|
||||
!used.has(candidateIndex) && String(candidate.source_content_id || "") === String(attachment.id || ""),
|
||||
);
|
||||
if (index < 0) {
|
||||
index = candidates.findIndex((candidate, candidateIndex) =>
|
||||
!used.has(candidateIndex)
|
||||
&& candidate.checksum_sha256 === attachment.checksum_sha256
|
||||
&& candidate.filename === attachment.filename
|
||||
&& Number(candidate.size) === Number(attachment.size),
|
||||
);
|
||||
}
|
||||
if (index < 0) continue;
|
||||
used.add(index);
|
||||
const candidate = candidates[index];
|
||||
attachment.source_index = candidate.source_index;
|
||||
attachment.source_content_id = candidate.source_content_id;
|
||||
attachment.checksum_sha256 = candidate.checksum_sha256;
|
||||
}
|
||||
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]").forEach((node) => {
|
||||
const id = String(node.dataset.rustboxCid || "");
|
||||
const attachment = state.composeAttachments.find((item) => String(item.id || "") === id && item.source === "imap");
|
||||
if (!attachment) return;
|
||||
const url = composeAttachmentUrl(attachment);
|
||||
if (!url) return;
|
||||
if (node.tagName === "IMG") node.setAttribute("src", url);
|
||||
else node.setAttribute("href", url);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Could not refresh IMAP draft attachment sources", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCurrentDraft(silent = false, force = false, syncImap = false) {
|
||||
if (!force && !$("#compose-dialog").open) return false;
|
||||
if (!force && !state.composeDirty) return true;
|
||||
if (!draftHasContent()) { state.composeDirty = false; clearLocalDraftSnapshot(); return true; }
|
||||
const payload = composeDraftPayload();
|
||||
payload.sync_imap = Boolean(syncImap);
|
||||
const draftsFolder = syncImap
|
||||
? (state.composeRemoteDraft?.folder || state.specialFolders?.drafts || (typeof detectSpecialFolder === "function" ? detectSpecialFolder("drafts") : null))
|
||||
: null;
|
||||
const releaseRealtimeSuppression = syncImap && draftsFolder && typeof suppressRealtimeNotificationsForFolder === "function"
|
||||
? suppressRealtimeNotificationsForFolder(draftsFolder)
|
||||
: () => { };
|
||||
try {
|
||||
await request("/drafts", { method: "POST", body: JSON.stringify(payload) });
|
||||
const result = await request("/drafts", { method: "POST", body: JSON.stringify(payload) });
|
||||
state.composeDirty = false;
|
||||
if (syncImap) state.composeNeedsRemoteSync = false;
|
||||
clearLocalDraftSnapshot();
|
||||
$("#compose-status").textContent = `${trText("Draft saved")} ${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
||||
if (!silent) toast("Draft saved", "success");
|
||||
if (syncImap && result?.remote_folder && result?.remote_uid && result?.remote_uid_validity) {
|
||||
await refreshRemoteDraftAttachmentSources(result.remote_folder, result.remote_uid, result.remote_uid_validity, result.remote_attachments);
|
||||
}
|
||||
if (!syncImap && state.composeRemoteDraft) storeLocalDraftSnapshot();
|
||||
else clearLocalDraftSnapshot();
|
||||
updateComposeDraftDeleteAction();
|
||||
const savedLabel = syncImap ? "Draft saved to IMAP" : "Draft saved";
|
||||
$("#compose-status").textContent = `${trText(savedLabel)} ${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
||||
if (!silent) toast(savedLabel, "success");
|
||||
return true;
|
||||
} catch (error) {
|
||||
storeLocalDraftSnapshot();
|
||||
if (!silent) toast(error.message, "error");
|
||||
return false;
|
||||
} finally {
|
||||
releaseRealtimeSuppression();
|
||||
}
|
||||
}
|
||||
|
||||
function updateComposeDraftDeleteAction() {
|
||||
const button = $("#compose-delete-draft");
|
||||
if (!button) return;
|
||||
const canDelete = Boolean(state.composeDraftId || state.composeRemoteDraft);
|
||||
button.classList.toggle("hidden", !canDelete);
|
||||
button.disabled = Boolean(state.composeUploading);
|
||||
}
|
||||
|
||||
function composeDraftDeleteUrl() {
|
||||
if (!state.composeDraftId) return null;
|
||||
const base = `/drafts/${encodeURIComponent(state.composeDraftId)}`;
|
||||
const source = state.composeRemoteDraft;
|
||||
if (!source) return base;
|
||||
const params = new URLSearchParams({
|
||||
imap_folder: String(source.folder || ""),
|
||||
imap_uid: String(Number(source.uid) || 0),
|
||||
imap_uid_validity: String(Number(source.uid_validity) || 0),
|
||||
});
|
||||
return `${base}?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function deleteComposeDraft() {
|
||||
if (state.composeUploadController || state.composeUploading) {
|
||||
toast("Wait for attachment upload to finish", "warning");
|
||||
return;
|
||||
}
|
||||
const deleteUrl = composeDraftDeleteUrl();
|
||||
if (!deleteUrl) return;
|
||||
const confirmed = await appConfirm(
|
||||
"Delete this draft? The IMAP draft and all locally uploaded draft attachments will be permanently removed.",
|
||||
{ title: "Delete draft", confirmLabel: "Delete draft", danger: true },
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
const button = $("#compose-delete-draft");
|
||||
clearTimeout(state.draftSaveTimer);
|
||||
clearTimeout(state.inlineImageCleanupTimer);
|
||||
setBusy(button, true);
|
||||
try {
|
||||
await request(deleteUrl, { method: "DELETE" });
|
||||
const deletedDraftId = state.composeDraftId;
|
||||
const deletedRemoteDraft = state.composeRemoteDraft ? { ...state.composeRemoteDraft } : null;
|
||||
clearLocalDraftSnapshot(deletedDraftId);
|
||||
state.composeDraftId = null;
|
||||
state.composeRemoteDraft = null;
|
||||
state.composeAttachments = [];
|
||||
state.inlineAttachmentIds = new Set();
|
||||
setSelectedInlineImage(null);
|
||||
state.composeReplyMeta = null;
|
||||
state.composeDirty = false;
|
||||
state.composeNeedsRemoteSync = false;
|
||||
state.pgpRecipientStatus = [];
|
||||
$("#compose-pgp-passphrase").value = "";
|
||||
renderComposeAttachments();
|
||||
updateComposeMessageSize();
|
||||
updateComposeDraftDeleteAction();
|
||||
if ($("#compose-dialog").open) $("#compose-dialog").close();
|
||||
if (deletedRemoteDraft) {
|
||||
const before = state.messages.length;
|
||||
state.messages = state.messages.filter((message) => !(
|
||||
String(message.folder || "") === String(deletedRemoteDraft.folder || "")
|
||||
&& Number(message.uid) === Number(deletedRemoteDraft.uid)
|
||||
));
|
||||
if (state.messages.length !== before) {
|
||||
state.total = Math.max(0, Number(state.total || 0) - 1);
|
||||
renderMessages();
|
||||
}
|
||||
}
|
||||
toast("Draft deleted", "success");
|
||||
await loadFolders();
|
||||
} catch (error) {
|
||||
toast(error.message, "error");
|
||||
} finally {
|
||||
setBusy(button, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,6 +768,22 @@ function scheduleDraftSave() {
|
||||
state.draftSaveTimer = setTimeout(() => saveCurrentDraft(true), Math.max(1000, Number(state.prefs.composeAutosaveMs || 3000)));
|
||||
}
|
||||
|
||||
async function saveComposeDraftToImap() {
|
||||
const button = $("#compose-save-draft");
|
||||
if (state.composeUploadController) {
|
||||
toast("Wait for attachment upload to finish", "warning");
|
||||
return;
|
||||
}
|
||||
clearTimeout(state.draftSaveTimer);
|
||||
await cleanupUnusedInlineAttachments();
|
||||
setBusy(button, true);
|
||||
try {
|
||||
await saveCurrentDraft(false, true, true);
|
||||
} finally {
|
||||
setBusy(button, false);
|
||||
}
|
||||
}
|
||||
|
||||
function setComposeUploadProgress(visible, percent = 0, label = trText("Uploading…")) {
|
||||
const box = $("#compose-upload-progress");
|
||||
const bar = $("#compose-upload-bar");
|
||||
@@ -407,14 +799,8 @@ async function uploadComposeFiles(files, options = {}) {
|
||||
const incoming = [...(files || [])];
|
||||
if (!incoming.length) return [];
|
||||
if (state.composeUploading) return [];
|
||||
const existing = new Set(state.composeAttachments.map((attachment) => `${String(attachment.filename || "")}\u0000${Number(attachment.size || 0)}`));
|
||||
const selected = new Set();
|
||||
const list = incoming.filter((file) => {
|
||||
const key = `${String(file.name || "")}\u0000${Number(file.size || 0)}`;
|
||||
if (existing.has(key) || selected.has(key)) return false;
|
||||
selected.add(key);
|
||||
return true;
|
||||
});
|
||||
// Treat every picker/drop entry as a separate attachment. Files may legitimately share a name and size.
|
||||
const list = incoming;
|
||||
if (!list.length) return [];
|
||||
const maxBytes = Math.max(1, Number(root.dataset.maxAttachmentBytes || 10 * 1024 * 1024));
|
||||
const maxMiB = Math.max(1, Number(root.dataset.maxAttachmentMb || Math.round(maxBytes / 1024 / 1024)));
|
||||
@@ -447,14 +833,16 @@ async function uploadComposeFiles(files, options = {}) {
|
||||
const overall = Math.round(((index + (percent / 100)) / list.length) * 100);
|
||||
setComposeUploadProgress(true, overall, `${trText("Uploading attachment…")} ${file.name} · ${overall}%`);
|
||||
}, controller.signal);
|
||||
const uploaded = data.attachments || [];
|
||||
const uploaded = Array.isArray(data.attachments) ? data.attachments : [];
|
||||
added.push(...uploaded);
|
||||
state.composeAttachments.push(...uploaded);
|
||||
state.composeAttachments = mergeComposeAttachments(state.composeAttachments, uploaded);
|
||||
renderComposeAttachments();
|
||||
state.composeDirty = true;
|
||||
state.composeNeedsRemoteSync = true;
|
||||
storeLocalDraftSnapshot();
|
||||
}
|
||||
await refreshComposeAttachmentsFromServer(id);
|
||||
storeLocalDraftSnapshot();
|
||||
setComposeUploadProgress(true, 100, trText("Upload complete"));
|
||||
setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 450);
|
||||
return added;
|
||||
@@ -489,6 +877,7 @@ function setComposeMode(mode, persist = false) {
|
||||
$$(".editor-toolbar [data-command], #editor-link, #editor-image, #editor-font, #editor-size, #editor-color").forEach((control) => { control.disabled = value !== "html"; });
|
||||
const imageEdit = $("#editor-image-edit");
|
||||
if (imageEdit) imageEdit.disabled = value !== "html" || !state.composeSelectedImage;
|
||||
updateInlineImageActions();
|
||||
$("#compose-markdown-preview")?.classList.toggle("hidden", value !== "markdown");
|
||||
updateComposeMessageSize();
|
||||
if (persist) savePreference("composeFormat", value);
|
||||
@@ -632,14 +1021,151 @@ function captureEditorRange(editor = $("#compose-editor")) {
|
||||
|
||||
function restoreEditorRange(range, editor = $("#compose-editor")) {
|
||||
editor.focus();
|
||||
if (!range) return;
|
||||
try {
|
||||
const selection = window.getSelection();
|
||||
const target = range?.cloneRange?.() || document.createRange();
|
||||
if (!range) {
|
||||
target.selectNodeContents(editor);
|
||||
target.collapse(false);
|
||||
}
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
selection.addRange(target);
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
function positionInlineImageActions() {
|
||||
const image = state.composeSelectedImage;
|
||||
const actions = $("#compose-image-actions");
|
||||
const editor = $("#compose-editor");
|
||||
if (!image?.isConnected || !actions || actions.classList.contains("hidden") || !editor) return;
|
||||
|
||||
const imageRect = image.getBoundingClientRect();
|
||||
const editorRect = editor.getBoundingClientRect();
|
||||
if (imageRect.bottom < editorRect.top || imageRect.top > editorRect.bottom) {
|
||||
actions.style.visibility = "hidden";
|
||||
return;
|
||||
}
|
||||
actions.style.visibility = "visible";
|
||||
const actionsRect = actions.getBoundingClientRect();
|
||||
const gap = 8;
|
||||
const viewportWidth = window.visualViewport?.width || window.innerWidth;
|
||||
const viewportHeight = window.visualViewport?.height || window.innerHeight;
|
||||
|
||||
let left = imageRect.left;
|
||||
left = Math.max(gap, Math.min(left, viewportWidth - actionsRect.width - gap));
|
||||
let top = imageRect.top - actionsRect.height - gap;
|
||||
if (top < Math.max(gap, editorRect.top + gap)) top = imageRect.bottom + gap;
|
||||
top = Math.max(gap, Math.min(top, viewportHeight - actionsRect.height - gap));
|
||||
|
||||
actions.style.left = `${Math.round(left)}px`;
|
||||
actions.style.top = `${Math.round(top)}px`;
|
||||
}
|
||||
|
||||
function positionInlineImageResizeHandles() {
|
||||
const image = state.composeSelectedImage;
|
||||
const overlay = $("#compose-image-resize");
|
||||
const editor = $("#compose-editor");
|
||||
if (!overlay || !editor || !image?.isConnected || $("#compose-mode")?.value !== "html") {
|
||||
overlay?.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
const imageRect = image.getBoundingClientRect();
|
||||
const editorRect = editor.getBoundingClientRect();
|
||||
if (imageRect.bottom < editorRect.top || imageRect.top > editorRect.bottom || imageRect.right < editorRect.left || imageRect.left > editorRect.right) {
|
||||
overlay.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
overlay.classList.remove("hidden");
|
||||
overlay.style.left = `${Math.round(imageRect.left)}px`;
|
||||
overlay.style.top = `${Math.round(imageRect.top)}px`;
|
||||
overlay.style.width = `${Math.max(1, Math.round(imageRect.width))}px`;
|
||||
overlay.style.height = `${Math.max(1, Math.round(imageRect.height))}px`;
|
||||
}
|
||||
|
||||
function positionInlineImageUi() {
|
||||
positionInlineImageActions();
|
||||
positionInlineImageResizeHandles();
|
||||
}
|
||||
|
||||
function beginInlineImageResize(event) {
|
||||
const handle = event.target.closest?.("[data-image-resize]");
|
||||
const image = state.composeSelectedImage;
|
||||
const editor = $("#compose-editor");
|
||||
if (!handle || !image?.isConnected || !editor) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const direction = handle.dataset.imageResize || "se";
|
||||
const sign = direction.includes("w") ? -1 : 1;
|
||||
const startX = event.clientX;
|
||||
const startWidth = image.getBoundingClientRect().width;
|
||||
const editorWidth = Math.max(1, editor.clientWidth);
|
||||
const alignment = inlineImageAlignment(image);
|
||||
document.body.classList.add("compose-image-resizing");
|
||||
|
||||
const move = (moveEvent) => {
|
||||
const delta = (moveEvent.clientX - startX) * sign;
|
||||
const widthPx = Math.max(editorWidth * 0.10, Math.min(editorWidth, startWidth + delta));
|
||||
const widthPercent = Math.max(10, Math.min(100, (widthPx / editorWidth) * 100));
|
||||
applyInlineImageDisplay(image, Math.round(widthPercent * 10) / 10, alignment);
|
||||
positionInlineImageUi();
|
||||
};
|
||||
const finish = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", finish);
|
||||
window.removeEventListener("pointercancel", finish);
|
||||
document.body.classList.remove("compose-image-resizing");
|
||||
scheduleDraftSave();
|
||||
positionInlineImageUi();
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", finish, { once: true });
|
||||
window.addEventListener("pointercancel", finish, { once: true });
|
||||
}
|
||||
|
||||
function moveInlineImageToRange(image, insertionRange) {
|
||||
const editor = $("#compose-editor");
|
||||
if (!image?.isConnected || !editor || !insertionRange) return false;
|
||||
try {
|
||||
const range = composeAttachmentInsertionRange(insertionRange, editor);
|
||||
range.insertNode(image);
|
||||
const spacer = document.createTextNode("\u00a0");
|
||||
image.after(spacer);
|
||||
const after = document.createRange();
|
||||
after.setStartAfter(spacer);
|
||||
after.collapse(true);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(after);
|
||||
setSelectedInlineImage(image);
|
||||
scheduleDraftSave();
|
||||
requestAnimationFrame(positionInlineImageUi);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateInlineImageActions() {
|
||||
const image = state.composeSelectedImage;
|
||||
const actions = $("#compose-image-actions");
|
||||
const visible = Boolean(image?.isConnected) && $("#compose-mode")?.value === "html";
|
||||
actions?.classList.toggle("hidden", !visible);
|
||||
const resize = $("#compose-image-resize");
|
||||
resize?.classList.toggle("hidden", !visible);
|
||||
if (!visible) return;
|
||||
const label = actions?.querySelector(".compose-image-actions-label");
|
||||
if (label) {
|
||||
const attachment = state.composeAttachments.find((item) => String(item.id) === String(image.dataset.rustboxCid || ""));
|
||||
label.textContent = attachment?.filename ? `Inline image · ${attachment.filename}` : "Inline image";
|
||||
label.title = label.textContent;
|
||||
}
|
||||
const width = /^\d+(?:\.\d+)?%$/.test(image.style.width || "") ? Number.parseFloat(image.style.width) : 100;
|
||||
const alignment = inlineImageAlignment(image);
|
||||
actions.querySelectorAll("[data-image-width]").forEach((button) => button.classList.toggle("active", Number(button.dataset.imageWidth) === Math.round(width)));
|
||||
actions.querySelectorAll("[data-image-align]").forEach((button) => button.classList.toggle("active", button.dataset.imageAlign === alignment));
|
||||
requestAnimationFrame(positionInlineImageUi);
|
||||
}
|
||||
|
||||
function setSelectedInlineImage(image) {
|
||||
if (state.composeSelectedImage && state.composeSelectedImage !== image) {
|
||||
state.composeSelectedImage.classList?.remove("compose-inline-image-selected");
|
||||
@@ -648,6 +1174,7 @@ function setSelectedInlineImage(image) {
|
||||
state.composeSelectedImage?.classList.add("compose-inline-image-selected");
|
||||
const edit = $("#editor-image-edit");
|
||||
if (edit) edit.disabled = $("#compose-mode")?.value !== "html" || !state.composeSelectedImage;
|
||||
updateInlineImageActions();
|
||||
}
|
||||
|
||||
function inlineImageAlignment(image) {
|
||||
@@ -688,6 +1215,7 @@ function applyInlineImageDisplay(image, widthPercent, alignment) {
|
||||
image.style.marginLeft = "0";
|
||||
image.style.marginRight = "0";
|
||||
}
|
||||
updateInlineImageActions();
|
||||
}
|
||||
|
||||
function imageCropValues() {
|
||||
@@ -843,26 +1371,11 @@ async function applyInlineImageEditor(event) {
|
||||
|
||||
function scheduleUnusedInlineAttachmentCleanup() {
|
||||
clearTimeout(state.inlineImageCleanupTimer);
|
||||
state.inlineImageCleanupTimer = setTimeout(cleanupUnusedInlineAttachments, 3500);
|
||||
state.inlineImageCleanupTimer = setTimeout(syncComposeInlineAttachments, 250);
|
||||
}
|
||||
|
||||
async function cleanupUnusedInlineAttachments() {
|
||||
if (!state.composeDraftId || !state.inlineAttachmentIds.size) return;
|
||||
const used = new Set($$("#compose-editor img[data-rustbox-cid]").map((image) => String(image.dataset.rustboxCid || "")).filter(Boolean));
|
||||
const unused = [...state.inlineAttachmentIds].filter((id) => !used.has(String(id)));
|
||||
if (!unused.length) return;
|
||||
let changed = false;
|
||||
for (const id of unused) {
|
||||
try {
|
||||
await request(`/drafts/${encodeURIComponent(state.composeDraftId)}/attachments/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
state.composeAttachments = state.composeAttachments.filter((attachment) => String(attachment.id) !== String(id));
|
||||
state.inlineAttachmentIds.delete(String(id));
|
||||
changed = true;
|
||||
} catch (error) {
|
||||
console.warn("Could not remove unused inline attachment", id, error);
|
||||
}
|
||||
}
|
||||
if (changed) renderComposeAttachments();
|
||||
syncComposeInlineAttachments();
|
||||
}
|
||||
|
||||
function editorRangeFromPoint(x, y) {
|
||||
@@ -890,28 +1403,65 @@ async function uploadInlineImage(files, insertionRange = null) {
|
||||
const uploaded = await uploadComposeFiles(images);
|
||||
if (!uploaded.length) return;
|
||||
setComposeMode("html");
|
||||
const editor = $("#compose-editor");
|
||||
restoreEditorRange(insertionRange, editor);
|
||||
const draftId = ensureDraftId();
|
||||
let lastInsertedId = null;
|
||||
let range = insertionRange;
|
||||
for (const attachment of uploaded) {
|
||||
const previewUrl = composeAttachmentContentUrl(draftId, attachment.id);
|
||||
state.inlineAttachmentIds.add(String(attachment.id));
|
||||
lastInsertedId = String(attachment.id);
|
||||
const html = `<img src="${escapeHtml(previewUrl)}" data-rustbox-cid="${escapeHtml(attachment.id)}" data-rustbox-align="inline" alt="${escapeHtml(attachment.filename || "image")}" style="max-width:100%;height:auto">`;
|
||||
document.execCommand("insertHTML", false, html);
|
||||
}
|
||||
if (lastInsertedId) {
|
||||
const inserted = [...editor.querySelectorAll("img[data-rustbox-cid]")].reverse().find((image) => String(image.dataset.rustboxCid) === lastInsertedId);
|
||||
if (inserted) setSelectedInlineImage(inserted);
|
||||
insertComposeAttachment(attachment, range);
|
||||
range = captureEditorRange();
|
||||
}
|
||||
syncComposeInlineAttachments();
|
||||
scheduleDraftSave();
|
||||
}
|
||||
|
||||
async function openImapDraftComposer(folder, uid) {
|
||||
const draft = await request(`/messages/${encodeURIComponent(folder)}/${uid}/draft-compose`, { method: "GET" });
|
||||
openCompose("new");
|
||||
state.composeSignatureGeneration += 1;
|
||||
state.composeDraftId = ensureDraftId();
|
||||
state.composeRemoteDraft = draft.imap_source || { folder, uid, uid_validity: 0 };
|
||||
state.composeAttachments = mergeComposeAttachments(draft.attachments);
|
||||
state.inlineAttachmentIds = new Set();
|
||||
setSelectedInlineImage(null);
|
||||
state.composeReplyMeta = {
|
||||
inReplyTo: draft.in_reply_to || null,
|
||||
references: draft.references || null,
|
||||
};
|
||||
$("#compose-to").value = draft.to || "";
|
||||
$("#compose-cc").value = draft.cc || "";
|
||||
$("#compose-bcc").value = draft.bcc || "";
|
||||
$("#compose-subject").value = draft.subject || "";
|
||||
if (draft.from_identity_id && $("#compose-from")) $("#compose-from").value = String(draft.from_identity_id);
|
||||
$("#compose-priority").value = ["high", "normal", "low"].includes(draft.priority) ? draft.priority : "normal";
|
||||
$("#compose-mdn").checked = Boolean(draft.request_mdn);
|
||||
$("#compose-pgp-mode").value = "none";
|
||||
$("#compose-pgp-inline").checked = false;
|
||||
$("#compose-pgp-attach-key").checked = false;
|
||||
$("#compose-pgp-attach-recipient-keys").checked = false;
|
||||
$("#compose-pgp-passphrase").value = "";
|
||||
const identity = state.identities.find((item) => String(item.email || "").trim().toLowerCase() === String(draft.from_address || "").trim().toLowerCase());
|
||||
if (identity && $("#compose-from")) $("#compose-from").value = String(identity.id);
|
||||
updateComposePgpUi();
|
||||
|
||||
const mode = draft.compose_format === "html" && draft.html_body ? "html" : "text";
|
||||
setComposeMode(mode);
|
||||
const editor = $("#compose-editor");
|
||||
if (mode === "html") editor.innerHTML = hydrateComposeHtml(draft.html_body || "", state.composeAttachments, state.composeDraftId);
|
||||
else editor.textContent = draft.text_body || "";
|
||||
renderComposeAttachments();
|
||||
state.composeDirty = false;
|
||||
state.composeNeedsRemoteSync = false;
|
||||
clearLocalDraftSnapshot(state.composeDraftId);
|
||||
updateComposeDraftDeleteAction();
|
||||
$("#compose-title").textContent = trText("Draft");
|
||||
$("#compose-status").textContent = trText("Loaded from IMAP Drafts");
|
||||
updateComposeMessageSize();
|
||||
setTimeout(() => editor.focus(), 20);
|
||||
}
|
||||
|
||||
function applyRecoveredDraft(draft, sourceLabel = "Saved draft") {
|
||||
state.composeSignatureGeneration += 1;
|
||||
state.composeDraftId = draft.id || null;
|
||||
state.composeAttachments = Array.isArray(draft.attachments) ? draft.attachments : [];
|
||||
state.composeRemoteDraft = draft.imap_source || null;
|
||||
state.composeAttachments = mergeComposeAttachments(draft.attachments);
|
||||
state.inlineAttachmentIds = new Set();
|
||||
setSelectedInlineImage(null);
|
||||
state.composeReplyMeta = {
|
||||
@@ -939,6 +1489,7 @@ function applyRecoveredDraft(draft, sourceLabel = "Saved draft") {
|
||||
state.composeDirty = sourceLabel === "Browser recovery";
|
||||
state.composeNeedsRemoteSync = sourceLabel === "Browser recovery";
|
||||
if (state.composeDirty) storeLocalDraftSnapshot(); else clearLocalDraftSnapshot();
|
||||
updateComposeDraftDeleteAction();
|
||||
$("#compose-title").textContent = trText("Recovered draft");
|
||||
$("#compose-status").textContent = `${sourceLabel}${draft.updated_at ? ` · ${draft.updated_at}` : ""}`;
|
||||
setTimeout(() => editor.focus(), 20);
|
||||
@@ -974,7 +1525,17 @@ async function recoverDraft() {
|
||||
const id = selected.slice("local:".length);
|
||||
const local = localDrafts.find((draft) => draft.id === id);
|
||||
if (!local) return toast("Browser recovery snapshot is no longer available", "error");
|
||||
applyRecoveredDraft(local, "Browser recovery");
|
||||
let recovered = local;
|
||||
try {
|
||||
const server = await request(`/drafts/${encodeURIComponent(id)}`, { method: "GET" });
|
||||
recovered = {
|
||||
...local,
|
||||
attachments: mergeComposeAttachments(local.attachments, server.attachments),
|
||||
};
|
||||
} catch (_) {
|
||||
recovered = { ...local, attachments: mergeComposeAttachments(local.attachments) };
|
||||
}
|
||||
applyRecoveredDraft(recovered, "Browser recovery");
|
||||
toast("Recovered unsaved browser draft", "success");
|
||||
return;
|
||||
}
|
||||
@@ -1010,14 +1571,27 @@ function attachmentReminderNeeded() {
|
||||
}
|
||||
|
||||
async function deleteComposeAttachment(id) {
|
||||
const attachment = state.composeAttachments.find((item) => String(item.id) === String(id));
|
||||
if (!attachment) return;
|
||||
if (attachment.source === "imap") {
|
||||
state.composeAttachments = state.composeAttachments.filter((item) => String(item.id) !== String(id));
|
||||
state.inlineAttachmentIds.delete(String(id));
|
||||
if (state.composeSelectedImage?.dataset?.rustboxCid === String(id)) setSelectedInlineImage(null);
|
||||
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]").forEach((node) => {
|
||||
if (String(node.dataset.rustboxCid) === String(id)) node.remove();
|
||||
});
|
||||
renderComposeAttachments();
|
||||
scheduleDraftSave();
|
||||
return;
|
||||
}
|
||||
if (!state.composeDraftId) return;
|
||||
try {
|
||||
await request(`/drafts/${encodeURIComponent(state.composeDraftId)}/attachments/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
state.composeAttachments = state.composeAttachments.filter((a) => a.id !== id);
|
||||
state.inlineAttachmentIds.delete(String(id));
|
||||
if (state.composeSelectedImage?.dataset?.rustboxCid === String(id)) setSelectedInlineImage(null);
|
||||
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid]").forEach((image) => {
|
||||
if (image.dataset.rustboxCid === id) image.remove();
|
||||
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]").forEach((node) => {
|
||||
if (String(node.dataset.rustboxCid) === String(id)) node.remove();
|
||||
});
|
||||
renderComposeAttachments();
|
||||
scheduleDraftSave();
|
||||
@@ -1058,6 +1632,23 @@ function toggleComposeFullscreen() {
|
||||
setComposeFullscreen(!$("#compose-dialog").classList.contains("compose-fullscreen"));
|
||||
}
|
||||
|
||||
function setComposeFieldsCollapsed(collapsed) {
|
||||
const dialog = $("#compose-dialog");
|
||||
const button = $("#compose-fields-toggle");
|
||||
if (!dialog || !button) return;
|
||||
const isCollapsed = Boolean(collapsed);
|
||||
dialog.classList.toggle("compose-fields-collapsed", isCollapsed);
|
||||
button.setAttribute("aria-expanded", isCollapsed ? "false" : "true");
|
||||
button.textContent = trText(isCollapsed ? "Show fields" : "Hide fields");
|
||||
button.title = trText(isCollapsed ? "Show addressing fields" : "Hide addressing fields");
|
||||
}
|
||||
|
||||
function toggleComposeFields() {
|
||||
const collapse = !$("#compose-dialog").classList.contains("compose-fields-collapsed");
|
||||
setComposeFieldsCollapsed(collapse);
|
||||
if (collapse) $("#compose-editor")?.focus();
|
||||
}
|
||||
|
||||
function openCompose(mode = "new") {
|
||||
if (mode === "forward" && state.prefs.forwardMode === "attachment") {
|
||||
void forwardAsAttachment();
|
||||
@@ -1069,15 +1660,18 @@ function openCompose(mode = "new") {
|
||||
renderComposeIdentities();
|
||||
if (detail && ["reply", "reply-all", "forward"].includes(mode)) selectIdentityForMessage(detail);
|
||||
state.composeDraftId = null;
|
||||
state.composeRemoteDraft = null;
|
||||
state.composeAttachments = [];
|
||||
state.composeSignatureGeneration += 1;
|
||||
state.inlineAttachmentIds = new Set();
|
||||
setSelectedInlineImage(null);
|
||||
setComposeFieldsCollapsed(false);
|
||||
state.composeDirty = false;
|
||||
state.composeNeedsRemoteSync = false;
|
||||
state.composeReplyMeta = null;
|
||||
setComposeUploadProgress(false);
|
||||
renderComposeAttachments();
|
||||
updateComposeDraftDeleteAction();
|
||||
$("#compose-error").classList.add("hidden");
|
||||
$("#compose-status").textContent = "";
|
||||
$("#compose-title").textContent = trText(mode === "reply" ? "Reply" : mode === "reply-all" ? "Reply all" : mode === "forward" ? "Forward" : "New message");
|
||||
@@ -1202,11 +1796,19 @@ async function sendMessage(event) {
|
||||
pgp_passphrase: $("#compose-pgp-passphrase").value || "",
|
||||
pgp_attach_public_key: Boolean($("#compose-pgp-attach-key").checked),
|
||||
pgp_attach_recipient_keys: Boolean($("#compose-pgp-attach-recipient-keys").checked),
|
||||
imap_source: state.composeRemoteDraft ? { ...state.composeRemoteDraft } : null,
|
||||
imap_attachments: state.composeRemoteDraft
|
||||
? state.composeAttachments
|
||||
.filter((attachment) => attachment?.source === "imap")
|
||||
.map((attachment) => ({ id: String(attachment.id), index: Number(attachment.source_index) }))
|
||||
.filter((attachment) => attachment.id && Number.isInteger(attachment.index) && attachment.index >= 0)
|
||||
: [],
|
||||
}),
|
||||
});
|
||||
$("#compose-dialog").close();
|
||||
clearLocalDraftSnapshot(state.composeDraftId);
|
||||
state.composeDraftId = null;
|
||||
state.composeRemoteDraft = null;
|
||||
state.composeAttachments = [];
|
||||
state.inlineAttachmentIds = new Set();
|
||||
setSelectedInlineImage(null);
|
||||
|
||||
Reference in new issue
Block a user