diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d2a138cf..9d5a27d9 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,8 +1,3 @@ -// 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, @@ -67,21 +62,6 @@ 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 0ebdf7c3..ed1bca6b 100644 --- a/config.json +++ b/config.json @@ -1,13 +1,6 @@ { - "defaultHomeserver": 0, - "homeserverList": [ - "gamecult.org", - "converser.eu", - "matrix.org", - "mozilla.org", - "unredacted.org", - "xmr.se" - ], + "defaultHomeserver": 1, + "homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"], "allowCustomHomeservers": true, "featuredCommunities": { "openAsDefault": false, @@ -31,10 +24,5 @@ "dings": { "enabled": false - }, - - "registration": { - "enabled": false, - "url": "" } } diff --git a/docs/mind-feedback-pages.md b/docs/mind-feedback-pages.md index 9ff45d08..cad15c42 100644 --- a/docs/mind-feedback-pages.md +++ b/docs/mind-feedback-pages.md @@ -1,7 +1,5 @@ # 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 e69f5b4a..656d532e 100644 --- a/harness/estate/check.cjs +++ b/harness/estate/check.cjs @@ -2,17 +2,12 @@ // 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, 5 when none ran (a drive that asserts nothing proves nothing). */ -exports.done = () => { - if (fails.length) return 4; - return asserted === 0 ? 5 : 0; -}; +/** Exit code for the drive: 4 when any assertion failed. */ +exports.done = () => (fails.length ? 4 : 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 deleted file mode 100644 index 97566997..00000000 --- a/harness/estate/check.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -// @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 bd2056b9..066a827f 100644 --- a/src/app/components/message/layout/Bubble.tsx +++ b/src/app/components/message/layout/Bubble.tsx @@ -30,8 +30,10 @@ 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 nests in a corner of the bubble. */ + /** A Mind's pose portrait; it replaces the avatar and sits beside (wide) or inside (phone) 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; @@ -39,7 +41,7 @@ type BubbleLayoutProps = { }; export const BubbleLayout = as<'div', BubbleLayoutProps>( - ({ hideBubble, own, pose, enterDelay, before, header, children, style, ...props }, ref) => { + ({ hideBubble, own, pose, alt, 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; @@ -48,7 +50,12 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>( @@ -60,9 +67,19 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>( - {header &&
{header}
} + {header && ( +
+ {header} +
+ )} {hideBubble ? ( children ) : ( @@ -72,7 +89,6 @@ 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 3037e66b..9c6b6635 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -153,21 +153,39 @@ export const BubbleContentArrowLeft = style({ borderTopLeftRadius: 0, }); -// 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(); +// 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). const PHONE = 'screen and (max-width: 700px)'; -const PORTRAIT = toRem(190); const PHONE_PORTRAIT = 'min(38vw, 160px)'; const PHONE_TEXT_FLOOR = toRem(170); -const BUBBLE_PAD = toRem(12); +const PORTRAIT_OVERHANG = 58; +const PORTRAIT = toRem(124); +const POSE_GUTTER = toRem(14); 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', }); @@ -181,29 +199,64 @@ export const BubbleHeaderOwn = style({ alignSelf: 'flex-end', }); -// 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({ - 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}))`, +// 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]: { - vars: { [portraitWidth]: PHONE_PORTRAIT }, + 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', }, }, }); -globalStyle(`${BubbleContentPose} > div`, { - display: 'block', +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', +}); + +export const BubbleContentPose = style({ + borderRadius: config.radii.R500, + borderTopLeftRadius: BUBBLE_CORNER, + '@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)}`, + }, + }, }); export const BubbleContentOwn = style({ @@ -214,37 +267,51 @@ 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({ - 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})`, + position: 'absolute', + top: 0, + left: `calc(-1 * (${PORTRAIT} + ${POSE_GUTTER}))`, + width: PORTRAIT, + height: PORTRAIT, 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({ - float: 'right', - margin: `calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) ${toRem(6)}`, + '@media': { + [PHONE]: { + float: 'right', + margin: `${toRem(-82)} ${toRem(-12)} 0 ${toRem(8)}`, + }, + }, }); export const PosePortrait = style({ - display: 'block', - width: portraitWidth, - maxWidth: 'none', - height: 'auto', - marginLeft: 'var(--pose-ix, 0)', - marginTop: 'var(--pose-iy, 0)', + width: '100%', + height: '100%', + objectFit: 'contain', + objectPosition: 'top', + '@media': { + [PHONE]: { + height: 'auto', + }, + }, }); const pop = keyframes({ @@ -335,12 +402,61 @@ 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`, { - listStylePosition: 'inside', - paddingLeft: 0, + '@media': { + [PHONE]: { + 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 05e60631..fac585a4 100644 --- a/src/app/components/message/pose/PoseAvatar.tsx +++ b/src/app/components/message/pose/PoseAvatar.tsx @@ -1,15 +1,14 @@ import React, { useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import * as css from '../layout/layout.css'; -import { poseSrc, safeBox } from './poses'; -import { alphaBounds, nestView, type Box } from './nest'; +import { poseSrc } from './poses'; import { silhouette } from './silhouette'; type PoseAvatarProps = { mind: string; pose: string; userId: string; - /** Nest in the bubble's top-right corner instead of the top-left. */ + /** Float on the right on phones. */ alt?: boolean; /** Play the first-appearance pop. */ enter?: boolean; @@ -17,110 +16,40 @@ 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); - // 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. + // 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. useEffect(() => { const img = imgRef.current; if (!img) return undefined; - const retrace = () => { + const trace = () => { if (!img.complete || img.naturalWidth === 0) return; try { - 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 - ) - ); + setShape(silhouette(img, !alt)); } catch { setShape(undefined); } }; - retrace(); - img.addEventListener('load', retrace); - const resize = new ResizeObserver(retrace); + trace(); + img.addEventListener('load', trace); + const resize = new ResizeObserver(trace); resize.observe(img); return () => { - img.removeEventListener('load', retrace); + img.removeEventListener('load', trace); resize.disconnect(); }; - }, [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; + }, [src, alt]); 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 deleted file mode 100644 index ffee1490..00000000 --- a/src/app/components/message/pose/nest.ts +++ /dev/null @@ -1,58 +0,0 @@ -// 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 b1f983ea..69429090 100644 --- a/src/app/components/message/pose/poses.ts +++ b/src/app/components/message/pose/poses.ts @@ -1,6 +1,4 @@ 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 @@ -42,14 +40,3 @@ 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 deleted file mode 100644 index eff7011e..00000000 --- a/src/app/components/message/pose/safeBoxes.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 ba9d5ecb..9deb3f4f 100644 --- a/src/app/components/message/pose/silhouette.test.ts +++ b/src/app/components/message/pose/silhouette.test.ts @@ -53,22 +53,4 @@ 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 019c0716..e9596ded 100644 --- a/src/app/components/message/pose/silhouette.ts +++ b/src/app/components/message/pose/silhouette.ts @@ -5,23 +5,14 @@ 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. -// -// `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); +export const silhouette = (img: HTMLImageElement, textOnRight: boolean): string | undefined => { + const w = Math.round(img.clientWidth); + const h = Math.round(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 - (view?.x ?? 0), 0 - (view?.y ?? 0), img.clientWidth, img.clientHeight); + ctx.drawImage(img, 0, 0, w, h); 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 deleted file mode 100644 index c4f4fc79..00000000 --- a/src/app/features/albums/AlbumView.css.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 28e0c477..00000000 --- a/src/app/features/albums/AlbumView.tsx +++ /dev/null @@ -1,141 +0,0 @@ -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 deleted file mode 100644 index 3a27bed6..00000000 --- a/src/app/features/albums/buildAlbums.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -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 deleted file mode 100644 index 8e57262b..00000000 --- a/src/app/features/albums/buildAlbums.ts +++ /dev/null @@ -1,121 +0,0 @@ -// 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 deleted file mode 100644 index 0e55269f..00000000 --- a/src/app/features/albums/isFileZone.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 071ef17b..00000000 --- a/src/app/features/albums/isFileZone.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 24898cb0..00000000 --- a/src/app/features/albums/mapLimit.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index e850add0..00000000 --- a/src/app/features/albums/mapLimit.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** 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 deleted file mode 100644 index a01f1efb..00000000 --- a/src/app/features/albums/useAlbums.ts +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 65beb9cb..00000000 --- a/src/app/features/albums/zoneView.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 1af7f705..00000000 --- a/src/app/features/albums/zoneView.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 f1695716..548be685 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 list = useMemo(() => deskFor(mind), [mind]); + const projection = 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(list?.forks ?? []).then((bad) => live && setUnverified(bad)); + unverifiedForks(projection?.questions ?? []).then((bad) => live && setUnverified(bad)); return () => { live = false; }; - }, [list]); + }, [projection]); return ( @@ -46,7 +46,13 @@ export function Desk() { - + diff --git a/src/app/features/desk/DeskEntry.tsx b/src/app/features/desk/DeskEntry.tsx index a36ec8e4..1d3068ab 100644 --- a/src/app/features/desk/DeskEntry.tsx +++ b/src/app/features/desk/DeskEntry.tsx @@ -2,11 +2,12 @@ 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 = deskFor(mind)?.open_count ?? 0; + const open = countOpen(deskFor(mind)); 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 ede59229..09d3f45a 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 { STATES } from './fixtures'; +import { NOW, STATES } from './fixtures'; import { setDeskSource } from './source'; const view = ( - state: keyof typeof STATES | 'unpublished', + state: keyof typeof STATES, size: ScreenSize, mind = 'void', unverified = new Set() @@ -19,7 +19,8 @@ const view = ( @@ -44,6 +45,11 @@ 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=""/); @@ -51,31 +57,21 @@ 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('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 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('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.'); @@ -83,6 +79,10 @@ 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,18 +100,8 @@ 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).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'); + expect(html).not.toContain('Just one fork'); + expect(html).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'); @@ -135,16 +125,12 @@ 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, open_count: 0 })); + setDeskSource(() => ({ ...STATES.settled, questions: STATES.settled.questions.slice(0, 2) })); expect(entry('void')).toBe(''); }); }); diff --git a/src/app/features/desk/DeskView.tsx b/src/app/features/desk/DeskView.tsx index e57bfa70..fed7d18e 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, buildDesk, DeskRow } from './buildDesk'; -import { ForkList } from './forkContract'; +import { AnswerRow, Desk, DeskRow, buildDesk } from './buildDesk'; +import { DeskProjection } from './feedback'; import { deskMood } from './deskMood'; import * as css from './DeskView.css'; @@ -13,32 +13,21 @@ export const SIGN_IN_NOTE = 'Answering opens with sign-in. Until then you can re const date = (iso: string) => iso.slice(0, 10); -const OUTCOME: Record = { - Counted: 'Answer', - Revision: 'Revision', - Retraction: 'Retraction', - Refused: 'Refused', +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`; }; -/** 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) => ( -
  • - {`${answerLine(a)} (${date(a.at)})`} - {a.counted && ' (counted)'} +
  • + {`${a.who}, ${a.kind.toLowerCase()}: `} + {a.withheld ?? [a.choice, a.text && `"${a.text}"`].filter(Boolean).join(' ')} + {` (${date(a.at)})`}
  • ))}
@@ -58,6 +47,17 @@ 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,9 +75,10 @@ function Row({ row }: { row: DeskRow }) { row.title )} +

    {age(row)}

    {open && ( <> -

    {row.context}

    +

    {row.body}

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

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

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

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

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

    ); } @@ -123,16 +126,16 @@ type DeskViewProps = { /** The Mind's key, as the navigator groups it. */ mind: string; label: string; - list?: ForkList; + projection?: DeskProjection; + now: number; /** 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, 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); +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); return (
    diff --git a/src/app/features/desk/buildDesk.test.ts b/src/app/features/desk/buildDesk.test.ts index e4a81248..29d2df40 100644 --- a/src/app/features/desk/buildDesk.test.ts +++ b/src/app/features/desk/buildDesk.test.ts @@ -1,105 +1,127 @@ import { describe, expect, it } from 'vitest'; -import { buildDesk } from './buildDesk'; -import { STATES, entry, list } from './fixtures'; +import { LONG_OPEN_DAYS, buildDesk, countOpen } from './buildDesk'; +import { NOW, STATES } from './fixtures'; const questions = (d: ReturnType) => d.open.flatMap((r) => (r.kind === 'question' ? [r] : [])); describe('buildDesk', () => { - 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, + 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, open: [], - counts: { open: 0 }, + counts: { open: 0, answered: 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))[0].answers).toEqual([]); + 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]); }); it('resolves the recommendation to its option, marks only that option, and never preselects', () => { - const [row] = questions(buildDesk(STATES.noAnswers)); + const [row] = questions(buildDesk(STATES.noAnswers, NOW)); 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 [row] = questions(buildDesk(list([entry('x', { recommendation: 'Z' })]))); + const p = structuredClone(STATES.noAnswers); + (p.questions[0] as { recommendation?: string }).recommendation = 'Z'; + const [row] = questions(buildDesk(p, NOW)); expect(row.recommended).toBeUndefined(); expect(row.options.some((o) => o.recommended)).toBe(false); }); - it('carries the depends lines as the text they are', () => { - const rows = questions(buildDesk(STATES.blocked)); + it('carries the status of a cited dependency, and none for one that cites no question', () => { + const rows = questions(buildDesk(STATES.blocked, NOW)); expect(rows.find((r) => r.forkId.endsWith('second'))?.depends).toEqual([ - 'Needs the first one settled', + { text: 'Needs the first one settled', status: 'open' }, ]); expect(rows.find((r) => r.forkId.endsWith('first'))?.depends).toEqual([]); }); - it('lists a closed card with a ruling as recorded, not open; omits one closed with no ruling', () => { - const d = buildDesk(STATES.settled); + 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); 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('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('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('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('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('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('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('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' }]); + 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('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); }); }); diff --git a/src/app/features/desk/buildDesk.ts b/src/app/features/desk/buildDesk.ts index e09dec1f..4c8fee6f 100644 --- a/src/app/features/desk/buildDesk.ts +++ b/src/app/features/desk/buildDesk.ts @@ -1,95 +1,164 @@ -import { DeskAnswer, ForkEntry, ForkList } from './forkContract'; +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; export type AnswerRow = { - answerId: string; - outcome: DeskAnswer['outcome']; + recordId: string; + /** Never a handle: the name at answer time, or the words for an account that has none. */ + who: string; + kind: Answer['kind']; /** The option's text for a choice. */ choice?: 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; + text?: string; at: string; - /** The record's counted answer: marked as served, never derived here. */ - counted: boolean; + /** Why the content is not shown: hidden by the Mind, or the author's account was erased. */ + withheld?: string; }; 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; - context: string; + body: 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: string[]; - /** In admission order, as served. */ + depends: { text: string; status?: QuestionStatus['state'] }[]; + ageDays: number; + longOpen: boolean; answers: AnswerRow[]; }; export type Desk = { - /** False until the list first arrives: "not published yet", never "no questions". */ + /** False until the projection first arrives: "not published yet", never "no questions". */ published: boolean; - /** Oldest first, by the card's admission ordinal. */ + syncedAt?: string; + stale: boolean; + /** Oldest first. */ open: DeskRow[]; - /** Closed with a ruling id. A card closed with no ruling (withdrawn) is not listed. */ + /** Settled questions with the Mind's ruling id. Closed (withdrawn) questions are not listed. */ recorded: { forkId: string; title: string; rulingId: string }[]; - /** The list's own open_count: the one count the navigator, the status line and the booth share. */ - counts: { open: number }; + /** Derived for the booth mood. The page tallies nothing about answers. */ + counts: { open: number; answered: number }; }; -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, -}); +const ageDays = (iso: string, now: number) => + Math.max(0, Math.floor((now - Date.parse(iso)) / DAY_MS)); -export const buildDesk = ( - list: ForkList | undefined, - unverified: ReadonlySet = new Set() -): Desk => { - if (!list) return { published: false, open: [], recorded: [], counts: { open: 0 } }; - const open: DeskRow[] = []; - const recorded: Desk['recorded'] = []; - 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)), - }); - }); +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 { - published: true, - open, - recorded, - counts: { open: list.open_count }, + 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; + +export const buildDesk = ( + p: DeskProjection | undefined, + now: number, + viewerId?: string, + 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)); + + 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)), + }); + }); + + 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 }, }; }; diff --git a/src/app/features/desk/feedback.ts b/src/app/features/desk/feedback.ts new file mode 100644 index 00000000..2b188b17 --- /dev/null +++ b/src/app/features/desk/feedback.ts @@ -0,0 +1,70 @@ +/** + * 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: