diff --git a/system/backend/MIME.md b/system/backend/MIME.md index 5af8b8aba4..3e9c6231bf 100644 --- a/system/backend/MIME.md +++ b/system/backend/MIME.md @@ -3,11 +3,17 @@ Discussion on AC media. The frontend is `/mime/` and the `https://mime.ac/` apex; the API is `/api/mime`. `www.mime.ac` redirects to the apex. The main feed is one responsive column of media cards, with contained media, -author handles, comment controls, and up to two recent comment previews. It loads +author-colored handles and avatars, comment controls, and the first comment preview. +Letterboxing uses a slowly drifting, author-tinted checkerboard; reduced motion +keeps it still. It loads 12 posts at a time and preserves the current card and offset across thread visits and orientation changes. Videos play muted inline and pause offscreen. A load-more button supports manual loading and -retries. The feed has no category bar, file composer, or footer. +retries. The feed has no category bar, file composer, or footer. Each post shows its full +MIME type and a bookmark ribbon. Saved favorites live in browser storage as post +IDs; the Saved view rechecks current public visibility and supports removal. +Failed media previews show an explicit fallback link. ZIP tapes remain downloads; +MP4 tapes autoplay muted while visible and pause offscreen. `/mimechan/` preserves old hash links through a redirect, and `/api/mimechan` remains an alias. Existing uploaded files and replies stay in `mimechan`. @@ -169,5 +175,5 @@ navigation/backgrounding; failures retry while the page remains open. Delivery on page exit is best effort. These are client-reported exposure estimates, not measured gaze, unique people, or fraud-resistant metrics. Multiple posts may accrue visible time simultaneously; only one accrues focused time. Thread reading time is -not included. Feed selection is still chronological; these counters do not yet -change ranking. +not included. The feed interleaves paintings, tapes, KidLisp, pieces, and native uploads, +newest first within each kind. These counters do not change ranking. diff --git a/system/backend/tests/mime.test.mjs b/system/backend/tests/mime.test.mjs index 8427b3f75c..fc4c5980dc 100644 --- a/system/backend/tests/mime.test.mjs +++ b/system/backend/tests/mime.test.mjs @@ -88,7 +88,7 @@ test("simultaneous first replies share one thread, with current source metadata" assert.equal(thread.replies.length, 8); const inFeed = body(await get()).recent.find((p) => p.code === paintingThread); assert.equal(inFeed.replies, 8); - assert.deepEqual(inFeed.preview, thread.replies.slice(-2).map(({ name, text }) => ({ name, text }))); + assert.deepEqual(inFeed.preview, thread.replies.slice(0, 1).map(({ name, text }) => ({ name, text }))); assert.ok(thread.replies.every((r) => r.parent === paintingThread && r.board === "image/png")); const board = body(await get({ board: "image/png" })); assert.equal(board.threads[0].op.code, paintingThread); @@ -201,6 +201,12 @@ test("archive pagination is stable and new uploads appear without a sync job", { assert.equal(new Set([...first.threads, ...second.threads].map((t) => t.op.code)).size, 24); const recent = body(await get()); assert.equal(recent.hasMore, true); + for (const kind of ["painting", "tape", "kidlisp", "piece"]) { + assert.ok(recent.recent.some((p) => p.media?.kind === kind), kind + " stays visible during a painting burst"); + } + const next = body(await get({ page: "1" })); + assert.equal(new Set([...recent.recent, ...next.recent].map((p) => p.code)).size, + recent.recent.length + next.recent.length); }); test("conversion changes the tape renderer while preserving its discussion", { skip: !uri }, async () => { diff --git a/system/netlify/functions/mime.mjs b/system/netlify/functions/mime.mjs index 38618e5bb8..433f6b53b2 100644 --- a/system/netlify/functions/mime.mjs +++ b/system/netlify/functions/mime.mjs @@ -137,17 +137,28 @@ function allRoots(activity = true, board) { } async function getIndex(db, posts, page) { - const [boards, recent] = await Promise.all([ + const end = (page + 1) * PAGE_SIZE; + const order = [{ $sort: { when: -1, code: 1 } }, { $limit: end + 1 }]; + const [boards, streams] = await Promise.all([ posts.aggregate([ ...allRoots(false), { $group: { _id: "$board", threads: { $sum: 1 }, bumped: { $max: "$bumped" } } }, { $sort: { bumped: -1, _id: 1 } }, ]).toArray(), - posts.aggregate([...allRoots(false), { $sort: { when: -1, code: 1 } }, - { $skip: page * PAGE_SIZE }, { $limit: PAGE_SIZE + 1 }]).toArray(), + Promise.all([ + ...["painting", "tape", "kidlisp", "piece"].map((kind) => + db.collection(MEDIA_KINDS[kind]).aggregate([...mediaPipeline(kind, {}, false), ...order]).toArray()), + posts.aggregate([{ $match: { parent: null } }, { $project: { "file.data": 0 } }, ...order]).toArray(), + ]), ]); - // The home feed sorts by publication date. Resolve reply counts only for - // this page, rather than looking up activity for the entire media archive. + // Round-robin newest-first streams so a burst of one format cannot bury + // the others. Take enough from each stream to preserve page boundaries. + const mixed = []; + for (let i = 0; mixed.length <= end && streams.some((stream) => i < stream.length); i++) { + for (const stream of streams) if (stream[i]) mixed.push(stream[i]); + } + const recent = mixed.slice(page * PAGE_SIZE, end + 1); + // Resolve activity only for the requested page. const ids = recent.filter((p) => p._media).map((p) => p.code); const activity = ids.length ? await db.collection(MEDIA_THREADS).find({ _id: { $in: ids } }).toArray() : []; const counts = new Map(activity.map((p) => [p._id, p.replies])); @@ -155,8 +166,8 @@ async function getIndex(db, posts, page) { const publicRecent = await publicPosts(db, recent.slice(0, PAGE_SIZE)); await Promise.all(publicRecent.map(async (op) => { if (!op.replies) return; - const tail = await posts.find({ parent: op.code }, NO_DATA).sort({ when: -1, _id: -1 }).limit(2).toArray(); - op.preview = (await publicPosts(db, tail.reverse())).map(({ name, text }) => ({ name, text })); + const first = await posts.find({ parent: op.code }, NO_DATA).sort({ when: 1, _id: 1 }).limit(1).toArray(); + op.preview = (await publicPosts(db, first)).map(({ name, text }) => ({ name, text })); })); return respond(200, { boards: boards.map((b) => ({ board: b._id, threads: b.threads, bumped: b.bumped })), diff --git a/system/public/mime/index.html b/system/public/mime/index.html index 823c22d044..224bcc4fd9 100644 --- a/system/public/mime/index.html +++ b/system/public/mime/index.html @@ -119,17 +119,36 @@ .avatar { display: grid; place-items: center; flex-shrink: 0; width: 38px; height: 38px; border-radius: 8px; background: var(--button); border: 1px solid var(--card-edge); box-shadow: inset 1px 1px 0 var(--shine), 1px 1px 0 var(--shadow); color: var(--ink); font: 22px/1 "YWFTProcessing-Regular", sans-serif; } + .post, .name { --author-light: #a52b72; --author-dark: #ff91c5; --author: var(--author-light); } + @media (prefers-color-scheme: dark) { + .post, .name { --author: var(--author-dark); } + } + .post .meta { background: color-mix(in srgb, var(--author) 9%, var(--card)); border-top: 3px solid var(--author); } + .avatar { background: var(--author); color: var(--card); border-color: var(--author); } .byline { min-width: 0; flex: 1; } - .name { font-weight: 650; color: var(--ink); font-size: 15px; text-decoration: none; } + .name { font-weight: 650; color: var(--author); font-size: 16px; text-decoration: none; } .post time { white-space: nowrap; font-size: 11px; } - .media-label { display: block; color: var(--dim); font-size: 11px; text-decoration: none; } + .media-label { display: block; color: var(--dim); font-size: 12px; text-decoration: none; } .feed-media { width: 100%; aspect-ratio: 1; max-height: 520px; display: grid; place-items: center; grid-template: minmax(0, 1fr) / minmax(0, 1fr); overflow: hidden; background: var(--media); border-block: 1px solid var(--card-edge); } + .save-ribbon { min-width: 44px; padding: 8px; color: var(--author); background: transparent; border: 0; box-shadow: none; } + .save-ribbon svg { width: 20px; height: 26px; fill: transparent; stroke: currentColor; stroke-width: 2; } + .save-ribbon[aria-pressed="true"] svg { fill: currentColor; } + .saved-link { padding: 8px; font-size: 13px; font-weight: 650; } + .mime-type { display: block; width: fit-content; margin-top: 5px; padding: 2px 7px; border: 1px solid color-mix(in srgb, var(--author) 40%, var(--card)); border-radius: 4px; color: var(--author); background: var(--card); font-size: 12px; font-weight: 650; } + .media-error { margin: 20px; padding: 16px; background: var(--card); border-radius: 6px; text-align: center; } + .feed-media { position: relative; isolation: isolate; } + .feed-media::before { content: ""; position: absolute; inset: -32px; z-index: -1; pointer-events: none; + background: repeating-conic-gradient(color-mix(in srgb, var(--author) 18%, var(--media)) 0% 25%, var(--media) 0% 50%) 0 0 / 32px 32px; + animation: checker-drift 12s linear infinite; animation-play-state: paused; } + .post[data-visible-ratio] .feed-media::before { animation-play-state: running; } + @keyframes checker-drift { to { transform: translate(32px, 32px); } } + @media (prefers-reduced-motion: reduce) { .feed-media::before { animation: none; } } .feed-media > * { min-width: 0; min-height: 0; } .feed-media > img, .feed-media > video, .feed-media > iframe.doc { display: block; width: 100%; height: 100%; object-fit: contain; border: 0; } .feed-media > audio { width: calc(100% - 32px); } - .feed-media > pre.textfile { width: 100%; height: 100%; margin: 0; padding: 18px; overflow: auto; font-size: 14px; white-space: pre-wrap; } + .feed-media > pre.textfile { width: 100%; height: 100%; margin: 0; padding: 18px; overflow: auto; font-size: 14px; white-space: pre-wrap; background: var(--card); } .specimen { padding: 18px; font-size: 32px; } .blob { margin: 16px; } .post .body { white-space: pre-wrap; margin: 0; padding: 12px 14px; } @@ -278,6 +297,7 @@
+ saved
@@ -472,7 +492,7 @@ function renderFile(file) { media.title = file.name; } else if (top === "text" || ["application/json", "application/xml", "application/javascript"].includes(file.type)) { media = el("pre", "textfile", "…"); - fetch(file.url).then((r) => r.text()).then((t) => { + fetch(file.url).then((r) => { if (!r.ok) throw new Error("File unavailable"); return r.text(); }).then((t) => { media.textContent = t.length > 8000 ? t.slice(0, 8000) + "\n…[truncated]" : t; }).catch(() => { media.textContent = "file unavailable"; }); } else if (top === "font") { @@ -487,6 +507,16 @@ function renderFile(file) { media.href = file.url; media.download = file.name; } + if (["IMG", "VIDEO", "AUDIO"].includes(media.tagName)) { + media.addEventListener("error", () => { + const fallback = el("div", "media-error"); + fallback.append(el("p", null, "Preview unavailable")); + const link = el("a", null, "Open " + file.name); + link.href = file.url; + fallback.append(link); + media.replaceWith(fallback); + }, { once: true }); + } return media; } @@ -510,9 +540,74 @@ function renderText(text) { return body; } +const SAVED_KEY = "mime-saved-v1"; +let savedCodes; +try { + const stored = JSON.parse(localStorage.getItem(SAVED_KEY) || "[]"); + savedCodes = new Set(Array.isArray(stored) ? stored.filter((code) => typeof code === "string") : []); +} catch { savedCodes = new Set(); } +function syncSavedButtons() { + for (const button of document.querySelectorAll("[data-save-code]")) { + const saved = savedCodes.has(button.dataset.saveCode); + button.setAttribute("aria-pressed", String(saved)); + button.setAttribute("aria-label", saved ? "Remove from saved" : "Save post"); + button.title = saved ? "Remove from saved" : "Save post"; + } +} +function saveRibbon(code) { + const button = el("button", "save-ribbon"); + button.type = "button"; + button.dataset.saveCode = code; + button.setAttribute("aria-pressed", String(savedCodes.has(code))); + button.setAttribute("aria-label", savedCodes.has(code) ? "Remove from saved" : "Save post"); + button.innerHTML = ''; + button.onclick = () => { + const next = new Set(savedCodes); + if (next.has(code)) next.delete(code); else next.add(code); + try { localStorage.setItem(SAVED_KEY, JSON.stringify([...next])); } + catch { button.setAttribute("aria-label", "Could not save: browser storage unavailable"); button.title = "Browser storage unavailable"; return; } + savedCodes = next; + syncSavedButtons(); + }; + return button; +} +async function savedPosts(page, signal) { + const codes = [...savedCodes].reverse(); + const selected = codes.slice(page * 12, (page + 1) * 12); + const results = await Promise.all(selected.map(async (code) => { + try { return await read(API + "?thread=" + encodeURIComponent(code), signal); } + catch (error) { if (signal.aborted) throw error; return { unavailable: code }; } + })); + if (signal.aborted) return; + const back = el("a", "back", "← feed"); back.href = "#/"; + const list = el("div", "feed-list"); + view.replaceChildren(back, el("h2", null, "Saved"), el("p", null, "Saved in this browser."), list); + for (const data of results) { + if (data.unavailable) { + const missing = el("article", "post", "Post unavailable "); + missing.append(saveRibbon(data.unavailable)); list.append(missing); continue; + } + data.op.preview = data.replies.slice(0, 1); + list.append(renderPost(data.op, false, false, undefined, true)); + } + if (!codes.length) list.append(el("p", null, "Tap a post’s ribbon to save it here.")); + view.append(pages(page, codes.length > (page + 1) * 12, "#/saved")); +} +function tintAuthor(node, name) { + // Stable per public name, with separate readable palettes for each theme. + const palette = [["#a52b72", "#ff91c5"], ["#087980", "#7de3d7"], + ["#7042b3", "#c5a1ff"], ["#a84a20", "#ffb18a"], + ["#286d42", "#a6e6b3"], ["#365fc1", "#9ebaff"]]; + let hash = 0; + for (const char of (name || "Anonymous").toLowerCase()) hash = (Math.imul(hash, 31) + char.codePointAt(0)) >>> 0; + const [light, dark] = palette[hash % palette.length]; + node.style.setProperty("--author-light", light); + node.style.setProperty("--author-dark", dark); +} function authorName(name) { const label = name || "Anonymous"; const node = el(label.startsWith("@") ? "a" : "span", "name", label); + tintAuthor(node, label); if (node.tagName === "A") node.href = "https://aesthetic.computer/" + encodeURIComponent(label); return node; } @@ -520,6 +615,7 @@ function renderPost(post, isOp, inThread = false, contextBoard, inFeed = false) const root = !post.parent; const div = el("article", "post" + (root ? " media-post" : "") + (inFeed ? " feed-post" : "")); div.id = post.code; + tintAuthor(div, post.name); const meta = el("div", "meta"); const avatar = el("span", "avatar", (post.name || "?").replace(/^@/, "").slice(0, 1).toLowerCase()); avatar.setAttribute("aria-hidden", "true"); @@ -529,11 +625,14 @@ function renderPost(post, isOp, inThread = false, contextBoard, inFeed = false) const source = el("a", "media-label", post.media ? post.media.kind + (post.media.code ? " " + post.media.code : "") : post.file?.name || "post " + post.code); source.href = post.media?.url || "#/t/" + post.code; byline.append(source); + const mime = el("span", "mime-type", post.file?.type || post.board || "text/plain"); + byline.append(mime); } const time = el("time", null, ago(post.when)); time.dateTime = post.when; time.title = new Date(post.when).toLocaleString(); meta.append(avatar, byline, time); + if (root) meta.append(saveRibbon(post.code)); div.append(meta); if (post.file) { const file = renderFile(post.file); @@ -873,7 +972,7 @@ async function thread(code, kind, signal) { code = data.op.code; if (kind) history.replaceState(null, "", "#/t/" + code); const cachedIndex = feedCache?.nodes.findIndex((node) => node.id === code); - if (cachedIndex >= 0) feedCache.nodes[cachedIndex] = renderPost({ ...data.op, preview: data.replies.slice(-2) }, false, false, undefined, true); + if (cachedIndex >= 0) feedCache.nodes[cachedIndex] = renderPost({ ...data.op, preview: data.replies.slice(0, 1) }, false, false, undefined, true); const back = el("a", "back", "← feed"); back.href = "#/"; view.replaceChildren(back, renderPost(data.op, true, true, data.op.board)); @@ -915,12 +1014,14 @@ async function route() { view.replaceChildren(el("p", null, "loading…")); try { if (!hash) await home(page, signal); + else if (hash === "saved") await savedPosts(page, signal); else if (hash.startsWith("t/")) await thread(hash.slice(2), undefined, signal); else if (hash.startsWith("media/")) { const [, kind, code] = hash.split("/"); await thread(decodeURIComponent(code || ""), kind, signal); } else await board(decodeURIComponent(hash), page, signal); if (signal.aborted) return; + syncSavedButtons(); if (params.get("comment") === "1" && (hash.startsWith("t/") || hash.startsWith("media/"))) { const comment = view.querySelector("#post textarea"); comment?.focus();