AkurAI Build
Menu

Public repository / 4f0b1d374fab4075f2b552b5f250bca5f28afee9

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 };
}