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 @@