diff --git a/system/backend/mime-media.mjs b/system/backend/mime-media.mjs index aafed5ce2c..9a37e3308d 100644 --- a/system/backend/mime-media.mjs +++ b/system/backend/mime-media.mjs @@ -7,6 +7,7 @@ export const MEDIA_KINDS = { tape: "tapes", piece: "pieces", kidlisp: "kidlisp", + whistlegraph: "whistlegraph-mints", }; export const MEDIA_THREADS = "mime-media-threads"; @@ -22,10 +23,30 @@ const visible = { const nonempty = { $type: "string", $ne: "" }; const literal = (value) => ({ $literal: value }); const pieceExtension = { $toLower: { $ifNull: ["$extension", { $ifNull: ["$ext", "mjs"] }] } }; +const ipfsPattern = /^ipfs:\/\/Qm[1-9A-HJ-NP-Za-km-z]{44}$/; +const origins = { + painting: { name: "Aesthetic Computer", url: "https://aesthetic.computer/" }, + tape: { name: "Aesthetic Computer", url: "https://aesthetic.computer/" }, + piece: { name: "Aesthetic Computer", url: "https://aesthetic.computer/" }, + kidlisp: { name: "KidLisp", url: "https://kidlisp.com/" }, + whistlegraph: { name: "Whistlegraph", url: "https://whistlegraph.app/" }, +}; + +function sourceVisibility(kind) { + if (kind === "whistlegraph") return { ...visible, status: "minted", + artifactUri: { $regex: ipfsPattern }, tokenId: { $type: "string", $regex: /^\d+$/ } }; + return { ...visible, ...(kind === "kidlisp" ? { source: nonempty } : { slug: nonempty }) }; +} + +export function packedMediaUrl(uri) { + if (typeof uri !== "string" || !ipfsPattern.test(uri)) throw new Error("Invalid packed artwork URI"); + return "https://ipfs.aesthetic.computer/ipfs/" + uri.slice(7); +} export function mediaPipeline(kind, match = {}, activity = true) { if (!Object.hasOwn(MEDIA_KINDS, kind)) throw new Error("Unknown media kind"); - const type = kind === "painting" ? literal("image/png") + const packed = kind === "whistlegraph"; + const type = packed ? literal("text/html") : kind === "painting" ? literal("image/png") : kind === "kidlisp" ? literal("text/x-lisp") : kind === "tape" ? { $cond: [ { $and: [{ $eq: ["$mp4Status", "complete"] }, { $ne: [{ $ifNull: ["$mp4Url", ""] }, ""] }] }, @@ -38,17 +59,19 @@ export function mediaPipeline(kind, match = {}, activity = true) { ], default: "text/javascript", } }; const pipeline = [ - { $match: { ...visible, ...(kind === "kidlisp" ? { source: nonempty } : { slug: nonempty }), ...match } }, + { $match: { ...match, ...sourceVisibility(kind) } }, { $project: { _id: 0, code: { $concat: [kind + "_", { $toString: "$_id" }] }, parent: literal(null), board: type, - when: { $ifNull: ["$when", { $toDate: "$_id" }] }, + when: packed ? "$mintedAt" : { $ifNull: ["$when", { $toDate: "$_id" }] }, text: literal(""), _media: { kind: literal(kind), id: { $toString: "$_id" }, code: "$code", - user: "$user", slug: "$slug", size: "$size", + user: "$user", slug: "$slug", size: packed ? "$bytes" : "$size", hasPoster: { $ne: [{ $ifNull: ["$thumbnailUrl", ""] }, ""] }, + ...(packed ? { title: "$title", version: "$version", aspect: "$aspect", + artifactUri: "$artifactUri", coverUri: "$coverUri", tokenId: "$tokenId" } : {}), }, } }, ]; @@ -66,6 +89,8 @@ export function mediaPipeline(kind, match = {}, activity = true) { export function parseMediaThread(code) { if (typeof code !== "string") return null; + const pack = /^whistlegraph_([a-f0-9]{64})$/.exec(code); + if (pack) return { kind: "whistlegraph", id: pack[1] }; const match = /^(painting|tape|piece|kidlisp)_([a-f0-9]{24})$/.exec(code); return match ? { kind: match[1], id: new ObjectId(match[2]) } : null; } @@ -79,14 +104,14 @@ export async function resolveMedia(db, kind, ref) { if (typeof ref !== "string" || !ref) return null; // Prefer a public short code. An ObjectId is the fallback for legacy records. return await mediaThread(db, kind, { code: ref }) || - (/^[a-f0-9]{24}$/.test(ref) ? mediaThread(db, kind, { _id: new ObjectId(ref) }) : null); + (kind === "whistlegraph" && /^[a-f0-9]{64}$/.test(ref) ? mediaThread(db, kind, { _id: ref }) : + /^[a-f0-9]{24}$/.test(ref) ? mediaThread(db, kind, { _id: new ObjectId(ref) }) : null); } export async function sourceRecord(db, code) { const ref = parseMediaThread(code); if (!ref) return null; - return db.collection(MEDIA_KINDS[ref.kind]).findOne({ ...visible, - ...(ref.kind === "kidlisp" ? { source: nonempty } : { slug: nonempty }), _id: ref.id }); + return db.collection(MEDIA_KINDS[ref.kind]).findOne({ ...sourceVisibility(ref.kind), _id: ref.id }); } export async function publicPosts(db, docs) { @@ -101,16 +126,18 @@ export async function publicPosts(db, docs) { const extension = { "image/png": "png", "video/mp4": "mp4", "application/zip": "zip", "text/javascript": "mjs", "text/x-lisp": "lisp", "text/x-lua": "lua", + "text/html": "html", }[doc.board]; const url = `/api/mime?file=${encodeURIComponent(doc.code)}`; const file = media - ? { name: `${media.code || media.id}.${extension}`, type: doc.board, size: media.size ?? null, url, - ...(media.kind === "tape" && media.hasPoster ? { poster: `/api/mime?poster=${encodeURIComponent(doc.code)}` } : {}) } + ? { name: `${media.code || media.id}${media.kind === "whistlegraph" ? "-v" + media.version : ""}.${extension}`, type: doc.board, size: media.size ?? null, url, + ...((media.kind === "tape" && media.hasPoster || media.kind === "whistlegraph" && ipfsPattern.test(media.coverUri || "")) ? { poster: `/api/mime?poster=${encodeURIComponent(doc.code)}` } : {}) } : doc.file ? { name: doc.file.name, type: doc.file.type, size: doc.file.size, url } : null; let original; if (media?.code) { const code = encodeURIComponent(media.code); - original = media.kind === "painting" ? `https://aesthetic.computer/#${code}` + original = media.kind === "whistlegraph" ? packedMediaUrl(media.artifactUri) + : media.kind === "painting" ? `https://aesthetic.computer/#${code}` : media.kind === "tape" ? `https://aesthetic.computer/!${code}` : media.kind === "kidlisp" ? `https://aesthetic.computer/$${code}` : `https://aesthetic.computer/${code}`; @@ -119,9 +146,12 @@ export async function publicPosts(db, docs) { code: doc.code, parent: doc.parent, board: doc.board, name: media || doc.user ? handle : doc.name, text: doc.text, when: doc.when, replies: doc.replies || 0, file, + origin: origins[media?.kind] || { name: "Mime", url: "https://mime.ac/" }, ...(media ? { media: { kind: media.kind, id: media.id, code: media.code || null, url: original || url, author: handle, + ...(media.kind === "whistlegraph" ? { title: media.title, version: media.version, + aspect: media.aspect, packed: true, tokenUrl: `https://teia.art/objkt/${media.tokenId}` } : {}), } } : {}), }; }); @@ -130,6 +160,7 @@ export async function publicPosts(db, docs) { // Resolve storage only from an allowlisted, visible media record. Request // bodies cannot supply external URLs or choose arbitrary collections/buckets. export function mediaFile(kind, record) { + if (kind === "whistlegraph") return { url: packedMediaUrl(record.artifactUri) }; if ((kind === "kidlisp" || kind === "piece") && typeof record.source === "string") { return { text: record.source }; } diff --git a/system/backend/tests/mime.test.mjs b/system/backend/tests/mime.test.mjs index fc4c5980dc..8937637a39 100644 --- a/system/backend/tests/mime.test.mjs +++ b/system/backend/tests/mime.test.mjs @@ -13,6 +13,10 @@ if (uri && !/^mongodb:\/\/(127\.0\.0\.1|localhost|\[::1\])[:/]/.test(uri)) { let client, db, handler; const paintingId = new ObjectId(); const paintingThread = `painting_${paintingId}`; +const packId = "a".repeat(64); +const packThread = `whistlegraph_${packId}`; +const artifactUri = "ipfs://QmfM7Jc8vdju2qCVNJ8ZZc2RQ9ma3QyEjYGXHb9xR5RPfp"; +const coverUri = "ipfs://QmbU6bEoXPXGr42FkoazpfQ4xx1r96MnHsXHTACByfV7S5"; const when = new Date("2026-09-01T12:00:00Z"); const fixture = (code, extra = {}) => ({ code, slug: code, when, ...extra }); const file = (name, type = "image/png") => ({ name, type, data: Buffer.from("fixture").toString("base64") }); @@ -43,6 +47,17 @@ before(async () => { ]); await db.collection("kidlisp").insertOne(fixture("lisp", { source: "(wipe red)", user: "auth0|fixture" })); await db.collection("tape-drafts").insertOne(fixture("never-published")); + const pack = { _id: packId, code: "wgDefen", version: 7, status: "minted", mintedAt: when, + user: "auth0|fixture", title: "Spinning tree", aspect: "2:3", bytes: 642922, + artifactUri, coverUri, tokenId: "885469", nonce: "secret-nonce", signature: "secret-signature", + source: "private-source", threadId: "private-thread" }; + await db.collection("whistlegraph-mints").insertMany([ + pack, + ...["packing", "packed", "ready", "requested", "failed"].map((status, i) => ({ ...pack, _id: String(i).repeat(64), status })), + { ...pack, _id: "b".repeat(64), private: true }, + { ...pack, _id: "c".repeat(64), visibility: "unlisted" }, + { ...pack, _id: "d".repeat(64), artifactUri: "https://evil.example/art.html" }, + ]); }); after(async () => { if (db) await db.dropDatabase(); if (client) await client.close(); }); @@ -61,10 +76,10 @@ test("public media automatically become attributed posts without writes", { skip const response = await get(); assert.equal(response.statusCode, 200); const index = body(response); - assert.equal(index.recent.length, 8); + assert.equal(index.recent.length, 9); assert.equal(index.boards.find((b) => b.board === "image/png").threads, 3); assert.deepEqual(new Set(index.boards.map((b) => b.board)), new Set([ - "image/png", "video/mp4", "application/zip", "text/javascript", "text/x-lua", "text/x-lisp", + "image/png", "video/mp4", "application/zip", "text/javascript", "text/x-lua", "text/x-lisp", "text/html", ])); const painting = index.recent.find((p) => p.code === paintingThread); assert.equal(painting.name, "@artist"); @@ -78,6 +93,36 @@ test("public media automatically become attributed posts without writes", { skip assert.deepEqual(resolved.replies, []); }); +test("Whistlegraph publishes only confirmed packs, with provenance and no mint secrets", { skip: !uri }, async () => { + const response = await get(); + const packs = body(response).recent.filter(p => p.media?.kind === "whistlegraph"); + assert.equal(packs.length, 1); + const [pack] = packs; + assert.equal(pack.code, packThread); + assert.equal(pack.name, "@artist"); + assert.equal(pack.when, when.toISOString()); + assert.deepEqual(pack.origin, { name: "Whistlegraph", url: "https://whistlegraph.app/" }); + assert.equal(pack.media.title, "Spinning tree"); + assert.equal(pack.media.aspect, "2:3"); + assert.equal(pack.media.packed, true); + assert.equal(pack.media.tokenUrl, "https://teia.art/objkt/885469"); + assert.equal(pack.file.name, "wgDefen-v7.html"); + assert.equal(pack.file.type, "text/html"); + assert.equal(pack.file.size, 642922); + assert.doesNotMatch(response.body, /secret-|private-|nonce|signature|threadId|auth0/); + for (const [key, uri] of [["file", artifactUri], ["poster", coverUri]]) { + const result = await get({ [key]: packThread }); + assert.equal(result.statusCode, 302); + assert.equal(result.headers.Location, uri.replace("ipfs://", "https://ipfs.aesthetic.computer/ipfs/")); + } + assert.equal(body(await get({ media: "whistlegraph", code: packId })).op.code, packThread); + await db.collection("whistlegraph-mints").updateOne({ _id: packId }, { $set: { hidden: true } }); + for (const key of ["thread", "file", "poster"]) assert.equal((await get({ [key]: packThread })).statusCode, 404); + assert.equal((await post({ parent: packThread, text: "hidden" })).statusCode, 404); + assert.ok(!body(await get()).recent.some(p => p.code === packThread)); + await db.collection("whistlegraph-mints").updateOne({ _id: packId }, { $unset: { hidden: "" } }); +}); + test("simultaneous first replies share one thread, with current source metadata", { skip: !uri }, async () => { const responses = await Promise.all(Array.from({ length: 8 }, (_, i) => post({ parent: paintingThread, text: `reply ${i}` }))); for (const r of responses) assert.equal(r.statusCode, 200, r.body); @@ -201,7 +246,7 @@ 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"]) { + for (const kind of ["whistlegraph", "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" })); diff --git a/system/netlify/functions/mime.mjs b/system/netlify/functions/mime.mjs index a80edf07ad..7425a82ced 100644 --- a/system/netlify/functions/mime.mjs +++ b/system/netlify/functions/mime.mjs @@ -5,7 +5,7 @@ import { respond as httpRespond } from "../../backend/http.mjs"; import { generateUniqueCode } from "../../backend/generate-short-code.mjs"; import { MEDIA_KINDS, MEDIA_THREADS, mediaPipeline, parseMediaThread, - mediaThread, resolveMedia, sourceRecord, publicPosts, mediaFile, publicMediaUrl, + mediaThread, resolveMedia, sourceRecord, publicPosts, mediaFile, publicMediaUrl, packedMediaUrl, } from "../../backend/mime-media.mjs"; const respond = (status, body, headers = {}) => httpRespond(status, body, { "Cache-Control": "no-store", ...headers }); @@ -127,8 +127,11 @@ async function serveFile(db, posts, code) { async function servePoster(db, code) { const ref = parseMediaThread(code); - if (ref?.kind !== "tape") return respond(404, { error: "poster not found" }); + if (!["tape", "whistlegraph"].includes(ref?.kind)) return respond(404, { error: "poster not found" }); const record = await sourceRecord(db, code); + if (ref.kind === "whistlegraph") return record?.coverUri + ? respond(302, "", { Location: packedMediaUrl(record.coverUri), "X-Content-Type-Options": "nosniff" }) + : respond(404, { error: "poster not found" }); if (!record?.thumbnailUrl) return respond(404, { error: "poster not found" }); return respond(302, "", { Location: publicMediaUrl(record.thumbnailUrl), "X-Content-Type-Options": "nosniff" }); } @@ -155,7 +158,7 @@ async function getIndex(db, posts, page, feedOnly = false) { { $sort: { bumped: -1, _id: 1 } }, ]).toArray(), Promise.all([ - ...["painting", "tape", "kidlisp", "piece"].map((kind) => + ...["whistlegraph", "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(), ]), diff --git a/system/public/mime/index.html b/system/public/mime/index.html index a15b6d5666..4d74665258 100644 --- a/system/public/mime/index.html +++ b/system/public/mime/index.html @@ -129,12 +129,15 @@ .avatar { background: var(--author); color: var(--card); border-color: var(--author); } .byline { min-width: 0; flex: 1; } .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: 12px; text-decoration: none; } + .post time { white-space: nowrap; font-size: 12px; } + .media-label { display: block; color: var(--ink); font-size: 14px; text-decoration: none; } + .media-origin { display: block; color: var(--dim); font-size: 12px; text-decoration: none; } .feed-media { width: 100%; aspect-ratio: 4 / 3; 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); } .feed-media.media-audio { aspect-ratio: auto; min-height: 112px; } + .feed-media.media-pack { aspect-ratio: var(--media-ratio, 2 / 3); + width: min(100%, calc(min(55svh, 600px) * var(--pack-ratio, .666667))); margin-inline: auto; } .feed-media.media-image, .feed-media.media-video { aspect-ratio: var(--media-ratio, 4 / 3); } .feed-media.media-image > img, .feed-media.media-video > video { object-fit: contain; } .media-loading { position: absolute; z-index: 1; left: 50%; top: 50%; transform: translate(-50%, -50%); @@ -154,7 +157,7 @@ .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: 2px; color: var(--dim); font-size: 11px; font-weight: 400; } + .mime-type { display: block; width: fit-content; margin-top: 2px; color: var(--dim); font-size: 12px; font-weight: 400; } .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; @@ -184,6 +187,8 @@ .body .gt { color: var(--green); } .post-actions { display: flex; align-items: center; gap: 10px; padding: 13px 14px 15px; flex-wrap: wrap; } .post-actions .source-button { margin-left: auto; font-size: 13px; } + .post-actions:has(.download-pack) { gap: 8px; } + .post-actions:has(.download-pack) > * { font-size: 12px; } .comment-preview { padding: 0 14px 13px; font-size: 14px; } .comment-preview p { margin: 4px 0; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .comment-preview .name { font-size: inherit; margin-right: 8px; } @@ -626,7 +631,8 @@ function syncPieceFocus() { if (!focused.isConnected || document.hidden || !document.hasFocus()) return; const iframe = el("iframe"); iframe.title = focused.dataset.title; - iframe.setAttribute("sandbox", "allow-scripts allow-same-origin"); + iframe.setAttribute("sandbox", focused.dataset.packed === "true" ? "allow-scripts" : "allow-scripts allow-same-origin"); + iframe.referrerPolicy = "no-referrer"; iframe.setAttribute("allow", "autoplay 'none'; camera 'none'; microphone 'none'"); iframe.addEventListener("load", () => { iframe.dataset.ready = "true"; }, { once: true }); iframe.src = focused.dataset.player; @@ -650,6 +656,7 @@ function syncPieceFocus() { const RUNNABLE = { kidlisp: { prefix: "$", label: "KidLisp $", poster: true }, piece: { prefix: "", label: "piece ", poster: false }, + whistlegraph: { prefix: "", label: "Whistlegraph ", poster: true }, }; function renderRunnable(post) { @@ -662,17 +669,24 @@ function renderRunnable(post) { // a card can never act as the signed-in user. preview.dataset.player = "https://aesthetic.computer/" + address + "?nogap=true&nolabel=true&noauth=true"; preview.dataset.title = kind.label + code + (post.name ? " by " + post.name : ""); + if (post.media.kind === "whistlegraph") { + preview.dataset.packed = "true"; + preview.dataset.player = post.media.url; + preview.dataset.title = (post.media.title || code) + (post.name ? " by " + post.name : ""); + } if (!kind.poster) return preview; const image = el("img"); image.alt = preview.dataset.title; image.loading = "lazy"; image.decoding = "async"; image.addEventListener("load", () => { image.dataset.ready = "true"; }, { once: true }); - image.src = "https://oven.aesthetic.computer/grab/webp/512/512/" + encodeURIComponent(kind.prefix + code); + if (post.media.kind === "whistlegraph" && !post.file.poster) return preview; + image.src = post.media.kind === "whistlegraph" ? post.file.poster + : "https://oven.aesthetic.computer/grab/webp/512/512/" + encodeURIComponent(kind.prefix + code); image.addEventListener("error", () => { image.remove(); const link = el("a", null, "Open " + kind.prefix + code); - link.href = "https://aesthetic.computer/" + address; + link.href = post.media.url || "https://aesthetic.computer/" + address; preview.prepend(link); }, { once: true }); preview.append(image); @@ -868,15 +882,21 @@ function renderPost(post, isOp, inThread = false, contextBoard, inFeed = false) const byline = el("div", "byline"); byline.append(authorName(post.name)); if (root) { - const source = el("a", "media-label", post.media ? post.media.kind + (post.media.code ? " " + post.media.code : "") : post.file?.name || "post " + post.code); + const source = el("a", "media-label", post.media?.title || (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); + if (post.origin) { + const origin = el("a", "media-origin", post.origin.name); + origin.href = post.origin.url; + byline.append(origin); + } 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(); + time.setAttribute("aria-label", time.title); meta.append(avatar, byline, time); if (root) meta.append(saveRibbon(post.code)); div.append(meta); @@ -884,6 +904,13 @@ function renderPost(post, isOp, inThread = false, contextBoard, inFeed = false) const runnable = post.media?.code && Object.hasOwn(RUNNABLE, post.media.kind); const file = runnable ? renderRunnable(post) : renderFile(post.file); const frame = el("div", "feed-media"); + if (runnable && post.media.packed) { + const aspect = ["2:3", "9:16", "1:1", "4:3", "16:9"].includes(post.media.aspect) ? post.media.aspect : "2:3"; + const [width, height] = aspect.split(":").map(Number); + frame.classList.add("media-pack"); + frame.style.setProperty("--media-ratio", `${width} / ${height}`); + frame.style.setProperty("--pack-ratio", width / height); + } if (inFeed && file.tagName === "VIDEO") { file.muted = true; file.loop = true; } if (post.media?.kind === "tape" && post.media.code) frame.dataset.tape = post.media.code; frame.append(file); @@ -926,11 +953,35 @@ function renderPost(post, isOp, inThread = false, contextBoard, inFeed = false) div.append(frame); } if (post.text) div.append(renderText(post.text)); - if (root && !inThread) { + if (root) { const actions = el("div", "post-actions"); - const discuss = el("a", "discussion", "comment" + (post.replies ? " · " + post.replies : "")); - discuss.href = "#/t/" + post.code + "?comment=1"; - actions.append(discuss); + if (!inThread) { + const discuss = el("a", "discussion", "comment" + (post.replies ? " · " + post.replies : "")); + discuss.href = "#/t/" + post.code + "?comment=1"; + actions.append(discuss); + } + if (post.media?.packed) { + const download = el("button", "download-pack", ".html ↓"); + download.setAttribute("aria-label", "Download " + post.file.name); + download.addEventListener("click", async () => { + download.disabled = true; + try { + const response = await fetch(post.file.url); + if (!response.ok) throw new Error("Download failed"); + const url = URL.createObjectURL(new Blob([await response.arrayBuffer()], { type: "text/html" })); + const link = el("a"); link.href = url; link.download = post.file.name; + document.body.append(link); link.click(); link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 60000); + download.textContent = ".html ↓"; + } catch { download.textContent = "Retry .html ↓"; } + finally { download.disabled = false; } + }); + actions.append(download); + if (post.media.tokenUrl) { + const token = el("a", "token-link", "OBJKT ↗"); token.href = post.media.tokenUrl; + actions.append(token); + } + } if (post.media?.url) { const source = el("a", "source-button", "open ↗"); source.href = post.media.url; @@ -938,7 +989,7 @@ function renderPost(post, isOp, inThread = false, contextBoard, inFeed = false) actions.append(source); } div.append(actions); - if (post.preview?.length) { + if (!inThread && post.preview?.length) { const preview = el("div", "comment-preview"); for (const reply of post.preview) { const line = el("p"); diff --git a/system/tests/mime-pack-browser.test.mjs b/system/tests/mime-pack-browser.test.mjs new file mode 100644 index 0000000000..9a6a8b5c37 --- /dev/null +++ b/system/tests/mime-pack-browser.test.mjs @@ -0,0 +1,64 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile, mkdir } from 'node:fs/promises'; +import { chromium } from 'playwright'; + +test('packed Whistlegraphs retain attribution, aspect, isolated interaction and exact HTML downloads', async () => { + const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL, headless: true }); + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, acceptDownloads: true }); + const page = await context.newPage(); + const html = await readFile(new URL('../public/mime/index.html', import.meta.url), 'utf8'); + const artifact = ``; + const post = { code: 'pack', parent: null, name: '@artist', when: '2026-10-05T22:00:00Z', replies: 0, + origin: { name: 'Whistlegraph', url: 'https://whistlegraph.app/' }, + file: { name: 'wgDefen-v7.html', type: 'text/html', url: '/artifact.html' }, + media: { kind: 'whistlegraph', code: 'wgDefen', title: 'Spinning tree', version: 7, aspect: '2:3', packed: true, + url: 'https://ipfs.aesthetic.computer/ipfs/fixture', tokenUrl: 'https://teia.art/objkt/885469' } }; + await context.route('**/*', route => { + const u = new URL(route.request().url()); + if (u.hostname === 'mime.ac' && u.pathname === '/') return route.fulfill({ contentType: 'text/html', body: html }); + if (u.pathname === '/api/mime') return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ recent: [post, { ...post, code: 'pack2' }], hasMore: false }) }); + if (u.pathname === '/ipfs/fixture' || u.pathname === '/artifact.html') return route.fulfill({ contentType: 'text/html', body: artifact }); + return route.fulfill({ status: 200, body: '' }); + }); + try { + await page.goto('https://mime.ac/?ac-automation=1'); + await page.locator('#pack iframe[data-ready]').waitFor(); + assert.equal(await page.locator('#pack .name').textContent(), '@artist'); + assert.equal(await page.locator('#pack .media-label').textContent(), 'Spinning tree'); + assert.equal(await page.locator('#pack .media-origin').textContent(), 'Whistlegraph'); + assert.equal(await page.locator('#pack .mime-type').textContent(), 'text/html'); + assert.equal(await page.locator('#pack time').getAttribute('datetime'), post.when); + assert.equal(await page.locator('#pack iframe').getAttribute('sandbox'), 'allow-scripts'); + assert.equal(await page.locator('#pack .token-link').getAttribute('href'), post.media.tokenUrl); + assert.equal(await page.locator('#pack .piece-preview').evaluate(el => el.inert), true); + const framed = page.frames().find(frame => frame.url() === post.media.url); + assert.equal(await framed.evaluate(() => document.body.dataset.running), 'yes', 'pack scripts run'); + assert.equal(await page.evaluate(() => document.body.dataset.escaped || localStorage.getItem('escape')), null); + for (const viewport of [{ width: 390, height: 844 }, { width: 900, height: 900 }]) { + await page.setViewportSize(viewport); + const box = await page.locator('#pack .media-pack').boundingBox(); + assert.ok(Math.abs(box.width / box.height - 2 / 3) < .01, JSON.stringify(box)); + assert.ok(box.height <= viewport.height * .56, 'preview leaves room for feed metadata'); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + } + await page.locator('#pack .media-toggle').click(); + await page.frameLocator('#pack iframe').getByRole('button', { name: 'Spin tree' }).click(); + await page.frameLocator('#pack iframe').getByRole('button', { name: 'Spinning', exact: true }).waitFor(); + await page.locator('#pack .media-toggle').click(); + const downloaded = page.waitForEvent('download'); + await page.locator('#pack .download-pack').click(); + const download = await downloaded; + assert.equal(download.suggestedFilename(), 'wgDefen-v7.html'); + assert.equal(await readFile(await download.path(), 'utf8'), artifact); + await page.locator('#pack2 .media-pack').evaluate(el => el.scrollIntoView({ block: 'center' })); + await page.locator('#pack2 iframe[data-ready]').waitFor(); + assert.equal(await page.locator('.piece-preview iframe').count(), 1, 'only the visible pack runs'); + if (process.env.MIME_SCREENSHOT_DIR) { + await mkdir(process.env.MIME_SCREENSHOT_DIR, { recursive: true }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.evaluate(() => scrollTo(0, 0)); + await page.screenshot({ path: `${process.env.MIME_SCREENSHOT_DIR}/mime-pack-mobile.png` }); + } + } finally { await context.close(); await browser.close(); } +});