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/harness/scene/ridgeDemo.ts b/harness/scene/ridgeDemo.ts index 3606d14f..931a1ad4 100644 --- a/harness/scene/ridgeDemo.ts +++ b/harness/scene/ridgeDemo.ts @@ -1,29 +1,12 @@ // 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. 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. +// tier, `?probe=1` shows the rAF median/p95 readout. 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; -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; +startBackgroundScene({ tier }, {}, { canvas }).then((scene) => { + (window as unknown as { scene: unknown }).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 deleted file mode 100644 index 527895a0..00000000 --- a/src/app/components/ServerConfigsLoader.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 ea56d8d2..3c8ce8eb 100644 --- a/src/app/components/ServerConfigsLoader.tsx +++ b/src/app/components/ServerConfigsLoader.tsx @@ -1,10 +1,5 @@ import { ReactNode, useCallback, useMemo } from 'react'; -import { - Capabilities, - MatrixClient, - validateAuthMetadata, - ValidatedAuthMetadata, -} from 'matrix-js-sdk'; +import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk'; import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback'; import { useMatrixClient } from '../hooks/useMatrixClient'; import { MediaConfig } from '../hooks/useMediaConfig'; @@ -19,42 +14,35 @@ 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(() => loadServerConfigs(mx), [mx]) + 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]) ); const configs: ServerConfigs = diff --git a/src/app/components/message/Reply.css.ts b/src/app/components/message/Reply.css.ts index d5254ecb..7e5540fd 100644 --- a/src/app/components/message/Reply.css.ts +++ b/src/app/components/message/Reply.css.ts @@ -31,7 +31,6 @@ export const Reply = style({ }); export const ReplyContent = style({ - minWidth: 0, opacity: config.opacity.P300, selectors: { @@ -40,19 +39,3 @@ 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 894b3b03..57bf2af9 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -101,14 +101,7 @@ export const Reply = as<'div', ReplyProps>( const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody; return ( - + {threadRootId && ( )} @@ -126,7 +119,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 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..a724241c 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -1,7 +1,6 @@ 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', @@ -153,21 +152,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 +198,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 +266,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({ @@ -284,9 +350,6 @@ 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', @@ -294,9 +357,6 @@ export const Username = style({ selectors: { 'button&': { cursor: 'pointer', - '@media': { - [SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 }, - }, }, 'button&:hover, button&:focus-visible': { textDecoration: 'underline', @@ -335,12 +395,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..75a4b333 100644 --- a/src/app/components/message/pose/PoseAvatar.tsx +++ b/src/app/components/message/pose/PoseAvatar.tsx @@ -1,126 +1,53 @@ 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; - /** 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; + onClick: React.MouseEventHandler; }; 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]); + }, [src, alt]); - // 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 deleted file mode 100644 index d8e6f36e..00000000 --- a/src/app/components/message/pose/nest.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -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 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 deleted file mode 100644 index f1695716..00000000 --- a/src/app/features/desk/Desk.tsx +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index a36ec8e4..00000000 --- a/src/app/features/desk/DeskEntry.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 4c7d08a7..00000000 --- a/src/app/features/desk/DeskView.css.ts +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index ede59229..00000000 --- a/src/app/features/desk/DeskView.test.tsx +++ /dev/null @@ -1,150 +0,0 @@ -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 deleted file mode 100644 index e57bfa70..00000000 --- a/src/app/features/desk/DeskView.tsx +++ /dev/null @@ -1,169 +0,0 @@ -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 -