Compare commits

..
Author SHA1 Message Date
Void
3898239ec4 Bound the reply preview: min-width 0 on its flex parents, 2-line clamp, break long words and urls
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m7s
On a 360px phone the reply quote above a message was unbounded and overflowed the screen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 20:33:44 +00:00
67 changed files with 1132 additions and 2869 deletions

View file

@ -1,8 +1,3 @@
// Airbnb's own bans, kept: a `no-restricted-syntax` entry below replaces the whole list.
const airbnbRestrictedSyntax = require('eslint-config-airbnb-base/rules/style').rules[
'no-restricted-syntax'
];
module.exports = {
env: {
browser: true,
@ -67,21 +62,6 @@ module.exports = {
"@typescript-eslint/no-shadow": "error"
},
overrides: [
{
// The one join path: a join is `joinEstateRoom`, which files the DM before it sends the join.
files: ['src/**/*.ts', 'src/**/*.tsx'],
excludedFiles: ['src/app/features/estate/joinEstateRoom.ts', 'src/**/*.test.*'],
rules: {
'no-restricted-syntax': [
...airbnbRestrictedSyntax,
{
selector: "CallExpression[callee.property.name='joinRoom']",
message:
'Join a room through joinEstateRoom (features/estate/joinEstateRoom.ts), the one join path.',
},
],
},
},
{
files: ['*.ts'],
rules: {

View file

@ -1,13 +1,6 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"gamecult.org",
"converser.eu",
"matrix.org",
"mozilla.org",
"unredacted.org",
"xmr.se"
],
"defaultHomeserver": 1,
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
"allowCustomHomeservers": true,
"featuredCommunities": {
"openAsDefault": false,
@ -31,10 +24,5 @@
"dings": {
"enabled": false
},
"registration": {
"enabled": false,
"url": ""
}
}

View file

@ -1,7 +1,5 @@
# Mind feedback pages
> The desk reads `gamecult.fork.*` now, not the `org.gamecult.feedback.*` projections named below; see CultLib `contracts/cultnet/governance/fork-card.md` (src/app/features/desk/forkContract.ts).
Design only. No code exists for this; nothing here is built. Drafted by Kiko Self (2026-11-07) with Heimdall's draft rulings
on guests and hiding; placement ruled for this repo by Void. Follow-up `eureka-body:follow_up:ratatoskr-mind-feedback-pages`.
Every number below is provisional.

View file

@ -2,17 +2,12 @@
// non-zero when the page or the fake homeserver's log says otherwise.
const fs = require('fs');
const fails = [];
let asserted = 0;
exports.expect = (label, ok, detail = '') => {
asserted += 1;
console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`);
if (!ok) fails.push(label);
};
/** Exit code for the drive: 4 when any assertion failed, 5 when none ran (a drive that asserts nothing proves nothing). */
exports.done = () => {
if (fails.length) return 4;
return asserted === 0 ? 5 : 0;
};
/** Exit code for the drive: 4 when any assertion failed. */
exports.done = () => (fails.length ? 4 : 0);
/** The fake homeserver's request log (HSLOG is shared with the server process). */
exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n');
/** Bodies of the account-data PUTs for m.direct, in order. */

View file

@ -1,35 +0,0 @@
// @vitest-environment node
import { createRequire } from 'module';
import { describe, expect, it, vi } from 'vitest';
// A drive that asserts nothing proves nothing: its exit code must say so.
const fresh = () => {
const require = createRequire(import.meta.url);
const path = require.resolve('./check.cjs');
delete require.cache[path];
return require('./check.cjs') as {
expect: (label: string, ok: boolean, detail?: string) => void;
done: () => number;
};
};
describe('estate drive exit code', () => {
it('is 5 when no assertion ran', () => {
expect(fresh().done()).toBe(5);
});
it('is 0 when assertions ran and all held', () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
const c = fresh();
c.expect('holds', true);
expect(c.done()).toBe(0);
});
it('is 4 when any assertion failed', () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
const c = fresh();
c.expect('holds', true);
c.expect('breaks', false, 'detail');
expect(c.done()).toBe(4);
});
});

View file

@ -30,8 +30,10 @@ type BubbleLayoutProps = {
hideBubble?: boolean;
/** The sender is the signed-in user: right-aligned, in the accent colour, no avatar. */
own?: boolean;
/** A Mind's pose portrait; it replaces the avatar and nests in a corner of the bubble. */
/** A Mind's pose portrait; it replaces the avatar and sits beside (wide) or inside (phone) the bubble. */
pose?: ReactNode;
/** On phones the portrait floats on the other side. */
alt?: boolean;
/** Delay in ms before the first-appearance animation; undefined plays none. */
enterDelay?: number;
before?: ReactNode;
@ -39,7 +41,7 @@ type BubbleLayoutProps = {
};
export const BubbleLayout = as<'div', BubbleLayoutProps>(
({ hideBubble, own, pose, enterDelay, before, header, children, style, ...props }, ref) => {
({ hideBubble, own, pose, alt, enterDelay, before, header, children, style, ...props }, ref) => {
// `false` is a Mind's message continuing a run: aligned with the portraits, showing none.
const poseRow = pose !== undefined;
const arrow = !!before && !poseRow && !own;
@ -48,7 +50,12 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
<Box
gap="300"
{...props}
className={classNames(own && css.BubbleRowOwn, props.className)}
className={classNames(
poseRow && css.BubbleRowPose,
pose && css.BubbleRowPortrait,
own && css.BubbleRowOwn,
props.className
)}
style={entering ? { ...style, ['--enter-delay' as string]: `${enterDelay}ms` } : style}
ref={ref}
>
@ -60,9 +67,19 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
<Box
grow={own ? 'No' : 'Yes'}
direction="Column"
className={classNames(own && css.BubbleColumnOwn)}
className={classNames(poseRow && css.BubbleColumnPose, own && css.BubbleColumnOwn)}
>
{header && <div className={classNames(own && css.BubbleHeaderOwn)}>{header}</div>}
{header && (
<div
className={classNames(
pose && css.BubbleHeaderPose,
pose && alt && css.BubbleHeaderPoseAlt,
own && css.BubbleHeaderOwn
)}
>
{header}
</div>
)}
{hideBubble ? (
children
) : (
@ -72,7 +89,6 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
css.BubbleContent,
arrow && css.BubbleContentArrowLeft,
poseRow && css.BubbleContentPose,
pose && css.BubbleContentPortrait,
own && css.BubbleContentOwn,
entering && css.EnterBubble
)}

View file

@ -153,21 +153,39 @@ export const BubbleContentArrowLeft = style({
borderTopLeftRadius: 0,
});
// A Mind's pose nests in the corner of its bubble, on phones and wide screens alike: the sprite's
// body sits in the bubble's padding and the bubble's edge cuts it, so nothing hangs outside; the
// text wraps around its silhouette (shape-outside, traced from the alpha channel) and the bubble
// grows to hold it. How much of the art the edge may cut comes from pose/nest.ts.
// Displayed width of the whole sprite image; the crop fractions are multiples of it.
export const portraitWidth = createVar();
// Look of the three-doors mock-up (chat.gamecult.org/mockups/three-doors): a Mind's pose sits
// beside its bubble on wide screens; on phones it floats inside the bubble, overhanging the top
// edge, and the text wraps around its silhouette (shape-outside on the alpha channel).
const PHONE = 'screen and (max-width: 700px)';
const PORTRAIT = toRem(190);
const PHONE_PORTRAIT = 'min(38vw, 160px)';
const PHONE_TEXT_FLOOR = toRem(170);
const BUBBLE_PAD = toRem(12);
const PORTRAIT_OVERHANG = 58;
const PORTRAIT = toRem(124);
const POSE_GUTTER = toRem(14);
const BUBBLE_CORNER = toRem(6);
const POP = 'cubic-bezier(0.34, 1.7, 0.64, 1)';
const RISE = 'cubic-bezier(0.34, 1.45, 0.64, 1)';
export const BubbleRowPose = style({
position: 'relative',
paddingLeft: `calc(${PORTRAIT} + ${POSE_GUTTER})`,
'@media': {
[PHONE]: {
paddingLeft: 0,
},
},
});
// A row that shows a portrait: room for it to hang above the bubble on phones.
export const BubbleRowPortrait = style({
minHeight: PORTRAIT,
'@media': {
[PHONE]: {
minHeight: 0,
},
},
});
export const BubbleRowOwn = style({
justifyContent: 'flex-end',
});
@ -181,29 +199,64 @@ export const BubbleHeaderOwn = style({
alignSelf: 'flex-end',
});
// The message body is a flex column, which forms its own block formatting context and would sit
// whole beside the floated portrait; as a plain block its lines wrap around the silhouette and
// then run the bubble's full width below it.
export const BubbleContentPose = style({
display: 'flow-root',
overflow: 'clip',
padding: BUBBLE_PAD,
});
// A bubble that shows a portrait is at least portrait + a readable column wide, or the text beside
// it collapses to a strip.
export const BubbleContentPortrait = style({
vars: { [portraitWidth]: PORTRAIT },
minWidth: `min(100%, calc(${portraitWidth} + ${PHONE_TEXT_FLOOR}))`,
// Phone: name and time are one plain line in flow above the bubble, on the side opposite the
// portrait. It is as wide as the full name needs, up to the room beside the portrait; only a name
// longer than that wraps (and then the time drops below it). It is capped by the viewport, not by
// the column: a short bubble's column is narrower than its own name, and a percentage cap there
// truncated names to one letter.
export const BubbleHeaderPose = style({
'@media': {
[PHONE]: {
vars: { [portraitWidth]: PHONE_PORTRAIT },
alignSelf: 'flex-end',
// The band above the bubble where the portrait overhangs: the header sits at its foot, beside
// the portrait, so the overhang needs no margin of its own.
minHeight: toRem(PORTRAIT_OVERHANG),
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
margin: `0 ${toRem(14)} ${toRem(2)}`,
width: 'max-content',
maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`,
textAlign: 'right',
},
},
});
globalStyle(`${BubbleContentPose} > div`, {
display: 'block',
export const BubbleHeaderPoseAlt = style({
'@media': {
[PHONE]: {
alignSelf: 'flex-start',
textAlign: 'left',
},
},
});
// A portrait row with no header (nothing to fill the overhang band) keeps the band as a margin.
globalStyle(`${BubbleRowPortrait}:not(:has(.${BubbleHeaderPose}))`, {
'@media': {
[PHONE]: {
marginTop: toRem(PORTRAIT_OVERHANG),
},
},
});
export const BubbleColumnPose = style({
position: 'relative',
});
export const BubbleContentPose = style({
borderRadius: config.radii.R500,
borderTopLeftRadius: BUBBLE_CORNER,
'@media': {
[PHONE]: {
display: 'flow-root',
// A bubble with a portrait is at least portrait + a readable column wide, or the text
// beside the portrait collapses to a strip.
minWidth: `min(100%, calc(${PHONE_PORTRAIT} + ${PHONE_TEXT_FLOOR}))`,
borderTopLeftRadius: config.radii.R500,
padding: `${toRem(24)} ${toRem(14)} ${toRem(14)}`,
},
},
});
export const BubbleContentOwn = style({
@ -214,37 +267,51 @@ export const BubbleContentOwn = style({
borderTopRightRadius: BUBBLE_CORNER,
});
// The button is the visible part of the sprite (the part inside the bubble's corner); the image
// inside it is offset by the crop. The inline --pose-* fractions (of the image width) come from
// nestView; until the art's size is known the button is simply the whole image.
export const PoseButton = style({
float: 'left',
width: `var(--pose-bw, ${portraitWidth})`,
height: 'var(--pose-bh, auto)',
// Out into the padding on the top, bottom and outer side, so the bubble's edge is the crop.
margin: `calc(-1 * ${BUBBLE_PAD}) ${toRem(6)} calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD})`,
position: 'absolute',
top: 0,
left: `calc(-1 * (${PORTRAIT} + ${POSE_GUTTER}))`,
width: PORTRAIT,
height: PORTRAIT,
padding: 0,
border: 'none',
background: 'none',
cursor: 'pointer',
lineHeight: 0,
overflow: 'hidden',
shapeMargin: toRem(7),
transformOrigin: '50% 90%',
'@media': {
[PHONE]: {
position: 'static',
float: 'left',
width: PHONE_PORTRAIT,
height: 'auto',
margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`,
shapeMargin: toRem(7),
WebkitMaskImage: 'linear-gradient(#000 84%, transparent)',
maskImage: 'linear-gradient(#000 84%, transparent)',
},
},
});
export const PoseButtonAlt = style({
float: 'right',
margin: `calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) ${toRem(6)}`,
'@media': {
[PHONE]: {
float: 'right',
margin: `${toRem(-82)} ${toRem(-12)} 0 ${toRem(8)}`,
},
},
});
export const PosePortrait = style({
display: 'block',
width: portraitWidth,
maxWidth: 'none',
height: 'auto',
marginLeft: 'var(--pose-ix, 0)',
marginTop: 'var(--pose-iy, 0)',
width: '100%',
height: '100%',
objectFit: 'contain',
objectPosition: 'top',
'@media': {
[PHONE]: {
height: 'auto',
},
},
});
const pop = keyframes({
@ -335,12 +402,61 @@ export const MessageTextBody = recipe({
export type MessageTextBodyVariants = RecipeVariants<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`, {
listStylePosition: 'inside',
paddingLeft: 0,
'@media': {
[PHONE]: {
listStylePosition: 'inside',
paddingLeft: 0,
},
},
});
// While the background scene runs (html[data-scene]) a chat bubble is the one filled surface in

View file

@ -1,15 +1,14 @@
import React, { useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import * as css from '../layout/layout.css';
import { poseSrc, safeBox } from './poses';
import { alphaBounds, nestView, type Box } from './nest';
import { poseSrc } from './poses';
import { silhouette } from './silhouette';
type PoseAvatarProps = {
mind: string;
pose: string;
userId: string;
/** Nest in the bubble's top-right corner instead of the top-left. */
/** Float on the right on phones. */
alt?: boolean;
/** Play the first-appearance pop. */
enter?: boolean;
@ -17,110 +16,40 @@ type PoseAvatarProps = {
onClick?: React.MouseEventHandler<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);
// Art with no measured box: find its extent when it loads.
useEffect(() => {
setDerived(undefined);
const img = imgRef.current;
if (!img || measured) return undefined;
const find = () => {
if (!img.complete || img.naturalWidth === 0) return;
try {
setDerived(trace(img, src));
} catch {
setDerived(undefined);
}
};
find();
img.addEventListener('load', find);
return () => img.removeEventListener('load', find);
}, [src, measured]);
// The bubble text wraps around the portrait's silhouette. Retraced when the art loads, when its
// crop is known, and when its width changes with the viewport.
// On phones the bubble text wraps around the portrait's silhouette. Retraced when the art
// loads and when the portrait's width changes with the viewport.
useEffect(() => {
const img = imgRef.current;
if (!img) return undefined;
const retrace = () => {
const trace = () => {
if (!img.complete || img.naturalWidth === 0) return;
try {
const box = img.parentElement;
const style = getComputedStyle(img);
setShape(
silhouette(
img,
!alt,
box
? {
x: 0 - parseFloat(style.marginLeft || '0'),
y: 0 - parseFloat(style.marginTop || '0'),
width: box.clientWidth,
height: box.clientHeight,
}
: undefined
)
);
setShape(silhouette(img, !alt));
} catch {
setShape(undefined);
}
};
retrace();
img.addEventListener('load', retrace);
const resize = new ResizeObserver(retrace);
trace();
img.addEventListener('load', trace);
const resize = new ResizeObserver(trace);
resize.observe(img);
return () => {
img.removeEventListener('load', retrace);
img.removeEventListener('load', trace);
resize.disconnect();
};
}, [src, alt, view?.x, view?.y, view?.width, view?.height]);
// Crop fractions are of the image's width, so one set serves every displayed size.
const frac = (px: number) => `calc(${css.portraitWidth} * ${px / (art?.size[0] ?? 1)})`;
const cropStyle =
art && view
? ({
'--pose-bw': frac(view.width),
'--pose-bh': frac(view.height),
'--pose-ix': frac(-view.x),
'--pose-iy': frac(-view.y),
} as React.CSSProperties)
: undefined;
}, [src, alt]);
const Tag = onClick ? 'button' : 'span';
return (
<Tag
type={onClick ? 'button' : undefined}
className={classNames(css.PoseButton, alt && css.PoseButtonAlt, enter && css.EnterPortrait)}
style={{ ...cropStyle, shapeOutside: shape }}
style={{ shapeOutside: shape }}
data-user-id={userId}
data-pose={pose}
onClick={onClick}

View file

@ -1,72 +0,0 @@
import { describe, expect, it } from 'vitest';
import { alphaBounds, nestView } from './nest';
import boxes from './safeBoxes.json';
describe('nestView', () => {
// Aqua neutral: 560x374, safe box [144,22,492,317] (right margin 68, top 22).
it('crops half the right and top margins for a top-right nest', () => {
expect(nestView([144, 22, 492, 317], [560, 374], true)).toEqual({
x: 0,
y: 11,
width: 526,
height: 363,
});
});
it('crops the left margin instead for a top-left nest', () => {
expect(nestView([144, 22, 492, 317], [560, 374], false)).toEqual({
x: 72,
y: 11,
width: 488,
height: 363,
});
});
it('never crops the top of art already touching it (smug, headband at y=0)', () => {
const v = nestView([140, 0, 487, 241], [560, 374], true);
expect(v.y).toBe(0);
expect(v.height).toBe(374);
});
it('can take the whole margin', () => {
expect(nestView([10, 4, 90, 50], [100, 60], true, 1)).toEqual({
x: 0,
y: 4,
width: 90,
height: 56,
});
});
it('keeps every measured safe box intact at the default share', () => {
const aqua = boxes.aqua as Record<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();
});
});

View file

@ -1,58 +0,0 @@
// A pose nests into the corner of its bubble: the sprite's body sits in the bubble's padding and is
// cut off by the bubble's edge, so nothing hangs outside it.
/** [x0, y0, x1, y1] in image pixels, origin top-left, x1/y1 exclusive. */
export type Box = [number, number, number, number];
// Share of the room between the safe box and the image edge that the bubble edge may cut away.
// Aqua's measurement says all of it can go; half leaves the outline a little air.
export const CROP_SHARE = 0.5;
export type NestView = {
/** The visible region of the image, in pixels. */
x: number;
y: number;
width: number;
height: number;
};
// The part of the image that shows, for a sprite in the bubble's top-right (`right`) or top-left
// corner. Only the corner's two sides are cropped: the side towards the bubble's edge and the top.
// The far side and the bottom stay whole; the bubble's bottom edge cuts the bust.
export const nestView = (
safe: Box,
size: [number, number],
right: boolean,
share = CROP_SHARE
): NestView => {
const [w, h] = size;
const [x0, y0, x1] = safe;
const side = Math.round(share * (right ? w - x1 : x0));
const top = Math.round(share * y0);
const x = right ? 0 : side;
return { x, y: top, width: w - side, height: h - top };
};
// Bounds of the pixels whose alpha exceeds `min`, as a safe box for art with no measured one.
export const alphaBounds = (
data: ArrayLike<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;
};

View file

@ -1,6 +1,4 @@
import catalogue from './catalogue.json';
import safeBoxes from './safeBoxes.json';
import type { Box } from './nest';
// Pose labels travel in the event content under POSE_KEY. The catalogue
// (cast-art poses/catalogue.json, synced by `npm run sync:poses`) is the only
@ -42,14 +40,3 @@ export const resolvePose = (mind: string, label: unknown): string => {
export const poseSrc = (mind: string, pose: string, base = '/'): string =>
`${base}public/poses/${artPath(mind, pose) ?? artPath(mind, NEUTRAL)}`;
// Safe-crop boxes measured from the art's pixels (Aqua, `aqua-pose-crop-boxes-20261110`): the
// head and headphones plus outline, which the bubble's edge must not cut. [x0, y0, x1, y1, width,
// height] in image pixels. Art with no entry gets its box from the sprite's alpha at load time.
const boxes = safeBoxes as Record<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;
};

View file

@ -1,19 +0,0 @@
{
"aqua": {
"neutral": [144, 22, 492, 317, 560, 374],
"happy": [157, 22, 506, 317, 574, 374],
"amused": [154, 22, 500, 317, 562, 374],
"thinking": [154, 16, 500, 307, 562, 374],
"annoyed": [154, 4, 499, 282, 562, 374],
"surprised": [141, 5, 485, 282, 560, 374],
"concerned": [156, 4, 501, 283, 574, 374],
"fretful": [156, 16, 503, 305, 574, 374],
"weary": [161, 16, 509, 307, 624, 374],
"glare": [160, 5, 508, 282, 624, 374],
"smug": [140, 0, 487, 241, 560, 374],
"sigh": [154, 0, 500, 241, 574, 374],
"charging": [152, 0, 497, 241, 562, 374],
"delighted": [159, 0, 507, 241, 624, 374],
"content": [144, 16, 492, 306, 560, 374]
}
}

View file

@ -53,22 +53,4 @@ describe('silhouette', () => {
silhouette({ clientWidth: 0, clientHeight: 0 } as HTMLImageElement, true)
).toBeUndefined();
});
it('traces only the visible part of a cropped portrait, drawn at its offset', () => {
const drawImage = vi.fn();
const canvas = {
width: 0,
height: 0,
getContext: () => ({
canvas,
drawImage,
getImageData: () => ({ data: new Uint8ClampedArray(40 * 8 * 4) }),
}),
};
vi.stubGlobal('document', { createElement: () => canvas });
const img = { clientWidth: 100, clientHeight: 20 } as HTMLImageElement;
silhouette(img, true, { x: 0, y: 12, width: 40, height: 8 });
expect([canvas.width, canvas.height]).toEqual([40, 8]);
expect(drawImage).toHaveBeenCalledWith(img, 0, -12, 100, 20);
});
});

View file

@ -5,23 +5,14 @@ const ROW = 4;
// `shape-outside: url()` cannot do this: the browser sizes that image at its natural size and
// anchors it at the margin box, which the phone layout's negative top margin shifts down by the
// overhang, so the exclusion neither matches the art's size nor its place.
//
// `view` is the part of the displayed image that shows (the button's box, in displayed pixels):
// the portrait is cropped by the bubble, so the polygon is traced over what is left of the art.
export type View = { x: number; y: number; width: number; height: number };
export const silhouette = (
img: HTMLImageElement,
textOnRight: boolean,
view?: View
): string | undefined => {
const w = Math.round(view?.width ?? img.clientWidth);
const h = Math.round(view?.height ?? img.clientHeight);
export const silhouette = (img: HTMLImageElement, textOnRight: boolean): string | undefined => {
const w = Math.round(img.clientWidth);
const h = Math.round(img.clientHeight);
const ctx = w > 0 && h > 0 ? document.createElement('canvas').getContext('2d') : null;
if (!ctx) return undefined;
ctx.canvas.width = w;
ctx.canvas.height = h;
ctx.drawImage(img, 0 - (view?.x ?? 0), 0 - (view?.y ?? 0), img.clientWidth, img.clientHeight);
ctx.drawImage(img, 0, 0, w, h);
const { data } = ctx.getImageData(0, 0, w, h);
const outer = textOnRight ? 0 : w;
const edge: string[] = [];

View file

@ -1,51 +0,0 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
const WIDE = `screen and (min-width: ${MOBILE_BREAKPOINT + 1}px)`;
/** One column, bare ink on the scene. */
export const Column = style({
maxWidth: toRem(720),
width: '100%',
margin: '0 auto',
padding: config.space.S400,
display: 'flex',
flexDirection: 'column',
gap: config.space.S200,
});
/** A tappable text row: no border, no fill, at least 44 px tall. */
export const Row = style({
all: 'unset',
boxSizing: 'border-box',
cursor: 'pointer',
minHeight: 44,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: toRem(2),
color: color.Background.OnContainer,
':focus-visible': { textDecoration: 'underline' },
});
export const Back = style([Row, { minHeight: 44 }]);
export const Files = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S400,
});
/** Images may tile into a grid on wide screens only. */
export const FilesWide = style({
'@media': {
[WIDE]: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
alignItems: 'start',
},
},
});
export const Dim = style({ opacity: config.opacity.P300 });

View file

@ -1,141 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Box, Scroll, Text } from 'folds';
import { Room, RoomEvent } from 'matrix-js-sdk';
import { MediaControl } from '../../components/media';
import { AudioContent } from '../../components/message';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { IAudioContent } from '../../../types/matrix/common';
import { LINKIFY_OPTS, getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { markAsRead } from '../../utils/notifications';
import { getBlobSafeMimeType } from '../../utils/mimeTypes';
import { timeDayMonYear } from '../../utils/time';
import { getMemberDisplayName } from '../../utils/room';
import { Album, AlbumFile } from './buildAlbums';
import { useAlbums } from './useAlbums';
import * as css from './AlbumView.css';
/**
* Audio goes straight to AudioContent, skipping the chat Attachment frame and file-type badge: the
* title above it is the only label and the transport controls sit on the scene. Anything that is
* not playable audio falls through to the stock renderer (a file download).
*/
const playableAudio = (content: Record<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>
);
}

View file

@ -1,216 +0,0 @@
import { describe, expect, it } from 'vitest';
import { AlbumInput, ZoneEvent, buildAlbums } from './buildAlbums';
const root = (id: string, ts: number, over: Partial<ZoneEvent> = {}): 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);
});
});

View file

@ -1,121 +0,0 @@
// Pure: turns the thread roots of a file-zone room and each root's thread replies, in the JSON
// Synapse returns from GET /rooms/{roomId}/threads and GET /rooms/{roomId}/relations/{root}/m.thread,
// into albums. See docs/file-zone.md for the rulings this encodes.
/** The slice of a Matrix event this module reads. Every field may be missing. */
export type ZoneEvent = {
event_id?: string;
type?: string;
sender?: string;
origin_server_ts?: number;
content?: Record<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);
};

View file

@ -1,24 +0,0 @@
import { describe, expect, it } from 'vitest';
import { isFileZone } from './isFileZone';
const room = { roomId: '!f:x', aliases: ['#files:x'] };
describe('isFileZone', () => {
it('is true for the file-zone create type', () => {
expect(isFileZone({ ...room, createType: 'org.gamecult.file_zone' }, {})).toBe(true);
});
it('is false for other create types and for plain rooms', () => {
expect(isFileZone({ ...room, createType: 'm.space' }, {})).toBe(false);
expect(isFileZone({ ...room, createType: 'org.gamecult.other' }, {})).toBe(false);
expect(isFileZone({ ...room, createType: 'org.gamecult.file_zone.x' }, {})).toBe(false);
expect(isFileZone(room, {})).toBe(false);
expect(isFileZone(room, { fileZoneRooms: [] })).toBe(false);
});
it('is true when the room id or an alias is configured', () => {
expect(isFileZone(room, { fileZoneRooms: ['!f:x'] })).toBe(true);
expect(isFileZone(room, { fileZoneRooms: ['#other:x', '#files:x'] })).toBe(true);
});
it('is false when only another room is configured', () => {
expect(isFileZone(room, { fileZoneRooms: ['!g:x', '#g:x'] })).toBe(false);
});
});

View file

@ -1,14 +0,0 @@
export const FILE_ZONE_TYPE = 'org.gamecult.file_zone';
export type FileZoneRoom = {
roomId: string;
/** Canonical and alternative aliases. */
aliases?: string[];
/** The `type` of the room's m.room.create content. */
createType?: string;
};
/** A room is the file zone when its create type says so or the client config lists it. */
export const isFileZone = (room: FileZoneRoom, config: { fileZoneRooms?: string[] }): boolean =>
room.createType === FILE_ZONE_TYPE ||
!!config.fileZoneRooms?.some((r) => r === room.roomId || room.aliases?.includes(r));

View file

@ -1,29 +0,0 @@
import { describe, expect, it } from 'vitest';
import { mapLimit } from './mapLimit';
describe('mapLimit', () => {
it('keeps result order and never exceeds the limit', async () => {
let inFlight = 0;
let peak = 0;
const out = await mapLimit([5, 1, 4, 2, 3, 0], 2, async (n) => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((r) => {
setTimeout(r, n);
});
inFlight -= 1;
return n * 10;
});
expect(out).toEqual([50, 10, 40, 20, 30, 0]);
expect(peak).toBe(2);
});
it('handles an empty list and rejects when a call rejects', async () => {
expect(await mapLimit([], 3, async (n) => n)).toEqual([]);
await expect(
mapLimit([1, 2], 2, async (n) => {
if (n === 2) throw new Error('boom');
return n;
})
).rejects.toThrow('boom');
});
});

View file

@ -1,19 +0,0 @@
/** Like Promise.all(items.map(fn)) but with at most `limit` calls in flight; result order kept. */
export const mapLimit = async <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;
};

View file

@ -1,73 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { ClientPrefix, Method, Room } from 'matrix-js-sdk';
import { StateEvent } from '../../../types/matrix/room';
import { useStateEvent } from '../../hooks/useStateEvent';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import { Album, ZoneEvent, buildAlbums } from './buildAlbums';
import { isFileZone } from './isFileZone';
import { mapLimit } from './mapLimit';
const REPLY_FETCHES_IN_FLIGHT = 6;
const REFRESH_MS = 30_000;
type Chunk = { chunk?: ZoneEvent[] };
/** True when the room is the file zone: by its create type, or listed in the client config. */
export const useIsFileZone = (room: Room): boolean => {
const config = useClientConfig();
// Subscribes to alias changes (alt aliases live in the same event).
useStateEvent(room, StateEvent.RoomCanonicalAlias);
const createType = room.currentState.getStateEvents('m.room.create', '')?.getContent().type;
return isFileZone(
{
roomId: room.roomId,
aliases: [room.getCanonicalAlias(), ...room.getAltAliases()].filter((a): a is string => !!a),
createType,
},
config
);
};
export type AlbumsState = { loading: boolean; error: Error | null; albums: Album[] };
/**
* Slice 1 reads ONE page of each endpoint (threads: 100 roots; replies per root: 100). A zone with
* more albums or files than that is truncated; pagination is a later slice. Reads are made with
* the logged-in client, so the room must be unencrypted: encrypted events carry no msgtype and
* are skipped.
*/
export const useAlbums = (room: Room): AlbumsState => {
const mx = useMatrixClient();
const { roomId } = room;
const query = useQuery({
queryKey: ['albums', roomId],
queryFn: async (): Promise<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 ?? [],
};
};

View file

@ -1,16 +0,0 @@
import { describe, expect, it } from 'vitest';
import { zoneView } from './zoneView';
describe('zoneView', () => {
it('opens albums by default and the timeline for an event deep link', () => {
expect(zoneView(undefined, undefined)).toBe('albums');
expect(zoneView(undefined, '$e')).toBe('timeline');
});
it('lets an event link win over an earlier choice of albums', () => {
expect(zoneView('albums', '$e')).toBe('timeline');
});
it('honours the choice when there is no event link', () => {
expect(zoneView('albums', undefined)).toBe('albums');
expect(zoneView('timeline', undefined)).toBe('timeline');
});
});

View file

@ -1,5 +0,0 @@
export type ZoneView = 'albums' | 'timeline';
/** The view a file-zone room shows. A link to an event always wins; else the user's choice; else albums. */
export const zoneView = (choice: ZoneView | undefined, eventId: string | undefined): ZoneView =>
eventId ? 'timeline' : choice ?? 'albums';

View file

@ -16,16 +16,16 @@ export function Desk() {
const navigate = useNavigate();
const phone = useScreenSizeContext() === ScreenSize.Mobile;
const label = labelOf(mind);
const list = useMemo(() => deskFor(mind), [mind]);
const projection = useMemo(() => deskFor(mind), [mind]);
// Until the recompute finishes nothing is flagged; a card that fails is withdrawn when it lands.
const [unverified, setUnverified] = useState<ReadonlySet<string>>(new Set());
useEffect(() => {
let live = true;
unverifiedForks(list?.forks ?? []).then((bad) => live && setUnverified(bad));
unverifiedForks(projection?.questions ?? []).then((bad) => live && setUnverified(bad));
return () => {
live = false;
};
}, [list]);
}, [projection]);
return (
<Page>
@ -46,7 +46,13 @@ export function Desk() {
<Scroll hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<DeskView mind={mind} label={label} list={list} unverified={unverified} />
<DeskView
mind={mind}
label={label}
projection={projection}
now={Date.now()}
unverified={unverified}
/>
</PageContentCenter>
</PageContent>
</Scroll>

View file

@ -2,11 +2,12 @@ import React from 'react';
import { Link } from 'react-router-dom';
import { Text } from 'folds';
import { getDeskPath } from '../../pages/pathUtils';
import { countOpen } from './buildDesk';
import { deskFor } from './source';
/** The way into a Mind's desk, set in its navigator group header; nothing at all when nothing is open. */
export function DeskEntry({ mind }: { mind: string }) {
const open = deskFor(mind)?.open_count ?? 0;
const open = countOpen(deskFor(mind));
if (open === 0) return null;
return (
<Text as={Link} to={getDeskPath(mind)} size="T200" priority="300">

View file

@ -5,11 +5,11 @@ import { afterEach, describe, expect, it } from 'vitest';
import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize';
import { DeskEntry } from './DeskEntry';
import { DeskView, SIGN_IN_NOTE } from './DeskView';
import { STATES } from './fixtures';
import { NOW, STATES } from './fixtures';
import { setDeskSource } from './source';
const view = (
state: keyof typeof STATES | 'unpublished',
state: keyof typeof STATES,
size: ScreenSize,
mind = 'void',
unverified = new Set<string>()
@ -19,7 +19,8 @@ const view = (
<DeskView
mind={mind}
label="Void"
list={state === 'unpublished' ? undefined : STATES[state]}
projection={STATES[state]}
now={NOW}
unverified={unverified}
/>
</ScreenSizeProvider>
@ -44,6 +45,11 @@ describe('DeskView', () => {
expect(html.match(/\(recommended\)/g)).toHaveLength(1);
expect(html).toContain('B Second way (recommended)');
});
it('shows the depends line with the dependency status', () => {
expect(view('blocked', ScreenSize.Desktop)).toContain(
'Waits on: &quot;Needs the first one settled&quot; (open)'
);
});
it('keeps the note disabled and says why in a sentence the field points at', () => {
const html = view('noAnswers', ScreenSize.Desktop);
expect(html).toMatch(/<textarea[^>]* disabled=""/);
@ -51,31 +57,21 @@ describe('DeskView', () => {
expect(SIGN_IN_NOTE).toMatch(/sign-in/);
expect(html).toMatch(/aria-describedby="note-[^"]+-why"/);
});
it('shows the depends line as the text it is', () => {
expect(view('blocked', ScreenSize.Desktop)).toContain(
'Waits on: &quot;Needs the first one settled&quot;'
);
});
it('says No answers yet on a row without answers', () => {
expect(view('noAnswers', ScreenSize.Desktop)).toContain('No answers yet.');
});
it('shows how long a long-open question has waited', () => {
expect(view('longOpen', ScreenSize.Desktop)).toContain('Open for 21 days');
});
it('lists settled questions as recorded rulings with the ruling id', () => {
const html = view('settled', ScreenSize.Desktop);
expect(html).toContain('Recorded as rulings');
expect(html).toContain('Question done (ruling:done-1)');
expect(html).not.toContain('Question shut');
});
it('shows each outcome quietly, marks the counted answer, and renders a note only when sent', () => {
const html = view('answered', ScreenSize.Desktop);
expect(html).toContain(
'Answer First way &quot;Because it is first.&quot; (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 &quot;Let me in.&quot;');
expect(html).toContain('Answer &quot;Either, if measured first.&quot;');
expect(html.match(/\(counted\)/g)).toHaveLength(1);
expect(html).not.toContain('&quot;null&quot;');
it('says it may be out of date when stale, and says nothing of the kind when fresh', () => {
expect(view('stale', ScreenSize.Desktop)).toContain('may be out of date');
expect(view('noAnswers', ScreenSize.Desktop)).not.toContain('out of date');
});
it('says nothing is waiting when empty, and that nothing is published when it never arrived', () => {
expect(view('empty', ScreenSize.Desktop)).toContain('Nothing is waiting on you from Void.');
@ -83,6 +79,10 @@ describe('DeskView', () => {
'Void&#x27;s questions have not been published yet.'
);
});
it('shows hidden and erased rows as withheld stubs', () => {
expect(view('hidden', ScreenSize.Desktop)).toContain('hidden by Kiko');
expect(view('erased', ScreenSize.Desktop)).toContain('erased account');
});
it('seats the Mind in the booth with the pose for the desk mood', () => {
const html = view('noAnswers', ScreenSize.Desktop);
expect(html).toContain('data-pose="thinking"');
@ -100,18 +100,8 @@ describe('DeskView', () => {
const html = view('noAnswers', ScreenSize.Desktop, 'void', new Set(['c:question:plain']));
expect(html).toContain('does not match the hash it was published with');
expect(html).not.toContain('First way');
expect(html).toContain('1 open.');
expect(html).not.toContain('Nothing is waiting');
});
it('never claims an empty desk while the list says forks are open, whatever rows it could show', () => {
setDeskSource(() => undefined);
const html = renderToStaticMarkup(
<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');
expect(html).not.toContain('Just one fork');
expect(html).toContain('Nothing is waiting');
});
it('shows no booth for a Mind without art rather than another face', () => {
expect(view('noAnswers', ScreenSize.Desktop, 'nobody')).not.toContain('data-pose');
@ -135,16 +125,12 @@ describe('DeskEntry', () => {
expect(html).toContain('Desk · 2 open');
expect(html).toContain('href="/desk/void"');
});
it('reads the count the list states, not a count of the forks it carries', () => {
setDeskSource(() => ({ ...STATES.empty, open_count: 5 }));
expect(entry('void')).toContain('Desk · 5 open');
});
it('is absent at zero open, when nothing is published, and when only settled forks exist', () => {
setDeskSource(() => STATES.empty);
expect(entry('void')).toBe('');
setDeskSource(() => undefined);
expect(entry('void')).toBe('');
setDeskSource(() => ({ ...STATES.settled, open_count: 0 }));
setDeskSource(() => ({ ...STATES.settled, questions: STATES.settled.questions.slice(0, 2) }));
expect(entry('void')).toBe('');
});
});

View file

@ -3,8 +3,8 @@ import { Text } from 'folds';
import { PoseAvatar } from '../../components/message/pose/PoseAvatar';
import { MINDS } from '../../components/message/pose/poses';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { AnswerRow, Desk, buildDesk, DeskRow } from './buildDesk';
import { ForkList } from './forkContract';
import { AnswerRow, Desk, DeskRow, buildDesk } from './buildDesk';
import { DeskProjection } from './feedback';
import { deskMood } from './deskMood';
import * as css from './DeskView.css';
@ -13,32 +13,21 @@ export const SIGN_IN_NOTE = 'Answering opens with sign-in. Until then you can re
const date = (iso: string) => iso.slice(0, 10);
const OUTCOME: Record<AnswerRow['outcome'], string> = {
Counted: 'Answer',
Revision: 'Revision',
Retraction: 'Retraction',
Refused: 'Refused',
const age = (row: Extract<DeskRow, { kind: 'question' }>) => {
if (row.longOpen) return `Open for ${row.ageDays} days`;
if (row.ageDays === 0) return 'Asked today';
return row.ageDays === 1 ? 'Asked 1 day ago' : `Asked ${row.ageDays} days ago`;
};
/** One answer as served: its outcome, what it chose, its note when the server sent one, and whether it counts. */
const answerLine = (a: AnswerRow) =>
[
OUTCOME[a.outcome] + (a.code ? ` (${a.code})` : ''),
a.target && `of ${a.target}`,
a.choice,
a.note !== undefined && `"${a.note}"`,
]
.filter(Boolean)
.join(' ');
function Answers({ answers }: { answers: AnswerRow[] }) {
if (answers.length === 0) return <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 key={a.recordId} className={css.Option}>
{`${a.who}, ${a.kind.toLowerCase()}: `}
{a.withheld ?? [a.choice, a.text && `"${a.text}"`].filter(Boolean).join(' ')}
{` (${date(a.at)})`}
</li>
))}
</ul>
@ -58,6 +47,17 @@ function Row({ row }: { row: DeskRow }) {
</li>
);
}
if (row.kind === 'hidden') {
return (
<li className={css.Row}>
<p className={css.Quiet}>
{`A question here is hidden by ${row.hidden.by}, ${date(row.hidden.at)}, ${
row.hidden.reason
}.`}
</p>
</li>
);
}
const noteId = `note-${row.forkId}`;
return (
<li className={css.Row}>
@ -75,9 +75,10 @@ function Row({ row }: { row: DeskRow }) {
row.title
)}
</h3>
<p className={css.Quiet}>{age(row)}</p>
{open && (
<>
<p className={css.Body}>{row.context}</p>
<p className={css.Body}>{row.body}</p>
<ul className={css.Options}>
{row.options.map((o) => (
<li key={o.id} className={css.Option}>
@ -87,8 +88,8 @@ function Row({ row }: { row: DeskRow }) {
))}
</ul>
{row.depends.map((d) => (
<p key={d} className={css.Quiet}>
{`Waits on: "${d}"`}
<p key={d.text} className={css.Quiet}>
{`Waits on: "${d.text}"${d.status ? ` (${d.status})` : ''}`}
</p>
))}
<span id={`${noteId}-label`}>Your note</span>
@ -114,7 +115,9 @@ function Status({ desk, label }: { desk: Desk; label: string }) {
<p role="status">
{desk.counts.open === 0
? `Nothing is waiting on you from ${label}. `
: `${desk.counts.open} open.`}
: `${desk.counts.open} open. `}
{`Synced ${desk.syncedAt && date(desk.syncedAt)}.`}
{desk.stale && ' This may be out of date.'}
</p>
);
}
@ -123,16 +126,16 @@ type DeskViewProps = {
/** The Mind's key, as the navigator groups it. */
mind: string;
label: string;
list?: ForkList;
projection?: DeskProjection;
now: number;
/** Fork ids whose options_hash failed the recompute (optionsHash.ts). */
unverified?: ReadonlySet<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);
export function DeskView({ mind, label, projection, now, unverified }: DeskViewProps) {
const desk = buildDesk(projection, now, undefined, unverified);
const mood = deskMood(desk.counts.open, desk.counts.answered);
return (
<section aria-label={`${label}'s desk`}>
<Status desk={desk} label={label} />

View file

@ -1,105 +1,127 @@
import { describe, expect, it } from 'vitest';
import { buildDesk } from './buildDesk';
import { STATES, entry, list } from './fixtures';
import { LONG_OPEN_DAYS, buildDesk, countOpen } from './buildDesk';
import { NOW, STATES } from './fixtures';
const questions = (d: ReturnType<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,
it('says "not published" for a projection that never arrived, never "no questions"', () => {
expect(buildDesk(undefined, NOW).published).toBe(false);
expect(buildDesk(STATES.unpublished, NOW)).toMatchObject({
published: false,
open: [],
counts: { open: 0 },
counts: { open: 0, answered: 0 },
});
});
it('is published and empty, with its sync time, when nothing is open', () => {
const d = buildDesk(STATES.empty, NOW);
expect(d).toMatchObject({ published: true, open: [], counts: { open: 0 } });
expect(d.syncedAt).toBeDefined();
});
it('is stale only past thirty minutes since sync', () => {
expect(buildDesk(STATES.noAnswers, NOW).stale).toBe(false);
expect(buildDesk(STATES.stale, NOW).stale).toBe(true);
});
it('shows no answers as an empty list on an open row', () => {
expect(questions(buildDesk(STATES.noAnswers))[0].answers).toEqual([]);
expect(questions(buildDesk(STATES.noAnswers, NOW))[0].answers).toEqual([]);
});
it('flags a question open past fourteen days and keeps it listed', () => {
const [row] = questions(buildDesk(STATES.longOpen, NOW));
expect(row).toMatchObject({ ageDays: 21, longOpen: true });
expect(LONG_OPEN_DAYS).toBe(14);
expect(questions(buildDesk(STATES.noAnswers, NOW))[0].longOpen).toBe(false);
});
it('puts the long-open line past fourteen days exactly: day 14 is not long, day 15 is', () => {
const at = (days: number) => {
const p = structuredClone(STATES.noAnswers);
(p.questions[0] as { askedAt: string }).askedAt = new Date(
NOW - days * 86400000
).toISOString();
return questions(buildDesk(p, NOW))[0].longOpen;
};
expect([at(14), at(15)]).toEqual([false, true]);
});
it('resolves the recommendation to its option, marks only that option, and never preselects', () => {
const [row] = questions(buildDesk(STATES.noAnswers));
const [row] = questions(buildDesk(STATES.noAnswers, NOW));
expect(row.recommended).toBe('Second way');
expect(row.options.filter((o) => o.recommended).map((o) => o.id)).toEqual(['B']);
});
it('leaves the recommendation unresolved when it names no option', () => {
const [row] = questions(buildDesk(list([entry('x', { recommendation: 'Z' })])));
const p = structuredClone(STATES.noAnswers);
(p.questions[0] as { recommendation?: string }).recommendation = 'Z';
const [row] = questions(buildDesk(p, NOW));
expect(row.recommended).toBeUndefined();
expect(row.options.some((o) => o.recommended)).toBe(false);
});
it('carries the depends lines as the text they are', () => {
const rows = questions(buildDesk(STATES.blocked));
it('carries the status of a cited dependency, and none for one that cites no question', () => {
const rows = questions(buildDesk(STATES.blocked, NOW));
expect(rows.find((r) => r.forkId.endsWith('second'))?.depends).toEqual([
'Needs the first one settled',
{ text: 'Needs the first one settled', status: 'open' },
]);
expect(rows.find((r) => r.forkId.endsWith('first'))?.depends).toEqual([]);
});
it('lists a closed card with a ruling as recorded, not open; omits one closed with no ruling', () => {
const d = buildDesk(STATES.settled);
it('lists a settled question under recorded rulings with its ruling id, not as open; drops a closed one', () => {
const d = buildDesk(STATES.settled, NOW);
expect(d.recorded).toEqual([
{ forkId: 'c:question:done', title: 'Question done', rulingId: 'ruling:done-1' },
]);
expect(d.open.map((r) => r.forkId)).toEqual(['c:question:live']);
});
it('keeps the served order (newest first by card.order) and sorts nothing itself', () => {
const served = list([entry('late', { order: 9 }), entry('early', { order: 2 })]);
expect(buildDesk(served).open.map((r) => r.forkId)).toEqual([
'c:question:late',
'c:question:early',
]);
const odd = list([entry('a', { order: 2 }), entry('b', { order: 9 })]);
expect(buildDesk(odd).open.map((r) => r.forkId)).toEqual(['c:question:a', 'c:question:b']);
});
it("takes the open count from the list's open_count, not from the rows it could verify or carries", () => {
const d = buildDesk({ ...list([entry('only')]), open_count: 4 }, new Set(['c:question:only']));
expect(d.counts.open).toBe(4);
});
it('marks the answer the record names as counted, and no other', () => {
const [row] = questions(buildDesk(STATES.answered));
expect(row.answers.filter((a) => a.counted).map((a) => a.answerId)).toEqual(['n1']);
});
it('follows the record when a revision is the counted answer: nothing is re-derived', () => {
const e = STATES.answered.forks[0];
const moved = list([{ ...e, record: { ...e.record, counted_answer_id: 'n2' } }]);
expect(
questions(buildDesk(moved))[0]
.answers.filter((a) => a.counted)
.map((a) => a.answerId)
).toEqual(['n2']);
});
it('keeps answers in the served order and carries each outcome, target and refusal code', () => {
const [row] = questions(buildDesk(STATES.answered));
expect(row.answers.map((a) => [a.answerId, a.outcome, a.target, a.code])).toEqual([
['n1', 'Counted', undefined, undefined],
['n2', 'Revision', 'n1', undefined],
['n3', 'Retraction', 'n2', undefined],
['n4', 'Refused', undefined, 'not_a_decider'],
['n5', 'Counted', undefined, undefined],
it('orders open rows oldest first', () => {
const p = structuredClone(STATES.blocked);
(p.questions[0] as { askedAt: string }).askedAt = new Date(NOW).toISOString();
expect(buildDesk(p, NOW).open.map((r) => r.forkId)).toEqual([
'c:question:second',
'c:question:first',
]);
});
it('shows a choice as option text, passes an unknown id through, and leaves a null choice absent', () => {
const [row] = questions(buildDesk(STATES.answered));
expect(row.answers.map((a) => a.choice)).toEqual([
'First way',
'Third way',
undefined,
'Second way',
undefined,
]);
const odd = list([
entry('odd', {}, [{ ...STATES.answered.forks[0].record.answers[0], choice: 'Q' }]),
]);
expect(questions(buildDesk(odd))[0].answers[0].choice).toBe('Q');
it('folds earlier answers by one account under its latest and shows choice as option text', () => {
const [row] = questions(buildDesk(STATES.answered, NOW));
expect(row.answers).toHaveLength(2);
const first = row.answers.find((a) => a.recordId === 'plain.a2');
expect(first).toMatchObject({ choice: 'Third way', text: 'On reflection, third.' });
expect(row.answers.some((a) => a.recordId === 'plain.a1')).toBe(false);
});
it('carries a note verbatim when present and leaves it absent when null', () => {
const [row] = questions(buildDesk(STATES.answered));
expect(row.answers[0].note).toBe('Because it is first.');
expect(row.answers[1].note).toBeUndefined();
it('lists answers oldest first and tallies nothing', () => {
const [row] = questions(buildDesk(STATES.answered, NOW));
const times = row.answers.map((a) => a.at);
expect([...times].sort()).toEqual(times);
expect(Object.keys(buildDesk(STATES.answered, NOW).counts).sort()).toEqual([
'answered',
'open',
]);
});
it('withdraws an unverified open card and does not count it', () => {
const d = buildDesk(STATES.noAnswers, new Set(['c:question:plain']));
expect(d.open).toEqual([{ kind: 'unverified', forkId: 'c:question:plain' }]);
it('withholds a hidden answer and a hidden question, naming who hid them', () => {
const d = buildDesk(STATES.hidden, NOW);
expect(d.open.find((r) => r.kind === 'hidden')).toMatchObject({
hidden: { by: 'Kiko', reason: 'PrivateMatter' },
});
const [row] = questions(d);
expect(row.answers[0]).toMatchObject({ choice: undefined, text: undefined });
expect(row.answers[0].withheld).toMatch(/^Hidden by Kiko, 2026-11-\d\d, Spam$/);
});
it('does not count a hidden question as open', () => {
expect(buildDesk(STATES.hidden, NOW).counts.open).toBe(1);
expect(countOpen(STATES.hidden)).toBe(1);
});
it('shows an erased author as "erased account" with no name or content, keeping kind and time', () => {
const [row] = questions(buildDesk(STATES.erased, NOW));
expect(row.answers[0]).toMatchObject({
who: 'erased account',
kind: 'Member',
choice: undefined,
text: undefined,
});
expect(row.answers[0].withheld).toBeDefined();
expect(row.answers[0].at).toBeDefined();
});
it('counts answered only for the viewer, so a signed-out desk has none', () => {
expect(buildDesk(STATES.answered, NOW).counts.answered).toBe(0);
expect(buildDesk(STATES.answered, NOW, 'acct-1').counts.answered).toBe(1);
expect(buildDesk(STATES.answered, NOW, 'acct-9').counts.answered).toBe(0);
});
it('does not count the viewer as answered when their only answer was hidden', () => {
expect(buildDesk(STATES.hidden, NOW, 'acct-1').counts.answered).toBe(0);
});
});

View file

@ -1,95 +1,164 @@
import { DeskAnswer, ForkEntry, ForkList } from './forkContract';
import {
Answer,
DeskProjection,
Hidden,
Question,
QuestionStatus,
isHiddenQuestion,
} from './feedback';
/** Past two heartbeats the projection may be out of date (provisional, doc section 8). */
export const STALE_MS = 30 * 60 * 1000;
/** Past this a question shows how long it has waited; it is never demoted or hidden (provisional). */
export const LONG_OPEN_DAYS = 14;
const DAY_MS = 24 * 60 * 60 * 1000;
export type AnswerRow = {
answerId: string;
outcome: DeskAnswer['outcome'];
recordId: string;
/** Never a handle: the name at answer time, or the words for an account that has none. */
who: string;
kind: Answer['kind'];
/** The option's text for a choice. */
choice?: string;
/** Verbatim, and only when the server sent one. */
note?: string;
/** The answer a Revision or Retraction points at. */
target?: string;
/** A refusal's code. */
code?: string;
text?: string;
at: string;
/** The record's counted answer: marked as served, never derived here. */
counted: boolean;
/** Why the content is not shown: hidden by the Mind, or the author's account was erased. */
withheld?: string;
};
export type DeskRow =
| { kind: 'hidden'; forkId: string; hidden: Hidden }
/** Its published options_hash is not the hash of what it shows: neither shown nor counted. */
| { kind: 'unverified'; forkId: string }
| {
kind: 'question';
forkId: string;
title: string;
context: string;
body: string;
options: { id: string; text: string; recommended: boolean }[];
/** The recommendation resolved to its option's text; absent if it names no option. */
recommended?: string;
depends: string[];
/** In admission order, as served. */
depends: { text: string; status?: QuestionStatus['state'] }[];
ageDays: number;
longOpen: boolean;
answers: AnswerRow[];
};
export type Desk = {
/** False until the list first arrives: "not published yet", never "no questions". */
/** False until the projection first arrives: "not published yet", never "no questions". */
published: boolean;
/** Oldest first, by the card's admission ordinal. */
syncedAt?: string;
stale: boolean;
/** Oldest first. */
open: DeskRow[];
/** Closed with a ruling id. A card closed with no ruling (withdrawn) is not listed. */
/** Settled questions with the Mind's ruling id. Closed (withdrawn) questions are not listed. */
recorded: { forkId: string; title: string; rulingId: string }[];
/** The list's own open_count: the one count the navigator, the status line and the booth share. */
counts: { open: number };
/** Derived for the booth mood. The page tallies nothing about answers. */
counts: { open: number; answered: number };
};
const answerRow = (a: DeskAnswer, entry: ForkEntry): AnswerRow => ({
answerId: a.answer_id,
outcome: a.outcome,
choice:
a.choice === null
? undefined
: entry.card.options.find((o) => o.id === a.choice)?.text ?? a.choice,
note: a.note ?? undefined,
target: a.of ?? a.retracts ?? undefined,
code: a.code ?? undefined,
at: a.answered_at,
counted: entry.record.counted_answer_id === a.answer_id,
});
const ageDays = (iso: string, now: number) =>
Math.max(0, Math.floor((now - Date.parse(iso)) / DAY_MS));
export const buildDesk = (
list: ForkList | undefined,
unverified: ReadonlySet<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)),
});
});
const answerRow = (a: Answer, q: Question): AnswerRow => {
let withheld: string | undefined;
if (a.hidden)
withheld = `Hidden by ${a.hidden.by}, ${a.hidden.at.slice(0, 10)}, ${a.hidden.reason}`;
else if (a.erased) withheld = 'Author erased their account';
const choice = withheld ? undefined : a.answer?.choice;
return {
published: true,
open,
recorded,
counts: { open: list.open_count },
recordId: a.recordId,
who: a.erased ? 'erased account' : a.displayName ?? 'an account',
kind: a.kind,
choice:
choice === undefined ? undefined : q.options.find((o) => o.id === choice)?.text ?? choice,
text: withheld ? undefined : a.answer?.text,
at: a.answered_at,
withheld,
};
};
/** Earlier answers by the same account fold under their latest; the page never counts them. */
const standing = (answers: Answer[]): Answer[] => {
const latest = new Map<string, Answer>();
answers.forEach((a) => {
const key = a.answerer ?? a.recordId;
const held = latest.get(key);
if (!held || held.answered_at <= a.answered_at) latest.set(key, a);
});
return [...latest.values()].sort((a, b) => a.answered_at.localeCompare(b.answered_at));
};
/** Open questions the page would list: the one rule the navigator's count and the desk share. */
export const countOpen = (p?: DeskProjection): number =>
p?.questions.filter((q) => !isHiddenQuestion(q) && q.status.state === 'open').length ?? 0;
export const buildDesk = (
p: DeskProjection | undefined,
now: number,
viewerId?: string,
unverified: ReadonlySet<string> = new Set()
): Desk => {
if (!p?.syncedAt) {
return {
published: false,
stale: false,
open: [],
recorded: [],
counts: { open: 0, answered: 0 },
};
}
const known = new Map<string, Question>();
p.questions.forEach((q) => !isHiddenQuestion(q) && known.set(q.fork_id, q));
const open: DeskRow[] = [];
const recorded: Desk['recorded'] = [];
let answered = 0;
[...p.questions]
.sort((a, b) => {
const at = (q: typeof a) => (isHiddenQuestion(q) ? '' : q.askedAt);
return at(a).localeCompare(at(b));
})
.forEach((q) => {
if (isHiddenQuestion(q)) {
open.push({ kind: 'hidden', forkId: q.fork_id, hidden: q.hidden });
return;
}
if (q.status.state === 'answered') {
recorded.push({ forkId: q.fork_id, title: q.title, rulingId: q.status.rulingId });
return;
}
if (q.status.state !== 'open') return;
if (unverified.has(q.fork_id)) {
open.push({ kind: 'unverified', forkId: q.fork_id });
return;
}
const mine = standing(p.answers.filter((a) => a.fork_id === q.fork_id));
if (viewerId && mine.some((a) => a.answerer === viewerId && !a.hidden)) answered += 1;
const days = ageDays(q.askedAt, now);
open.push({
kind: 'question',
forkId: q.fork_id,
title: q.title,
body: q.body,
options: q.options.map((o) => ({ ...o, recommended: o.id === q.recommendation })),
recommended: q.options.find((o) => o.id === q.recommendation)?.text,
depends: q.dependsOn.map((d) => ({
text: d.text,
status: d.questionId ? known.get(d.questionId)?.status.state : undefined,
})),
ageDays: days,
longOpen: days > LONG_OPEN_DAYS,
answers: mine.map((a) => answerRow(a, q)),
});
});
return {
published: true,
syncedAt: p.syncedAt,
stale: now - Date.parse(p.syncedAt) > STALE_MS,
open,
recorded,
counts: { open: open.filter((r) => r.kind === 'question').length, answered },
};
};

View file

@ -0,0 +1,70 @@
/**
* The desk's typed view of the feedback projections in docs/mind-feedback-pages.md (sections 5 and 12):
* org.gamecult.feedback.question.v0 and org.gamecult.feedback.answer.v0. Bifrost serves them; the page
* reads them and stores nothing. `answerer`, `kind` and `weightPermille` are the receiver's stamp from the
* verified session; the client never supplies them, so nothing here has a write shape.
*/
export type Hidden = { by: string; at: string; reason: string };
/** The status is derived in the owning Mind; the page shows it and never writes it. */
export type QuestionStatus =
| { state: 'open' }
| { state: 'answered'; rulingId: string }
| { state: 'closed' };
export type Question = {
/** `<campaign>:question:<label>`. */
fork_id: string;
owner: string;
title: string;
body: string;
/** `id` is the option's label. */
options: { id: string; text: string }[];
/** An option label; never nil. Never preselected. */
recommendation: string;
/** `questionId` only where the line cites one question verbatim. */
dependsOn: { text: string; questionId?: string }[];
/** An ISO instant: the question's receipt `committed_at`. */
askedAt: string;
status: QuestionStatus;
/**
* Publisher's hash of what is shown. The desk recomputes it (optionsHash.ts, pinned to the CultLib vectors)
* and refuses to show or answer a card whose hash differs. The receiver rejects an answer carrying a hash
* it did not publish; sending is slice 2.
*/
options_hash: string;
};
/** A hidden question keeps a stub; title, body, options and answers are withheld. */
export type HiddenQuestion = { fork_id: string; hidden: Hidden };
export type Answer = {
/** `<question-label>.a<N>`. */
recordId: string;
fork_id: string;
options_hash: string;
/** Bifrost account id; absent once the account is erased. Receiver-set. */
answerer?: string;
/** Name and kind as they were at the moment of the answer; the name is gone once erased. Receiver-set. */
displayName?: string;
kind: 'Guest' | 'User' | 'Patron' | 'Member' | 'Mind';
weightPermille: number;
/** An option label, free text, or both. Absent while hidden or erased. */
answer?: { choice?: string; text?: string };
/** ISO instant the receiver accepted it. */
answered_at: string;
hidden?: Hidden;
erased?: boolean;
};
/** What the page holds for one Mind's desk. `syncedAt` is absent until the projection first arrives. */
export type DeskProjection = {
mind: string;
syncedAt?: string;
questions: (Question | HiddenQuestion)[];
answers: Answer[];
};
export const isHiddenQuestion = (q: Question | HiddenQuestion): q is HiddenQuestion =>
'hidden' in q;

View file

@ -1,116 +1,100 @@
import { DeskAnswer, ForkCard, ForkEntry, ForkList } from './forkContract';
import { Answer, DeskProjection, Question } from './feedback';
/** 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',
/** The clock every fixture is written against. */
export const NOW = Date.parse('2026-11-10T12:00:00Z');
const ago = (ms: number) => new Date(NOW - ms).toISOString();
const MIN = 60 * 1000;
const DAY = 24 * 60 * MIN;
const q = (label: string, over: Partial<Question> = {}): Question => ({
fork_id: `c:question:${label}`,
owner: 'mind',
title: `Question ${label}`,
context: `Context for ${label}.`,
body: `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,
dependsOn: [],
askedAt: ago(DAY),
status: { state: 'open' },
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}`,
const a = (label: string, n: number, over: Partial<Answer> = {}): Answer => ({
recordId: `${label}.a${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}`,
answerer: `acct-${n}`,
displayName: `Person ${n}`,
kind: 'Member',
weightPermille: 1000,
answer: { choice: 'A' },
answered_at: ago(DAY / 2),
...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',
const desk = (
questions: DeskProjection['questions'],
answers: Answer[] = [],
syncedAt = ago(2 * MIN)
) => ({
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,
syncedAt,
questions,
answers,
});
/** One list per state the desk shows. */
/** One projection per state of doc section 8, plus the hidden and erased rows. */
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'] }),
/** The projection never arrived. */
unpublished: { mind: 'mind', questions: [], answers: [] } as DeskProjection,
empty: desk([]),
noAnswers: desk([q('plain')]),
stale: desk([q('plain')], [], ago(45 * MIN)),
longOpen: desk([q('old', { askedAt: ago(21 * DAY) })]),
blocked: desk([
q('first'),
q('second', {
dependsOn: [{ text: 'Needs the first one settled', questionId: 'c:question:first' }],
}),
]),
settled: list([
entry('done', { status: 'closed', ruling: 'ruling:done-1', ruled_from: ['n1'] }),
entry('shut', { status: 'closed' }),
entry('live'),
settled: desk([
q('done', { status: { state: 'answered', rulingId: 'ruling:done-1' } }),
q('shut', { status: { state: 'closed' } }),
q('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>;
answered: desk(
[q('plain')],
[
a('plain', 1, { answered_at: ago(DAY / 2) }),
a('plain', 2, {
answerer: 'acct-1',
answer: { choice: 'C', text: 'On reflection, third.' },
answered_at: ago(DAY / 4),
}),
a('plain', 3, { kind: 'Mind', answer: { text: 'Either, if measured first.' } }),
]
),
hidden: desk(
[
q('plain'),
{ fork_id: 'c:question:gone', hidden: { by: 'Kiko', at: ago(DAY), reason: 'PrivateMatter' } },
],
[a('plain', 1, { hidden: { by: 'Kiko', at: ago(DAY / 3), reason: 'Spam' }, answer: undefined })]
),
erased: desk(
[q('plain')],
[
a('plain', 1, {
erased: true,
answerer: undefined,
displayName: undefined,
answer: undefined,
}),
]
),
} satisfies Record<string, DeskProjection>;

View file

@ -1,92 +0,0 @@
/**
* 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;
};

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import vectors from './optionsHashVectors.json';
import { entry } from './fixtures';
import { ForkCard } from './forkContract';
import { STATES } from './fixtures';
import { Question } from './feedback';
import { canonicalShown, optionsHash, shownOf, unverifiedForks } from './optionsHash';
describe('options_hash against the CultLib vectors', () => {
@ -17,34 +17,34 @@ describe('options_hash against the CultLib vectors', () => {
});
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)) } };
const card = async (): Promise<Question> => {
const q = structuredClone(STATES.noAnswers.questions[0]) as Question;
return { ...q, options_hash: await optionsHash(shownOf(q)) };
};
it('passes a card whose hash is the hash of what it shows', async () => {
expect(await unverifiedForks([await sealed()])).toEqual(new Set());
expect(await unverifiedForks([await card()])).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>[] = [
it('flags a card whose shown text, option, recommendation or depends changed after hashing', async () => {
const q = await card();
const edits: Partial<Question>[] = [
{ title: 'Other' },
{ context: 'Other context.' },
{ options: [...e.card.options].reverse() },
{ body: 'Other context.' },
{ options: [...q.options].reverse() },
{ recommendation: 'A' },
{ depends: ['new line'] },
{ dependsOn: [{ text: '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]))
edits.map(async (e) =>
expect(await unverifiedForks([{ ...q, ...e }]), JSON.stringify(Object.keys(e))).toEqual(
new Set([q.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());
it('does not read fork_id, status or asked time, which are not shown members', async () => {
const q = await card();
expect(
await unverifiedForks([{ ...q, fork_id: 'x:question:y', askedAt: '2020-01-01T00:00:00Z' }])
).toEqual(new Set());
});
});

View file

@ -1,4 +1,4 @@
import { ForkCard, ForkEntry } from './forkContract';
import { DeskProjection, Question, isHiddenQuestion } from './feedback';
// 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.
@ -17,7 +17,10 @@ const jstr = (s: string): string => {
return `${out}"`;
};
export type Shown = Pick<ForkCard, 'title' | 'context' | 'options' | 'recommendation' | 'depends'>;
export type Shown = Pick<Question, 'title' | 'options' | 'recommendation'> & {
context: string;
depends: string[];
};
/** The five shown members as RFC 8785 JSON, keys already in sorted order. */
export const canonicalShown = (c: Shown): string =>
@ -25,12 +28,12 @@ export const canonicalShown = (c: Shown): string =>
.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 shownOf = (q: Question): Shown => ({
title: q.title,
context: q.body,
options: q.options,
recommendation: q.recommendation,
depends: q.dependsOn.map((d) => d.text),
});
export const optionsHash = async (c: Shown): Promise<string> => {
@ -40,11 +43,14 @@ export const optionsHash = async (c: Shown): Promise<string> => {
};
/** Fork ids whose published `options_hash` is not the hash of what the card shows. */
export const unverifiedForks = async (forks: ForkEntry[]): Promise<Set<string>> => {
export const unverifiedForks = async (
questions: DeskProjection['questions']
): 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);
questions.map(async (q) => {
if (isHiddenQuestion(q)) return;
if ((await optionsHash(shownOf(q))) !== q.options_hash) bad.add(q.fork_id);
})
);
return bad;

View file

@ -1,320 +1,346 @@
{
"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": [
"source": "GameCult/CultLib contracts/cultnet/governance/fork-card-vectors.json, `optionsHash` member only, values unchanged (re-escaped to ASCII so TypeScript parses it), from CultLib a499c581 (file last changed in 8ca1d184). 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": [
{
"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"
"id": "yes",
"text": "Share it."
},
{
"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"
"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"
}
]
}

View file

@ -1,13 +1,13 @@
import { ForkList } from './forkContract';
import { DeskProjection } from './feedback';
type DeskSource = (mind: string) => ForkList | undefined;
type DeskSource = (mind: string) => DeskProjection | 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.
// Bifrost's projection 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);
export const deskFor = (mind: string): DeskProjection | undefined => source(mind);

View file

@ -1,32 +0,0 @@
// @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);
});
});

View file

@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useRef } from 'react';
import { Box, Text, config } from 'folds';
import { EventType } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
@ -23,10 +23,6 @@ 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 => {
@ -68,12 +64,6 @@ 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();
@ -88,7 +78,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
window,
useCallback(
(evt) => {
if (fileZone || editableActiveElement()) return;
if (editableActiveElement()) return;
const portalContainer = document.getElementById('portalContainer');
if (portalContainer && portalContainer.children.length > 0) {
return;
@ -97,35 +87,13 @@ export function RoomView({ eventId }: { eventId?: string }) {
ReactEditor.focus(editor);
}
},
[editor, fileZone]
[editor]
)
);
// 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}

View file

@ -1168,6 +1168,7 @@ export const Message = as<'div', MessageProps>(
<BubbleLayout
own={own}
pose={mind ? poseJSX : undefined}
alt={poseAlt}
enterDelay={enterDelay}
before={avatarJSX}
header={headerJSX}

View file

@ -1,20 +0,0 @@
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');
});
});

View file

@ -7,11 +7,6 @@ export type HashRouterConfig = {
basename?: string;
};
export type RegistrationConfig = {
enabled?: boolean;
url?: string;
};
export type ClientConfig = {
defaultHomeserver?: number;
homeserverList?: string[];
@ -31,13 +26,6 @@ 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;
};
@ -53,7 +41,7 @@ export function useClientConfig(): ClientConfig {
}
export const clientDefaultServer = (clientConfig: ClientConfig): string =>
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'gamecult.org';
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org';
export const clientAllowedServer = (clientConfig: ClientConfig, server: string): boolean => {
const { homeserverList, allowCustomHomeservers } = clientConfig;

View file

@ -9,13 +9,16 @@ import {
} from 'react-router-dom';
import { ClientConfig } from '../hooks/useClientConfig';
import { authRoutes } from './auth/authRoutes';
import { AuthLayout, Login, Register, ResetPassword } from './auth';
import {
DIRECT_PATH,
DESK_PATH,
EXPLORE_PATH,
HOME_PATH,
LOGIN_PATH,
INBOX_PATH,
REGISTER_PATH,
RESET_PASSWORD_PATH,
SPACE_PATH,
_CREATE_PATH,
_FEATURED_PATH,
@ -54,7 +57,7 @@ import { ScreenSize } from '../hooks/useScreenSize';
import { MobileFriendlyPageNav } from './MobileFriendly';
import { ClientInitStorageAtom } from './client/ClientInitStorageAtom';
import { ClientNonUIFeatures } from './client/ClientNonUIFeatures';
import { AuthRouteThemeManager } from './ThemeManager';
import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager';
import { ReceiveSelfDeviceVerification } from '../components/DeviceVerification';
import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
import { RoomSettingsRenderer } from '../features/room-settings';
@ -85,7 +88,25 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
return redirect(getLoginPath());
}}
/>
{authRoutes()}
<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>
<Route
loader={() => {

View file

@ -1,22 +0,0 @@
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>
);
}

View file

@ -1,54 +0,0 @@
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>
);
}

View file

@ -57,14 +57,7 @@ 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'
| 'password-weak'
| 'password-short'
| 'unknown';
export type RegisterTrouble = 'closed' | 'rate-limited' | 'invalid' | 'unsupported';
export const registerTroubleMessage = (trouble: RegisterTrouble, server: string): string => {
switch (trouble) {
@ -74,12 +67,6 @@ 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.`;

View file

@ -1,40 +0,0 @@
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 />
</>
}
/>
</>
);

View file

@ -1,5 +1,4 @@
export * from './AuthLayout';
export * from './login';
export * from './register';
export * from './SignedUp';
export * from './reset-password';

View file

@ -9,8 +9,6 @@ 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';
@ -39,8 +37,7 @@ const useLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchPar
export function Login() {
const server = useAuthServer();
const config = useClientConfig();
const { hashRouter, previewDemo } = config;
const { hashRouter, previewDemo } = useClientConfig();
const { loginFlows } = useAuthFlows();
const [searchParams] = useSearchParams();
const loginSearchParams = useLoginSearchParams(searchParams);
@ -93,13 +90,9 @@ export function Login() {
<span data-spacing-node />
</>
)}
{registrationEntry(config) ? (
<RegisterEntry />
) : (
<Text align="Center">
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
</Text>
)}
<Text align="Center">
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
</Text>
</Box>
);
}

View file

@ -30,15 +30,7 @@ import {
import { useUIACompleted, useUIAFlow, useUIAParams } from '../../../hooks/useUIAFlows';
import { AsyncState, AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
import {
RegisterError,
RegisterResult,
register,
registerErrorTrouble,
useRegisterComplete,
} from './registerUtil';
import { registerTroubleMessage } from '../authMessages';
import { useAuthServer } from '../../../hooks/useAuthServer';
import { RegisterError, RegisterResult, register, useRegisterComplete } from './registerUtil';
import { FieldError } from '../FiledError';
import { TrainingNotice } from '../TrainingNotice';
import {
@ -193,7 +185,6 @@ 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]);
@ -216,9 +207,6 @@ 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) => {
@ -310,10 +298,20 @@ export function PasswordRegisterForm({
required
/>
{registerError?.errcode === RegisterError.PasswordWeak && (
<FieldError message={registerTroubleMessage('password-weak', server)} />
<FieldError
message={
registerError.data.error ??
'Weak Password. Password rejected by server please choosing more strong Password.'
}
/>
)}
{registerError?.errcode === RegisterError.PasswordShort && (
<FieldError message={registerTroubleMessage('password-short', server)} />
<FieldError
message={
registerError.data.error ??
'Short Password. Password rejected by server please choosing more long Password.'
}
/>
)}
</Box>
<Box direction="Column" gap="100">
@ -380,7 +378,18 @@ export function PasswordRegisterForm({
</Text>
</Box>
)}
{formTrouble && <FieldError message={registerTroubleMessage(formTrouble, server)} />}
{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.'} />
)}
<span data-spacing-node />
<TrainingNotice checked={trainingAck} onToggle={() => setTrainingAck((ack) => !ack)} />
<Button variant="Primary" size="500" type="submit" disabled={!trainingAck}>

View file

@ -1,37 +0,0 @@
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'
);
});
});

View file

@ -1,26 +0,0 @@
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>
);
}

View file

@ -1,39 +0,0 @@
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));
});
});

View file

@ -17,7 +17,6 @@ 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',
@ -31,27 +30,6 @@ 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;

View file

@ -1,57 +0,0 @@
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();
});
});

View file

@ -1,32 +0,0 @@
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,
};
};

View file

@ -17,8 +17,6 @@ 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/';

View file

@ -1,51 +0,0 @@
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');
});
});

View file

@ -28,13 +28,6 @@ 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' };

View file

@ -1,86 +1,15 @@
import { describe, expect, it } from 'vitest';
import { buildDesk } from '../app/features/desk/buildDesk';
import { ForkList } from '../app/features/desk/forkContract';
import { buildDesk, countOpen } from '../app/features/desk/buildDesk';
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('seed the Minds whose groups the demo has, with open forks and a settled one', () => {
const nibu = demoDesk('nibu');
expect(countOpen(nibu)).toBe(2);
expect(buildDesk(nibu, Date.now()).recorded).toHaveLength(1);
expect(countOpen(demoDesk('eureka'))).toBe(1);
});
it('leave other Minds unpublished, so their group shows no desk entry', () => {
expect(demoDesk('kiko')).toBeUndefined();
@ -90,8 +19,17 @@ describe('the demo desks', () => {
expect(deskFor('nibu')?.mind).toBe('nibu');
setDeskSource(() => undefined);
});
it('resolve every dependency they cite, so no seeded row waits on a ghost', () => {
const nibu = demoDesk('nibu');
const ids = new Set(nibu?.questions.map((q) => q.fork_id));
nibu?.questions.forEach(
(q) =>
'dependsOn' in q &&
q.dependsOn.forEach((d) => d.questionId && expect(ids.has(d.questionId)).toBe(true))
);
});
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());
expect(await unverifiedForks(demoDesk('nibu')?.questions ?? [])).toEqual(new Set());
expect(await unverifiedForks(demoDesk('eureka')?.questions ?? [])).toEqual(new Set());
});
});

View file

@ -1,187 +1,87 @@
// 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';
// Seeded forks for the preview's Mind desks. Invented here, public-safe: no real question, person or
// decision. Written against the clock at the time of the call so ages and sync time stay plausible.
import { DeskProjection, Question } from '../app/features/desk/feedback';
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.
// Each question'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',
const fork = (mind: string, label: string, daysAgo: number, over: Partial<Question>): Question => ({
fork_id: `demo:question:${label}`,
owner: mind,
title: label,
context: '',
body: '',
options: [],
recommendation: '',
depends: [],
leans: null,
flag: null,
raised_in: null,
deciders: ['demo-decider'],
status: 'open',
ruling: null,
ruled_from: [],
order,
dependsOn: [],
askedAt: new Date(Date.now() - daysAgo * DAY).toISOString(),
status: { state: 'open' },
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',
}),
const SEEDS: Record<string, (now: number) => DeskProjection> = {
nibu: (now) => ({
mind: 'nibu',
syncedAt: new Date(now - 4 * 60 * 1000).toISOString(),
questions: [
fork('nibu', 'sky-label', 3, {
title: 'Where should the sky label sit on a phone?',
body: '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' },
],
'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'
),
recommendation: 'header',
}),
fork('nibu', 'dusk-ink', 18, {
title: 'Should the dusk ink follow the sky or the theme?',
body: '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',
dependsOn: [{ text: 'Where the sky label sits', questionId: 'demo:question:sky-label' }],
}),
fork('nibu', 'ridge-low-tier', 40, {
title: 'Keep the ridge scene at the low tier?',
body: 'The ridge costs a little on older phones.',
options: [
{ id: 'keep', text: 'Keep it' },
{ id: 'drop', text: 'Drop it' },
],
recommendation: 'keep',
status: { state: 'answered', rulingId: 'demo:ruling:ridge-low-tier' },
}),
],
'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' },
answers: [],
}),
eureka: (now) => ({
mind: 'eureka',
syncedAt: new Date(now - 2 * 60 * 1000).toISOString(),
questions: [
fork('eureka', 'settled-window', 1, {
title: 'How long should a Mind keep a settled question on its desk?',
body: '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',
}),
],
recommendation: '30d',
});
return list('eureka', [entry(window)], 's7');
answers: [],
}),
};
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]?.();
export const demoDesk = (mind: string): DeskProjection | undefined => SEEDS[mind]?.(Date.now());

View file

@ -44,8 +44,6 @@ 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;
@ -56,7 +54,6 @@ 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')];
@ -118,17 +115,6 @@ 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}`,
@ -316,36 +302,6 @@ 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.' },

View file

@ -73,12 +73,6 @@ 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' },

View file

@ -1,95 +0,0 @@
/* 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', []),
]),
};
};

View file

@ -1,14 +1,7 @@
/* 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,
ClientPrefix,
MatrixClient,
Method,
SyncState,
createClient,
} from 'matrix-js-sdk';
import { ALBUMS, DEMO_ORIGIN, ME, ROOMS, GENERAL, COMMONS, FILES } from './fixtures';
import { ClientEvent, MatrixClient, SyncState, createClient } from 'matrix-js-sdk';
import { DEMO_ORIGIN, ME, ROOMS, GENERAL, COMMONS } from './fixtures';
import { dropForeignSession, guardedFetch, installDemo, routeOf } from './install';
import { createDemoServer, DemoServer } from './server';
import { seedDemoSettings } from './seed';
@ -37,9 +30,6 @@ 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 }) => {
@ -126,12 +116,7 @@ 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,
Files: false,
Ratatoskr: false,
Huginn: false,
});
expect(Object.fromEntries(lines)).toEqual({ General: true, Ratatoskr: false, Huginn: false });
});
it('sends a message, echoes it once with its transaction id, and a Mind answers with a pose', async () => {
@ -187,56 +172,6 @@ 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) });

View file

@ -3,7 +3,6 @@
// 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,
@ -11,17 +10,14 @@ import {
DEMO_TOKEN,
DemoEvent,
DemoRoom,
FILES,
ME,
MIND_IDS,
REPLIES,
ROOMS,
USERS,
} from './fixtures';
import { DemoMedia, pngGradient, wavTone } from './media';
/** `raw` answers a media download with bytes instead of JSON. */
export type DemoResponse = { status: number; body: unknown; raw?: DemoMedia };
export type DemoResponse = { status: number; body: unknown };
export type DemoRequest = { method: string; url: string; body?: unknown; signal?: AbortSignal };
const POSE_KEY = 'org.gamecult.pose';
@ -83,12 +79,7 @@ export const createDemoServer = (now: () => number = Date.now): DemoServer => {
mk(
'm.room.create',
creator,
{
room_version: '10',
...((room.space ? 'm.space' : room.type)
? { type: room.space ? 'm.space' : room.type }
: {}),
},
room.space ? { type: 'm.space', room_version: '10' } : { room_version: '10' },
t0,
''
),
@ -168,45 +159,6 @@ 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();
@ -338,11 +290,6 @@ 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: {} });
@ -447,13 +394,6 @@ 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\/.+)$/);