chat look: the three-doors mock-up in the bubble layout
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
NPM Lockfile Changes / lockfile_changes (pull_request) Has been cancelled
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
NPM Lockfile Changes / lockfile_changes (pull_request) Has been cancelled
Pose portrait beside the bubble on wide screens and floating inside it on phones with shape-outside; asymmetric bubble corner; own messages right-aligned in the accent colour; first-appearance pop and rise, staggered, off under prefers-reduced-motion; Nunito in the GameCult theme; package name and README move to Ratatoskr, Cinny credit and AGPL notice kept. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4df22558bf
commit
3043690657
12 changed files with 345 additions and 197 deletions
155
README.md
155
README.md
|
|
@ -1,128 +1,55 @@
|
|||
# Cinny
|
||||
<p>
|
||||
<a href="https://github.com/ajbura/cinny/releases">
|
||||
<img alt="GitHub release downloads" src="https://img.shields.io/github/downloads/ajbura/cinny/total?logo=github&style=social"></a>
|
||||
<a href="https://hub.docker.com/r/ajbura/cinny">
|
||||
<img alt="DockerHub downloads" src="https://img.shields.io/docker/pulls/ajbura/cinny?logo=docker&style=social"></a>
|
||||
<a href="https://fosstodon.org/@cinnyapp">
|
||||
<img alt="Follow on Mastodon" src="https://img.shields.io/mastodon/follow/106845779685925461?domain=https%3A%2F%2Ffosstodon.org&logo=mastodon&style=social"></a>
|
||||
<a href="https://twitter.com/intent/follow?screen_name=cinnyapp">
|
||||
<img alt="Follow on Twitter" src="https://img.shields.io/twitter/follow/cinnyapp?logo=twitter&style=social"></a>
|
||||
<a href="https://cinny.in/#sponsor">
|
||||
<img alt="Sponsor Cinny" src="https://img.shields.io/opencollective/all/cinny?logo=opencollective&style=social"></a>
|
||||
</p>
|
||||
# Ratatoskr
|
||||
|
||||
A Matrix client focusing primarily on simple, elegant and secure interface.
|
||||
The main goal is to have an instant messaging application that is easy on
|
||||
people and has a modern touch.
|
||||
- [Roadmap](https://github.com/orgs/cinnyapp/projects/1)
|
||||
- [Contributing](./CONTRIBUTING.md)
|
||||
GameCult's own Matrix client: the chat where members and the Minds (GameCult's agent
|
||||
Personas) talk in the same rooms. It is a fork of [Cinny](https://github.com/cinnyapp/cinny)
|
||||
4.12.8, kept under the same license, with the engine left alone and the interface rebuilt
|
||||
estate-first and mobile-first.
|
||||
|
||||
> [!IMPORTANT]
|
||||
We are currently in the process of [replacing] the matrix-js-sdk with our
|
||||
own SDK. As a result, we will not be accepting any pull requests until
|
||||
further notice. Thank you for your understanding.
|
||||
## What is here that Cinny does not have
|
||||
|
||||
[replacing]: https://github.com/cinnyapp/cinny/issues/257#issuecomment-3714406704
|
||||
Live on `main`:
|
||||
|
||||
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380">
|
||||
- **Estate navigator**: rooms are listed before you join them, opening one shows its name
|
||||
and a one-tap Join, and the menu hides behind a hamburger.
|
||||
`src/app/features/estate/`, the plan in `docs/gamecult/ratatoskr-target.md`.
|
||||
- **GameCult theme**: navy ground, orange accent, Nunito. `src/colors.css.ts`
|
||||
(`gamecultTheme`), `src/gamecult.css.ts`.
|
||||
- **Background scene** (opt-in, off by default): a WebGL2 terrain behind the chat, tiered by
|
||||
device capability. `src/app/features/scene/`, switched by `backgroundScene.tier` in
|
||||
`config.json`. The chat itself never depends on it.
|
||||
- **Soundscape** (opt-in, muted by default): `src/app/features/soundscape/`.
|
||||
|
||||
## Getting started
|
||||
The web app is available at [app.cinny.in] and gets updated on each new
|
||||
release. The `dev` branch is continuously deployed at [dev.cinny.in]
|
||||
but keep in mind that it could have things broken.
|
||||
Planned, not built: file zones with albums, the activity feed, the corner avatar and EXP bar.
|
||||
`docs/gamecult/ratatoskr-target.md` has the rulings and the cut sequence.
|
||||
|
||||
You can also download our desktop app from the [cinny-desktop repository].
|
||||
## Pose portraits
|
||||
|
||||
[app.cinny.in]: https://app.cinny.in
|
||||
[dev.cinny.in]: https://dev.cinny.in
|
||||
[cinny-desktop repository]: https://github.com/cinnyapp/cinny-desktop
|
||||
A Mind's message carries a pose label (`org.gamecult.pose`). With the Pose Portraits setting
|
||||
on, the sender's portrait stands beside the bubble (inside it, text wrapping the silhouette, on
|
||||
phones). The art is not committed: `npm run sync:poses` copies it from the cast-art
|
||||
catalogue. `src/app/components/message/pose/`.
|
||||
|
||||
## Contributing and Developing
|
||||
|
||||
If you have any ideas, issues, etc. regarding Cinny, or would like to
|
||||
contribute to Cinny through pull requests, please check out our
|
||||
["Contributing to Cinny"](CONTRIBUTING.md) document. Those who would like
|
||||
to get involved with Cinny's development as well should also read the
|
||||
["Developing Cinny"](HACKING.md) document for more technical details.
|
||||
|
||||
## Self-hosting
|
||||
To host Cinny on your own, simply download the tarball from
|
||||
[GitHub releases], and serve the files from `dist/` using your preferred
|
||||
webserver. Alternatively, you can just pull the docker image from
|
||||
[DockerHub] or [GitHub Container Registry].
|
||||
|
||||
* The default homeservers and explore pages are defined in [`config.json`](config.json).
|
||||
|
||||
* You need to set up redirects to serve the assests. Example configurations;
|
||||
[netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf),
|
||||
[caddy](contrib/caddy/caddyfile).
|
||||
* If you have trouble configuring redirects you can
|
||||
[enable hash routing](config.json#L35) — the url in the browser will have
|
||||
a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/`
|
||||
instead of `app.cinny.in/home/`) but you won't have to configure your webserver.
|
||||
|
||||
* To deploy on subdirectory, you need to rebuild the app youself after
|
||||
updating the `base` path in [`build.config.ts`](build.config.ts).
|
||||
* For example, if you want to deploy on `https://cinny.in/app`, then
|
||||
set `base: '/app'`.
|
||||
|
||||
[GitHub releases]: https://github.com/cinnyapp/cinny/releases/latest
|
||||
[DockerHub]: https://hub.docker.com/r/ajbura/cinny
|
||||
[GitHub Container Registry]: https://github.com/cinnyapp/cinny/pkgs/container/cinny
|
||||
|
||||
<details><summary><b>PGP Public Key to verify tarball</b></summary>
|
||||
## Run and build
|
||||
|
||||
```
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
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
|
||||
mxFo+ioe/ABCufSmyqFye0psX3Sp
|
||||
=WtqZ
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
npm ci
|
||||
npm start # vite dev server
|
||||
npm test # vitest
|
||||
npm run build # dist/
|
||||
```
|
||||
</details>
|
||||
|
||||
## License
|
||||
To serve `dist/`, send unknown paths to `index.html`
|
||||
([netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf),
|
||||
[caddy](contrib/caddy/caddyfile)) or turn on hash routing in [`config.json`](config.json).
|
||||
Default homeservers and the featured rooms are in `config.json`. For a sub-path, set
|
||||
`CINNY_BASE` when building.
|
||||
|
||||
## Credit and license
|
||||
|
||||
Ratatoskr is free software under the GNU Affero General Public License v3 ([LICENSE](LICENSE)).
|
||||
It is based on Cinny by Ajay Bura and contributors: Cinny's engine, components and the
|
||||
copyright below are theirs. The in-app About page credits Cinny and links the source.
|
||||
|
||||
Cinny Project
|
||||
Copyright © 2024–present Ajay Bura
|
||||
https://cinny.in
|
||||
|
||||
Cinny is licensed under the GNU Affero General Public License,
|
||||
Version 3 of the License (AGPL-3.0-only).
|
||||
You may obtain a copy of the License at https://www.gnu.org/licenses/agpl-3.0.html
|
||||
https://cinny.in
|
||||
|
|
|
|||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
|||
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||
"@fontsource-variable/nunito": "5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "5.2.5",
|
||||
"@fontsource/inter": "4.5.14",
|
||||
"@fontsource/montserrat": "5.2.5",
|
||||
|
|
@ -2192,6 +2193,15 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/nunito": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/nunito/-/nunito-5.3.0.tgz",
|
||||
"integrity": "sha512-tw0ch7evIDSwoeUy+0XzocYHfbRWLeCRPJy22KgbrLjDLAbNw/fErBiil10Ssrs3Sjr55zaZrr36a975j8cBkQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/ibm-plex-mono": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.5.tgz",
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "cinny",
|
||||
"name": "ratatoskr",
|
||||
"version": "4.12.8",
|
||||
"description": "Yet another matrix client",
|
||||
"main": "index.js",
|
||||
|
|
@ -38,7 +38,11 @@
|
|||
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||
"@fontsource-variable/nunito": "5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "5.2.5",
|
||||
"@fontsource/inter": "4.5.14",
|
||||
"@fontsource/montserrat": "5.2.5",
|
||||
"@fontsource/ubuntu": "5.2.5",
|
||||
"@tanstack/react-query": "5.24.1",
|
||||
"@tanstack/react-query-devtools": "5.24.1",
|
||||
"@tanstack/react-virtual": "3.2.0",
|
||||
|
|
@ -89,10 +93,7 @@
|
|||
"slate-dom": "0.123.0",
|
||||
"slate-history": "0.113.1",
|
||||
"slate-react": "0.123.0",
|
||||
"ua-parser-js": "1.0.35",
|
||||
"@fontsource/ubuntu": "5.2.5",
|
||||
"@fontsource/montserrat": "5.2.5",
|
||||
"@fontsource/ibm-plex-mono": "5.2.5"
|
||||
"ua-parser-js": "1.0.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-call-embedded": "0.20.1",
|
||||
|
|
|
|||
|
|
@ -5,12 +5,11 @@ import * as css from './layout.css';
|
|||
|
||||
type BubbleArrowProps = {
|
||||
variant: ContainerColor;
|
||||
pose?: boolean;
|
||||
};
|
||||
function BubbleLeftArrow({ variant, pose }: BubbleArrowProps) {
|
||||
function BubbleLeftArrow({ variant }: BubbleArrowProps) {
|
||||
return (
|
||||
<svg
|
||||
className={classNames(css.BubbleLeftArrow, pose && css.BubbleArrowPose)}
|
||||
className={css.BubbleLeftArrow}
|
||||
width="9"
|
||||
height="8"
|
||||
viewBox="0 0 9 8"
|
||||
|
|
@ -29,46 +28,80 @@ function BubbleLeftArrow({ variant, pose }: BubbleArrowProps) {
|
|||
|
||||
type BubbleLayoutProps = {
|
||||
hideBubble?: boolean;
|
||||
pose?: boolean;
|
||||
/** The sender is the signed-in user: right-aligned, in the accent colour, no avatar. */
|
||||
own?: boolean;
|
||||
/** A Mind's pose portrait; it replaces the avatar and sits beside (wide) or inside (phone) the bubble. */
|
||||
pose?: ReactNode;
|
||||
/** On phones the portrait floats on the other side. */
|
||||
alt?: boolean;
|
||||
/** Delay in ms before the first-appearance animation; undefined plays none. */
|
||||
enterDelay?: number;
|
||||
before?: ReactNode;
|
||||
header?: ReactNode;
|
||||
};
|
||||
|
||||
export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
||||
({ 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">
|
||||
{header}
|
||||
{hideBubble ? (
|
||||
children
|
||||
) : (
|
||||
<Box>
|
||||
<Box
|
||||
className={
|
||||
hideBubble
|
||||
? undefined
|
||||
: classNames(
|
||||
css.BubbleContent,
|
||||
before ? css.BubbleContentArrowLeft : undefined,
|
||||
pose && css.BubbleContentPoseStacked
|
||||
)
|
||||
}
|
||||
direction="Column"
|
||||
>
|
||||
{before ? <BubbleLeftArrow variant="SurfaceVariant" pose={pose} /> : null}
|
||||
{children}
|
||||
</Box>
|
||||
({ hideBubble, own, pose, alt, enterDelay, before, header, children, style, ...props }, ref) => {
|
||||
// `false` is a Mind's message continuing a run: aligned with the portraits, showing none.
|
||||
const poseRow = pose !== undefined;
|
||||
const arrow = !!before && !poseRow && !own;
|
||||
const entering = enterDelay !== undefined;
|
||||
return (
|
||||
<Box
|
||||
gap="300"
|
||||
{...props}
|
||||
className={classNames(
|
||||
poseRow && css.BubbleRowPose,
|
||||
pose && css.BubbleRowPortrait,
|
||||
own && css.BubbleRowOwn,
|
||||
props.className
|
||||
)}
|
||||
style={entering ? { ...style, ['--enter-delay' as string]: `${enterDelay}ms` } : style}
|
||||
ref={ref}
|
||||
>
|
||||
{!poseRow && !own && (
|
||||
<Box className={css.BubbleBefore} shrink="No">
|
||||
{before}
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
grow={own ? 'No' : 'Yes'}
|
||||
direction="Column"
|
||||
className={classNames(poseRow && css.BubbleColumnPose, own && css.BubbleColumnOwn)}
|
||||
>
|
||||
{header && (
|
||||
<div
|
||||
className={classNames(
|
||||
pose && css.BubbleHeaderPose,
|
||||
pose && alt && css.BubbleHeaderPoseAlt,
|
||||
own && css.BubbleHeaderOwn
|
||||
)}
|
||||
>
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
{hideBubble ? (
|
||||
children
|
||||
) : (
|
||||
<Box>
|
||||
<Box
|
||||
className={classNames(
|
||||
css.BubbleContent,
|
||||
arrow && css.BubbleContentArrowLeft,
|
||||
poseRow && css.BubbleContentPose,
|
||||
own && css.BubbleContentOwn,
|
||||
entering && css.EnterBubble
|
||||
)}
|
||||
direction="Column"
|
||||
>
|
||||
{arrow ? <BubbleLeftArrow variant="SurfaceVariant" /> : null}
|
||||
{pose}
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -138,39 +138,12 @@ 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,
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderRadius: config.radii.R500,
|
||||
position: 'relative',
|
||||
});
|
||||
|
|
@ -179,30 +152,171 @@ export const BubbleContentArrowLeft = style({
|
|||
borderTopLeftRadius: 0,
|
||||
});
|
||||
|
||||
export const BubbleContentPoseStacked = style({
|
||||
// Look of the three-doors mock-up (chat.gamecult.org/mockups/three-doors): a Mind's pose sits
|
||||
// beside its bubble on wide screens; on phones it floats inside the bubble, overhanging the top
|
||||
// edge, and the text wraps around its silhouette (shape-outside on the alpha channel).
|
||||
const PHONE = 'screen and (max-width: 700px)';
|
||||
const PORTRAIT = toRem(124);
|
||||
const POSE_GUTTER = toRem(14);
|
||||
const BUBBLE_CORNER = toRem(6);
|
||||
const POP = 'cubic-bezier(0.34, 1.7, 0.64, 1)';
|
||||
const RISE = 'cubic-bezier(0.34, 1.45, 0.64, 1)';
|
||||
|
||||
export const BubbleRowPose = style({
|
||||
position: 'relative',
|
||||
paddingLeft: `calc(${PORTRAIT} + ${POSE_GUTTER})`,
|
||||
'@media': {
|
||||
'screen and (max-width: 750px)': {
|
||||
borderTopLeftRadius: config.radii.R500,
|
||||
[PHONE]: {
|
||||
paddingLeft: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// A row that shows a portrait: room for it to hang above the bubble on phones.
|
||||
export const BubbleRowPortrait = style({
|
||||
minHeight: PORTRAIT,
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
minHeight: 0,
|
||||
marginTop: toRem(58),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BubbleRowOwn = style({
|
||||
justifyContent: 'flex-end',
|
||||
});
|
||||
|
||||
export const BubbleColumnOwn = style({
|
||||
alignItems: 'flex-end',
|
||||
maxWidth: toRem(640),
|
||||
});
|
||||
|
||||
export const BubbleHeaderOwn = style({
|
||||
alignSelf: 'flex-end',
|
||||
});
|
||||
|
||||
// Phone: the name is a pill straddling the bubble's top edge, on the side opposite the portrait.
|
||||
export const BubbleHeaderPose = style({
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
position: 'absolute',
|
||||
zIndex: 2,
|
||||
top: toRem(-15),
|
||||
right: toRem(14),
|
||||
padding: `0 ${toRem(13)}`,
|
||||
background: color.Secondary.Container,
|
||||
border: `1px solid ${color.Secondary.ContainerLine}`,
|
||||
borderRadius: toRem(999),
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BubbleHeaderPoseAlt = style({
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
right: 'auto',
|
||||
left: toRem(14),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BubbleColumnPose = style({
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const BubbleContentPose = style({
|
||||
borderRadius: config.radii.R500,
|
||||
borderTopLeftRadius: BUBBLE_CORNER,
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
display: 'flow-root',
|
||||
borderTopLeftRadius: config.radii.R500,
|
||||
padding: `${toRem(24)} ${toRem(14)} ${toRem(14)}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BubbleContentOwn = style({
|
||||
backgroundColor: color.Primary.Container,
|
||||
color: color.Primary.OnContainer,
|
||||
border: `1px solid ${color.Primary.ContainerLine}`,
|
||||
borderRadius: config.radii.R500,
|
||||
borderTopRightRadius: BUBBLE_CORNER,
|
||||
});
|
||||
|
||||
export const PoseButton = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: `calc(-1 * (${PORTRAIT} + ${POSE_GUTTER}))`,
|
||||
width: PORTRAIT,
|
||||
height: PORTRAIT,
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
lineHeight: 0,
|
||||
transformOrigin: '50% 90%',
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
position: 'static',
|
||||
float: 'left',
|
||||
width: 'min(50vw, 210px)',
|
||||
height: 'auto',
|
||||
margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`,
|
||||
shapeMargin: toRem(7),
|
||||
shapeImageThreshold: 0.35,
|
||||
WebkitMaskImage: 'linear-gradient(#000 84%, transparent)',
|
||||
maskImage: 'linear-gradient(#000 84%, transparent)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const PoseButtonAlt = style({
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
float: 'right',
|
||||
margin: `${toRem(-82)} ${toRem(-12)} 0 ${toRem(8)}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const PosePortrait = style({
|
||||
width: toRem(64),
|
||||
height: toRem(64),
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'top',
|
||||
'@media': {
|
||||
'screen and (max-width: 750px)': {
|
||||
width: toRem(48),
|
||||
height: toRem(48),
|
||||
[PHONE]: {
|
||||
height: 'auto',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const pop = keyframes({
|
||||
from: { opacity: 0, transform: `translateY(${toRem(34)}) scale(0.42)` },
|
||||
to: { opacity: 1, transform: 'none' },
|
||||
});
|
||||
const rise = keyframes({
|
||||
from: { opacity: 0, transform: `translateY(${toRem(14)}) scale(0.96)` },
|
||||
to: { opacity: 1, transform: 'none' },
|
||||
});
|
||||
|
||||
// First appearance of a message: the portrait pops, the bubble rises just behind it. The delay
|
||||
// (a --enter-delay on the row) staggers messages that arrive together.
|
||||
export const EnterPortrait = style({
|
||||
'@media': {
|
||||
'(prefers-reduced-motion: no-preference)': {
|
||||
animation: `${pop} 460ms ${POP} var(--enter-delay, 0ms) both`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const EnterBubble = style({
|
||||
'@media': {
|
||||
'(prefers-reduced-motion: no-preference)': {
|
||||
animation: `${rise} 380ms ${RISE} calc(var(--enter-delay, 0ms) + 50ms) both`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as css from '../layout/layout.css';
|
||||
import { poseSrc } from './poses';
|
||||
|
||||
|
|
@ -6,25 +7,26 @@ type PoseAvatarProps = {
|
|||
mind: string;
|
||||
pose: string;
|
||||
userId: string;
|
||||
/** Float on the right on phones. */
|
||||
alt?: boolean;
|
||||
/** Play the first-appearance pop. */
|
||||
enter?: boolean;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
export function PoseAvatar({ mind, pose, userId, onClick }: PoseAvatarProps) {
|
||||
export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvatarProps) {
|
||||
const src = poseSrc(mind, pose, import.meta.env.BASE_URL);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.PoseButton}
|
||||
className={classNames(css.PoseButton, alt && css.PoseButtonAlt, enter && css.EnterPortrait)}
|
||||
// On phones the bubble text wraps around the portrait's silhouette.
|
||||
style={{ shapeOutside: `url(${src})` }}
|
||||
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}
|
||||
/>
|
||||
<img className={css.PosePortrait} src={src} alt={`${mind}, ${pose}`} draggable={false} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
19
src/app/components/message/pose/entrance.test.ts
Normal file
19
src/app/components/message/pose/entrance.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { claimEntrance } from './entrance';
|
||||
|
||||
describe('claimEntrance', () => {
|
||||
it('animates a message once, never on a remount', () => {
|
||||
expect(claimEntrance('$once', 1_000_000)).toBe(0);
|
||||
expect(claimEntrance('$once', 1_000_500)).toBeUndefined();
|
||||
});
|
||||
it('staggers messages that mount together, capped so a long backlog still lands fast', () => {
|
||||
const t = 2_000_000;
|
||||
const delays = ['$a', '$b', '$c', '$d', '$e', '$f'].map((id) => claimEntrance(id, t));
|
||||
expect(delays.slice(0, 3)).toEqual([0, 300, 600]);
|
||||
expect(Math.max(...(delays as number[]))).toBe(900);
|
||||
});
|
||||
it('lets a lone later message in at once', () => {
|
||||
claimEntrance('$x', 3_000_000);
|
||||
expect(claimEntrance('$y', 3_010_000)).toBe(0);
|
||||
});
|
||||
});
|
||||
22
src/app/components/message/pose/entrance.ts
Normal file
22
src/app/components/message/pose/entrance.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
// A message animates in the first time it is shown, not each time the virtualised timeline
|
||||
// remounts it. Messages mounting together are staggered so a screenful does not pop at once.
|
||||
const STEP_MS = 300;
|
||||
const MAX_DELAY_MS = 900;
|
||||
const SEEN_CAP = 2000;
|
||||
|
||||
const seen = new Set<string>();
|
||||
let nextSlot = 0;
|
||||
|
||||
export const claimEntrance = (id: string, now: number): number | undefined => {
|
||||
if (seen.has(id)) return undefined;
|
||||
if (seen.size >= SEEN_CAP) seen.clear();
|
||||
seen.add(id);
|
||||
const start = Math.max(nextSlot, now);
|
||||
nextSlot = Math.min(start + STEP_MS, now + MAX_DELAY_MS);
|
||||
return Math.min(start - now, MAX_DELAY_MS);
|
||||
};
|
||||
|
||||
export const useEntranceDelay = (id: string | undefined): number | undefined =>
|
||||
useState(() => (id ? claimEntrance(id, performance.now()) : undefined))[0];
|
||||
|
|
@ -11,6 +11,7 @@ type MindEntry = {
|
|||
signatures: Record<string, string>;
|
||||
};
|
||||
const minds = catalogue.minds as Record<string, MindEntry>;
|
||||
export const MINDS = Object.keys(minds);
|
||||
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -61,7 +61,8 @@ import {
|
|||
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 { POSE_KEY, MINDS, mindOfSender, resolvePose } from '../../../components/message/pose/poses';
|
||||
import { useEntranceDelay } from '../../../components/message/pose/entrance';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||
import * as css from './styles.css';
|
||||
|
|
@ -797,6 +798,11 @@ export const Message = as<'div', MessageProps>(
|
|||
? mindOfSender(getMxIdLocalPart(senderId), senderDisplayName)
|
||||
: undefined;
|
||||
|
||||
const own = senderId === mx.getUserId();
|
||||
const enterDelay = useEntranceDelay(mEvent.getId());
|
||||
// Minds alternate sides on phones, each always on the same one.
|
||||
const poseAlt = !!mind && MINDS.indexOf(mind) % 2 === 1;
|
||||
|
||||
const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
|
||||
<AvatarBase
|
||||
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
|
||||
|
|
@ -827,6 +833,8 @@ export const Message = as<'div', MessageProps>(
|
|||
mind={mind}
|
||||
pose={resolvePose(mind, mEvent.getContent()[POSE_KEY])}
|
||||
userId={senderId}
|
||||
alt={poseAlt}
|
||||
enter={enterDelay !== undefined}
|
||||
onClick={onUserClick}
|
||||
/>
|
||||
);
|
||||
|
|
@ -1154,9 +1162,14 @@ export const Message = as<'div', MessageProps>(
|
|||
)}
|
||||
{messageLayout === MessageLayout.Bubble && (
|
||||
<BubbleLayout
|
||||
pose={!!mind}
|
||||
before={mind ? poseJSX : avatarJSX}
|
||||
header={headerJSX} onContextMenu={handleContextMenu}>
|
||||
own={own}
|
||||
pose={mind ? poseJSX : undefined}
|
||||
alt={poseAlt}
|
||||
enterDelay={enterDelay}
|
||||
before={avatarJSX}
|
||||
header={headerJSX}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{msgContentJSX}
|
||||
</BubbleLayout>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -18,3 +18,8 @@ globalStyle(scope, {
|
|||
[config.lineHeight.D400]: '1.7',
|
||||
},
|
||||
});
|
||||
|
||||
// Nunito for the GameCult look (the three-doors mock-up); the stock themes keep Inter.
|
||||
globalStyle(scope, {
|
||||
fontFamily: "'Nunito Variable', var(--font-secondary)",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React from 'react';
|
|||
import { createRoot } from 'react-dom/client';
|
||||
import { enableMapSet } from 'immer';
|
||||
import '@fontsource/inter/variable.css';
|
||||
import '@fontsource-variable/nunito/index.css';
|
||||
import '@fontsource/ubuntu/300.css';
|
||||
import '@fontsource/ubuntu/400.css';
|
||||
import '@fontsource/ubuntu/500.css';
|
||||
|
|
|
|||
Loading…
Reference in a new issue