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/harness/scene/ridgeDemo.ts b/harness/scene/ridgeDemo.ts index 931a1ad4..3606d14f 100644 --- a/harness/scene/ridgeDemo.ts +++ b/harness/scene/ridgeDemo.ts @@ -1,12 +1,29 @@ // Standalone page for the ridge scene: full window, no host app. `?tier=still|low|full` picks the -// tier, `?probe=1` shows the rAF median/p95 readout. Not shipped. +// tier, `?probe=1` shows the rAF median/p95 readout. The bake record (each chunk's cost, from +// the scene's `onBake`) and the long tasks the page saw are on `window.bakes` and `window.longTasks` +// for a driver to read. Not shipped. import { startBackgroundScene } from '../../src/app/features/scene'; import type { SceneTier } from '../../src/app/features/scene'; const q = new URLSearchParams(location.search); const tier = (q.get('tier') ?? 'full') as SceneTier; const canvas = document.getElementById('c') as HTMLCanvasElement; -startBackgroundScene({ tier }, {}, { canvas }).then((scene) => { - (window as unknown as { scene: unknown }).scene = scene; +const w = window as unknown as { scene: unknown; bakes: unknown[]; longTasks: number[]; firstViewMs: number }; +w.bakes = []; +w.longTasks = []; +try { + new PerformanceObserver((l) => l.getEntries().forEach((e) => w.longTasks.push(e.duration))).observe({ + entryTypes: ['longtask'], + }); +} catch { + // No long-task entries in this browser. +} +const t0 = performance.now(); +startBackgroundScene({ tier }, {}, { + canvas, + onBake: (b: unknown) => w.bakes.push(b), +} as never).then((scene) => { + w.firstViewMs = performance.now() - t0; + w.scene = scene; document.title = scene ? 'ridge: running' : 'ridge: unavailable (CSS ground)'; }); diff --git a/src/app/components/ServerConfigsLoader.test.ts b/src/app/components/ServerConfigsLoader.test.ts new file mode 100644 index 00000000..527895a0 --- /dev/null +++ b/src/app/components/ServerConfigsLoader.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { loadServerConfigs } from './ServerConfigsLoader'; + +const server = (authMetadata: () => Promise) => + ({ + getCapabilities: async () => ({}), + getMediaConfig: async () => ({ 'm.upload.size': 1 }), + getAuthMetadata: authMetadata, + } as unknown as Parameters[0]); + +describe('loadServerConfigs', () => { + afterEach(() => vi.restoreAllMocks()); + + it('logs nothing when the homeserver has no OIDC (no auth metadata came back)', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const configs = await loadServerConfigs( + server(async () => { + throw new Error('M_UNRECOGNIZED'); + }) + ); + expect(error).not.toHaveBeenCalled(); + expect(configs.authMetadata).toBeUndefined(); + expect(configs.mediaConfig).toEqual({ 'm.upload.size': 1 }); + }); + + it('still logs and drops auth metadata that came back malformed', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const configs = await loadServerConfigs(server(async () => ({ issuer: 'https://x' }))); + expect(error).toHaveBeenCalled(); + expect(configs.authMetadata).toBeUndefined(); + }); +}); diff --git a/src/app/components/ServerConfigsLoader.tsx b/src/app/components/ServerConfigsLoader.tsx index 3c8ce8eb..ea56d8d2 100644 --- a/src/app/components/ServerConfigsLoader.tsx +++ b/src/app/components/ServerConfigsLoader.tsx @@ -1,5 +1,10 @@ import { ReactNode, useCallback, useMemo } from 'react'; -import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk'; +import { + Capabilities, + MatrixClient, + validateAuthMetadata, + ValidatedAuthMetadata, +} from 'matrix-js-sdk'; import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback'; import { useMatrixClient } from '../hooks/useMatrixClient'; import { MediaConfig } from '../hooks/useMediaConfig'; @@ -14,35 +19,42 @@ export type ServerConfigs = { type ServerConfigsLoaderProps = { children: (configs: ServerConfigs) => ReactNode; }; +/** + * The homeserver's capabilities, media config and OIDC metadata. A homeserver with no OIDC (and the demo's pretend + * server) returns no auth metadata: there is nothing to validate, so nothing is logged. Metadata that did come back + * and fails validation is logged and dropped. + */ +export async function loadServerConfigs( + mx: Pick +): Promise { + const result = await Promise.allSettled([ + mx.getCapabilities(), + mx.getMediaConfig(), + mx.getAuthMetadata(), + ]); + + const capabilities = promiseFulfilledResult(result[0]); + const mediaConfig = promiseFulfilledResult(result[1]); + const authMetadata = promiseFulfilledResult(result[2]); + let validatedAuthMetadata: ValidatedAuthMetadata | undefined; + + if (authMetadata) { + try { + validatedAuthMetadata = validateAuthMetadata(authMetadata); + } catch (e) { + console.error(e); + } + } + + return { capabilities, mediaConfig, authMetadata: validatedAuthMetadata }; +} + export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) { const mx = useMatrixClient(); const fallbackConfigs = useMemo(() => ({}), []); const [configsState] = useAsyncCallbackValue( - useCallback(async () => { - const result = await Promise.allSettled([ - mx.getCapabilities(), - mx.getMediaConfig(), - mx.getAuthMetadata(), - ]); - - const capabilities = promiseFulfilledResult(result[0]); - const mediaConfig = promiseFulfilledResult(result[1]); - const authMetadata = promiseFulfilledResult(result[2]); - let validatedAuthMetadata: ValidatedAuthMetadata | undefined; - - try { - validatedAuthMetadata = validateAuthMetadata(authMetadata); - } catch (e) { - console.error(e); - } - - return { - capabilities, - mediaConfig, - authMetadata: validatedAuthMetadata, - }; - }, [mx]) + useCallback(() => loadServerConfigs(mx), [mx]) ); const configs: ServerConfigs = diff --git a/src/app/components/message/Reply.css.ts b/src/app/components/message/Reply.css.ts index 7e5540fd..d5254ecb 100644 --- a/src/app/components/message/Reply.css.ts +++ b/src/app/components/message/Reply.css.ts @@ -31,6 +31,7 @@ export const Reply = style({ }); export const ReplyContent = style({ + minWidth: 0, opacity: config.opacity.P300, selectors: { @@ -39,3 +40,19 @@ export const ReplyContent = style({ }, }, }); + +export const ReplyText = style({ + display: '-webkit-box', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: 2, + overflow: 'hidden', + minWidth: 0, + maxWidth: '100%', + overflowWrap: 'anywhere', + wordBreak: 'break-word', +}); + +export const ReplyWrap = style({ + minWidth: 0, + maxWidth: '100%', +}); diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 57bf2af9..894b3b03 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -101,7 +101,14 @@ export const Reply = as<'div', ReplyProps>( const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody; return ( - + {threadRootId && ( )} @@ -119,7 +126,7 @@ export const Reply = as<'div', ReplyProps>( onClick={onClick} > {replyEvent !== undefined ? ( - + {badEncryption ? : bodyJSX} ) : ( 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 a724241c..3037e66b 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -1,6 +1,7 @@ import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css'; import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; import { DefaultReset, color, config, toRem } from 'folds'; +import { MOBILE_BREAKPOINT } from '../../../hooks/useScreenSize'; export const StickySection = style({ position: 'sticky', @@ -152,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', }); @@ -198,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, @@ -266,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({ @@ -350,6 +284,9 @@ export const BubbleLeftArrow = style({ zIndex: 1, }); +// Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the +// negative margin takes the growth back out of the layout. Hit area only: no background, border or outline moves. +const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; export const Username = style({ overflow: 'hidden', whiteSpace: 'nowrap', @@ -357,6 +294,9 @@ export const Username = style({ selectors: { 'button&': { cursor: 'pointer', + '@media': { + [SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 }, + }, }, 'button&:hover, button&:focus-visible': { textDecoration: 'underline', @@ -395,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 75a4b333..05e60631 100644 --- a/src/app/components/message/pose/PoseAvatar.tsx +++ b/src/app/components/message/pose/PoseAvatar.tsx @@ -1,53 +1,126 @@ 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; - onClick: React.MouseEventHandler; + /** Without it the portrait is a plain image: no button, no tab stop. */ + 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 ( - + ); } diff --git a/src/app/components/message/pose/nest.test.ts b/src/app/components/message/pose/nest.test.ts new file mode 100644 index 00000000..d8e6f36e --- /dev/null +++ b/src/app/components/message/pose/nest.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { alphaBounds, nestView } from './nest'; +import boxes from './safeBoxes.json'; + +describe('nestView', () => { + // 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 new file mode 100644 index 00000000..f1695716 --- /dev/null +++ b/src/app/features/desk/Desk.tsx @@ -0,0 +1,56 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds'; +import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { getHomePath } from '../../pages/pathUtils'; +import { DeskView } from './DeskView'; +import { unverifiedForks } from './optionsHash'; +import { deskFor } from './source'; + +const labelOf = (mind: string) => mind.charAt(0).toUpperCase() + mind.slice(1); + +/** The route's page: one Mind's desk under the navigator. */ +export function Desk() { + const { mind = '' } = useParams(); + const navigate = useNavigate(); + const phone = useScreenSizeContext() === ScreenSize.Mobile; + const label = labelOf(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(list?.forks ?? []).then((bad) => live && setUnverified(bad)); + return () => { + live = false; + }; + }, [list]); + + return ( + + + + + {phone && ( + navigate(getHomePath())}> + + + )} + + {`${label}'s desk`} + + + + + + + + + + + + + + ); +} diff --git a/src/app/features/desk/DeskEntry.tsx b/src/app/features/desk/DeskEntry.tsx new file mode 100644 index 00000000..a36ec8e4 --- /dev/null +++ b/src/app/features/desk/DeskEntry.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Text } from 'folds'; +import { getDeskPath } from '../../pages/pathUtils'; +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; + if (open === 0) return null; + return ( + + {`Desk · ${open} open`} + + ); +} diff --git a/src/app/features/desk/DeskView.css.ts b/src/app/features/desk/DeskView.css.ts new file mode 100644 index 00000000..4c7d08a7 --- /dev/null +++ b/src/app/features/desk/DeskView.css.ts @@ -0,0 +1,99 @@ +import { globalStyle, style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; +import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; + +const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; + +/** Rows and booth side by side on wide screens. */ +export const Layout = style({ + display: 'flex', + gap: config.space.S500, + alignItems: 'flex-start', + '@media': { [PHONE]: { display: 'block', paddingBottom: toRem(120) } }, +}); + +export const Rows = style({ flex: 1, minWidth: 0, margin: 0, padding: 0, listStyle: 'none' }); + +/** A hairline splits rows; it is the only chrome. */ +export const Row = style({ + padding: `${config.space.S300} 0`, + borderTop: `1px solid ${color.Background.ContainerLine}`, +}); + +export const Title = style({ + margin: 0, + font: 'inherit', + fontWeight: 600, +}); + +/** The fold button reads as the title itself, not as a control. */ +export const Fold = style({ + all: 'unset', + cursor: 'pointer', + display: 'block', + width: '100%', + minHeight: 44, + boxSizing: 'border-box', +}); + +export const Quiet = style({ opacity: config.opacity.P300, margin: `${config.space.S100} 0` }); +export const Body = style({ whiteSpace: 'pre-wrap', margin: `${config.space.S200} 0` }); +export const Options = style({ margin: 0, padding: 0, listStyle: 'none' }); +export const Option = style({ padding: `${config.space.S100} 0` }); + +export const Note = style({ + display: 'block', + width: '100%', + boxSizing: 'border-box', + minHeight: 44, + margin: `${config.space.S200} 0`, + font: 'inherit', + color: 'inherit', + background: 'none', + border: `1px solid ${color.Background.ContainerLine}`, + opacity: config.opacity.Disabled, +}); + +/** Sticky beside the rows on wide screens; a small fixed corner image on phones. */ +export const Booth = style({ + position: 'sticky', + top: config.space.S400, + width: toRem(168), + flexShrink: 0, + '@media': { + [PHONE]: { + position: 'fixed', + top: 'auto', + right: config.space.S200, + bottom: config.space.S200, + width: toRem(88), + }, + }, +}); + +// PoseAvatar's button is laid out for message gutters; the booth takes it back into flow. +globalStyle(`${Booth} > [data-pose]`, { + position: 'static', + float: 'none', + display: 'block', + width: '100%', + height: 'auto', + margin: 0, + maskImage: 'none', + WebkitMaskImage: 'none', + cursor: 'default', +}); + +export const BoothLine = style({ + '@media': { + // Still read out by the live region; only the picture shows in the corner. + [PHONE]: { + position: 'absolute', + width: 1, + height: 1, + overflow: 'hidden', + clip: 'rect(0 0 0 0)', + whiteSpace: 'nowrap', + }, + }, +}); diff --git a/src/app/features/desk/DeskView.test.tsx b/src/app/features/desk/DeskView.test.tsx new file mode 100644 index 00000000..ede59229 --- /dev/null +++ b/src/app/features/desk/DeskView.test.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { MemoryRouter } from 'react-router-dom'; +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 { setDeskSource } from './source'; + +const view = ( + state: keyof typeof STATES | 'unpublished', + size: ScreenSize, + mind = 'void', + unverified = new Set() +) => + renderToStaticMarkup( + + + + ); + +describe('DeskView', () => { + it('folds a row behind its title on a phone, with the fold button saying so', () => { + const html = view('noAnswers', ScreenSize.Mobile); + expect(html).toContain('aria-expanded="false"'); + expect(html).toContain('Question plain'); + expect(html).not.toContain('Context for plain'); + expect(html).not.toContain('First way'); + }); + it('shows the row open, with no fold button, on a wide screen', () => { + const html = view('noAnswers', ScreenSize.Desktop); + expect(html).not.toContain('aria-expanded'); + expect(html).toContain('Context for plain'); + expect(html).toContain('First way'); + }); + it('states the recommendation in words, on that option only', () => { + const html = view('noAnswers', ScreenSize.Desktop); + expect(html.match(/\(recommended\)/g)).toHaveLength(1); + expect(html).toContain('B Second way (recommended)'); + }); + 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=""/); + expect(html).toContain(SIGN_IN_NOTE); + 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('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 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.'); + expect(view('unpublished', ScreenSize.Desktop)).toContain( + 'Void's questions have not been published yet.' + ); + }); + 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"'); + expect(html).toContain('Just one fork on the desk.'); + }); + it('makes the booth portrait a plain image: no button, no tab stop', () => { + const html = view('noAnswers', ScreenSize.Desktop); + const booth = html.slice(html.indexOf('data-pose')); + expect(booth).toContain(']*data-pose/); + expect(html).not.toMatch(/<[a-z]+[^>]*data-pose[^>]*tabindex/); + expect(html).toMatch(/]*data-pose/); + }); + it('withdraws a card whose hash does not match and does not count it open', () => { + 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'); + }); + it('shows no booth for a Mind without art rather than another face', () => { + expect(view('noAnswers', ScreenSize.Desktop, 'nobody')).not.toContain('data-pose'); + }); + it('shows no booth before anything is published', () => { + expect(view('unpublished', ScreenSize.Desktop)).not.toContain('data-pose'); + }); +}); + +describe('DeskEntry', () => { + afterEach(() => setDeskSource(() => undefined)); + const entry = (mind: string) => + renderToStaticMarkup( + + + + ); + it('reads "Desk · N open" and links to that Mind\'s desk', () => { + setDeskSource(() => STATES.blocked); + const html = entry('void'); + 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 })); + expect(entry('void')).toBe(''); + }); +}); diff --git a/src/app/features/desk/DeskView.tsx b/src/app/features/desk/DeskView.tsx new file mode 100644 index 00000000..e57bfa70 --- /dev/null +++ b/src/app/features/desk/DeskView.tsx @@ -0,0 +1,169 @@ +import React, { useState } from 'react'; +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 { deskMood } from './deskMood'; +import * as css from './DeskView.css'; + +/** Slice 1 has no session: every viewer is signed out, and the note says why it cannot be used. */ +export const SIGN_IN_NOTE = 'Answering opens with sign-in. Until then you can read the questions.'; + +const date = (iso: string) => iso.slice(0, 10); + +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) => ( +
  • + {`${answerLine(a)} (${date(a.at)})`} + {a.counted && ' (counted)'} +
  • + ))} +
+ ); +} + +function Row({ row }: { row: DeskRow }) { + const phone = useScreenSizeContext() === ScreenSize.Mobile; + // Phones list titles; a tap opens one. Wide screens show every row open. + const [open, setOpen] = useState(!phone); + if (row.kind === 'unverified') { + return ( +
  • +

    + A question here does not match the hash it was published with, so it is not shown. +

    +
  • + ); + } + const noteId = `note-${row.forkId}`; + return ( +
  • +

    + {phone ? ( + + ) : ( + row.title + )} +

    + {open && ( + <> +

    {row.context}

    +
      + {row.options.map((o) => ( +
    • + {`${o.id} ${o.text}`} + {o.recommended && ' (recommended)'} +
    • + ))} +
    + {row.depends.map((d) => ( +

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

    + ))} + Your note +