poses: labels only from the catalogue; client-status pose line (do not merge) #10

Closed
void wants to merge 1 commit from void/pose-catalogue into void/pose-bubble
94 changed files with 441 additions and 38 deletions

5
.gitignore vendored
View file

@ -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

View file

@ -4,3 +4,4 @@ package.json
package-lock.json
LICENSE
harness
src/app/components/message/pose/catalogue.json

View file

@ -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
View file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

24
scripts/sync-poses.mjs Normal file
View 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}`);

View 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."
}
}

View file

@ -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'
);
});
});

View file

@ -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())}`;
};

View file

@ -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">

View 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>
);
}