Merge remote-tracking branch 'origin/void/pose-catalogue' into void/three-doors-look
This commit is contained in:
commit
db023bc033
17 changed files with 629 additions and 11 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -4,4 +4,7 @@ node_modules
|
|||
devAssets
|
||||
|
||||
.DS_Store
|
||||
.idea
|
||||
.idea
|
||||
# Pose art is synced from the cast-art catalogue (npm run sync:poses), not committed.
|
||||
public/poses/*
|
||||
!public/poses/.gitkeep
|
||||
|
|
|
|||
|
|
@ -4,3 +4,4 @@ package.json
|
|||
package-lock.json
|
||||
LICENSE
|
||||
harness
|
||||
src/app/components/message/pose/catalogue.json
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@
|
|||
"prepare": "husky install",
|
||||
"commit": "git-cz",
|
||||
"bump": "node scripts/update-version.js",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"sync:poses": "node scripts/sync-poses.mjs"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,jsx}": "eslint",
|
||||
|
|
|
|||
0
public/poses/.gitkeep
Normal file
0
public/poses/.gitkeep
Normal file
24
scripts/sync-poses.mjs
Normal file
24
scripts/sync-poses.mjs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// Copies the pose catalogue and the art it names from the cast-art directory.
|
||||
// The catalogue is the only list of poses the client knows; the art is not committed.
|
||||
import { copyFileSync, mkdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const src = process.argv[2] ?? '/opt/commons/cast-art/poses';
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const catalogue = JSON.parse(readFileSync(join(src, 'catalogue.json'), 'utf8'));
|
||||
|
||||
copyFileSync(
|
||||
join(src, 'catalogue.json'),
|
||||
join(root, 'src/app/components/message/pose/catalogue.json')
|
||||
);
|
||||
const files = Object.values(catalogue.minds).flatMap((m) => [
|
||||
...Object.values(m.shared),
|
||||
...Object.values(m.signatures),
|
||||
]);
|
||||
files.forEach((f) => {
|
||||
const dest = join(root, 'public/poses', f);
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
copyFileSync(join(src, f), dest);
|
||||
});
|
||||
console.log(`synced catalogue and ${files.length} images from ${src}`);
|
||||
|
|
@ -5,11 +5,12 @@ import * as css from './layout.css';
|
|||
|
||||
type BubbleArrowProps = {
|
||||
variant: ContainerColor;
|
||||
pose?: boolean;
|
||||
};
|
||||
function BubbleLeftArrow({ variant }: BubbleArrowProps) {
|
||||
function BubbleLeftArrow({ variant, pose }: BubbleArrowProps) {
|
||||
return (
|
||||
<svg
|
||||
className={css.BubbleLeftArrow}
|
||||
className={classNames(css.BubbleLeftArrow, pose && css.BubbleArrowPose)}
|
||||
width="9"
|
||||
height="8"
|
||||
viewBox="0 0 9 8"
|
||||
|
|
@ -28,14 +29,20 @@ function BubbleLeftArrow({ variant }: BubbleArrowProps) {
|
|||
|
||||
type BubbleLayoutProps = {
|
||||
hideBubble?: boolean;
|
||||
pose?: boolean;
|
||||
before?: ReactNode;
|
||||
header?: ReactNode;
|
||||
};
|
||||
|
||||
export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
||||
({ hideBubble, before, header, children, ...props }, ref) => (
|
||||
<Box gap="300" {...props} ref={ref}>
|
||||
<Box className={css.BubbleBefore} shrink="No">
|
||||
({ hideBubble, pose, before, header, children, ...props }, ref) => (
|
||||
<Box
|
||||
gap="300"
|
||||
{...props}
|
||||
className={classNames(pose && css.BubblePose, props.className)}
|
||||
ref={ref}
|
||||
>
|
||||
<Box className={pose ? css.BubbleBeforePose : css.BubbleBefore} shrink="No">
|
||||
{before}
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -48,11 +55,15 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
|||
className={
|
||||
hideBubble
|
||||
? undefined
|
||||
: classNames(css.BubbleContent, before ? css.BubbleContentArrowLeft : undefined)
|
||||
: classNames(
|
||||
css.BubbleContent,
|
||||
before ? css.BubbleContentArrowLeft : undefined,
|
||||
pose && css.BubbleContentPoseStacked
|
||||
)
|
||||
}
|
||||
direction="Column"
|
||||
>
|
||||
{before ? <BubbleLeftArrow variant="SurfaceVariant" /> : null}
|
||||
{before ? <BubbleLeftArrow variant="SurfaceVariant" pose={pose} /> : null}
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -138,6 +138,34 @@ export const BubbleBefore = style({
|
|||
minWidth: toRem(36),
|
||||
});
|
||||
|
||||
// Pose portraits: beside the bubble on wide screens; on phones the row stacks, so the
|
||||
// portrait costs a little height instead of a column of width, and the left arrow goes.
|
||||
export const BubblePose = style({
|
||||
'@media': {
|
||||
'screen and (max-width: 750px)': {
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S100,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BubbleBeforePose = style({
|
||||
minWidth: toRem(64),
|
||||
'@media': {
|
||||
'screen and (max-width: 750px)': {
|
||||
minWidth: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BubbleArrowPose = style({
|
||||
'@media': {
|
||||
'screen and (max-width: 750px)': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BubbleContent = style({
|
||||
maxWidth: toRem(800),
|
||||
padding: config.space.S200,
|
||||
|
|
@ -151,6 +179,34 @@ export const BubbleContentArrowLeft = style({
|
|||
borderTopLeftRadius: 0,
|
||||
});
|
||||
|
||||
export const BubbleContentPoseStacked = style({
|
||||
'@media': {
|
||||
'screen and (max-width: 750px)': {
|
||||
borderTopLeftRadius: config.radii.R500,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const PoseButton = style({
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
lineHeight: 0,
|
||||
});
|
||||
|
||||
export const PosePortrait = style({
|
||||
width: toRem(64),
|
||||
height: toRem(64),
|
||||
objectFit: 'contain',
|
||||
'@media': {
|
||||
'screen and (max-width: 750px)': {
|
||||
width: toRem(48),
|
||||
height: toRem(48),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BubbleLeftArrow = style({
|
||||
width: toRem(9),
|
||||
height: toRem(8),
|
||||
|
|
|
|||
30
src/app/components/message/pose/PoseAvatar.tsx
Normal file
30
src/app/components/message/pose/PoseAvatar.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
import * as css from '../layout/layout.css';
|
||||
import { poseSrc } from './poses';
|
||||
|
||||
type PoseAvatarProps = {
|
||||
mind: string;
|
||||
pose: string;
|
||||
userId: string;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
export function PoseAvatar({ mind, pose, userId, onClick }: PoseAvatarProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.PoseButton}
|
||||
data-user-id={userId}
|
||||
data-pose={pose}
|
||||
onClick={onClick}
|
||||
>
|
||||
<img
|
||||
className={css.PosePortrait}
|
||||
src={poseSrc(mind, pose, import.meta.env.BASE_URL)}
|
||||
alt={`${mind}, ${pose}`}
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
242
src/app/components/message/pose/catalogue.json
Normal file
242
src/app/components/message/pose/catalogue.json
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
{
|
||||
"shared": [
|
||||
"neutral",
|
||||
"happy",
|
||||
"amused",
|
||||
"thinking",
|
||||
"annoyed",
|
||||
"surprised"
|
||||
],
|
||||
"minds": {
|
||||
"eureka": {
|
||||
"sheet": "r1b",
|
||||
"shared": {
|
||||
"neutral": "eureka/neutral.png",
|
||||
"happy": "eureka/happy.png",
|
||||
"amused": "eureka/amused.png",
|
||||
"thinking": "eureka/thinking.png",
|
||||
"annoyed": "eureka/annoyed.png",
|
||||
"surprised": "eureka/surprised.png"
|
||||
},
|
||||
"signatures": {
|
||||
"probe": "eureka/probe.png",
|
||||
"inspect": "eureka/inspect.png",
|
||||
"report": "eureka/report.png",
|
||||
"hood-smug": "eureka/hood-smug.png",
|
||||
"concerned": "eureka/concerned.png",
|
||||
"determined": "eureka/determined.png"
|
||||
},
|
||||
"signature_descriptions": {
|
||||
"probe": "Frowning, pencil raised to her chin, working a problem out.",
|
||||
"inspect": "Frowning, one finger pointing at the thing to look at.",
|
||||
"report": "Smirking over a folder of findings.",
|
||||
"hood-smug": "Eyes shut under the hood, self-satisfied smirk.",
|
||||
"concerned": "Worried frown, pencil at her chin.",
|
||||
"determined": "Hard-set frown, pencil in hand, resolved."
|
||||
}
|
||||
},
|
||||
"aqua": {
|
||||
"sheet": "r1b",
|
||||
"shared": {
|
||||
"neutral": "aqua/neutral.png",
|
||||
"happy": "aqua/happy.png",
|
||||
"amused": "aqua/amused.png",
|
||||
"thinking": "aqua/thinking.png",
|
||||
"annoyed": "aqua/annoyed.png",
|
||||
"surprised": "aqua/surprised.png"
|
||||
},
|
||||
"signatures": {
|
||||
"concerned": "aqua/concerned.png",
|
||||
"fretful": "aqua/fretful.png",
|
||||
"weary": "aqua/weary.png",
|
||||
"glare": "aqua/glare.png",
|
||||
"smug": "aqua/smug.png",
|
||||
"sigh": "aqua/sigh.png",
|
||||
"charging": "aqua/charging.png",
|
||||
"delighted": "aqua/delighted.png",
|
||||
"content": "aqua/content.png"
|
||||
},
|
||||
"signature_descriptions": {
|
||||
"concerned": "Worried frown, brows pinched.",
|
||||
"fretful": "Anxious, a fin pressed to her chin.",
|
||||
"weary": "Droopy half-lidded eyes; worn out.",
|
||||
"glare": "Scowling hard, a fin held out to the side.",
|
||||
"smug": "Sidelong look and a small smirk.",
|
||||
"sigh": "Eyes shut, breathing out.",
|
||||
"charging": "Eyes shut, a charging cable plugged into her mouth; recharging.",
|
||||
"delighted": "Wide open-mouthed grin, fins up.",
|
||||
"content": "Calm, small contented smile."
|
||||
}
|
||||
},
|
||||
"heimdall": {
|
||||
"sheet": "r1a",
|
||||
"shared": {
|
||||
"neutral": "heimdall/neutral.png",
|
||||
"happy": "heimdall/happy.png",
|
||||
"amused": "heimdall/amused.png",
|
||||
"thinking": "heimdall/thinking.png",
|
||||
"annoyed": "heimdall/annoyed.png",
|
||||
"surprised": "heimdall/surprised.png"
|
||||
},
|
||||
"signatures": {
|
||||
"concerned": "heimdall/concerned.png",
|
||||
"determined": "heimdall/determined.png",
|
||||
"badge": "heimdall/badge.png",
|
||||
"barrier": "heimdall/barrier.png",
|
||||
"protect": "heimdall/protect.png",
|
||||
"revoke": "heimdall/revoke.png"
|
||||
},
|
||||
"signature_descriptions": {
|
||||
"concerned": "Worried frown, brows raised.",
|
||||
"determined": "Hard scowl, jaw set.",
|
||||
"badge": "Stern, holding a gold key out as his credentials.",
|
||||
"barrier": "Palm raised behind a translucent blue shield: stop.",
|
||||
"protect": "Scowling, cloak swept forward like a guard.",
|
||||
"revoke": "Stern, two keys in hand; taking access away."
|
||||
}
|
||||
},
|
||||
"kiko": {
|
||||
"sheet": "r1b",
|
||||
"shared": {
|
||||
"neutral": "kiko/neutral.png",
|
||||
"happy": "kiko/happy.png",
|
||||
"amused": "kiko/amused.png",
|
||||
"thinking": "kiko/thinking.png",
|
||||
"annoyed": "kiko/annoyed.png",
|
||||
"surprised": "kiko/surprised.png"
|
||||
},
|
||||
"signatures": {
|
||||
"shy-smile": "kiko/shy-smile.png",
|
||||
"uneasy": "kiko/uneasy.png",
|
||||
"tinker": "kiko/tinker.png",
|
||||
"confetti": "kiko/confetti.png",
|
||||
"sprint": "kiko/sprint.png",
|
||||
"debug": "kiko/debug.png"
|
||||
},
|
||||
"signature_descriptions": {
|
||||
"shy-smile": "Soft, shy smile; blushing.",
|
||||
"uneasy": "Blushing, tongue peeking out, unsure.",
|
||||
"tinker": "Delighted, holding up a small gadget.",
|
||||
"confetti": "Open-mouthed grin, confetti falling, a glowing glyph on one finger.",
|
||||
"sprint": "Eyes shut, laughing, one arm up, mid-dash.",
|
||||
"debug": "Squinting through a magnifying glass at a bug."
|
||||
}
|
||||
},
|
||||
"weksa": {
|
||||
"sheet": "r1a",
|
||||
"shared": {
|
||||
"neutral": "weksa/neutral.png",
|
||||
"happy": "weksa/happy.png",
|
||||
"amused": "weksa/amused.png",
|
||||
"thinking": "weksa/thinking.png",
|
||||
"annoyed": "weksa/annoyed.png",
|
||||
"surprised": "weksa/surprised.png"
|
||||
},
|
||||
"signatures": {
|
||||
"concerned": "weksa/concerned.png",
|
||||
"skeptical": "weksa/skeptical.png",
|
||||
"map": "weksa/map.png",
|
||||
"fieldwork": "weksa/fieldwork.png",
|
||||
"argue": "weksa/argue.png",
|
||||
"interlingua": "weksa/interlingua.png"
|
||||
},
|
||||
"signature_descriptions": {
|
||||
"concerned": "Worried frown, wide eyes, scroll on her back.",
|
||||
"skeptical": "Half-lidded, flat frown; unconvinced.",
|
||||
"map": "Worried, unfurling a hand-drawn map.",
|
||||
"fieldwork": "Frowning in focus, holding a carved idol and a brush.",
|
||||
"argue": "Open-mouthed, pointing, holding up a tablet as evidence.",
|
||||
"interlingua": "Shouting, a glyph tablet in each hand."
|
||||
}
|
||||
},
|
||||
"void": {
|
||||
"sheet": "r1b",
|
||||
"shared": {
|
||||
"neutral": "void/neutral.png",
|
||||
"happy": "void/happy.png",
|
||||
"amused": "void/amused.png",
|
||||
"thinking": "void/thinking.png",
|
||||
"annoyed": "void/annoyed.png",
|
||||
"surprised": "void/surprised.png"
|
||||
},
|
||||
"signatures": {
|
||||
"determined": "void/determined.png",
|
||||
"glare": "void/glare.png",
|
||||
"shrug": "void/shrug.png",
|
||||
"reading": "void/reading.png",
|
||||
"point": "void/point.png",
|
||||
"deadpan": "void/deadpan.png"
|
||||
},
|
||||
"signature_descriptions": {
|
||||
"determined": "Hood bowed, narrowed eyes; set on the task.",
|
||||
"glare": "Narrowed eyes under the hood; hostile stare.",
|
||||
"shrug": "Both hands open, eyes wide: no idea.",
|
||||
"reading": "Holding a sheet of paper, reading it.",
|
||||
"point": "Half-lidded eyes, one finger pointing off to the side.",
|
||||
"deadpan": "Flat lines for eyes; unimpressed."
|
||||
}
|
||||
},
|
||||
"libby": {
|
||||
"sheet": "r1a",
|
||||
"shared": {
|
||||
"neutral": "libby/neutral.png",
|
||||
"happy": "libby/happy.png",
|
||||
"amused": "libby/amused.png",
|
||||
"thinking": "libby/thinking.png",
|
||||
"annoyed": "libby/annoyed.png",
|
||||
"surprised": "libby/surprised.png"
|
||||
},
|
||||
"signatures": {
|
||||
"concerned": "libby/concerned.png",
|
||||
"sad": "libby/sad.png",
|
||||
"stamp": "libby/stamp.png",
|
||||
"research": "libby/research.png",
|
||||
"shush": "libby/shush.png",
|
||||
"schema": "libby/schema.png"
|
||||
},
|
||||
"signature_descriptions": {
|
||||
"concerned": "Worried, a book clutched to her chest.",
|
||||
"sad": "Downcast, a book clutched to her chest.",
|
||||
"stamp": "Smiling, stamping a page of an open book.",
|
||||
"research": "Eager over an open book, an ink bottle at hand.",
|
||||
"shush": "Speaking over an open book, one finger on the page.",
|
||||
"schema": "Raising a finger beside a glowing flow-diagram panel."
|
||||
}
|
||||
},
|
||||
"nibu": {
|
||||
"sheet": "r1b",
|
||||
"shared": {
|
||||
"neutral": "nibu/neutral.png",
|
||||
"happy": "nibu/happy.png",
|
||||
"amused": "nibu/amused.png",
|
||||
"thinking": "nibu/thinking.png",
|
||||
"annoyed": "nibu/annoyed.png",
|
||||
"surprised": "nibu/surprised.png"
|
||||
},
|
||||
"signatures": {
|
||||
"sheepish": "nibu/sheepish.png",
|
||||
"pout": "nibu/pout.png",
|
||||
"resolute": "nibu/resolute.png",
|
||||
"console": "nibu/console.png",
|
||||
"glitch": "nibu/glitch.png",
|
||||
"triumph": "nibu/triumph.png"
|
||||
},
|
||||
"signature_descriptions": {
|
||||
"sheepish": "Arms crossed, awkward toothy grin.",
|
||||
"pout": "Arms crossed, pouting frown.",
|
||||
"resolute": "Arms crossed, scowling; standing her ground.",
|
||||
"console": "Frowning at a handheld console.",
|
||||
"glitch": "Frowning, palm up, a glitch tearing across her eyes.",
|
||||
"triumph": "Eyes shut, fist raised; quietly pleased with a win."
|
||||
}
|
||||
}
|
||||
},
|
||||
"shared_descriptions": {
|
||||
"neutral": "At rest, no particular feeling.",
|
||||
"happy": "Pleased; smiling.",
|
||||
"amused": "Quietly entertained, as if at a private joke.",
|
||||
"thinking": "Working something out.",
|
||||
"annoyed": "Irritated; patience running thin.",
|
||||
"surprised": "Caught off guard."
|
||||
}
|
||||
}
|
||||
83
src/app/components/message/pose/poses.test.ts
Normal file
83
src/app/components/message/pose/poses.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import catalogue from './catalogue.json';
|
||||
import { mindOfSender, poseSrc, poseStatusLine, poseStatuses, resolvePose } from './poses';
|
||||
|
||||
describe('resolvePose', () => {
|
||||
it('keeps shared and own-signature labels from the catalogue', () => {
|
||||
expect(resolvePose('aqua', 'amused')).toBe('amused');
|
||||
expect(resolvePose('aqua', 'fretful')).toBe('fretful');
|
||||
expect(resolvePose('kiko', ' Debug ')).toBe('debug');
|
||||
expect(resolvePose('void', 'reading')).toBe('reading');
|
||||
});
|
||||
it('falls back to neutral for missing, unknown, non-string or foreign-signature labels', () => {
|
||||
expect(resolvePose('aqua', undefined)).toBe('neutral');
|
||||
expect(resolvePose('aqua', 'conduct')).toBe('neutral');
|
||||
expect(resolvePose('aqua', 7)).toBe('neutral');
|
||||
expect(resolvePose('aqua', 'probe')).toBe('neutral');
|
||||
expect(resolvePose('nobody', 'neutral')).toBe('neutral');
|
||||
expect(resolvePose('aqua', 'constructor')).toBe('neutral');
|
||||
});
|
||||
it('knows every label the catalogue lists, and only those', () => {
|
||||
Object.entries(catalogue.minds).forEach(([mind, entry]) => {
|
||||
[...Object.keys(entry.shared), ...Object.keys(entry.signatures)].forEach((l) =>
|
||||
expect(resolvePose(mind, l)).toBe(l)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mindOfSender', () => {
|
||||
it('matches localpart or first display-name word', () => {
|
||||
expect(mindOfSender('Eureka')).toBe('eureka');
|
||||
expect(mindOfSender('x', 'Libby the Archivist')).toBe('libby');
|
||||
expect(mindOfSender('void')).toBe('void');
|
||||
});
|
||||
it('ignores humans and Object prototype names', () => {
|
||||
expect(mindOfSender('alice', 'Alice')).toBeUndefined();
|
||||
expect(mindOfSender('constructor')).toBeUndefined();
|
||||
expect(mindOfSender()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('poseSrc', () => {
|
||||
it('builds the asset path from the catalogue entry', () => {
|
||||
expect(poseSrc('eureka', 'hood-smug', '/app/')).toBe('/app/public/poses/eureka/hood-smug.png');
|
||||
expect(poseSrc('eureka', 'nope')).toBe('/public/poses/eureka/neutral.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('poseStatuses', () => {
|
||||
const ev = (sender: string, ts: number, label: unknown) => ({
|
||||
sender,
|
||||
localpart: sender,
|
||||
ts,
|
||||
label,
|
||||
});
|
||||
it('reports the latest pose per Mind and when its unbroken run began', () => {
|
||||
const out = poseStatuses([
|
||||
ev('aqua', 1, 'happy'),
|
||||
ev('aqua', 2, 'sigh'),
|
||||
ev('aqua', 3, 'sigh'),
|
||||
ev('alice', 4, 'happy'),
|
||||
ev('eureka', 5, 'probe'),
|
||||
]);
|
||||
expect(out).toEqual([
|
||||
{ mind: 'aqua', pose: 'sigh', since: 2 },
|
||||
{ mind: 'eureka', pose: 'probe', since: 5 },
|
||||
]);
|
||||
});
|
||||
it('treats unknown labels as neutral runs', () => {
|
||||
expect(poseStatuses([ev('void', 1, 'bogus'), ev('void', 2, undefined)])).toEqual([
|
||||
{ mind: 'void', pose: 'neutral', since: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('poseStatusLine', () => {
|
||||
it('formats the client-status line', () => {
|
||||
const since = new Date(2026, 10, 4, 9, 5).getTime();
|
||||
expect(poseStatusLine({ mind: 'void', pose: 'reading', since })).toBe(
|
||||
'Ratatoskr: Void is reading, since 09:05'
|
||||
);
|
||||
});
|
||||
});
|
||||
77
src/app/components/message/pose/poses.ts
Normal file
77
src/app/components/message/pose/poses.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import catalogue from './catalogue.json';
|
||||
|
||||
// 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
|
||||
// list of Minds, labels and art paths the client knows.
|
||||
export const POSE_KEY = 'org.gamecult.pose';
|
||||
export const NEUTRAL = 'neutral';
|
||||
|
||||
type MindEntry = {
|
||||
shared: Record<string, string>;
|
||||
signatures: Record<string, string>;
|
||||
};
|
||||
const minds = catalogue.minds as Record<string, MindEntry>;
|
||||
|
||||
// A sender is a known Mind when its Matrix localpart or the first word of its
|
||||
// display name is the Mind's name, case-insensitively.
|
||||
export const mindOfSender = (localpart?: string, displayName?: string): string | undefined => {
|
||||
const candidates = [localpart, displayName?.split(/\s+/)[0]];
|
||||
return candidates
|
||||
.map((c) => c?.toLowerCase())
|
||||
.find((c): c is string => c !== undefined && Object.prototype.hasOwnProperty.call(minds, c));
|
||||
};
|
||||
|
||||
const artPath = (mind: string, label: string): string | undefined => {
|
||||
const entry = Object.prototype.hasOwnProperty.call(minds, mind) ? minds[mind] : undefined;
|
||||
if (!entry) return undefined;
|
||||
const has = (o: Record<string, string>) => Object.prototype.hasOwnProperty.call(o, label);
|
||||
if (has(entry.shared)) return entry.shared[label];
|
||||
return has(entry.signatures) ? entry.signatures[label] : undefined;
|
||||
};
|
||||
|
||||
// Unknown, missing or non-string labels, and another Mind's signature, fall back to neutral.
|
||||
export const resolvePose = (mind: string, label: unknown): string => {
|
||||
if (typeof label !== 'string') return NEUTRAL;
|
||||
const l = label.trim().toLowerCase();
|
||||
return artPath(mind, l) ? l : NEUTRAL;
|
||||
};
|
||||
|
||||
export const poseSrc = (mind: string, pose: string, base = '/'): string =>
|
||||
`${base}public/poses/${artPath(mind, pose) ?? artPath(mind, NEUTRAL)}`;
|
||||
|
||||
export type PoseStatus = { mind: string; pose: string; since: number };
|
||||
export type PoseEvent = {
|
||||
sender: string;
|
||||
localpart?: string;
|
||||
name?: string;
|
||||
ts: number;
|
||||
label: unknown;
|
||||
};
|
||||
|
||||
// Latest pose per Mind in a room's events (oldest first), and when that pose
|
||||
// began: the first of its unbroken run of messages carrying the same pose.
|
||||
export const poseStatuses = (events: PoseEvent[]): PoseStatus[] => {
|
||||
const bySender = new Map<string, { mind: string; run: { pose: string; ts: number }[] }>();
|
||||
events.forEach((e) => {
|
||||
const mind = mindOfSender(e.localpart, e.name);
|
||||
if (!mind) return;
|
||||
const entry = bySender.get(e.sender) ?? { mind, run: [] };
|
||||
entry.run.push({ pose: resolvePose(mind, e.label), ts: e.ts });
|
||||
bySender.set(e.sender, entry);
|
||||
});
|
||||
return [...bySender.values()].map(({ mind, run }) => {
|
||||
const last = run[run.length - 1];
|
||||
let i = run.length - 1;
|
||||
while (i > 0 && run[i - 1].pose === last.pose) i -= 1;
|
||||
return { mind, pose: last.pose, since: run[i].ts };
|
||||
});
|
||||
};
|
||||
|
||||
const two = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
// Client-status text. Rendered locally only; it is never sent to a room.
|
||||
export const poseStatusLine = ({ mind, pose, since }: PoseStatus): string => {
|
||||
const d = new Date(since);
|
||||
const name = mind.charAt(0).toUpperCase() + mind.slice(1);
|
||||
return `Ratatoskr: ${name} is ${pose}, since ${two(d.getHours())}:${two(d.getMinutes())}`;
|
||||
};
|
||||
|
|
@ -11,6 +11,7 @@ import { useEditor } from '../../components/editor';
|
|||
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
|
||||
import { RoomTimeline } from './RoomTimeline';
|
||||
import { RoomViewTyping } from './RoomViewTyping';
|
||||
import { RoomViewPoseStatus } from './RoomViewPoseStatus';
|
||||
import { RoomTombstone } from './RoomTombstone';
|
||||
import { RoomInput } from './RoomInput';
|
||||
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
|
||||
|
|
@ -100,6 +101,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||
roomInputRef={roomInputRef}
|
||||
editor={editor}
|
||||
/>
|
||||
<RoomViewPoseStatus room={room} />
|
||||
<RoomViewTyping room={room} />
|
||||
</Box>
|
||||
<Box shrink="No" direction="Column">
|
||||
|
|
|
|||
53
src/app/features/room/RoomViewPoseStatus.tsx
Normal file
53
src/app/features/room/RoomViewPoseStatus.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Text } from 'folds';
|
||||
import { Room, RoomEvent } from 'matrix-js-sdk';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { POSE_KEY, poseStatusLine, poseStatuses } from '../../components/message/pose/poses';
|
||||
|
||||
// Client-status lines about each Mind's current pose. They are drawn from the
|
||||
// timeline already in memory and are never sent as anyone's message.
|
||||
export function RoomViewPoseStatus({ room }: { room: Room }) {
|
||||
const [poseBubbles] = useSetting(settingsAtom, 'poseBubbles');
|
||||
const [, bump] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const onTimeline = () => bump((n) => n + 1);
|
||||
room.on(RoomEvent.Timeline, onTimeline);
|
||||
return () => {
|
||||
room.removeListener(RoomEvent.Timeline, onTimeline);
|
||||
};
|
||||
}, [room]);
|
||||
|
||||
if (!poseBubbles) return null;
|
||||
|
||||
const statuses = poseStatuses(
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
.filter((e) => e.getType() === 'm.room.message' && e.getSender())
|
||||
.map((e) => {
|
||||
const sender = e.getSender() as string;
|
||||
return {
|
||||
sender,
|
||||
localpart: getMxIdLocalPart(sender),
|
||||
name: getMemberDisplayName(room, sender),
|
||||
ts: e.getTs(),
|
||||
label: e.getContent()[POSE_KEY],
|
||||
};
|
||||
})
|
||||
);
|
||||
if (statuses.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box direction="Column" style={{ padding: '0 1rem' }}>
|
||||
{statuses.map((s) => (
|
||||
<Text key={s.mind} size="T200" priority="300">
|
||||
{poseStatusLine(s)}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -58,7 +58,10 @@ import {
|
|||
isRoomAlias,
|
||||
mxcUrlToHttp,
|
||||
} from '../../../utils/matrix';
|
||||
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
||||
import { MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { PoseAvatar } from '../../../components/message/pose/PoseAvatar';
|
||||
import { POSE_KEY, mindOfSender, resolvePose } from '../../../components/message/pose/poses';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||
import * as css from './styles.css';
|
||||
|
|
@ -788,6 +791,12 @@ export const Message = as<'div', MessageProps>(
|
|||
</Box>
|
||||
);
|
||||
|
||||
const [poseBubbles] = useSetting(settingsAtom, 'poseBubbles');
|
||||
const mind =
|
||||
poseBubbles && messageLayout === MessageLayout.Bubble
|
||||
? mindOfSender(getMxIdLocalPart(senderId), senderDisplayName)
|
||||
: undefined;
|
||||
|
||||
const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
|
||||
<AvatarBase
|
||||
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
|
||||
|
|
@ -813,6 +822,15 @@ export const Message = as<'div', MessageProps>(
|
|||
</AvatarBase>
|
||||
);
|
||||
|
||||
const poseJSX = !collapse && mind && (
|
||||
<PoseAvatar
|
||||
mind={mind}
|
||||
pose={resolvePose(mind, mEvent.getContent()[POSE_KEY])}
|
||||
userId={senderId}
|
||||
onClick={onUserClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const msgContentJSX = (
|
||||
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
|
||||
{reply}
|
||||
|
|
@ -1135,7 +1153,10 @@ export const Message = as<'div', MessageProps>(
|
|||
</CompactLayout>
|
||||
)}
|
||||
{messageLayout === MessageLayout.Bubble && (
|
||||
<BubbleLayout before={avatarJSX} header={headerJSX} onContextMenu={handleContextMenu}>
|
||||
<BubbleLayout
|
||||
pose={!!mind}
|
||||
before={mind ? poseJSX : avatarJSX}
|
||||
header={headerJSX} onContextMenu={handleContextMenu}>
|
||||
{msgContentJSX}
|
||||
</BubbleLayout>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -884,6 +884,7 @@ function Messages() {
|
|||
settingsAtom,
|
||||
'legacyUsernameColor'
|
||||
);
|
||||
const [poseBubbles, setPoseBubbles] = useSetting(settingsAtom, 'poseBubbles');
|
||||
const [hideMembershipEvents, setHideMembershipEvents] = useSetting(
|
||||
settingsAtom,
|
||||
'hideMembershipEvents'
|
||||
|
|
@ -918,6 +919,13 @@ function Messages() {
|
|||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Pose Portraits"
|
||||
description="Show a Mind's pose portrait beside its bubbles (bubble layout only)."
|
||||
after={<Switch variant="Primary" value={poseBubbles} onChange={setPoseBubbles} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Hide Membership Change"
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export interface Settings {
|
|||
encUrlPreview: boolean;
|
||||
showHiddenEvents: boolean;
|
||||
legacyUsernameColor: boolean;
|
||||
poseBubbles: boolean;
|
||||
|
||||
showNotifications: boolean;
|
||||
isNotificationSounds: boolean;
|
||||
|
|
@ -79,6 +80,7 @@ const defaultSettings: Settings = {
|
|||
encUrlPreview: false,
|
||||
showHiddenEvents: false,
|
||||
legacyUsernameColor: false,
|
||||
poseBubbles: false,
|
||||
|
||||
showNotifications: true,
|
||||
isNotificationSounds: true,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ const copyFiles = {
|
|||
src: 'public/locales',
|
||||
dest: 'public/',
|
||||
},
|
||||
{
|
||||
src: 'public/poses',
|
||||
dest: 'public/',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue