`. The caller must be the stored recipient
+or sender. The default response downloads raw bytes; `&json=1` returns base64
+for the worker bridge, and `&preview=1` returns a bounded PNG thumbnail. All
+responses use `private, no-store`. Invalid images remain downloadable.
+
+Validation (no production data or outbound delivery):
+
+```sh
+npm ci --prefix lith --ignore-scripts
+node --experimental-vm-modules spec/mail-privacy-spec.mjs
+node --experimental-vm-modules spec/mail-media-spec.mjs
+```
+
+The media spec exercises real MIME generation and loopback SMTP with an
+isolated in-memory mailbox. Deploying this change requires both lith's web
+process and `lith-mail.service` to reload the changed modules.
diff --git a/system/backend/mail-media.mjs b/system/backend/mail-media.mjs
new file mode 100644
index 0000000000..d9473a4bc3
--- /dev/null
+++ b/system/backend/mail-media.mjs
@@ -0,0 +1,88 @@
+// Mail references public AC media; files arriving by SMTP stay in the letter.
+// Eight MiB of base64 stays below MongoDB's 16 MiB document limit.
+export const MAX_MAIL_FILES = 10;
+export const MAX_MAIL_FILE_BYTES = 8 * 1024 * 1024;
+export const MAX_MAIL_WIRE_BYTES = 12 * 1024 * 1024;
+const RASTER = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
+const KINDS = { "#": "painting", "!": "tape", "$": "kidlisp" };
+const COLLECTIONS = { painting: "paintings", tape: "tapes", kidlisp: "kidlisp" };
+const PUBLIC = {
+ nuked: { $ne: true }, deleted: { $ne: true }, private: { $ne: true },
+ hidden: { $ne: true }, draft: { $ne: true }, visibility: { $in: [null, "public"] },
+};
+
+export function mediaCodes(text) {
+ const found = new Map();
+ // Match bare chat codes and canonical AC URLs, but not URL fragments on
+ // unrelated sites, email local-parts, or pieces of longer words.
+ const rx = /(?:^|[\s(\[])((?:https:\/\/aesthetic\.computer\/)?([#!$])([a-zA-Z0-9]{3,64}))(?=$|[\s)\].,;:?])/g;
+ for (const match of (text || "").matchAll(rx)) {
+ const label = match[2] + match[3];
+ found.set(label, { kind: KINDS[match[2]], code: match[3], label });
+ if (found.size === 10) break;
+ }
+ return [...found.values()];
+}
+
+export async function resolveMailMedia(text, database) {
+ const refs = await Promise.all(mediaCodes(text).map(async (ref) => {
+ const record = await database.db.collection(COLLECTIONS[ref.kind]).findOne(
+ { ...PUBLIC, code: ref.code }, { projection: { _id: 1 } },
+ );
+ if (!record) return null; // An ordinary hashtag stays ordinary text.
+ return {
+ ...ref, path: ref.kind === "painting" ? `painting${ref.label}` : ref.kind === "tape" ? `video~${ref.label}` : ref.label,
+ url: `https://aesthetic.computer/${ref.label}`,
+ ...(ref.kind === "painting" ? { preview: `/media/paintings/${ref.code}.png` } : {}),
+ };
+ }));
+ return refs.filter(Boolean);
+}
+
+const escapeHTML = (text) => String(text).replace(/[&<>"']/g, (c) => ({
+ "&": "&", "<": "<", ">": ">", '"': """, "'": "'",
+})[c]);
+
+export function outsideMediaBody(text, media) {
+ if (!media.length) return { text };
+ return {
+ text: `${text}\n\n${media.map((m) => `${m.label}: ${m.url}`).join("\n")}`,
+ html: `${escapeHTML(text)}
` + media.map((m) =>
+ `${m.preview
+ ? `})
`
+ : ""}${escapeHTML(m.label)}
`).join(""),
+ };
+}
+
+export function incomingAttachments(files = []) {
+ let total = 0;
+ const tooLarge = () => Object.assign(new Error("At most 10 files and 8 MiB of attachments per letter"), { responseCode: 552 });
+ if (files.length > MAX_MAIL_FILES) throw tooLarge();
+ return files.map((file, index) => {
+ const bytes = file.content;
+ total += bytes.length;
+ if (total > MAX_MAIL_FILE_BYTES) throw tooLarge();
+ const name = String(file.filename || `file-${index + 1}`)
+ .split(/[\\/]/).pop().replace(/[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]/g, "").slice(0, 120) || `file-${index + 1}`;
+ const declared = String(file.contentType || "").toLowerCase();
+ const type = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(declared)
+ ? declared : "application/octet-stream";
+ return { name, type, size: bytes.length, data: bytes.toString("base64") };
+ });
+}
+
+export function attachmentList(files = []) {
+ return files.map(({ name, type, size }, index) => ({ index, name, type, size, image: RASTER.has(type) }));
+}
+
+export async function attachmentThumbnail(file) {
+ if (!RASTER.has(file.type)) return null;
+ try {
+ const sharp = (await import("sharp")).default;
+ const data = await sharp(Buffer.from(file.data, "base64"), { limitInputPixels: 20_000_000 })
+ .resize(320, 240, { fit: "inside", withoutEnlargement: true }).png().toBuffer();
+ return { type: "image/png", data: data.toString("base64") };
+ } catch {
+ return null; // A bad image is still downloadable; never log its contents.
+ }
+}
diff --git a/system/backend/mail.mjs b/system/backend/mail.mjs
index b3a9c802c2..faf1b37d42 100644
--- a/system/backend/mail.mjs
+++ b/system/backend/mail.mjs
@@ -1,7 +1,5 @@
// mail, 26.09.11
-// Internal AC mail. Nothing leaves the wall yet, so there is no SMTP in here —
-// a message is a row in `tells`, the collection `tell` has been filling since
-// 26.04 with nothing on the other end to read it.
+// AC mail: internal letters and the inbound/outbound SMTP bridge share `tells`.
//
// Addressing: a message is filed against the recipient's `sub`, never a handle,
// so a rename can't orphan a thread. Two spellings reach a person — their
@@ -13,6 +11,7 @@ import { filter } from "./filter.mjs";
import { shell } from "./shell.mjs";
import { sendToUser } from "../../shared/push.mjs";
import { letterNotification, mailErrorCode, quietMailPush } from "../../shared/mail-privacy.mjs";
+import { resolveMailMedia, outsideMediaBody } from "./mail-media.mjs";
// Since 26.09.13 the root domain is the address: Google Workspace holds its
// MX, catches every unknown @aesthetic.computer, and hands the letter to
@@ -134,7 +133,7 @@ export async function deliver(
// letter twice — scoped to the box, so nobody can pre-empt another's letter.
let dedupeIndexed = false;
export async function deliverFromOutside(
- { to, fromEmail, fromName, subject, text, messageId, auth = null, quiet = false },
+ { to, fromEmail, fromName, subject, text, messageId, auth = null, quiet = false, attachments = [] },
database,
) {
const tells = await mailbox(database);
@@ -166,6 +165,7 @@ export async function deliverFromOutside(
...(messageId ? { messageId } : {}),
...(auth ? { auth } : {}),
via: "smtp",
+ ...(attachments.length ? { attachments } : {}),
when,
read: false,
}));
@@ -226,7 +226,10 @@ export async function sendOutside({ from, toEmail, subject, text }, database) {
replyTo: home,
to: toEmail,
subject: subject || `a letter from ${fromHandle}`,
- text: `${text}\n\n— ${fromHandle}, via aesthetic.computer mail · reply to ${home}`,
+ ...outsideMediaBody(
+ `${text}\n\n— ${fromHandle}, via aesthetic.computer mail · reply to ${home}`,
+ await resolveMailMedia(text, database),
+ ),
};
let info;
try {
diff --git a/system/netlify/functions/mail.mjs b/system/netlify/functions/mail.mjs
index ccaee6af8f..ad6963e9fc 100644
--- a/system/netlify/functions/mail.mjs
+++ b/system/netlify/functions/mail.mjs
@@ -9,7 +9,8 @@
import { authorize } from "../../backend/authorization.mjs";
import { connect } from "../../backend/database.mjs";
-import { respond } from "../../backend/http.mjs";
+import { respond as httpRespond } from "../../backend/http.mjs";
+import { attachmentList, attachmentThumbnail, resolveMailMedia } from "../../backend/mail-media.mjs";
import {
addressesFor,
clean,
@@ -23,6 +24,8 @@ import { ObjectId } from "mongodb";
import { mailErrorCode } from "../../../shared/mail-privacy.mjs";
const PAGE = 50;
+const respond = (status, body, headers = {}) => httpRespond(status, body, { "Cache-Control": "private, no-store", ...headers });
+const NO_FILES = { projection: { "attachments.data": 0 } };
export async function handler(event) {
try {
@@ -48,6 +51,32 @@ async function handleMail(event) {
const tells = await mailbox(database);
if (event.httpMethod === "GET") {
+ const query = event.queryStringParameters || {};
+ if (query.attachment !== undefined) {
+ if (!/^[a-f\d]{24}$/i.test(query.id || "") || !/^\d{1,2}$/.test(query.attachment)) {
+ return respond(400, { message: "Invalid attachment" });
+ }
+ // Authorize against the letter, never just a guessable file index.
+ const letter = await tells.findOne({
+ _id: new ObjectId(query.id), $or: [{ to: user.sub }, { from: user.sub }],
+ }, { projection: { attachments: 1 } });
+ const file = letter?.attachments?.[Number(query.attachment)];
+ if (!file) return respond(404, { message: "Attachment not found" });
+ if (query.preview !== undefined) {
+ const preview = await attachmentThumbnail(file);
+ return preview ? respond(200, preview) : respond(404, { message: "Preview unavailable" });
+ }
+ if (query.json !== undefined) return respond(200, { name: file.name, type: file.type, data: file.data });
+ return {
+ ...respond(200, file.data, {
+ "Content-Type": "application/octet-stream",
+ "Content-Disposition": `attachment; filename="file"; filename*=UTF-8''${encodeURIComponent(file.name).replace(/'/g, "%27")}`,
+ "X-Content-Type-Options": "nosniff",
+ "Content-Security-Policy": "sandbox",
+ }),
+ isBase64Encoded: true,
+ };
+ }
// `?count` is the cheap one — the prompt asks it on every boot just to
// know whether to draw the envelope.
if (event.queryStringParameters?.count !== undefined) {
@@ -59,16 +88,23 @@ async function handleMail(event) {
}
const [inbox, sent, unread, addresses] = await Promise.all([
- tells.find({ to: user.sub }).sort({ when: -1 }).limit(PAGE).toArray(),
- tells.find({ from: user.sub }).sort({ when: -1 }).limit(PAGE).toArray(),
+ tells.find({ to: user.sub }, NO_FILES).sort({ when: -1 }).limit(PAGE).toArray(),
+ tells.find({ from: user.sub }, NO_FILES).sort({ when: -1 }).limit(PAGE).toArray(),
tells.countDocuments({ to: user.sub, read: { $ne: true } }),
addressesFor(user.sub, database),
]);
+ const media = new Map();
+ // Cache repeated text within this request, and recheck media visibility
+ // on each inbox read rather than persisting public preview URLs.
+ const references = (text) => {
+ if (!media.has(text)) media.set(text, resolveMailMedia(text, database));
+ return media.get(text);
+ };
return respond(200, {
addresses,
unread,
- inbox: inbox.map((m) => ({
+ inbox: await Promise.all(inbox.map(async (m) => ({
id: m._id,
from: m.fromHandle,
fromEmail: m.fromEmail || null, // set when the letter came from outside
@@ -77,15 +113,19 @@ async function handleMail(event) {
text: m.text,
when: m.when,
read: m.read === true,
- })),
- sent: sent.map((m) => ({
+ attachments: attachmentList(m.attachments),
+ media: await references(m.text),
+ }))),
+ sent: await Promise.all(sent.map(async (m) => ({
id: m._id,
to: m.toHandle,
toEmail: m.toEmail || null, // set when the letter left the wall
subject: m.subject || null,
text: m.text,
when: m.when,
- })),
+ attachments: attachmentList(m.attachments),
+ media: await references(m.text),
+ }))),
});
}
diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs
index 41a43bc728..49a08c3e98 100644
--- a/system/public/aesthetic.computer/bios.mjs
+++ b/system/public/aesthetic.computer/bios.mjs
@@ -22047,6 +22047,20 @@ async function boot(parsed, bpm = 60, resolution, debug) {
// Downloads both cached files via `data` and network stored files for
// users and guests.
async function receivedDownload({ filename, data, modifiers }) {
+ // Mail attachments are exact bytes, never painting/tape inputs. Keep
+ // filenames out of diagnostics and don't dispatch by their extension.
+ if (modifiers?.private && modifiers?.encoding === "binary" && typeof data === "string") {
+ const bytes = Uint8Array.from(data, (c) => c.charCodeAt(0));
+ const url = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename.split(/[\\/]/).pop();
+ link.rel = "noopener";
+ document.body.appendChild(link);
+ link.click();
+ setTimeout(() => { link.remove(); URL.revokeObjectURL(url); }, 60_000);
+ return;
+ }
console.log("đź’ľ Downloading:", filename);
// if (data) console.log("Data:", typeof data);
// if (modifiers.sharing === true) presharingFile = true;
diff --git a/system/public/aesthetic.computer/disks/common/laklok-tema.mjs b/system/public/aesthetic.computer/disks/common/laklok-tema.mjs
index 3c2ef86b95..1e2ed2c80b 100644
--- a/system/public/aesthetic.computer/disks/common/laklok-tema.mjs
+++ b/system/public/aesthetic.computer/disks/common/laklok-tema.mjs
@@ -256,6 +256,11 @@ const STRINGS = {
nothingSent: "intet sendt endnu",
tryHint: "prøv: mail @jeffrey hej",
composeHint: "enter går ned · tryk på en række",
+ mediaHint: "#maleri · !bånd · $kidlisp",
+ download: "hent",
+ downloading: "henter fil…",
+ downloadFailed: "filen kunne ikke hentes",
+ noPreview: "ingen forhĂĄndsvisning",
whoTo: "til hvem?",
nothingToSay: "intet at sige endnu",
noOne: "ingen svarer pĂĄ",
@@ -278,7 +283,7 @@ const STRINGS = {
error: "fejl",
adTitle: "breve mellem @handles",
adBody:
- "mail er posten på aesthetic.computer — intet forlader computeren. log ind, tag et @handle, og din boks er klar.",
+ "mail er posten på aesthetic.computer — til @handles og emailadresser. log ind, tag et @handle, og din boks er klar.",
adPrompt: "fra enhver prompt: mail @handle dine ord",
signup: "opret dig",
login: "log ind",
@@ -302,6 +307,11 @@ const STRINGS = {
nothingSent: "nothing sent yet",
tryHint: "try: mail @jeffrey hello",
composeHint: "enter moves down · tap a row to jump",
+ mediaHint: "#painting · !tape · $kidlisp",
+ download: "download",
+ downloading: "downloading…",
+ downloadFailed: "couldn't download that file",
+ noPreview: "no preview",
whoTo: "who is it to?",
nothingToSay: "nothing to say yet",
noOne: "no one answers to",
@@ -324,7 +334,7 @@ const STRINGS = {
error: "error",
adTitle: "letters between @handles",
adBody:
- "mail is the post of aesthetic.computer — nothing leaves the computer. log in, take a @handle, and your box is ready.",
+ "mail is the post of aesthetic.computer — to @handles and email addresses. log in, take a @handle, and your box is ready.",
adPrompt: "from any prompt: mail @handle your words",
signup: "sign up",
login: "log in",
diff --git a/system/public/aesthetic.computer/disks/common/mail-media.mjs b/system/public/aesthetic.computer/disks/common/mail-media.mjs
new file mode 100644
index 0000000000..f4a9b3a78a
--- /dev/null
+++ b/system/public/aesthetic.computer/disks/common/mail-media.mjs
@@ -0,0 +1,88 @@
+// Mail-only previews stay in memory; private bytes never enter the public
+// bitmap loader, persistent browser store, or piece diagnostics.
+export class MailMedia {
+ previews = new Map();
+ active = 0;
+ generation = 0;
+
+ clear() {
+ this.generation++;
+ this.previews.clear();
+ }
+
+ layout(api, letter, width, words) {
+ const items = [
+ ...(letter.media || []).map((ref) => ({
+ key: ref.label, label: ref.label, image: !!ref.preview, ref,
+ })),
+ ...(letter.attachments || []).map((file) => ({
+ key: `${letter.id}:${file.index}`, label: `${words.download} ${file.name} (${Math.max(1, Math.ceil(file.size / 1024))} KiB)`,
+ image: file.image, file, id: letter.id,
+ })),
+ ];
+ for (const item of items) {
+ item.face = api.screen.width < 320 || api.screen.height < 220 ? "MatrixChunky8" : undefined;
+ const labelWidth = Math.max(32, width - (item.image ? 110 : 0));
+ const labelH = api.text.box(item.label, undefined, labelWidth, 1, true, item.face).box.height;
+ item.height = Math.max(item.image ? 80 : 14, labelH + 8);
+ }
+ return items;
+ }
+
+ async load(api, item) {
+ if (this.previews.has(item.key) || this.active >= 2) return;
+ const generation = this.generation;
+ this.previews.set(item.key, { loading: true });
+ this.active++;
+ let image;
+ try {
+ if (item.ref) {
+ image = (await api.get.picture(item.ref.preview)).img;
+ } else {
+ const res = await api.net.userRequest("GET", `/api/mail?id=${item.id}&attachment=${item.file.index}&preview=1`);
+ if (res.status !== 200) throw new Error("Preview unavailable");
+ const bytes = Uint8Array.from(atob(res.data), (c) => c.charCodeAt(0));
+ const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/png" }));
+ try {
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
+ const ctx = canvas.getContext("2d");
+ ctx.drawImage(bitmap, 0, 0);
+ image = { width: bitmap.width, height: bitmap.height, pixels: ctx.getImageData(0, 0, bitmap.width, bitmap.height).data };
+ } finally { bitmap.close(); }
+ }
+ } catch { /* Keep the download available even if previewing fails. */ }
+ finally {
+ this.active--;
+ if (generation === this.generation) {
+ this.previews.set(item.key, { image });
+ }
+ api.needsPaint();
+ }
+ }
+
+ paint(api, item, x, y, width, color, words) {
+ if (item.image) {
+ this.load(api, item);
+ const cached = this.previews.get(item.key);
+ if (cached?.image) {
+ const image = cached.image;
+ const scale = Math.min(104 / image.width, 72 / image.height);
+ const w = Math.max(1, Math.floor(image.width * scale));
+ const h = Math.max(1, Math.floor(image.height * scale));
+ api.paste(image, x + Math.floor((104 - w) / 2), y + 4, { width: w, height: h });
+ } else {
+ api.ink(color).write(cached?.loading || !cached ? words.loading : words.noPreview,
+ { x, y: y + 8 }, undefined, 104, true, item.face);
+ }
+ }
+ const labelX = x + (item.image ? 110 : 0);
+ api.ink(color).write(item.label, { x: labelX, y: y + 4 }, undefined, width - (labelX - x), true, item.face);
+ }
+
+ async open(api, item) {
+ if (item.ref) return api.jump(item.ref.path);
+ const res = await api.net.userRequest("GET", `/api/mail?id=${item.id}&attachment=${item.file.index}&json=1`);
+ if (res.status !== 200) throw new Error("Download unavailable");
+ api.download(res.name, atob(res.data), { encoding: "binary", private: true });
+ }
+}
diff --git a/system/public/aesthetic.computer/disks/mail.mjs b/system/public/aesthetic.computer/disks/mail.mjs
index 985253d48c..d2b97dda1e 100644
--- a/system/public/aesthetic.computer/disks/mail.mjs
+++ b/system/public/aesthetic.computer/disks/mail.mjs
@@ -1,7 +1,7 @@
// Mail, 2026.2.12 → 2026.9.14 (`amail` for a day; that path still aliases here)
// The post of aesthetic.computer. `mail @handle words...` sends from the
// prompt; `mail` lands here; `mail~@handle` lands in compose, addressed.
-// Tier 1: nothing leaves aesthetic.computer. See `system/backend/mail.mjs`.
+// Public media codes work like chat; outside attachments stay in the mailbox.
//
// Mail wears the same tema and speaks the same language as `laklok` — one
// saved choice each, one census — so the two rooms read as one house. Its QR
@@ -36,6 +36,12 @@ import {
temaRow,
langRow,
} from "./common/laklok-tema.mjs";
+import { MailMedia } from "./common/mail-media.mjs";
+
+const mediaView = new MailMedia();
+let mediaHits = [];
+let mediaNote = null;
+let downloading = false;
let view = "inbox"; // inbox · sent · prefs · compose
let status = "loading"; // loading, loaded, error, noauth
@@ -112,7 +118,7 @@ function makeFields(api) {
fields = new api.ui.TextFields(
api,
[
- { name: "to", label: s.to, placeholder: "@handle or ac25namuc" },
+ { name: "to", label: s.to, placeholder: "@handle or email" },
{ name: "subject", label: s.re, placeholder: s.optional },
{ name: "body", label: s.say, lines: 4, placeholder: "…" },
],
@@ -137,6 +143,9 @@ async function boot(api) {
view = "inbox";
composeNote = null;
hits = [];
+ mediaView.clear();
+ mediaNote = null;
+ downloading = false;
// đź‘— A colon/`~` token pins a tema or language (`mail~skov`, `mail~da`);
// otherwise the saved ones.
@@ -356,6 +365,7 @@ function paint(api) {
const c = t.chat;
const s = S();
hits = [];
+ mediaHits = [];
wipe(...t.bg);
const x = 6;
@@ -435,6 +445,10 @@ function paint(api) {
ink(c.lines).box(x, y, wide, 1);
y += 6;
+ if (mediaNote) {
+ ink(c.timestamp).write(mediaNote, { x, y }, undefined, wide, true, CHIP_FONT);
+ y += text.box(mediaNote, undefined, wide, 1, true, CHIP_FONT).box.height + 4;
+ }
// Compose sits in the room instead of replacing it — the addresses and tabs
// stay put and the field takes the space the letters were using.
@@ -443,7 +457,7 @@ function paint(api) {
x,
y,
width: wide,
- height: Math.min(110, Math.max(64, screen.height - y - 30)),
+ height: Math.min(110, Math.max(64, screen.height - y - 40)),
};
fields.paint(api, frame);
@@ -457,6 +471,7 @@ function paint(api) {
false,
CHIP_FONT,
);
+ ink(c.timestamp).write(s.mediaHint, { x, y: footer + CHIP_H + 4 }, undefined, wide, true, CHIP_FONT);
paintCorner(api);
paintSettings(api);
return;
@@ -498,7 +513,8 @@ function paint(api) {
if (body.length > most) body = body.slice(0, most) + "…";
}
const h = text.box(body, { x: x + 10, y: 0 }, bounds, 1, true, face).box.height;
- return { letter, body, rowH: h + lh + (compact ? 5 : 8) };
+ const mediaItems = mediaView.layout(api, letter, bounds, s);
+ return { letter, body, mediaItems, textH: h, rowH: h + lh + (compact ? 5 : 8) + mediaItems.reduce((n, item) => n + item.height, 0) };
});
const contentH = measured.reduce((sum, m) => sum + m.rowH, 0);
const maxScroll = Math.max(0, contentH - listH);
@@ -506,7 +522,7 @@ function paint(api) {
mask({ x: 0, y: listTop, width: screen.width, height: listH });
let ly = listTop - scroll;
- measured.forEach(({ letter, body, rowH }, i) => {
+ measured.forEach(({ letter, body, rowH, mediaItems, textH }, i) => {
if (ly + rowH >= listTop && ly < listTop + listH) {
const unread = view === "inbox" && !letter.read;
const who = (view === "inbox" ? letter.from : letter.to) || "someone";
@@ -548,6 +564,15 @@ function paint(api) {
}
ink([...c.timestamp, 160]).write(ago(letter.when), { x: screen.width - x - agoW + 4, y: yy }, undefined, undefined, false, face);
ink(unread ? c.messageText : [...c.messageText, 190]).write(body, { x: x + 10, y: yy + lh }, undefined, bounds, true, face);
+ let mediaY = yy + lh + textH + 2;
+ for (const item of mediaItems) {
+ if (mediaY + item.height > listTop && mediaY < screen.height - 4) {
+ mediaView.paint(api, item, x + 10, mediaY, bounds, c.painting, s);
+ mediaHits.push({ x: x + 10, y: Math.max(listTop, mediaY), w: bounds,
+ h: Math.min(screen.height - 4, mediaY + item.height) - Math.max(listTop, mediaY), item });
+ }
+ mediaY += item.height;
+ }
}
ly += rowH;
});
@@ -761,6 +786,18 @@ function act(api) {
// Tap a letter to answer it — the field opens already addressed. A drag
// that ended on a letter was a scroll, not a tap.
if (e.is("lift") && !dragged && listing) {
+ const media = mediaHits.find((box) => hit(box));
+ if (media) {
+ if (!downloading) {
+ downloading = true;
+ mediaNote = media.item.file ? S().downloading : null;
+ mediaView.open(api, media.item).then(() => { mediaNote = null; })
+ .catch(() => { mediaNote = S().downloadFailed; })
+ .finally(() => { downloading = false; needsPaint(); });
+ }
+ needsPaint();
+ return;
+ }
const row = rows.find((r) => e.y >= r.y0 && e.y < r.y1);
const address = row?.who?.startsWith("@") ? row.who : row?.email;
if (address && e.y >= listTop) {
@@ -770,4 +807,6 @@ function act(api) {
}
}
-export { meta, boot, sim, paint, act };
+function leave() { mediaView.clear(); }
+
+export { meta, boot, sim, paint, act, leave };