diff --git a/system/public/aesthetic.computer/disks/common/laklok-tema.mjs b/system/public/aesthetic.computer/disks/common/laklok-tema.mjs index 1e2ed2c80b..e2f88bd1ed 100644 --- a/system/public/aesthetic.computer/disks/common/laklok-tema.mjs +++ b/system/public/aesthetic.computer/disks/common/laklok-tema.mjs @@ -252,6 +252,8 @@ const STRINGS = { write: "skriv", send: "send", markRead: "markรฉr lรฆst", + reply: "svar", + replyingTo: "svar til", noMail: "ingen post endnu", nothingSent: "intet sendt endnu", tryHint: "prรธv: mail @jeffrey hej", @@ -303,6 +305,8 @@ const STRINGS = { write: "write", send: "send", markRead: "mark read", + reply: "reply", + replyingTo: "replying to", noMail: "no mail yet", nothingSent: "nothing sent yet", tryHint: "try: mail @jeffrey hello", diff --git a/system/public/aesthetic.computer/disks/laklok.mjs b/system/public/aesthetic.computer/disks/laklok.mjs index 5b56b3306e..16cf79172c 100644 --- a/system/public/aesthetic.computer/disks/laklok.mjs +++ b/system/public/aesthetic.computer/disks/laklok.mjs @@ -65,8 +65,58 @@ let settingsHits = []; // [{x, y, w, h, action}] chips, rebuilt each paint // ๐Ÿ“ฌ The door to `mail`, beside the gear: an envelope, lit when something is // waiting, with a red count of what's unread. Only a signed-in visitor has a // box, so only they get the door. -let mailCount = null; // { unread, total } once asked; "asking" in flight +let mailCount = null; // { unread, total } once asked let mailBox = null; // hit area, or null when the door isn't drawn +let mailAsking = false; // one count request in flight at a time +let mailAskedAt = 0; // when the last count was asked, ms +let mailAsks = 0; // how many times, for the test hook +// People sit in this room all day, so the count is re-asked on a cadence +// and again when the window comes back into focus. `let` so a test can +// shorten it (see installLakTestHook). +let mailPollMs = 60_000; + +// ๐Ÿงช Test hook โ€” a BroadcastChannel the browser e2e listens on +// (tests/browser/mail-journey.test.mjs), like chat.mjs' own hook but for the +// envelope: one snapshot per resolved count. Only when booted with `?test=1`. +let lakTestChannel = null; +function installLakTestHook(query) { + lakTestChannel?.close(); + lakTestChannel = null; + if (!query?.test || typeof BroadcastChannel === "undefined") return; + lakTestChannel = new BroadcastChannel("ac-mail-test"); + lakTestChannel.onmessage = ({ data }) => { + if (data?.type === "mail-poll" && Number.isFinite(data.every)) { + mailPollMs = Math.max(1000, data.every); + } + }; +} +function tellLakTest() { + lakTestChannel?.postMessage({ + ready: true, + piece: "laklok", + mail: mailCount ? { unread: mailCount.unread || 0, total: mailCount.total || 0 } : null, + asks: mailAsks, + }); +} + +// ๐Ÿ“ฌ Ask whether there's mail waiting โ€” two counts, not the inbox. The badge +// repaints when the count moved. +function askMail($) { + mailAsking = true; + mailAskedAt = Date.now(); + mailAsks += 1; + const settle = (count) => { + mailAsking = false; + const moved = count.unread !== mailCount?.unread || count.total !== mailCount?.total; + mailCount = count; + if (moved) $.needsPaint?.(); + tellLakTest(); + }; + $.net + .userRequest("GET", "/api/mail?count=1") + .then((res) => settle(res?.status === 200 ? res : { unread: 0, total: 0 })) + .catch(() => settle({ unread: 0, total: 0 })); +} // ๐Ÿ”— What counts as a media link โ€” hosts that ARE media plus direct files. // Mirrored verbatim in the vector client; edit both or the sisters drift. @@ -87,7 +137,7 @@ function chatView() { return { ...sys, messages: sys.messages.filter((m) => hasMediaLink(m.text)) }; } -function boot({ api, wipe, debug, send, hud, store, colon, params, jump, net }) { +function boot({ api, wipe, debug, send, hud, store, colon, params, jump, net, query }) { client = new Chat(debug, send); client.connect("clock"); // Connect to 'clock' chat. (DB stays `chat-clock`.) chat.boot(api, client.system); // Use default font @@ -104,6 +154,10 @@ function boot({ api, wipe, debug, send, hud, store, colon, params, jump, net }) lakTV = false; mailCount = null; mailBox = null; + mailAsking = false; + mailAskedAt = 0; + mailAsks = 0; + installLakTestHook(query); let colonLinks = false; // URL `~` separators land in params, `:` in colon โ€” `laer-klokken~tv` // and `laklok:tv` should both reach the same switches, so scan both. @@ -256,7 +310,7 @@ function paintCorner($) { const gy = qrY + Math.floor((qrBoxSize - GEAR) / 2); gearBox = paintGear($, gx, gy, settingsOpen); - if (!mailCount || mailCount === "asking") { mailBox = null; return; } + if (!mailCount) { mailBox = null; return; } const unread = mailCount.unread || 0; const label = unread > 0 ? `${unread}` : null; // The badge overhangs the envelope's top-right corner, so the cluster is @@ -354,6 +408,10 @@ function act($) { return; } + // Back from another tab or window: the count is due now, not next minute. + // (bios forwards window focus; a plain visibilitychange never reaches act.) + if (e.is("focus")) mailAskedAt = 0; + if (settingsOpen && (e.is("touch") || e.is("draw") || e.is("lift"))) { if (e.is("touch")) { const chip = settingsHits.find((h) => hit(h)); @@ -391,17 +449,10 @@ function sim($) { // ๐ŸŒ… The ambient tema drifts a hair each minute; recolor when it does. if (lakTheme === "realtime" && realtimeTick()) chat.refresh(client.system); - // ๐Ÿ“ฌ Ask once whether there's mail waiting โ€” two counts, not the inbox. - // Asked from sim, not boot, because the signed-in user can settle a beat - // after the piece does (the prompt curtain does the same). - if (mailCount === null && $.user && !lakTV) { - mailCount = "asking"; - $.net - .userRequest("GET", "/api/mail?count=1") - .then((res) => { - mailCount = res?.status === 200 ? res : { unread: 0, total: 0 }; - }) - .catch(() => (mailCount = { unread: 0, total: 0 })); + // ๐Ÿ“ฌ Ask whether there's mail waiting โ€” once the signed-in user has settled + // (a beat after boot, like the prompt curtain), then every mailPollMs. + if ($.user && !lakTV && !mailAsking && Date.now() - mailAskedAt >= mailPollMs) { + askMail($); } chat.sim($); @@ -409,6 +460,8 @@ function sim($) { function leave() { client.kill(); + lakTestChannel?.close(); + lakTestChannel = null; } export { boot, paint, act, sim, leave }; diff --git a/system/public/aesthetic.computer/disks/mail.mjs b/system/public/aesthetic.computer/disks/mail.mjs index d2b97dda1e..18e74ed3ec 100644 --- a/system/public/aesthetic.computer/disks/mail.mjs +++ b/system/public/aesthetic.computer/disks/mail.mjs @@ -48,12 +48,13 @@ let status = "loading"; // loading, loaded, error, noauth let mail = null; let prefs = null; // blast subscription + history, fetched when prefs opens let errorMsg = null; -let rows = []; // [{ y0, y1, who }] โ€” paint measures them, act replies to them +let rows = []; // [{ y0, y1, who, email, subject }] โ€” paint measures them, act replies to them let ellipsisTicker; let busy = false; let fields; // to ยท subject ยท body, sharing one keyboard (see lib/type.mjs) let composeNote = null; // what went wrong with the last send, if anything let pendingTo = null; // an address that arrived in the URL, waiting for the box +let replyTo = null; // { who } while compose is an answer to a letter โ€” painted as a note // ๐ŸŽ›๏ธ The compact controls โ€” tabs and chips โ€” register hit boxes each paint, // the way the pane's chips do, instead of carrying Button objects around. @@ -75,6 +76,62 @@ let settingsHits = []; // [{x, y, w, h, action}] chips, rebuilt each paint let qrCells = null; // aesthetic.computer/mail, top-right const QR_PAPER = [226, 238, 255]; // pale blue โ€” laklok's is white +// ๐Ÿงช Test hook โ€” a BroadcastChannel the browser e2e listens on +// (tests/browser/mail-journey.test.mjs), the chat.mjs pattern: a snapshot +// after boot, every refresh, every view change and every send; a few +// messages that do what the controls do. Only when booted with `?test=1`. +let testChannel = null; +let testApi = null; // boot's api, kept for the hook's handlers +function installMailTestHook(api) { + testChannel?.close(); + testChannel = null; + if (!api.query?.test || typeof BroadcastChannel === "undefined") return; + testApi = api; + testChannel = new BroadcastChannel("ac-mail-test"); + testChannel.onmessage = async ({ data }) => { + const api = testApi; + if (!data?.type || !api) return; + if (data.type === "compose") { + if (status === "loaded") { + compose(api, data.to); + fields.values.to = data.to || ""; + fields.values.subject = data.subject || ""; + fields.values.body = data.text || ""; + await send(api, { ...fields.values }); + } + } else if (data.type === "read") { + await markAllRead(api); + } else if (data.type === "reply") { + const letter = mail?.inbox.find((l) => l.id === data.id); + if (letter) answer(api, letter.from, letter.fromEmail, letter.subject); + } else if (data.type === "refresh") { + await refresh(api); + } else if (data.type === "view") { + showView(api, data.value); + } + api.needsPaint(); + tell(); + }; +} +function tell() { + if (!testChannel) return; + const composing = view === "compose"; + testChannel.postMessage({ + ready: true, + piece: "mail", + status, + view, + unread: mail?.unread ?? 0, + total: mail?.inbox?.length ?? 0, + addresses: mail?.addresses ?? [], + inbox: (mail?.inbox ?? []).map(({ id, from, subject, text, read }) => ({ id, from, subject, text, read })), + sent: (mail?.sent ?? []).map(({ id, to, subject, text }) => ({ id, to, subject, text })), + composeTo: composing ? fields.values.to : null, + composeSubject: composing ? fields.values.subject : null, + composeNote, + }); +} + function meta() { return { title: "Mail โ€” aesthetic.computer", @@ -142,7 +199,9 @@ async function boot(api) { settingsOpen = false; view = "inbox"; composeNote = null; + replyTo = null; hits = []; + installMailTestHook(api); mediaView.clear(); mediaNote = null; downloading = false; @@ -170,11 +229,13 @@ async function boot(api) { if (!user) { status = "noauth"; + tell(); return; } await refresh(api); if (pendingTo && status === "loaded") compose(api, pendingTo); + tell(); // The census waits its turn: two authorized requests in flight at once // used to lose one (disk.mjs kept a single pending authorization), and a // lost inbox fetch left boot hanging on the noise forever. @@ -195,6 +256,7 @@ async function refresh({ net }) { status = "error"; errorMsg = "Could not load letters"; } + tell(); } // Post the letter, then show it in `sent`. @@ -233,26 +295,75 @@ async function send(api, { to, subject, body }) { } else { composeNote = s.couldntSend; } + tell(); } // Put the fields away and go somewhere. function leaveCompose(api, to) { view = to; composeNote = null; + replyTo = null; fields.input.mute = true; api.send({ type: "keyboard:close" }); + tell(); } // Open compose, optionally already addressed to someone โ€” a reply lands on the -// subject, since the `to` is already answered. -function compose(api, to) { +// subject, since the `to` is already answered; with the subject answered too +// (`Re: โ€ฆ`), on the body. +function compose(api, to, subject) { view = "compose"; composeNote = null; + replyTo = null; // answer() sets it after fields.input.mute = false; // `focus` syncs the live buffer back into the field it is leaving first, so // the address has to land after the focus has moved off the `to` row. - fields.focus(to ? 1 : 0, api); + fields.focus(subject ? 2 : to ? 1 : 0, api); if (to) fields.values.to = to; + if (subject) fields.values.subject = subject; + tell(); +} + +// Answer a letter: compose to whoever sent it โ€” outside letters go back out +// as email โ€” with the subject carried over under `Re:`. +function answer(api, who, email, subject) { + const address = who?.startsWith("@") ? who : email; + if (!address) return; + const re = subject && !/^re:/i.test(subject) ? `Re: ${subject}` : subject || null; + compose(api, address, re); + replyTo = { who: address }; +} + +// Switch tabs; leaving compose puts the fields away first. +function showView(api, value) { + if (view === "compose") leaveCompose(api, value); + else view = value; + scroll = 0; + if (value === "prefs" && !prefs) { + api.net.userRequest("GET", "/api/mail-status").then((res) => { + if (res.status === 200) prefs = res; + api.needsPaint(); + }).catch(() => { + api.needsPaint(); + }); + } + tell(); +} + +// The mark-all-read control. +async function markAllRead(api) { + if (busy) return; + busy = true; + try { + const res = await api.net.userRequest("POST", "/api/mail", { action: "read" }); + if (res.status === 200) { + mail.unread = 0; + mail.inbox.forEach((letter) => (letter.read = true)); + } + } catch {} + busy = false; + api.needsPaint(); + tell(); } // ๐Ÿงฎ Sim @@ -453,6 +564,11 @@ function paint(api) { // 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. if (view === "compose") { + if (replyTo) { + ink(c.timestamp).write(s.replyingTo, { x, y }, undefined, undefined, false, CHIP_FONT); + ink(c.handle).write(replyTo.who, { x: x + (s.replyingTo.length + 1) * 4, y }, undefined, undefined, false, CHIP_FONT); + y += 10; + } const frame = { x, y, @@ -530,11 +646,13 @@ function paint(api) { // `email` off the row for that. const email = view === "inbox" ? letter.fromEmail : letter.toEmail; const yy = ly + 3; + const { pen } = api; + const hot = !!pen && pen.y >= Math.max(ly, listTop) && pen.y < ly + rowH; // Stripe the row behind everything, so a long message stays one block โ€” // the tema's stripes, unread rows on the brighter one. ink(unread ? t.stripeB : i % 2 ? t.stripeA : [...t.stripeA, 110]).box(x, ly, wide, rowH); - rows.push({ y0: ly, y1: ly + rowH, who, email }); + rows.push({ y0: ly, y1: ly + rowH, who, email, subject: letter.subject }); if (unread) ink(c.log).box(x + 2, yy + 2, 3, 3); ink(unread ? c.handle : c.timestamp).write(who, { x: x + 8, y: yy }, undefined, undefined, false, face); @@ -553,16 +671,27 @@ function paint(api) { ink([...c.timestamp, 170]).write(s.outside, { x: afterWho, y: yy + smallTint }, undefined, undefined, false, CHIP_FONT); afterWho += s.outside.length * 4 + 6; } - const agoW = (compact ? 4 : 6) * 5 + 4; + // The reply chip sits at the row's right end, the clock beside it โ€” + // the whole row answers the letter, the chip just says so. It wakes + // on the hot row; inbox only, since `sent` rows aren't answers. + let right = screen.width - x; + if (view === "inbox") { + const chipW = chipWidth(s.reply); + right -= chipW; + paintChip(api, right, yy - (compact ? 2 : 1), s.reply, { tint: WRITE, dim: !hot }); + right -= 4; + } + const agoW = cw * 5 + 4; + right -= agoW; if (letter.subject) { // Cut the subject to the room left before the timestamp โ€” `write` // with a bound and no wrap still runs on under the clock. - const room = Math.floor((screen.width - x - agoW - afterWho) / cw); + const room = Math.floor((right - afterWho) / cw); let subject = letter.subject; if (subject.length > room) subject = room > 1 ? subject.slice(0, room - 1) + "โ€ฆ" : ""; ink(unread ? c.painting : [...c.painting, 150]).write(subject, { x: afterWho, y: yy }, undefined, undefined, false, face); } - ink([...c.timestamp, 160]).write(ago(letter.when), { x: screen.width - x - agoW + 4, y: yy }, undefined, undefined, false, face); + ink([...c.timestamp, 160]).write(ago(letter.when), { x: right + 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) { @@ -692,37 +821,15 @@ function act(api) { if (type === "signup") net.signup(); else if (type === "login") net.login(); else if (type === "perma") showPerma = !showPerma; - else if (type === "view") { - if (view === "compose") leaveCompose(api, value); - else view = value; - scroll = 0; - if (value === "prefs" && !prefs) { - net.userRequest("GET", "/api/mail-status").then((res) => { - if (res.status === 200) prefs = res; - needsPaint(); - }).catch(() => { - needsPaint(); - }); - } - } else if (type === "write") { + else if (type === "view") showView(api, value); + else if (type === "write") { if (view === "compose") leaveCompose(api, "inbox"); else compose(api); } else if (type === "send") { fields.sync(); send(api, { ...fields.values }); - } else if (type === "read" && !busy) { - busy = true; - net.userRequest("POST", "/api/mail", { action: "read" }).then((res) => { - if (res.status === 200) { - mail.unread = 0; - mail.inbox.forEach((letter) => (letter.read = true)); - } - busy = false; - needsPaint(); - }).catch(() => { - busy = false; - needsPaint(); - }); + } else if (type === "read") { + markAllRead(api); } else if ((type === "subscribe" || type === "unsubscribe") && !busy) { busy = true; net.userRequest("POST", "/api/mail-status", { action: type }).then((res) => { @@ -783,8 +890,8 @@ 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. + // Tap a letter (or its reply chip โ€” same thing) to answer it: the field + // opens already addressed. A drag that ended on a letter was a scroll. if (e.is("lift") && !dragged && listing) { const media = mediaHits.find((box) => hit(box)); if (media) { @@ -799,14 +906,21 @@ function act(api) { 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) { - compose(api, address); + if (row && e.y >= listTop) { + // A sent letter's row just writes to them again โ€” not a reply. + const address = row.who?.startsWith("@") ? row.who : row.email; + if (view === "inbox") answer(api, row.who, row.email, row.subject); + else if (address) compose(api, address); needsPaint(); } } } -function leave() { mediaView.clear(); } +function leave() { + mediaView.clear(); + testChannel?.close(); + testChannel = null; + testApi = null; +} export { meta, boot, sim, paint, act, leave };