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/README.md b/README.md
index 46c7a67b..66d9114a 100644
--- a/README.md
+++ b/README.md
@@ -1,128 +1,55 @@
-# Cinny
-
-
-
-
-
-
-
-
-
-
-
-
+# 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`:
-
+- **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
-
-PGP Public Key to verify tarball
+## Run and build
```
------BEGIN PGP PUBLIC KEY BLOCK-----
-
-mQGNBGJw/g0BDAC8qQeLqDMzYzfPyOmRlHVEoguVTo+eo1aVdQH2X7OELdjjBlyj
-6d6c1adv/uF2g83NNMoQY7GEeHjRnXE4m8kYSaarb840pxrYUagDc0dAbJOGaCBY
-FKTo7U1Kvg0vdiaRuus0pvc1NVdXSxRNQbFXBSwduD+zn66TI3HfcEHNN62FG1cE
-K1jWDwLAU0P3kKmj8+CAc3h9ZklPu0k/+t5bf/LJkvdBJAUzGZpehbPL5f3u3BZ0
-leZLIrR8uV7PiV5jKFahxlKR5KQHld8qQm+qVhYbUzpuMBGmh419I6UvTzxuRcvU
-Frn9ttCEzV55Y+so4X2e4ZnB+5gOnNw+ecifGVdj/+UyWnqvqqDvLrEjjK890nLb
-Pil4siecNMEpiwAN6WSmKpWaCwQAHEGDVeZCc/kT0iYfj5FBcsTVqWiO6eaxkUlm
-jnulqWqRrlB8CJQQvih/g//uSEBdzIibo+ro+3Jpe120U/XVUH62i9HoRQEm6ADG
-4zS5hIq4xyA8fL8AEQEAAbQdQ2lubnlBcHAgPGNpbm55YXBwQGdtYWlsLmNvbT6J
-AdQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQSRri2MHidaaZv+
-vvuUMwx6UK/M8wUCZqEDwAUJFvwIswAKCRCUMwx6UK/M877qC/4lxXOQIoWnLLkK
-YiRCTkGsH6NdxgeYr6wpXT4xuQ45ZxCytwHpOGQmO/5up5961TxWW8D1frRIJHjj
-AZGoRCL3EKEuY8nt3D99fpf3DvZrs1uoVAhiyn737hRlZAg+QsJheeGCmdSJ0hX5
-Yud8SE+9zxLS1+CEjMrsUd/RGre/phme+wNXfaHfREAC9ewolgVChPIbMxG2f+vs
-K8Xv52BFng7ta9fgsl1XuOjpuaSbQv6g+4ONk/lxKF0SmnhEGM3dmIYPONxW47Yf
-atnIjRra/YhPTNwrNBGMmG4IFKaOsMbjW/eakjWTWOVKKJNBMoDdRcYYWIMCpLy8
-AQUrMtQEsHSnqCwrw818S5A6rrhcfVGk36RGm0nOy6LS5g5jmqaYsvbCcBGY9B2c
-SUAVNm17oo7TtEajk8hcSXoZod1t++pyjcVKEmSn3nFK7v5m3V+cPhNTxZMK459P
-3x1Ucqj/kTqrxKw6s2Uknuk0ajmw0ljV+BQwgL6maguo9BKgCNW5AY0EYnD+DQEM
-ANOu/d6ZMF8bW+Df9RDCUQKytbaZfa+ZbIHBus7whCD/SQMOhPKntv3HX7SmMCs+
-5i27kJMu4YN623JCS7hdCoXVO1R5kXCEcneW/rPBMDutaM472YvIWMIqK9Wwl5+0
-Piu2N+uTkKhe9uS2u7eN+Khef3d7xfjGRxoppM+xI9dZO+jhYiy8LuC0oBohTjJq
-QPqfGDpowBwRkkOsGz/XVcesJ1Pzg4bKivTS9kZjZSyT9RRSY8As0sVUN57AwYul
-s1+eh00n/tVpi2Jj9pCm7S0csSXvXj8v2OTdK1jt4YjpzR0/rwh4+/xlOjDjZEqH
-vMPhpzpbgnwkxZ3X8BFne9dJ3maC5zQ3LAeCP5m1W0hXzagYhfyjo74slJgD1O8c
-LDf2Oxc5MyM8Y/UK497zfqSPfgT3NhQmhHzk83DjXw3I6Z3A3U+Jp61w0eBRI1nx
-H1UIG+gldcAKUTcfwL0lghoT3nmi9JAbvek0Smhz00Bbo8/dx8vwQRxDUxlt7Exx
-NwARAQABiQG8BBgBCAAmAhsMFiEEka4tjB4nWmmb/r77lDMMelCvzPMFAmahA9IF
-CRb8CMUACgkQlDMMelCvzPPQgQv/d5/z+fxgKqgfhQX+V49X4WgTVxZ/CzztDoJ1
-XAq1dzTNEy8AFguXIo6eVXPSpMxec7ZreN3+UPQBnCf3eR5YxWNYOYKmk0G4E8D2
-KGUJept7TSA42/8N2ov6tToXFg4CgzKZj0fYLwgutly7K8eiWmSU6ptaO8aEQBHB
-gTGIOO3h6vJMGVycmoeRnHjv4wV84YWSVFSoJ7cY0he4Z9UznJBbE/KHZjrkXsPo
-N+Gg5lDuOP5xjKzM5SogV9lhxBAhMWAg3URUF15yruZBiA8uV1FOK8sal/9C1G7V
-M6ygA6uOZqXlZtcdA94RoSsW2pZ9eLVPsxz2B3Zko7tu11MpNP/wYmfGTI3KxZBj
-n/eodvwjJSgHpGOFSmbNzvPJo3to5nNlp7wH1KxIMc6Uuu9hgfDfwkFZgV2bnFIa
-Q6gyF548Ub48z7Dz83+WwLgbX19ve4oZx+dqSdczP6ILHRQomtrzrkkP2LU52oI5
-mxFo+ioe/ABCufSmyqFye0psX3Sp
-=WtqZ
------END PGP PUBLIC KEY BLOCK-----
+npm ci
+npm start # vite dev server
+npm test # vitest
+npm run build # dist/
```
-
-## 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
\ No newline at end of file
+https://cinny.in
diff --git a/index.html b/index.html
index 2eefeee2..7242f309 100644
--- a/index.html
+++ b/index.html
@@ -4,21 +4,17 @@
- Cinny
-
-
+ Ratatoskr
+
+
-
+
-
-
-
+
+
-
-
+
+
diff --git a/package-lock.json b/package-lock.json
index dc774000..0817f2b2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 298fbf36..8ec86dea 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,5 @@
{
- "name": "cinny",
+ "name": "ratatoskr",
"version": "4.12.8",
"description": "Yet another matrix client",
"main": "index.js",
@@ -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",
@@ -37,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",
@@ -88,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",
diff --git a/public/favicon.ico b/public/favicon.ico
index 91671306..cfce9943 100644
Binary files a/public/favicon.ico and b/public/favicon.ico differ
diff --git a/public/manifest.json b/public/manifest.json
index acdcd004..81fd714c 100644
--- a/public/manifest.json
+++ b/public/manifest.json
@@ -1,7 +1,7 @@
{
- "name": "Cinny",
- "short_name": "Cinny",
- "description": "Yet another matrix client",
+ "name": "Ratatoskr",
+ "short_name": "Ratatoskr",
+ "description": "GameCult's Matrix client",
"dir": "auto",
"lang": "en-US",
"display": "standalone",
diff --git a/public/poses/.gitkeep b/public/poses/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/public/res/android/android-chrome-144x144.png b/public/res/android/android-chrome-144x144.png
index 8c8c2668..d76ed356 100644
Binary files a/public/res/android/android-chrome-144x144.png and b/public/res/android/android-chrome-144x144.png differ
diff --git a/public/res/android/android-chrome-192x192.png b/public/res/android/android-chrome-192x192.png
index 6d53edb5..7689b6d0 100644
Binary files a/public/res/android/android-chrome-192x192.png and b/public/res/android/android-chrome-192x192.png differ
diff --git a/public/res/android/android-chrome-256x256.png b/public/res/android/android-chrome-256x256.png
index 57378b41..2f61f46b 100644
Binary files a/public/res/android/android-chrome-256x256.png and b/public/res/android/android-chrome-256x256.png differ
diff --git a/public/res/android/android-chrome-36x36.png b/public/res/android/android-chrome-36x36.png
index 507c4439..91b26af2 100644
Binary files a/public/res/android/android-chrome-36x36.png and b/public/res/android/android-chrome-36x36.png differ
diff --git a/public/res/android/android-chrome-384x384.png b/public/res/android/android-chrome-384x384.png
index 92fd32c7..ab06a074 100644
Binary files a/public/res/android/android-chrome-384x384.png and b/public/res/android/android-chrome-384x384.png differ
diff --git a/public/res/android/android-chrome-48x48.png b/public/res/android/android-chrome-48x48.png
index 489cfd32..f8fafcd9 100644
Binary files a/public/res/android/android-chrome-48x48.png and b/public/res/android/android-chrome-48x48.png differ
diff --git a/public/res/android/android-chrome-512x512.png b/public/res/android/android-chrome-512x512.png
index d312b532..2c584f3d 100644
Binary files a/public/res/android/android-chrome-512x512.png and b/public/res/android/android-chrome-512x512.png differ
diff --git a/public/res/android/android-chrome-72x72.png b/public/res/android/android-chrome-72x72.png
index 8e04930c..32d5d516 100644
Binary files a/public/res/android/android-chrome-72x72.png and b/public/res/android/android-chrome-72x72.png differ
diff --git a/public/res/android/android-chrome-96x96.png b/public/res/android/android-chrome-96x96.png
index 44981d96..5da68e4b 100644
Binary files a/public/res/android/android-chrome-96x96.png and b/public/res/android/android-chrome-96x96.png differ
diff --git a/public/res/apple/apple-touch-icon-114x114.png b/public/res/apple/apple-touch-icon-114x114.png
index 418f8833..1ff43a12 100644
Binary files a/public/res/apple/apple-touch-icon-114x114.png and b/public/res/apple/apple-touch-icon-114x114.png differ
diff --git a/public/res/apple/apple-touch-icon-120x120.png b/public/res/apple/apple-touch-icon-120x120.png
index 1d570eba..d10efb31 100644
Binary files a/public/res/apple/apple-touch-icon-120x120.png and b/public/res/apple/apple-touch-icon-120x120.png differ
diff --git a/public/res/apple/apple-touch-icon-144x144.png b/public/res/apple/apple-touch-icon-144x144.png
index b6a731eb..d76ed356 100644
Binary files a/public/res/apple/apple-touch-icon-144x144.png and b/public/res/apple/apple-touch-icon-144x144.png differ
diff --git a/public/res/apple/apple-touch-icon-152x152.png b/public/res/apple/apple-touch-icon-152x152.png
index 968ae2f0..76590ddf 100644
Binary files a/public/res/apple/apple-touch-icon-152x152.png and b/public/res/apple/apple-touch-icon-152x152.png differ
diff --git a/public/res/apple/apple-touch-icon-167x167.png b/public/res/apple/apple-touch-icon-167x167.png
index a41d0d10..3f26f91b 100644
Binary files a/public/res/apple/apple-touch-icon-167x167.png and b/public/res/apple/apple-touch-icon-167x167.png differ
diff --git a/public/res/apple/apple-touch-icon-180x180.png b/public/res/apple/apple-touch-icon-180x180.png
index a651cd1c..ed498370 100644
Binary files a/public/res/apple/apple-touch-icon-180x180.png and b/public/res/apple/apple-touch-icon-180x180.png differ
diff --git a/public/res/apple/apple-touch-icon-57x57.png b/public/res/apple/apple-touch-icon-57x57.png
index f5ff2ea5..2e2081f5 100644
Binary files a/public/res/apple/apple-touch-icon-57x57.png and b/public/res/apple/apple-touch-icon-57x57.png differ
diff --git a/public/res/apple/apple-touch-icon-60x60.png b/public/res/apple/apple-touch-icon-60x60.png
index 47fafb07..87c8fc2b 100644
Binary files a/public/res/apple/apple-touch-icon-60x60.png and b/public/res/apple/apple-touch-icon-60x60.png differ
diff --git a/public/res/apple/apple-touch-icon-72x72.png b/public/res/apple/apple-touch-icon-72x72.png
index 6e5b13c4..32d5d516 100644
Binary files a/public/res/apple/apple-touch-icon-72x72.png and b/public/res/apple/apple-touch-icon-72x72.png differ
diff --git a/public/res/apple/apple-touch-icon-76x76.png b/public/res/apple/apple-touch-icon-76x76.png
index 7799a6fd..1164b72d 100644
Binary files a/public/res/apple/apple-touch-icon-76x76.png and b/public/res/apple/apple-touch-icon-76x76.png differ
diff --git a/public/res/svg/cinny-highlight.svg b/public/res/svg/cinny-highlight.svg
deleted file mode 100644
index d59b42a1..00000000
--- a/public/res/svg/cinny-highlight.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-
diff --git a/public/res/svg/cinny-unread.svg b/public/res/svg/cinny-unread.svg
deleted file mode 100644
index be301375..00000000
--- a/public/res/svg/cinny-unread.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-
diff --git a/public/res/svg/cinny.svg b/public/res/svg/cinny.svg
deleted file mode 100644
index 8701d67b..00000000
--- a/public/res/svg/cinny.svg
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
\ No newline at end of file
diff --git a/public/res/svg/ratatoskr-highlight.svg b/public/res/svg/ratatoskr-highlight.svg
new file mode 100644
index 00000000..661442d4
--- /dev/null
+++ b/public/res/svg/ratatoskr-highlight.svg
@@ -0,0 +1,17 @@
+
diff --git a/public/res/svg/ratatoskr-unread.svg b/public/res/svg/ratatoskr-unread.svg
new file mode 100644
index 00000000..69a08c95
--- /dev/null
+++ b/public/res/svg/ratatoskr-unread.svg
@@ -0,0 +1,17 @@
+
diff --git a/public/res/svg/ratatoskr.svg b/public/res/svg/ratatoskr.svg
new file mode 100644
index 00000000..3ca98122
--- /dev/null
+++ b/public/res/svg/ratatoskr.svg
@@ -0,0 +1,15 @@
+
diff --git a/scripts/gen-brand.mjs b/scripts/gen-brand.mjs
new file mode 100644
index 00000000..fa44bfde
--- /dev/null
+++ b/scripts/gen-brand.mjs
@@ -0,0 +1,202 @@
+// Draws the Ratatoskr squirrel once, as shapes, and writes the SVG logos plus the
+// favicon and app-icon PNGs from the same shapes (no image tools needed).
+// Run: node scripts/gen-brand.mjs
+import { deflateSync } from 'node:zlib';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..');
+const BG = '#1c1b22';
+
+// Squirrel facing right on a 64x64 grid. Later shapes draw over earlier ones.
+const squirrel = [
+ { t: 'ellipse', cx: 19, cy: 33, rx: 13, ry: 22, rot: -15, fill: '#d9742b' },
+ { t: 'ellipse', cx: 21, cy: 33, rx: 7, ry: 15, rot: -15, fill: '#f0a45d' },
+ { t: 'ellipse', cx: 36, cy: 43, rx: 11, ry: 14, rot: 0, fill: '#e0833a' },
+ { t: 'ellipse', cx: 40, cy: 46, rx: 6, ry: 9, rot: 0, fill: '#f6dcb8' },
+ {
+ t: 'poly',
+ pts: [
+ [35, 19],
+ [36, 7],
+ [43, 15],
+ ],
+ fill: '#c9672a',
+ },
+ {
+ t: 'poly',
+ pts: [
+ [44, 15],
+ [50, 7],
+ [52, 19],
+ ],
+ fill: '#c9672a',
+ },
+ { t: 'circle', cx: 43, cy: 25, r: 10, fill: '#e0833a' },
+ { t: 'ellipse', cx: 47, cy: 29, rx: 5, ry: 3.5, rot: 0, fill: '#f6dcb8' },
+ { t: 'circle', cx: 46.5, cy: 23.5, r: 1.8, fill: '#1a1a1a' },
+ { t: 'circle', cx: 47, cy: 23, r: 0.6, fill: '#ffffff' },
+ { t: 'circle', cx: 52, cy: 27.5, r: 1.5, fill: '#1a1a1a' },
+ { t: 'ellipse', cx: 33, cy: 56, rx: 5, ry: 2.5, rot: 0, fill: '#c9672a' },
+ { t: 'ellipse', cx: 44, cy: 56, rx: 4.5, ry: 2.5, rot: 0, fill: '#c9672a' },
+];
+const badge = (fill) => [
+ { t: 'circle', cx: 52, cy: 52, r: 11, fill: '#ffffff' },
+ { t: 'circle', cx: 52, cy: 52, r: 8, fill },
+];
+const background = [{ t: 'rect', x: 0, y: 0, w: 64, h: 64, fill: BG }];
+
+const f = (n) => +n.toFixed(2);
+const svgOf = (shapes) =>
+ shapes
+ .map((s) => {
+ if (s.t === 'circle')
+ return ``;
+ if (s.t === 'rect') return ``;
+ if (s.t === 'poly')
+ return ``;
+ return ``;
+ })
+ .join('\n');
+const writeSvg = (name, shapes) =>
+ writeFileSync(
+ join(root, 'public/res/svg', name),
+ `\n`
+ );
+
+const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
+const inside = (s, x, y) => {
+ if (s.t === 'rect') return x >= s.x && x < s.x + s.w && y >= s.y && y < s.y + s.h;
+ if (s.t === 'circle') return (x - s.cx) ** 2 + (y - s.cy) ** 2 <= s.r ** 2;
+ if (s.t === 'ellipse') {
+ const a = (-s.rot * Math.PI) / 180;
+ const dx = x - s.cx;
+ const dy = y - s.cy;
+ const u = dx * Math.cos(a) - dy * Math.sin(a);
+ const v = dx * Math.sin(a) + dy * Math.cos(a);
+ return (u / s.rx) ** 2 + (v / s.ry) ** 2 <= 1;
+ }
+ let c = false;
+ for (let i = 0, j = s.pts.length - 1; i < s.pts.length; j = i++) {
+ const [xi, yi] = s.pts[i];
+ const [xj, yj] = s.pts[j];
+ if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) c = !c;
+ }
+ return c;
+};
+
+const crcTable = Array.from({ length: 256 }, (_, n) => {
+ let c = n;
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ return c >>> 0;
+});
+const crc = (b) => {
+ let c = 0xffffffff;
+ for (const x of b) c = crcTable[(c ^ x) & 255] ^ (c >>> 8);
+ return (c ^ 0xffffffff) >>> 0;
+};
+const chunk = (type, data) => {
+ const out = Buffer.alloc(12 + data.length);
+ out.writeUInt32BE(data.length, 0);
+ out.write(type, 4, 'ascii');
+ data.copy(out, 8);
+ out.writeUInt32BE(crc(out.subarray(4, 8 + data.length)), 8 + data.length);
+ return out;
+};
+
+// Rasterise with 4x4 supersampling; `scale` shrinks the drawing about the centre.
+const png = (shapes, size, scale = 1) => {
+ const rgba = Buffer.alloc(size * size * 4);
+ const SS = 4;
+ const colours = shapes.map((s) => hex(s.fill));
+ for (let py = 0; py < size; py += 1) {
+ for (let px = 0; px < size; px += 1) {
+ let r = 0;
+ let g = 0;
+ let b = 0;
+ let a = 0;
+ for (let sy = 0; sy < SS; sy += 1) {
+ for (let sx = 0; sx < SS; sx += 1) {
+ const gx = ((px + (sx + 0.5) / SS) / size) * 64;
+ const gy = ((py + (sy + 0.5) / SS) / size) * 64;
+ // the background rect covers the whole canvas and ignores `scale`
+ let hit = -1;
+ shapes.forEach((s, i) => {
+ const x = s.t === 'rect' ? gx : (gx - 32) / scale + 32;
+ const y = s.t === 'rect' ? gy : (gy - 32) / scale + 32;
+ if (inside(s, x, y)) hit = i;
+ });
+ if (hit >= 0) {
+ [r, g, b] = [r + colours[hit][0], g + colours[hit][1], b + colours[hit][2]];
+ a += 1;
+ }
+ }
+ }
+ const o = (py * size + px) * 4;
+ if (a > 0) {
+ rgba[o] = Math.round(r / a);
+ rgba[o + 1] = Math.round(g / a);
+ rgba[o + 2] = Math.round(b / a);
+ }
+ rgba[o + 3] = Math.round((a / (SS * SS)) * 255);
+ }
+ }
+ const raw = Buffer.alloc(size * (size * 4 + 1));
+ for (let y = 0; y < size; y += 1) {
+ raw[y * (size * 4 + 1)] = 0;
+ rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
+ }
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(size, 0);
+ ihdr.writeUInt32BE(size, 4);
+ ihdr.set([8, 6, 0, 0, 0], 8);
+ return Buffer.concat([
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
+ chunk('IHDR', ihdr),
+ chunk('IDAT', deflateSync(raw)),
+ chunk('IEND', Buffer.alloc(0)),
+ ]);
+};
+
+// ICO container holding PNG images.
+const ico = (images) => {
+ const head = Buffer.alloc(6 + images.length * 16);
+ head.writeUInt16LE(1, 2);
+ head.writeUInt16LE(images.length, 4);
+ let offset = head.length;
+ images.forEach(({ size, data }, i) => {
+ const e = 6 + i * 16;
+ head[e] = size;
+ head[e + 1] = size;
+ head.writeUInt16LE(1, e + 4);
+ head.writeUInt16LE(32, e + 6);
+ head.writeUInt32LE(data.length, e + 8);
+ head.writeUInt32LE(offset, e + 12);
+ offset += data.length;
+ });
+ return Buffer.concat([head, ...images.map((i) => i.data)]);
+};
+
+writeSvg('ratatoskr.svg', squirrel);
+writeSvg('ratatoskr-unread.svg', [...squirrel, ...badge('#989898')]);
+writeSvg('ratatoskr-highlight.svg', [...squirrel, ...badge('#45B83B')]);
+
+const icon = [...background, ...squirrel];
+const put = (dir, name, size) => {
+ mkdirSync(join(root, 'public/res', dir), { recursive: true });
+ writeFileSync(join(root, 'public/res', dir, name), png(icon, size, 0.82));
+};
+[36, 48, 72, 96, 144, 192, 256, 384, 512].forEach((s) =>
+ put('android', `android-chrome-${s}x${s}.png`, s)
+);
+[57, 60, 72, 76, 114, 120, 144, 152, 167, 180].forEach((s) =>
+ put('apple', `apple-touch-icon-${s}x${s}.png`, s)
+);
+writeFileSync(
+ join(root, 'public/favicon.ico'),
+ ico([16, 32, 48].map((size) => ({ size, data: png(icon, size, 0.9) })))
+);
+console.log('wrote svg logos, favicon.ico, android and apple icons');
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..066a827f 100644
--- a/src/app/components/message/layout/Bubble.tsx
+++ b/src/app/components/message/layout/Bubble.tsx
@@ -28,36 +28,80 @@ function BubbleLeftArrow({ variant }: BubbleArrowProps) {
type BubbleLayoutProps = {
hideBubble?: 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, before, header, children, ...props }, ref) => (
-
-
- {before}
-
-
- {header}
- {hideBubble ? (
- children
- ) : (
-
-
- {before ? : null}
- {children}
-
+ ({ 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 (
+
+ {!poseRow && !own && (
+
+ {before}
)}
+
+ {header && (
+
+ {header}
+
+ )}
+ {hideBubble ? (
+ children
+ ) : (
+
+
+ {arrow ? : null}
+ {pose}
+ {children}
+
+
+ )}
+
-
- )
+ );
+ }
);
diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts
index cc2cd0c6..cdf5f3e1 100644
--- a/src/app/components/message/layout/layout.css.ts
+++ b/src/app/components/message/layout/layout.css.ts
@@ -143,6 +143,7 @@ export const BubbleContent = style({
padding: config.space.S200,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
+ border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R500,
position: 'relative',
});
@@ -151,6 +152,175 @@ export const BubbleContentArrowLeft = style({
borderTopLeftRadius: 0,
});
+// 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': {
+ [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: '100%',
+ height: '100%',
+ objectFit: 'contain',
+ objectPosition: 'top',
+ '@media': {
+ [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`,
+ },
+ },
+});
+
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..e98d6139
--- /dev/null
+++ b/src/app/components/message/pose/PoseAvatar.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import classNames from 'classnames';
+import * as css from '../layout/layout.css';
+import { poseSrc } from './poses';
+
+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;
+};
+
+export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvatarProps) {
+ const src = poseSrc(mind, pose, import.meta.env.BASE_URL);
+ return (
+
+ );
+}
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/entrance.test.ts b/src/app/components/message/pose/entrance.test.ts
new file mode 100644
index 00000000..6f16a384
--- /dev/null
+++ b/src/app/components/message/pose/entrance.test.ts
@@ -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);
+ });
+});
diff --git a/src/app/components/message/pose/entrance.ts b/src/app/components/message/pose/entrance.ts
new file mode 100644
index 00000000..8386f80b
--- /dev/null
+++ b/src/app/components/message/pose/entrance.ts
@@ -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();
+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];
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..a5b705c3
--- /dev/null
+++ b/src/app/components/message/pose/poses.ts
@@ -0,0 +1,78 @@
+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;
+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.
+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/components/splash-screen/SplashScreen.tsx b/src/app/components/splash-screen/SplashScreen.tsx
index 27adadba..8e8f05b6 100644
--- a/src/app/components/splash-screen/SplashScreen.tsx
+++ b/src/app/components/splash-screen/SplashScreen.tsx
@@ -21,7 +21,7 @@ export function SplashScreen({ children }: SplashScreenProps) {
justifyContent="Center"
>
- Cinny
+ Ratatoskr
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..5c4421b6 100644
--- a/src/app/features/room/message/Message.tsx
+++ b/src/app/features/room/message/Message.tsx
@@ -58,7 +58,11 @@ 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, 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';
@@ -788,6 +792,17 @@ export const Message = as<'div', MessageProps>(
);
+ const [poseBubbles] = useSetting(settingsAtom, 'poseBubbles');
+ const mind =
+ poseBubbles && messageLayout === MessageLayout.Bubble
+ ? 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 && (
(
);
+ const poseJSX = !collapse && mind && (
+
+ );
+
const msgContentJSX = (
{reply}
@@ -1135,7 +1161,15 @@ export const Message = as<'div', MessageProps>(
)}
{messageLayout === MessageLayout.Bubble && (
-
+
{msgContentJSX}
)}
diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx
index d3dcde96..6b304965 100644
--- a/src/app/features/settings/about/About.tsx
+++ b/src/app/features/settings/about/About.tsx
@@ -4,7 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
-import CinnySVG from '../../../../../public/res/svg/cinny.svg';
+import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg';
import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -38,23 +38,23 @@ export function About({ requestClose }: AboutProps) {
- Cinny
+ Ratatoskr
v4.12.8
- Yet another matrix client.
+ GameCult's Matrix client.
- }
- >
- Support
-
+
+ Based on{' '}
+
+ Cinny
+ {' '}
+ 4.12.8 by Ajay Bura and contributors. Ratatoskr is free software under the GNU
+ Affero General Public License v3; its source is at the Source Code link above.
+
diff --git a/src/app/features/settings/devices/LocalBackup.tsx b/src/app/features/settings/devices/LocalBackup.tsx
index 00128c8f..4fcdacdb 100644
--- a/src/app/features/settings/devices/LocalBackup.tsx
+++ b/src/app/features/settings/devices/LocalBackup.tsx
@@ -28,7 +28,7 @@ function ExportKeys() {
const blob = new Blob([encKeys], {
type: 'text/plain;charset=us-ascii',
});
- FileSaver.saveAs(blob, 'cinny-keys.txt');
+ FileSaver.saveAs(blob, 'ratatoskr-keys.txt');
},
[mx]
)
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() {
}
/>
+
+ }
+ />
+
-
+
About
- v4.12.8
+ Source
-
- Twitter
+
+ Based on Cinny
Powered by Matrix
diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx
index 3943f42d..c53e5a65 100644
--- a/src/app/pages/auth/AuthLayout.tsx
+++ b/src/app/pages/auth/AuthLayout.tsx
@@ -20,7 +20,7 @@ import {
} from '../../hooks/useClientConfig';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
-import CinnySVG from '../../../../public/res/svg/cinny.svg';
+import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg';
import { ServerPicker } from './ServerPicker';
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
@@ -134,8 +134,8 @@ export function AuthLayout() {
-
- Cinny
+
+ Ratatoskr
diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx
index 62f46dd2..3c8641d9 100644
--- a/src/app/pages/auth/login/PasswordLoginForm.tsx
+++ b/src/app/pages/auth/login/PasswordLoginForm.tsx
@@ -133,7 +133,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
user: username,
},
password,
- initial_device_display_name: 'Cinny Web',
+ initial_device_display_name: 'Ratatoskr Web',
});
};
@@ -151,7 +151,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
user: mxIdUsername,
},
password,
- initial_device_display_name: 'Cinny Web',
+ initial_device_display_name: 'Ratatoskr Web',
});
};
const handleEmailLogin = (email: string, password: string) => {
@@ -163,7 +163,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
address: email,
},
password,
- initial_device_display_name: 'Cinny Web',
+ initial_device_display_name: 'Ratatoskr Web',
});
};
diff --git a/src/app/pages/auth/login/TokenLogin.tsx b/src/app/pages/auth/login/TokenLogin.tsx
index 761d5dc5..0541742d 100644
--- a/src/app/pages/auth/login/TokenLogin.tsx
+++ b/src/app/pages/auth/login/TokenLogin.tsx
@@ -57,7 +57,7 @@ export function TokenLogin({ token }: TokenLoginProps) {
startLogin(baseUrl, {
type: 'm.login.token',
token,
- initial_device_display_name: 'Cinny Web',
+ initial_device_display_name: 'Ratatoskr Web',
});
}, [baseUrl, token, startLogin]);
diff --git a/src/app/pages/auth/register/PasswordRegisterForm.tsx b/src/app/pages/auth/register/PasswordRegisterForm.tsx
index 9f173422..c9b1924c 100644
--- a/src/app/pages/auth/register/PasswordRegisterForm.tsx
+++ b/src/app/pages/auth/register/PasswordRegisterForm.tsx
@@ -109,7 +109,7 @@ function RegisterUIAFlow({
auth: authDict,
password,
username,
- initial_device_display_name: 'Cinny Web',
+ initial_device_display_name: 'Ratatoskr Web',
});
},
[onRegister, formData]
@@ -250,7 +250,7 @@ export function PasswordRegisterForm({
auth: {
session: authData.session,
},
- initial_device_display_name: 'Cinny Web',
+ initial_device_display_name: 'Ratatoskr Web',
});
};
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index ce952bfc..db3eefc2 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -3,9 +3,9 @@ import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
-import LogoSVG from '../../../../public/res/svg/cinny.svg';
-import LogoUnreadSVG from '../../../../public/res/svg/cinny-unread.svg';
-import LogoHighlightSVG from '../../../../public/res/svg/cinny-highlight.svg';
+import LogoSVG from '../../../../public/res/svg/ratatoskr.svg';
+import LogoUnreadSVG from '../../../../public/res/svg/ratatoskr-unread.svg';
+import LogoHighlightSVG from '../../../../public/res/svg/ratatoskr-highlight.svg';
import NotificationSound from '../../../../public/sound/notification.ogg';
import InviteSound from '../../../../public/sound/invite.ogg';
import { notificationPermission, setFavicon } from '../../utils/dom';
diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx
index 6a25392c..74869c3f 100644
--- a/src/app/pages/client/WelcomePage.tsx
+++ b/src/app/pages/client/WelcomePage.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
import { Page, PageHero, PageHeroSection } from '../../components/page';
-import CinnySVG from '../../../../public/res/svg/cinny.svg';
+import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg';
export function WelcomePage() {
return (
@@ -14,17 +14,17 @@ export function WelcomePage() {
>
}
- title="Welcome to Cinny"
+ icon={
}
+ title="Welcome to Ratatoskr"
subTitle={
- Yet another matrix client.{' '}
+ GameCult's Matrix client, based on{' '}
- v4.12.8
+ Cinny
}
@@ -33,7 +33,7 @@ export function WelcomePage() {
}
@@ -42,18 +42,6 @@ export function WelcomePage() {
Source Code
- }
- >
-
- Support
-
-
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index d4deb39c..6f9972a3 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -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,
diff --git a/src/gamecult.css.ts b/src/gamecult.css.ts
index 4aaf3e76..57fbbf05 100644
--- a/src/gamecult.css.ts
+++ b/src/gamecult.css.ts
@@ -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)",
+});
diff --git a/src/index.tsx b/src/index.tsx
index 672ac5c0..4aba7ed4 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -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';
diff --git a/vite.config.js b/vite.config.js
index a5af94d8..891c8216 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -42,6 +42,10 @@ const copyFiles = {
src: 'public/locales',
dest: 'public/',
},
+ {
+ src: 'public/poses',
+ dest: 'public/',
+ },
],
};