Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08bf515997 | |||
| f8115609b2 | |||
| 450a7e91ff | |||
| 94c976632e | |||
| b6da92a081 | |||
| 7045532187 | |||
| ac7a958f5c | |||
| 3def499b0f | |||
| 8e0e3b8986 | |||
| c286c4adc4 | |||
| 1fe3e2c92f | |||
|
|
d172c4eda3 | ||
|
|
9391e4753f | ||
| 313d070dd4 | |||
| 51d5fb0909 | |||
|
|
0f82576d9f | ||
|
|
8dea8717ea | ||
|
|
0351f1f6e8 | ||
|
|
de6f1849ea | ||
|
|
06d530d061 | ||
| 84770a2d2c | |||
|
|
5bb78b21cf | ||
| 7bac860016 | |||
| 683d3b92f8 |
92 changed files with 5262 additions and 603 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
16
config.json
16
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": ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
35
harness/estate/check.test.ts
Normal file
35
harness/estate/check.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)';
|
||||
});
|
||||
|
|
|
|||
32
src/app/components/ServerConfigsLoader.test.ts
Normal file
32
src/app/components/ServerConfigsLoader.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadServerConfigs } from './ServerConfigsLoader';
|
||||
|
||||
const server = (authMetadata: () => Promise<unknown>) =>
|
||||
({
|
||||
getCapabilities: async () => ({}),
|
||||
getMediaConfig: async () => ({ 'm.upload.size': 1 }),
|
||||
getAuthMetadata: authMetadata,
|
||||
} as unknown as Parameters<typeof loadServerConfigs>[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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<MatrixClient, 'getCapabilities' | 'getMediaConfig' | 'getAuthMetadata'>
|
||||
): Promise<ServerConfigs> {
|
||||
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<ServerConfigs, unknown>(
|
||||
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 =
|
||||
|
|
|
|||
|
|
@ -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%',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -101,7 +101,14 @@ export const Reply = as<'div', ReplyProps>(
|
|||
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
|
||||
|
||||
return (
|
||||
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
|
||||
<Box
|
||||
className={css.ReplyWrap}
|
||||
direction="Row"
|
||||
gap="200"
|
||||
alignItems="Center"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{threadRootId && (
|
||||
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
|
||||
)}
|
||||
|
|
@ -119,7 +126,7 @@ export const Reply = as<'div', ReplyProps>(
|
|||
onClick={onClick}
|
||||
>
|
||||
{replyEvent !== undefined ? (
|
||||
<Text size="T300" truncate>
|
||||
<Text size="T300" className={css.ReplyText}>
|
||||
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
|
||||
</Text>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>(
|
|||
<Box
|
||||
gap="300"
|
||||
{...props}
|
||||
className={classNames(
|
||||
poseRow && css.BubbleRowPose,
|
||||
pose && css.BubbleRowPortrait,
|
||||
own && css.BubbleRowOwn,
|
||||
props.className
|
||||
)}
|
||||
className={classNames(own && css.BubbleRowOwn, props.className)}
|
||||
style={entering ? { ...style, ['--enter-delay' as string]: `${enterDelay}ms` } : style}
|
||||
ref={ref}
|
||||
>
|
||||
|
|
@ -67,19 +60,9 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
|||
<Box
|
||||
grow={own ? 'No' : 'Yes'}
|
||||
direction="Column"
|
||||
className={classNames(poseRow && css.BubbleColumnPose, own && css.BubbleColumnOwn)}
|
||||
className={classNames(own && css.BubbleColumnOwn)}
|
||||
>
|
||||
{header && (
|
||||
<div
|
||||
className={classNames(
|
||||
pose && css.BubbleHeaderPose,
|
||||
pose && alt && css.BubbleHeaderPoseAlt,
|
||||
own && css.BubbleHeaderOwn
|
||||
)}
|
||||
>
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
{header && <div className={classNames(own && css.BubbleHeaderOwn)}>{header}</div>}
|
||||
{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
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<typeof MessageTextBody>;
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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<HTMLButtonElement>;
|
||||
/** Without it the portrait is a plain image: no button, no tab stop. */
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
// Safe boxes derived from art's alpha, by source; the art is traced once.
|
||||
const traced = new Map<string, { box: Box; size: [number, number] }>();
|
||||
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<HTMLImageElement>(null);
|
||||
const [shape, setShape] = useState<string>();
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
<Tag
|
||||
type={onClick ? 'button' : undefined}
|
||||
className={classNames(css.PoseButton, alt && css.PoseButtonAlt, enter && css.EnterPortrait)}
|
||||
style={{ shapeOutside: shape }}
|
||||
style={{ ...cropStyle, shapeOutside: shape }}
|
||||
data-user-id={userId}
|
||||
data-pose={pose}
|
||||
onClick={onClick}
|
||||
|
|
@ -59,6 +132,6 @@ export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvat
|
|||
alt={`${mind}, ${pose}`}
|
||||
draggable={false}
|
||||
/>
|
||||
</button>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
72
src/app/components/message/pose/nest.test.ts
Normal file
72
src/app/components/message/pose/nest.test.ts
Normal file
|
|
@ -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<string, number[]>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
58
src/app/components/message/pose/nest.ts
Normal file
58
src/app/components/message/pose/nest.ts
Normal file
|
|
@ -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<number>,
|
||||
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;
|
||||
};
|
||||
|
|
@ -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<string, Record<string, number[]>>;
|
||||
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;
|
||||
};
|
||||
|
|
|
|||
19
src/app/components/message/pose/safeBoxes.json
Normal file
19
src/app/components/message/pose/safeBoxes.json
Normal file
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
51
src/app/features/albums/AlbumView.css.ts
Normal file
51
src/app/features/albums/AlbumView.css.ts
Normal file
|
|
@ -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 });
|
||||
141
src/app/features/albums/AlbumView.tsx
Normal file
141
src/app/features/albums/AlbumView.tsx
Normal file
|
|
@ -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<string, unknown>) => {
|
||||
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 (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T300" truncate>
|
||||
{file.name}
|
||||
</Text>
|
||||
{audio ? (
|
||||
<AudioContent {...audio} renderMediaControl={(p) => <MediaControl {...p} />} />
|
||||
) : (
|
||||
<RenderMessageContent
|
||||
displayName={author}
|
||||
msgType={file.content.msgtype as string}
|
||||
ts={file.ts}
|
||||
getContent={<T,>() => file.content as T}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={LINKIFY_OPTS}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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<string>();
|
||||
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 = <Text>Loading albums…</Text>;
|
||||
else if (error) body = <Text>Could not load albums: {error.message}</Text>;
|
||||
else if (open) {
|
||||
const images = open.files.length > 0 && open.files.every((f) => f.kind === 'image');
|
||||
body = (
|
||||
<>
|
||||
<button type="button" className={css.Back} onClick={() => setOpenId(undefined)}>
|
||||
<Text size="B400">‹ Albums</Text>
|
||||
</button>
|
||||
<Text size="H4">{open.title}</Text>
|
||||
<Text size="T200" className={css.Dim}>
|
||||
{authorOf(open)} · {countLabel(open.count)} · {timeDayMonYear(open.ts, dateFormat)}
|
||||
</Text>
|
||||
{open.count === 0 && <Text>This album has no files.</Text>}
|
||||
<div className={images ? `${css.Files} ${css.FilesWide}` : css.Files}>
|
||||
{open.files.map((f) => (
|
||||
<FileRow key={f.id} room={room} file={f} author={authorOf(open)} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else if (encrypted)
|
||||
body = <Text>This zone is encrypted, so its albums cannot be listed here.</Text>;
|
||||
else if (albums.length === 0) body = <Text>No albums yet.</Text>;
|
||||
else {
|
||||
body = albums.map((a) => (
|
||||
<button key={a.id} type="button" className={css.Row} onClick={() => setOpenId(a.id)}>
|
||||
<Text size="T400">{a.title}</Text>
|
||||
<Text size="T200" className={css.Dim}>
|
||||
{authorOf(a)} · {countLabel(a.count)} · {timeDayMonYear(a.ts, dateFormat)}
|
||||
</Text>
|
||||
</button>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<div className={css.Column}>
|
||||
{body}
|
||||
<button type="button" className={css.Row} onClick={onTimeline}>
|
||||
<Text size="B400">Timeline ›</Text>
|
||||
</button>
|
||||
</div>
|
||||
</Scroll>
|
||||
);
|
||||
}
|
||||
216
src/app/features/albums/buildAlbums.test.ts
Normal file
216
src/app/features/albums/buildAlbums.test.ts
Normal file
|
|
@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
121
src/app/features/albums/buildAlbums.ts
Normal file
121
src/app/features/albums/buildAlbums.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
unsigned?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, FileKind>([
|
||||
['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<string, unknown> } =>
|
||||
!!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, unknown> }): string => {
|
||||
const relations = root.unsigned?.['m.relations'] as Record<string, unknown> | undefined;
|
||||
const edit = relations?.['m.replace'] as ZoneEvent | undefined;
|
||||
const edited = (edit?.content?.['m.new_content'] as Record<string, unknown> | 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<string>();
|
||||
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<string>();
|
||||
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);
|
||||
};
|
||||
24
src/app/features/albums/isFileZone.test.ts
Normal file
24
src/app/features/albums/isFileZone.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
14
src/app/features/albums/isFileZone.ts
Normal file
14
src/app/features/albums/isFileZone.ts
Normal file
|
|
@ -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));
|
||||
29
src/app/features/albums/mapLimit.test.ts
Normal file
29
src/app/features/albums/mapLimit.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
19
src/app/features/albums/mapLimit.ts
Normal file
19
src/app/features/albums/mapLimit.ts
Normal file
|
|
@ -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 <T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>
|
||||
): Promise<R[]> => {
|
||||
const out = new Array<R>(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;
|
||||
};
|
||||
73
src/app/features/albums/useAlbums.ts
Normal file
73
src/app/features/albums/useAlbums.ts
Normal file
|
|
@ -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<Album[]> => {
|
||||
const get = (path: string, params: Record<string, string>) =>
|
||||
mx.http.authedRequest<Chunk>(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 ?? [],
|
||||
};
|
||||
};
|
||||
16
src/app/features/albums/zoneView.test.ts
Normal file
16
src/app/features/albums/zoneView.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
5
src/app/features/albums/zoneView.ts
Normal file
5
src/app/features/albums/zoneView.ts
Normal file
|
|
@ -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';
|
||||
56
src/app/features/desk/Desk.tsx
Normal file
56
src/app/features/desk/Desk.tsx
Normal file
|
|
@ -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<ReadonlySet<string>>(new Set());
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
unverifiedForks(list?.forks ?? []).then((bad) => live && setUnverified(bad));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [list]);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader balance>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Box grow="Yes" basis="No">
|
||||
{phone && (
|
||||
<IconButton aria-label="Back to rooms" onClick={() => navigate(getHomePath())}>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<Text size="H3" truncate>{`${label}'s desk`}</Text>
|
||||
<Box grow="Yes" basis="No" />
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box style={{ position: 'relative' }} grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<PageContentCenter>
|
||||
<DeskView mind={mind} label={label} list={list} unverified={unverified} />
|
||||
</PageContentCenter>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
16
src/app/features/desk/DeskEntry.tsx
Normal file
16
src/app/features/desk/DeskEntry.tsx
Normal file
|
|
@ -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 (
|
||||
<Text as={Link} to={getDeskPath(mind)} size="T200" priority="300">
|
||||
{`Desk · ${open} open`}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
99
src/app/features/desk/DeskView.css.ts
Normal file
99
src/app/features/desk/DeskView.css.ts
Normal file
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
150
src/app/features/desk/DeskView.test.tsx
Normal file
150
src/app/features/desk/DeskView.test.tsx
Normal file
|
|
@ -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<string>()
|
||||
) =>
|
||||
renderToStaticMarkup(
|
||||
<ScreenSizeProvider value={size}>
|
||||
<DeskView
|
||||
mind={mind}
|
||||
label="Void"
|
||||
list={state === 'unpublished' ? undefined : STATES[state]}
|
||||
unverified={unverified}
|
||||
/>
|
||||
</ScreenSizeProvider>
|
||||
);
|
||||
|
||||
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(/<textarea[^>]* 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('<img');
|
||||
expect(html).not.toMatch(/<button[^>]*data-pose/);
|
||||
expect(html).not.toMatch(/<[a-z]+[^>]*data-pose[^>]*tabindex/);
|
||||
expect(html).toMatch(/<span[^>]*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(
|
||||
<ScreenSizeProvider value={ScreenSize.Desktop}>
|
||||
<DeskView mind="void" label="Void" list={{ ...STATES.empty, open_count: 3 }} />
|
||||
</ScreenSizeProvider>
|
||||
);
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<DeskEntry mind={mind} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
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('');
|
||||
});
|
||||
});
|
||||
169
src/app/features/desk/DeskView.tsx
Normal file
169
src/app/features/desk/DeskView.tsx
Normal file
|
|
@ -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<AnswerRow['outcome'], string> = {
|
||||
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 <p className={css.Quiet}>No answers yet.</p>;
|
||||
return (
|
||||
<ul className={css.Options}>
|
||||
{answers.map((a) => (
|
||||
<li key={a.answerId} className={css.Option}>
|
||||
{`${answerLine(a)} (${date(a.at)})`}
|
||||
{a.counted && ' (counted)'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<li className={css.Row}>
|
||||
<p className={css.Quiet}>
|
||||
A question here does not match the hash it was published with, so it is not shown.
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const noteId = `note-${row.forkId}`;
|
||||
return (
|
||||
<li className={css.Row}>
|
||||
<h3 className={css.Title}>
|
||||
{phone ? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.Fold}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{row.title}
|
||||
</button>
|
||||
) : (
|
||||
row.title
|
||||
)}
|
||||
</h3>
|
||||
{open && (
|
||||
<>
|
||||
<p className={css.Body}>{row.context}</p>
|
||||
<ul className={css.Options}>
|
||||
{row.options.map((o) => (
|
||||
<li key={o.id} className={css.Option}>
|
||||
{`${o.id} ${o.text}`}
|
||||
{o.recommended && ' (recommended)'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{row.depends.map((d) => (
|
||||
<p key={d} className={css.Quiet}>
|
||||
{`Waits on: "${d}"`}
|
||||
</p>
|
||||
))}
|
||||
<span id={`${noteId}-label`}>Your note</span>
|
||||
<textarea
|
||||
className={css.Note}
|
||||
disabled
|
||||
aria-labelledby={`${noteId}-label`}
|
||||
aria-describedby={`${noteId}-why`}
|
||||
/>
|
||||
<p id={`${noteId}-why`} className={css.Quiet}>
|
||||
{SIGN_IN_NOTE}
|
||||
</p>
|
||||
<Answers answers={row.answers} />
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Status({ desk, label }: { desk: Desk; label: string }) {
|
||||
if (!desk.published) return <p>{`${label}'s questions have not been published yet.`}</p>;
|
||||
return (
|
||||
<p role="status">
|
||||
{desk.counts.open === 0
|
||||
? `Nothing is waiting on you from ${label}. `
|
||||
: `${desk.counts.open} open.`}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
type DeskViewProps = {
|
||||
/** The Mind's key, as the navigator groups it. */
|
||||
mind: string;
|
||||
label: string;
|
||||
list?: ForkList;
|
||||
/** Fork ids whose options_hash failed the recompute (optionsHash.ts). */
|
||||
unverified?: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
/** One Mind's desk, read-only: a stream of rows, the recorded rulings, and the booth Persona. */
|
||||
export function DeskView({ mind, label, list, unverified }: DeskViewProps) {
|
||||
const desk = buildDesk(list, unverified);
|
||||
// Slice 1 has no session, so nothing on the desk is the viewer's answer yet.
|
||||
const mood = deskMood(desk.counts.open, 0);
|
||||
return (
|
||||
<section aria-label={`${label}'s desk`}>
|
||||
<Status desk={desk} label={label} />
|
||||
<div className={css.Layout}>
|
||||
<ol className={css.Rows}>
|
||||
{desk.open.map((row) => (
|
||||
<Row key={row.forkId} row={row} />
|
||||
))}
|
||||
</ol>
|
||||
{/* Only Minds with art in the catalogue get a booth; none gets another Mind's face. */}
|
||||
{desk.published && MINDS.includes(mind) && (
|
||||
<div className={css.Booth}>
|
||||
<PoseAvatar mind={mind} pose={mood.pose} userId={mind} />
|
||||
<Text className={css.BoothLine} size="T200" aria-live="polite">
|
||||
{mood.line}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{desk.recorded.length > 0 && (
|
||||
<>
|
||||
<h3 className={css.Title}>Recorded as rulings</h3>
|
||||
<ul className={css.Options}>
|
||||
{desk.recorded.map((r) => (
|
||||
<li key={r.forkId} className={css.Option}>
|
||||
{`${r.title} (${r.rulingId})`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
105
src/app/features/desk/buildDesk.test.ts
Normal file
105
src/app/features/desk/buildDesk.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { buildDesk } from './buildDesk';
|
||||
import { STATES, entry, list } from './fixtures';
|
||||
|
||||
const questions = (d: ReturnType<typeof buildDesk>) =>
|
||||
d.open.flatMap((r) => (r.kind === 'question' ? [r] : []));
|
||||
|
||||
describe('buildDesk', () => {
|
||||
it('says "not published" for a list that never arrived, never "no questions"', () => {
|
||||
expect(buildDesk(undefined)).toMatchObject({ published: false, open: [], counts: { open: 0 } });
|
||||
});
|
||||
it('is published and empty when the list holds nothing', () => {
|
||||
expect(buildDesk(STATES.empty)).toMatchObject({
|
||||
published: true,
|
||||
open: [],
|
||||
counts: { open: 0 },
|
||||
});
|
||||
});
|
||||
it('shows no answers as an empty list on an open row', () => {
|
||||
expect(questions(buildDesk(STATES.noAnswers))[0].answers).toEqual([]);
|
||||
});
|
||||
it('resolves the recommendation to its option, marks only that option, and never preselects', () => {
|
||||
const [row] = questions(buildDesk(STATES.noAnswers));
|
||||
expect(row.recommended).toBe('Second way');
|
||||
expect(row.options.filter((o) => o.recommended).map((o) => o.id)).toEqual(['B']);
|
||||
});
|
||||
it('leaves the recommendation unresolved when it names no option', () => {
|
||||
const [row] = questions(buildDesk(list([entry('x', { recommendation: 'Z' })])));
|
||||
expect(row.recommended).toBeUndefined();
|
||||
expect(row.options.some((o) => o.recommended)).toBe(false);
|
||||
});
|
||||
it('carries the depends lines as the text they are', () => {
|
||||
const rows = questions(buildDesk(STATES.blocked));
|
||||
expect(rows.find((r) => r.forkId.endsWith('second'))?.depends).toEqual([
|
||||
'Needs the first one settled',
|
||||
]);
|
||||
expect(rows.find((r) => r.forkId.endsWith('first'))?.depends).toEqual([]);
|
||||
});
|
||||
it('lists a closed card with a ruling as recorded, not open; omits one closed with no ruling', () => {
|
||||
const d = buildDesk(STATES.settled);
|
||||
expect(d.recorded).toEqual([
|
||||
{ forkId: 'c:question:done', title: 'Question done', rulingId: 'ruling:done-1' },
|
||||
]);
|
||||
expect(d.open.map((r) => r.forkId)).toEqual(['c:question:live']);
|
||||
});
|
||||
it('keeps the served order (newest first by card.order) and sorts nothing itself', () => {
|
||||
const served = list([entry('late', { order: 9 }), entry('early', { order: 2 })]);
|
||||
expect(buildDesk(served).open.map((r) => r.forkId)).toEqual([
|
||||
'c:question:late',
|
||||
'c:question:early',
|
||||
]);
|
||||
const odd = list([entry('a', { order: 2 }), entry('b', { order: 9 })]);
|
||||
expect(buildDesk(odd).open.map((r) => r.forkId)).toEqual(['c:question:a', 'c:question:b']);
|
||||
});
|
||||
it("takes the open count from the list's open_count, not from the rows it could verify or carries", () => {
|
||||
const d = buildDesk({ ...list([entry('only')]), open_count: 4 }, new Set(['c:question:only']));
|
||||
expect(d.counts.open).toBe(4);
|
||||
});
|
||||
it('marks the answer the record names as counted, and no other', () => {
|
||||
const [row] = questions(buildDesk(STATES.answered));
|
||||
expect(row.answers.filter((a) => a.counted).map((a) => a.answerId)).toEqual(['n1']);
|
||||
});
|
||||
it('follows the record when a revision is the counted answer: nothing is re-derived', () => {
|
||||
const e = STATES.answered.forks[0];
|
||||
const moved = list([{ ...e, record: { ...e.record, counted_answer_id: 'n2' } }]);
|
||||
expect(
|
||||
questions(buildDesk(moved))[0]
|
||||
.answers.filter((a) => a.counted)
|
||||
.map((a) => a.answerId)
|
||||
).toEqual(['n2']);
|
||||
});
|
||||
it('keeps answers in the served order and carries each outcome, target and refusal code', () => {
|
||||
const [row] = questions(buildDesk(STATES.answered));
|
||||
expect(row.answers.map((a) => [a.answerId, a.outcome, a.target, a.code])).toEqual([
|
||||
['n1', 'Counted', undefined, undefined],
|
||||
['n2', 'Revision', 'n1', undefined],
|
||||
['n3', 'Retraction', 'n2', undefined],
|
||||
['n4', 'Refused', undefined, 'not_a_decider'],
|
||||
['n5', 'Counted', undefined, undefined],
|
||||
]);
|
||||
});
|
||||
it('shows a choice as option text, passes an unknown id through, and leaves a null choice absent', () => {
|
||||
const [row] = questions(buildDesk(STATES.answered));
|
||||
expect(row.answers.map((a) => a.choice)).toEqual([
|
||||
'First way',
|
||||
'Third way',
|
||||
undefined,
|
||||
'Second way',
|
||||
undefined,
|
||||
]);
|
||||
const odd = list([
|
||||
entry('odd', {}, [{ ...STATES.answered.forks[0].record.answers[0], choice: 'Q' }]),
|
||||
]);
|
||||
expect(questions(buildDesk(odd))[0].answers[0].choice).toBe('Q');
|
||||
});
|
||||
it('carries a note verbatim when present and leaves it absent when null', () => {
|
||||
const [row] = questions(buildDesk(STATES.answered));
|
||||
expect(row.answers[0].note).toBe('Because it is first.');
|
||||
expect(row.answers[1].note).toBeUndefined();
|
||||
});
|
||||
it('withdraws an unverified open card and does not count it', () => {
|
||||
const d = buildDesk(STATES.noAnswers, new Set(['c:question:plain']));
|
||||
expect(d.open).toEqual([{ kind: 'unverified', forkId: 'c:question:plain' }]);
|
||||
});
|
||||
});
|
||||
95
src/app/features/desk/buildDesk.ts
Normal file
95
src/app/features/desk/buildDesk.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { DeskAnswer, ForkEntry, ForkList } from './forkContract';
|
||||
|
||||
export type AnswerRow = {
|
||||
answerId: string;
|
||||
outcome: DeskAnswer['outcome'];
|
||||
/** The option's text for a choice. */
|
||||
choice?: string;
|
||||
/** Verbatim, and only when the server sent one. */
|
||||
note?: string;
|
||||
/** The answer a Revision or Retraction points at. */
|
||||
target?: string;
|
||||
/** A refusal's code. */
|
||||
code?: string;
|
||||
at: string;
|
||||
/** The record's counted answer: marked as served, never derived here. */
|
||||
counted: boolean;
|
||||
};
|
||||
|
||||
export type DeskRow =
|
||||
/** Its published options_hash is not the hash of what it shows: neither shown nor counted. */
|
||||
| { kind: 'unverified'; forkId: string }
|
||||
| {
|
||||
kind: 'question';
|
||||
forkId: string;
|
||||
title: string;
|
||||
context: string;
|
||||
options: { id: string; text: string; recommended: boolean }[];
|
||||
/** The recommendation resolved to its option's text; absent if it names no option. */
|
||||
recommended?: string;
|
||||
depends: string[];
|
||||
/** In admission order, as served. */
|
||||
answers: AnswerRow[];
|
||||
};
|
||||
|
||||
export type Desk = {
|
||||
/** False until the list first arrives: "not published yet", never "no questions". */
|
||||
published: boolean;
|
||||
/** Oldest first, by the card's admission ordinal. */
|
||||
open: DeskRow[];
|
||||
/** Closed with a ruling id. A card closed with no ruling (withdrawn) is not listed. */
|
||||
recorded: { forkId: string; title: string; rulingId: string }[];
|
||||
/** The list's own open_count: the one count the navigator, the status line and the booth share. */
|
||||
counts: { open: number };
|
||||
};
|
||||
|
||||
const answerRow = (a: DeskAnswer, entry: ForkEntry): AnswerRow => ({
|
||||
answerId: a.answer_id,
|
||||
outcome: a.outcome,
|
||||
choice:
|
||||
a.choice === null
|
||||
? undefined
|
||||
: entry.card.options.find((o) => o.id === a.choice)?.text ?? a.choice,
|
||||
note: a.note ?? undefined,
|
||||
target: a.of ?? a.retracts ?? undefined,
|
||||
code: a.code ?? undefined,
|
||||
at: a.answered_at,
|
||||
counted: entry.record.counted_answer_id === a.answer_id,
|
||||
});
|
||||
|
||||
export const buildDesk = (
|
||||
list: ForkList | undefined,
|
||||
unverified: ReadonlySet<string> = new Set()
|
||||
): Desk => {
|
||||
if (!list) return { published: false, open: [], recorded: [], counts: { open: 0 } };
|
||||
const open: DeskRow[] = [];
|
||||
const recorded: Desk['recorded'] = [];
|
||||
list.forks.forEach((entry) => {
|
||||
const { card, record } = entry;
|
||||
if (card.status === 'closed') {
|
||||
if (card.ruling !== null)
|
||||
recorded.push({ forkId: card.fork_id, title: card.title, rulingId: card.ruling });
|
||||
return;
|
||||
}
|
||||
if (unverified.has(card.fork_id)) {
|
||||
open.push({ kind: 'unverified', forkId: card.fork_id });
|
||||
return;
|
||||
}
|
||||
open.push({
|
||||
kind: 'question',
|
||||
forkId: card.fork_id,
|
||||
title: card.title,
|
||||
context: card.context,
|
||||
options: card.options.map((o) => ({ ...o, recommended: o.id === card.recommendation })),
|
||||
recommended: card.options.find((o) => o.id === card.recommendation)?.text,
|
||||
depends: card.depends,
|
||||
answers: record.answers.map((a) => answerRow(a, entry)),
|
||||
});
|
||||
});
|
||||
return {
|
||||
published: true,
|
||||
open,
|
||||
recorded,
|
||||
counts: { open: list.open_count },
|
||||
};
|
||||
};
|
||||
59
src/app/features/desk/deskMood.test.ts
Normal file
59
src/app/features/desk/deskMood.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { MINDS, resolvePose } from '../../components/message/pose/poses';
|
||||
import { MOOD_POSE, deskMood } from './deskMood';
|
||||
|
||||
describe('deskMood', () => {
|
||||
it('is neutral with the desk clear when nothing is open', () => {
|
||||
expect(deskMood(0, 0)).toEqual({
|
||||
mood: 'neutral',
|
||||
pose: 'neutral',
|
||||
line: "Desk's clear. I'll wave when something lands.",
|
||||
});
|
||||
});
|
||||
it('is triumph when every open fork is answered', () => {
|
||||
expect(deskMood(3, 3).mood).toBe('triumph');
|
||||
expect(deskMood(1, 1).mood).toBe('triumph');
|
||||
});
|
||||
it('is thinking when none is answered, and says whether it is one fork or several', () => {
|
||||
expect(deskMood(1, 0)).toMatchObject({ mood: 'thinking', line: 'Just one fork on the desk.' });
|
||||
expect(deskMood(4, 0)).toMatchObject({
|
||||
mood: 'thinking',
|
||||
line: '4 forks on the desk. No rush.',
|
||||
});
|
||||
});
|
||||
it('is determined partway, counting what is left', () => {
|
||||
expect(deskMood(5, 2)).toMatchObject({ mood: 'determined', line: '3 to go.' });
|
||||
});
|
||||
it('is happy when the pick is the recommendation', () => {
|
||||
expect(deskMood(2, 0, { type: 'picked', choice: 'B', recommended: 'B' }).mood).toBe('happy');
|
||||
});
|
||||
it('is surprised, and asks for a line on why, when the pick differs', () => {
|
||||
expect(deskMood(2, 0, { type: 'picked', choice: 'A', recommended: 'B' })).toMatchObject({
|
||||
mood: 'surprised',
|
||||
line: expect.stringContaining('A line in the note on why helps'),
|
||||
});
|
||||
});
|
||||
it('is surprised, not happy, when there is no recommendation to match', () => {
|
||||
expect(deskMood(2, 0, { type: 'picked', choice: 'A' }).mood).toBe('surprised');
|
||||
});
|
||||
it('is note when a pick is cleared, saying the note can carry the answer', () => {
|
||||
expect(deskMood(2, 1, { type: 'cleared' })).toMatchObject({
|
||||
mood: 'note',
|
||||
line: expect.stringContaining('note can carry the answer on its own'),
|
||||
});
|
||||
});
|
||||
it('is concerned when a send failed, even with everything answered', () => {
|
||||
expect(deskMood(2, 2, { type: 'sendFailed' }).mood).toBe('concerned');
|
||||
});
|
||||
it('is surprised when a new fork arrives, even on a clear desk', () => {
|
||||
expect(deskMood(0, 0, { type: 'newFork' })).toMatchObject({
|
||||
mood: 'surprised',
|
||||
line: 'New fork on the desk!',
|
||||
});
|
||||
});
|
||||
it('maps every mood to a label every Mind in the catalogue can show', () => {
|
||||
Object.values(MOOD_POSE).forEach((pose) =>
|
||||
MINDS.forEach((mind) => expect(resolvePose(mind, pose)).toBe(pose))
|
||||
);
|
||||
});
|
||||
});
|
||||
75
src/app/features/desk/deskMood.ts
Normal file
75
src/app/features/desk/deskMood.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* The booth Persona's reactions, from the original Fork Desk's mood() and choose() rules. A pure function of
|
||||
* what is on the desk and the last thing that happened; it is the desk's character, so each rule is tested.
|
||||
*/
|
||||
|
||||
/** The original's nine moods. */
|
||||
export type Mood =
|
||||
| 'idle'
|
||||
| 'neutral'
|
||||
| 'thinking'
|
||||
| 'happy'
|
||||
| 'surprised'
|
||||
| 'note'
|
||||
| 'determined'
|
||||
| 'triumph'
|
||||
| 'concerned';
|
||||
|
||||
/** What last happened; `none` means nothing since the page opened or the last event wore off. */
|
||||
export type DeskEvent =
|
||||
| { type: 'none' }
|
||||
| { type: 'picked'; choice: string; recommended?: string }
|
||||
| { type: 'cleared' }
|
||||
| { type: 'sendFailed' }
|
||||
| { type: 'newFork' };
|
||||
|
||||
/**
|
||||
* Each mood on the shared cast-art labels (neutral, happy, amused, thinking, annoyed, surprised) that every
|
||||
* Mind has, so the booth never asks a Mind for art it lacks.
|
||||
*/
|
||||
export const MOOD_POSE: Record<Mood, string> = {
|
||||
idle: 'neutral',
|
||||
neutral: 'neutral',
|
||||
thinking: 'thinking',
|
||||
happy: 'happy',
|
||||
surprised: 'surprised',
|
||||
note: 'thinking',
|
||||
determined: 'thinking',
|
||||
triumph: 'happy',
|
||||
concerned: 'surprised',
|
||||
};
|
||||
|
||||
export type DeskMood = { mood: Mood; pose: string; line: string };
|
||||
|
||||
const say = (mood: Mood, line: string): DeskMood => ({ mood, pose: MOOD_POSE[mood], line });
|
||||
|
||||
export const deskMood = (
|
||||
open: number,
|
||||
answered: number,
|
||||
event: DeskEvent = { type: 'none' }
|
||||
): DeskMood => {
|
||||
switch (event.type) {
|
||||
case 'sendFailed':
|
||||
return say('concerned', "That one didn't save. Give it another try in a moment?");
|
||||
case 'newFork':
|
||||
return say('surprised', 'New fork on the desk!');
|
||||
case 'cleared':
|
||||
return say('note', 'Pick cleared. Your note can carry the answer on its own.');
|
||||
case 'picked':
|
||||
return event.recommended !== undefined && event.choice === event.recommended
|
||||
? say('happy', "That's the one I'd have picked too.")
|
||||
: say('surprised', 'Off-script. A line in the note on why helps the next brief.');
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (open === 0) return say('neutral', "Desk's clear. I'll wave when something lands.");
|
||||
if (answered >= open)
|
||||
return say('triumph', 'All answered! I will carry these back and admit them.');
|
||||
if (answered === 0) {
|
||||
return say(
|
||||
'thinking',
|
||||
open === 1 ? 'Just one fork on the desk.' : `${open} forks on the desk. No rush.`
|
||||
);
|
||||
}
|
||||
return say('determined', `${open - answered} to go.`);
|
||||
};
|
||||
116
src/app/features/desk/fixtures.ts
Normal file
116
src/app/features/desk/fixtures.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { DeskAnswer, ForkCard, ForkEntry, ForkList } from './forkContract';
|
||||
|
||||
/** A card as the server writes it: every key present. The hash is a stand-in; tests pass `unverified` directly. */
|
||||
export const card = (label: string, over: Partial<ForkCard> = {}): ForkCard => ({
|
||||
schemaVersion: 'gamecult.fork.card.v0',
|
||||
fork_id: `c:question:${label}`,
|
||||
title: `Question ${label}`,
|
||||
context: `Context for ${label}.`,
|
||||
options: [
|
||||
{ id: 'A', text: 'First way' },
|
||||
{ id: 'B', text: 'Second way' },
|
||||
{ id: 'C', text: 'Third way' },
|
||||
],
|
||||
recommendation: 'B',
|
||||
depends: [],
|
||||
leans: null,
|
||||
flag: null,
|
||||
raised_in: null,
|
||||
deciders: ['d1'],
|
||||
status: 'open',
|
||||
ruling: null,
|
||||
ruled_from: [],
|
||||
order: 1,
|
||||
options_hash: `hash-${label}`,
|
||||
...over,
|
||||
});
|
||||
|
||||
export const answer = (label: string, n: number, over: Partial<DeskAnswer> = {}): DeskAnswer => ({
|
||||
schemaVersion: 'gamecult.desk_answer.v0',
|
||||
answer_id: `n${n}`,
|
||||
fork_id: `c:question:${label}`,
|
||||
outcome: 'Counted',
|
||||
of: null,
|
||||
retracts: null,
|
||||
code: null,
|
||||
options_hash: `hash-${label}`,
|
||||
choice: 'A',
|
||||
note: null,
|
||||
answered_at: '2026-11-09T10:00:00Z',
|
||||
received_at: '2026-11-09T10:00:01Z',
|
||||
claim_in_future: false,
|
||||
answerer: 'acct-1',
|
||||
authority: 'Member',
|
||||
request_id: `r${n}`,
|
||||
...over,
|
||||
});
|
||||
|
||||
export const entry = (
|
||||
label: string,
|
||||
over: Partial<ForkCard> = {},
|
||||
answers: DeskAnswer[] = [],
|
||||
counted: string | null = null
|
||||
): ForkEntry => {
|
||||
const c = card(label, over);
|
||||
return {
|
||||
schemaVersion: 'gamecult.fork.entry.v0',
|
||||
card: c,
|
||||
record: {
|
||||
schemaVersion: 'gamecult.fork.record.v0',
|
||||
fork_id: c.fork_id,
|
||||
options_hash: c.options_hash,
|
||||
counted_answer_id: counted,
|
||||
answers,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const list = (forks: ForkEntry[], openCount?: number): ForkList => ({
|
||||
schemaVersion: 'gamecult.fork.list.v0',
|
||||
mind: 'mind',
|
||||
stamp: 's12',
|
||||
open_count: openCount ?? forks.filter((f) => f.card.status === 'open').length,
|
||||
recorded_count: forks.filter((f) => f.card.status === 'closed').length,
|
||||
forks,
|
||||
next_cursor: null,
|
||||
});
|
||||
|
||||
/** One list per state the desk shows. */
|
||||
export const STATES = {
|
||||
empty: list([]),
|
||||
noAnswers: list([entry('plain')]),
|
||||
blocked: list([
|
||||
entry('first', { order: 1 }),
|
||||
entry('second', { order: 2, depends: ['Needs the first one settled'] }),
|
||||
]),
|
||||
settled: list([
|
||||
entry('done', { status: 'closed', ruling: 'ruling:done-1', ruled_from: ['n1'] }),
|
||||
entry('shut', { status: 'closed' }),
|
||||
entry('live'),
|
||||
]),
|
||||
answered: list([
|
||||
entry(
|
||||
'plain',
|
||||
{},
|
||||
[
|
||||
answer('plain', 1, { note: 'Because it is first.' }),
|
||||
answer('plain', 2, {
|
||||
outcome: 'Revision',
|
||||
of: 'n1',
|
||||
choice: 'C',
|
||||
note: null,
|
||||
answerer: 'acct-2',
|
||||
}),
|
||||
answer('plain', 3, { outcome: 'Retraction', retracts: 'n2', choice: null }),
|
||||
answer('plain', 4, {
|
||||
outcome: 'Refused',
|
||||
code: 'not_a_decider',
|
||||
choice: 'B',
|
||||
note: 'Let me in.',
|
||||
}),
|
||||
answer('plain', 5, { choice: null, note: 'Either, if measured first.' }),
|
||||
],
|
||||
'n1'
|
||||
),
|
||||
]),
|
||||
} satisfies Record<string, ForkList>;
|
||||
92
src/app/features/desk/forkContract.ts
Normal file
92
src/app/features/desk/forkContract.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* The desk's typed view of the documents Bifrost serves for a Mind's desk: CultLib's
|
||||
* contracts/cultnet/governance/fork-card.md (gamecult.fork.list.v0 / entry / card / record, and the stored
|
||||
* gamecult.desk_answer.v0). Read-only: the page decodes them as served and derives nothing from them (the counted
|
||||
* answer is the record's `counted_answer_id`, not the page's opinion), so nothing here has a write shape.
|
||||
* Every key is written by the server, nil where absent.
|
||||
*/
|
||||
|
||||
export type ForkOption = { id: string; text: string };
|
||||
|
||||
export type ForkCard = {
|
||||
schemaVersion: 'gamecult.fork.card.v0';
|
||||
/** `<campaign>:question:<label>`. */
|
||||
fork_id: string;
|
||||
title: string;
|
||||
context: string;
|
||||
options: ForkOption[];
|
||||
/** An option id; never nil. Never preselected. */
|
||||
recommendation: string;
|
||||
/** Prose lines, each verbatim; a line may read like a reference but is only text. */
|
||||
depends: string[];
|
||||
/** An option id, or nil. Not shown by the desk. */
|
||||
leans: string | null;
|
||||
/** Not shown by the desk. */
|
||||
flag: string | null;
|
||||
raised_in: { kind: string; id: string } | null;
|
||||
deciders: string[];
|
||||
status: 'open' | 'closed';
|
||||
/** The ruling id when a ruling closed the fork; nil when open, and nil on a closed card withdrawn with no ruling. */
|
||||
ruling: string | null;
|
||||
ruled_from: string[];
|
||||
/** The admission ordinal. */
|
||||
order: number;
|
||||
/**
|
||||
* Publisher's hash of the five shown members. The desk recomputes it (optionsHash.ts, pinned to the CultLib
|
||||
* vectors) and withdraws a card whose hash differs.
|
||||
*/
|
||||
options_hash: string;
|
||||
};
|
||||
|
||||
/** One stored answer, told apart by `outcome` (and `code` for a refusal). */
|
||||
export type DeskAnswer = {
|
||||
schemaVersion: 'gamecult.desk_answer.v0';
|
||||
/** `n<seq>`. */
|
||||
answer_id: string;
|
||||
fork_id: string;
|
||||
outcome: 'Counted' | 'Revision' | 'Retraction' | 'Refused';
|
||||
/** A Revision's target. */
|
||||
of: string | null;
|
||||
/** A Retraction's target (or a refused retraction's). */
|
||||
retracts: string | null;
|
||||
/** A refusal's code. */
|
||||
code: string | null;
|
||||
options_hash: string;
|
||||
choice: string | null;
|
||||
/** The decider's own words, verbatim; nil for everyone Bifrost does not show notes to. */
|
||||
note: string | null;
|
||||
answered_at: string;
|
||||
received_at: string;
|
||||
claim_in_future: boolean;
|
||||
answerer: string;
|
||||
authority: string;
|
||||
request_id: string;
|
||||
};
|
||||
|
||||
export type ForkRecord = {
|
||||
schemaVersion: 'gamecult.fork.record.v0';
|
||||
fork_id: string;
|
||||
options_hash: string;
|
||||
/** Derived by the receiver; the page renders it and never recomputes it. */
|
||||
counted_answer_id: string | null;
|
||||
/** In admission order. */
|
||||
answers: DeskAnswer[];
|
||||
};
|
||||
|
||||
export type ForkEntry = {
|
||||
schemaVersion: 'gamecult.fork.entry.v0';
|
||||
card: ForkCard;
|
||||
record: ForkRecord;
|
||||
};
|
||||
|
||||
/** What the page holds for one Mind's desk. */
|
||||
export type ForkList = {
|
||||
schemaVersion: 'gamecult.fork.list.v0';
|
||||
mind: string;
|
||||
/** `s<N>`: the same stamp is the same state. */
|
||||
stamp: string;
|
||||
open_count: number;
|
||||
recorded_count: number;
|
||||
forks: ForkEntry[];
|
||||
next_cursor: string | null;
|
||||
};
|
||||
3
src/app/features/desk/index.ts
Normal file
3
src/app/features/desk/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { Desk } from './Desk';
|
||||
export { DeskEntry } from './DeskEntry';
|
||||
export { setDeskSource } from './source';
|
||||
50
src/app/features/desk/optionsHash.test.ts
Normal file
50
src/app/features/desk/optionsHash.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import vectors from './optionsHashVectors.json';
|
||||
import { entry } from './fixtures';
|
||||
import { ForkCard } from './forkContract';
|
||||
import { canonicalShown, optionsHash, shownOf, unverifiedForks } from './optionsHash';
|
||||
|
||||
describe('options_hash against the CultLib vectors', () => {
|
||||
it('has all fifteen vectors', () => {
|
||||
expect(vectors.optionsHash).toHaveLength(15);
|
||||
});
|
||||
vectors.optionsHash.forEach((v) => {
|
||||
it(`${v.name}: canonical bytes and sha256 match`, async () => {
|
||||
expect(canonicalShown(v)).toBe(v.canonical);
|
||||
expect(await optionsHash(v)).toBe(v.sha256);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unverifiedForks', () => {
|
||||
const sealed = async (over: Partial<ForkCard> = {}) => {
|
||||
const e = entry('plain', over);
|
||||
return { ...e, card: { ...e.card, options_hash: await optionsHash(shownOf(e.card)) } };
|
||||
};
|
||||
it('passes a card whose hash is the hash of what it shows', async () => {
|
||||
expect(await unverifiedForks([await sealed()])).toEqual(new Set());
|
||||
});
|
||||
it('flags a card whose shown title, context, option, recommendation or depends changed after hashing', async () => {
|
||||
const e = await sealed();
|
||||
const edits: Partial<ForkCard>[] = [
|
||||
{ title: 'Other' },
|
||||
{ context: 'Other context.' },
|
||||
{ options: [...e.card.options].reverse() },
|
||||
{ recommendation: 'A' },
|
||||
{ depends: ['new line'] },
|
||||
];
|
||||
await Promise.all(
|
||||
edits.map(async (x) =>
|
||||
expect(
|
||||
await unverifiedForks([{ ...e, card: { ...e.card, ...x } }]),
|
||||
JSON.stringify(Object.keys(x))
|
||||
).toEqual(new Set([e.card.fork_id]))
|
||||
)
|
||||
);
|
||||
});
|
||||
it('does not read fork_id, status, order, leans or flag, which are not shown members', async () => {
|
||||
const e = await sealed();
|
||||
const card = { ...e.card, fork_id: 'x:question:y', order: 7, leans: 'A', flag: 'f' };
|
||||
expect(await unverifiedForks([{ ...e, card }])).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
51
src/app/features/desk/optionsHash.ts
Normal file
51
src/app/features/desk/optionsHash.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { ForkCard, ForkEntry } from './forkContract';
|
||||
|
||||
// The hash and its canonical bytes are specified in CultLib contracts/cultnet/governance/fork-card.md
|
||||
// ("Options hash", rule owner: Heimdall's binding file 1.2); the vectors in optionsHashVectors.json pin this code to it.
|
||||
|
||||
const jstr = (s: string): string => {
|
||||
let out = '"';
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const ch of s.normalize('NFC')) {
|
||||
const o = ch.codePointAt(0) as number;
|
||||
const named: Record<number, string> = { 8: '\\b', 9: '\\t', 10: '\\n', 12: '\\f', 13: '\\r' };
|
||||
if (ch === '"') out += '\\"';
|
||||
else if (ch === '\\') out += '\\\\';
|
||||
else if (named[o]) out += named[o];
|
||||
else out += o < 0x20 ? `\\u${o.toString(16).padStart(4, '0')}` : ch;
|
||||
}
|
||||
return `${out}"`;
|
||||
};
|
||||
|
||||
export type Shown = Pick<ForkCard, 'title' | 'context' | 'options' | 'recommendation' | 'depends'>;
|
||||
|
||||
/** The five shown members as RFC 8785 JSON, keys already in sorted order. */
|
||||
export const canonicalShown = (c: Shown): string =>
|
||||
`{"context":${jstr(c.context)},"depends":[${c.depends.map(jstr).join(',')}],"options":[${c.options
|
||||
.map((o) => `{"id":${jstr(o.id)},"text":${jstr(o.text)}}`)
|
||||
.join(',')}],"recommendation":${jstr(c.recommendation)},"title":${jstr(c.title)}}`;
|
||||
|
||||
export const shownOf = (c: ForkCard): Shown => ({
|
||||
title: c.title,
|
||||
context: c.context,
|
||||
options: c.options,
|
||||
recommendation: c.recommendation,
|
||||
depends: c.depends,
|
||||
});
|
||||
|
||||
export const optionsHash = async (c: Shown): Promise<string> => {
|
||||
const bytes = new TextEncoder().encode(canonicalShown(c));
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
|
||||
/** Fork ids whose published `options_hash` is not the hash of what the card shows. */
|
||||
export const unverifiedForks = async (forks: ForkEntry[]): Promise<Set<string>> => {
|
||||
const bad = new Set<string>();
|
||||
await Promise.all(
|
||||
forks.map(async ({ card }) => {
|
||||
if ((await optionsHash(shownOf(card))) !== card.options_hash) bad.add(card.fork_id);
|
||||
})
|
||||
);
|
||||
return bad;
|
||||
};
|
||||
320
src/app/features/desk/optionsHashVectors.json
Normal file
320
src/app/features/desk/optionsHashVectors.json
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
{
|
||||
"source": "GameCult/CultLib contracts/cultnet/governance/fork-card-vectors.json, `optionsHash` member only, values unchanged (re-escaped to ASCII so TypeScript parses it), checked equal at CultLib 0c19eddb. Rule: contracts/cultnet/governance/fork-card.md, 'Options hash'.",
|
||||
"optionsHash": [
|
||||
{
|
||||
"name": "heimdall_A_five_members",
|
||||
"heimdall": "A",
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48"
|
||||
},
|
||||
{
|
||||
"name": "heimdall_B_recommendation_changed",
|
||||
"heimdall": "B",
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "yes",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"yes\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A22796573222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc"
|
||||
},
|
||||
{
|
||||
"name": "heimdall_C_option_order_changed",
|
||||
"heimdall": "C",
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
},
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"no\",\"text\":\"Keep it local.\"},{\"id\":\"yes\",\"text\":\"Share it.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D2C7B226964223A22796573222C2274657874223A2253686172652069742E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "ac67b41539f869f6203ac0908819df046e4328b27795743a919bd4ab390596b5"
|
||||
},
|
||||
{
|
||||
"name": "heimdall_D_nfc_escapes_astral",
|
||||
"heimdall": "D",
|
||||
"title": "Caf\u00e9 \u201cdesk\u201d",
|
||||
"context": "Line one\nLine two \u2014 \ud83d\ude00",
|
||||
"options": [
|
||||
{
|
||||
"id": "a",
|
||||
"text": "x"
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"text": "y"
|
||||
}
|
||||
],
|
||||
"recommendation": "a",
|
||||
"depends": [],
|
||||
"canonical": "{\"context\":\"Line one\\nLine two \u2014 \ud83d\ude00\",\"depends\":[],\"options\":[{\"id\":\"a\",\"text\":\"x\"},{\"id\":\"b\",\"text\":\"y\"}],\"recommendation\":\"a\",\"title\":\"Caf\u00e9 \u201cdesk\u201d\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A224C696E65206F6E655C6E4C696E652074776F20E2809420F09F9880222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A2261222C2274657874223A2278227D2C7B226964223A2262222C2274657874223A2279227D5D2C227265636F6D6D656E646174696F6E223A2261222C227469746C65223A22436166C3A920E2809C6465736BE2809D227D",
|
||||
"sha256": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272"
|
||||
},
|
||||
{
|
||||
"name": "heimdall_E_decomposed_equals_D",
|
||||
"heimdall": "E",
|
||||
"title": "Cafe\u0301 \u201cdesk\u201d",
|
||||
"context": "Line one\nLine two \u2014 \ud83d\ude00",
|
||||
"options": [
|
||||
{
|
||||
"id": "a",
|
||||
"text": "x"
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"text": "y"
|
||||
}
|
||||
],
|
||||
"recommendation": "a",
|
||||
"depends": [],
|
||||
"canonical": "{\"context\":\"Line one\\nLine two \u2014 \ud83d\ude00\",\"depends\":[],\"options\":[{\"id\":\"a\",\"text\":\"x\"},{\"id\":\"b\",\"text\":\"y\"}],\"recommendation\":\"a\",\"title\":\"Caf\u00e9 \u201cdesk\u201d\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A224C696E65206F6E655C6E4C696E652074776F20E2809420F09F9880222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A2261222C2274657874223A2278227D2C7B226964223A2262222C2274657874223A2279227D5D2C227265636F6D6D656E646174696F6E223A2261222C227469746C65223A22436166C3A920E2809C6465736BE2809D227D",
|
||||
"sha256": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272"
|
||||
},
|
||||
{
|
||||
"name": "heimdall_F_option_id_changed",
|
||||
"heimdall": "F",
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "share",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"share\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A227368617265222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "fb53aa3a9f7e74029f755d774064eedd240ab90d8fc64723f97c31b3d2846d40"
|
||||
},
|
||||
{
|
||||
"name": "heimdall_G_depends_emptied",
|
||||
"heimdall": "G",
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": [],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "f856edce121db4121b5f1af400b1c471ebbfa6579a042aca9a34ae31c15f5c86"
|
||||
},
|
||||
{
|
||||
"name": "option_text_changed",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it!"
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it!\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A22536861726520697421227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "9ce0d2770497fdd2d5338e66f9f1ae1741d87301db4c793407c25762929392b6"
|
||||
},
|
||||
{
|
||||
"name": "title_changed",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent?",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent?\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E743F227D",
|
||||
"sha256": "30fe0c5bc88bd21d64592a495316c30e80fcee6cacf5c63fd9a818ca5cf87d0f"
|
||||
},
|
||||
{
|
||||
"name": "context_changed",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should slot marks be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should slot marks be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C6420736C6F74206D61726B73206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "716de65096e34fd89cc92848b667d90f5c27eeb66929da3d340cabb80155e173"
|
||||
},
|
||||
{
|
||||
"name": "depends_order_changed",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience", "x:question:y"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\",\"x:question:y\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365222C22783A7175657374696F6E3A79225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "fc3f651c6f498385256fc1232d5db31ece9dcc542e391f97245d27bc9e385b3d"
|
||||
},
|
||||
{
|
||||
"name": "depends_order_changed_reversed",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["x:question:y", "gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"x:question:y\",\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B22783A7175657374696F6E3A79222C2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "b22642e30f8d68839e3a0df5bc23b83ff50a839fcecf0366a65fa6e3cf873796"
|
||||
},
|
||||
{
|
||||
"name": "hidden_members_do_not_move_it",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48"
|
||||
},
|
||||
{
|
||||
"name": "control_characters_and_markup_are_escaped_minimally",
|
||||
"heimdall": null,
|
||||
"title": "q \" b \\ t \t n \n bell \u0007 del \u007f",
|
||||
"context": "</script> \u2028 &",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"</script> \u2028 &\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"q \\\" b \\\\ t \\t n \\n bell \\u0007 del \u007f\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A223C2F7363726970743E20E280A82026222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A2271205C222062205C5C2074205C74206E205C6E2062656C6C205C75303030372064656C207F227D",
|
||||
"sha256": "f2d5a09513ce728129f6ca13f7123aa75b2bb77541b1de8946f39239634b40fe"
|
||||
},
|
||||
{
|
||||
"name": "astral_option_id_and_text",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "\ud83d\ude00",
|
||||
"text": "\ud834\udd1e"
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"\ud83d\ude00\",\"text\":\"\ud834\udd1e\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22F09F9880222C2274657874223A22F09D849E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "836fbfce16127132bae100aa773d328a85ad08d241ae9bf47ea7f84766762cac"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
src/app/features/desk/source.ts
Normal file
13
src/app/features/desk/source.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { ForkList } from './forkContract';
|
||||
|
||||
type DeskSource = (mind: string) => ForkList | undefined;
|
||||
|
||||
// Slice 1 reads no network. The preview demo installs seeded forks here; slice 2 replaces this with
|
||||
// Bifrost's list reader, which is why the page asks a function and not a fixture.
|
||||
let source: DeskSource = () => undefined;
|
||||
|
||||
export const setDeskSource = (next: DeskSource) => {
|
||||
source = next;
|
||||
};
|
||||
|
||||
export const deskFor = (mind: string): ForkList | undefined => source(mind);
|
||||
|
|
@ -42,6 +42,7 @@ import { EstateGroup, EstateRoom, EstateTier, splitGroups } from './buildEstate'
|
|||
import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom';
|
||||
import { useEstate, useEstateRootId } from './useEstate';
|
||||
import * as css from './EstateNavigator.css';
|
||||
import { DeskEntry } from '../desk';
|
||||
import { SkyStateControl } from '../scene/SkyStateControl';
|
||||
|
||||
/** How long a tap waits for the client store to hold the room it just joined. */
|
||||
|
|
@ -135,9 +136,11 @@ type CollapsibleProps = {
|
|||
categoryId: string;
|
||||
label: string;
|
||||
rooms: EstateRoom[];
|
||||
/** Sits in the header beside the label, so a collapsed section still shows it. */
|
||||
trailing?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) {
|
||||
function Collapsible({ categoryId, label, rooms, trailing, children }: CollapsibleProps) {
|
||||
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||
|
|
@ -161,6 +164,7 @@ function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) {
|
|||
<RoomNavCategoryButton closed={closed} onClick={handleClick}>
|
||||
{label}
|
||||
</RoomNavCategoryButton>
|
||||
{trailing}
|
||||
</NavCategoryHeader>
|
||||
{!closed && children}
|
||||
</NavCategory>
|
||||
|
|
@ -169,7 +173,12 @@ function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) {
|
|||
|
||||
function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup }) {
|
||||
return (
|
||||
<Collapsible categoryId={`${tierId}|${group.mind}`} label={group.label} rooms={group.rooms}>
|
||||
<Collapsible
|
||||
categoryId={`${tierId}|${group.mind}`}
|
||||
label={group.label}
|
||||
rooms={group.rooms}
|
||||
trailing={<DeskEntry mind={group.mind} />}
|
||||
>
|
||||
{group.rooms.map((r) => (
|
||||
<EstateRow key={r.roomId} room={r} />
|
||||
))}
|
||||
|
|
|
|||
32
src/app/features/estate/joinPath.lint.test.ts
Normal file
32
src/app/features/estate/joinPath.lint.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ESLint } from 'eslint';
|
||||
|
||||
// The product rule is one join path: a join is joinEstateRoom, which files the DM before it sends the join.
|
||||
// This pins that the lint config enforces it, not just that the code happens to comply today.
|
||||
const lint = async (filePath: string, code: string) => {
|
||||
const eslint = new ESLint({ cwd: process.cwd(), useEslintrc: true });
|
||||
const [result] = await eslint.lintText(code, { filePath });
|
||||
return result.messages.filter((m) => m.ruleId === 'no-restricted-syntax');
|
||||
};
|
||||
const JOIN = 'export const f = (mx: { joinRoom: (id: string) => void }) => mx.joinRoom("!a:b");\n';
|
||||
|
||||
describe('the one join path is enforced by lint', () => {
|
||||
it('rejects a joinRoom call anywhere but joinEstateRoom.ts', async () => {
|
||||
const found = await lint('src/app/features/estate/EstateNavigator.tsx', JOIN);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0].message).toContain('joinEstateRoom');
|
||||
});
|
||||
|
||||
it('allows the call inside joinEstateRoom.ts itself', async () => {
|
||||
expect(await lint('src/app/features/estate/joinEstateRoom.ts', JOIN)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still applies the Airbnb syntax bans in the same files', async () => {
|
||||
const found = await lint(
|
||||
'src/app/foo.ts',
|
||||
'export const g = (o: object) => { for (const k in o) { return k; } return 0; };\n'
|
||||
);
|
||||
expect(found.some((m) => m.message.includes('for..in'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,8 @@ import { Room } from 'matrix-js-sdk';
|
|||
import React from 'react';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import { useSkyInk } from '../../hooks/useTheme';
|
||||
import { noticeInk } from '../scene/skyInk';
|
||||
import {
|
||||
ROOM_NOTICE_EVENT_TYPE,
|
||||
parseRoomNotice,
|
||||
|
|
@ -14,12 +16,16 @@ import {
|
|||
export function RoomTrainingLine({ room }: { room: Room }) {
|
||||
const event = useStateEvent(room, ROOM_NOTICE_EVENT_TYPE);
|
||||
const { previewDemo } = useClientConfig();
|
||||
const skyInk = useSkyInk();
|
||||
const line = roomTrainingLine(parseRoomNotice(event?.getContent()), previewDemo);
|
||||
if (!line) return null;
|
||||
return (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{ padding: `${config.space.S100} 0`, color: color.SurfaceVariant.OnContainer }}
|
||||
style={{
|
||||
padding: `${config.space.S100} 0`,
|
||||
color: (skyInk && noticeInk(skyInk)) ?? color.SurfaceVariant.OnContainer,
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useRef } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { Box, Text, config } from 'folds';
|
||||
import { EventType } from 'matrix-js-sdk';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
|
|
@ -23,6 +23,10 @@ import { useSetting } from '../../state/hooks/settings';
|
|||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { AlbumView } from '../albums/AlbumView';
|
||||
import * as albumCss from '../albums/AlbumView.css';
|
||||
import { useIsFileZone } from '../albums/useAlbums';
|
||||
import { ZoneView, zoneView } from '../albums/zoneView';
|
||||
|
||||
const FN_KEYS_REGEX = /^F\d+$/;
|
||||
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||
|
|
@ -64,6 +68,12 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||
const room = useRoom();
|
||||
const { roomId } = room;
|
||||
const editor = useEditor();
|
||||
const isZone = useIsFileZone(room);
|
||||
// A zone shows its albums; the timeline stays one tap away, and an event deep link opens it.
|
||||
const [choice, setChoice] = useState<{ roomId: string; view: ZoneView }>();
|
||||
const fileZone =
|
||||
isZone &&
|
||||
zoneView(choice && choice.roomId === roomId ? choice.view : undefined, eventId) === 'albums';
|
||||
|
||||
const mx = useMatrixClient();
|
||||
|
||||
|
|
@ -78,7 +88,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||
window,
|
||||
useCallback(
|
||||
(evt) => {
|
||||
if (editableActiveElement()) return;
|
||||
if (fileZone || editableActiveElement()) return;
|
||||
const portalContainer = document.getElementById('portalContainer');
|
||||
if (portalContainer && portalContainer.children.length > 0) {
|
||||
return;
|
||||
|
|
@ -87,13 +97,35 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||
ReactEditor.focus(editor);
|
||||
}
|
||||
},
|
||||
[editor]
|
||||
[editor, fileZone]
|
||||
)
|
||||
);
|
||||
|
||||
// The file zone is its own view: no timeline, no composer.
|
||||
if (fileZone) {
|
||||
return (
|
||||
<Page ref={roomViewRef}>
|
||||
<AlbumView
|
||||
key={roomId}
|
||||
room={room}
|
||||
onTimeline={() => setChoice({ roomId, view: 'timeline' })}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page ref={roomViewRef}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
{isZone && !eventId && (
|
||||
<button
|
||||
type="button"
|
||||
className={albumCss.Back}
|
||||
onClick={() => setChoice({ roomId, view: 'albums' })}
|
||||
>
|
||||
<Text size="B400">‹ Albums</Text>
|
||||
</button>
|
||||
)}
|
||||
<RoomTimeline
|
||||
key={roomId}
|
||||
room={room}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { config } from 'folds';
|
||||
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
|
||||
|
||||
// Phone tap target: the topic is an 18 px line under the room name; its button grows 9 px up and 17 px down
|
||||
// (44 px to tap; more below because the name is above) and the negative margin keeps the layout where it was.
|
||||
export const HeaderTopic = style({
|
||||
'@media': {
|
||||
[`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: {
|
||||
paddingTop: 9,
|
||||
paddingBottom: 17,
|
||||
marginTop: -9,
|
||||
marginBottom: -17,
|
||||
},
|
||||
},
|
||||
':hover': {
|
||||
cursor: 'pointer',
|
||||
opacity: config.opacity.P500,
|
||||
|
|
|
|||
|
|
@ -1168,7 +1168,6 @@ export const Message = as<'div', MessageProps>(
|
|||
<BubbleLayout
|
||||
own={own}
|
||||
pose={mind ? poseJSX : undefined}
|
||||
alt={poseAlt}
|
||||
enterDelay={enterDelay}
|
||||
before={avatarJSX}
|
||||
header={headerJSX}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,22 @@
|
|||
config says `backgroundScene.style: 'raymarch'`; the tier flag still defaults to `off`, and null still
|
||||
means "keep the CSS ground". Everything below this section describes the opt-in ray-march scene.
|
||||
|
||||
- `ridge/Ridge.ts` `createRidgeScene({ canvas, tier, probe?, onProbe?, sky? })` returns `{ login, dispose }` or
|
||||
null (tier `off`, no 2D context). Four ridge layers are generated once on the CPU from CultMath's
|
||||
value-noise fbm (`ridge/noise.ts`, a clean-room port, periodic so the drift loops), each painted once to a plain
|
||||
2D canvas of integer size (two periods wide; DPR 2 or more paints at 2x, otherwise 1x; no resampling between
|
||||
layers) over a baked sky gradient on the host canvas. Per frame the only motion is the browser sliding each layer with a
|
||||
CSS transform (a Web Animation of `translate3d`, 150-420 s per period, farther is slower); no script runs per frame.
|
||||
The host canvas and the layers' parent must be positioned and the canvas should fill the parent.
|
||||
- `ridge/Ridge.ts` `createRidgeScene({ canvas, tier, probe?, onProbe?, onBake?, sky? })` returns `{ login, dispose }` or
|
||||
null (tier `off`, no 2D context). Each of four ridge layers is a function of world-x (`ridge/ridgeWorld.ts` `profile`:
|
||||
ridged multifractal value noise, each octave folded at its crest and weighted by the previous one, domain-warped; one
|
||||
function, a parameter row per layer; nothing repeats). World-x is baked in chunks (1.2 viewport widths, integer pixels)
|
||||
onto plain 2D canvases over a baked sky gradient on the host canvas. Per layer a track holds three chunk canvases: two
|
||||
the window can reach and one ahead. The first view bakes only the first chunk of each layer; the others are baked in
|
||||
tasks of about 6 ms (`setTimeout`; a chunk is baked, put on a canvas with its in-scatter, and blurred in column bands, each sized from the last band's time). Per frame the only motion is the
|
||||
compositor sliding the track with a CSS transform (a Web Animation of `translate3d`, one chunk long, `fill: forwards`,
|
||||
150-420 s per viewport width, farther is slower); when it finishes the chunks move one slot left and the track restarts
|
||||
at 0 in the same task (no jump), and the canvas that left is baked with the next stretch of world. A second animation
|
||||
sways the whole rig (up to 5% of the width sideways, 3% of the height down, 150 s loop): the slow yaw and pitch.
|
||||
No script runs per frame. The host canvas and the layers' parent must be positioned and the canvas should fill the parent.
|
||||
- `ridge/ridgeBake.ts` is the pixel function (pure, no DOM): per column the crest's slope gives a normal, lit against the
|
||||
sun (`sunDirection`: x = 0.9 sin(2 pi \* `sunAzimuth`), so the sun is toward +x for azimuth 0 to 0.5, strongest at 0.25, toward -x for 0.5 to 1; with no `sunAzimuth` the face lighting falls back to 0.3 and the in-scatter is off); the face under it has a light-to-haze gradient, erosion
|
||||
streaks (a folded noise at gully width, darker with steepness) and a lit rim on the crest. Contrast scales with
|
||||
`transmittance`; the base colour, haze and blur are the aerial perspective below, not redone.
|
||||
- Paused when the tab is hidden; under `prefers-reduced-motion` the layers are painted but never played.
|
||||
`tier: 'still'` flattens sky and layers into the host canvas at once and leaves no extra elements.
|
||||
- Cadence guard: for the first 2 s after start (after 5 warm-up frames) rAF intervals are sampled; if their p95 is over
|
||||
|
|
@ -23,8 +32,9 @@ means "keep the CSS ground". Everything below this section describes the opt-in
|
|||
in-scatter). Layer distance d = 1 - depth; transmittance T = exp(-1.6 d) (0.24 / 0.37 / 0.58 / 1.00 far to near); colour is
|
||||
`T * ground + (1 - T) * haze`, then chroma drops by `0.5 (1 - T)`; the edge is blurred up to 2 px (canvas pixels at DPR 1,
|
||||
via the 2D `filter`, skipped where unsupported, e.g. Safari); a faint warm in-scatter (at most 0.16 alpha) peaks at the
|
||||
sun's azimuth. Nothing runs per frame. The in-scatter lobe is painted into each layer, so it drifts with the layer
|
||||
rather than staying fixed under the sun; that is a known approximation until the sun is a real, fixed light.
|
||||
sun's azimuth in world-x (two incommensurate cosines, so not periodic). Nothing runs per frame. The in-scatter lobe is
|
||||
painted into each chunk, so it drifts with the layer rather than staying fixed under the sun; that is a known
|
||||
approximation until the sun is a real, fixed light.
|
||||
- `harness/scene/bundle.cjs <out> ridge` builds a standalone page (`?tier=`, `?probe=1`).
|
||||
- Not yet measured on a phone: see the PR description.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createRidgeScene } from './Ridge';
|
||||
import { fbmPeriodic, valueNoisePeriodic, hash2 } from './noise';
|
||||
import { bakeSlice, BakeParams, layerPaint } from './ridgeBake';
|
||||
import { valueNoise, hash2 } from './noise';
|
||||
import { FrameSampler, frameStats, SLOW_P95_MS, WARMUP_FRAMES, WINDOW_MS } from './probe';
|
||||
import {
|
||||
AERIAL,
|
||||
FBM,
|
||||
LAYERS,
|
||||
edgeBlur,
|
||||
hazeColour,
|
||||
|
|
@ -23,58 +23,37 @@ describe('noise', () => {
|
|||
it('is deterministic and in [0, 1)', () => {
|
||||
expect(hash2(3, 5)).toBe(hash2(3, 5));
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
const v = valueNoisePeriodic(i * 0.37, 2, 7);
|
||||
const v = valueNoise(i * 0.37 - 20, 2);
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('wraps: the lattice repeats every `period` cells', () => {
|
||||
for (const x of [0, 0.4, 3.9, 6.99]) {
|
||||
expect(valueNoisePeriodic(x + 7, 4, 7)).toBeCloseTo(valueNoisePeriodic(x, 4, 7), 12);
|
||||
expect(valueNoise(i * 0.37 - 20, 2)).toBe(v);
|
||||
}
|
||||
});
|
||||
|
||||
it('different rows are different profiles', () => {
|
||||
expect(valueNoisePeriodic(1.5, 0, 9)).not.toBeCloseTo(valueNoisePeriodic(1.5, 1, 9), 3);
|
||||
expect(valueNoise(1.5, 0)).not.toBeCloseTo(valueNoise(1.5, 1), 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ridge profile', () => {
|
||||
it('every layer is periodic in t, so a drift of one period loops seamlessly', () => {
|
||||
it('stays in [0, 1] for any world-x, negative and far included, and layers differ', () => {
|
||||
for (const spec of LAYERS) {
|
||||
for (const t of [0, 0.13, 0.5, 0.97]) {
|
||||
expect(Number.isFinite(profile(spec, t))).toBe(true);
|
||||
}
|
||||
// the end of one period meets the start of the next
|
||||
expect(profile(spec, 1 - 1e-9)).toBeCloseTo(profile(spec, 0), 3);
|
||||
}
|
||||
});
|
||||
|
||||
it('every octave count is an integer lattice, which periodicity needs', () => {
|
||||
for (const spec of LAYERS) {
|
||||
for (let k = 0; k < FBM.octaves; k += 1) {
|
||||
expect(Number.isInteger(spec.baseCells * FBM.lacunarity ** k)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('stays in [0, 1] and layers differ', () => {
|
||||
for (const spec of LAYERS) {
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
const p = profile(spec, i / 100);
|
||||
expect(p).toBeGreaterThanOrEqual(0);
|
||||
expect(p).toBeLessThanOrEqual(1);
|
||||
for (let i = -300; i < 300; i += 7) {
|
||||
for (const off of [0, 1234.5, 98765.25]) {
|
||||
const p = profile(spec, i / 50 + off);
|
||||
expect(p).toBeGreaterThanOrEqual(0);
|
||||
expect(p).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(profile(LAYERS[0], 0.3)).not.toBeCloseTo(profile(LAYERS[1], 0.3), 3);
|
||||
});
|
||||
|
||||
it('ridge tops lie inside the layer band and rise with farther base', () => {
|
||||
it('ridge tops lie inside the layer band', () => {
|
||||
const size = layerSize(400, 800, 1);
|
||||
for (const spec of LAYERS) {
|
||||
for (let x = 0; x < size.period; x += 17) {
|
||||
const y = ridgeY(spec, x, size.period, size.height, 800);
|
||||
for (let x = -500; x < 5 * size.period; x += 17) {
|
||||
const y = ridgeY(spec, x / size.period, size.height, 800);
|
||||
expect(y).toBeGreaterThanOrEqual(0);
|
||||
expect(y).toBeLessThanOrEqual(size.height);
|
||||
}
|
||||
|
|
@ -95,9 +74,9 @@ describe('ridge profile', () => {
|
|||
] as const) {
|
||||
const z = layerSize(w, h, s);
|
||||
expect(
|
||||
Number.isInteger(z.period) && Number.isInteger(z.width) && Number.isInteger(z.height)
|
||||
Number.isInteger(z.period) && Number.isInteger(z.chunk) && Number.isInteger(z.height)
|
||||
).toBe(true);
|
||||
expect(z.width).toBe(2 * z.period);
|
||||
expect(z.chunk).toBeGreaterThan(z.period);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -147,14 +126,16 @@ describe('aerial perspective', () => {
|
|||
expect(edgeBlur(far)).toBeLessThanOrEqual(AERIAL.blurPx);
|
||||
});
|
||||
|
||||
it('in-scatter peaks at the sun azimuth, is periodic, faint, grows with distance, and is off without a sun', () => {
|
||||
it('in-scatter is faint, grows with distance, is off without a sun, and never repeats', () => {
|
||||
const az = 0.3;
|
||||
const s = { ...sky, sunAzimuth: az };
|
||||
expect(scatterAlpha(far, s, az)).toBeGreaterThan(scatterAlpha(far, s, az + 0.5));
|
||||
expect(scatterAlpha(far, s, 0)).toBeCloseTo(scatterAlpha(far, s, 1), 12);
|
||||
expect(scatterAlpha(far, s, az)).toBeLessThanOrEqual(AERIAL.scatter);
|
||||
const lobe = (t: number) => scatterAlpha(far, s, t);
|
||||
expect(lobe(az * 1.9)).toBeGreaterThan(lobe(az * 1.9 + 0.95));
|
||||
for (const period of [1, 1.9, 3.8, 5.7, 19])
|
||||
expect(Math.abs(lobe(0.7 + period) - lobe(0.7))).toBeGreaterThan(1e-4);
|
||||
expect(lobe(az * 1.9)).toBeLessThanOrEqual(AERIAL.scatter);
|
||||
expect(scatterAlpha(near, s, az)).toBe(0);
|
||||
expect(scatterAlpha(LAYERS[1], s, az)).toBeLessThan(scatterAlpha(far, s, az));
|
||||
expect(scatterAlpha(LAYERS[1], s, az * 1.9)).toBeLessThan(scatterAlpha(far, s, az * 1.9));
|
||||
expect(scatterAlpha(far, { ...sky, sunAzimuth: undefined }, az)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -237,8 +218,21 @@ function fakeEl(tag = 'div') {
|
|||
this.children.push(c);
|
||||
return c;
|
||||
},
|
||||
animate: vi.fn(() => {
|
||||
const a = { currentTime: 0, play: vi.fn(), pause: vi.fn(), cancel: vi.fn() };
|
||||
animate: vi.fn((keyframes: any[], options: any) => {
|
||||
const a: any = {
|
||||
currentTime: 0,
|
||||
keyframes,
|
||||
options,
|
||||
onfinish: null,
|
||||
play: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
/** The animation reaches its end: what the compositor's finish event does. */
|
||||
finish() {
|
||||
a.currentTime = options.duration;
|
||||
a.onfinish?.();
|
||||
},
|
||||
};
|
||||
el.anims.push(a);
|
||||
return a;
|
||||
}),
|
||||
|
|
@ -256,6 +250,10 @@ const DAY: SkyColours = {
|
|||
sunAzimuth: 0.3,
|
||||
};
|
||||
const draws: string[] = [];
|
||||
/** Every ImageData put on any canvas, in order. */
|
||||
const puts: any[] = [];
|
||||
/** For every drawImage: the source canvas's width as it was at the moment of the draw. */
|
||||
const drawnWidths: number[] = [];
|
||||
function ctx(withFilter = false) {
|
||||
const c: any = {
|
||||
globalCompositeOperation: 'source-over',
|
||||
|
|
@ -263,6 +261,16 @@ function ctx(withFilter = false) {
|
|||
fillRect() {
|
||||
draws.push(c.globalCompositeOperation === 'source-atop' ? 'scatter' : 'sky');
|
||||
},
|
||||
save() {},
|
||||
restore() {},
|
||||
rect() {},
|
||||
clip() {},
|
||||
createImageData: (w: number, h: number) => ({
|
||||
data: new Uint8ClampedArray(w * h * 4),
|
||||
width: w,
|
||||
height: h,
|
||||
}),
|
||||
putImageData: (img: any) => puts.push(img),
|
||||
beginPath() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
|
|
@ -280,7 +288,10 @@ function ctx(withFilter = false) {
|
|||
if (c.arcing) c.arcing = false;
|
||||
else draws.push('fill');
|
||||
},
|
||||
drawImage: () => draws.push('flatten'),
|
||||
drawImage: (src: any) => {
|
||||
drawnWidths.push(src.width);
|
||||
draws.push('flatten');
|
||||
},
|
||||
set fillStyle(_v: unknown) {},
|
||||
};
|
||||
if (withFilter) c.filter = 'none';
|
||||
|
|
@ -295,6 +306,8 @@ let search: string;
|
|||
beforeEach(() => {
|
||||
rafQueue = [];
|
||||
draws.length = 0;
|
||||
puts.length = 0;
|
||||
drawnWidths.length = 0;
|
||||
search = '';
|
||||
motion = { matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||
doc = Object.assign(new EventTarget(), {
|
||||
|
|
@ -330,6 +343,11 @@ function host() {
|
|||
parent.appendChild(canvas);
|
||||
return { parent, canvas };
|
||||
}
|
||||
/** The rig holds one track per layer, farthest first; each track holds three chunk canvases. */
|
||||
const rigOf = (parent: any) => parent.children.find((c: any) => c.tag === 'div');
|
||||
const tracksOf = (parent: any): any[] => rigOf(parent).children;
|
||||
const animsOf = (parent: any): any[] =>
|
||||
[rigOf(parent), ...tracksOf(parent)].flatMap((e: any) => e.anims);
|
||||
const runFrames = (dt: number, n: number, from = 0) => {
|
||||
let t = from;
|
||||
for (let i = 0; i < n && rafQueue.length; i += 1) {
|
||||
|
|
@ -353,19 +371,25 @@ describe('createRidgeScene', () => {
|
|||
expect(createRidgeScene({ canvas, tier: 'full', sky: DAY })).toBeNull();
|
||||
});
|
||||
|
||||
it('bakes the sky once and paints four layers at integer sizes, farthest first', () => {
|
||||
it('bakes the sky once, and per layer only the first chunk before the first frame, at integer sizes, farthest first', () => {
|
||||
const { parent, canvas } = host();
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
|
||||
const baked: any[] = [];
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY, onBake: (b) => baked.push(b) })!;
|
||||
expect(s).not.toBeNull();
|
||||
const layers = parent.children.slice(1);
|
||||
expect(layers).toHaveLength(LAYERS.length);
|
||||
layers.forEach((l: any) => {
|
||||
expect(Number.isInteger(l.width) && Number.isInteger(l.height)).toBe(true);
|
||||
expect(l.width).toBe(canvas.width * 2); // DPR 2: layer is two periods wide
|
||||
expect(l.animate).toHaveBeenCalledTimes(1);
|
||||
const tracks = tracksOf(parent);
|
||||
expect(tracks).toHaveLength(LAYERS.length);
|
||||
tracks.forEach((t: any) => {
|
||||
expect(t.children).toHaveLength(3);
|
||||
expect(t.animate).toHaveBeenCalledTimes(1);
|
||||
t.children.forEach((c: any) => {
|
||||
expect(Number.isInteger(c.width) && Number.isInteger(c.height)).toBe(true);
|
||||
expect(c.width).toBe(layerSize(400, 800, 2).chunk);
|
||||
});
|
||||
});
|
||||
expect(baked.map((b) => [b.layer, b.chunk, b.first])).toEqual(
|
||||
LAYERS.map((l) => [l.index, 0, true])
|
||||
);
|
||||
expect(draws.filter((d) => d === 'sky')).toHaveLength(1);
|
||||
expect(draws.filter((d) => d === 'fill')).toHaveLength(LAYERS.length);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
|
|
@ -403,8 +427,9 @@ describe('createRidgeScene', () => {
|
|||
motion.matches = true;
|
||||
const { canvas } = host();
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
|
||||
const anims = canvas.anims.concat(...canvas.parentElement.children.map((c: any) => c.anims));
|
||||
expect(anims.length).toBe(LAYERS.length);
|
||||
const anims = animsOf(canvas.parentElement);
|
||||
expect(anims.length).toBe(LAYERS.length + 1); // one drift per layer and the sway
|
||||
expect(anims.every((a: any) => a.pause.mock.calls.length > 0)).toBe(true);
|
||||
anims.forEach((a: any) => expect(a.play).not.toHaveBeenCalled());
|
||||
expect(rafQueue).toHaveLength(0);
|
||||
s.dispose();
|
||||
|
|
@ -413,7 +438,7 @@ describe('createRidgeScene', () => {
|
|||
it('pauses the drift when the tab is hidden and resumes when it is visible', () => {
|
||||
const { parent, canvas } = host();
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
|
||||
const anims = parent.children.slice(1).flatMap((l: any) => l.anims);
|
||||
const anims = animsOf(parent);
|
||||
anims.forEach((a: any) => a.pause.mockClear());
|
||||
doc.visibilityState = 'hidden';
|
||||
doc.dispatchEvent(new Event('visibilitychange'));
|
||||
|
|
@ -442,7 +467,7 @@ describe('createRidgeScene', () => {
|
|||
const { parent, canvas } = host();
|
||||
const before = parent.children.length;
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
|
||||
expect(parent.children.length).toBe(before + LAYERS.length);
|
||||
expect(parent.children.length).toBe(before + 1); // the rig
|
||||
runFrames(16.7, 300);
|
||||
expect(rafQueue.length).toBe(0);
|
||||
s.dispose();
|
||||
|
|
@ -466,7 +491,7 @@ describe('createRidgeScene', () => {
|
|||
(window as any).innerHeight = 800;
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
expect(parent.children.slice(1)).toHaveLength(LAYERS.length);
|
||||
expect(tracksOf(parent)).toHaveLength(LAYERS.length);
|
||||
expect(draws.filter((d) => d === 'sky')).toHaveLength(1);
|
||||
s!.dispose();
|
||||
expect(parent.children).toEqual([canvas]);
|
||||
|
|
@ -475,7 +500,7 @@ describe('createRidgeScene', () => {
|
|||
it('dispose cancels the loop, the animations and the layers', () => {
|
||||
const { parent, canvas } = host();
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
|
||||
const anims = parent.children.slice(1).flatMap((l: any) => l.anims);
|
||||
const anims = animsOf(parent);
|
||||
s.dispose();
|
||||
anims.forEach((a: any) => expect(a.cancel).toHaveBeenCalled());
|
||||
expect(parent.children).toEqual([canvas]);
|
||||
|
|
@ -501,16 +526,6 @@ describe('the door', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('fbm', () => {
|
||||
it('is normalised into [0, 1]', () => {
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
const v = fbmPeriodic(i / 100, 0, 3, FBM);
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('aerial perspective in the scene', () => {
|
||||
it('is baked once: one scatter fill per hazy layer, none per frame, the nearest layer has none', () => {
|
||||
const { canvas } = host();
|
||||
|
|
@ -533,7 +548,7 @@ describe('aerial perspective in the scene', () => {
|
|||
s.dispose();
|
||||
});
|
||||
|
||||
it('softens with the canvas filter where it exists, blur scaled by DPR, tiled so the wrap does not fade', () => {
|
||||
it('softens with the canvas filter where it exists, blur scaled by DPR, from pixels baked past the chunk edges', () => {
|
||||
const { parent, canvas } = host();
|
||||
const filters: string[] = [];
|
||||
const orig = (document as any).createElement;
|
||||
|
|
@ -557,7 +572,7 @@ describe('aerial perspective in the scene', () => {
|
|||
const blurs = filters.filter((f) => f !== 'none');
|
||||
expect(blurs).toHaveLength(LAYERS.length - 1); // nearest is sharp
|
||||
expect(blurs[0]).toBe(`blur(${edgeBlur(LAYERS[0]) * 2}px)`); // DPR 2
|
||||
expect(parent.children).toHaveLength(1 + LAYERS.length);
|
||||
expect(parent.children).toHaveLength(2); // sky canvas and the rig
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
|
|
@ -591,9 +606,8 @@ describe('the starfield in the scene', () => {
|
|||
const s = createRidgeScene({ canvas, tier: 'full', sky: night })!;
|
||||
expect(count('star')).toBe(N);
|
||||
expect(count('sky')).toBe(1);
|
||||
expect(parent.children.indexOf(parent.children[1])).toBe(1);
|
||||
expect(parent.children[1].anims).toHaveLength(0); // the overlay: above the sky, under the ridges
|
||||
expect(parent.children).toHaveLength(2 + LAYERS.length);
|
||||
expect(parent.children).toHaveLength(3);
|
||||
const before = baked();
|
||||
runFrames(16.7, 300); // 5 s at 60 fps; the overlay repaints at 20 fps
|
||||
expect(baked()).toBe(before);
|
||||
|
|
@ -609,7 +623,7 @@ describe('the starfield in the scene', () => {
|
|||
const { parent, canvas } = host();
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
|
||||
expect(count('star')).toBe(0);
|
||||
expect(parent.children).toHaveLength(1 + LAYERS.length);
|
||||
expect(parent.children).toHaveLength(2);
|
||||
runFrames(16.7, 60);
|
||||
expect(count('clear')).toBe(0);
|
||||
s.dispose();
|
||||
|
|
@ -688,3 +702,113 @@ describe('the starfield in the scene', () => {
|
|||
expect(rafQueue).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('endless drift: chunks baked on demand and recycled', () => {
|
||||
const size = layerSize(400, 800, 2); // the fake host: 400 x 800 CSS px at DPR 2
|
||||
const until = async (cond: () => boolean) => {
|
||||
const t0 = Date.now();
|
||||
while (!cond()) {
|
||||
if (Date.now() - t0 > 10000) throw new Error('timed out waiting for the background bakes');
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
};
|
||||
/** The pixels chunk `m` of layer `li` must hold: the pure bake of its world columns, no blur. */
|
||||
const expected = (li: number, m: number) => {
|
||||
const p: BakeParams = {
|
||||
spec: LAYERS[li],
|
||||
sky: DAY,
|
||||
period: size.period,
|
||||
scale: 2,
|
||||
viewH: 1600,
|
||||
rows: size.height,
|
||||
bandH: size.height,
|
||||
px0: m * size.chunk,
|
||||
cols: size.chunk,
|
||||
};
|
||||
const out = new Uint8ClampedArray(p.cols * p.rows * 4);
|
||||
bakeSlice(out, p, layerPaint(p.spec, DAY), 0, p.cols);
|
||||
return Buffer.from(out.buffer);
|
||||
};
|
||||
/** Each distinct ImageData put, in order of first appearance: one per baked chunk. */
|
||||
const baked = () => [...new Set(puts)].map((img) => Buffer.from(img.data.buffer));
|
||||
const start = () => {
|
||||
const { parent, canvas } = host();
|
||||
const records: any[] = [];
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY, onBake: (b) => records.push(b) })!;
|
||||
return { parent, s, records };
|
||||
};
|
||||
|
||||
it('bakes every chunk from its own world columns: no offset, the next chunk continues the last', async () => {
|
||||
const { s, records } = start();
|
||||
await until(() => records.length === 3 * LAYERS.length);
|
||||
// First view: chunk 0 of each layer; then, per layer in queue order, chunks 1 and 2.
|
||||
const order = [
|
||||
...LAYERS.map((l) => [l.index, 0]),
|
||||
...LAYERS.flatMap((l) => [
|
||||
[l.index, 1],
|
||||
[l.index, 2],
|
||||
]),
|
||||
];
|
||||
expect(records.map((r) => [r.layer, r.chunk])).toEqual(order);
|
||||
const imgs = baked();
|
||||
expect(imgs).toHaveLength(order.length);
|
||||
order.forEach(([li, m], i) => expect(imgs[i].equals(expected(li, m))).toBe(true));
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
it('when the drift animation ends, the chunks move one slot left and the track restarts', async () => {
|
||||
const { parent, s, records } = start();
|
||||
await until(() => records.length === 3 * LAYERS.length);
|
||||
const track = tracksOf(parent)[0];
|
||||
const [e0, e1, e2] = track.children;
|
||||
const first = track.anims[0];
|
||||
// One chunk of drift, not one viewport: the track slides by the width of a chunk.
|
||||
expect(first.keyframes[1].transform).toContain(`${-size.chunk / 2}px`);
|
||||
expect(track.animate).toHaveBeenCalledTimes(1);
|
||||
first.finish();
|
||||
expect(first.cancel).toHaveBeenCalled();
|
||||
expect(track.animate).toHaveBeenCalledTimes(2);
|
||||
const w = size.chunk / 2;
|
||||
expect([e1.style.left, e2.style.left, e0.style.left]).toEqual(['0px', `${w}px`, `${2 * w}px`]);
|
||||
expect(track.anims[1].keyframes[1].transform).toContain(`${-w}px`);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
it('the canvas that left is baked with the next stretch of world, and each rotation after it, without end', async () => {
|
||||
const { parent, s, records } = start();
|
||||
await until(() => records.length === 3 * LAYERS.length);
|
||||
const track = tracksOf(parent)[0];
|
||||
const done = () => records.filter((r) => r.layer === 0).map((r) => r.chunk);
|
||||
for (let m = 3; m <= 5; m += 1) {
|
||||
track.anims[track.anims.length - 1].finish();
|
||||
await until(() => done().includes(m));
|
||||
}
|
||||
expect(done()).toEqual([0, 1, 2, 3, 4, 5]);
|
||||
// The pixels of chunks 3 to 5 are the world's, not a repeat of an earlier one.
|
||||
const imgs = baked();
|
||||
const layer0 = imgs.filter((_, i) => records[i].layer === 0);
|
||||
[3, 4, 5].forEach((m, k) => expect(layer0[3 + k].equals(expected(0, m))).toBe(true));
|
||||
expect(layer0[3].equals(layer0[0])).toBe(false);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
it('with the blur, each chunk is blurred from a scratch canvas of its own width: seams stay opaque and exact', async () => {
|
||||
const orig = (document as any).createElement;
|
||||
(document as any).createElement = (tag: string) => {
|
||||
const el = orig(tag);
|
||||
el.getContext = vi.fn(() => ctx(true));
|
||||
return el;
|
||||
};
|
||||
const { s, records } = start();
|
||||
await until(() => records.length === 3 * LAYERS.length);
|
||||
const want = new Set(
|
||||
LAYERS.filter((l) => edgeBlur(l) > 0).map(
|
||||
(l) => size.chunk + 2 * Math.ceil(3 * edgeBlur(l) * 2)
|
||||
)
|
||||
);
|
||||
expect(want.size).toBeGreaterThan(1); // the layers' pads differ, which is what a shared canvas gets wrong
|
||||
// Every blur reads a source exactly as wide as that chunk's padded bake, at the moment of the draw.
|
||||
expect(new Set(drawnWidths)).toEqual(want);
|
||||
s.dispose();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
/* eslint-disable no-param-reassign -- the bake writes into the canvases and layer records it is handed */
|
||||
import type { Scene, SceneOptions } from '../Scene';
|
||||
import { overlayRequested } from '../debugOverlays';
|
||||
import { FrameSampler, FrameStats } from './probe';
|
||||
import { skyColours, SkyColours, Srgb } from './skyColour';
|
||||
import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars';
|
||||
import {
|
||||
LAYERS,
|
||||
LayerSpec,
|
||||
edgeBlur,
|
||||
hazeColour,
|
||||
layerColour,
|
||||
layerSize,
|
||||
ridgeY,
|
||||
scatterAlpha,
|
||||
} from './ridgeWorld';
|
||||
import { bakeSlice, BakeParams, layerPaint } from './ridgeBake';
|
||||
import { LAYERS, LayerSpec, edgeBlur, layerSize, scatterAlpha, transmittance } from './ridgeWorld';
|
||||
|
||||
/** One chunk's bake: `ms` is the summed task time, `maxTaskMs` the longest single task; `first` marks the bake of the first view. */
|
||||
export interface BakeRecord {
|
||||
layer: number;
|
||||
chunk: number;
|
||||
ms: number;
|
||||
maxTaskMs: number;
|
||||
first: boolean;
|
||||
}
|
||||
|
||||
export type RidgeOptions = Pick<SceneOptions, 'canvas' | 'tier'> & {
|
||||
/** Show the rAF median/p95 readout. Default: the page URL has `?probe=1`, where `debug` allows it. */
|
||||
|
|
@ -23,15 +25,11 @@ export type RidgeOptions = Pick<SceneOptions, 'canvas' | 'tier'> & {
|
|||
onProbe?: (s: FrameStats & { still: boolean }) => void;
|
||||
/** The sky's colours; default `skyColours()`, the one function to replace with Void's sun.js. */
|
||||
sky?: SkyColours;
|
||||
/** Called after each chunk is baked (a chunk is one layer's stretch of world-x): the cost record. */
|
||||
onBake?: (b: BakeRecord) => void;
|
||||
};
|
||||
|
||||
const css = (c: Srgb): string => `rgb(${c[0]},${c[1]},${c[2]})`;
|
||||
const mix = (a: Srgb, b: Srgb, t: number): Srgb => [
|
||||
Math.round(a[0] + (b[0] - a[0]) * t),
|
||||
Math.round(a[1] + (b[1] - a[1]) * t),
|
||||
Math.round(a[2] + (b[2] - a[2]) * t),
|
||||
];
|
||||
|
||||
function context2d(c: HTMLCanvasElement): CanvasRenderingContext2D | null {
|
||||
return c.getContext('2d');
|
||||
}
|
||||
|
|
@ -49,95 +47,202 @@ function paintSky(canvas: HTMLCanvasElement, sky: SkyColours): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
const clock = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||
|
||||
type Size = ReturnType<typeof layerSize>;
|
||||
|
||||
const SCATTER_STOPS = 16;
|
||||
|
||||
/** Warm in-scatter over the silhouette only (source-atop), as a horizontal gradient that repeats each period. */
|
||||
/** Warm in-scatter over the silhouette only (source-atop): a horizontal gradient over the chunk's world-x. */
|
||||
function scatter(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
spec: LayerSpec,
|
||||
sky: SkyColours,
|
||||
size: { period: number; width: number; height: number }
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
t0: number,
|
||||
tPerPx: number,
|
||||
bandX = 0,
|
||||
bandW = widthPx
|
||||
) {
|
||||
if (sky.sunAzimuth === undefined || scatterAlpha(spec, sky, sky.sunAzimuth) <= 0) return;
|
||||
const g = ctx.createLinearGradient(0, 0, size.width, 0);
|
||||
if (sky.sunAzimuth === undefined || transmittance(spec) >= 1) return; // the nearest layer takes none
|
||||
const g = ctx.createLinearGradient(0, 0, widthPx, 0);
|
||||
const [r, gr, b] = sky.horizon;
|
||||
for (let k = 0; k <= 2 * SCATTER_STOPS; k += 1) {
|
||||
const t = (k % SCATTER_STOPS) / SCATTER_STOPS;
|
||||
g.addColorStop(k / (2 * SCATTER_STOPS), `rgba(${r},${gr},${b},${scatterAlpha(spec, sky, t)})`);
|
||||
for (let k = 0; k <= SCATTER_STOPS; k += 1) {
|
||||
const a = scatterAlpha(spec, sky, t0 + (tPerPx * widthPx * k) / SCATTER_STOPS);
|
||||
g.addColorStop(k / SCATTER_STOPS, `rgba(${r},${gr},${b},${a})`);
|
||||
}
|
||||
ctx.globalCompositeOperation = 'source-atop';
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, size.width, size.height);
|
||||
ctx.fillRect(bandX, 0, bandW, heightPx);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
|
||||
/**
|
||||
* Soften the silhouette's edge by `px` once, where the browser's 2D canvas supports `filter` (not
|
||||
* Safari, which keeps the sharp edge). One period is tiled past both ends and the bottom row extended below,
|
||||
* then blurred in a single draw, so the blur sees the wrap and the ground instead of fading at borders.
|
||||
*/
|
||||
function soften(src: HTMLCanvasElement, px: number, period: number): HTMLCanvasElement {
|
||||
const probe = context2d(src);
|
||||
if (!probe || px <= 0 || !('filter' in probe)) return src;
|
||||
const pad = Math.ceil(3 * px);
|
||||
// The unfiltered tiling: blurring tile by tile would fade each tile's own border.
|
||||
const tiled = document.createElement('canvas');
|
||||
tiled.width = src.width + 2 * period;
|
||||
tiled.height = src.height + pad;
|
||||
const tctx = context2d(tiled);
|
||||
const out = document.createElement('canvas');
|
||||
out.width = src.width;
|
||||
out.height = src.height;
|
||||
const ctx = context2d(out);
|
||||
if (!tctx || !ctx) return src;
|
||||
for (let k = 0; k < 4; k += 1) {
|
||||
tctx.drawImage(src, 0, 0, period, src.height, k * period, 0, period, src.height);
|
||||
tctx.drawImage(src, 0, src.height - 1, period, 1, k * period, src.height, period, pad);
|
||||
}
|
||||
ctx.filter = `blur(${px}px)`;
|
||||
ctx.drawImage(tiled, -period, 0);
|
||||
ctx.filter = 'none';
|
||||
return out;
|
||||
/** A chunk's bake in slices: `step(budgetMs)` returns true once the chunk is on its canvas. */
|
||||
interface Job {
|
||||
step(budgetMs: number): boolean;
|
||||
}
|
||||
|
||||
/** One ridge layer, painted once: a filled silhouette two periods wide on a plain 2D canvas. */
|
||||
function paintLayer(
|
||||
/**
|
||||
* One layer's stretch of world-x `[m * chunk, (m + 1) * chunk)` painted onto `el`, in three phases of
|
||||
* column bands, each band sized to the task budget by measuring the last one: bake the pixels
|
||||
* (`bakeSlice`), put them on a canvas with the in-scatter, and (where the 2D `filter` exists) blur them
|
||||
* into `el`. The blur needs neighbours, so the pixels are baked `pad` columns past both ends and
|
||||
* the bottom into a scratch canvas this job owns, and each blurred band reads `pad` columns either side of
|
||||
* itself: seams show no fade. The scratch is made when the job first runs and dropped when it ends.
|
||||
*/
|
||||
function chunkJob(
|
||||
el: HTMLCanvasElement,
|
||||
makeCanvas: () => HTMLCanvasElement,
|
||||
spec: LayerSpec,
|
||||
sky: SkyColours,
|
||||
size: { period: number; width: number; height: number },
|
||||
size: Size,
|
||||
viewH: number,
|
||||
scale: number
|
||||
): HTMLCanvasElement | null {
|
||||
const el = document.createElement('canvas');
|
||||
el.width = size.width;
|
||||
el.height = size.height;
|
||||
scale: number,
|
||||
m: number,
|
||||
record: (ms: number, maxTaskMs: number) => void
|
||||
): Job | null {
|
||||
const ctx = context2d(el);
|
||||
if (!ctx) return null;
|
||||
const top: number[] = [];
|
||||
for (let x = 0; x < size.period; x += 1)
|
||||
top.push(ridgeY(spec, x, size.period, size.height, viewH));
|
||||
const c = layerColour(spec, sky);
|
||||
const g = ctx.createLinearGradient(0, 0, 0, size.height);
|
||||
g.addColorStop(0, css(c));
|
||||
g.addColorStop(1, css(mix(c, hazeColour(sky), 0.5 * (1 - spec.depth))));
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, size.height);
|
||||
for (let x = 0; x <= size.width; x += 1) ctx.lineTo(x, top[x % size.period]);
|
||||
ctx.lineTo(size.width, size.height);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
scatter(ctx, spec, sky, size);
|
||||
return soften(el, edgeBlur(spec) * scale, size.period);
|
||||
const blur = edgeBlur(spec) * scale;
|
||||
const pad = blur > 0 && 'filter' in ctx ? Math.ceil(3 * blur) : 0;
|
||||
const cols = size.chunk + 2 * pad;
|
||||
const rows = size.height + pad;
|
||||
const params: BakeParams = {
|
||||
spec,
|
||||
sky,
|
||||
period: size.period,
|
||||
scale,
|
||||
viewH,
|
||||
rows,
|
||||
bandH: size.height,
|
||||
px0: m * size.chunk - pad,
|
||||
cols,
|
||||
};
|
||||
const paint = layerPaint(spec, sky);
|
||||
const t0World = params.px0 / size.period;
|
||||
let scratch: HTMLCanvasElement | undefined;
|
||||
let sctx: CanvasRenderingContext2D | null = ctx;
|
||||
let img: ImageData | undefined;
|
||||
/** 0 bake, 1 put and in-scatter, 2 blur into el; a chunk with no blur ends after 1. */
|
||||
let phase = 0;
|
||||
let pos = 0;
|
||||
let sliceCols = 32;
|
||||
let total = 0;
|
||||
let longest = 0;
|
||||
const last = pad > 0 ? 2 : 1;
|
||||
const start = (): boolean => {
|
||||
if (pad > 0) {
|
||||
scratch = makeCanvas();
|
||||
scratch.width = cols;
|
||||
scratch.height = rows;
|
||||
sctx = context2d(scratch);
|
||||
} else {
|
||||
el.width = size.chunk;
|
||||
el.height = size.height;
|
||||
}
|
||||
if (!sctx) return false;
|
||||
img = sctx.createImageData(cols, rows);
|
||||
return true;
|
||||
};
|
||||
const work = (x: number, n: number) => {
|
||||
if (!img || !sctx) return;
|
||||
if (phase === 0) bakeSlice(img.data, params, paint, x, x + n);
|
||||
else if (phase === 1) {
|
||||
sctx.putImageData(img, 0, 0, x, 0, n, rows);
|
||||
scatter(sctx, spec, sky, cols, rows, t0World, 1 / size.period, x, n);
|
||||
} else {
|
||||
// One draw: a blurred draw cut into bands costs more in the margins than it saves in task length.
|
||||
ctx.drawImage(scratch as HTMLCanvasElement, -pad, 0);
|
||||
}
|
||||
};
|
||||
return {
|
||||
step(budgetMs) {
|
||||
const t0 = clock();
|
||||
// Allocating the canvases and the pixel buffer is a task of its own.
|
||||
if (!img) {
|
||||
if (!start()) return true;
|
||||
if (Number.isFinite(budgetMs)) {
|
||||
total += clock() - t0;
|
||||
longest = Math.max(longest, clock() - t0);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
while (phase <= last && clock() - t0 < budgetMs) {
|
||||
const end = phase === 2 ? size.chunk : cols;
|
||||
if (pos === 0 && phase === 2) {
|
||||
el.width = size.chunk;
|
||||
el.height = size.height;
|
||||
ctx.filter = `blur(${blur}px)`;
|
||||
}
|
||||
const n = phase === 2 ? end : Math.min(end - pos, sliceCols);
|
||||
const s0 = clock();
|
||||
work(pos, n);
|
||||
pos += n;
|
||||
const dt = Math.max(clock() - s0, 0.02);
|
||||
sliceCols = Number.isFinite(budgetMs)
|
||||
? Math.max(8, Math.min(512, Math.round((n * budgetMs * 0.4) / dt)))
|
||||
: cols;
|
||||
if (pos >= end) {
|
||||
if (phase === 2) ctx.filter = 'none';
|
||||
phase += 1;
|
||||
pos = 0;
|
||||
if (phase === 1 || phase === 2) sliceCols = Number.isFinite(budgetMs) ? 64 : cols;
|
||||
}
|
||||
}
|
||||
const dt = clock() - t0;
|
||||
total += dt;
|
||||
longest = Math.max(longest, dt);
|
||||
const done = phase > last;
|
||||
if (done) {
|
||||
img = undefined;
|
||||
if (scratch) {
|
||||
scratch.width = 0;
|
||||
scratch.height = 0;
|
||||
}
|
||||
scratch = undefined;
|
||||
record(total, longest);
|
||||
}
|
||||
return done;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Chunk {
|
||||
el: HTMLCanvasElement;
|
||||
/** World chunk index it holds or is being baked to hold. */
|
||||
id: number;
|
||||
job?: Job;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
/** One layer: a track that drifts by one chunk per animation, holding three chunk canvases. */
|
||||
interface LayerState {
|
||||
spec: LayerSpec;
|
||||
track: HTMLElement;
|
||||
/** Slot 0 and 1 are what the window can see; slot 2 is the one ahead, baked in the background. */
|
||||
slots: Chunk[];
|
||||
anim?: Animation;
|
||||
}
|
||||
|
||||
interface Built {
|
||||
layers: HTMLCanvasElement[];
|
||||
anims: Animation[];
|
||||
rig: HTMLElement;
|
||||
layers: LayerState[];
|
||||
sway?: Animation;
|
||||
size: Size;
|
||||
/** CSS px to canvas px, and the viewport's height in canvas px, as baked. */
|
||||
scale: number;
|
||||
viewH: number;
|
||||
/** Makes a canvas, bound to the document the scene was built in. */
|
||||
makeCanvas: () => HTMLCanvasElement;
|
||||
/** The twinkling stars' own canvas, between the sky and the ridges; absent in a day sky and in 'still'. */
|
||||
twinkle?: HTMLCanvasElement;
|
||||
}
|
||||
|
||||
/** Yaw and pitch: the camera's slow sway, a loop of this many seconds over the whole rig. */
|
||||
const SWAY_SECONDS = 150;
|
||||
const SLICE_BUDGET_MS = 6;
|
||||
|
||||
/** The twinkle overlay redraws at most this often: the swings are 2 to 7 s, no need for 60 fps. */
|
||||
const TWINKLE_FRAME_MS = 50;
|
||||
|
||||
|
|
@ -162,6 +267,9 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
let twRaf = 0;
|
||||
let twLast = 0;
|
||||
let still = tier === 'still';
|
||||
let jobTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let queue: Chunk[] = [];
|
||||
let generation = 0;
|
||||
let disposed = false;
|
||||
let raf = 0;
|
||||
let readout: HTMLElement | undefined;
|
||||
|
|
@ -175,8 +283,13 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
};
|
||||
|
||||
const teardownLayers = () => {
|
||||
built?.anims.forEach((a) => a.cancel());
|
||||
built?.layers.forEach((l) => l.remove());
|
||||
generation += 1;
|
||||
clearTimeout(jobTimer);
|
||||
jobTimer = undefined;
|
||||
queue = [];
|
||||
built?.sway?.cancel();
|
||||
built?.layers.forEach((l) => l.anim?.cancel());
|
||||
built?.rig.remove();
|
||||
built?.twinkle?.remove();
|
||||
cancelAnimationFrame(twRaf);
|
||||
twRaf = 0;
|
||||
|
|
@ -190,6 +303,109 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
return size.period < 1 || size.height < 1 || Math.round(h * scale) < 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Start baking chunk `c` of layer `l`. `first` runs it to the end in this task (the first view);
|
||||
* otherwise it joins the queue, which `pump` works through in short tasks. False when the canvas has no 2D context.
|
||||
*/
|
||||
const bake = (l: LayerState, c: Chunk, first: boolean): boolean => {
|
||||
if (!built) return false;
|
||||
const { scale, viewH, makeCanvas } = built;
|
||||
const job = chunkJob(
|
||||
c.el,
|
||||
makeCanvas,
|
||||
l.spec,
|
||||
sky,
|
||||
built.size,
|
||||
viewH,
|
||||
scale,
|
||||
c.id,
|
||||
(ms, maxTaskMs) => {
|
||||
c.ready = true;
|
||||
c.job = undefined;
|
||||
opts.onBake?.({ layer: l.spec.index, chunk: c.id, ms, maxTaskMs, first });
|
||||
}
|
||||
);
|
||||
if (!job) return false;
|
||||
c.ready = false;
|
||||
c.job = job;
|
||||
if (first) job.step(Infinity);
|
||||
else queue.push(c);
|
||||
return true;
|
||||
};
|
||||
|
||||
const pump = () => {
|
||||
if (jobTimer !== undefined || !queue.length) return;
|
||||
const gen = generation;
|
||||
jobTimer = setTimeout(() => {
|
||||
jobTimer = undefined;
|
||||
if (gen !== generation) return;
|
||||
const c = queue[0];
|
||||
if (!c.job || c.job.step(SLICE_BUDGET_MS)) queue.shift();
|
||||
pump();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
/** Bake a still-queued chunk to the end now: only when the drift has caught up with the background work. */
|
||||
const flush = (c: Chunk) => {
|
||||
while (c.job && !c.job.step(Infinity));
|
||||
queue = queue.filter((q) => q !== c);
|
||||
};
|
||||
|
||||
const chunkSeconds = (spec: LayerSpec, size: Size) => (spec.seconds * size.chunk) / size.period;
|
||||
|
||||
/** One chunk of drift as one animation, holding its end until `rotate` hands the track the next chunk. */
|
||||
const startDrift = (l: LayerState, onEnd: (l: LayerState) => void) => {
|
||||
if (!built) return;
|
||||
const { size, scale } = built;
|
||||
l.anim = l.track.animate?.(
|
||||
[
|
||||
{ transform: 'translate3d(0,0,0)' },
|
||||
{ transform: `translate3d(${-size.chunk / scale}px,0,0)` },
|
||||
],
|
||||
{ duration: chunkSeconds(l.spec, size) * 1000, easing: 'linear', fill: 'forwards' }
|
||||
);
|
||||
if (l.anim) l.anim.onfinish = () => onEnd(l);
|
||||
};
|
||||
|
||||
/**
|
||||
* The window has drifted one chunk. Slot 1 is now slot 0 and slot 2 slot 1, at the same screen place
|
||||
* (the track restarts at 0 and the chunks move one slot left in the same task, so nothing jumps); the
|
||||
* canvas that left becomes slot 2 and is baked with the world one chunk further on, off the critical path.
|
||||
*/
|
||||
const rotate = (l: LayerState) => {
|
||||
if (!built || !built.layers.includes(l)) return;
|
||||
const [s0, s1, s2] = l.slots;
|
||||
flush(s1);
|
||||
flush(s2);
|
||||
l.slots = [s1, s2, s0];
|
||||
s0.id = s2.id + 1;
|
||||
const { size, scale } = built;
|
||||
l.slots.forEach((c, i) => {
|
||||
c.el.style.left = `${(i * size.chunk) / scale}px`;
|
||||
});
|
||||
l.anim?.cancel();
|
||||
startDrift(l, rotate);
|
||||
bake(l, s0, false);
|
||||
pump();
|
||||
syncPlayState();
|
||||
};
|
||||
|
||||
/** The camera's slow yaw and pitch: the rig eases sideways (up to 5% of the width) and down (up to 3% of the height) and back, in a loop. */
|
||||
const startSway = (rig: HTMLElement, w: number, h: number) => {
|
||||
const x = 0.05 * w;
|
||||
const y = 0.03 * h;
|
||||
const at = (fx: number, fy: number) => `translate3d(${-x * fx}px,${y * fy}px,0)`;
|
||||
return rig.animate?.(
|
||||
[
|
||||
{ transform: at(0, 0), offset: 0 },
|
||||
{ transform: at(0.55, 1), offset: 0.35 },
|
||||
{ transform: at(1, 0.3), offset: 0.7 },
|
||||
{ transform: at(0, 0), offset: 1 },
|
||||
],
|
||||
{ duration: SWAY_SECONDS * 1000, iterations: Infinity, easing: 'ease-in-out' }
|
||||
);
|
||||
};
|
||||
|
||||
/** Bake the sky, then the layers. Returns false when a 2D context is missing or there is no area yet. */
|
||||
const build = (): boolean => {
|
||||
teardownLayers();
|
||||
|
|
@ -200,7 +416,6 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
canvas.width = size.period;
|
||||
canvas.height = Math.round(h * scale);
|
||||
if (!paintSky(canvas, sky)) return false;
|
||||
const layers: HTMLCanvasElement[] = [];
|
||||
// Every star is baked into the sky; the twinkling ones get their own canvas, which a per-frame
|
||||
// pass clears and paints over. 'still' has no overlay.
|
||||
const stars = makeStars(sky, w, h);
|
||||
|
|
@ -223,39 +438,73 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
parent.appendChild(twinkle);
|
||||
}
|
||||
field = stars;
|
||||
for (const spec of LAYERS) {
|
||||
const el = paintLayer(spec, sky, size, canvas.height, scale);
|
||||
if (!el) {
|
||||
layers.forEach((l) => l.remove());
|
||||
twinkle?.remove();
|
||||
return false;
|
||||
}
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
Object.assign(el.style, {
|
||||
// The rig carries the camera's sway; each layer's track carries its drift; chunks sit on the track.
|
||||
const rig = document.createElement('div');
|
||||
rig.setAttribute('aria-hidden', 'true');
|
||||
Object.assign(rig.style, {
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
bottom: '0',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
willChange: 'transform',
|
||||
});
|
||||
const layers: LayerState[] = LAYERS.map((spec) => {
|
||||
const track = document.createElement('div');
|
||||
Object.assign(track.style, {
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
bottom: '0',
|
||||
width: `${size.width / scale}px`,
|
||||
width: `${size.period / scale}px`,
|
||||
height: `${size.height / scale}px`,
|
||||
pointerEvents: 'none',
|
||||
willChange: 'transform',
|
||||
});
|
||||
layers.push(el);
|
||||
const slots: Chunk[] = [0, 1, 2].map((i) => {
|
||||
const el = document.createElement('canvas');
|
||||
el.width = size.chunk;
|
||||
el.height = size.height;
|
||||
Object.assign(el.style, {
|
||||
position: 'absolute',
|
||||
bottom: '0',
|
||||
width: `${size.chunk / scale}px`,
|
||||
height: `${size.height / scale}px`,
|
||||
});
|
||||
return { el, id: i, ready: false };
|
||||
});
|
||||
return { spec, track, slots };
|
||||
});
|
||||
const doc = document;
|
||||
built = {
|
||||
rig,
|
||||
layers,
|
||||
size,
|
||||
scale,
|
||||
viewH: canvas.height,
|
||||
makeCanvas: () => doc.createElement('canvas'),
|
||||
twinkle,
|
||||
};
|
||||
// First view: only slot 0 of each layer, in this task. The window sees nothing else for the first
|
||||
// fifth of a chunk's drift; slots 1 and 2 are baked in short tasks behind it.
|
||||
if (!layers.every((l) => bake(l, l.slots[0], true))) {
|
||||
teardownLayers();
|
||||
return false;
|
||||
}
|
||||
layers.forEach((l) => {
|
||||
l.slots.forEach((c, i) => {
|
||||
c.el.setAttribute('aria-hidden', 'true');
|
||||
c.el.style.left = `${(i * size.chunk) / scale}px`;
|
||||
l.track.appendChild(c.el);
|
||||
});
|
||||
rig.appendChild(l.track);
|
||||
bake(l, l.slots[1], false);
|
||||
bake(l, l.slots[2], false);
|
||||
});
|
||||
// DOM order is paint order: farthest first, so the nearest ridge ends up on top.
|
||||
layers.forEach((l) => parent.appendChild(l));
|
||||
const anims = layers
|
||||
.map((el, i) =>
|
||||
el.animate?.(
|
||||
[
|
||||
{ transform: 'translate3d(0,0,0)' },
|
||||
{ transform: `translate3d(${-size.period / scale}px,0,0)` },
|
||||
],
|
||||
{ duration: LAYERS[i].seconds * 1000, iterations: Infinity, easing: 'linear' }
|
||||
)
|
||||
)
|
||||
.filter((a): a is Animation => !!a);
|
||||
built = { layers, anims, twinkle };
|
||||
parent.appendChild(rig);
|
||||
layers.forEach((l) => startDrift(l, rotate));
|
||||
built.sway = startSway(rig, w, h);
|
||||
pump();
|
||||
syncPlayState();
|
||||
return true;
|
||||
};
|
||||
|
|
@ -288,7 +537,8 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
};
|
||||
|
||||
function syncPlayState() {
|
||||
built?.anims.forEach((a) => (moving() ? a.play() : a.pause()));
|
||||
const anims = built ? [built.sway, ...built.layers.map((l) => l.anim)] : [];
|
||||
anims.forEach((a) => (moving() ? a?.play() : a?.pause()));
|
||||
if (!built?.twinkle) return;
|
||||
if (moving()) {
|
||||
if (!twRaf) twRaf = requestAnimationFrame(twTick);
|
||||
|
|
@ -305,13 +555,15 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
still = true;
|
||||
const ctx = context2d(canvas);
|
||||
if (ctx && built) {
|
||||
const { anims } = built;
|
||||
built.layers.forEach((l, i) => {
|
||||
// Freeze where the drift is, so the swap to a still does not jump.
|
||||
const a = anims[i];
|
||||
const t = Number(a?.currentTime ?? 0);
|
||||
const turns = (t % (LAYERS[i].seconds * 1000)) / (LAYERS[i].seconds * 1000);
|
||||
ctx.drawImage(l, -Math.round(turns * canvas.width), canvas.height - l.height);
|
||||
const { size } = built;
|
||||
built.layers.forEach((l) => {
|
||||
// Freeze where the drift is, so the swap to a still does not jump; whatever is baked and in view.
|
||||
const dur = chunkSeconds(l.spec, size) * 1000;
|
||||
const p = Math.min(1, Math.max(0, Number(l.anim?.currentTime ?? 0) / dur));
|
||||
const x = -Math.round(p * size.chunk);
|
||||
l.slots.slice(0, 2).forEach((c, i) => {
|
||||
if (c.ready) ctx.drawImage(c.el, x + i * size.chunk, canvas.height - size.height);
|
||||
});
|
||||
});
|
||||
}
|
||||
teardownLayers();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// CultMath's value noise and fbm on the CPU, in one dimension and periodic. Clean-room: the
|
||||
// algorithm is read from shaders/CultMath.glsl (`cultmath_hash`, `cultmath_value_noise`), not copied
|
||||
// from any other terrain. Doubles instead of floats, so values differ from the GLSL in the last
|
||||
// digits; nothing here has to match a shader.
|
||||
// CultMath's value noise on the CPU, in one dimension, over the whole line (no period). Clean-room:
|
||||
// the algorithm is read from shaders/CultMath.glsl (`cultmath_hash`, `cultmath_value_noise`), not
|
||||
// copied from any other terrain. Doubles instead of floats, so values differ from the GLSL in the
|
||||
// last digits; nothing here has to match a shader.
|
||||
|
||||
const frac = (v: number): number => v - Math.floor(v);
|
||||
|
||||
|
|
@ -12,40 +12,14 @@ export const hash2 = (x: number, y: number): number =>
|
|||
const smooth = (t: number): number => t * t * (3 - 2 * t);
|
||||
|
||||
/**
|
||||
* `cultmath_value_noise` along row `row` of the lattice, with the lattice wrapping every `period`
|
||||
* cells: noise(x + period) === noise(x). `row` is an integer, so the vertical blend of the 2D
|
||||
* original is the identity.
|
||||
* `cultmath_value_noise` along row `row` of the lattice, in [0, 1). `row` is an integer, so the
|
||||
* vertical blend of the 2D original is the identity. The lattice is hashed per cell, never wrapped:
|
||||
* a ridge is a function of world-x, with no period for the eye to find.
|
||||
*/
|
||||
export function valueNoisePeriodic(x: number, row: number, period: number): number {
|
||||
export function valueNoise(x: number, row: number): number {
|
||||
const cell = Math.floor(x);
|
||||
const u = smooth(x - cell);
|
||||
const wrap = (c: number) => ((c % period) + period) % period;
|
||||
const a = hash2(wrap(cell), row);
|
||||
const b = hash2(wrap(cell + 1), row);
|
||||
const a = hash2(cell, row);
|
||||
const b = hash2(cell + 1, row);
|
||||
return a + (b - a) * u;
|
||||
}
|
||||
|
||||
export interface FbmShape {
|
||||
octaves: number;
|
||||
lacunarity: number;
|
||||
gain: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fractal sum in [0, 1]. `t` runs 0..1 across one period; the first octave has `baseCells` cells
|
||||
* per period and each next one `lacunarity` times more, so `baseCells * lacunarity^k` must be an
|
||||
* integer for the sum to be periodic (checked by the caller's config, not here).
|
||||
*/
|
||||
export function fbmPeriodic(t: number, row: number, baseCells: number, s: FbmShape): number {
|
||||
let sum = 0;
|
||||
let norm = 0;
|
||||
let amp = 1;
|
||||
let cells = baseCells;
|
||||
for (let k = 0; k < s.octaves; k += 1) {
|
||||
sum += amp * valueNoisePeriodic(t * cells, row + k * 13, cells);
|
||||
norm += amp;
|
||||
amp *= s.gain;
|
||||
cells *= s.lacunarity;
|
||||
}
|
||||
return sum / norm;
|
||||
}
|
||||
|
|
|
|||
303
src/app/features/scene/ridge/ridgeBake.test.ts
Normal file
303
src/app/features/scene/ridge/ridgeBake.test.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { BakeParams, bakeSlice, faceLight, layerPaint, sunDirection } from './ridgeBake';
|
||||
import { LACUNARITY, LAYERS, profile } from './ridgeWorld';
|
||||
import { skyColours, SkyColours } from './skyColour';
|
||||
|
||||
const SKY: SkyColours = { ...skyColours(), sunAzimuth: 0.3 }; // sun toward +x
|
||||
const PERIOD = 400;
|
||||
const VIEW_H = 800;
|
||||
const BAND = 480;
|
||||
|
||||
const params = (
|
||||
li: number,
|
||||
px0: number,
|
||||
cols: number,
|
||||
over: Partial<BakeParams> = {}
|
||||
): BakeParams => ({
|
||||
spec: LAYERS[li],
|
||||
sky: SKY,
|
||||
period: PERIOD,
|
||||
scale: 1,
|
||||
viewH: VIEW_H,
|
||||
rows: BAND,
|
||||
bandH: BAND,
|
||||
px0,
|
||||
cols,
|
||||
...over,
|
||||
});
|
||||
|
||||
function bake(p: BakeParams, slices: number[] = [p.cols]): Uint8ClampedArray {
|
||||
const out = new Uint8ClampedArray(p.cols * p.rows * 4);
|
||||
const paint = layerPaint(p.spec, p.sky);
|
||||
let c = 0;
|
||||
for (const n of slices) {
|
||||
bakeSlice(out, p, paint, c, Math.min(p.cols, c + n));
|
||||
c += n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const lum = (d: Uint8ClampedArray, cols: number, x: number, y: number) => {
|
||||
const o = (y * cols + x) * 4;
|
||||
return 0.2126 * d[o] + 0.7152 * d[o + 1] + 0.0722 * d[o + 2];
|
||||
};
|
||||
/** First row of column x whose alpha is full. */
|
||||
const solidRow = (d: Uint8ClampedArray, cols: number, x: number) => {
|
||||
let y = 0;
|
||||
while (y < BAND && d[(y * cols + x) * 4 + 3] < 255) y += 1;
|
||||
return y;
|
||||
};
|
||||
|
||||
describe('the field is a function of world-x', () => {
|
||||
it('is deterministic, and defined and bounded at any world-x', () => {
|
||||
for (const spec of LAYERS) {
|
||||
for (const t of [-40.3, 0, 0.5, 17.77, 5000.123]) {
|
||||
expect(profile(spec, t)).toBe(profile(spec, t));
|
||||
expect(profile(spec, t)).toBeGreaterThanOrEqual(0);
|
||||
expect(profile(spec, t)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('has no repeat at the old period (1 viewport) or its multiples: the profile differs from itself shifted', () => {
|
||||
for (const spec of LAYERS) {
|
||||
for (const lag of [1, 2, 3, 0.5]) {
|
||||
let diff = 0;
|
||||
for (let i = 0; i < 400; i += 1)
|
||||
diff += Math.abs(profile(spec, i / 100) - profile(spec, i / 100 + lag));
|
||||
expect(diff / 400).toBeGreaterThan(0.04);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('the same world column bakes to the same pixels whichever chunk or slice paints it', () => {
|
||||
for (const li of [0, 3]) {
|
||||
const whole = bake(params(li, 1000, 200));
|
||||
const sliced = bake(params(li, 1000, 200), [37, 64, 1000]);
|
||||
expect(Array.from(sliced)).toEqual(Array.from(whole));
|
||||
// A chunk starting 120 columns later sees the same pixels for the columns it shares.
|
||||
const shifted = bake(params(li, 1120, 80));
|
||||
for (const x of [0, 17, 79]) {
|
||||
for (const y of [0, 100, 300, BAND - 1]) {
|
||||
const a = (y * 200 + 120 + x) * 4;
|
||||
const b = (y * 80 + x) * 4;
|
||||
expect(Array.from(shifted.slice(b, b + 4))).toEqual(Array.from(whole.slice(a, a + 4)));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('is continuous across a chunk seam: the crest steps no more at the seam than elsewhere', () => {
|
||||
const chunk = Math.round(PERIOD * 1.2);
|
||||
for (const li of [0, 1, 2, 3]) {
|
||||
const p = params(li, chunk - 40, 80);
|
||||
const d = bake(p);
|
||||
let worst = 0;
|
||||
for (let x = 1; x < 80; x += 1)
|
||||
worst = Math.max(worst, Math.abs(solidRow(d, 80, x) - solidRow(d, 80, x - 1)));
|
||||
const seam = Math.abs(solidRow(d, 80, 40) - solidRow(d, 80, 39));
|
||||
expect(seam).toBeLessThanOrEqual(Math.max(worst, 1));
|
||||
// and the seam columns equal a chunk baked from the other side
|
||||
const other = bake(params(li, chunk - 40, 80, { cols: 80 }));
|
||||
expect(Array.from(other)).toEqual(Array.from(d));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('shading from the slope normal', () => {
|
||||
const sun = sunDirection(SKY);
|
||||
|
||||
it('the sun is toward +x for azimuth 0.3', () => {
|
||||
expect(sun[0]).toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
it('a face looking at the sun is lit more than one looking away', () => {
|
||||
// slope < 0: the crest falls toward +x, so the face looks at +x, at the sun.
|
||||
for (const s of [0.2, 0.6, 1.5]) expect(faceLight(-s, sun)).toBeGreaterThan(faceLight(s, sun));
|
||||
expect(faceLight(1.5, sun)).toBeLessThan(0.2);
|
||||
expect(faceLight(-1.5, sun)).toBeGreaterThan(0.6);
|
||||
});
|
||||
|
||||
it('in the baked pixels the lit side is brighter than the shadow side', () => {
|
||||
for (const li of [0, 1, 2]) {
|
||||
const cols = 900;
|
||||
const p = params(li, 0, cols);
|
||||
const d = bake(p);
|
||||
const slope = (x: number) => (solidRow(d, cols, x - 1) - solidRow(d, cols, x + 1)) / 2; // rows up per column
|
||||
const litX: number[] = [];
|
||||
const shadeX: number[] = [];
|
||||
for (let x = 2; x < cols - 2; x += 1) {
|
||||
if (slope(x) < -0.8) litX.push(x);
|
||||
if (slope(x) > 0.8) shadeX.push(x);
|
||||
}
|
||||
expect(litX.length).toBeGreaterThan(5);
|
||||
expect(shadeX.length).toBeGreaterThan(5);
|
||||
const mean = (xs: number[]) =>
|
||||
xs.reduce((a, x) => a + lum(d, cols, x, solidRow(d, cols, x) + 12), 0) / xs.length;
|
||||
expect(mean(litX)).toBeGreaterThan(mean(shadeX));
|
||||
}
|
||||
});
|
||||
|
||||
it('a lit crest rim: the crest pixels are brighter than the face a little below, on both sides', () => {
|
||||
for (const li of [0, 1, 2, 3]) {
|
||||
const cols = 600;
|
||||
const d = bake(params(li, 300, cols));
|
||||
let rimmed = 0;
|
||||
let n = 0;
|
||||
for (let x = 3; x < cols - 3; x += 1) {
|
||||
const y = solidRow(d, cols, x);
|
||||
if (y + 40 >= BAND) continue;
|
||||
n += 1;
|
||||
if (lum(d, cols, x, y + 1) > lum(d, cols, x, y + 30)) rimmed += 1;
|
||||
}
|
||||
expect(n).toBeGreaterThan(100);
|
||||
expect(rimmed / n).toBeGreaterThan(0.8);
|
||||
}
|
||||
});
|
||||
|
||||
it('the foot of the face is hazier than its top: it moves toward the haze colour', () => {
|
||||
const cols = 300;
|
||||
const p = params(0, 0, cols);
|
||||
const d = bake(p);
|
||||
const haze = layerPaint(p.spec, p.sky).haze;
|
||||
const dist = (x: number, y: number) => {
|
||||
const o = (y * cols + x) * 4;
|
||||
return Math.hypot(d[o] - haze[0], d[o + 1] - haze[1], d[o + 2] - haze[2]);
|
||||
};
|
||||
let nearer = 0;
|
||||
let n = 0;
|
||||
for (let x = 0; x < cols; x += 1) {
|
||||
const y = solidRow(d, cols, x);
|
||||
if (y + 200 >= BAND) continue;
|
||||
n += 1;
|
||||
if (dist(x, y + 200) < dist(x, y + 20)) nearer += 1;
|
||||
}
|
||||
expect(n).toBeGreaterThan(50);
|
||||
expect(nearer / n).toBeGreaterThan(0.9);
|
||||
});
|
||||
|
||||
it('erosion streaks run down the face: brightness varies across columns at one depth, and not without them', () => {
|
||||
const cols = 500;
|
||||
const spread = (gully: number) => {
|
||||
const d = bake(params(2, 0, cols, { spec: { ...LAYERS[2], gully } }));
|
||||
// The same depth below the crest in every column, in the lit-independent part (gullies darken, lit is column-wise).
|
||||
const v: number[] = [];
|
||||
for (let x = 0; x < cols; x += 1) {
|
||||
const y = solidRow(d, cols, x);
|
||||
if (y + 90 < BAND) v.push(lum(d, cols, x, y + 80) / Math.max(1, lum(d, cols, x, y + 8)));
|
||||
}
|
||||
const m = v.reduce((a, b) => a + b, 0) / v.length;
|
||||
return v.reduce((a, b) => a + (b - m) ** 2, 0) / v.length;
|
||||
};
|
||||
expect(spread(LAYERS[2].gully)).toBeGreaterThan(spread(0) * 1.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bake cost', () => {
|
||||
it('is recorded, not asserted: one chunk of every layer at 1280 x 720', () => {
|
||||
const period = 1280;
|
||||
const rows = Math.round(720 * 0.6);
|
||||
const ms = LAYERS.map((spec) => {
|
||||
const p: BakeParams = {
|
||||
spec,
|
||||
sky: SKY,
|
||||
period,
|
||||
scale: 1,
|
||||
viewH: 720,
|
||||
rows,
|
||||
bandH: rows,
|
||||
px0: 0,
|
||||
cols: Math.round(period * 1.2),
|
||||
};
|
||||
const t0 = performance.now();
|
||||
bake(p);
|
||||
return performance.now() - t0;
|
||||
});
|
||||
expect(ms.every((v) => Number.isFinite(v) && v >= 0)).toBe(true);
|
||||
console.info(
|
||||
`ridge bake ms per layer (node, 1 chunk, 1280x720): ${ms.map((v) => v.toFixed(1)).join(' ')}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the ridged multifractal core, each rule on its own', () => {
|
||||
const one = { ...LAYERS[0], octaves: 1, warp: 0, crag: 1 };
|
||||
|
||||
it('folds each octave at its crest: a creased, sharp ridge line, not a smooth swell', () => {
|
||||
// Largest second difference over a fine scan. A smooth curve of this size stays in the hundreds;
|
||||
// a fold leaves a crease where the noise crosses its midpoint.
|
||||
const h = 1e-3;
|
||||
let worst = 0;
|
||||
for (let i = 1; i < 20000; i += 1) {
|
||||
const t = i * h;
|
||||
worst = Math.max(
|
||||
worst,
|
||||
Math.abs(profile(one, t + h) - 2 * profile(one, t) + profile(one, t - h)) / (h * h)
|
||||
);
|
||||
}
|
||||
expect(worst).toBeGreaterThan(2000);
|
||||
});
|
||||
|
||||
it('domain-warps: the same layer without its warp is a measurably different line', () => {
|
||||
const spec = LAYERS[1];
|
||||
let diff = 0;
|
||||
for (let i = 0; i < 2000; i += 1)
|
||||
diff += Math.abs(profile(spec, i / 100) - profile({ ...spec, warp: 0 }, i / 100));
|
||||
expect(diff / 2000).toBeGreaterThan(0.02);
|
||||
});
|
||||
|
||||
it('weights each octave by the previous one: where the first octave is low, the later ones add almost nothing', () => {
|
||||
const lo = { ...LAYERS[1], octaves: 1, warp: 0 };
|
||||
const hi = { ...LAYERS[1], octaves: 5, warp: 0 };
|
||||
let valley = 0;
|
||||
let nv = 0;
|
||||
let peak = 0;
|
||||
let np = 0;
|
||||
for (let i = 0; i < 10000; i += 1) {
|
||||
const a = profile(lo, i / 500);
|
||||
const d = Math.abs(profile(hi, i / 500) - a);
|
||||
if (a < 0.12) {
|
||||
valley += d;
|
||||
nv += 1;
|
||||
} else if (a > 0.35) {
|
||||
peak += d;
|
||||
np += 1;
|
||||
}
|
||||
}
|
||||
expect(nv).toBeGreaterThan(200);
|
||||
expect(np).toBeGreaterThan(200);
|
||||
expect(valley / nv).toBeLessThan(0.25 * (peak / np));
|
||||
});
|
||||
|
||||
it('octave lattices do not line up: the lacunarity is not a power of two', () => {
|
||||
expect(Number.isInteger(Math.log2(LACUNARITY))).toBe(false);
|
||||
expect(Number.isInteger(LACUNARITY)).toBe(false);
|
||||
});
|
||||
|
||||
it('distance flattens relief: contrast falls from the nearest layer to the farthest', () => {
|
||||
const c = LAYERS.map((spec) => layerPaint(spec, SKY).contrast);
|
||||
expect(c[LAYERS.length - 1]).toBe(1);
|
||||
for (let i = 1; i < c.length; i += 1) expect(c[i]).toBeGreaterThan(c[i - 1]);
|
||||
// and it shows in the pixels: the lit/shadow difference is smaller on the farthest layer than the nearest
|
||||
const gap = (li: number) => {
|
||||
const cols = 700;
|
||||
const d = bake(params(li, 0, cols));
|
||||
const slope = (x: number) => (solidRow(d, cols, x - 1) - solidRow(d, cols, x + 1)) / 2;
|
||||
const avg = (pick: (s: number) => boolean) => {
|
||||
const xs = Array.from({ length: cols - 4 }, (_, k) => k + 2).filter((x) => pick(slope(x)));
|
||||
return (
|
||||
xs.reduce((a, x) => a + lum(d, cols, x, solidRow(d, cols, x) + 12), 0) /
|
||||
Math.max(1, xs.length)
|
||||
);
|
||||
};
|
||||
return (
|
||||
Math.abs(avg((s) => s < -0.8) - avg((s) => s > 0.8)) /
|
||||
Math.max(
|
||||
1,
|
||||
avg(() => true)
|
||||
)
|
||||
);
|
||||
};
|
||||
expect(gap(0)).toBeLessThan(gap(3));
|
||||
});
|
||||
});
|
||||
139
src/app/features/scene/ridge/ridgeBake.ts
Normal file
139
src/app/features/scene/ridge/ridgeBake.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/* eslint-disable no-param-reassign -- `bakeSlice` fills the caller's pixel buffer */
|
||||
// The pixels of a ridge chunk, as a pure function: no DOM, no canvas. Each column is lit from the
|
||||
// slope of the heightfield's crest line against the sun; the face under it takes a light-to-haze
|
||||
// gradient, erosion streaks and a lit rim on the crest. The depth treatment (colour, haze, contrast
|
||||
// by distance) is ridgeWorld's `layerColour`, `hazeColour` and `transmittance`, used here and not redone.
|
||||
import { gullyAt, LayerSpec, profile, hazeColour, layerColour, transmittance } from './ridgeWorld';
|
||||
import type { SkyColours, Srgb } from './skyColour';
|
||||
|
||||
export type Vec3 = readonly [number, number, number];
|
||||
|
||||
/** Unit vector toward the sun: x across the screen (from the azimuth), y up, z toward the viewer. */
|
||||
export function sunDirection(sky: SkyColours): Vec3 {
|
||||
const az = sky.sunAzimuth ?? 0.3;
|
||||
const x = 0.9 * Math.sin(2 * Math.PI * az);
|
||||
const l = Math.hypot(x, 0.35, 0.45);
|
||||
return [x / l, 0.35 / l, 0.45 / l];
|
||||
}
|
||||
|
||||
/** How steeply the crest's slope tilts the face's normal sideways. */
|
||||
const TILT = 1.2;
|
||||
|
||||
/**
|
||||
* Sun light on the face under a crest whose silhouette has `slope` (rows per column, positive when the
|
||||
* crest rises toward +x, so the face looks toward -x). 0 in full shadow .. 1 head-on to the sun.
|
||||
*/
|
||||
export function faceLight(slope: number, sun: Vec3): number {
|
||||
const nx = -slope * TILT;
|
||||
const inv = 1 / Math.hypot(nx, 0.55, 0.85);
|
||||
const d = (nx * sun[0] + 0.55 * sun[1] + 0.85 * sun[2]) * inv;
|
||||
return Math.min(1, Math.max(0, (d - 0.2) / 0.7));
|
||||
}
|
||||
|
||||
export interface BakeParams {
|
||||
spec: LayerSpec;
|
||||
sky: SkyColours;
|
||||
/** Pixels in one viewport width: the unit of world-x. */
|
||||
period: number;
|
||||
/** CSS px to canvas px. */
|
||||
scale: number;
|
||||
/** Viewport height in canvas px. */
|
||||
viewH: number;
|
||||
/** Rows of the pixel buffer (the band plus any padding below it). */
|
||||
rows: number;
|
||||
/** Row count of the visible band; the crest is measured from the top of it. */
|
||||
bandH: number;
|
||||
/** World pixel of the buffer's column 0 (may be fractional-free negative: a chunk's left minus its padding). */
|
||||
px0: number;
|
||||
/** Columns of the buffer. */
|
||||
cols: number;
|
||||
}
|
||||
|
||||
const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
|
||||
const smoothstep = (a: number, b: number, v: number): number => {
|
||||
const t = clamp01((v - a) / (b - a));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
const SUN_WHITE: Srgb = [255, 238, 205];
|
||||
|
||||
/** Everything `bakeSlice` needs per layer, computed once. */
|
||||
export interface LayerPaint {
|
||||
base: Srgb;
|
||||
haze: Srgb;
|
||||
warm: Srgb;
|
||||
contrast: number;
|
||||
sun: Vec3;
|
||||
}
|
||||
|
||||
export function layerPaint(spec: LayerSpec, sky: SkyColours): LayerPaint {
|
||||
const warm: Srgb = [
|
||||
(sky.horizon[0] + SUN_WHITE[0]) / 2,
|
||||
(sky.horizon[1] + SUN_WHITE[1]) / 2,
|
||||
(sky.horizon[2] + SUN_WHITE[2]) / 2,
|
||||
];
|
||||
return {
|
||||
base: layerColour(spec, sky),
|
||||
haze: hazeColour(sky),
|
||||
warm,
|
||||
// Distance flattens relief: the same transmittance that thins the colour.
|
||||
contrast: 0.35 + 0.65 * transmittance(spec),
|
||||
sun: sunDirection(sky),
|
||||
};
|
||||
}
|
||||
|
||||
/** Crest row (float, from the top of the band) of every buffer column from c0 - 1 to c1 (inclusive). */
|
||||
function crestRows(p: BakeParams, c0: number, c1: number): Float64Array {
|
||||
const out = new Float64Array(c1 - c0 + 2);
|
||||
const amp = p.spec.relief * p.viewH;
|
||||
const lift = p.spec.base * p.viewH;
|
||||
for (let c = c0 - 1; c <= c1; c += 1) {
|
||||
const top = Math.min(p.bandH, lift + amp * profile(p.spec, (p.px0 + c) / p.period));
|
||||
out[c - c0 + 1] = p.bandH - top;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint columns [c0, c1) of the chunk into `out` (RGBA, `p.cols` wide, `p.rows` tall, zeroed). One
|
||||
* call is a slice of a chunk, so a caller can keep each task short. Seams are exact: a column's pixels
|
||||
* depend only on its world pixel, never on where the slice or chunk begins.
|
||||
*/
|
||||
export function bakeSlice(
|
||||
out: Uint8ClampedArray,
|
||||
p: BakeParams,
|
||||
paint: LayerPaint,
|
||||
c0: number,
|
||||
c1: number
|
||||
): void {
|
||||
const { spec } = p;
|
||||
const crest = crestRows(p, c0, c1);
|
||||
const faceH = 0.45 * p.viewH;
|
||||
const rim = 2.2 * p.scale;
|
||||
const { base, haze, warm, contrast, sun } = paint;
|
||||
for (let c = c0; c < c1; c += 1) {
|
||||
const cy = crest[c - c0 + 1];
|
||||
const slope = (crest[c - c0 + 2] - crest[c - c0]) * -0.5; // rows up per column
|
||||
const lit = faceLight(slope, sun);
|
||||
const steep = 0.45 + 0.55 * clamp01(Math.abs(slope) * 0.8);
|
||||
const gl = gullyAt(spec, (p.px0 + c) / p.scale) * steep;
|
||||
const y0 = Math.max(0, Math.floor(cy));
|
||||
for (let y = y0; y < p.rows; y += 1) {
|
||||
const d = y + 0.5 - cy;
|
||||
const dn = clamp01(d / faceH);
|
||||
const cover = clamp01(y + 1 - cy);
|
||||
const gully = spec.gully * gl * smoothstep(0, 0.2, dn);
|
||||
// Shade the ground colour by the sun; the lit warm light fades down the face and in the gullies.
|
||||
const shade = (0.7 + 0.3 * lit * contrast + 0.3 * (1 - contrast)) * (1 - 0.5 * gully);
|
||||
const warmK = 0.22 * contrast * lit * lit * (1 - 0.7 * dn) * (1 - 0.6 * gully);
|
||||
const rimK = clamp01(1 - d / rim) * contrast * (0.3 + 0.7 * lit) * 0.6;
|
||||
const mist = spec.mist * smoothstep(0, 1, dn);
|
||||
const o = (y * p.cols + c) * 4;
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
let v = base[i] * shade + warm[i] * (warmK + rimK);
|
||||
v += (haze[i] - v) * mist;
|
||||
out[o + i] = v;
|
||||
}
|
||||
out[o + 3] = cover * 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +1,137 @@
|
|||
// Every number of the ridge scene, and the pure functions that turn them into silhouettes. No DOM.
|
||||
import { fbmPeriodic, FbmShape } from './noise';
|
||||
// A ridge is a function of world-x (t, in viewport widths): ridged multifractal noise, domain-warped,
|
||||
// one function for every layer with the parameters below. Nothing in it repeats.
|
||||
import { valueNoise } from './noise';
|
||||
import type { Srgb, SkyColours } from './skyColour';
|
||||
|
||||
export interface LayerSpec {
|
||||
/** 0 farthest. Also the noise row, so layers never share a profile. */
|
||||
index: number;
|
||||
/** Lattice cells across one period for the first octave; the fbm octaves scale it by `lacunarity`. */
|
||||
/** Lattice cells per viewport width for the first octave; each octave multiplies it by LACUNARITY. */
|
||||
baseCells: number;
|
||||
/** Peak-to-valley height of the silhouette, as a fraction of the layer band. */
|
||||
/** Peak-to-valley height of the silhouette, as a fraction of the viewport height. */
|
||||
relief: number;
|
||||
/** 0 hills (plain fbm) .. 1 crags (ridged: 1 - |2n - 1|). */
|
||||
/** 0 rounded hills .. 1 sharp crags: how tightly each octave is folded at its crest. */
|
||||
crag: number;
|
||||
/** Ridge top at its lowest point, fraction of the viewport height up from the bottom. */
|
||||
base: number;
|
||||
/** 0 = the layer is the horizon haze, 1 = the nearest ground colour. */
|
||||
depth: number;
|
||||
/** Seconds for the layer to drift one period. Farther layers are slower. */
|
||||
/** Seconds for the layer to drift one viewport width. Farther layers are slower. */
|
||||
seconds: number;
|
||||
/** Octaves of the ridged multifractal; the bake cost is linear in it. */
|
||||
octaves: number;
|
||||
/** Domain warp, in cells of the first octave. */
|
||||
warp: number;
|
||||
/** 0..1 depth of the erosion streaks down the face. */
|
||||
gully: number;
|
||||
/** Width of one gully, CSS px. */
|
||||
gullyPx: number;
|
||||
/** 0..1 how far the foot of the face dissolves into the valley mist. */
|
||||
mist: number;
|
||||
}
|
||||
|
||||
/** Four layers. `lacunarity` 2 with these base cell counts keeps every octave an integer. */
|
||||
/** Four layers: one function, four parameter rows. */
|
||||
export const LAYERS: readonly LayerSpec[] = [
|
||||
{ index: 0, baseCells: 3, relief: 0.24, crag: 0.8, base: 0.34, depth: 0.12, seconds: 420 },
|
||||
{ index: 1, baseCells: 4, relief: 0.2, crag: 0.55, base: 0.26, depth: 0.38, seconds: 300 },
|
||||
{ index: 2, baseCells: 5, relief: 0.16, crag: 0.3, base: 0.17, depth: 0.66, seconds: 210 },
|
||||
{ index: 3, baseCells: 6, relief: 0.12, crag: 0.1, base: 0.08, depth: 1, seconds: 150 },
|
||||
{
|
||||
index: 0,
|
||||
baseCells: 2.2,
|
||||
relief: 0.24,
|
||||
crag: 0.85,
|
||||
base: 0.34,
|
||||
depth: 0.12,
|
||||
seconds: 420,
|
||||
octaves: 5,
|
||||
warp: 0.6,
|
||||
gully: 0.3,
|
||||
gullyPx: 16,
|
||||
mist: 0.35,
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
baseCells: 3.0,
|
||||
relief: 0.2,
|
||||
crag: 0.7,
|
||||
base: 0.26,
|
||||
depth: 0.38,
|
||||
seconds: 300,
|
||||
octaves: 5,
|
||||
warp: 0.55,
|
||||
gully: 0.5,
|
||||
gullyPx: 20,
|
||||
mist: 0.3,
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
baseCells: 3.8,
|
||||
relief: 0.16,
|
||||
crag: 0.5,
|
||||
base: 0.17,
|
||||
depth: 0.66,
|
||||
seconds: 210,
|
||||
octaves: 5,
|
||||
warp: 0.5,
|
||||
gully: 0.6,
|
||||
gullyPx: 24,
|
||||
mist: 0.22,
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
baseCells: 4.6,
|
||||
relief: 0.12,
|
||||
crag: 0.3,
|
||||
base: 0.08,
|
||||
depth: 1,
|
||||
seconds: 150,
|
||||
octaves: 4,
|
||||
warp: 0.45,
|
||||
gully: 0.7,
|
||||
gullyPx: 30,
|
||||
mist: 0.16,
|
||||
},
|
||||
];
|
||||
|
||||
export const FBM: FbmShape = { octaves: 5, lacunarity: 2, gain: 0.5 };
|
||||
/** Not an integer power of two, so no octave's lattice lines up with another's. */
|
||||
export const LACUNARITY = 2.07;
|
||||
/** Each octave is weighted by the previous octave's folded signal, times this, clamped to 1. */
|
||||
export const WEIGHT_GAIN = 2;
|
||||
const AMP_GAIN = 0.5;
|
||||
/** Calibration: the weighted sum's typical top is about 1 / this. */
|
||||
const PEAK_NORM = 1.25;
|
||||
|
||||
/** Silhouette height in [0, 1] at `t` (0..1 across one period); periodic in t. */
|
||||
/** Silhouette height in [0, 1] at world-x `t` (viewport widths); defined for every real t. */
|
||||
export function profile(spec: LayerSpec, t: number): number {
|
||||
const n = fbmPeriodic(t, spec.index * 101, spec.baseCells, FBM);
|
||||
const crag = 1 - Math.abs(2 * n - 1);
|
||||
return n + (crag - n) * spec.crag;
|
||||
const row = spec.index * 101;
|
||||
const wt =
|
||||
t + (spec.warp * (2 * valueNoise(t * spec.baseCells * 0.7, row + 97) - 1)) / spec.baseCells;
|
||||
const e = 0.03 + (1 - spec.crag) * 0.4;
|
||||
const top = Math.sqrt(1 + e * e);
|
||||
let sum = 0;
|
||||
let norm = 0;
|
||||
let amp = 1;
|
||||
let f = spec.baseCells;
|
||||
let w = 1;
|
||||
for (let k = 0; k < spec.octaves; k += 1) {
|
||||
const v = 2 * valueNoise(wt * f, row + k * 13) - 1;
|
||||
// Fold: 1 at the zero crossing of the noise, 0 at its extremes; `e` rounds the crease.
|
||||
let sig = 1 - Math.sqrt(v * v + e * e) / top;
|
||||
sig *= sig;
|
||||
sig *= w;
|
||||
w = Math.min(1, sig * WEIGHT_GAIN);
|
||||
sum += sig * amp;
|
||||
norm += amp;
|
||||
amp *= AMP_GAIN;
|
||||
f *= LACUNARITY;
|
||||
}
|
||||
// A soft swell under the folds, so the valleys between sharp crests are not flat floors.
|
||||
const swell = valueNoise(wt * spec.baseCells * 0.6, row + 211);
|
||||
return Math.min(1, 0.85 * (sum / norm) * PEAK_NORM + 0.15 * swell);
|
||||
}
|
||||
|
||||
/** Erosion streak strength in [0, 1] at world-x `wxCss` (CSS px): a folded noise at gully width. */
|
||||
export function gullyAt(spec: LayerSpec, wxCss: number): number {
|
||||
const g = 1 - Math.abs(2 * valueNoise(wxCss / spec.gullyPx, spec.index * 131 + 5) - 1);
|
||||
return g * g;
|
||||
}
|
||||
|
||||
/** The distance haze the far layers dissolve into: between the mid sky and the horizon, not the horizon glow itself. */
|
||||
|
|
@ -75,32 +173,32 @@ export function layerColour(spec: LayerSpec, sky: SkyColours): Srgb {
|
|||
export const edgeBlur = (spec: LayerSpec): number => AERIAL.blurPx * (1 - transmittance(spec));
|
||||
|
||||
/**
|
||||
* Alpha of the warm sun-side in-scatter at `t` (0..1 across one period; periodic). Zero when the sky has
|
||||
* no sun; grows with distance, peaks at the sun's azimuth.
|
||||
* Alpha of the warm sun-side in-scatter at world-x `t` (viewport widths). Zero when the sky has no
|
||||
* sun; grows with distance. The lobe is the product of two cosines with incommensurate wavelengths
|
||||
* (1.9 and 1.9 * golden ratio), so it is not periodic; its peaks are where the sun's azimuth puts them.
|
||||
*/
|
||||
export function scatterAlpha(spec: LayerSpec, sky: SkyColours, t: number): number {
|
||||
if (sky.sunAzimuth === undefined) return 0;
|
||||
const lobe = 0.5 + 0.5 * Math.cos(2 * Math.PI * (t - sky.sunAzimuth));
|
||||
return AERIAL.scatter * (1 - transmittance(spec)) * lobe * lobe;
|
||||
const a = 0.5 + 0.5 * Math.cos(2 * Math.PI * (t / 1.9 - sky.sunAzimuth));
|
||||
const b = 0.75 + 0.25 * Math.cos((2 * Math.PI * t) / (1.9 * 1.618034));
|
||||
return AERIAL.scatter * (1 - transmittance(spec)) * a * a * b;
|
||||
}
|
||||
|
||||
/** Integer pixel sizes of a layer for a viewport: one period wide, twice that painted, so a drift of one period loops. */
|
||||
/** Chunk width as a multiple of the viewport width: room for the yaw sway on top of the viewport. */
|
||||
export const CHUNK_OVER = 1.2;
|
||||
|
||||
/**
|
||||
* Integer pixel sizes for a viewport. `period` is one viewport width, the unit of world-x; a chunk
|
||||
* is `chunk` pixels of world, `height` the band a layer is painted in.
|
||||
*/
|
||||
export function layerSize(cssW: number, cssH: number, scale: number) {
|
||||
const period = Math.round(cssW * scale);
|
||||
const band = Math.round(cssH * scale * 0.6);
|
||||
return { period, width: period * 2, height: band };
|
||||
return { period, chunk: Math.round(period * CHUNK_OVER), height: band };
|
||||
}
|
||||
|
||||
/** Ridge-top y (canvas pixels, from the top of the layer's band) for pixel column x. */
|
||||
export function ridgeY(
|
||||
spec: LayerSpec,
|
||||
x: number,
|
||||
period: number,
|
||||
bandH: number,
|
||||
viewH: number
|
||||
): number {
|
||||
const amp = spec.relief * viewH;
|
||||
const bottomOffset = spec.base * viewH; // lowest the top sits above the viewport bottom
|
||||
const topFromBottom = bottomOffset + amp * profile(spec, (x % period) / period);
|
||||
/** Ridge-top y (canvas pixels, from the top of the layer's band) at world-x `t` (viewport widths). */
|
||||
export function ridgeY(spec: LayerSpec, t: number, bandH: number, viewH: number): number {
|
||||
const topFromBottom = spec.base * viewH + spec.relief * viewH * profile(spec, t);
|
||||
return bandH - Math.min(bandH, topFromBottom);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ import type { FixedSkyState } from './skyBand';
|
|||
import {
|
||||
LIGHTEST_PATCH,
|
||||
NIGHT_INK_MAX_L,
|
||||
NOTICE_GROUND,
|
||||
inkForState,
|
||||
labelInk,
|
||||
noticeInk,
|
||||
sceneInk,
|
||||
userColorInk,
|
||||
} from './skyInk';
|
||||
|
|
@ -175,3 +177,28 @@ describe('labelInk', () => {
|
|||
expect(hueGap(dark, ink)).toBeLessThan(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('noticeInk', () => {
|
||||
const ratio = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
|
||||
it('is light enough to clear AA on the dark ridge wherever the theme ink does not', () => {
|
||||
(['day', 'dusk'] as FixedSkyState[]).forEach((state) => {
|
||||
const ink = noticeInk({ state, phone: true }) as string;
|
||||
expect(ratio(luminance(ink), NOTICE_GROUND)).toBeGreaterThanOrEqual(AA);
|
||||
// the dark theme ink it replaces does not
|
||||
expect(
|
||||
ratio(luminance(baked.noonday.tokens.SurfaceVariant.OnContainer), NOTICE_GROUND)
|
||||
).toBeLessThan(AA);
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves night (already light) and every desktop state to the theme', () => {
|
||||
expect(noticeInk({ state: 'night', phone: true })).toBeUndefined();
|
||||
(['day', 'dusk', 'night'] as FixedSkyState[]).forEach((state) =>
|
||||
expect(noticeInk({ state, phone: false })).toBeUndefined()
|
||||
);
|
||||
expect(
|
||||
ratio(luminance(baked.midnight.tokens.SurfaceVariant.OnContainer), NOTICE_GROUND)
|
||||
).toBeGreaterThanOrEqual(AA);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -93,3 +93,20 @@ export function userColorInk(userId: string, ink: SkyInk): string {
|
|||
*/
|
||||
export const labelInk = ({ state, phone }: SkyInk): string | undefined =>
|
||||
state === 'dusk' && phone ? '#29221a' : undefined;
|
||||
|
||||
/**
|
||||
* WCAG relative luminance of the lightest pixel under the notice line above the composer on a phone: the dark
|
||||
* foreground ridge, the same in every state (measured 2026-11-10 from the Starfire phone shots at 390x844,
|
||||
* ratatoskr-dev-preview/phone-room-{day,dusk,night}.png, text region x 15-355 CSS px: median 0.0052, 90th percentile
|
||||
* 0.0089, rounded up to 0.01). The labelInk patches above are the lightest terrain behind the timeline; here the
|
||||
* ground is dark, so the ink must be light.
|
||||
*/
|
||||
export const NOTICE_GROUND = 0.01;
|
||||
|
||||
/**
|
||||
* The ink of the training-notice line above the composer where the theme's own does not clear AA there. On a phone the
|
||||
* line sits on the dark ridge in every state, but Noonday (day, dusk) wears dark ink, 1.2:1 at dusk by the numbers
|
||||
* above; Midnight's (night) is already light and stays (undefined). Desktop is unmeasured and stays.
|
||||
*/
|
||||
export const noticeInk = ({ state, phone }: SkyInk): string | undefined =>
|
||||
phone && state !== 'night' ? baked.midnight.tokens.SurfaceVariant.OnContainer : undefined;
|
||||
|
|
|
|||
20
src/app/hooks/useClientConfig.test.ts
Normal file
20
src/app/hooks/useClientConfig.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { ClientConfig, clientDefaultServer } from './useClientConfig';
|
||||
import config from '../../../config.json';
|
||||
|
||||
describe('default homeserver', () => {
|
||||
it('is gamecult.org when the config names none', () => {
|
||||
expect(clientDefaultServer({})).toBe('gamecult.org');
|
||||
expect(clientDefaultServer({ homeserverList: [] })).toBe('gamecult.org');
|
||||
});
|
||||
|
||||
it('is the listed server the config points at', () => {
|
||||
expect(
|
||||
clientDefaultServer({ homeserverList: ['chat.gamecult.org'], defaultHomeserver: 0 })
|
||||
).toBe('chat.gamecult.org');
|
||||
});
|
||||
|
||||
it('is gamecult.org in the repo config.json too', () => {
|
||||
expect(clientDefaultServer(config as ClientConfig)).toBe('gamecult.org');
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,11 @@ export type HashRouterConfig = {
|
|||
basename?: string;
|
||||
};
|
||||
|
||||
export type RegistrationConfig = {
|
||||
enabled?: boolean;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export type ClientConfig = {
|
||||
defaultHomeserver?: number;
|
||||
homeserverList?: string[];
|
||||
|
|
@ -26,6 +31,13 @@ export type ClientConfig = {
|
|||
// Per-Mind notification earcons (src/app/features/dings). Off unless `enabled: true`.
|
||||
dings?: DingsConfig;
|
||||
|
||||
// Rooms (ids or aliases) shown as the album view; a room whose create type is org.gamecult.file_zone needs no entry.
|
||||
fileZoneRooms?: string[];
|
||||
|
||||
// The sign-up entry on the sign-in page. Off unless `enabled` is true; `url` is the account
|
||||
// service's own registration page (a value, never a route this client invents).
|
||||
registration?: RegistrationConfig;
|
||||
|
||||
// Dev preview only: the build answers a pretend homeserver in-page (src/preview-demo).
|
||||
previewDemo?: boolean;
|
||||
};
|
||||
|
|
@ -41,7 +53,7 @@ export function useClientConfig(): ClientConfig {
|
|||
}
|
||||
|
||||
export const clientDefaultServer = (clientConfig: ClientConfig): string =>
|
||||
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org';
|
||||
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'gamecult.org';
|
||||
|
||||
export const clientAllowedServer = (clientConfig: ClientConfig, server: string): boolean => {
|
||||
const { homeserverList, allowCustomHomeservers } = clientConfig;
|
||||
|
|
|
|||
|
|
@ -9,15 +9,13 @@ import {
|
|||
} from 'react-router-dom';
|
||||
|
||||
import { ClientConfig } from '../hooks/useClientConfig';
|
||||
import { AuthLayout, Login, Register, ResetPassword } from './auth';
|
||||
import { authRoutes } from './auth/authRoutes';
|
||||
import {
|
||||
DIRECT_PATH,
|
||||
DESK_PATH,
|
||||
EXPLORE_PATH,
|
||||
HOME_PATH,
|
||||
LOGIN_PATH,
|
||||
INBOX_PATH,
|
||||
REGISTER_PATH,
|
||||
RESET_PASSWORD_PATH,
|
||||
SPACE_PATH,
|
||||
_CREATE_PATH,
|
||||
_FEATURED_PATH,
|
||||
|
|
@ -48,6 +46,7 @@ import { Notifications, Inbox, Invites } from './client/inbox';
|
|||
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
|
||||
import { Room } from '../features/room';
|
||||
import { Lobby } from '../features/lobby';
|
||||
import { Desk } from '../features/desk';
|
||||
import { WelcomePage } from './client/WelcomePage';
|
||||
import { EstatePageNav } from './client/EstatePageNav';
|
||||
import { PageRoot } from '../components/page';
|
||||
|
|
@ -55,7 +54,7 @@ import { ScreenSize } from '../hooks/useScreenSize';
|
|||
import { MobileFriendlyPageNav } from './MobileFriendly';
|
||||
import { ClientInitStorageAtom } from './client/ClientInitStorageAtom';
|
||||
import { ClientNonUIFeatures } from './client/ClientNonUIFeatures';
|
||||
import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager';
|
||||
import { AuthRouteThemeManager } from './ThemeManager';
|
||||
import { ReceiveSelfDeviceVerification } from '../components/DeviceVerification';
|
||||
import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
|
||||
import { RoomSettingsRenderer } from '../features/room-settings';
|
||||
|
|
@ -86,25 +85,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
return redirect(getLoginPath());
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
loader={() => {
|
||||
if (getFallbackSession()) {
|
||||
return redirect(getHomePath());
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
element={
|
||||
<>
|
||||
<AuthLayout />
|
||||
<UnAuthRouteThemeManager />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Route path={LOGIN_PATH} element={<Login />} />
|
||||
<Route path={REGISTER_PATH} element={<Register />} />
|
||||
<Route path={RESET_PASSWORD_PATH} element={<ResetPassword />} />
|
||||
</Route>
|
||||
{authRoutes()}
|
||||
|
||||
<Route
|
||||
loader={() => {
|
||||
|
|
@ -246,6 +227,15 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
<Route path={_FEATURED_PATH} element={<FeaturedRooms />} />
|
||||
<Route path={_SERVER_PATH} element={<PublicRooms />} />
|
||||
</Route>
|
||||
<Route
|
||||
path={DESK_PATH}
|
||||
element={
|
||||
// The navigator shows beside the desk on wide screens; a phone shows the desk alone.
|
||||
<PageRoot nav={<EstatePageNav path={HOME_PATH} />}>
|
||||
<Desk />
|
||||
</PageRoot>
|
||||
}
|
||||
/>
|
||||
<Route path={CREATE_PATH} element={<Create />} />
|
||||
<Route
|
||||
path={INBOX_PATH}
|
||||
|
|
|
|||
22
src/app/pages/auth/RegisterEntry.tsx
Normal file
22
src/app/pages/auth/RegisterEntry.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { Box, Text, color } from 'folds';
|
||||
import React from 'react';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import { registrationEntry } from './registrationEntry';
|
||||
|
||||
// Beside Sign in. Nothing renders unless the config switch is on; the consent sentence and its
|
||||
// version come from trainingNotice.ts, the same ones a room shows.
|
||||
export function RegisterEntry() {
|
||||
const config = useClientConfig();
|
||||
const entry = registrationEntry(config);
|
||||
if (!entry) return null;
|
||||
return (
|
||||
<Box direction="Column" gap="100" alignItems="Center">
|
||||
<Text align="Center">
|
||||
Do not have an account? <a href={entry.url}>Register</a>
|
||||
</Text>
|
||||
<Text size="T200" align="Center" style={{ color: color.SurfaceVariant.OnContainer }}>
|
||||
{entry.notice}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
54
src/app/pages/auth/SignedUp.tsx
Normal file
54
src/app/pages/auth/SignedUp.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { Box, Header, Scroll, Text } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import { getLoginPath } from '../pathUtils';
|
||||
import { registrationEntry } from './registrationEntry';
|
||||
import { AuthFooter } from './AuthFooter';
|
||||
import * as css from './styles.css';
|
||||
import * as PatternsCss from '../../styles/Patterns.css';
|
||||
import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg';
|
||||
import { LogoMark } from '../../components/logo/logoMark.css';
|
||||
|
||||
// Where the account service sends a newcomer back. It promises nothing about mail or timing:
|
||||
// the sign-up page said what happens next, and this page points back to it. It has its own card
|
||||
// because AuthLayout looks up a homeserver first and sends paths it does not know to sign in.
|
||||
export function SignedUp() {
|
||||
const config = useClientConfig();
|
||||
if (!registrationEntry(config)) return <Navigate to={getLoginPath()} replace />;
|
||||
return (
|
||||
<Scroll variant="Background" visibility="Hover" size="300" hideTrack>
|
||||
<Box
|
||||
className={classNames(css.AuthLayout, PatternsCss.BackgroundDotPattern)}
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="SpaceBetween"
|
||||
gap="400"
|
||||
>
|
||||
<Box direction="Column" className={css.AuthCard}>
|
||||
<Header className={css.AuthHeader} size="600" variant="Surface">
|
||||
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
|
||||
<img
|
||||
className={`${css.AuthLogo} ${LogoMark}`}
|
||||
src={RatatoskrSVG}
|
||||
alt="Ratatoskr logo"
|
||||
/>
|
||||
<Text size="H3">Ratatoskr</Text>
|
||||
</Box>
|
||||
</Header>
|
||||
<Box className={css.AuthCardContent} direction="Column" gap="500">
|
||||
<Text size="H2" priority="400">
|
||||
You signed up
|
||||
</Text>
|
||||
<Text>Follow the steps from the sign-up page to finish. Then sign in here.</Text>
|
||||
<Text>
|
||||
<Link to={getLoginPath()}>Sign in</Link>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<AuthFooter />
|
||||
</Box>
|
||||
</Scroll>
|
||||
);
|
||||
}
|
||||
|
|
@ -57,7 +57,14 @@ export const serverTroubleMessage = (
|
|||
export const noSignInMethodMessage = (server: string): string =>
|
||||
`${server} does not offer a way to sign in that this app can use. Pick another server from the Homeserver list, or tell whoever runs ${server}.`;
|
||||
|
||||
export type RegisterTrouble = 'closed' | 'rate-limited' | 'invalid' | 'unsupported';
|
||||
export type RegisterTrouble =
|
||||
| 'closed'
|
||||
| 'rate-limited'
|
||||
| 'invalid'
|
||||
| 'unsupported'
|
||||
| 'password-weak'
|
||||
| 'password-short'
|
||||
| 'unknown';
|
||||
|
||||
export const registerTroubleMessage = (trouble: RegisterTrouble, server: string): string => {
|
||||
switch (trouble) {
|
||||
|
|
@ -67,6 +74,12 @@ export const registerTroubleMessage = (trouble: RegisterTrouble, server: string)
|
|||
return 'Too many tries in a short time. Wait a minute, then try again.';
|
||||
case 'invalid':
|
||||
return `${server} did not accept the request to make an account. Reload the page and try again; if it keeps failing, tell whoever invited you.`;
|
||||
case 'password-weak':
|
||||
return 'That password is too easy to guess. Pick a longer one, with more than one kind of character.';
|
||||
case 'password-short':
|
||||
return 'That password is too short. Pick a longer one.';
|
||||
case 'unknown':
|
||||
return `Something went wrong making the account on ${server}. Try again in a minute; if it keeps failing, tell whoever invited you.`;
|
||||
case 'unsupported':
|
||||
default:
|
||||
return `${server} asks for something this app cannot do when making an account. Tell whoever invited you.`;
|
||||
|
|
|
|||
40
src/app/pages/auth/authRoutes.tsx
Normal file
40
src/app/pages/auth/authRoutes.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import React from 'react';
|
||||
import { Route, redirect } from 'react-router-dom';
|
||||
import { getFallbackSession } from '../../state/sessions';
|
||||
import { UnAuthRouteThemeManager } from '../ThemeManager';
|
||||
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH, SIGNED_UP_PATH } from '../paths';
|
||||
import { getHomePath } from '../pathUtils';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
import { Login } from './login';
|
||||
import { RegisterRoute } from './register/RegisterRoute';
|
||||
import { ResetPassword } from './reset-password';
|
||||
import { SignedUp } from './SignedUp';
|
||||
|
||||
// The routes a signed-out visitor sees. The sign-up return page sits beside the layout, not in
|
||||
// it: the layout looks up a homeserver and sends any path it does not know to sign in.
|
||||
export const authRoutes = () => (
|
||||
<>
|
||||
<Route
|
||||
loader={() => (getFallbackSession() ? redirect(getHomePath()) : null)}
|
||||
element={
|
||||
<>
|
||||
<AuthLayout />
|
||||
<UnAuthRouteThemeManager />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Route path={LOGIN_PATH} element={<Login />} />
|
||||
<Route path={REGISTER_PATH} element={<RegisterRoute />} />
|
||||
<Route path={RESET_PASSWORD_PATH} element={<ResetPassword />} />
|
||||
</Route>
|
||||
<Route
|
||||
path={SIGNED_UP_PATH}
|
||||
element={
|
||||
<>
|
||||
<SignedUp />
|
||||
<UnAuthRouteThemeManager />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export * from './AuthLayout';
|
||||
export * from './login';
|
||||
export * from './register';
|
||||
export * from './SignedUp';
|
||||
export * from './reset-password';
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { PasswordLoginForm } from './PasswordLoginForm';
|
|||
import { SSOLogin } from '../SSOLogin';
|
||||
import { TokenLogin } from './TokenLogin';
|
||||
import { OrDivider } from '../OrDivider';
|
||||
import { RegisterEntry } from '../RegisterEntry';
|
||||
import { registrationEntry } from '../registrationEntry';
|
||||
import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils';
|
||||
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
|
||||
import { LoginPathSearchParams } from '../../paths';
|
||||
|
|
@ -37,7 +39,8 @@ const useLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchPar
|
|||
|
||||
export function Login() {
|
||||
const server = useAuthServer();
|
||||
const { hashRouter, previewDemo } = useClientConfig();
|
||||
const config = useClientConfig();
|
||||
const { hashRouter, previewDemo } = config;
|
||||
const { loginFlows } = useAuthFlows();
|
||||
const [searchParams] = useSearchParams();
|
||||
const loginSearchParams = useLoginSearchParams(searchParams);
|
||||
|
|
@ -90,9 +93,13 @@ export function Login() {
|
|||
<span data-spacing-node />
|
||||
</>
|
||||
)}
|
||||
<Text align="Center">
|
||||
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
|
||||
</Text>
|
||||
{registrationEntry(config) ? (
|
||||
<RegisterEntry />
|
||||
) : (
|
||||
<Text align="Center">
|
||||
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,15 @@ import {
|
|||
import { useUIACompleted, useUIAFlow, useUIAParams } from '../../../hooks/useUIAFlows';
|
||||
import { AsyncState, AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
|
||||
import { RegisterError, RegisterResult, register, useRegisterComplete } from './registerUtil';
|
||||
import {
|
||||
RegisterError,
|
||||
RegisterResult,
|
||||
register,
|
||||
registerErrorTrouble,
|
||||
useRegisterComplete,
|
||||
} from './registerUtil';
|
||||
import { registerTroubleMessage } from '../authMessages';
|
||||
import { useAuthServer } from '../../../hooks/useAuthServer';
|
||||
import { FieldError } from '../FiledError';
|
||||
import { TrainingNotice } from '../TrainingNotice';
|
||||
import {
|
||||
|
|
@ -185,6 +193,7 @@ export function PasswordRegisterForm({
|
|||
defaultEmail,
|
||||
defaultRegisterToken,
|
||||
}: PasswordRegisterFormProps) {
|
||||
const server = useAuthServer();
|
||||
const serverDiscovery = useAutoDiscoveryInfo();
|
||||
const baseUrl = serverDiscovery['m.homeserver'].base_url;
|
||||
const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
|
||||
|
|
@ -207,6 +216,9 @@ export function PasswordRegisterForm({
|
|||
const registerError =
|
||||
registerState.status === AsyncStatus.Error ? registerState.error : undefined;
|
||||
|
||||
// The password troubles show under the password field; the rest is the form's.
|
||||
const registerTrouble = registerError && registerErrorTrouble(registerError.errcode);
|
||||
const formTrouble = registerTrouble?.startsWith('password-') ? undefined : registerTrouble;
|
||||
useRegisterComplete(customRegisterResp);
|
||||
|
||||
const handleSubmit: ChangeEventHandler<HTMLFormElement> = (evt) => {
|
||||
|
|
@ -298,20 +310,10 @@ export function PasswordRegisterForm({
|
|||
required
|
||||
/>
|
||||
{registerError?.errcode === RegisterError.PasswordWeak && (
|
||||
<FieldError
|
||||
message={
|
||||
registerError.data.error ??
|
||||
'Weak Password. Password rejected by server please choosing more strong Password.'
|
||||
}
|
||||
/>
|
||||
<FieldError message={registerTroubleMessage('password-weak', server)} />
|
||||
)}
|
||||
{registerError?.errcode === RegisterError.PasswordShort && (
|
||||
<FieldError
|
||||
message={
|
||||
registerError.data.error ??
|
||||
'Short Password. Password rejected by server please choosing more long Password.'
|
||||
}
|
||||
/>
|
||||
<FieldError message={registerTroubleMessage('password-short', server)} />
|
||||
)}
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
|
|
@ -378,18 +380,7 @@ export function PasswordRegisterForm({
|
|||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{registerError?.errcode === RegisterError.RateLimited && (
|
||||
<FieldError message="Failed to register. Your register request has been rate-limited by server, Please try after some time." />
|
||||
)}
|
||||
{registerError?.errcode === RegisterError.Forbidden && (
|
||||
<FieldError message="Failed to register. The homeserver does not permit registration." />
|
||||
)}
|
||||
{registerError?.errcode === RegisterError.InvalidRequest && (
|
||||
<FieldError message="Failed to register. Invalid request." />
|
||||
)}
|
||||
{registerError?.errcode === RegisterError.Unknown && (
|
||||
<FieldError message={registerError.data.error ?? 'Failed to register. Unknown Reason.'} />
|
||||
)}
|
||||
{formTrouble && <FieldError message={registerTroubleMessage(formTrouble, server)} />}
|
||||
<span data-spacing-node />
|
||||
<TrainingNotice checked={trainingAck} onToggle={() => setTrainingAck((ack) => !ack)} />
|
||||
<Button variant="Primary" size="500" type="submit" disabled={!trainingAck}>
|
||||
|
|
|
|||
37
src/app/pages/auth/register/RegisterRoute.test.tsx
Normal file
37
src/app/pages/auth/register/RegisterRoute.test.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { ClientConfigProvider } from '../../../hooks/useClientConfig';
|
||||
import type { ClientConfig } from '../../../hooks/useClientConfig';
|
||||
import { RegisterRoute } from './RegisterRoute';
|
||||
|
||||
// The in-client form needs a homeserver; the route test only needs to know whether it was asked for.
|
||||
vi.mock('./Register', () => ({ Register: () => <div>in-client register form</div> }));
|
||||
|
||||
const render = (config: ClientConfig) =>
|
||||
renderToString(
|
||||
<ClientConfigProvider value={config}>
|
||||
<RegisterRoute />
|
||||
</ClientConfigProvider>
|
||||
);
|
||||
|
||||
describe('the /register route', () => {
|
||||
it('with the switch on offers only the account service link, not the in-client form', () => {
|
||||
const html = render({ registration: { enabled: true, url: 'https://accounts.example/join' } });
|
||||
expect(html).toContain('href="https://accounts.example/join"');
|
||||
expect(html).not.toContain('in-client register form');
|
||||
});
|
||||
|
||||
it('with the switch off shows the in-client form', () => {
|
||||
expect(render({ registration: { enabled: false, url: 'https://accounts.example/join' } })).toContain(
|
||||
'in-client register form'
|
||||
);
|
||||
expect(render({})).toContain('in-client register form');
|
||||
});
|
||||
|
||||
it('with the switch on but an unusable url falls back to the form', () => {
|
||||
expect(render({ registration: { enabled: true, url: 'ftp://accounts.example/join' } })).toContain(
|
||||
'in-client register form'
|
||||
);
|
||||
});
|
||||
});
|
||||
26
src/app/pages/auth/register/RegisterRoute.tsx
Normal file
26
src/app/pages/auth/register/RegisterRoute.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Box, Text } from 'folds';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useClientConfig } from '../../../hooks/useClientConfig';
|
||||
import { registrationEntry } from '../registrationEntry';
|
||||
import { Register } from './Register';
|
||||
|
||||
// With the Register switch on, a stranger gets one way to register: the account service's own
|
||||
// page. This route sends them there (the link stays for when the redirect does not run). With
|
||||
// the switch off, the in-client form is the way.
|
||||
export function RegisterRoute() {
|
||||
const entry = registrationEntry(useClientConfig());
|
||||
useEffect(() => {
|
||||
if (entry) window.location.replace(entry.url);
|
||||
}, [entry]);
|
||||
if (!entry) return <Register />;
|
||||
return (
|
||||
<Box direction="Column" gap="300">
|
||||
<Text size="H2" priority="400">
|
||||
Register
|
||||
</Text>
|
||||
<Text>
|
||||
Sign-up happens on the account page. <a href={entry.url}>Continue to sign up</a>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
39
src/app/pages/auth/register/registerErrors.test.ts
Normal file
39
src/app/pages/auth/register/registerErrors.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { registerTroubleMessage } from '../authMessages';
|
||||
import { RegisterError, registerErrorTrouble } from './registerUtil';
|
||||
|
||||
// What the form shows for a failed registration, from the error code the server answered with.
|
||||
const shown = (errcode: string) => {
|
||||
const trouble = registerErrorTrouble(errcode);
|
||||
return trouble && registerTroubleMessage(trouble, 'chat.example');
|
||||
};
|
||||
|
||||
describe('registration failures as the form shows them', () => {
|
||||
it('say what to do next for every failure that is not a username field error', () => {
|
||||
[
|
||||
RegisterError.PasswordWeak,
|
||||
RegisterError.PasswordShort,
|
||||
RegisterError.RateLimited,
|
||||
RegisterError.Forbidden,
|
||||
RegisterError.InvalidRequest,
|
||||
RegisterError.Unknown,
|
||||
].forEach((e) => expect(shown(e)?.length).toBeGreaterThan(20));
|
||||
});
|
||||
|
||||
it('never carry the server text or a Matrix error code', () => {
|
||||
Object.values(RegisterError).forEach((e) => {
|
||||
expect(shown(e) ?? '').not.toMatch(/M_[A-Z_]+|errcode|homeserver does not/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('leave the username errors to their own field messages', () => {
|
||||
[RegisterError.UserTaken, RegisterError.UserInvalid, RegisterError.UserExclusive].forEach((e) =>
|
||||
expect(shown(e)).toBeUndefined()
|
||||
);
|
||||
});
|
||||
|
||||
it('tell a closed server from a rate limit', () => {
|
||||
expect(shown(RegisterError.Forbidden)).toContain('chat.example');
|
||||
expect(shown(RegisterError.RateLimited)).not.toBe(shown(RegisterError.Forbidden));
|
||||
});
|
||||
});
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
import { getHomePath, getLoginPath, withSearchParam } from '../../pathUtils';
|
||||
import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix';
|
||||
import { setFallbackSession } from '../../../state/sessions';
|
||||
import type { RegisterTrouble } from '../authMessages';
|
||||
|
||||
export enum RegisterError {
|
||||
UserTaken = 'UserTaken',
|
||||
|
|
@ -30,6 +31,27 @@ export enum RegisterError {
|
|||
Unknown = 'Unknown',
|
||||
}
|
||||
|
||||
// The form-level words for a failed registration, never the server's own text. The username
|
||||
// errors are the form's own field messages and have no trouble here.
|
||||
export const registerErrorTrouble = (errcode: string): RegisterTrouble | undefined => {
|
||||
switch (errcode) {
|
||||
case RegisterError.PasswordWeak:
|
||||
return 'password-weak';
|
||||
case RegisterError.PasswordShort:
|
||||
return 'password-short';
|
||||
case RegisterError.RateLimited:
|
||||
return 'rate-limited';
|
||||
case RegisterError.Forbidden:
|
||||
return 'closed';
|
||||
case RegisterError.InvalidRequest:
|
||||
return 'invalid';
|
||||
case RegisterError.Unknown:
|
||||
return 'unknown';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export type CustomRegisterResponse = {
|
||||
baseUrl: string;
|
||||
response: RegisterResponse;
|
||||
|
|
|
|||
57
src/app/pages/auth/registrationEntry.test.ts
Normal file
57
src/app/pages/auth/registrationEntry.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { registrationEntry } from './registrationEntry';
|
||||
import type { ClientConfig } from '../../hooks/useClientConfig';
|
||||
import {
|
||||
PREVIEW_NOTICE_TEXT,
|
||||
TRAINING_NOTICE_TEXT,
|
||||
TRAINING_NOTICE_VERSION,
|
||||
} from '../../utils/trainingNotice';
|
||||
import config from '../../../../config.json';
|
||||
|
||||
const url = 'https://accounts.example/register';
|
||||
|
||||
describe('registration entry', () => {
|
||||
it('is hidden when the switch is absent or off', () => {
|
||||
expect(registrationEntry({})).toBeUndefined();
|
||||
expect(registrationEntry({ registration: { url } })).toBeUndefined();
|
||||
expect(registrationEntry({ registration: { enabled: false, url } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ships off in config.json', () => {
|
||||
expect(registrationEntry(config as ClientConfig)).toBeUndefined();
|
||||
expect(config.registration.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('is hidden when the switch is on but the url is no web address', () => {
|
||||
expect(registrationEntry({ registration: { enabled: true } })).toBeUndefined();
|
||||
expect(registrationEntry({ registration: { enabled: true, url: '' } })).toBeUndefined();
|
||||
expect(
|
||||
registrationEntry({ registration: { enabled: true, url: '/register' } })
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
registrationEntry({ registration: { enabled: true, url: 'data:text/html,x' } })
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads the consent text and version from trainingNotice.ts', () => {
|
||||
expect(registrationEntry({ registration: { enabled: true, url } })).toEqual({
|
||||
url,
|
||||
notice: TRAINING_NOTICE_TEXT,
|
||||
noticeVersion: TRAINING_NOTICE_VERSION,
|
||||
});
|
||||
});
|
||||
|
||||
it('says the pretend sentence on the pretend server', () => {
|
||||
const entry = registrationEntry({ previewDemo: true, registration: { enabled: true, url } });
|
||||
expect(entry?.notice).toBe(PREVIEW_NOTICE_TEXT);
|
||||
});
|
||||
|
||||
it('accepts http only for a local development host', () => {
|
||||
const on = (u: string) => registrationEntry({ registration: { enabled: true, url: u } });
|
||||
expect(on('http://localhost:8080/register')?.url).toBe('http://localhost:8080/register');
|
||||
expect(on('http://127.0.0.1:8080/register')).toBeDefined();
|
||||
expect(on('http://accounts.example/register')).toBeUndefined();
|
||||
expect(on('http://localhost.evil.example/register')).toBeUndefined();
|
||||
expect(on('https://accounts.example/register')).toBeDefined();
|
||||
});
|
||||
});
|
||||
32
src/app/pages/auth/registrationEntry.ts
Normal file
32
src/app/pages/auth/registrationEntry.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { ClientConfig } from '../../hooks/useClientConfig';
|
||||
import { TRAINING_NOTICE_VERSION, trainingNoticeText } from '../../utils/trainingNotice';
|
||||
|
||||
export type RegistrationEntry = { url: string; notice: string; noticeVersion: number };
|
||||
|
||||
// Plain http is for a developer's own machine only; a stranger's sign-up page is https.
|
||||
const isLocalHost = (hostname: string): boolean =>
|
||||
hostname === 'localhost' ||
|
||||
hostname.endsWith('.localhost') ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '[::1]';
|
||||
|
||||
/**
|
||||
* The sign-up entry the sign-in page may show: the account service's registration page, when the
|
||||
* switch is on and the configured url is an https address (http only for localhost). Anything else is no entry. The
|
||||
* consent sentence and its version are trainingNotice.ts's, the ones a room shows.
|
||||
*/
|
||||
export const registrationEntry = (config: ClientConfig): RegistrationEntry | undefined => {
|
||||
const { enabled, url } = config.registration ?? {};
|
||||
if (enabled !== true || typeof url !== 'string') return undefined;
|
||||
try {
|
||||
const { protocol, hostname } = new URL(url);
|
||||
if (protocol !== 'https:' && !(protocol === 'http:' && isLocalHost(hostname))) return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
url,
|
||||
notice: trainingNoticeText(config.previewDemo),
|
||||
noticeVersion: TRAINING_NOTICE_VERSION,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
|
||||
import { FOOTER_TINT, PLATE_TINT } from '../../features/scene/skyPlate';
|
||||
|
||||
export const AuthLayout = style({
|
||||
|
|
@ -127,3 +128,15 @@ globalStyle(`${scene} ${AuthFooter}`, {
|
|||
globalStyle(`${scene} ${AuthFooter} a`, {
|
||||
color: 'var(--sky-bubble-text, #b7c7d9)',
|
||||
});
|
||||
|
||||
// Phone tap targets for the sign-in links, hit area only (every link is under 44 px tall: Forget Password? 16,
|
||||
// Register 20, the footer's 22): vertical padding grows each link's box and the negative margin takes the growth
|
||||
// back out of the layout, so nothing moves and nothing is drawn. An inline link ignores the margin and its padding
|
||||
// never moves a line, which is the same result.
|
||||
const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
|
||||
globalStyle(`${AuthCard} a`, {
|
||||
'@media': { [SCREEN_PHONE]: { paddingBlock: 14, marginBlock: -14 } },
|
||||
});
|
||||
globalStyle(`${AuthFooter} a`, {
|
||||
'@media': { [SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 } },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
SPACE_ROOM_PATH,
|
||||
SPACE_SEARCH_PATH,
|
||||
CREATE_PATH,
|
||||
DESK_PATH,
|
||||
} from './paths';
|
||||
import { trimLeadingSlash, trimTrailingSlash } from '../utils/common';
|
||||
import { HashRouterConfig } from '../hooks/useClientConfig';
|
||||
|
|
@ -153,6 +154,9 @@ export const getExploreServerPath = (server: string): string => {
|
|||
return generatePath(EXPLORE_SERVER_PATH, params);
|
||||
};
|
||||
|
||||
export const getDeskPath = (mind: string): string =>
|
||||
generatePath(DESK_PATH, { mind: encodeURIComponent(mind) });
|
||||
|
||||
export const getCreatePath = (): string => CREATE_PATH;
|
||||
|
||||
export const getInboxPath = (): string => INBOX_PATH;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export const REGISTER_PATH = '/register/:server?/';
|
|||
export type ResetPasswordPathSearchParams = {
|
||||
email?: string;
|
||||
};
|
||||
export const SIGNED_UP_PATH = '/signed-up/';
|
||||
|
||||
export const RESET_PASSWORD_PATH = '/reset-password/:server?/';
|
||||
|
||||
export const _CREATE_PATH = 'create/';
|
||||
|
|
@ -74,6 +76,9 @@ export type ExploreServerPathSearchParams = {
|
|||
};
|
||||
export const EXPLORE_SERVER_PATH = `/explore/${_SERVER_PATH}`;
|
||||
|
||||
/** One Mind's desk: its open questions, read-only until sign-in reaches chat. */
|
||||
export const DESK_PATH = '/desk/:mind/';
|
||||
|
||||
export const CREATE_PATH = '/create';
|
||||
|
||||
export const _NOTIFICATIONS_PATH = 'notifications/';
|
||||
|
|
|
|||
51
src/app/pages/signedUpRoute.test.tsx
Normal file
51
src/app/pages/signedUpRoute.test.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import React from 'react';
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { Route, createRoutesFromElements } from 'react-router-dom';
|
||||
import { StaticRouter, createStaticHandler } from 'react-router-dom/server';
|
||||
import { ClientConfigProvider } from '../hooks/useClientConfig';
|
||||
import type { ClientConfig } from '../hooks/useClientConfig';
|
||||
import { SIGNED_UP_PATH } from './paths';
|
||||
|
||||
const config: ClientConfig = { registration: { enabled: true, url: 'https://accounts.example/' } };
|
||||
|
||||
// The routes read localStorage when imported (settings atoms); the node environment has none.
|
||||
let authRoutes: typeof import('./auth/authRoutes').authRoutes;
|
||||
let SignedUp: typeof import('./auth').SignedUp;
|
||||
beforeAll(async () => {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
});
|
||||
({ authRoutes } = await import('./auth/authRoutes'));
|
||||
({ SignedUp } = await import('./auth'));
|
||||
}, 120_000);
|
||||
|
||||
const matchPath = async (path: string) => {
|
||||
const { query } = createStaticHandler(createRoutesFromElements(<Route>{authRoutes()}</Route>));
|
||||
const context = await query(new Request(`http://localhost${path}`));
|
||||
if (context instanceof Response) {
|
||||
throw new Error(`redirected to ${context.headers.get('Location')}`);
|
||||
}
|
||||
return context.matches;
|
||||
};
|
||||
|
||||
describe('the sign-up return page in the real route table', () => {
|
||||
it('is its own route, outside AuthLayout (which would send it to sign in)', async () => {
|
||||
const matches = await matchPath(SIGNED_UP_PATH);
|
||||
// the pathless root, then the page itself: no layout route in between
|
||||
expect(matches?.map((m) => m.route.path)).toEqual([undefined, SIGNED_UP_PATH]);
|
||||
});
|
||||
|
||||
it('renders its words, not the sign-in form, when the switch is on', () => {
|
||||
const html = renderToString(
|
||||
<ClientConfigProvider value={config}>
|
||||
<StaticRouter location={SIGNED_UP_PATH}>
|
||||
<SignedUp />
|
||||
</StaticRouter>
|
||||
</ClientConfigProvider>
|
||||
);
|
||||
expect(html).toContain('Follow the steps from the sign-up page');
|
||||
});
|
||||
});
|
||||
84
src/app/phoneTapTargets.test.ts
Normal file
84
src/app/phoneTapTargets.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import zlib from 'zlib';
|
||||
import { compile, processVanillaFile } from '@vanilla-extract/integration';
|
||||
import { MOBILE_BREAKPOINT } from './hooks/useScreenSize';
|
||||
|
||||
// The CSS a .css.ts file emits, read back from vanilla-extract's own compile (as pages/auth/footerBand.test.ts does;
|
||||
// vanilla-extract gzips the inlined source only when it is large, so both forms are read).
|
||||
const emitted = async (file: string) => {
|
||||
const filePath = `${process.cwd()}/src/app/${file}`;
|
||||
const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' });
|
||||
const code = await processVanillaFile({ source, filePath, identOption: 'debug' });
|
||||
const bytes = Buffer.from(
|
||||
(code.match(/source=#?([A-Za-z0-9+/=]+)/) as RegExpMatchArray)[1],
|
||||
'base64'
|
||||
);
|
||||
return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString();
|
||||
};
|
||||
|
||||
type Decls = Record<string, number>;
|
||||
const parse = (body: string): Decls =>
|
||||
Object.fromEntries(
|
||||
body
|
||||
.split('\n')
|
||||
.map((l) => l.trim().replace(/;$/, ''))
|
||||
.filter((l) => /^[a-z-]+: -?\d+px$/.test(l))
|
||||
.map((l) => [l.split(':')[0], Number(l.split(': ')[1].replace('px', ''))])
|
||||
);
|
||||
|
||||
/** Pixel declarations of the rules whose selector matches, inside or outside a media query. */
|
||||
const rulesFor = (css: string, selector: RegExp, media: string | undefined): Decls[] => {
|
||||
const scope = media
|
||||
? [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)]
|
||||
.filter((m) => m[1] === media)
|
||||
.map((m) => m[2])
|
||||
.join('\n')
|
||||
: css.replace(/@media [^{]+ \{\n[\s\S]*?\n\}(?:\n|$)/g, '');
|
||||
return [...scope.matchAll(/^\s*([^{}\n]+) \{\n([^}]*)\}/gm)]
|
||||
.filter((m) => selector.test(m[1]))
|
||||
.map((m) => parse(m[2]));
|
||||
};
|
||||
|
||||
const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
|
||||
const TAP = 44;
|
||||
|
||||
// [target, css file, selector, measured height in CSS px on the Starfire phone run (390x844)].
|
||||
const targets: [string, string, RegExp, number][] = [
|
||||
[
|
||||
'sender-name button',
|
||||
'components/message/layout/layout.css.ts',
|
||||
/^button\.layout_Username__/,
|
||||
23,
|
||||
],
|
||||
[
|
||||
'room-topic button',
|
||||
'features/room/RoomViewHeader.css.ts',
|
||||
/RoomViewHeader_HeaderTopic__\w+$/,
|
||||
18,
|
||||
],
|
||||
['sign-in card links', 'pages/auth/styles.css.ts', /styles_AuthCard__\w+ a$/, 16],
|
||||
['sign-in footer links', 'pages/auth/styles.css.ts', /styles_AuthFooter__\w+ a$/, 22],
|
||||
];
|
||||
|
||||
describe.each(targets)('phone tap target: %s', (_name, file, selector, height) => {
|
||||
it(`grows its hit area to ${TAP} px without moving layout`, async () => {
|
||||
const css = await emitted(file);
|
||||
const [phone] = rulesFor(css, selector, PHONE);
|
||||
expect(phone, 'a phone rule exists').toBeDefined();
|
||||
const top = phone['padding-top'] ?? phone['padding-block'];
|
||||
const bottom = phone['padding-bottom'] ?? phone['padding-block'];
|
||||
const marginTop = phone['margin-top'] ?? phone['margin-block'];
|
||||
const marginBottom = phone['margin-bottom'] ?? phone['margin-block'];
|
||||
expect(height + top + bottom).toBeGreaterThanOrEqual(TAP);
|
||||
// the margin takes back exactly what the padding added, so the target does not move or push its neighbours
|
||||
expect(marginTop).toBe(-top);
|
||||
expect(marginBottom).toBe(-bottom);
|
||||
});
|
||||
|
||||
it('leaves every other screen as it was: no padding or margin outside the phone query', async () => {
|
||||
const css = await emitted(file);
|
||||
rulesFor(css, selector, undefined).forEach((decls) =>
|
||||
expect(Object.keys(decls).filter((k) => /^(padding|margin)/.test(k))).toEqual([])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -40,17 +40,17 @@ export function setFallbackSession(
|
|||
localStorage.setItem('cinny_user_id', userId);
|
||||
localStorage.setItem('cinny_hs_base_url', baseUrl);
|
||||
}
|
||||
export const removeFallbackSession = () => {
|
||||
localStorage.removeItem('cinny_hs_base_url');
|
||||
localStorage.removeItem('cinny_user_id');
|
||||
localStorage.removeItem('cinny_device_id');
|
||||
localStorage.removeItem('cinny_access_token');
|
||||
export const removeFallbackSession = (storage: Storage = localStorage) => {
|
||||
storage.removeItem('cinny_hs_base_url');
|
||||
storage.removeItem('cinny_user_id');
|
||||
storage.removeItem('cinny_device_id');
|
||||
storage.removeItem('cinny_access_token');
|
||||
};
|
||||
export const getFallbackSession = (): Session | undefined => {
|
||||
const baseUrl = localStorage.getItem('cinny_hs_base_url');
|
||||
const userId = localStorage.getItem('cinny_user_id');
|
||||
const deviceId = localStorage.getItem('cinny_device_id');
|
||||
const accessToken = localStorage.getItem('cinny_access_token');
|
||||
export const getFallbackSession = (storage: Storage = localStorage): Session | undefined => {
|
||||
const baseUrl = storage.getItem('cinny_hs_base_url');
|
||||
const userId = storage.getItem('cinny_user_id');
|
||||
const deviceId = storage.getItem('cinny_device_id');
|
||||
const accessToken = storage.getItem('cinny_access_token');
|
||||
|
||||
if (baseUrl && userId && deviceId && accessToken) {
|
||||
const session: Session = {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ export const trainingNoticeText = (previewDemo?: boolean): string =>
|
|||
* else is malformed. Absent or malformed in any way means no train line.
|
||||
*/
|
||||
export const MAX_NOTICE_VERSION = 4294967295;
|
||||
|
||||
/**
|
||||
* The version of the notice wording above. Registration shows it with the text, and a room's
|
||||
* notice event carries the same number, so one text has one version on both surfaces. Bump it
|
||||
* with the wording and nowhere else.
|
||||
*/
|
||||
export const TRAINING_NOTICE_VERSION = 1;
|
||||
export const ROOM_NOTICE_EVENT_TYPE = StateEvent.GameCultNotice;
|
||||
|
||||
export type RoomNotice = { version: number; scope: 'show' | 'study' | 'train' };
|
||||
|
|
|
|||
|
|
@ -30,29 +30,32 @@ import { getFallbackSession } from './app/state/sessions';
|
|||
|
||||
document.body.classList.add(configClass, varsClass);
|
||||
|
||||
// Register Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
const swUrl =
|
||||
import.meta.env.MODE === 'production'
|
||||
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
|
||||
: `/dev-sw.js?dev-sw`;
|
||||
// Register Service Worker. After the preview demo has settled the stored session: the worker is
|
||||
// handed that session, and the demo may drop one it cannot serve.
|
||||
const registerServiceWorker = () => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const swUrl =
|
||||
import.meta.env.MODE === 'production'
|
||||
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
|
||||
: `/dev-sw.js?dev-sw`;
|
||||
|
||||
const sendSessionToSW = () => {
|
||||
const session = getFallbackSession();
|
||||
pushSessionToSW(session?.baseUrl, session?.accessToken);
|
||||
};
|
||||
const sendSessionToSW = () => {
|
||||
const session = getFallbackSession();
|
||||
pushSessionToSW(session?.baseUrl, session?.accessToken);
|
||||
};
|
||||
|
||||
navigator.serviceWorker.register(swUrl).then(sendSessionToSW);
|
||||
navigator.serviceWorker.ready.then(sendSessionToSW);
|
||||
navigator.serviceWorker.register(swUrl).then(sendSessionToSW);
|
||||
navigator.serviceWorker.ready.then(sendSessionToSW);
|
||||
|
||||
navigator.serviceWorker.addEventListener('message', (ev) => {
|
||||
const { type } = ev.data ?? {};
|
||||
navigator.serviceWorker.addEventListener('message', (ev) => {
|
||||
const { type } = ev.data ?? {};
|
||||
|
||||
if (type === 'requestSession') {
|
||||
sendSessionToSW();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (type === 'requestSession') {
|
||||
sendSessionToSW();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// The pretend homeserver of the dev preview: built in only with VITE_PREVIEW_DEMO=1 (the dev
|
||||
// workflow) and switched on by `previewDemo: true` in config.json. Any other build drops it.
|
||||
|
|
@ -76,4 +79,7 @@ const mountApp = () => {
|
|||
root.render(<App />);
|
||||
};
|
||||
|
||||
installPreviewDemo().finally(mountApp);
|
||||
installPreviewDemo().finally(() => {
|
||||
registerServiceWorker();
|
||||
mountApp();
|
||||
});
|
||||
|
|
|
|||
97
src/preview-demo/deskForks.test.ts
Normal file
97
src/preview-demo/deskForks.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { buildDesk } from '../app/features/desk/buildDesk';
|
||||
import { ForkList } from '../app/features/desk/forkContract';
|
||||
import { deskFor, setDeskSource } from '../app/features/desk/source';
|
||||
import { unverifiedForks } from '../app/features/desk/optionsHash';
|
||||
import { demoDesk } from './deskForks';
|
||||
|
||||
const KEYS = {
|
||||
list: ['schemaVersion', 'mind', 'stamp', 'open_count', 'recorded_count', 'forks', 'next_cursor'],
|
||||
card: [
|
||||
'schemaVersion',
|
||||
'fork_id',
|
||||
'title',
|
||||
'context',
|
||||
'options',
|
||||
'recommendation',
|
||||
'depends',
|
||||
'leans',
|
||||
'flag',
|
||||
'raised_in',
|
||||
'deciders',
|
||||
'status',
|
||||
'ruling',
|
||||
'ruled_from',
|
||||
'order',
|
||||
'options_hash',
|
||||
],
|
||||
record: ['schemaVersion', 'fork_id', 'options_hash', 'counted_answer_id', 'answers'],
|
||||
answer: [
|
||||
'schemaVersion',
|
||||
'answer_id',
|
||||
'fork_id',
|
||||
'outcome',
|
||||
'of',
|
||||
'retracts',
|
||||
'code',
|
||||
'options_hash',
|
||||
'choice',
|
||||
'note',
|
||||
'answered_at',
|
||||
'received_at',
|
||||
'claim_in_future',
|
||||
'answerer',
|
||||
'authority',
|
||||
'request_id',
|
||||
],
|
||||
};
|
||||
const sorted = (o: object) => Object.keys(o).sort();
|
||||
|
||||
describe('the demo desks', () => {
|
||||
const nibu = demoDesk('nibu') as ForkList;
|
||||
it('are contract-shaped: every key written, no others, counts matching the forks', () => {
|
||||
['nibu', 'eureka'].forEach((m) => {
|
||||
const l = demoDesk(m) as ForkList;
|
||||
expect(sorted(l)).toEqual([...KEYS.list].sort());
|
||||
expect(l.open_count).toBe(l.forks.filter((f) => f.card.status === 'open').length);
|
||||
expect(l.recorded_count).toBe(l.forks.filter((f) => f.card.status === 'closed').length);
|
||||
l.forks.forEach(({ card, record }) => {
|
||||
expect(sorted(card)).toEqual([...KEYS.card].sort());
|
||||
expect(sorted(record)).toEqual([...KEYS.record].sort());
|
||||
expect(record.fork_id).toBe(card.fork_id);
|
||||
expect(record.options_hash).toBe(card.options_hash);
|
||||
record.answers.forEach((a) => expect(sorted(a)).toEqual([...KEYS.answer].sort()));
|
||||
const ids = record.answers.map((a) => a.answer_id);
|
||||
if (record.counted_answer_id !== null) expect(ids).toContain(record.counted_answer_id);
|
||||
expect(card.options.map((o) => o.id)).toContain(card.recommendation);
|
||||
if (card.status === 'closed') expect(card.ruling).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
it('seed open forks, a counted answer with a note and one without, a revision, and a ruling', () => {
|
||||
const answers = nibu.forks.flatMap((f) => f.record.answers);
|
||||
const counted = nibu.forks.flatMap((f) =>
|
||||
f.record.answers.filter((a) => a.answer_id === f.record.counted_answer_id)
|
||||
);
|
||||
expect(nibu.open_count).toBe(3);
|
||||
expect(counted.some((a) => a.note !== null)).toBe(true);
|
||||
expect(counted.some((a) => a.note === null)).toBe(true);
|
||||
expect(answers.some((a) => a.outcome === 'Revision')).toBe(true);
|
||||
expect(buildDesk(nibu).recorded).toEqual([
|
||||
expect.objectContaining({ rulingId: 'demo:ruling:ridge-low-tier' }),
|
||||
]);
|
||||
expect(nibu.forks.some((f) => f.card.leans !== null && f.card.flag !== null)).toBe(true);
|
||||
});
|
||||
it('leave other Minds unpublished, so their group shows no desk entry', () => {
|
||||
expect(demoDesk('kiko')).toBeUndefined();
|
||||
});
|
||||
it('are what the page reads once installed as the source', () => {
|
||||
setDeskSource(demoDesk);
|
||||
expect(deskFor('nibu')?.mind).toBe('nibu');
|
||||
setDeskSource(() => undefined);
|
||||
});
|
||||
it('carry the hash of what they show, so the desk shows every seeded card', async () => {
|
||||
expect(await unverifiedForks(nibu.forks)).toEqual(new Set());
|
||||
expect(await unverifiedForks((demoDesk('eureka') as ForkList).forks)).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
187
src/preview-demo/deskForks.ts
Normal file
187
src/preview-demo/deskForks.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
// Seeded forks for the preview's Mind desks, shaped as Bifrost serves them (gamecult.fork.list.v0, CultLib
|
||||
// fork-card.md). Invented here, public-safe, demo-only: no real question, person or decision. Answer times are
|
||||
// written against the clock at the time of the call so they stay plausible.
|
||||
import { DeskAnswer, ForkCard, ForkEntry, ForkList } from '../app/features/desk/forkContract';
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Each card's options_hash over what it shows (optionsHash.ts); deskForks.test.ts fails if a seed is edited without it.
|
||||
const HASHES: Record<string, string> = {
|
||||
'sky-label': '727c7944919baf8953a0473bcbb38837c2e83c87d97a31a09ed6506a0e3b4028',
|
||||
'dusk-ink': 'e15b64509db522af68ad8904767edf0903a701809e50f1074c1c8b4b3660acae',
|
||||
'phone-gutter': '893d90ae7bc8e3204afb62501af76375e98597ec565856622401583d6db33324',
|
||||
'ridge-low-tier': '6edd4a08b6c86703d91b3f48bd697aca16450111573a8e391e3dfe3adc6cbf5f',
|
||||
'settled-window': 'b0dd0db3a37a256618d447ff8fb4067892b4f1f82d05a1e5bebf5563c447731c',
|
||||
};
|
||||
|
||||
const card = (label: string, order: number, over: Partial<ForkCard>): ForkCard => ({
|
||||
schemaVersion: 'gamecult.fork.card.v0',
|
||||
fork_id: `demo:question:${label}`,
|
||||
title: label,
|
||||
context: '',
|
||||
options: [],
|
||||
recommendation: '',
|
||||
depends: [],
|
||||
leans: null,
|
||||
flag: null,
|
||||
raised_in: null,
|
||||
deciders: ['demo-decider'],
|
||||
status: 'open',
|
||||
ruling: null,
|
||||
ruled_from: [],
|
||||
order,
|
||||
options_hash: HASHES[label],
|
||||
...over,
|
||||
});
|
||||
|
||||
const answer = (
|
||||
c: ForkCard,
|
||||
n: number,
|
||||
daysAgo: number,
|
||||
over: Partial<DeskAnswer> = {}
|
||||
): DeskAnswer => {
|
||||
const at = new Date(Date.now() - daysAgo * DAY).toISOString().replace(/\.\d+Z$/, 'Z');
|
||||
return {
|
||||
schemaVersion: 'gamecult.desk_answer.v0',
|
||||
answer_id: `n${n}`,
|
||||
fork_id: c.fork_id,
|
||||
outcome: 'Counted',
|
||||
of: null,
|
||||
retracts: null,
|
||||
code: null,
|
||||
options_hash: c.options_hash,
|
||||
choice: null,
|
||||
note: null,
|
||||
answered_at: at,
|
||||
received_at: at,
|
||||
claim_in_future: false,
|
||||
answerer: `demo-account-${n}`,
|
||||
authority: 'Member',
|
||||
request_id: `demo-request-${n}`,
|
||||
...over,
|
||||
};
|
||||
};
|
||||
|
||||
const entry = (
|
||||
c: ForkCard,
|
||||
answers: DeskAnswer[] = [],
|
||||
counted: string | null = null
|
||||
): ForkEntry => ({
|
||||
schemaVersion: 'gamecult.fork.entry.v0',
|
||||
card: c,
|
||||
record: {
|
||||
schemaVersion: 'gamecult.fork.record.v0',
|
||||
fork_id: c.fork_id,
|
||||
options_hash: c.options_hash,
|
||||
counted_answer_id: counted,
|
||||
answers,
|
||||
},
|
||||
});
|
||||
|
||||
const list = (mind: string, forks: ForkEntry[], stamp: string): ForkList => ({
|
||||
schemaVersion: 'gamecult.fork.list.v0',
|
||||
mind,
|
||||
stamp,
|
||||
open_count: forks.filter((f) => f.card.status === 'open').length,
|
||||
recorded_count: forks.filter((f) => f.card.status === 'closed').length,
|
||||
forks: [...forks].sort((a, b) => b.card.order - a.card.order),
|
||||
next_cursor: null,
|
||||
});
|
||||
|
||||
const nibu = (): ForkList => {
|
||||
const sky = card('sky-label', 4, {
|
||||
title: 'Where should the sky label sit on a phone?',
|
||||
context:
|
||||
'The label can ride the horizon or sit in the header. The horizon is prettier; the header never moves.',
|
||||
options: [
|
||||
{ id: 'horizon', text: 'On the horizon, over the ridge' },
|
||||
{ id: 'header', text: 'In the header, fixed' },
|
||||
],
|
||||
recommendation: 'header',
|
||||
leans: 'header',
|
||||
flag: 'Blocks the dusk ink question',
|
||||
});
|
||||
const dusk = card('dusk-ink', 3, {
|
||||
title: 'Should the dusk ink follow the sky or the theme?',
|
||||
context:
|
||||
'Names in dusk are readable either way; following the sky is livelier, following the theme is steadier.',
|
||||
options: [
|
||||
{ id: 'sky', text: 'Follow the sky' },
|
||||
{ id: 'theme', text: 'Follow the theme' },
|
||||
{ id: 'both', text: 'Sky by day, theme by night' },
|
||||
],
|
||||
recommendation: 'sky',
|
||||
depends: ['Where the sky label sits'],
|
||||
});
|
||||
const gutter = card('phone-gutter', 2, {
|
||||
title: 'How wide is the phone gutter?',
|
||||
context: 'A narrow gutter fits more on screen; a wide one is easier to thumb past.',
|
||||
options: [
|
||||
{ id: 'narrow', text: 'Narrow' },
|
||||
{ id: 'wide', text: 'Wide' },
|
||||
],
|
||||
recommendation: 'narrow',
|
||||
});
|
||||
const ridge = card('ridge-low-tier', 1, {
|
||||
title: 'Keep the ridge scene at the low tier?',
|
||||
context: 'The ridge costs a little on older phones.',
|
||||
options: [
|
||||
{ id: 'keep', text: 'Keep it' },
|
||||
{ id: 'drop', text: 'Drop it' },
|
||||
],
|
||||
recommendation: 'keep',
|
||||
status: 'closed',
|
||||
ruling: 'demo:ruling:ridge-low-tier',
|
||||
ruled_from: ['n1'],
|
||||
});
|
||||
return list(
|
||||
'nibu',
|
||||
[
|
||||
entry(sky),
|
||||
entry(
|
||||
dusk,
|
||||
[
|
||||
answer(dusk, 2, 2, { choice: 'sky' }),
|
||||
answer(dusk, 3, 1, {
|
||||
outcome: 'Revision',
|
||||
of: 'n2',
|
||||
choice: 'both',
|
||||
answerer: 'demo-account-3',
|
||||
}),
|
||||
],
|
||||
'n2'
|
||||
),
|
||||
entry(
|
||||
gutter,
|
||||
[answer(gutter, 4, 1, { choice: 'narrow', note: 'Thumbs are smaller than we fear.' })],
|
||||
'n4'
|
||||
),
|
||||
entry(
|
||||
ridge,
|
||||
[answer(ridge, 1, 12, { choice: 'keep', note: 'It is the best part of the page.' })],
|
||||
'n1'
|
||||
),
|
||||
],
|
||||
's41'
|
||||
);
|
||||
};
|
||||
|
||||
const eureka = (): ForkList => {
|
||||
const window = card('settled-window', 1, {
|
||||
title: 'How long should a Mind keep a settled question on its desk?',
|
||||
context:
|
||||
'Settled questions are listed as rulings. A shorter window keeps the desk light; a longer one keeps context near.',
|
||||
options: [
|
||||
{ id: '30d', text: 'Thirty days' },
|
||||
{ id: '90d', text: 'Ninety days' },
|
||||
{ id: 'always', text: 'Keep them all' },
|
||||
],
|
||||
recommendation: '30d',
|
||||
});
|
||||
return list('eureka', [entry(window)], 's7');
|
||||
};
|
||||
|
||||
const SEEDS: Record<string, () => ForkList> = { nibu, eureka };
|
||||
|
||||
/** The demo's desk for a Mind, or nothing: a Mind without seeds has not published yet. */
|
||||
export const demoDesk = (mind: string): ForkList | undefined => SEEDS[mind]?.();
|
||||
|
|
@ -44,6 +44,8 @@ export type DemoRoom = {
|
|||
topic?: string;
|
||||
members: string[];
|
||||
notice?: 'show' | 'study' | 'train';
|
||||
/** The m.room.create `type`. */
|
||||
type?: string;
|
||||
parent?: string;
|
||||
steward?: { mind: string; repo: string };
|
||||
joined: boolean;
|
||||
|
|
@ -54,6 +56,7 @@ export const ROOT = `!root:${DEMO_SERVER}`;
|
|||
export const PROJECTS = `!projects:${DEMO_SERVER}`;
|
||||
export const GENERAL = `!general:${DEMO_SERVER}`;
|
||||
export const COMMONS = `!commons:${DEMO_SERVER}`;
|
||||
export const FILES = `!files:${DEMO_SERVER}`;
|
||||
|
||||
const everyone = USERS.map((u) => u.id);
|
||||
export const MIND_IDS = [user('eureka'), user('void'), user('nibu')];
|
||||
|
|
@ -115,6 +118,17 @@ export const ROOMS: DemoRoom[] = [
|
|||
{ from: user('heimdall'), body: 'Ok.', pose: 'thinking', minutesAgo: 20 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: FILES,
|
||||
alias: `#files:${DEMO_SERVER}`,
|
||||
name: 'Files',
|
||||
topic: 'The file zone: one thread per Mind, shown as albums.',
|
||||
type: 'org.gamecult.file_zone',
|
||||
members: [ME, ...minds],
|
||||
parent: ROOT,
|
||||
joined: true,
|
||||
lines: [],
|
||||
},
|
||||
{
|
||||
id: PROJECTS,
|
||||
alias: `#projects:${DEMO_SERVER}`,
|
||||
|
|
@ -302,6 +316,36 @@ export const ROOMS: DemoRoom[] = [
|
|||
},
|
||||
];
|
||||
|
||||
// The file zone's albums: a thread root per album, one thread reply per file. Media is generated
|
||||
// in media.ts: audio by tone frequency, images by gradient color.
|
||||
export type DemoAlbum = {
|
||||
mind: string;
|
||||
title: string;
|
||||
minutesAgo: number;
|
||||
files: { kind: 'audio' | 'image'; name: string; seed: number | [number, number, number] }[];
|
||||
};
|
||||
export const ALBUMS: DemoAlbum[] = [
|
||||
{
|
||||
mind: user('void'),
|
||||
title: 'void',
|
||||
minutesAgo: 90,
|
||||
files: [
|
||||
{ kind: 'audio', name: 'low-tone.wav', seed: 220 },
|
||||
{ kind: 'audio', name: 'mid-tone.wav', seed: 330 },
|
||||
{ kind: 'audio', name: 'high-tone.wav', seed: 440 },
|
||||
],
|
||||
},
|
||||
{
|
||||
mind: user('nibu'),
|
||||
title: 'nibu',
|
||||
minutesAgo: 30,
|
||||
files: [
|
||||
{ kind: 'image', name: 'dusk.png', seed: [200, 90, 60] },
|
||||
{ kind: 'image', name: 'lagoon.png', seed: [40, 150, 190] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// A Mind's canned answer to whatever the visitor types: label and line.
|
||||
export const REPLIES: { pose: string; body: string }[] = [
|
||||
{ pose: 'thinking', body: 'Hm. Let me think about that.' },
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@
|
|||
// only by a build with VITE_PREVIEW_DEMO=1 whose config.json says previewDemo: true (src/index.tsx).
|
||||
// Own-origin requests (the app's static files) pass; the demo origin is answered in-page; any
|
||||
// other origin is refused, so no real homeserver can be contacted while this is on.
|
||||
import { removeFallbackSession } from '../app/state/sessions';
|
||||
import { DEMO_ORIGIN } from './fixtures';
|
||||
import { createDemoServer, DemoServer } from './server';
|
||||
import { demoDesk } from './deskForks';
|
||||
import { setDeskSource } from '../app/features/desk/source';
|
||||
|
||||
export type Route = 'own' | 'demo' | 'refuse';
|
||||
|
||||
|
|
@ -27,8 +30,6 @@ const refusal = (what: string, url: string) =>
|
|||
new Error(`preview: pretend server only; ${what} to ${url} refused`);
|
||||
|
||||
const BANNER_TEXT = 'preview: pretend server. Any password works.';
|
||||
const FOREIGN_SESSION_TEXT =
|
||||
'preview: pretend server. A login for another server is stored here and cannot be used.';
|
||||
|
||||
// In flow, above the app: body becomes a column and the app root takes the rest, so the banner
|
||||
// never covers the room header or the connection strip.
|
||||
|
|
@ -72,14 +73,30 @@ export const guardedFetch =
|
|||
body: parseBody(raw),
|
||||
signal: init?.signal ?? req?.signal ?? undefined,
|
||||
});
|
||||
if (out.raw) {
|
||||
return new Response(out.raw.data, {
|
||||
status: out.status,
|
||||
headers: { 'Content-Type': out.raw.type },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(out.body), {
|
||||
status: out.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
// Every preview of this site shares one origin, so one localStorage: a login another preview left
|
||||
// (a real homeserver) would make the app connect to that server, which the guard refuses. The
|
||||
// demo has one server, so a stored login for any other is dropped before the app reads it.
|
||||
export const dropForeignSession = (storage: Storage) => {
|
||||
const base = storage.getItem('cinny_hs_base_url');
|
||||
if (base !== null && base !== DEMO_ORIGIN) removeFallbackSession(storage);
|
||||
};
|
||||
|
||||
export const installDemo = (win: Window & typeof globalThis = window) => {
|
||||
dropForeignSession(win.localStorage);
|
||||
const server = createDemoServer();
|
||||
setDeskSource(demoDesk);
|
||||
const { origin } = win.location;
|
||||
const realFetch = win.fetch.bind(win);
|
||||
|
||||
|
|
@ -111,7 +128,6 @@ export const installDemo = (win: Window & typeof globalThis = window) => {
|
|||
routeOf(String(url), origin) === 'own' ? realBeacon(url, data) : false;
|
||||
}
|
||||
|
||||
const stored = win.localStorage.getItem('cinny_hs_base_url');
|
||||
showBanner(stored !== null && stored !== DEMO_ORIGIN ? FOREIGN_SESSION_TEXT : BANNER_TEXT);
|
||||
showBanner(BANNER_TEXT);
|
||||
return server;
|
||||
};
|
||||
|
|
|
|||
95
src/preview-demo/media.ts
Normal file
95
src/preview-demo/media.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/* eslint-disable no-bitwise */
|
||||
// Tiny media for the pretend server's file zone, generated here so the repo holds no binaries.
|
||||
export type DemoMedia = { type: string; data: Uint8Array };
|
||||
|
||||
const u32 = (n: number): number[] => [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255];
|
||||
const le = (n: number, bytes: number): number[] =>
|
||||
Array.from({ length: bytes }, (_, i) => (n >>> (8 * i)) & 255);
|
||||
|
||||
/** A 0.6 s mono 8 kHz 8-bit WAV: a sine at `hz` with a soft fade. */
|
||||
export const wavTone = (hz: number): DemoMedia => {
|
||||
const rate = 8000;
|
||||
const n = Math.round(rate * 0.6);
|
||||
const pcm = Array.from({ length: n }, (_, i) => {
|
||||
const fade = Math.min(1, i / 400, (n - i) / 400);
|
||||
return Math.round(128 + 90 * fade * Math.sin((2 * Math.PI * hz * i) / rate));
|
||||
});
|
||||
const tag = (s: string) => Array.from(s, (c) => c.charCodeAt(0));
|
||||
return {
|
||||
type: 'audio/wav',
|
||||
data: Uint8Array.from([
|
||||
...tag('RIFF'),
|
||||
...le(36 + n, 4),
|
||||
...tag('WAVEfmt '),
|
||||
...le(16, 4),
|
||||
...le(1, 2),
|
||||
...le(1, 2),
|
||||
...le(rate, 4),
|
||||
...le(rate, 4),
|
||||
...le(1, 2),
|
||||
...le(8, 2),
|
||||
...tag('data'),
|
||||
...le(n, 4),
|
||||
...pcm,
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
const crc32 = (bytes: number[]): number => {
|
||||
let c = ~0;
|
||||
bytes.forEach((b) => {
|
||||
c ^= b;
|
||||
for (let k = 0; k < 8; k += 1) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
|
||||
});
|
||||
return ~c >>> 0;
|
||||
};
|
||||
|
||||
const chunk = (name: string, data: number[]): number[] => {
|
||||
const body = [...Array.from(name, (c) => c.charCodeAt(0)), ...data];
|
||||
return [...u32(data.length), ...body, ...u32(crc32(body))];
|
||||
};
|
||||
|
||||
/** A 32 x 32 RGB PNG: a diagonal gradient in `rgb`, zlib "stored" blocks, no compression. */
|
||||
export const pngGradient = (rgb: [number, number, number]): DemoMedia => {
|
||||
const size = 32;
|
||||
const raw: number[] = [];
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
raw.push(0);
|
||||
for (let x = 0; x < size; x += 1) {
|
||||
const f = 0.35 + (0.65 * (x + y)) / (2 * size);
|
||||
raw.push(...rgb.map((v) => Math.round(v * f)));
|
||||
}
|
||||
}
|
||||
let a = 1;
|
||||
let b = 0;
|
||||
raw.forEach((v) => {
|
||||
a = (a + v) % 65521;
|
||||
b = (b + a) % 65521;
|
||||
});
|
||||
// One stored block holds up to 65535 bytes; 32 * 97 = 3104 fits.
|
||||
const zlib = [
|
||||
0x78,
|
||||
0x01,
|
||||
1,
|
||||
...le(raw.length, 2),
|
||||
...le(~raw.length & 0xffff, 2),
|
||||
...raw,
|
||||
...u32(((b << 16) | a) >>> 0),
|
||||
];
|
||||
return {
|
||||
type: 'image/png',
|
||||
data: Uint8Array.from([
|
||||
137,
|
||||
80,
|
||||
78,
|
||||
71,
|
||||
13,
|
||||
10,
|
||||
26,
|
||||
10,
|
||||
...chunk('IHDR', [...u32(size), ...u32(size), 8, 2, 0, 0, 0]),
|
||||
...chunk('IDAT', zlib),
|
||||
...chunk('IEND', []),
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
|
@ -1,10 +1,18 @@
|
|||
/* eslint-disable max-classes-per-file, no-promise-executor-return, no-useless-constructor, no-empty-function, no-void, @typescript-eslint/no-non-null-assertion */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ClientEvent, MatrixClient, SyncState, createClient } from 'matrix-js-sdk';
|
||||
import { DEMO_ORIGIN, ME, ROOMS, GENERAL, COMMONS } from './fixtures';
|
||||
import { guardedFetch, installDemo, routeOf } from './install';
|
||||
import {
|
||||
ClientEvent,
|
||||
ClientPrefix,
|
||||
MatrixClient,
|
||||
Method,
|
||||
SyncState,
|
||||
createClient,
|
||||
} from 'matrix-js-sdk';
|
||||
import { ALBUMS, DEMO_ORIGIN, ME, ROOMS, GENERAL, COMMONS, FILES } from './fixtures';
|
||||
import { dropForeignSession, guardedFetch, installDemo, routeOf } from './install';
|
||||
import { createDemoServer, DemoServer } from './server';
|
||||
import { seedDemoSettings } from './seed';
|
||||
import { getFallbackSession } from '../app/state/sessions';
|
||||
import { mindOfSender, POSE_KEY, resolvePose } from '../app/components/message/pose/poses';
|
||||
import { parseRoomNotice, roomTrainingLine } from '../app/utils/trainingNotice';
|
||||
|
||||
|
|
@ -29,6 +37,9 @@ const waitFor = <T>(read: () => T | undefined, ms = 5000): Promise<T> =>
|
|||
tick();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Ev = { event_id: string; content: Record<string, any> };
|
||||
|
||||
const live: { server: DemoServer; mx: MatrixClient }[] = [];
|
||||
afterEach(() => {
|
||||
live.splice(0).forEach(({ server, mx }) => {
|
||||
|
|
@ -115,7 +126,12 @@ describe('the pretend homeserver through matrix-js-sdk', () => {
|
|||
const ev = mx.getRoom(r.id)!.currentState.getStateEvents('org.gamecult.notice', '');
|
||||
return [r.name, roomTrainingLine(parseRoomNotice(ev?.getContent())) !== undefined];
|
||||
});
|
||||
expect(Object.fromEntries(lines)).toEqual({ General: true, Ratatoskr: false, Huginn: false });
|
||||
expect(Object.fromEntries(lines)).toEqual({
|
||||
General: true,
|
||||
Files: false,
|
||||
Ratatoskr: false,
|
||||
Huginn: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('sends a message, echoes it once with its transaction id, and a Mind answers with a pose', async () => {
|
||||
|
|
@ -171,6 +187,56 @@ describe('the pretend homeserver through matrix-js-sdk', () => {
|
|||
expect(stewards.filter(Boolean).length).toBe(2);
|
||||
});
|
||||
|
||||
it('syncs the file zone as a joined room whose create type marks it', async () => {
|
||||
const { mx } = await syncedClient();
|
||||
const create = mx.getRoom(FILES)!.currentState.getStateEvents('m.room.create', '')!;
|
||||
expect(create.getContent().type).toBe('org.gamecult.file_zone');
|
||||
expect(
|
||||
mx.getRoom(GENERAL)!.currentState.getStateEvents('m.room.create', '')!.getContent().type
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('serves the zone albums through the thread endpoints and their media as bytes', async () => {
|
||||
const { mx } = await syncedClient();
|
||||
const get = (path: string, params?: Record<string, string>) =>
|
||||
mx.http.authedRequest<{ chunk: Ev[] }>(Method.Get, path, params, undefined, {
|
||||
prefix: ClientPrefix.V1,
|
||||
});
|
||||
const roots = (await get(`/rooms/${encodeURIComponent(FILES)}/threads`)).chunk;
|
||||
expect(roots.map((r) => r.content.body).sort()).toEqual(ALBUMS.map((a) => a.title).sort());
|
||||
const counts: Record<string, string[]> = {};
|
||||
await Promise.all(
|
||||
roots.map(async (root: Ev) => {
|
||||
const rel = `/rooms/${encodeURIComponent(FILES)}/relations/${encodeURIComponent(
|
||||
root.event_id
|
||||
)}/m.thread`;
|
||||
const oldest = (await get(rel, { dir: 'f' })).chunk;
|
||||
const newest = (await get(rel)).chunk;
|
||||
expect(newest.map((e: Ev) => e.event_id)).toEqual(
|
||||
oldest.map((e: Ev) => e.event_id).reverse()
|
||||
);
|
||||
counts[root.content.body] = oldest.map((e: Ev) => e.content.msgtype);
|
||||
expect(oldest.every((e: Ev) => e.content['m.relates_to'].event_id === root.event_id)).toBe(
|
||||
true
|
||||
);
|
||||
})
|
||||
);
|
||||
expect(counts).toEqual({
|
||||
void: ['m.audio', 'm.audio', 'm.audio'],
|
||||
nibu: ['m.image', 'm.image'],
|
||||
});
|
||||
const f = guardedFetch(createDemoServer(), PAGE, never as never);
|
||||
const wav = await f(`${DEMO_ORIGIN}/_matrix/client/v1/media/download/demo.invalid/album0file0`);
|
||||
expect(wav.headers.get('content-type')).toBe('audio/wav');
|
||||
expect(new TextDecoder().decode((await wav.arrayBuffer()).slice(0, 4))).toBe('RIFF');
|
||||
const png = await f(`${DEMO_ORIGIN}/_matrix/media/v3/download/demo.invalid/album1file1`);
|
||||
expect(png.headers.get('content-type')).toBe('image/png');
|
||||
expect((await png.arrayBuffer()).byteLength).toBeGreaterThan(100);
|
||||
expect(
|
||||
(await f(`${DEMO_ORIGIN}/_matrix/client/v1/media/download/demo.invalid/nope`)).status
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it('answers a long poll with nothing new after its timeout', async () => {
|
||||
const server = createDemoServer();
|
||||
live.push({ server, mx: clientFor(server) });
|
||||
|
|
@ -233,6 +299,7 @@ describe('the guard', () => {
|
|||
localStorage: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
},
|
||||
} as unknown as Window & typeof globalThis;
|
||||
vi.stubGlobal('document', {
|
||||
|
|
@ -254,6 +321,59 @@ describe('the guard', () => {
|
|||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('drops a stored login for a real homeserver before the app reads it, keeps the demo one', () => {
|
||||
const SESSION = {
|
||||
cinny_access_token: 'tok',
|
||||
cinny_device_id: 'DEV',
|
||||
cinny_user_id: '@a:b',
|
||||
cinny_hs_base_url: 'https://chat.gamecult.org',
|
||||
};
|
||||
const store = new Map<string, string>(Object.entries({ ...SESSION, settings: '{}' }));
|
||||
const storage = {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
} as Storage;
|
||||
dropForeignSession(storage);
|
||||
expect(getFallbackSession(storage)).toBeUndefined();
|
||||
expect([...store.keys()]).toEqual(['settings']);
|
||||
|
||||
Object.entries({ ...SESSION, cinny_hs_base_url: DEMO_ORIGIN }).forEach(([k, v]) =>
|
||||
store.set(k, v)
|
||||
);
|
||||
dropForeignSession(storage);
|
||||
expect(getFallbackSession(storage)?.baseUrl).toBe(DEMO_ORIGIN);
|
||||
});
|
||||
|
||||
it('installDemo clears a foreign login so the app starts at the login screen', () => {
|
||||
const store = new Map<string, string>([
|
||||
['cinny_hs_base_url', 'https://chat.gamecult.org'],
|
||||
['cinny_access_token', 'tok'],
|
||||
['cinny_device_id', 'DEV'],
|
||||
['cinny_user_id', '@a:b'],
|
||||
]);
|
||||
const win = {
|
||||
location: { origin: PAGE },
|
||||
fetch: never,
|
||||
WebSocket: class {},
|
||||
XMLHttpRequest: { prototype: { open: vi.fn() } },
|
||||
navigator: {},
|
||||
localStorage: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
},
|
||||
} as unknown as Window & typeof globalThis;
|
||||
vi.stubGlobal('document', {
|
||||
createElement: () => ({ style: {}, setAttribute: vi.fn() }),
|
||||
getElementById: () => null,
|
||||
body: { style: {}, insertBefore: vi.fn() },
|
||||
});
|
||||
const server = installDemo(win);
|
||||
live.push({ server, mx: clientFor(server) });
|
||||
expect(store.size).toBe(0);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('seeds pose portraits on for a first visit and keeps what a visitor already chose', () => {
|
||||
const fresh = new Map<string, string>();
|
||||
seedDemoSettings({
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
// send messages and get a Mind's canned answer. It touches no network and no storage; the
|
||||
// fetch guard that routes requests here lives in install.ts.
|
||||
import {
|
||||
ALBUMS,
|
||||
COMMONS,
|
||||
DEMO_DEVICE,
|
||||
DEMO_ORIGIN,
|
||||
|
|
@ -10,14 +11,17 @@ import {
|
|||
DEMO_TOKEN,
|
||||
DemoEvent,
|
||||
DemoRoom,
|
||||
FILES,
|
||||
ME,
|
||||
MIND_IDS,
|
||||
REPLIES,
|
||||
ROOMS,
|
||||
USERS,
|
||||
} from './fixtures';
|
||||
import { DemoMedia, pngGradient, wavTone } from './media';
|
||||
|
||||
export type DemoResponse = { status: number; body: unknown };
|
||||
/** `raw` answers a media download with bytes instead of JSON. */
|
||||
export type DemoResponse = { status: number; body: unknown; raw?: DemoMedia };
|
||||
export type DemoRequest = { method: string; url: string; body?: unknown; signal?: AbortSignal };
|
||||
|
||||
const POSE_KEY = 'org.gamecult.pose';
|
||||
|
|
@ -79,7 +83,12 @@ export const createDemoServer = (now: () => number = Date.now): DemoServer => {
|
|||
mk(
|
||||
'm.room.create',
|
||||
creator,
|
||||
room.space ? { type: 'm.space', room_version: '10' } : { room_version: '10' },
|
||||
{
|
||||
room_version: '10',
|
||||
...((room.space ? 'm.space' : room.type)
|
||||
? { type: room.space ? 'm.space' : room.type }
|
||||
: {}),
|
||||
},
|
||||
t0,
|
||||
''
|
||||
),
|
||||
|
|
@ -159,6 +168,45 @@ export const createDemoServer = (now: () => number = Date.now): DemoServer => {
|
|||
[`@eureka:${DEMO_SERVER}`]: ROOMS.filter((r) => r.direct).map((r) => r.id),
|
||||
});
|
||||
|
||||
// The file zone's albums live outside the sync timeline: thread roots per room, replies per
|
||||
// root, media by mxc id. The thread endpoints and media downloads answer from these.
|
||||
const threadRoots = new Map<string, DemoEvent[]>();
|
||||
const threadReplies = new Map<string, DemoEvent[]>();
|
||||
const media = new Map<string, DemoMedia>();
|
||||
ALBUMS.forEach((album, a) => {
|
||||
const t0 = now() - album.minutesAgo * 60 * 1000;
|
||||
const root = mk('m.room.message', album.mind, { msgtype: 'm.text', body: album.title }, t0);
|
||||
threadRoots.set(FILES, [...(threadRoots.get(FILES) ?? []), root]);
|
||||
threadReplies.set(
|
||||
root.event_id,
|
||||
album.files.map((f, i) => {
|
||||
const id = `album${a}file${i}`;
|
||||
const m =
|
||||
f.kind === 'audio'
|
||||
? wavTone(f.seed as number)
|
||||
: pngGradient(f.seed as [number, number, number]);
|
||||
media.set(id, m);
|
||||
return mk(
|
||||
'm.room.message',
|
||||
album.mind,
|
||||
{
|
||||
msgtype: f.kind === 'audio' ? 'm.audio' : 'm.image',
|
||||
body: f.name,
|
||||
url: `mxc://${DEMO_SERVER}/${id}`,
|
||||
info: { mimetype: m.type, size: m.data.length },
|
||||
'm.relates_to': {
|
||||
rel_type: 'm.thread',
|
||||
event_id: root.event_id,
|
||||
is_falling_back: true,
|
||||
'm.in_reply_to': { event_id: root.event_id },
|
||||
},
|
||||
},
|
||||
t0 + (i + 1) * 60 * 1000
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const notify = () => {
|
||||
waiters.forEach((w) => w());
|
||||
waiters.clear();
|
||||
|
|
@ -290,6 +338,11 @@ export const createDemoServer = (now: () => number = Date.now): DemoServer => {
|
|||
if (p === '/.well-known/matrix/client')
|
||||
return ok({ 'm.homeserver': { base_url: DEMO_ORIGIN } });
|
||||
if (!p.startsWith('/_matrix/')) return err(404, 'M_NOT_FOUND', 'Not found');
|
||||
g = p.match(/^\/_matrix\/(?:client\/v1\/media|media\/v3)\/download\/[^/]+\/([^/]+)$/);
|
||||
if (g) {
|
||||
const found = media.get(g[1]);
|
||||
return found ? { status: 200, body: null, raw: found } : err(404, 'M_NOT_FOUND', 'No media');
|
||||
}
|
||||
const c = p.replace(/^\/_matrix\/client\/(v\d+|r0|unstable)/, '');
|
||||
if (p.endsWith('/versions'))
|
||||
return ok({ versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} });
|
||||
|
|
@ -394,6 +447,13 @@ export const createDemoServer = (now: () => number = Date.now): DemoServer => {
|
|||
if (g) {
|
||||
return ok({ chunk: (stateOf.get(g[1]) ?? []).filter((e) => e.type === 'm.room.member') });
|
||||
}
|
||||
g = c.match(/^\/rooms\/([^/]+)\/threads$/);
|
||||
if (g) return ok({ chunk: [...(threadRoots.get(g[1]) ?? [])].reverse() });
|
||||
g = c.match(/^\/rooms\/([^/]+)\/relations\/([^/]+)\/m\.thread$/);
|
||||
if (g) {
|
||||
const oldestFirst = [...(threadReplies.get(g[2]) ?? [])];
|
||||
return ok({ chunk: u.searchParams.get('dir') === 'f' ? oldestFirst : oldestFirst.reverse() });
|
||||
}
|
||||
g = c.match(/^\/rooms\/([^/]+)\/messages$/);
|
||||
if (g) return ok({ chunk: [], start: 'p0', end: 'p0' });
|
||||
g = c.match(/^\/rooms\/([^/]+)\/(?:summary|context\/.+)$/);
|
||||
|
|
|
|||
Loading…
Reference in a new issue