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----- - -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 -mxFo+ioe/ABCufSmyqFye0psX3Sp -=WtqZ ------END PGP PUBLIC KEY BLOCK----- +npm ci +npm start # vite dev server +npm test # vitest +npm run build # dist/ ``` -
-## 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/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 0e470460..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", @@ -38,7 +38,11 @@ "@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", + "@fontsource-variable/nunito": "5.3.0", + "@fontsource/ibm-plex-mono": "5.2.5", "@fontsource/inter": "4.5.14", + "@fontsource/montserrat": "5.2.5", + "@fontsource/ubuntu": "5.2.5", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.2.0", @@ -89,10 +93,7 @@ "slate-dom": "0.123.0", "slate-history": "0.113.1", "slate-react": "0.123.0", - "ua-parser-js": "1.0.35", - "@fontsource/ubuntu": "5.2.5", - "@fontsource/montserrat": "5.2.5", - "@fontsource/ibm-plex-mono": "5.2.5" + "ua-parser-js": "1.0.35" }, "devDependencies": { "@element-hq/element-call-embedded": "0.20.1", diff --git a/src/app/components/message/layout/Bubble.tsx b/src/app/components/message/layout/Bubble.tsx index 7fc5171c..066a827f 100644 --- a/src/app/components/message/layout/Bubble.tsx +++ b/src/app/components/message/layout/Bubble.tsx @@ -5,12 +5,11 @@ import * as css from './layout.css'; type BubbleArrowProps = { variant: ContainerColor; - pose?: boolean; }; -function BubbleLeftArrow({ variant, pose }: BubbleArrowProps) { +function BubbleLeftArrow({ variant }: BubbleArrowProps) { return ( ( - ({ hideBubble, pose, 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 cb53a62d..cdf5f3e1 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -138,39 +138,12 @@ export const BubbleBefore = style({ minWidth: toRem(36), }); -// Pose portraits: beside the bubble on wide screens; on phones the row stacks, so the -// portrait costs a little height instead of a column of width, and the left arrow goes. -export const BubblePose = style({ - '@media': { - 'screen and (max-width: 750px)': { - flexDirection: 'column', - gap: config.space.S100, - }, - }, -}); - -export const BubbleBeforePose = style({ - minWidth: toRem(64), - '@media': { - 'screen and (max-width: 750px)': { - minWidth: 0, - }, - }, -}); - -export const BubbleArrowPose = style({ - '@media': { - 'screen and (max-width: 750px)': { - display: 'none', - }, - }, -}); - export const BubbleContent = style({ maxWidth: toRem(800), padding: config.space.S200, backgroundColor: color.SurfaceVariant.Container, color: color.SurfaceVariant.OnContainer, + border: `1px solid ${color.SurfaceVariant.ContainerLine}`, borderRadius: config.radii.R500, position: 'relative', }); @@ -179,30 +152,171 @@ export const BubbleContentArrowLeft = style({ borderTopLeftRadius: 0, }); -export const BubbleContentPoseStacked = style({ +// Look of the three-doors mock-up (chat.gamecult.org/mockups/three-doors): a Mind's pose sits +// beside its bubble on wide screens; on phones it floats inside the bubble, overhanging the top +// edge, and the text wraps around its silhouette (shape-outside on the alpha channel). +const PHONE = 'screen and (max-width: 700px)'; +const PORTRAIT = toRem(124); +const POSE_GUTTER = toRem(14); +const BUBBLE_CORNER = toRem(6); +const POP = 'cubic-bezier(0.34, 1.7, 0.64, 1)'; +const RISE = 'cubic-bezier(0.34, 1.45, 0.64, 1)'; + +export const BubbleRowPose = style({ + position: 'relative', + paddingLeft: `calc(${PORTRAIT} + ${POSE_GUTTER})`, '@media': { - 'screen and (max-width: 750px)': { - borderTopLeftRadius: config.radii.R500, + [PHONE]: { + paddingLeft: 0, }, }, }); +// A row that shows a portrait: room for it to hang above the bubble on phones. +export const BubbleRowPortrait = style({ + minHeight: PORTRAIT, + '@media': { + [PHONE]: { + minHeight: 0, + marginTop: toRem(58), + }, + }, +}); + +export const BubbleRowOwn = style({ + justifyContent: 'flex-end', +}); + +export const BubbleColumnOwn = style({ + alignItems: 'flex-end', + maxWidth: toRem(640), +}); + +export const BubbleHeaderOwn = style({ + alignSelf: 'flex-end', +}); + +// Phone: the name is a pill straddling the bubble's top edge, on the side opposite the portrait. +export const BubbleHeaderPose = style({ + '@media': { + [PHONE]: { + position: 'absolute', + zIndex: 2, + top: toRem(-15), + right: toRem(14), + padding: `0 ${toRem(13)}`, + background: color.Secondary.Container, + border: `1px solid ${color.Secondary.ContainerLine}`, + borderRadius: toRem(999), + whiteSpace: 'nowrap', + }, + }, +}); + +export const BubbleHeaderPoseAlt = style({ + '@media': { + [PHONE]: { + right: 'auto', + left: toRem(14), + }, + }, +}); + +export const BubbleColumnPose = style({ + position: 'relative', +}); + +export const BubbleContentPose = style({ + borderRadius: config.radii.R500, + borderTopLeftRadius: BUBBLE_CORNER, + '@media': { + [PHONE]: { + display: 'flow-root', + borderTopLeftRadius: config.radii.R500, + padding: `${toRem(24)} ${toRem(14)} ${toRem(14)}`, + }, + }, +}); + +export const BubbleContentOwn = style({ + backgroundColor: color.Primary.Container, + color: color.Primary.OnContainer, + border: `1px solid ${color.Primary.ContainerLine}`, + borderRadius: config.radii.R500, + borderTopRightRadius: BUBBLE_CORNER, +}); + export const PoseButton = style({ + position: 'absolute', + top: 0, + left: `calc(-1 * (${PORTRAIT} + ${POSE_GUTTER}))`, + width: PORTRAIT, + height: PORTRAIT, padding: 0, border: 'none', background: 'none', cursor: 'pointer', lineHeight: 0, + transformOrigin: '50% 90%', + '@media': { + [PHONE]: { + position: 'static', + float: 'left', + width: 'min(50vw, 210px)', + height: 'auto', + margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`, + shapeMargin: toRem(7), + shapeImageThreshold: 0.35, + WebkitMaskImage: 'linear-gradient(#000 84%, transparent)', + maskImage: 'linear-gradient(#000 84%, transparent)', + }, + }, +}); + +export const PoseButtonAlt = style({ + '@media': { + [PHONE]: { + float: 'right', + margin: `${toRem(-82)} ${toRem(-12)} 0 ${toRem(8)}`, + }, + }, }); export const PosePortrait = style({ - width: toRem(64), - height: toRem(64), + width: '100%', + height: '100%', objectFit: 'contain', + objectPosition: 'top', '@media': { - 'screen and (max-width: 750px)': { - width: toRem(48), - height: toRem(48), + [PHONE]: { + height: 'auto', + }, + }, +}); + +const pop = keyframes({ + from: { opacity: 0, transform: `translateY(${toRem(34)}) scale(0.42)` }, + to: { opacity: 1, transform: 'none' }, +}); +const rise = keyframes({ + from: { opacity: 0, transform: `translateY(${toRem(14)}) scale(0.96)` }, + to: { opacity: 1, transform: 'none' }, +}); + +// First appearance of a message: the portrait pops, the bubble rises just behind it. The delay +// (a --enter-delay on the row) staggers messages that arrive together. +export const EnterPortrait = style({ + '@media': { + '(prefers-reduced-motion: no-preference)': { + animation: `${pop} 460ms ${POP} var(--enter-delay, 0ms) both`, + }, + }, +}); + +export const EnterBubble = style({ + '@media': { + '(prefers-reduced-motion: no-preference)': { + animation: `${rise} 380ms ${RISE} calc(var(--enter-delay, 0ms) + 50ms) both`, }, }, }); diff --git a/src/app/components/message/pose/PoseAvatar.tsx b/src/app/components/message/pose/PoseAvatar.tsx index ed8bc485..e98d6139 100644 --- a/src/app/components/message/pose/PoseAvatar.tsx +++ b/src/app/components/message/pose/PoseAvatar.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import classNames from 'classnames'; import * as css from '../layout/layout.css'; import { poseSrc } from './poses'; @@ -6,25 +7,26 @@ type PoseAvatarProps = { mind: string; pose: string; userId: string; + /** Float on the right on phones. */ + alt?: boolean; + /** Play the first-appearance pop. */ + enter?: boolean; onClick: React.MouseEventHandler; }; -export function PoseAvatar({ mind, pose, userId, onClick }: PoseAvatarProps) { +export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvatarProps) { + const src = poseSrc(mind, pose, import.meta.env.BASE_URL); return ( ); } 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.ts b/src/app/components/message/pose/poses.ts index 01fb89e1..a5b705c3 100644 --- a/src/app/components/message/pose/poses.ts +++ b/src/app/components/message/pose/poses.ts @@ -11,6 +11,7 @@ type MindEntry = { 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. diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 42d74a6b..5c4421b6 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -61,7 +61,8 @@ import { import { MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings'; import { useSetting } from '../../../state/hooks/settings'; import { PoseAvatar } from '../../../components/message/pose/PoseAvatar'; -import { POSE_KEY, mindOfSender, resolvePose } from '../../../components/message/pose/poses'; +import { POSE_KEY, MINDS, mindOfSender, resolvePose } from '../../../components/message/pose/poses'; +import { useEntranceDelay } from '../../../components/message/pose/entrance'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import * as css from './styles.css'; @@ -797,6 +798,11 @@ export const Message = as<'div', MessageProps>( ? mindOfSender(getMxIdLocalPart(senderId), senderDisplayName) : undefined; + const own = senderId === mx.getUserId(); + const enterDelay = useEntranceDelay(mEvent.getId()); + // Minds alternate sides on phones, each always on the same one. + const poseAlt = !!mind && MINDS.indexOf(mind) % 2 === 1; + const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && ( ( mind={mind} pose={resolvePose(mind, mEvent.getContent()[POSE_KEY])} userId={senderId} + alt={poseAlt} + enter={enterDelay !== undefined} onClick={onUserClick} /> ); @@ -1154,9 +1162,14 @@ export const Message = as<'div', MessageProps>( )} {messageLayout === MessageLayout.Bubble && ( + own={own} + pose={mind ? poseJSX : undefined} + alt={poseAlt} + enterDelay={enterDelay} + before={avatarJSX} + header={headerJSX} + onContextMenu={handleContextMenu} + > {msgContentJSX} )} 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';