Merge remote-tracking branch 'origin/void/pose-catalogue' into void/three-doors-look

This commit is contained in:
Void 2026-11-05 10:44:31 +00:00
commit db023bc033
17 changed files with 629 additions and 11 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

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

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

View file

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

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

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

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

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

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

View file

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

View file

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

View file

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

View file

@ -42,6 +42,10 @@ const copyFiles = {
src: 'public/locales',
dest: 'public/',
},
{
src: 'public/poses',
dest: 'public/',
},
],
};