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 -

- - GitHub release downloads - - DockerHub downloads - - Follow on Mastodon - - Follow on Twitter - - Sponsor 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${svgOf( + shapes + )}\n\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 logo - Cinny + Ratatoskr v4.12.8 - Yet another matrix client. + GameCult's Matrix client. - + + 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 Logo - Cinny + Ratatoskr logo + 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={Ratatoskr logo} + 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() { - 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/', + }, ], };