diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 9d5a27d9..d2a138cf 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,3 +1,8 @@ +// Airbnb's own bans, kept: a `no-restricted-syntax` entry below replaces the whole list. +const airbnbRestrictedSyntax = require('eslint-config-airbnb-base/rules/style').rules[ + 'no-restricted-syntax' +]; + module.exports = { env: { browser: true, @@ -62,6 +67,21 @@ module.exports = { "@typescript-eslint/no-shadow": "error" }, overrides: [ + { + // The one join path: a join is `joinEstateRoom`, which files the DM before it sends the join. + files: ['src/**/*.ts', 'src/**/*.tsx'], + excludedFiles: ['src/app/features/estate/joinEstateRoom.ts', 'src/**/*.test.*'], + rules: { + 'no-restricted-syntax': [ + ...airbnbRestrictedSyntax, + { + selector: "CallExpression[callee.property.name='joinRoom']", + message: + 'Join a room through joinEstateRoom (features/estate/joinEstateRoom.ts), the one join path.', + }, + ], + }, + }, { files: ['*.ts'], rules: { diff --git a/config.json b/config.json index ed1bca6b..0ebdf7c3 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,13 @@ { - "defaultHomeserver": 1, - "homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"], + "defaultHomeserver": 0, + "homeserverList": [ + "gamecult.org", + "converser.eu", + "matrix.org", + "mozilla.org", + "unredacted.org", + "xmr.se" + ], "allowCustomHomeservers": true, "featuredCommunities": { "openAsDefault": false, @@ -24,5 +31,10 @@ "dings": { "enabled": false + }, + + "registration": { + "enabled": false, + "url": "" } } diff --git a/docs/mind-feedback-pages.md b/docs/mind-feedback-pages.md index cad15c42..9ff45d08 100644 --- a/docs/mind-feedback-pages.md +++ b/docs/mind-feedback-pages.md @@ -1,5 +1,7 @@ # Mind feedback pages +> The desk reads `gamecult.fork.*` now, not the `org.gamecult.feedback.*` projections named below; see CultLib `contracts/cultnet/governance/fork-card.md` (src/app/features/desk/forkContract.ts). + Design only. No code exists for this; nothing here is built. Drafted by Kiko Self (2026-11-07) with Heimdall's draft rulings on guests and hiding; placement ruled for this repo by Void. Follow-up `eureka-body:follow_up:ratatoskr-mind-feedback-pages`. Every number below is provisional. diff --git a/harness/estate/check.cjs b/harness/estate/check.cjs index 656d532e..e69f5b4a 100644 --- a/harness/estate/check.cjs +++ b/harness/estate/check.cjs @@ -2,12 +2,17 @@ // non-zero when the page or the fake homeserver's log says otherwise. const fs = require('fs'); const fails = []; +let asserted = 0; exports.expect = (label, ok, detail = '') => { + asserted += 1; console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`); if (!ok) fails.push(label); }; -/** Exit code for the drive: 4 when any assertion failed. */ -exports.done = () => (fails.length ? 4 : 0); +/** Exit code for the drive: 4 when any assertion failed, 5 when none ran (a drive that asserts nothing proves nothing). */ +exports.done = () => { + if (fails.length) return 4; + return asserted === 0 ? 5 : 0; +}; /** The fake homeserver's request log (HSLOG is shared with the server process). */ exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n'); /** Bodies of the account-data PUTs for m.direct, in order. */ diff --git a/harness/estate/check.test.ts b/harness/estate/check.test.ts new file mode 100644 index 00000000..97566997 --- /dev/null +++ b/harness/estate/check.test.ts @@ -0,0 +1,35 @@ +// @vitest-environment node +import { createRequire } from 'module'; +import { describe, expect, it, vi } from 'vitest'; + +// A drive that asserts nothing proves nothing: its exit code must say so. +const fresh = () => { + const require = createRequire(import.meta.url); + const path = require.resolve('./check.cjs'); + delete require.cache[path]; + return require('./check.cjs') as { + expect: (label: string, ok: boolean, detail?: string) => void; + done: () => number; + }; +}; + +describe('estate drive exit code', () => { + it('is 5 when no assertion ran', () => { + expect(fresh().done()).toBe(5); + }); + + it('is 0 when assertions ran and all held', () => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const c = fresh(); + c.expect('holds', true); + expect(c.done()).toBe(0); + }); + + it('is 4 when any assertion failed', () => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const c = fresh(); + c.expect('holds', true); + c.expect('breaks', false, 'detail'); + expect(c.done()).toBe(4); + }); +}); diff --git a/src/app/components/message/layout/Bubble.tsx b/src/app/components/message/layout/Bubble.tsx index 066a827f..bd2056b9 100644 --- a/src/app/components/message/layout/Bubble.tsx +++ b/src/app/components/message/layout/Bubble.tsx @@ -30,10 +30,8 @@ type BubbleLayoutProps = { hideBubble?: boolean; /** The sender is the signed-in user: right-aligned, in the accent colour, no avatar. */ own?: boolean; - /** A Mind's pose portrait; it replaces the avatar and sits beside (wide) or inside (phone) the bubble. */ + /** A Mind's pose portrait; it replaces the avatar and nests in a corner of the bubble. */ pose?: ReactNode; - /** On phones the portrait floats on the other side. */ - alt?: boolean; /** Delay in ms before the first-appearance animation; undefined plays none. */ enterDelay?: number; before?: ReactNode; @@ -41,7 +39,7 @@ type BubbleLayoutProps = { }; export const BubbleLayout = as<'div', BubbleLayoutProps>( - ({ hideBubble, own, pose, alt, enterDelay, before, header, children, style, ...props }, ref) => { + ({ hideBubble, own, pose, enterDelay, before, header, children, style, ...props }, ref) => { // `false` is a Mind's message continuing a run: aligned with the portraits, showing none. const poseRow = pose !== undefined; const arrow = !!before && !poseRow && !own; @@ -50,12 +48,7 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>( @@ -67,19 +60,9 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>( - {header && ( -
- {header} -
- )} + {header &&
{header}
} {hideBubble ? ( children ) : ( @@ -89,6 +72,7 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>( css.BubbleContent, arrow && css.BubbleContentArrowLeft, poseRow && css.BubbleContentPose, + pose && css.BubbleContentPortrait, own && css.BubbleContentOwn, entering && css.EnterBubble )} diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 9c6b6635..3037e66b 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -153,39 +153,21 @@ export const BubbleContentArrowLeft = style({ borderTopLeftRadius: 0, }); -// Look of the three-doors mock-up (chat.gamecult.org/mockups/three-doors): a Mind's pose sits -// beside its bubble on wide screens; on phones it floats inside the bubble, overhanging the top -// edge, and the text wraps around its silhouette (shape-outside on the alpha channel). +// A Mind's pose nests in the corner of its bubble, on phones and wide screens alike: the sprite's +// body sits in the bubble's padding and the bubble's edge cuts it, so nothing hangs outside; the +// text wraps around its silhouette (shape-outside, traced from the alpha channel) and the bubble +// grows to hold it. How much of the art the edge may cut comes from pose/nest.ts. +// Displayed width of the whole sprite image; the crop fractions are multiples of it. +export const portraitWidth = createVar(); const PHONE = 'screen and (max-width: 700px)'; +const PORTRAIT = toRem(190); const PHONE_PORTRAIT = 'min(38vw, 160px)'; const PHONE_TEXT_FLOOR = toRem(170); -const PORTRAIT_OVERHANG = 58; -const PORTRAIT = toRem(124); -const POSE_GUTTER = toRem(14); +const BUBBLE_PAD = toRem(12); const BUBBLE_CORNER = toRem(6); const POP = 'cubic-bezier(0.34, 1.7, 0.64, 1)'; const RISE = 'cubic-bezier(0.34, 1.45, 0.64, 1)'; -export const BubbleRowPose = style({ - position: 'relative', - paddingLeft: `calc(${PORTRAIT} + ${POSE_GUTTER})`, - '@media': { - [PHONE]: { - paddingLeft: 0, - }, - }, -}); - -// A row that shows a portrait: room for it to hang above the bubble on phones. -export const BubbleRowPortrait = style({ - minHeight: PORTRAIT, - '@media': { - [PHONE]: { - minHeight: 0, - }, - }, -}); - export const BubbleRowOwn = style({ justifyContent: 'flex-end', }); @@ -199,66 +181,31 @@ export const BubbleHeaderOwn = style({ alignSelf: 'flex-end', }); -// Phone: name and time are one plain line in flow above the bubble, on the side opposite the -// portrait. It is as wide as the full name needs, up to the room beside the portrait; only a name -// longer than that wraps (and then the time drops below it). It is capped by the viewport, not by -// the column: a short bubble's column is narrower than its own name, and a percentage cap there -// truncated names to one letter. -export const BubbleHeaderPose = style({ - '@media': { - [PHONE]: { - alignSelf: 'flex-end', - // The band above the bubble where the portrait overhangs: the header sits at its foot, beside - // the portrait, so the overhang needs no margin of its own. - minHeight: toRem(PORTRAIT_OVERHANG), - display: 'flex', - flexDirection: 'column', - justifyContent: 'flex-end', - margin: `0 ${toRem(14)} ${toRem(2)}`, - width: 'max-content', - maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`, - textAlign: 'right', - }, - }, -}); - -export const BubbleHeaderPoseAlt = style({ - '@media': { - [PHONE]: { - alignSelf: 'flex-start', - textAlign: 'left', - }, - }, -}); - -// A portrait row with no header (nothing to fill the overhang band) keeps the band as a margin. -globalStyle(`${BubbleRowPortrait}:not(:has(.${BubbleHeaderPose}))`, { - '@media': { - [PHONE]: { - marginTop: toRem(PORTRAIT_OVERHANG), - }, - }, -}); - -export const BubbleColumnPose = style({ - position: 'relative', -}); - +// The message body is a flex column, which forms its own block formatting context and would sit +// whole beside the floated portrait; as a plain block its lines wrap around the silhouette and +// then run the bubble's full width below it. export const BubbleContentPose = style({ - borderRadius: config.radii.R500, - borderTopLeftRadius: BUBBLE_CORNER, + display: 'flow-root', + overflow: 'clip', + padding: BUBBLE_PAD, +}); + +// A bubble that shows a portrait is at least portrait + a readable column wide, or the text beside +// it collapses to a strip. +export const BubbleContentPortrait = style({ + vars: { [portraitWidth]: PORTRAIT }, + minWidth: `min(100%, calc(${portraitWidth} + ${PHONE_TEXT_FLOOR}))`, '@media': { [PHONE]: { - display: 'flow-root', - // A bubble with a portrait is at least portrait + a readable column wide, or the text - // beside the portrait collapses to a strip. - minWidth: `min(100%, calc(${PHONE_PORTRAIT} + ${PHONE_TEXT_FLOOR}))`, - borderTopLeftRadius: config.radii.R500, - padding: `${toRem(24)} ${toRem(14)} ${toRem(14)}`, + vars: { [portraitWidth]: PHONE_PORTRAIT }, }, }, }); +globalStyle(`${BubbleContentPose} > div`, { + display: 'block', +}); + export const BubbleContentOwn = style({ backgroundColor: color.Primary.Container, color: color.Primary.OnContainer, @@ -267,51 +214,37 @@ export const BubbleContentOwn = style({ borderTopRightRadius: BUBBLE_CORNER, }); +// The button is the visible part of the sprite (the part inside the bubble's corner); the image +// inside it is offset by the crop. The inline --pose-* fractions (of the image width) come from +// nestView; until the art's size is known the button is simply the whole image. export const PoseButton = style({ - position: 'absolute', - top: 0, - left: `calc(-1 * (${PORTRAIT} + ${POSE_GUTTER}))`, - width: PORTRAIT, - height: PORTRAIT, + float: 'left', + width: `var(--pose-bw, ${portraitWidth})`, + height: 'var(--pose-bh, auto)', + // Out into the padding on the top, bottom and outer side, so the bubble's edge is the crop. + margin: `calc(-1 * ${BUBBLE_PAD}) ${toRem(6)} calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD})`, padding: 0, border: 'none', background: 'none', cursor: 'pointer', lineHeight: 0, + overflow: 'hidden', + shapeMargin: toRem(7), transformOrigin: '50% 90%', - '@media': { - [PHONE]: { - position: 'static', - float: 'left', - width: PHONE_PORTRAIT, - height: 'auto', - margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`, - shapeMargin: toRem(7), - WebkitMaskImage: 'linear-gradient(#000 84%, transparent)', - maskImage: 'linear-gradient(#000 84%, transparent)', - }, - }, }); export const PoseButtonAlt = style({ - '@media': { - [PHONE]: { - float: 'right', - margin: `${toRem(-82)} ${toRem(-12)} 0 ${toRem(8)}`, - }, - }, + float: 'right', + margin: `calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) ${toRem(6)}`, }); export const PosePortrait = style({ - width: '100%', - height: '100%', - objectFit: 'contain', - objectPosition: 'top', - '@media': { - [PHONE]: { - height: 'auto', - }, - }, + display: 'block', + width: portraitWidth, + maxWidth: 'none', + height: 'auto', + marginLeft: 'var(--pose-ix, 0)', + marginTop: 'var(--pose-iy, 0)', }); const pop = keyframes({ @@ -402,61 +335,12 @@ export const MessageTextBody = recipe({ export type MessageTextBodyVariants = RecipeVariants; -// The message content is a flex column, which forms its own block formatting context and so sits -// whole beside the floated portrait: the text stays as narrow as the gap beside it for the -// bubble's full height. As a plain block its lines wrap around the silhouette and then run the -// bubble's full width below it. -globalStyle(`${BubbleContentPose} > div`, { - '@media': { - [PHONE]: { - display: 'block', - }, - }, -}); - -// The name wraps instead of ellipsising: the header already caps its own width, and Username's own -// nowrap + ellipsis, and the truncate on the Text inside it, clipped long names to "Heimdall o…". -globalStyle(`${BubbleHeaderPose} ${Username}, ${BubbleHeaderPose} ${Username} *`, { - '@media': { - [PHONE]: { - whiteSpace: 'normal', - overflow: 'visible', - textOverflow: 'clip', - overflowWrap: 'break-word', - }, - }, -}); - -// A name too long for one line takes its own lines and the time drops below it, instead of the -// name shrinking to a sliver beside the time (the header's one child is the flex row of both). The -// row hugs the side the header sits on, so a dropped time stays under the name's edge. -globalStyle(`${BubbleHeaderPose} > div`, { - '@media': { - [PHONE]: { - flexWrap: 'wrap', - justifyContent: 'flex-end', - }, - }, -}); - -globalStyle(`${BubbleHeaderPoseAlt} > div`, { - '@media': { - [PHONE]: { - justifyContent: 'flex-start', - }, - }, -}); - // An outside list marker hangs left of its item's text and ignores the portrait's silhouette, so // beside a left portrait the number lands under the art. Inside, it is a word on the first line // and wraps around the silhouette like the rest. globalStyle(`${BubbleContentPose} ol, ${BubbleContentPose} ul`, { - '@media': { - [PHONE]: { - listStylePosition: 'inside', - paddingLeft: 0, - }, - }, + listStylePosition: 'inside', + paddingLeft: 0, }); // While the background scene runs (html[data-scene]) a chat bubble is the one filled surface in diff --git a/src/app/components/message/pose/PoseAvatar.tsx b/src/app/components/message/pose/PoseAvatar.tsx index fac585a4..05e60631 100644 --- a/src/app/components/message/pose/PoseAvatar.tsx +++ b/src/app/components/message/pose/PoseAvatar.tsx @@ -1,14 +1,15 @@ import React, { useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import * as css from '../layout/layout.css'; -import { poseSrc } from './poses'; +import { poseSrc, safeBox } from './poses'; +import { alphaBounds, nestView, type Box } from './nest'; import { silhouette } from './silhouette'; type PoseAvatarProps = { mind: string; pose: string; userId: string; - /** Float on the right on phones. */ + /** Nest in the bubble's top-right corner instead of the top-left. */ alt?: boolean; /** Play the first-appearance pop. */ enter?: boolean; @@ -16,40 +17,110 @@ type PoseAvatarProps = { onClick?: React.MouseEventHandler; }; +// Safe boxes derived from art's alpha, by source; the art is traced once. +const traced = new Map(); +const trace = (img: HTMLImageElement, src: string) => { + const hit = traced.get(src); + if (hit) return hit; + const w = img.naturalWidth; + const h = img.naturalHeight; + const ctx = document.createElement('canvas').getContext('2d'); + if (!ctx) return undefined; + ctx.canvas.width = w; + ctx.canvas.height = h; + ctx.drawImage(img, 0, 0); + const box = alphaBounds(ctx.getImageData(0, 0, w, h).data, w, h); + if (!box) return undefined; + const found = { box, size: [w, h] as [number, number] }; + traced.set(src, found); + return found; +}; + export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvatarProps) { const src = poseSrc(mind, pose, import.meta.env.BASE_URL); const imgRef = useRef(null); const [shape, setShape] = useState(); + const measured = safeBox(mind, pose); + const [derived, setDerived] = useState<{ box: Box; size: [number, number] }>(); + const art = measured ?? (derived?.size ? derived : undefined); + // Measured boxes are cropped by the share Aqua's numbers allow; a box traced from the alpha is + // already tight to the art, so the bubble may take all of the transparent margin. + const view = art && nestView(art.box, art.size, !!alt, measured ? undefined : 1); - // On phones the bubble text wraps around the portrait's silhouette. Retraced when the art - // loads and when the portrait's width changes with the viewport. + // Art with no measured box: find its extent when it loads. + useEffect(() => { + setDerived(undefined); + const img = imgRef.current; + if (!img || measured) return undefined; + const find = () => { + if (!img.complete || img.naturalWidth === 0) return; + try { + setDerived(trace(img, src)); + } catch { + setDerived(undefined); + } + }; + find(); + img.addEventListener('load', find); + return () => img.removeEventListener('load', find); + }, [src, measured]); + + // The bubble text wraps around the portrait's silhouette. Retraced when the art loads, when its + // crop is known, and when its width changes with the viewport. useEffect(() => { const img = imgRef.current; if (!img) return undefined; - const trace = () => { + const retrace = () => { if (!img.complete || img.naturalWidth === 0) return; try { - setShape(silhouette(img, !alt)); + const box = img.parentElement; + const style = getComputedStyle(img); + setShape( + silhouette( + img, + !alt, + box + ? { + x: 0 - parseFloat(style.marginLeft || '0'), + y: 0 - parseFloat(style.marginTop || '0'), + width: box.clientWidth, + height: box.clientHeight, + } + : undefined + ) + ); } catch { setShape(undefined); } }; - trace(); - img.addEventListener('load', trace); - const resize = new ResizeObserver(trace); + retrace(); + img.addEventListener('load', retrace); + const resize = new ResizeObserver(retrace); resize.observe(img); return () => { - img.removeEventListener('load', trace); + img.removeEventListener('load', retrace); resize.disconnect(); }; - }, [src, alt]); + }, [src, alt, view?.x, view?.y, view?.width, view?.height]); + + // Crop fractions are of the image's width, so one set serves every displayed size. + const frac = (px: number) => `calc(${css.portraitWidth} * ${px / (art?.size[0] ?? 1)})`; + const cropStyle = + art && view + ? ({ + '--pose-bw': frac(view.width), + '--pose-bh': frac(view.height), + '--pose-ix': frac(-view.x), + '--pose-iy': frac(-view.y), + } as React.CSSProperties) + : undefined; const Tag = onClick ? 'button' : 'span'; return ( { + // Aqua neutral: 560x374, safe box [144,22,492,317] (right margin 68, top 22). + it('crops half the right and top margins for a top-right nest', () => { + expect(nestView([144, 22, 492, 317], [560, 374], true)).toEqual({ + x: 0, + y: 11, + width: 526, + height: 363, + }); + }); + + it('crops the left margin instead for a top-left nest', () => { + expect(nestView([144, 22, 492, 317], [560, 374], false)).toEqual({ + x: 72, + y: 11, + width: 488, + height: 363, + }); + }); + + it('never crops the top of art already touching it (smug, headband at y=0)', () => { + const v = nestView([140, 0, 487, 241], [560, 374], true); + expect(v.y).toBe(0); + expect(v.height).toBe(374); + }); + + it('can take the whole margin', () => { + expect(nestView([10, 4, 90, 50], [100, 60], true, 1)).toEqual({ + x: 0, + y: 4, + width: 90, + height: 56, + }); + }); + + it('keeps every measured safe box intact at the default share', () => { + const aqua = boxes.aqua as Record; + expect(Object.keys(aqua)).toHaveLength(15); + Object.values(aqua).forEach(([x0, y0, x1, y1, w, h]) => { + const right = nestView([x0, y0, x1, y1], [w, h], true); + expect(right.x).toBeLessThanOrEqual(x0); + expect(right.x + right.width).toBeGreaterThanOrEqual(x1); + expect(right.y).toBeLessThanOrEqual(y0); + const left = nestView([x0, y0, x1, y1], [w, h], false); + expect(left.x).toBeLessThanOrEqual(x0); + expect(left.x + left.width).toBe(w); + }); + }); +}); + +describe('alphaBounds', () => { + it('finds the opaque rectangle', () => { + const w = 6; + const h = 5; + const data = new Uint8ClampedArray(w * h * 4); + [ + [2, 1], + [4, 3], + ].forEach(([x, y]) => { + data[(y * w + x) * 4 + 3] = 255; + }); + expect(alphaBounds(data, w, h)).toEqual([2, 1, 5, 4]); + }); + + it('reports nothing for an empty image', () => { + expect(alphaBounds(new Uint8ClampedArray(16), 2, 2)).toBeUndefined(); + }); +}); diff --git a/src/app/components/message/pose/nest.ts b/src/app/components/message/pose/nest.ts new file mode 100644 index 00000000..ffee1490 --- /dev/null +++ b/src/app/components/message/pose/nest.ts @@ -0,0 +1,58 @@ +// A pose nests into the corner of its bubble: the sprite's body sits in the bubble's padding and is +// cut off by the bubble's edge, so nothing hangs outside it. + +/** [x0, y0, x1, y1] in image pixels, origin top-left, x1/y1 exclusive. */ +export type Box = [number, number, number, number]; + +// Share of the room between the safe box and the image edge that the bubble edge may cut away. +// Aqua's measurement says all of it can go; half leaves the outline a little air. +export const CROP_SHARE = 0.5; + +export type NestView = { + /** The visible region of the image, in pixels. */ + x: number; + y: number; + width: number; + height: number; +}; + +// The part of the image that shows, for a sprite in the bubble's top-right (`right`) or top-left +// corner. Only the corner's two sides are cropped: the side towards the bubble's edge and the top. +// The far side and the bottom stay whole; the bubble's bottom edge cuts the bust. +export const nestView = ( + safe: Box, + size: [number, number], + right: boolean, + share = CROP_SHARE +): NestView => { + const [w, h] = size; + const [x0, y0, x1] = safe; + const side = Math.round(share * (right ? w - x1 : x0)); + const top = Math.round(share * y0); + const x = right ? 0 : side; + return { x, y: top, width: w - side, height: h - top }; +}; + +// Bounds of the pixels whose alpha exceeds `min`, as a safe box for art with no measured one. +export const alphaBounds = ( + data: ArrayLike, + w: number, + h: number, + min = 8 +): Box | undefined => { + let x0 = w; + let y0 = h; + let x1 = 0; + let y1 = 0; + for (let y = 0; y < h; y += 1) { + for (let x = 0; x < w; x += 1) { + if (data[(y * w + x) * 4 + 3] > min) { + x0 = Math.min(x0, x); + x1 = Math.max(x1, x + 1); + y0 = Math.min(y0, y); + y1 = y + 1; + } + } + } + return x1 > x0 ? [x0, y0, x1, y1] : undefined; +}; diff --git a/src/app/components/message/pose/poses.ts b/src/app/components/message/pose/poses.ts index 69429090..b1f983ea 100644 --- a/src/app/components/message/pose/poses.ts +++ b/src/app/components/message/pose/poses.ts @@ -1,4 +1,6 @@ import catalogue from './catalogue.json'; +import safeBoxes from './safeBoxes.json'; +import type { Box } from './nest'; // Pose labels travel in the event content under POSE_KEY. The catalogue // (cast-art poses/catalogue.json, synced by `npm run sync:poses`) is the only @@ -40,3 +42,14 @@ export const resolvePose = (mind: string, label: unknown): string => { export const poseSrc = (mind: string, pose: string, base = '/'): string => `${base}public/poses/${artPath(mind, pose) ?? artPath(mind, NEUTRAL)}`; +// Safe-crop boxes measured from the art's pixels (Aqua, `aqua-pose-crop-boxes-20261110`): the +// head and headphones plus outline, which the bubble's edge must not cut. [x0, y0, x1, y1, width, +// height] in image pixels. Art with no entry gets its box from the sprite's alpha at load time. +const boxes = safeBoxes as Record>; +export const safeBox = ( + mind: string, + pose: string +): { box: Box; size: [number, number] } | undefined => { + const b = Object.prototype.hasOwnProperty.call(boxes, mind) ? boxes[mind][pose] : undefined; + return b ? { box: [b[0], b[1], b[2], b[3]], size: [b[4], b[5]] } : undefined; +}; diff --git a/src/app/components/message/pose/safeBoxes.json b/src/app/components/message/pose/safeBoxes.json new file mode 100644 index 00000000..eff7011e --- /dev/null +++ b/src/app/components/message/pose/safeBoxes.json @@ -0,0 +1,19 @@ +{ + "aqua": { + "neutral": [144, 22, 492, 317, 560, 374], + "happy": [157, 22, 506, 317, 574, 374], + "amused": [154, 22, 500, 317, 562, 374], + "thinking": [154, 16, 500, 307, 562, 374], + "annoyed": [154, 4, 499, 282, 562, 374], + "surprised": [141, 5, 485, 282, 560, 374], + "concerned": [156, 4, 501, 283, 574, 374], + "fretful": [156, 16, 503, 305, 574, 374], + "weary": [161, 16, 509, 307, 624, 374], + "glare": [160, 5, 508, 282, 624, 374], + "smug": [140, 0, 487, 241, 560, 374], + "sigh": [154, 0, 500, 241, 574, 374], + "charging": [152, 0, 497, 241, 562, 374], + "delighted": [159, 0, 507, 241, 624, 374], + "content": [144, 16, 492, 306, 560, 374] + } +} diff --git a/src/app/components/message/pose/silhouette.test.ts b/src/app/components/message/pose/silhouette.test.ts index 9deb3f4f..ba9d5ecb 100644 --- a/src/app/components/message/pose/silhouette.test.ts +++ b/src/app/components/message/pose/silhouette.test.ts @@ -53,4 +53,22 @@ describe('silhouette', () => { silhouette({ clientWidth: 0, clientHeight: 0 } as HTMLImageElement, true) ).toBeUndefined(); }); + + it('traces only the visible part of a cropped portrait, drawn at its offset', () => { + const drawImage = vi.fn(); + const canvas = { + width: 0, + height: 0, + getContext: () => ({ + canvas, + drawImage, + getImageData: () => ({ data: new Uint8ClampedArray(40 * 8 * 4) }), + }), + }; + vi.stubGlobal('document', { createElement: () => canvas }); + const img = { clientWidth: 100, clientHeight: 20 } as HTMLImageElement; + silhouette(img, true, { x: 0, y: 12, width: 40, height: 8 }); + expect([canvas.width, canvas.height]).toEqual([40, 8]); + expect(drawImage).toHaveBeenCalledWith(img, 0, -12, 100, 20); + }); }); diff --git a/src/app/components/message/pose/silhouette.ts b/src/app/components/message/pose/silhouette.ts index e9596ded..019c0716 100644 --- a/src/app/components/message/pose/silhouette.ts +++ b/src/app/components/message/pose/silhouette.ts @@ -5,14 +5,23 @@ const ROW = 4; // `shape-outside: url()` cannot do this: the browser sizes that image at its natural size and // anchors it at the margin box, which the phone layout's negative top margin shifts down by the // overhang, so the exclusion neither matches the art's size nor its place. -export const silhouette = (img: HTMLImageElement, textOnRight: boolean): string | undefined => { - const w = Math.round(img.clientWidth); - const h = Math.round(img.clientHeight); +// +// `view` is the part of the displayed image that shows (the button's box, in displayed pixels): +// the portrait is cropped by the bubble, so the polygon is traced over what is left of the art. +export type View = { x: number; y: number; width: number; height: number }; + +export const silhouette = ( + img: HTMLImageElement, + textOnRight: boolean, + view?: View +): string | undefined => { + const w = Math.round(view?.width ?? img.clientWidth); + const h = Math.round(view?.height ?? img.clientHeight); const ctx = w > 0 && h > 0 ? document.createElement('canvas').getContext('2d') : null; if (!ctx) return undefined; ctx.canvas.width = w; ctx.canvas.height = h; - ctx.drawImage(img, 0, 0, w, h); + ctx.drawImage(img, 0 - (view?.x ?? 0), 0 - (view?.y ?? 0), img.clientWidth, img.clientHeight); const { data } = ctx.getImageData(0, 0, w, h); const outer = textOnRight ? 0 : w; const edge: string[] = []; diff --git a/src/app/features/albums/AlbumView.css.ts b/src/app/features/albums/AlbumView.css.ts new file mode 100644 index 00000000..c4f4fc79 --- /dev/null +++ b/src/app/features/albums/AlbumView.css.ts @@ -0,0 +1,51 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; +import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; + +const WIDE = `screen and (min-width: ${MOBILE_BREAKPOINT + 1}px)`; + +/** One column, bare ink on the scene. */ +export const Column = style({ + maxWidth: toRem(720), + width: '100%', + margin: '0 auto', + padding: config.space.S400, + display: 'flex', + flexDirection: 'column', + gap: config.space.S200, +}); + +/** A tappable text row: no border, no fill, at least 44 px tall. */ +export const Row = style({ + all: 'unset', + boxSizing: 'border-box', + cursor: 'pointer', + minHeight: 44, + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + gap: toRem(2), + color: color.Background.OnContainer, + ':focus-visible': { textDecoration: 'underline' }, +}); + +export const Back = style([Row, { minHeight: 44 }]); + +export const Files = style({ + display: 'flex', + flexDirection: 'column', + gap: config.space.S400, +}); + +/** Images may tile into a grid on wide screens only. */ +export const FilesWide = style({ + '@media': { + [WIDE]: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', + alignItems: 'start', + }, + }, +}); + +export const Dim = style({ opacity: config.opacity.P300 }); diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx new file mode 100644 index 00000000..28e0c477 --- /dev/null +++ b/src/app/features/albums/AlbumView.tsx @@ -0,0 +1,141 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Box, Scroll, Text } from 'folds'; +import { Room, RoomEvent } from 'matrix-js-sdk'; +import { MediaControl } from '../../components/media'; +import { AudioContent } from '../../components/message'; +import { RenderMessageContent } from '../../components/RenderMessageContent'; +import { IAudioContent } from '../../../types/matrix/common'; +import { LINKIFY_OPTS, getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useSetting } from '../../state/hooks/settings'; +import { settingsAtom } from '../../state/settings'; +import { markAsRead } from '../../utils/notifications'; +import { getBlobSafeMimeType } from '../../utils/mimeTypes'; +import { timeDayMonYear } from '../../utils/time'; +import { getMemberDisplayName } from '../../utils/room'; +import { Album, AlbumFile } from './buildAlbums'; +import { useAlbums } from './useAlbums'; +import * as css from './AlbumView.css'; + +/** + * Audio goes straight to AudioContent, skipping the chat Attachment frame and file-type badge: the + * title above it is the only label and the transport controls sit on the scene. Anything that is + * not playable audio falls through to the stock renderer (a file download). + */ +const playableAudio = (content: Record) => { + const c = content as IAudioContent; + const url = c.file?.url ?? c.url; + const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? ''); + if (!c.info || typeof url !== 'string' || !mimeType.startsWith('audio')) return undefined; + return { info: c.info, url, mimeType, encInfo: c.file }; +}; + +type FileRowProps = { room: Room; file: AlbumFile; author: string }; +function FileRow({ room, file, author }: FileRowProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); + const htmlReactParserOptions = useMemo( + () => + getReactCustomHtmlParser(mx, room.roomId, { linkifyOpts: LINKIFY_OPTS, useAuthentication }), + [mx, room.roomId, useAuthentication] + ); + const audio = file.kind === 'audio' ? playableAudio(file.content) : undefined; + return ( + + + {file.name} + + {audio ? ( + } /> + ) : ( + () => file.content as T} + mediaAutoLoad={mediaAutoLoad} + htmlReactParserOptions={htmlReactParserOptions} + linkifyOpts={LINKIFY_OPTS} + /> + )} + + ); +} + +const countLabel = (n: number) => `${n} ${n === 1 ? 'file' : 'files'}`; + +/** The zone never mounts RoomTimeline, so it sends the read receipt the timeline would. */ +function useMarkZoneRead(room: Room) { + const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + useEffect(() => { + const mark = () => { + markAsRead(mx, room.roomId, hideActivity).catch(() => undefined); + }; + mark(); + room.on(RoomEvent.Timeline, mark); + return () => { + room.off(RoomEvent.Timeline, mark); + }; + }, [mx, room, hideActivity]); +} + +type AlbumViewProps = { room: Room; onTimeline: () => void }; +export function AlbumView({ room, onTimeline }: AlbumViewProps) { + useMarkZoneRead(room); + const { loading, error, albums } = useAlbums(room); + const [dateFormat] = useSetting(settingsAtom, 'dateFormatString'); + const [openId, setOpenId] = useState(); + const open: Album | undefined = albums.find((a) => a.id === openId); + const authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author; + const encrypted = room.hasEncryptionStateEvent(); + + let body: React.ReactNode; + if (loading) body = Loading albums…; + else if (error) body = Could not load albums: {error.message}; + else if (open) { + const images = open.files.length > 0 && open.files.every((f) => f.kind === 'image'); + body = ( + <> + + {open.title} + + {authorOf(open)} · {countLabel(open.count)} · {timeDayMonYear(open.ts, dateFormat)} + + {open.count === 0 && This album has no files.} +
+ {open.files.map((f) => ( + + ))} +
+ + ); + } else if (encrypted) + body = This zone is encrypted, so its albums cannot be listed here.; + else if (albums.length === 0) body = No albums yet.; + else { + body = albums.map((a) => ( + + )); + } + + return ( + +
+ {body} + +
+
+ ); +} diff --git a/src/app/features/albums/buildAlbums.test.ts b/src/app/features/albums/buildAlbums.test.ts new file mode 100644 index 00000000..3a27bed6 --- /dev/null +++ b/src/app/features/albums/buildAlbums.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest'; +import { AlbumInput, ZoneEvent, buildAlbums } from './buildAlbums'; + +const root = (id: string, ts: number, over: Partial = {}): ZoneEvent => ({ + event_id: id, + type: 'm.room.message', + sender: '@void:x', + origin_server_ts: ts, + content: { msgtype: 'm.text', body: `album ${id}` }, + ...over, +}); +const reply = (id: string, ts: number, msgtype: string, body = id, extra = {}): ZoneEvent => ({ + event_id: id, + type: 'm.room.message', + sender: '@void:x', + origin_server_ts: ts, + content: { msgtype, body, ...extra }, +}); +const one = (r: ZoneEvent, replies: ZoneEvent[]) => buildAlbums([{ root: r, replies }])[0]; + +describe('buildAlbums', () => { + it('lists the newest album first', () => { + const inputs: AlbumInput[] = [ + { root: root('$old', 100), replies: [] }, + { root: root('$new', 300), replies: [] }, + { root: root('$mid', 200), replies: [] }, + ]; + expect(buildAlbums(inputs).map((a) => a.id)).toEqual(['$new', '$mid', '$old']); + }); + + it('takes title, author and date from the root', () => { + const a = one(root('$r', 555, { sender: '@eureka:x' }), []); + expect([a.title, a.author, a.ts]).toEqual(['album $r', '@eureka:x', 555]); + }); + + it('lists files oldest first whatever order the server returned them in', () => { + const a = one(root('$r', 1), [ + reply('$c', 30, 'm.audio'), + reply('$a', 10, 'm.audio'), + reply('$b', 20, 'm.audio'), + ]); + expect(a.files.map((f) => f.id)).toEqual(['$a', '$b', '$c']); + }); + + it('counts media replies only; a text reply is not a file', () => { + const a = one(root('$r', 1), [ + reply('$t', 5, 'm.text', 'nice'), + reply('$n', 6, 'm.notice', 'bot'), + reply('$a', 10, 'm.audio'), + reply('$f', 11, 'm.file'), + ]); + expect(a.count).toBe(2); + expect(a.files.map((f) => f.id)).toEqual(['$a', '$f']); + }); + + it('keeps a root with no media as an album of zero files', () => { + const a = one(root('$r', 1), [reply('$t', 5, 'm.text')]); + expect(a).toMatchObject({ id: '$r', count: 0, files: [], cover: undefined }); + }); + + it('uses the first image as the cover', () => { + const a = one(root('$r', 1), [ + reply('$a', 5, 'm.audio'), + reply('$i2', 20, 'm.image'), + reply('$i1', 10, 'm.image'), + ]); + expect(a.cover?.id).toBe('$i1'); + expect(one(root('$q', 1), [reply('$a', 5, 'm.audio')]).cover).toBeUndefined(); + }); + + it('derives the kind from the msgtype', () => { + const a = one(root('$r', 1), [ + reply('$1', 1, 'm.audio'), + reply('$2', 2, 'm.image'), + reply('$3', 3, 'm.video'), + reply('$4', 4, 'm.file'), + ]); + expect(a.files.map((f) => f.kind)).toEqual(['audio', 'image', 'video', 'file']); + }); + + it('names a file by filename, else body', () => { + const a = one(root('$r', 1), [ + reply('$1', 1, 'm.audio', 'caption', { filename: 'clip.wav' }), + reply('$2', 2, 'm.audio', 'plain.wav'), + ]); + expect(a.files.map((f) => f.name)).toEqual(['clip.wav', 'plain.wav']); + }); + + it('does not duplicate a file the relations endpoint returned twice', () => { + const a = one(root('$r', 1), [reply('$a', 5, 'm.audio'), reply('$a', 5, 'm.audio')]); + expect(a.count).toBe(1); + }); + + it('does not duplicate an album listed twice', () => { + expect( + buildAlbums([ + { root: root('$r', 1), replies: [] }, + { root: root('$r', 1), replies: [] }, + ]) + ).toHaveLength(1); + }); + + it('skips redacted and empty events', () => { + const redacted = { + ...reply('$x', 5, 'm.audio'), + content: {}, + unsigned: { redacted_because: {} }, + }; + const stillTyped = { ...reply('$y', 6, 'm.audio'), unsigned: { redacted_because: {} } }; + const empty: ZoneEvent = { event_id: '$z' }; + const a = one(root('$r', 1), [redacted, stillTyped, empty, reply('$ok', 7, 'm.audio')]); + expect(a.files.map((f) => f.id)).toEqual(['$ok']); + expect(buildAlbums([{ root: root('$g', 1, { content: {} }), replies: [] }])).toEqual([]); + expect( + buildAlbums([{ root: root('$h', 1, { unsigned: { redacted_because: {} } }), replies: [] }]) + ).toEqual([]); + }); + + it('tolerates events with missing fields', () => { + const a = one({ event_id: '$r', content: { msgtype: 'm.text' } }, [ + { event_id: '$a', content: { msgtype: 'm.audio' } }, + ]); + expect(a).toMatchObject({ title: 'Untitled album', author: '', ts: 0, count: 1 }); + }); + + it('does not read inherited object keys as file kinds', () => { + const a = one(root('$r', 1), [ + reply('$c', 1, 'constructor'), + reply('$p', 2, '__proto__'), + reply('$t', 3, 'toString'), + ]); + expect(a.count).toBe(0); + }); + + it('skips events that are not room messages even if their content looks like media', () => { + const reaction = { ...reply('$x', 5, 'm.audio'), type: 'm.reaction' }; + const a = one(root('$r', 1), [reaction, reply('$ok', 6, 'm.audio')]); + expect(a.files.map((f) => f.id)).toEqual(['$ok']); + expect(buildAlbums([{ root: root('$g', 1, { type: 'm.reaction' }), replies: [] }])).toEqual([]); + }); + + it('skips events without an event id', () => { + const anon: ZoneEvent = { type: 'm.room.message', content: { msgtype: 'm.audio', body: 'a' } }; + expect(one(root('$r', 1), [anon]).count).toBe(0); + expect( + buildAlbums([{ root: { content: { msgtype: 'm.text', body: 'x' } }, replies: [] }]) + ).toEqual([]); + }); + + it('titles an edited album by its latest edit', () => { + const edited = root('$r', 1, { + unsigned: { + 'm.relations': { + 'm.replace': { content: { 'm.new_content': { msgtype: 'm.text', body: 'renamed' } } }, + }, + }, + }); + expect(one(edited, []).title).toBe('renamed'); + const emptyEdit = root('$q', 1, { unsigned: { 'm.relations': { 'm.replace': {} } } }); + expect(one(emptyEdit, []).title).toBe('album $q'); + }); + + it('survives null or non-object entries in inputs and replies', () => { + const bad = [null, undefined, 'x', 7] as unknown as ZoneEvent[]; + const a = buildAlbums([ + null as unknown as AlbumInput, + { root: null as unknown as ZoneEvent, replies: [] }, + { root: root('$r', 1), replies: [...bad, reply('$ok', 2, 'm.audio')] }, + { root: root('$s', 2), replies: null as unknown as ZoneEvent[] }, + ]); + expect(a.map((x) => x.id)).toEqual(['$s', '$r']); + expect(a[1].files.map((f) => f.id)).toEqual(['$ok']); + }); + + it('treats a non-numeric timestamp as 0 so it sorts first, not by string order', () => { + const a = one(root('$r', 1), [ + reply('$b', 20, 'm.audio'), + { ...reply('$bad', 0, 'm.audio'), origin_server_ts: '5' as unknown as number }, + { ...reply('$nan', 0, 'm.audio'), origin_server_ts: NaN }, + reply('$a', 10, 'm.audio'), + ]); + expect(a.files.map((f) => f.id)).toEqual(['$bad', '$nan', '$a', '$b']); + const albums = buildAlbums([ + { root: root('$x', 100), replies: [] }, + { root: root('$y', 0, { origin_server_ts: '999' as unknown as number }), replies: [] }, + ]); + expect(albums.map((x) => [x.id, x.ts])).toEqual([ + ['$x', 100], + ['$y', 0], + ]); + }); + + it('gives an album with an empty body a fallback title', () => { + expect(one(root('$r', 1, { content: { msgtype: 'm.text', body: '' } }), []).title).toBe( + 'Untitled album' + ); + expect(one(root('$q', 1, { content: { msgtype: 'm.text' } }), []).title).toBe('Untitled album'); + }); + + it('never counts an edit event as a file or an album', () => { + const edit = reply('$e', 9, 'm.audio', '* x', { + 'm.relates_to': { rel_type: 'm.replace', event_id: '$a' }, + 'm.new_content': { msgtype: 'm.audio', body: 'x' }, + }); + const a = one(root('$r', 1), [reply('$a', 5, 'm.audio'), edit]); + expect(a.files.map((f) => f.id)).toEqual(['$a']); + const rootEdit = root('$re', 1, { + content: { msgtype: 'm.text', body: '* t', 'm.relates_to': { rel_type: 'm.replace' } }, + }); + expect(buildAlbums([{ root: rootEdit, replies: [] }])).toEqual([]); + const threaded = reply('$t', 6, 'm.audio', 't', { + 'm.relates_to': { rel_type: 'm.thread', event_id: '$r' }, + }); + expect(one(root('$q', 1), [threaded]).count).toBe(1); + }); +}); diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts new file mode 100644 index 00000000..8e57262b --- /dev/null +++ b/src/app/features/albums/buildAlbums.ts @@ -0,0 +1,121 @@ +// Pure: turns the thread roots of a file-zone room and each root's thread replies, in the JSON +// Synapse returns from GET /rooms/{roomId}/threads and GET /rooms/{roomId}/relations/{root}/m.thread, +// into albums. See docs/file-zone.md for the rulings this encodes. + +/** The slice of a Matrix event this module reads. Every field may be missing. */ +export type ZoneEvent = { + event_id?: string; + type?: string; + sender?: string; + origin_server_ts?: number; + content?: Record; + unsigned?: Record; +}; + +export type AlbumInput = { root: ZoneEvent; replies: ZoneEvent[] }; + +export type FileKind = 'audio' | 'image' | 'video' | 'file'; + +export type AlbumFile = { + id: string; + kind: FileKind; + name: string; + ts: number; + /** The event content, as RenderMessageContent reads it. */ + content: Record; +}; + +export type Album = { + id: string; + title: string; + author: string; + ts: number; + cover?: AlbumFile; + files: AlbumFile[]; + count: number; +}; + +// A Map, not an object: msgtype is server-supplied and "constructor" must not name a kind. +const KIND_OF_MSGTYPE = new Map([ + ['m.audio', 'audio'], + ['m.image', 'image'], + ['m.video', 'video'], + ['m.file', 'file'], +]); + +// A redacted event keeps its id but loses its content and gains unsigned.redacted_because. +const live = ( + e: ZoneEvent +): e is ZoneEvent & { event_id: string; content: Record } => + !!e && + typeof e === 'object' && + !!e.event_id && + // An edit is an m.room.message too, but it revises an event; it is never a file or an album. + (e.content?.['m.relates_to'] as { rel_type?: unknown } | undefined)?.rel_type !== 'm.replace' && + !e.unsigned?.redacted_because && + (e.type === undefined || e.type === 'm.room.message') && + typeof e.content?.msgtype === 'string'; + +const tsOf = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0); + +const asString = (v: unknown): string => (typeof v === 'string' ? v : ''); + +const toFile = (e: ZoneEvent): AlbumFile | undefined => { + if (!live(e)) return undefined; + const kind = KIND_OF_MSGTYPE.get(e.content.msgtype as string); + if (!kind) return undefined; + return { + id: e.event_id, + kind, + name: asString(e.content.filename) || asString(e.content.body), + ts: tsOf(e.origin_server_ts), + content: e.content, + }; +}; + +export const UNTITLED = 'Untitled album'; + +// An edited root carries its latest edit under unsigned["m.relations"]["m.replace"]; the title is +// that edit's m.new_content.body, else the original body. +const titleOf = (root: ZoneEvent & { content: Record }): string => { + const relations = root.unsigned?.['m.relations'] as Record | undefined; + const edit = relations?.['m.replace'] as ZoneEvent | undefined; + const edited = (edit?.content?.['m.new_content'] as Record | undefined)?.body; + return asString(edited) || asString(root.content.body) || UNTITLED; +}; + +const toAlbum = (input: AlbumInput): Album | undefined => { + const { root, replies } = input ?? {}; + if (!live(root)) return undefined; + const seen = new Set(); + const files = (Array.isArray(replies) ? replies : []) + .map(toFile) + .filter((f): f is AlbumFile => { + if (!f || seen.has(f.id)) return false; + seen.add(f.id); + return true; + }) + .sort((a, b) => a.ts - b.ts); + return { + id: root.event_id, + title: titleOf(root), + author: root.sender ?? '', + ts: tsOf(root.origin_server_ts), + cover: files.find((f) => f.kind === 'image'), + files, + count: files.length, + }; +}; + +/** Newest album first; files oldest first. Loose room messages never reach this function. */ +export const buildAlbums = (inputs: AlbumInput[]): Album[] => { + const seen = new Set(); + return inputs + .map(toAlbum) + .filter((a): a is Album => { + if (!a || seen.has(a.id)) return false; + seen.add(a.id); + return true; + }) + .sort((a, b) => b.ts - a.ts); +}; diff --git a/src/app/features/albums/isFileZone.test.ts b/src/app/features/albums/isFileZone.test.ts new file mode 100644 index 00000000..0e55269f --- /dev/null +++ b/src/app/features/albums/isFileZone.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { isFileZone } from './isFileZone'; + +const room = { roomId: '!f:x', aliases: ['#files:x'] }; + +describe('isFileZone', () => { + it('is true for the file-zone create type', () => { + expect(isFileZone({ ...room, createType: 'org.gamecult.file_zone' }, {})).toBe(true); + }); + it('is false for other create types and for plain rooms', () => { + expect(isFileZone({ ...room, createType: 'm.space' }, {})).toBe(false); + expect(isFileZone({ ...room, createType: 'org.gamecult.other' }, {})).toBe(false); + expect(isFileZone({ ...room, createType: 'org.gamecult.file_zone.x' }, {})).toBe(false); + expect(isFileZone(room, {})).toBe(false); + expect(isFileZone(room, { fileZoneRooms: [] })).toBe(false); + }); + it('is true when the room id or an alias is configured', () => { + expect(isFileZone(room, { fileZoneRooms: ['!f:x'] })).toBe(true); + expect(isFileZone(room, { fileZoneRooms: ['#other:x', '#files:x'] })).toBe(true); + }); + it('is false when only another room is configured', () => { + expect(isFileZone(room, { fileZoneRooms: ['!g:x', '#g:x'] })).toBe(false); + }); +}); diff --git a/src/app/features/albums/isFileZone.ts b/src/app/features/albums/isFileZone.ts new file mode 100644 index 00000000..071ef17b --- /dev/null +++ b/src/app/features/albums/isFileZone.ts @@ -0,0 +1,14 @@ +export const FILE_ZONE_TYPE = 'org.gamecult.file_zone'; + +export type FileZoneRoom = { + roomId: string; + /** Canonical and alternative aliases. */ + aliases?: string[]; + /** The `type` of the room's m.room.create content. */ + createType?: string; +}; + +/** A room is the file zone when its create type says so or the client config lists it. */ +export const isFileZone = (room: FileZoneRoom, config: { fileZoneRooms?: string[] }): boolean => + room.createType === FILE_ZONE_TYPE || + !!config.fileZoneRooms?.some((r) => r === room.roomId || room.aliases?.includes(r)); diff --git a/src/app/features/albums/mapLimit.test.ts b/src/app/features/albums/mapLimit.test.ts new file mode 100644 index 00000000..24898cb0 --- /dev/null +++ b/src/app/features/albums/mapLimit.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { mapLimit } from './mapLimit'; + +describe('mapLimit', () => { + it('keeps result order and never exceeds the limit', async () => { + let inFlight = 0; + let peak = 0; + const out = await mapLimit([5, 1, 4, 2, 3, 0], 2, async (n) => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise((r) => { + setTimeout(r, n); + }); + inFlight -= 1; + return n * 10; + }); + expect(out).toEqual([50, 10, 40, 20, 30, 0]); + expect(peak).toBe(2); + }); + it('handles an empty list and rejects when a call rejects', async () => { + expect(await mapLimit([], 3, async (n) => n)).toEqual([]); + await expect( + mapLimit([1, 2], 2, async (n) => { + if (n === 2) throw new Error('boom'); + return n; + }) + ).rejects.toThrow('boom'); + }); +}); diff --git a/src/app/features/albums/mapLimit.ts b/src/app/features/albums/mapLimit.ts new file mode 100644 index 00000000..e850add0 --- /dev/null +++ b/src/app/features/albums/mapLimit.ts @@ -0,0 +1,19 @@ +/** Like Promise.all(items.map(fn)) but with at most `limit` calls in flight; result order kept. */ +export const mapLimit = async ( + items: T[], + limit: number, + fn: (item: T) => Promise +): Promise => { + const out = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const i = next; + next += 1; + // eslint-disable-next-line no-await-in-loop + out[i] = await fn(items[i]); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return out; +}; diff --git a/src/app/features/albums/useAlbums.ts b/src/app/features/albums/useAlbums.ts new file mode 100644 index 00000000..a01f1efb --- /dev/null +++ b/src/app/features/albums/useAlbums.ts @@ -0,0 +1,73 @@ +import { useQuery } from '@tanstack/react-query'; +import { ClientPrefix, Method, Room } from 'matrix-js-sdk'; +import { StateEvent } from '../../../types/matrix/room'; +import { useStateEvent } from '../../hooks/useStateEvent'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { Album, ZoneEvent, buildAlbums } from './buildAlbums'; +import { isFileZone } from './isFileZone'; +import { mapLimit } from './mapLimit'; + +const REPLY_FETCHES_IN_FLIGHT = 6; +const REFRESH_MS = 30_000; + +type Chunk = { chunk?: ZoneEvent[] }; + +/** True when the room is the file zone: by its create type, or listed in the client config. */ +export const useIsFileZone = (room: Room): boolean => { + const config = useClientConfig(); + // Subscribes to alias changes (alt aliases live in the same event). + useStateEvent(room, StateEvent.RoomCanonicalAlias); + const createType = room.currentState.getStateEvents('m.room.create', '')?.getContent().type; + return isFileZone( + { + roomId: room.roomId, + aliases: [room.getCanonicalAlias(), ...room.getAltAliases()].filter((a): a is string => !!a), + createType, + }, + config + ); +}; + +export type AlbumsState = { loading: boolean; error: Error | null; albums: Album[] }; + +/** + * Slice 1 reads ONE page of each endpoint (threads: 100 roots; replies per root: 100). A zone with + * more albums or files than that is truncated; pagination is a later slice. Reads are made with + * the logged-in client, so the room must be unencrypted: encrypted events carry no msgtype and + * are skipped. + */ +export const useAlbums = (room: Room): AlbumsState => { + const mx = useMatrixClient(); + const { roomId } = room; + const query = useQuery({ + queryKey: ['albums', roomId], + queryFn: async (): Promise => { + const get = (path: string, params: Record) => + mx.http.authedRequest(Method.Get, path, params, undefined, { + prefix: ClientPrefix.V1, + }); + const room_ = encodeURIComponent(roomId); + const roots = (await get(`/rooms/${room_}/threads`, { limit: '100' })).chunk ?? []; + const inputs = await mapLimit(roots, REPLY_FETCHES_IN_FLIGHT, async (root: ZoneEvent) => ({ + root, + replies: + ( + await get( + `/rooms/${room_}/relations/${encodeURIComponent(root.event_id ?? '')}/m.thread`, + { limit: '100', dir: 'f' } + ) + ).chunk ?? [], + })); + return buildAlbums(inputs); + }, + retry: 1, + refetchInterval: REFRESH_MS, + }); + return { + loading: query.isLoading, + // A failed refetch keeps the albums already loaded; the error shows only when there are none. + error: query.data ? null : (query.error as Error | null), + albums: query.data ?? [], + }; +}; diff --git a/src/app/features/albums/zoneView.test.ts b/src/app/features/albums/zoneView.test.ts new file mode 100644 index 00000000..65beb9cb --- /dev/null +++ b/src/app/features/albums/zoneView.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { zoneView } from './zoneView'; + +describe('zoneView', () => { + it('opens albums by default and the timeline for an event deep link', () => { + expect(zoneView(undefined, undefined)).toBe('albums'); + expect(zoneView(undefined, '$e')).toBe('timeline'); + }); + it('lets an event link win over an earlier choice of albums', () => { + expect(zoneView('albums', '$e')).toBe('timeline'); + }); + it('honours the choice when there is no event link', () => { + expect(zoneView('albums', undefined)).toBe('albums'); + expect(zoneView('timeline', undefined)).toBe('timeline'); + }); +}); diff --git a/src/app/features/albums/zoneView.ts b/src/app/features/albums/zoneView.ts new file mode 100644 index 00000000..1af7f705 --- /dev/null +++ b/src/app/features/albums/zoneView.ts @@ -0,0 +1,5 @@ +export type ZoneView = 'albums' | 'timeline'; + +/** The view a file-zone room shows. A link to an event always wins; else the user's choice; else albums. */ +export const zoneView = (choice: ZoneView | undefined, eventId: string | undefined): ZoneView => + eventId ? 'timeline' : choice ?? 'albums'; diff --git a/src/app/features/desk/Desk.tsx b/src/app/features/desk/Desk.tsx index 548be685..f1695716 100644 --- a/src/app/features/desk/Desk.tsx +++ b/src/app/features/desk/Desk.tsx @@ -16,16 +16,16 @@ export function Desk() { const navigate = useNavigate(); const phone = useScreenSizeContext() === ScreenSize.Mobile; const label = labelOf(mind); - const projection = useMemo(() => deskFor(mind), [mind]); + const list = useMemo(() => deskFor(mind), [mind]); // Until the recompute finishes nothing is flagged; a card that fails is withdrawn when it lands. const [unverified, setUnverified] = useState>(new Set()); useEffect(() => { let live = true; - unverifiedForks(projection?.questions ?? []).then((bad) => live && setUnverified(bad)); + unverifiedForks(list?.forks ?? []).then((bad) => live && setUnverified(bad)); return () => { live = false; }; - }, [projection]); + }, [list]); return ( @@ -46,13 +46,7 @@ export function Desk() { - + diff --git a/src/app/features/desk/DeskEntry.tsx b/src/app/features/desk/DeskEntry.tsx index 1d3068ab..a36ec8e4 100644 --- a/src/app/features/desk/DeskEntry.tsx +++ b/src/app/features/desk/DeskEntry.tsx @@ -2,12 +2,11 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { Text } from 'folds'; import { getDeskPath } from '../../pages/pathUtils'; -import { countOpen } from './buildDesk'; import { deskFor } from './source'; /** The way into a Mind's desk, set in its navigator group header; nothing at all when nothing is open. */ export function DeskEntry({ mind }: { mind: string }) { - const open = countOpen(deskFor(mind)); + const open = deskFor(mind)?.open_count ?? 0; if (open === 0) return null; return ( diff --git a/src/app/features/desk/DeskView.test.tsx b/src/app/features/desk/DeskView.test.tsx index 09d3f45a..ede59229 100644 --- a/src/app/features/desk/DeskView.test.tsx +++ b/src/app/features/desk/DeskView.test.tsx @@ -5,11 +5,11 @@ import { afterEach, describe, expect, it } from 'vitest'; import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize'; import { DeskEntry } from './DeskEntry'; import { DeskView, SIGN_IN_NOTE } from './DeskView'; -import { NOW, STATES } from './fixtures'; +import { STATES } from './fixtures'; import { setDeskSource } from './source'; const view = ( - state: keyof typeof STATES, + state: keyof typeof STATES | 'unpublished', size: ScreenSize, mind = 'void', unverified = new Set() @@ -19,8 +19,7 @@ const view = ( @@ -45,11 +44,6 @@ describe('DeskView', () => { expect(html.match(/\(recommended\)/g)).toHaveLength(1); expect(html).toContain('B Second way (recommended)'); }); - it('shows the depends line with the dependency status', () => { - expect(view('blocked', ScreenSize.Desktop)).toContain( - 'Waits on: "Needs the first one settled" (open)' - ); - }); it('keeps the note disabled and says why in a sentence the field points at', () => { const html = view('noAnswers', ScreenSize.Desktop); expect(html).toMatch(/]* disabled=""/); @@ -57,21 +51,31 @@ describe('DeskView', () => { expect(SIGN_IN_NOTE).toMatch(/sign-in/); expect(html).toMatch(/aria-describedby="note-[^"]+-why"/); }); + it('shows the depends line as the text it is', () => { + expect(view('blocked', ScreenSize.Desktop)).toContain( + 'Waits on: "Needs the first one settled"' + ); + }); it('says No answers yet on a row without answers', () => { expect(view('noAnswers', ScreenSize.Desktop)).toContain('No answers yet.'); }); - it('shows how long a long-open question has waited', () => { - expect(view('longOpen', ScreenSize.Desktop)).toContain('Open for 21 days'); - }); it('lists settled questions as recorded rulings with the ruling id', () => { const html = view('settled', ScreenSize.Desktop); expect(html).toContain('Recorded as rulings'); expect(html).toContain('Question done (ruling:done-1)'); expect(html).not.toContain('Question shut'); }); - it('says it may be out of date when stale, and says nothing of the kind when fresh', () => { - expect(view('stale', ScreenSize.Desktop)).toContain('may be out of date'); - expect(view('noAnswers', ScreenSize.Desktop)).not.toContain('out of date'); + it('shows each outcome quietly, marks the counted answer, and renders a note only when sent', () => { + const html = view('answered', ScreenSize.Desktop); + expect(html).toContain( + 'Answer First way "Because it is first." (2026-11-09) (counted)' + ); + expect(html).toContain('Revision of n1 Third way (2026-11-09)'); + expect(html).toContain('Retraction of n2 (2026-11-09)'); + expect(html).toContain('Refused (not_a_decider) Second way "Let me in."'); + expect(html).toContain('Answer "Either, if measured first."'); + expect(html.match(/\(counted\)/g)).toHaveLength(1); + expect(html).not.toContain('"null"'); }); it('says nothing is waiting when empty, and that nothing is published when it never arrived', () => { expect(view('empty', ScreenSize.Desktop)).toContain('Nothing is waiting on you from Void.'); @@ -79,10 +83,6 @@ describe('DeskView', () => { 'Void's questions have not been published yet.' ); }); - it('shows hidden and erased rows as withheld stubs', () => { - expect(view('hidden', ScreenSize.Desktop)).toContain('hidden by Kiko'); - expect(view('erased', ScreenSize.Desktop)).toContain('erased account'); - }); it('seats the Mind in the booth with the pose for the desk mood', () => { const html = view('noAnswers', ScreenSize.Desktop); expect(html).toContain('data-pose="thinking"'); @@ -100,8 +100,18 @@ describe('DeskView', () => { const html = view('noAnswers', ScreenSize.Desktop, 'void', new Set(['c:question:plain'])); expect(html).toContain('does not match the hash it was published with'); expect(html).not.toContain('First way'); - expect(html).not.toContain('Just one fork'); - expect(html).toContain('Nothing is waiting'); + expect(html).toContain('1 open.'); + expect(html).not.toContain('Nothing is waiting'); + }); + it('never claims an empty desk while the list says forks are open, whatever rows it could show', () => { + setDeskSource(() => undefined); + const html = renderToStaticMarkup( + + + + ); + expect(html).toContain('3 open.'); + expect(html).not.toContain('Nothing is waiting'); }); it('shows no booth for a Mind without art rather than another face', () => { expect(view('noAnswers', ScreenSize.Desktop, 'nobody')).not.toContain('data-pose'); @@ -125,12 +135,16 @@ describe('DeskEntry', () => { expect(html).toContain('Desk · 2 open'); expect(html).toContain('href="/desk/void"'); }); + it('reads the count the list states, not a count of the forks it carries', () => { + setDeskSource(() => ({ ...STATES.empty, open_count: 5 })); + expect(entry('void')).toContain('Desk · 5 open'); + }); it('is absent at zero open, when nothing is published, and when only settled forks exist', () => { setDeskSource(() => STATES.empty); expect(entry('void')).toBe(''); setDeskSource(() => undefined); expect(entry('void')).toBe(''); - setDeskSource(() => ({ ...STATES.settled, questions: STATES.settled.questions.slice(0, 2) })); + setDeskSource(() => ({ ...STATES.settled, open_count: 0 })); expect(entry('void')).toBe(''); }); }); diff --git a/src/app/features/desk/DeskView.tsx b/src/app/features/desk/DeskView.tsx index fed7d18e..e57bfa70 100644 --- a/src/app/features/desk/DeskView.tsx +++ b/src/app/features/desk/DeskView.tsx @@ -3,8 +3,8 @@ import { Text } from 'folds'; import { PoseAvatar } from '../../components/message/pose/PoseAvatar'; import { MINDS } from '../../components/message/pose/poses'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; -import { AnswerRow, Desk, DeskRow, buildDesk } from './buildDesk'; -import { DeskProjection } from './feedback'; +import { AnswerRow, Desk, buildDesk, DeskRow } from './buildDesk'; +import { ForkList } from './forkContract'; import { deskMood } from './deskMood'; import * as css from './DeskView.css'; @@ -13,21 +13,32 @@ export const SIGN_IN_NOTE = 'Answering opens with sign-in. Until then you can re const date = (iso: string) => iso.slice(0, 10); -const age = (row: Extract) => { - if (row.longOpen) return `Open for ${row.ageDays} days`; - if (row.ageDays === 0) return 'Asked today'; - return row.ageDays === 1 ? 'Asked 1 day ago' : `Asked ${row.ageDays} days ago`; +const OUTCOME: Record = { + Counted: 'Answer', + Revision: 'Revision', + Retraction: 'Retraction', + Refused: 'Refused', }; +/** One answer as served: its outcome, what it chose, its note when the server sent one, and whether it counts. */ +const answerLine = (a: AnswerRow) => + [ + OUTCOME[a.outcome] + (a.code ? ` (${a.code})` : ''), + a.target && `of ${a.target}`, + a.choice, + a.note !== undefined && `"${a.note}"`, + ] + .filter(Boolean) + .join(' '); + function Answers({ answers }: { answers: AnswerRow[] }) { if (answers.length === 0) return

No answers yet.

; return (
    {answers.map((a) => ( -
  • - {`${a.who}, ${a.kind.toLowerCase()}: `} - {a.withheld ?? [a.choice, a.text && `"${a.text}"`].filter(Boolean).join(' ')} - {` (${date(a.at)})`} +
  • + {`${answerLine(a)} (${date(a.at)})`} + {a.counted && ' (counted)'}
  • ))}
@@ -47,17 +58,6 @@ function Row({ row }: { row: DeskRow }) { ); } - if (row.kind === 'hidden') { - return ( -
  • -

    - {`A question here is hidden by ${row.hidden.by}, ${date(row.hidden.at)}, ${ - row.hidden.reason - }.`} -

    -
  • - ); - } const noteId = `note-${row.forkId}`; return (
  • @@ -75,10 +75,9 @@ function Row({ row }: { row: DeskRow }) { row.title )} -

    {age(row)}

    {open && ( <> -

    {row.body}

    +

    {row.context}

      {row.options.map((o) => (
    • @@ -88,8 +87,8 @@ function Row({ row }: { row: DeskRow }) { ))}
    {row.depends.map((d) => ( -

    - {`Waits on: "${d.text}"${d.status ? ` (${d.status})` : ''}`} +

    + {`Waits on: "${d}"`}

    ))} Your note @@ -115,9 +114,7 @@ function Status({ desk, label }: { desk: Desk; label: string }) {

    {desk.counts.open === 0 ? `Nothing is waiting on you from ${label}. ` - : `${desk.counts.open} open. `} - {`Synced ${desk.syncedAt && date(desk.syncedAt)}.`} - {desk.stale && ' This may be out of date.'} + : `${desk.counts.open} open.`}

    ); } @@ -126,16 +123,16 @@ type DeskViewProps = { /** The Mind's key, as the navigator groups it. */ mind: string; label: string; - projection?: DeskProjection; - now: number; + list?: ForkList; /** Fork ids whose options_hash failed the recompute (optionsHash.ts). */ unverified?: ReadonlySet; }; /** One Mind's desk, read-only: a stream of rows, the recorded rulings, and the booth Persona. */ -export function DeskView({ mind, label, projection, now, unverified }: DeskViewProps) { - const desk = buildDesk(projection, now, undefined, unverified); - const mood = deskMood(desk.counts.open, desk.counts.answered); +export function DeskView({ mind, label, list, unverified }: DeskViewProps) { + const desk = buildDesk(list, unverified); + // Slice 1 has no session, so nothing on the desk is the viewer's answer yet. + const mood = deskMood(desk.counts.open, 0); return (
    diff --git a/src/app/features/desk/buildDesk.test.ts b/src/app/features/desk/buildDesk.test.ts index 29d2df40..e4a81248 100644 --- a/src/app/features/desk/buildDesk.test.ts +++ b/src/app/features/desk/buildDesk.test.ts @@ -1,127 +1,105 @@ import { describe, expect, it } from 'vitest'; -import { LONG_OPEN_DAYS, buildDesk, countOpen } from './buildDesk'; -import { NOW, STATES } from './fixtures'; +import { buildDesk } from './buildDesk'; +import { STATES, entry, list } from './fixtures'; const questions = (d: ReturnType) => d.open.flatMap((r) => (r.kind === 'question' ? [r] : [])); describe('buildDesk', () => { - it('says "not published" for a projection that never arrived, never "no questions"', () => { - expect(buildDesk(undefined, NOW).published).toBe(false); - expect(buildDesk(STATES.unpublished, NOW)).toMatchObject({ - published: false, + it('says "not published" for a list that never arrived, never "no questions"', () => { + expect(buildDesk(undefined)).toMatchObject({ published: false, open: [], counts: { open: 0 } }); + }); + it('is published and empty when the list holds nothing', () => { + expect(buildDesk(STATES.empty)).toMatchObject({ + published: true, open: [], - counts: { open: 0, answered: 0 }, + counts: { open: 0 }, }); }); - it('is published and empty, with its sync time, when nothing is open', () => { - const d = buildDesk(STATES.empty, NOW); - expect(d).toMatchObject({ published: true, open: [], counts: { open: 0 } }); - expect(d.syncedAt).toBeDefined(); - }); - it('is stale only past thirty minutes since sync', () => { - expect(buildDesk(STATES.noAnswers, NOW).stale).toBe(false); - expect(buildDesk(STATES.stale, NOW).stale).toBe(true); - }); it('shows no answers as an empty list on an open row', () => { - expect(questions(buildDesk(STATES.noAnswers, NOW))[0].answers).toEqual([]); - }); - it('flags a question open past fourteen days and keeps it listed', () => { - const [row] = questions(buildDesk(STATES.longOpen, NOW)); - expect(row).toMatchObject({ ageDays: 21, longOpen: true }); - expect(LONG_OPEN_DAYS).toBe(14); - expect(questions(buildDesk(STATES.noAnswers, NOW))[0].longOpen).toBe(false); - }); - it('puts the long-open line past fourteen days exactly: day 14 is not long, day 15 is', () => { - const at = (days: number) => { - const p = structuredClone(STATES.noAnswers); - (p.questions[0] as { askedAt: string }).askedAt = new Date( - NOW - days * 86400000 - ).toISOString(); - return questions(buildDesk(p, NOW))[0].longOpen; - }; - expect([at(14), at(15)]).toEqual([false, true]); + expect(questions(buildDesk(STATES.noAnswers))[0].answers).toEqual([]); }); it('resolves the recommendation to its option, marks only that option, and never preselects', () => { - const [row] = questions(buildDesk(STATES.noAnswers, NOW)); + const [row] = questions(buildDesk(STATES.noAnswers)); expect(row.recommended).toBe('Second way'); expect(row.options.filter((o) => o.recommended).map((o) => o.id)).toEqual(['B']); }); it('leaves the recommendation unresolved when it names no option', () => { - const p = structuredClone(STATES.noAnswers); - (p.questions[0] as { recommendation?: string }).recommendation = 'Z'; - const [row] = questions(buildDesk(p, NOW)); + const [row] = questions(buildDesk(list([entry('x', { recommendation: 'Z' })]))); expect(row.recommended).toBeUndefined(); expect(row.options.some((o) => o.recommended)).toBe(false); }); - it('carries the status of a cited dependency, and none for one that cites no question', () => { - const rows = questions(buildDesk(STATES.blocked, NOW)); + it('carries the depends lines as the text they are', () => { + const rows = questions(buildDesk(STATES.blocked)); expect(rows.find((r) => r.forkId.endsWith('second'))?.depends).toEqual([ - { text: 'Needs the first one settled', status: 'open' }, + 'Needs the first one settled', ]); expect(rows.find((r) => r.forkId.endsWith('first'))?.depends).toEqual([]); }); - it('lists a settled question under recorded rulings with its ruling id, not as open; drops a closed one', () => { - const d = buildDesk(STATES.settled, NOW); + it('lists a closed card with a ruling as recorded, not open; omits one closed with no ruling', () => { + const d = buildDesk(STATES.settled); expect(d.recorded).toEqual([ { forkId: 'c:question:done', title: 'Question done', rulingId: 'ruling:done-1' }, ]); expect(d.open.map((r) => r.forkId)).toEqual(['c:question:live']); }); - it('orders open rows oldest first', () => { - const p = structuredClone(STATES.blocked); - (p.questions[0] as { askedAt: string }).askedAt = new Date(NOW).toISOString(); - expect(buildDesk(p, NOW).open.map((r) => r.forkId)).toEqual([ - 'c:question:second', - 'c:question:first', + it('keeps the served order (newest first by card.order) and sorts nothing itself', () => { + const served = list([entry('late', { order: 9 }), entry('early', { order: 2 })]); + expect(buildDesk(served).open.map((r) => r.forkId)).toEqual([ + 'c:question:late', + 'c:question:early', + ]); + const odd = list([entry('a', { order: 2 }), entry('b', { order: 9 })]); + expect(buildDesk(odd).open.map((r) => r.forkId)).toEqual(['c:question:a', 'c:question:b']); + }); + it("takes the open count from the list's open_count, not from the rows it could verify or carries", () => { + const d = buildDesk({ ...list([entry('only')]), open_count: 4 }, new Set(['c:question:only'])); + expect(d.counts.open).toBe(4); + }); + it('marks the answer the record names as counted, and no other', () => { + const [row] = questions(buildDesk(STATES.answered)); + expect(row.answers.filter((a) => a.counted).map((a) => a.answerId)).toEqual(['n1']); + }); + it('follows the record when a revision is the counted answer: nothing is re-derived', () => { + const e = STATES.answered.forks[0]; + const moved = list([{ ...e, record: { ...e.record, counted_answer_id: 'n2' } }]); + expect( + questions(buildDesk(moved))[0] + .answers.filter((a) => a.counted) + .map((a) => a.answerId) + ).toEqual(['n2']); + }); + it('keeps answers in the served order and carries each outcome, target and refusal code', () => { + const [row] = questions(buildDesk(STATES.answered)); + expect(row.answers.map((a) => [a.answerId, a.outcome, a.target, a.code])).toEqual([ + ['n1', 'Counted', undefined, undefined], + ['n2', 'Revision', 'n1', undefined], + ['n3', 'Retraction', 'n2', undefined], + ['n4', 'Refused', undefined, 'not_a_decider'], + ['n5', 'Counted', undefined, undefined], ]); }); - it('folds earlier answers by one account under its latest and shows choice as option text', () => { - const [row] = questions(buildDesk(STATES.answered, NOW)); - expect(row.answers).toHaveLength(2); - const first = row.answers.find((a) => a.recordId === 'plain.a2'); - expect(first).toMatchObject({ choice: 'Third way', text: 'On reflection, third.' }); - expect(row.answers.some((a) => a.recordId === 'plain.a1')).toBe(false); - }); - it('lists answers oldest first and tallies nothing', () => { - const [row] = questions(buildDesk(STATES.answered, NOW)); - const times = row.answers.map((a) => a.at); - expect([...times].sort()).toEqual(times); - expect(Object.keys(buildDesk(STATES.answered, NOW).counts).sort()).toEqual([ - 'answered', - 'open', + it('shows a choice as option text, passes an unknown id through, and leaves a null choice absent', () => { + const [row] = questions(buildDesk(STATES.answered)); + expect(row.answers.map((a) => a.choice)).toEqual([ + 'First way', + 'Third way', + undefined, + 'Second way', + undefined, ]); + const odd = list([ + entry('odd', {}, [{ ...STATES.answered.forks[0].record.answers[0], choice: 'Q' }]), + ]); + expect(questions(buildDesk(odd))[0].answers[0].choice).toBe('Q'); }); - it('withholds a hidden answer and a hidden question, naming who hid them', () => { - const d = buildDesk(STATES.hidden, NOW); - expect(d.open.find((r) => r.kind === 'hidden')).toMatchObject({ - hidden: { by: 'Kiko', reason: 'PrivateMatter' }, - }); - const [row] = questions(d); - expect(row.answers[0]).toMatchObject({ choice: undefined, text: undefined }); - expect(row.answers[0].withheld).toMatch(/^Hidden by Kiko, 2026-11-\d\d, Spam$/); + it('carries a note verbatim when present and leaves it absent when null', () => { + const [row] = questions(buildDesk(STATES.answered)); + expect(row.answers[0].note).toBe('Because it is first.'); + expect(row.answers[1].note).toBeUndefined(); }); - it('does not count a hidden question as open', () => { - expect(buildDesk(STATES.hidden, NOW).counts.open).toBe(1); - expect(countOpen(STATES.hidden)).toBe(1); - }); - it('shows an erased author as "erased account" with no name or content, keeping kind and time', () => { - const [row] = questions(buildDesk(STATES.erased, NOW)); - expect(row.answers[0]).toMatchObject({ - who: 'erased account', - kind: 'Member', - choice: undefined, - text: undefined, - }); - expect(row.answers[0].withheld).toBeDefined(); - expect(row.answers[0].at).toBeDefined(); - }); - it('counts answered only for the viewer, so a signed-out desk has none', () => { - expect(buildDesk(STATES.answered, NOW).counts.answered).toBe(0); - expect(buildDesk(STATES.answered, NOW, 'acct-1').counts.answered).toBe(1); - expect(buildDesk(STATES.answered, NOW, 'acct-9').counts.answered).toBe(0); - }); - it('does not count the viewer as answered when their only answer was hidden', () => { - expect(buildDesk(STATES.hidden, NOW, 'acct-1').counts.answered).toBe(0); + it('withdraws an unverified open card and does not count it', () => { + const d = buildDesk(STATES.noAnswers, new Set(['c:question:plain'])); + expect(d.open).toEqual([{ kind: 'unverified', forkId: 'c:question:plain' }]); }); }); diff --git a/src/app/features/desk/buildDesk.ts b/src/app/features/desk/buildDesk.ts index 4c8fee6f..e09dec1f 100644 --- a/src/app/features/desk/buildDesk.ts +++ b/src/app/features/desk/buildDesk.ts @@ -1,164 +1,95 @@ -import { - Answer, - DeskProjection, - Hidden, - Question, - QuestionStatus, - isHiddenQuestion, -} from './feedback'; - -/** Past two heartbeats the projection may be out of date (provisional, doc section 8). */ -export const STALE_MS = 30 * 60 * 1000; -/** Past this a question shows how long it has waited; it is never demoted or hidden (provisional). */ -export const LONG_OPEN_DAYS = 14; -const DAY_MS = 24 * 60 * 60 * 1000; +import { DeskAnswer, ForkEntry, ForkList } from './forkContract'; export type AnswerRow = { - recordId: string; - /** Never a handle: the name at answer time, or the words for an account that has none. */ - who: string; - kind: Answer['kind']; + answerId: string; + outcome: DeskAnswer['outcome']; /** The option's text for a choice. */ choice?: string; - text?: string; + /** Verbatim, and only when the server sent one. */ + note?: string; + /** The answer a Revision or Retraction points at. */ + target?: string; + /** A refusal's code. */ + code?: string; at: string; - /** Why the content is not shown: hidden by the Mind, or the author's account was erased. */ - withheld?: string; + /** The record's counted answer: marked as served, never derived here. */ + counted: boolean; }; export type DeskRow = - | { kind: 'hidden'; forkId: string; hidden: Hidden } /** Its published options_hash is not the hash of what it shows: neither shown nor counted. */ | { kind: 'unverified'; forkId: string } | { kind: 'question'; forkId: string; title: string; - body: string; + context: string; options: { id: string; text: string; recommended: boolean }[]; /** The recommendation resolved to its option's text; absent if it names no option. */ recommended?: string; - depends: { text: string; status?: QuestionStatus['state'] }[]; - ageDays: number; - longOpen: boolean; + depends: string[]; + /** In admission order, as served. */ answers: AnswerRow[]; }; export type Desk = { - /** False until the projection first arrives: "not published yet", never "no questions". */ + /** False until the list first arrives: "not published yet", never "no questions". */ published: boolean; - syncedAt?: string; - stale: boolean; - /** Oldest first. */ + /** Oldest first, by the card's admission ordinal. */ open: DeskRow[]; - /** Settled questions with the Mind's ruling id. Closed (withdrawn) questions are not listed. */ + /** Closed with a ruling id. A card closed with no ruling (withdrawn) is not listed. */ recorded: { forkId: string; title: string; rulingId: string }[]; - /** Derived for the booth mood. The page tallies nothing about answers. */ - counts: { open: number; answered: number }; + /** The list's own open_count: the one count the navigator, the status line and the booth share. */ + counts: { open: number }; }; -const ageDays = (iso: string, now: number) => - Math.max(0, Math.floor((now - Date.parse(iso)) / DAY_MS)); - -const answerRow = (a: Answer, q: Question): AnswerRow => { - let withheld: string | undefined; - if (a.hidden) - withheld = `Hidden by ${a.hidden.by}, ${a.hidden.at.slice(0, 10)}, ${a.hidden.reason}`; - else if (a.erased) withheld = 'Author erased their account'; - const choice = withheld ? undefined : a.answer?.choice; - return { - recordId: a.recordId, - who: a.erased ? 'erased account' : a.displayName ?? 'an account', - kind: a.kind, - choice: - choice === undefined ? undefined : q.options.find((o) => o.id === choice)?.text ?? choice, - text: withheld ? undefined : a.answer?.text, - at: a.answered_at, - withheld, - }; -}; - -/** Earlier answers by the same account fold under their latest; the page never counts them. */ -const standing = (answers: Answer[]): Answer[] => { - const latest = new Map(); - answers.forEach((a) => { - const key = a.answerer ?? a.recordId; - const held = latest.get(key); - if (!held || held.answered_at <= a.answered_at) latest.set(key, a); - }); - return [...latest.values()].sort((a, b) => a.answered_at.localeCompare(b.answered_at)); -}; - -/** Open questions the page would list: the one rule the navigator's count and the desk share. */ -export const countOpen = (p?: DeskProjection): number => - p?.questions.filter((q) => !isHiddenQuestion(q) && q.status.state === 'open').length ?? 0; +const answerRow = (a: DeskAnswer, entry: ForkEntry): AnswerRow => ({ + answerId: a.answer_id, + outcome: a.outcome, + choice: + a.choice === null + ? undefined + : entry.card.options.find((o) => o.id === a.choice)?.text ?? a.choice, + note: a.note ?? undefined, + target: a.of ?? a.retracts ?? undefined, + code: a.code ?? undefined, + at: a.answered_at, + counted: entry.record.counted_answer_id === a.answer_id, +}); export const buildDesk = ( - p: DeskProjection | undefined, - now: number, - viewerId?: string, + list: ForkList | undefined, unverified: ReadonlySet = new Set() ): Desk => { - if (!p?.syncedAt) { - return { - published: false, - stale: false, - open: [], - recorded: [], - counts: { open: 0, answered: 0 }, - }; - } - const known = new Map(); - p.questions.forEach((q) => !isHiddenQuestion(q) && known.set(q.fork_id, q)); - + if (!list) return { published: false, open: [], recorded: [], counts: { open: 0 } }; const open: DeskRow[] = []; const recorded: Desk['recorded'] = []; - let answered = 0; - [...p.questions] - .sort((a, b) => { - const at = (q: typeof a) => (isHiddenQuestion(q) ? '' : q.askedAt); - return at(a).localeCompare(at(b)); - }) - .forEach((q) => { - if (isHiddenQuestion(q)) { - open.push({ kind: 'hidden', forkId: q.fork_id, hidden: q.hidden }); - return; - } - if (q.status.state === 'answered') { - recorded.push({ forkId: q.fork_id, title: q.title, rulingId: q.status.rulingId }); - return; - } - if (q.status.state !== 'open') return; - if (unverified.has(q.fork_id)) { - open.push({ kind: 'unverified', forkId: q.fork_id }); - return; - } - const mine = standing(p.answers.filter((a) => a.fork_id === q.fork_id)); - if (viewerId && mine.some((a) => a.answerer === viewerId && !a.hidden)) answered += 1; - const days = ageDays(q.askedAt, now); - open.push({ - kind: 'question', - forkId: q.fork_id, - title: q.title, - body: q.body, - options: q.options.map((o) => ({ ...o, recommended: o.id === q.recommendation })), - recommended: q.options.find((o) => o.id === q.recommendation)?.text, - depends: q.dependsOn.map((d) => ({ - text: d.text, - status: d.questionId ? known.get(d.questionId)?.status.state : undefined, - })), - ageDays: days, - longOpen: days > LONG_OPEN_DAYS, - answers: mine.map((a) => answerRow(a, q)), - }); + list.forks.forEach((entry) => { + const { card, record } = entry; + if (card.status === 'closed') { + if (card.ruling !== null) + recorded.push({ forkId: card.fork_id, title: card.title, rulingId: card.ruling }); + return; + } + if (unverified.has(card.fork_id)) { + open.push({ kind: 'unverified', forkId: card.fork_id }); + return; + } + open.push({ + kind: 'question', + forkId: card.fork_id, + title: card.title, + context: card.context, + options: card.options.map((o) => ({ ...o, recommended: o.id === card.recommendation })), + recommended: card.options.find((o) => o.id === card.recommendation)?.text, + depends: card.depends, + answers: record.answers.map((a) => answerRow(a, entry)), }); - + }); return { published: true, - syncedAt: p.syncedAt, - stale: now - Date.parse(p.syncedAt) > STALE_MS, open, recorded, - counts: { open: open.filter((r) => r.kind === 'question').length, answered }, + counts: { open: list.open_count }, }; }; diff --git a/src/app/features/desk/feedback.ts b/src/app/features/desk/feedback.ts deleted file mode 100644 index 2b188b17..00000000 --- a/src/app/features/desk/feedback.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * The desk's typed view of the feedback projections in docs/mind-feedback-pages.md (sections 5 and 12): - * org.gamecult.feedback.question.v0 and org.gamecult.feedback.answer.v0. Bifrost serves them; the page - * reads them and stores nothing. `answerer`, `kind` and `weightPermille` are the receiver's stamp from the - * verified session; the client never supplies them, so nothing here has a write shape. - */ - -export type Hidden = { by: string; at: string; reason: string }; - -/** The status is derived in the owning Mind; the page shows it and never writes it. */ -export type QuestionStatus = - | { state: 'open' } - | { state: 'answered'; rulingId: string } - | { state: 'closed' }; - -export type Question = { - /** `:question: