diff --git a/.gitignore b/.gitignore index 1af58a97..4e7d929d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ node_modules devAssets .DS_Store -.idea \ No newline at end of file +.idea +# Pose art is synced from the cast-art catalogue (npm run sync:poses), not committed. +public/poses/* +!public/poses/.gitkeep diff --git a/.prettierignore b/.prettierignore index 036ee8bc..c81e668b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,4 @@ package.json package-lock.json LICENSE harness +src/app/components/message/pose/catalogue.json diff --git a/package.json b/package.json index 298fbf36..0e470460 100644 --- a/package.json +++ b/package.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", diff --git a/public/poses/.gitkeep b/public/poses/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/scripts/sync-poses.mjs b/scripts/sync-poses.mjs new file mode 100644 index 00000000..9a5b1f83 --- /dev/null +++ b/scripts/sync-poses.mjs @@ -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}`); diff --git a/src/app/components/message/layout/Bubble.tsx b/src/app/components/message/layout/Bubble.tsx index 93ff84bc..7fc5171c 100644 --- a/src/app/components/message/layout/Bubble.tsx +++ b/src/app/components/message/layout/Bubble.tsx @@ -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 ( ( - ({ hideBubble, before, header, children, ...props }, ref) => ( - - + ({ hideBubble, pose, before, header, children, ...props }, ref) => ( + + {before} @@ -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 ? : null} + {before ? : null} {children} diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index cc2cd0c6..cb53a62d 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -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), diff --git a/src/app/components/message/pose/PoseAvatar.tsx b/src/app/components/message/pose/PoseAvatar.tsx new file mode 100644 index 00000000..ed8bc485 --- /dev/null +++ b/src/app/components/message/pose/PoseAvatar.tsx @@ -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; +}; + +export function PoseAvatar({ mind, pose, userId, onClick }: PoseAvatarProps) { + return ( + {`${mind}, + + ); +} diff --git a/src/app/components/message/pose/catalogue.json b/src/app/components/message/pose/catalogue.json new file mode 100644 index 00000000..87e7ba71 --- /dev/null +++ b/src/app/components/message/pose/catalogue.json @@ -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." + } +} \ No newline at end of file diff --git a/src/app/components/message/pose/poses.test.ts b/src/app/components/message/pose/poses.test.ts new file mode 100644 index 00000000..ed503abe --- /dev/null +++ b/src/app/components/message/pose/poses.test.ts @@ -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' + ); + }); +}); diff --git a/src/app/components/message/pose/poses.ts b/src/app/components/message/pose/poses.ts new file mode 100644 index 00000000..01fb89e1 --- /dev/null +++ b/src/app/components/message/pose/poses.ts @@ -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; + signatures: Record; +}; +const minds = catalogue.minds as Record; + +// 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) => 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(); + 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())}`; +}; diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index a4f0d7af..c35c3c8c 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -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} /> + diff --git a/src/app/features/room/RoomViewPoseStatus.tsx b/src/app/features/room/RoomViewPoseStatus.tsx new file mode 100644 index 00000000..33d0d80f --- /dev/null +++ b/src/app/features/room/RoomViewPoseStatus.tsx @@ -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 ( + + {statuses.map((s) => ( + + {poseStatusLine(s)} + + ))} + + ); +} diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 2d87e9bd..42d74a6b 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -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>( ); + const [poseBubbles] = useSetting(settingsAtom, 'poseBubbles'); + const mind = + poseBubbles && messageLayout === MessageLayout.Bubble + ? mindOfSender(getMxIdLocalPart(senderId), senderDisplayName) + : undefined; + const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && ( ( ); + const poseJSX = !collapse && mind && ( + + ); + const msgContentJSX = ( {reply} @@ -1135,7 +1153,10 @@ export const Message = as<'div', MessageProps>( )} {messageLayout === MessageLayout.Bubble && ( - + {msgContentJSX} )} diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index b861a060..5b51cd8c 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -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() { } /> + + } + /> +