poses: labels only from the catalogue; client-status pose line (do not merge) #10
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
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 6 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 6 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 6 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5 KiB |
|
Before Width: | Height: | Size: 5 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 5 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 5 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 5 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
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}`);
|
||||
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."
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,28 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { mindOfSender, poseSrc, resolvePose } from './poses';
|
||||
import catalogue from './catalogue.json';
|
||||
import { mindOfSender, poseSrc, poseStatusLine, poseStatuses, resolvePose } from './poses';
|
||||
|
||||
describe('resolvePose', () => {
|
||||
it('keeps shared and own-signature labels', () => {
|
||||
it('keeps shared and own-signature labels from the catalogue', () => {
|
||||
expect(resolvePose('aqua', 'amused')).toBe('amused');
|
||||
expect(resolvePose('aqua', 'conduct')).toBe('conduct');
|
||||
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', 'smug')).toBe('neutral');
|
||||
expect(resolvePose('aqua', 'conduct')).toBe('neutral');
|
||||
expect(resolvePose('aqua', 7)).toBe('neutral');
|
||||
expect(resolvePose('aqua', 'probe')).toBe('neutral');
|
||||
expect(resolvePose('nobody', '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)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -20,6 +30,7 @@ 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();
|
||||
|
|
@ -29,7 +40,44 @@ describe('mindOfSender', () => {
|
|||
});
|
||||
|
||||
describe('poseSrc', () => {
|
||||
it('builds the asset path', () => {
|
||||
expect(poseSrc('nibu', 'glitch', '/app/')).toBe('/app/public/poses/nibu/glitch.webp');
|
||||
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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,48 +1,77 @@
|
|||
// Pose labels travel in the event content under POSE_KEY. Art exists per Mind;
|
||||
// every Mind has the shared set plus four signature poses of its own.
|
||||
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 SHARED_POSES = [
|
||||
'neutral',
|
||||
'happy',
|
||||
'amused',
|
||||
'thinking',
|
||||
'concerned',
|
||||
'annoyed',
|
||||
'surprised',
|
||||
'determined',
|
||||
] as const;
|
||||
|
||||
export const MIND_POSES: Record<string, readonly string[]> = {
|
||||
aqua: ['conduct', 'listen', 'patch', 'flourish'],
|
||||
eureka: ['probe', 'inspect', 'report', 'hood'],
|
||||
heimdall: ['badge', 'barrier', 'protect', 'revoke'],
|
||||
kiko: ['tinker', 'confetti', 'sprint', 'debug'],
|
||||
libby: ['stamp', 'research', 'shush', 'schema'],
|
||||
nibu: ['console', 'glitch', 'lore', 'triumph'],
|
||||
weksa: ['map', 'fieldwork', 'argue', 'interlingua'],
|
||||
};
|
||||
|
||||
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(MIND_POSES, c)
|
||||
);
|
||||
.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();
|
||||
const known = (SHARED_POSES as readonly string[]).includes(l) || MIND_POSES[mind]?.includes(l);
|
||||
return known ? l : NEUTRAL;
|
||||
return artPath(mind, l) ? l : NEUTRAL;
|
||||
};
|
||||
|
||||
export const poseSrc = (mind: string, pose: string, base = '/'): string =>
|
||||
`${base}public/poses/${mind}/${pose}.webp`;
|
||||
`${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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||