Menu
Public repository / 054e5cda0171bd13facff3c16a38f2048a1052cc
AkurAI-Mail / src/framework/compose-island.js
Latest change 054e5cda0171bd13facff3c16a38f2048a1052cc - OLI-249: compose, settings, admin console and sign-in on the framework; SvelteKit removed by Ólafur Búi Ólafsson
// compose-island.js — the compose window as a framework island.
//
// Recipients use recipient-combobox.js, the body is the vendored framework rich-text island
// (sanitized HTML, toolbar via data-cmd) plus a quote button, attachments are read as base64
// like before. Everything else (send, undo, send later, drafts) lives in the store.
import { effect } from "./vendor/signals.js";
import { hydrate } from "./vendor/islands.js";
import "./vendor/rich-text.js";
import { h, replace } from "./dom.js";
import { icon } from "./icons.js";
import { recipientCombobox } from "./recipient-combobox.js";
const MAX_ATTACHMENTS = 5;
/**
* Reads up to five files as base64 attachments.
* @param {FileList | null} files
* @returns {Promise<import("../lib/webmail/compose.js").ComposeAttachment[]>}
*/
export function readAttachments(files) {
return Promise.all(Array.from(files ?? []).slice(0, MAX_ATTACHMENTS).map((file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve({ name: file.name, type: file.type, size: file.size, data: String(reader.result).split(",", 2)[1] ?? "" });
reader.onerror = reject;
reader.readAsDataURL(file);
})));
}
/**
* @param {HTMLElement} root
* @param {ReturnType<typeof import("./store.js").createStore>} store
*/
export function mountCompose(root, store) {
/** @param {Partial<import("../lib/webmail/compose.js").ComposeState>} patch */
const update = (patch) => store.compose({ ...store.compose(), ...patch });
let latest = store.compose();
effect(() => { latest = store.compose(); });
// Writes from inside the editor effect must not read the draft signal, or the effect would
// re-subscribe to it and rebuild the editor on every keystroke.
/** @param {Partial<import("../lib/webmail/compose.js").ComposeState>} patch */
const updateUntracked = (patch) => store.compose({ ...latest, ...patch });
// ── fields that keep their own DOM across re-renders ──
const to = recipientCombobox({ label: "To", placeholder: "name@example.com", value: () => store.compose().to, onChange: (value) => update({ to: value }), suggestions: store.suggestions });
const cc = recipientCombobox({ label: "Cc", value: () => store.compose().cc, onChange: (value) => update({ cc: value }), suggestions: store.suggestions });
const bcc = recipientCombobox({ label: "Bcc", value: () => store.compose().bcc, onChange: (value) => update({ bcc: value }), suggestions: store.suggestions });
const subject = /** @type {HTMLInputElement} */ (h("input", { placeholder: "Subject", "aria-label": "Subject", oninput: () => update({ subject: subject.value }) }));
effect(() => { const next = store.compose().subject; if (subject.value !== next) subject.value = next; });
const sendLaterAt = /** @type {HTMLInputElement} */ (h("input", { type: "datetime-local", "aria-label": "Send later at", oninput: () => store.sendLaterAt(sendLaterAt.value) }));
effect(() => { const next = store.sendLaterAt(); if (sendLaterAt.value !== next) sendLaterAt.value = next; });
const fileInput = /** @type {HTMLInputElement} */ (h("input", { id: "attachment-input", class: "file-input", type: "file", multiple: true, onchange: async () => { update({ attachments: await readAttachments(fileInput.files) }); } }));
const bodyField = h("div", { class: "body-field" });
const ccRows = h("div", { style: { display: "contents" } });
const notes = h("div", { style: { display: "contents" } });
const actions = h("div", { class: "compose-actions" });
// The editor reloads only when the store replaces the draft (new compose, reply, forward, reset),
// never while typing, so the caret stays put.
effect(() => {
store.composeVersion();
if (!store.composeOpen()) return;
const editor = h("div", { "data-island": "rich-text" },
h("div", { class: "rte-toolbar", role: "toolbar", "aria-label": "Formatting", "data-rt-toolbar": "" },
h("button", { type: "button", "aria-label": "Bold", title: "Bold", "data-cmd": "bold" }, icon("Bold", { size: 16 })),
h("button", { type: "button", "aria-label": "Italic", title: "Italic", "data-cmd": "italic" }, icon("Italic", { size: 16 })),
h("button", { type: "button", "aria-label": "Bulleted list", title: "Bulleted list", "data-cmd": "insertUnorderedList" }, icon("List", { size: 16 })),
h("button", { type: "button", "aria-label": "Numbered list", title: "Numbered list", "data-cmd": "insertOrderedList" }, icon("ListOrdered", { size: 16 })),
h("button", { type: "button", "aria-label": "Link", title: "Link", "data-cmd": "link" }, icon("Link2", { size: 16 })),
h("button", { type: "button", "aria-label": "Quote", title: "Quote", onmousedown: (/** @type {Event} */ event) => event.preventDefault(), onclick: () => { body.focus(); /* execCommand is deprecated but still the only editing API in every browser */ document.execCommand("formatBlock", false, "blockquote"); body.dispatchEvent(new Event("input", { bubbles: true })); } }, icon("Quote", { size: 16 }))),
h("div", { class: "rte-body", "data-rt-editor": "", contenteditable: "true", role: "textbox", "aria-multiline": "true", "aria-label": "Message", "data-placeholder": "Write your message" }),
h("input", { type: "hidden" }));
const body = /** @type {HTMLElement} */ (editor.querySelector("[data-rt-editor]"));
const state = h("script", { type: "application/json", "data-island-state": "" });
state.textContent = JSON.stringify({ html: latest.body });
editor.prepend(state);
editor.addEventListener("rt-change", (/** @type {any} */ event) => { updateUntracked({ body: event.detail, bodyText: body.innerText }); });
replace(bodyField, editor);
hydrate(bodyField);
updateUntracked({ bodyText: body.innerText });
});
effect(() => {
replace(ccRows, store.showCcBcc() ? [h("div", { class: "recipient-row" }, h("span", null, "Cc"), cc), h("div", { class: "recipient-row" }, h("span", null, "Bcc"), bcc)] : []);
});
effect(() => {
const count = store.compose().attachments?.length ?? 0;
replace(notes, [
store.activeSignature() ? h("p", { class: "signature-note" }, "Your saved signature will be appended.") : null,
count ? h("p", { class: "attachment-note" }, icon("Paperclip", { size: 15 }), ` ${count} attachment${count === 1 ? "" : "s"} ready`) : null,
]);
});
effect(() => {
const busy = store.busy();
replace(actions, [
h("div", null,
h("button", { class: "primary-action", disabled: busy, onclick: () => store.send() }, icon("Send", { size: 17 }), " Send"),
h("button", { class: "icon-button", "aria-label": "Attach files", title: "Attach files", onclick: () => fileInput.click() }, icon("Paperclip", { size: 18 })),
fileInput),
h("div", { class: "schedule-row" }, sendLaterAt, h("button", { class: "subtle-button", disabled: busy, onclick: () => store.sendLater() }, icon("Clock3", { size: 15 }), " Send later")),
h("div", null,
h("button", { class: "subtle-button", disabled: busy, onclick: () => store.saveDraft() }, "Save draft"),
h("button", { class: "icon-button", "aria-label": "Discard draft", title: "Discard draft", onclick: () => { store.composeOpen(false); store.resetCompose(); } }, icon("Trash2", { size: 18 }))),
]);
});
const ccToggle = h("button", { class: "subtle-button", onclick: () => store.showCcBcc(!store.showCcBcc()) });
effect(() => { ccToggle.textContent = store.showCcBcc() ? "Hide Cc/Bcc" : "Cc/Bcc"; });
const window = h("div", { class: "compose-window", "aria-label": "Compose message", role: "dialog", "aria-modal": "true" },
h("div", { class: "compose-title" }, h("span", null, icon("PenLine", { size: 18 }), " New message"), h("button", { class: "icon-button inverse", "aria-label": "Close compose window", onclick: () => store.composeOpen(false) }, icon("X", { size: 18 }))),
h("div", { class: "recipient-row" }, h("span", null, "To"), to),
ccRows,
h("div", { class: "compose-subject" }, subject, ccToggle),
bodyField,
notes,
actions,
);
const overlay = h("div", { class: "overlay", role: "presentation" }, window);
effect(() => { replace(root, store.composeOpen() ? overlay : null); });
return { overlay };
}