diff --git a/client/DESIGN.md b/client/DESIGN.md new file mode 100644 index 0000000..7e05eec --- /dev/null +++ b/client/DESIGN.md @@ -0,0 +1,106 @@ +# Voicy design book + +> **Temporary draft.** Written alongside the profile customization work and +> likely to change: treat it as a working reference, not a settled spec. + +What the app looks like and why, so new screens and art fit in without +guessing. Everything here is already in `src/styles.css` and +`src/components/icons.tsx`; this file names the rules behind them. + +## Character + +A quiet, near-black room where the only loud thing is the voice. Voicy +does not decorate its chrome: surfaces are flat and dark, text is plain, +and color is spent on one thing at a time — who is talking, what is +selected, what needs a click. + +The brand mark is a sound wave (`Logo`): one thick line, round caps, a +dip and a rise. Waves, levels and rings that react to the voice are the +app's own motifs; reach for them before anything borrowed. + +## Color + +| Token | Value | Use | +|---|---|---| +| `--bg` | `#09090b` | window, inputs | +| `--surface` | `#111113` | the main panel | +| `--panel` / `--panel-2` | `#17171a` / `#1f1f24` | dialogs, menus, buttons | +| `--line` / `--line-soft` | `#26262b` / `#1f1f24` | 1 px borders, dividers | +| `--text` / `--muted` / `--faint` | `#f4f4f5` / `#b6b6be` / `#9a9aa4` | three steps of text, no more | +| `--accent` | `#c6f36b` | the voice, the selection, the go button | +| `--accent-ink` | `#09090b` | text on the accent | +| `--warn`, `--danger` | `#ff8b5e`, `#e5484d` | muted mic, leaving, deleting | + +- One accent per view. Lime marks *live* things (speaking, selected, + primary action); it is never a background for large areas, only + 10–20 % tints of it (`color-mix(in oklch, var(--accent) 14%, transparent)`). +- The primary button is white on black (`.btn.primary`), not lime: lime is + kept for "go" moments (join, update). +- Member colors (name color, the glow while talking) replace the accent + *for that member only*. + +## Type + +- UI: Segoe UI Variable Text, Display for titles (`--ui`, `--display`). + It is hinted for ClearType and stays crisp at 12–14 px on Windows, + which web fonts do not. +- Mono: Cascadia Mono (`--mono`) for codes, links, logs and tiny status. +- Section labels: 12 px, 600, uppercase, `.05em` tracking, `--faint` + (`.sec-label`, `.field > span`, `.set-section h3`). +- Body 14 px; hints 12.5 px in `--faint`; titles 18–19 px, slightly + negative tracking. +- Display fonts belong to members (their names), never to the chrome. + +## Shape and space + +- Radii: 8 (small controls), 10 (buttons, inputs), 12 (rows, menus), + 16 (cards, the main panel), 22 (dialogs). Pills for chips and small + header buttons. +- Borders are 1 px `--line`; cards are `rgba(255,255,255,.025)` with a + border, not a shadow. Shadows only on things that float (dialogs, + menus, the dock, the drag ghost). +- Rows: 52 px in a call, 38 px otherwise; 10–14 px side padding; hover is + `rgba(255,255,255,.04)`, never a border. +- The main panel has a faint 18 px dot grid, lit in lime under the + cursor. Previews of "how you look in a room" sit on that grid. + +## Icons + +Voicy's own glyphs are **filled shapes with cut-out details**: a solid +body in `currentColor`, details punched out in `--cut` (the color of the +surface underneath). No outlines-only icons in the main controls; Lucide +line icons are fine in menus and dialogs. + +## Motion + +- Short and springy: 120–160 ms for color, 220–280 ms for dialogs + (`cubic-bezier(.2, .9, .3, 1.15)` pops, `.2, .9, .25, 1` sheets). +- The voice drives motion: `--level` (0..1, 20 times a second) grows the + speaking ring and lights the level segments. Things that move on their + own are rare and slow (the update button's glow). +- Anything decorative that loops holds still in lists and moves only + while its owner talks or is hovered. `prefers-reduced-motion` stops it. + +## Decoration art + +Avatar decorations are drawn here, in one style, as SVG +(`src/components/decorations.tsx`): + +- **Sticker style**: flat fills in two or three tones (base, shade, one + highlight), every piece outlined in `#0b0b0e` at 2.4 units so it reads + on any picture and any background. No gradients except glow and fire. +- Details are cut out, like the icons: gems, dots, grilles are holes or + dark insets, not extra colors. +- Canvas: `viewBox="0 0 120 120"`, the avatar is the circle + `cx=60 cy=60 r=50` — Discord's layout, a square 1.2 times the avatar. + Pieces hug that circle; they may cover its rim, never its middle + third, where the face is. +- Palette per piece: at most three hues plus the outline; warm pieces + (gold, flame) use `#ffd84d`/`#ffab3d`/`#ff6b3d`, cool ones + `#9fe8ff`/`#5ec4f0`, and `var(--c1)` wherever the member's own color + belongs (cat ears, the equalizer, headphones). +- Motion comes from the voice first (the equalizer, the headphone + waves), then from small loops: a flicker, a sway, a spin — never more + than a few pixels of travel at 34 px. +- Check every decoration at 34 px (a call row), 26 px (lists) and 72 px + (the profile card) before shipping it. diff --git a/client/package-lock.json b/client/package-lock.json index 27bfa86..c78fa5e 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,12 +8,31 @@ "name": "voicy", "version": "0.1.17", "dependencies": { + "@fontsource/caveat": "5.3.0", + "@fontsource/comfortaa": "5.3.0", + "@fontsource/dela-gothic-one": "5.3.0", + "@fontsource/great-vibes": "5.3.0", + "@fontsource/lobster": "5.3.0", + "@fontsource/marck-script": "5.3.0", + "@fontsource/montserrat-alternates": "5.3.0", + "@fontsource/pacifico": "5.3.0", + "@fontsource/pixelify-sans": "5.3.0", + "@fontsource/press-start-2p": "5.3.0", + "@fontsource/rubik-bubbles": "5.3.0", + "@fontsource/rubik-glitch": "5.3.0", + "@fontsource/rubik-mono-one": "5.3.0", + "@fontsource/rubik-wet-paint": "5.3.0", + "@fontsource/russo-one": "5.3.0", + "@fontsource/tektur": "5.3.0", + "@fontsource/unbounded": "5.3.0", + "@fontsource/yeseva-one": "5.3.0", "@sapphi-red/web-noise-suppressor": "0.4.1", "@tauri-apps/api": "^2", "@tauri-apps/plugin-deep-link": "^2.4.10", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.12.0", "deepfilternet3-noise-filter": "1.3.0", + "gifenc": "1.0.3", "livekit-client": "^2.22.3", "lucide-react": "^1.48.0", "react": "^19.1.0", @@ -34,6 +53,168 @@ "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", "license": "(Apache-2.0 AND BSD-3-Clause)" }, + "node_modules/@fontsource/caveat": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/caveat/-/caveat-5.3.0.tgz", + "integrity": "sha512-eHTfzQxFSW9ABERRLNruHoNJmKed2J7pqTUc5lp4a4POMgXGKpxmWj3d9Hz10kmAg6iuTTLe6UagbA4bnsmfQw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/comfortaa": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/comfortaa/-/comfortaa-5.3.0.tgz", + "integrity": "sha512-ab4DERH0q6ko8QCpEZzOV9ebK39LgQ2oM91AkLNqu0VIxxwT0nV2D3sOuI7iLirWWq4IMooaIN6/2llxrDN2JA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/dela-gothic-one": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/dela-gothic-one/-/dela-gothic-one-5.3.0.tgz", + "integrity": "sha512-4A26Qs7TqYFWegJfYHx7KKWy01YybW/e7uhFDM8A8e1neVPm721laLnh0en60wtKihfNdCbJt0VVcEMHC4pHjw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/great-vibes": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/great-vibes/-/great-vibes-5.3.0.tgz", + "integrity": "sha512-1w60cXzXnC9V4y943po8044O1mqRQBiTTK75h7OpZE+1VQtx+KlAAzoIyEE5KNzu05gUpxaCQg7cbL7ITSwegQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/lobster": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/lobster/-/lobster-5.3.0.tgz", + "integrity": "sha512-jt+RDk/8Sstl1Omhox62gTbvRSLbh06kn5BNfDFgf4rNuDPHF+19eWYFxzq5FsdkalbRJNtc5p5C3qE0+leNVg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/marck-script": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/marck-script/-/marck-script-5.3.0.tgz", + "integrity": "sha512-PLm3zCeXB7osgon3TC0bjBFPua3d5syocLWFpK5eZZGoaSsOw6bpHLdMoGB8PkAErf4RiRUEiszs+Y7JhDnB/w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/montserrat-alternates": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/montserrat-alternates/-/montserrat-alternates-5.3.0.tgz", + "integrity": "sha512-PgdXHwkXQZ6YiE6aKgfFiMAn9vFf+5ADdVb95qMgJT/E/bvarrjeeTq+d8rWUAMvxpxn0jwFoGUNcvbzwSx68w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/pacifico": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/pacifico/-/pacifico-5.3.0.tgz", + "integrity": "sha512-bWv0fHR1n36p7scRn+maaEaVeQCOq9yvfVfjyAeUzKrK4Y/3qiRAEj4hJgfxj/sjgZZGkeHyKPrbWdDaZtpCVw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/pixelify-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/pixelify-sans/-/pixelify-sans-5.3.0.tgz", + "integrity": "sha512-4RCe9nXbi7wUu5+H0FNX1sgPZ2kJ1MmY1uTkEfvQIzzqhoS8zGKJaJfdK8oreE0OU+5gM+NdurFi3H3FJRBvyw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/press-start-2p": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/press-start-2p/-/press-start-2p-5.3.0.tgz", + "integrity": "sha512-gxWFxdeDglPhEYSwEeooomcaFK8kSQYLQwVxfnOJeYRj2sPEHXjpVtFgEaYfqzOjuAJGrIcJOipu2KY5YkbyuQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/rubik-bubbles": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/rubik-bubbles/-/rubik-bubbles-5.3.0.tgz", + "integrity": "sha512-b0ZQsu6LTUFziwzdicuDNQnPI8+2ovufGMuAwjI+dMmjtsGz2u4ndYu+WSiNEF/XEgCU7f6B7HH45oqNAstUuw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/rubik-glitch": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/rubik-glitch/-/rubik-glitch-5.3.0.tgz", + "integrity": "sha512-TUe1qXVF+5IeMWU73HvfDdao9IssUK06hOHOdF8QKmPJj36pWGLVtdFWUgHGQQjytxpWitSHgmcJ7DkiWmTXQA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/rubik-mono-one": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/rubik-mono-one/-/rubik-mono-one-5.3.0.tgz", + "integrity": "sha512-ReaCXaIKJAFdJAUaw8Gq3qXC9od7JotgqZFsVnwp06KqnBFtrX9qdeUvBdL+7avyUTic20gmzSH0NtEC6xAbDQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/rubik-wet-paint": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/rubik-wet-paint/-/rubik-wet-paint-5.3.0.tgz", + "integrity": "sha512-nPEMWjlPzZgXd8E1N5zXJuIYfjjuKEa209rhEaTLlVZJAklmtGOPIauUuTT0U0m4nDPR8BOFd0WZoaru8plTMg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/russo-one": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/russo-one/-/russo-one-5.3.0.tgz", + "integrity": "sha512-loHnqTq6D+H2WQdOB2pnAeCHbbsGqobmxnxU79MNFKjPWV2TPXdc78dbbkOAcWuKWDEY+FNXZFZ1nhaQvCWNAw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/tektur": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/tektur/-/tektur-5.3.0.tgz", + "integrity": "sha512-Yp5ejjv/9+bxoT2zl6aKI3uBkuwLJ/aA+yAf1e9iUoaj6iE26QDL78pKTbpZj0+sm3gSOjy0p1XyKmnIGyCD9Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/unbounded": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/unbounded/-/unbounded-5.3.0.tgz", + "integrity": "sha512-pPD6hsTnjoQRr6pCsqMHbHBfauB4LZygCNQEUJHu+CO2wzDFHqELEFSG7sTwo5Z5Gr4q3OK0fIILOXBJDv6lcw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/yeseva-one": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/yeseva-one/-/yeseva-one-5.3.0.tgz", + "integrity": "sha512-9R6Rk4nInFQnnoghsjsGN+3EqE9Pb4OjfV9rUy20uqFGjs7r1Z/miSVvM0HMYK21y1RVIzALCtuHifCfzN/gSw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@livekit/mutex": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz", @@ -746,6 +927,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gifenc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz", + "integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==", + "license": "MIT" + }, "node_modules/jose": { "version": "6.2.12", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", diff --git a/client/package.json b/client/package.json index bd88d6f..448ca24 100644 --- a/client/package.json +++ b/client/package.json @@ -14,12 +14,31 @@ "tauri": "tauri" }, "dependencies": { + "@fontsource/caveat": "5.3.0", + "@fontsource/comfortaa": "5.3.0", + "@fontsource/dela-gothic-one": "5.3.0", + "@fontsource/great-vibes": "5.3.0", + "@fontsource/lobster": "5.3.0", + "@fontsource/marck-script": "5.3.0", + "@fontsource/montserrat-alternates": "5.3.0", + "@fontsource/pacifico": "5.3.0", + "@fontsource/pixelify-sans": "5.3.0", + "@fontsource/press-start-2p": "5.3.0", + "@fontsource/rubik-bubbles": "5.3.0", + "@fontsource/rubik-glitch": "5.3.0", + "@fontsource/rubik-mono-one": "5.3.0", + "@fontsource/rubik-wet-paint": "5.3.0", + "@fontsource/russo-one": "5.3.0", + "@fontsource/tektur": "5.3.0", + "@fontsource/unbounded": "5.3.0", + "@fontsource/yeseva-one": "5.3.0", "@sapphi-red/web-noise-suppressor": "0.4.1", "@tauri-apps/api": "^2", "@tauri-apps/plugin-deep-link": "^2.4.10", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.12.0", "deepfilternet3-noise-filter": "1.3.0", + "gifenc": "1.0.3", "livekit-client": "^2.22.3", "lucide-react": "^1.48.0", "react": "^19.1.0", diff --git a/client/src/components/AvatarDialog.tsx b/client/src/components/AvatarDialog.tsx index 49e757f..83baab3 100644 --- a/client/src/components/AvatarDialog.tsx +++ b/client/src/components/AvatarDialog.tsx @@ -1,24 +1,32 @@ import { Crop as CropIcon, Search } from "lucide-react"; import { FormEvent, PointerEvent, useEffect, useRef, useState } from "react"; -import { Crop, renderAvatar, syncAvatarEverywhere } from "../lib/avatar"; +import { Crop, isAnimated, renderAnimatedAvatar, renderAvatar, syncAvatarEverywhere } from "../lib/avatar"; import { updateSettings } from "../lib/settings"; import { errorText, Pin, pinterestImage, pinterestSearch } from "../lib/tauri"; import { Modal } from "./ui"; const IDEAS = ["аниме аватарки", "котики", "aesthetic pfp", "капибара", "мемные аватарки", "y2k pfp"]; +/** The picture being framed: its bytes for animation, a URL to show it. */ +interface Source { + blob: Blob; + url: string; +} + +const sourceOf = (blob: Blob): Source => ({ blob, url: URL.createObjectURL(blob) }); + /** * Frames a picture in a circle and saves it as our avatar. With `file` it * starts at framing that file; without, it first searches Pinterest. */ export function AvatarDialog({ file, onClose }: { file?: File; onClose: () => void }) { - const [source, setSource] = useState(() => (file ? URL.createObjectURL(file) : null)); + const [source, setSource] = useState(() => (file ? sourceOf(file) : null)); // Object URLs hold the whole image in memory until revoked. - useEffect(() => () => void (source && URL.revokeObjectURL(source)), [source]); + useEffect(() => () => void (source && URL.revokeObjectURL(source.url)), [source]); - if (source) return setSource(null)} onDone={onClose} />; - return ; + if (source) return setSource(null)} onDone={onClose} />; + return setSource(sourceOf(blob))} onClose={onClose} />; } /** Removes our avatar here and on every server. */ @@ -27,7 +35,7 @@ export function removeAvatar() { void syncAvatarEverywhere(); } -function PickStep({ onPicked, onClose }: { onPicked: (url: string) => void; onClose: () => void }) { +function PickStep({ onPicked, onClose }: { onPicked: (blob: Blob) => void; onClose: () => void }) { const [query, setQuery] = useState(() => IDEAS[Math.floor(Math.random() * IDEAS.length)]); const [searched, setSearched] = useState(""); const [pins, setPins] = useState([]); @@ -72,7 +80,7 @@ function PickStep({ onPicked, onClose }: { onPicked: (url: string) => void; onCl setError(""); try { const bytes = await pinterestImage(pin.full); - onPicked(URL.createObjectURL(new Blob([bytes]))); + onPicked(new Blob([bytes])); } catch (e) { setError(errorText(e)); setOpening(null); @@ -131,8 +139,16 @@ const MAX_ZOOM = 5; // The preview circle is as big as a person in a call (34 px). const PREVIEW_K = 34 / VIEW; -function CropStep({ src, back, onBack, onDone }: { src: string; back: string; onBack: () => void; onDone: () => void }) { +function CropStep({ source, back, onBack, onDone }: { source: Source; back: string; onBack: () => void; onDone: () => void }) { + const src = source.url; const img = useRef(null); + // GIFs and moving WebPs stay animated; known a moment after opening. + const [animated, setAnimated] = useState(false); + useEffect(() => { + let live = true; + void isAnimated(source.blob).then((yes) => live && setAnimated(yes)); + return () => void (live = false); + }, [source.blob]); const [size, setSize] = useState<{ w: number; h: number } | null>(null); const [zoom, setZoom] = useState(1); // Center of the visible square, in image pixels. @@ -172,7 +188,8 @@ function CropStep({ src, back, onBack, onDone }: { src: string; back: string; on setError(""); try { const crop: Crop = { x: center.x - side / 2, y: center.y - side / 2, size: side }; - updateSettings({ avatar: await renderAvatar(img.current, crop) }); + const avatar = animated ? await renderAnimatedAvatar(source.blob, crop) : await renderAvatar(img.current, crop); + updateSettings({ avatar }); void syncAvatarEverywhere(); onDone(); } catch (e) { @@ -182,7 +199,12 @@ function CropStep({ src, back, onBack, onDone }: { src: string; back: string; on } return ( - } sub="Двигай картинку и приближай колёсиком: сохранится то, что в круге." onClose={onBack}> + } + sub={`Двигай картинку и приближай колёсиком: сохранится то, что в круге.${animated ? " Анимация сохранится, у друзей она оживает, когда ты говоришь." : ""}`} + onClose={onBack} + >
diff --git a/client/src/components/ProfileDialog.tsx b/client/src/components/ProfileDialog.tsx new file mode 100644 index 0000000..1950b9b --- /dev/null +++ b/client/src/components/ProfileDialog.tsx @@ -0,0 +1,348 @@ +import { Check, Plus, Sparkles, Upload } from "lucide-react"; +import { FormEvent, ReactNode, useEffect, useRef, useState } from "react"; + +import { LocalAvatar, localAvatar, renderDecoration, syncPictureEverywhere } from "../lib/avatar"; +import { DECORATIONS } from "../lib/decorations"; +import { FONT_GROUPS, GOOGLE_PREFIX, googleFamily, googleFontExists, readFontFile, SHIPPED_FONTS } from "../lib/fonts"; +import { cleanProfile, COLORS, EFFECTS, STATUS_IDEAS, STATUS_MAX, statusUntil, syncProfileEverywhere } from "../lib/profile"; +import { updateSettings } from "../lib/settings"; +import { errorText, Profile } from "../lib/tauri"; +import { MicIcon, MicOffIcon } from "./icons"; +import { Avatar, Modal, Nick } from "./ui"; + +type Tab = "nick" | "decoration" | "status"; + +/** How long a status stays, in minutes; "keep" is the end time it already had. */ +const EXPIRY = [ + { value: "0", label: "Никогда" }, + { value: "30", label: "30 мин" }, + { value: "60", label: "Час" }, + { value: "240", label: "4 часа" }, + { value: "day", label: "Сегодня" }, +]; + +const clock = (unix: number) => new Date(unix * 1000).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); + +/** Voicy's segmented control (`.seg`). */ +function Seg({ value, options, onChange }: { value: T; options: { value: T; label: string }[]; onChange: (v: T) => void }) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} + +function Section({ title, hint, children }: { title: string; hint?: ReactNode; children: ReactNode }) { + return ( +
+

{title}

+ {children} + {hint &&

{hint}

} +
+ ); +} + +function Swatches({ value, onChange, none }: { value: string | null; onChange: (c: string | null) => void; none?: boolean }) { + const own = value && !COLORS.includes(value); + return ( +
+ {none &&
+ ); +} + +/** Pretends to talk while `on`, so the preview shows what the voice does. */ +function useFakeVoice(on: boolean) { + const ref = useRef(null); + useEffect(() => { + const el = ref.current; + if (!el || !on) return el?.style.setProperty("--level", "0"); + const t0 = performance.now(); + const tick = setInterval(() => { + const t = (performance.now() - t0) / 1000; + // Syllables on top of phrases, with pauses between them. + const level = Math.sin(t * 9) * 0.3 + Math.sin(t * 2.3) * 0.35 + Math.sin(t * 0.8) * 0.45; + el.style.setProperty("--level", Math.max(0, Math.min(1, level)).toFixed(2)); + }, 50); + return () => clearInterval(tick); + }, [on]); + return ref; +} + +/** Our name style, decoration and status, shown as friends see them; saved here and on every server. */ +export function ProfileDialog(props: { + id: string; + name: string; + avatar: string | null; + /** URLs of our own decoration image and name font, if there are any. */ + decoration: string | null; + font: string | null; + profile?: Profile; + onClose: () => void; +}) { + const initial = cleanProfile(props.profile ?? {}); + const [tab, setTab] = useState("nick"); + const [font, setFont] = useState(initial.font ?? null); + const [effect, setEffect] = useState(initial.effect ?? null); + const [color, setColor] = useState(initial.color ?? null); + const [color2, setColor2] = useState(initial.color2 ?? null); + const [decoration, setDecoration] = useState(initial.decoration ?? null); + const [status, setStatus] = useState(initial.status ?? ""); + const [expiry, setExpiry] = useState(initial.status_until ? "keep" : "0"); + // Own files picked here; undefined while unchanged. + const [ownDeco, setOwnDeco] = useState(); + const [ownFont, setOwnFont] = useState(); + const [google, setGoogle] = useState(googleFamily(initial.font) ?? ""); + const [busy, setBusy] = useState<"" | "deco" | "font" | "google">(""); + const [error, setError] = useState(""); + const [talking, setTalking] = useState(true); + const preview = useFakeVoice(talking); + const decoFile = useRef(null); + const fontFile = useRef(null); + + const decoUrl = ownDeco?.url ?? props.decoration; + const fontUrl = ownFont?.url ?? props.font; + const two = EFFECTS.find((e) => e.id === effect)?.two; + const draft: Profile = { font, effect, color, color2: two ? color2 : null, decoration, status: status.trim() || null }; + + async function run(what: typeof busy, job: () => Promise) { + setBusy(what); + setError(""); + try { + await job(); + } catch (e) { + setError(errorText(e)); + } finally { + setBusy(""); + } + } + + const pickDeco = (f: File) => + run("deco", async () => { + setOwnDeco(await renderDecoration(f)); + setDecoration("custom"); + }); + + const pickFont = (f: File) => + run("font", async () => { + setOwnFont(await localAvatar(await readFontFile(f))); + setFont("custom"); + }); + + const useGoogle = (e: FormEvent) => { + e.preventDefault(); + const family = google.trim().replace(/\s+/g, " "); + if (!family) return; + void run("google", async () => { + if (!/^[A-Za-z0-9 ]{1,40}$/.test(family)) throw new Error("в названии только латиница, цифры и пробелы, как на fonts.google.com"); + if (!(await googleFontExists(family))) throw new Error(`в Google Fonts нет «${family}»`); + setFont(GOOGLE_PREFIX + family); + }); + }; + + function save() { + if (ownDeco) { + updateSettings({ decorationFile: ownDeco }); + void syncPictureEverywhere("decoration"); + } + if (ownFont) { + updateSettings({ fontFile: ownFont }); + void syncPictureEverywhere("font"); + } + const until = expiry === "keep" ? initial.status_until : statusUntil(expiry === "day" ? "day" : Number(expiry)); + updateSettings({ profile: cleanProfile({ ...draft, status_until: until }) }); + void syncProfileEverywhere(); + props.onClose(); + } + + const avatar = (p: Profile, className: string, still = false) => ( + + ); + + const fontRow = (id: string | null, label: string) => ( + + ); + + return ( + } sub="Так тебя видят друзья на всех твоих серверах." onClose={props.onClose}> +
+
+ {avatar(draft, `av xl${talking ? " speaking" : ""}`)} +
+ +
{draft.status ?? "без статуса"}
+
+ +
+
+ {avatar(draft, `av${talking ? " speaking" : ""}`)} +
+
+ + ты +
+ {draft.status &&
{draft.status}
} +
+
+
+ + + value={tab} + onChange={setTab} + options={[ + { value: "nick", label: "Ник" }, + { value: "decoration", label: "Декорация" }, + { value: "status", label: "Статус" }, + ]} + /> + + {tab === "nick" && ( + <> +
+
+ {fontRow(null, "Segoe UI")} + {FONT_GROUPS.map((g) => ( +
+
{g.name}
+ {SHIPPED_FONTS.filter((f) => f.group === g.id).map((f) => fontRow(f.id, f.family))} +
+ ))} +
+
+
+ setGoogle(e.target.value)} /> + +
+
+ Свой файл + {fontUrl ? (font === "custom" ? "выбран" : "загружен") : "TTF, OTF, WOFF2 до 512 КБ"} + {fontUrl && font !== "custom" && ( + + )} + +
+
+ { + const f = e.target.files?.[0]; + e.target.value = ""; + if (f) void pickFont(f); + }} + /> +
+
+ setEffect(v || null)} options={EFFECTS.map((e) => ({ value: e.id ?? "", label: e.name }))} /> +
+
+ + {two && } +
+ + )} + + {tab === "decoration" && ( +
+
+ + {DECORATIONS.map((d) => ( + + ))} + +
+ { + const f = e.target.files?.[0]; + e.target.value = ""; + if (f) void pickDeco(f); + }} + /> +
+ )} + + {tab === "status" && ( + <> + +
+ {STATUS_IDEAS.map((idea) => ( + + ))} + {status && ( + + )} +
+ {status.trim() && ( +
+ +
+ )} + + )} + + {error &&
{error}
} +
+ + +
+
+ ); +} diff --git a/client/src/components/ServerView.tsx b/client/src/components/ServerView.tsx index 39dfa9f..035eb32 100644 --- a/client/src/components/ServerView.tsx +++ b/client/src/components/ServerView.tsx @@ -1,10 +1,11 @@ -import { ImagePlus, LogOut, Maximize2, Pencil, Search, Shield, ShieldOff, Trash2, Upload, UserMinus } from "lucide-react"; +import { ImagePlus, LogOut, Maximize2, Pencil, Search, Shield, ShieldOff, Sparkles, Trash2, Upload, UserMinus } from "lucide-react"; import { FormEvent, PointerEvent as ReactPointerEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from "react"; -import { syncAvatar } from "../lib/avatar"; +import { syncAvatar, syncPicture } from "../lib/avatar"; import { ask } from "../lib/confirm"; +import { statusOf, syncProfile } from "../lib/profile"; import { updateSettings, useSettings } from "../lib/settings"; -import { api, avatarUrl, errorCode, errorText, forgetServer, Member, Role, RoomInfo, SavedServer } from "../lib/tauri"; +import { api, avatarUrl, decorationUrl, errorCode, errorText, fontUrl, forgetServer, Member, Profile, Role, RoomInfo, SavedServer } from "../lib/tauri"; import { EndReason, Peer, ScreenShare, useVoice, voice } from "../lib/voice"; import { AvatarDialog, removeAvatar } from "./AvatarDialog"; import { DeleteDialog } from "./DeleteDialog"; @@ -21,8 +22,9 @@ import { SlidersIcon, } from "./icons"; import { InviteDialog } from "./InviteDialog"; +import { ProfileDialog } from "./ProfileDialog"; import { SettingsDialog } from "./SettingsDialog"; -import { Avatar, Modal, RoleBadge } from "./ui"; +import { Avatar, Modal, Nick, RoleBadge } from "./ui"; const RANK: Record = { member: 0, admin: 1, owner: 2 }; @@ -105,10 +107,11 @@ function ScreenTile({ screen }: { screen: ScreenShare }) { } /** - * Opens under our own row: "change avatar", then a file or Pinterest. - * Clicks on the row itself are left to the row, which toggles the menu. + * Opens under our own row: "change avatar", then a file or Pinterest; or + * the rest of the profile. Clicks on the row itself are left to the row, + * which toggles the menu. */ -function MeMenu(props: { onClose: () => void; onFile: (f: File) => void; onPinterest: () => void; onNickname: () => void }) { +function MeMenu(props: { onClose: () => void; onFile: (f: File) => void; onPinterest: () => void; onNickname: () => void; onProfile: () => void }) { const { onClose, onFile, onPinterest } = props; const s = useSettings(); const [choosing, setChoosing] = useState(false); @@ -134,6 +137,9 @@ function MeMenu(props: { onClose: () => void; onFile: (f: File) => void; onPinte + ) : ( <> @@ -170,6 +176,18 @@ function MeMenu(props: { onClose: () => void; onFile: (f: File) => void; onPinte ); } +/** A name in the one-line rows, in its color, with the status after it. */ +function MemberName({ name, profile, font, self }: { name: string; profile?: Profile; font?: string | null; self?: boolean }) { + const status = statusOf(profile); + return ( +
+ + {self && · ты} + {status && {status}} +
+ ); +} + /** * One person in the call. Volume lives under the mouse wheel (right click * resets it) and is only shown when it is not 100% or while changing it. @@ -178,12 +196,17 @@ function MeMenu(props: { onClose: () => void; onFile: (f: File) => void; onPinte function PeerRow(props: { peer: Peer; avatar: string | null; + profile?: Profile; + decoration?: string | null; + /** URL of their own name font. */ + font?: string | null; actions?: ReactNode; onClick?: () => void; onGrab?: (e: ReactPointerEvent) => void; children?: ReactNode; }) { const { peer, actions } = props; + const status = statusOf(props.profile); const level = useVoiceLevel(peer.identity); const settings = useSettings(); const volume = settings.volumes[peer.identity] ?? 1; @@ -216,15 +239,16 @@ function PeerRow(props: { e.preventDefault(); setVolume(1); }} - title={peer.isLocal ? (props.onClick ? "Нажми, чтобы сменить аватар" : undefined) : `Громкость ${Math.round(volume * 100)}% · колесо мыши — изменить, правый клик — сбросить`} + title={peer.isLocal ? (props.onClick ? "Нажми, чтобы изменить профиль" : undefined) : `Громкость ${Math.round(volume * 100)}% · колесо мыши — изменить, правый клик — сбросить`} > - +
- {peer.name} + {peer.isLocal && ты}
+ {status &&
{status}
}
{actions} @@ -251,7 +275,7 @@ export function ServerView({ server, onChanged, onRemoved }: { server: SavedServ const [role, setRole] = useState(server.role); const [name, setName] = useState(server.name); const [fatal, setFatal] = useState<"unauthorized" | "gone" | null>(null); - const [dialog, setDialog] = useState(null); + const [dialog, setDialog] = useState(null); const [meMenu, setMeMenu] = useState(false); const closeMeMenu = useCallback(() => setMeMenu(false), []); // Set when the avatar comes from disk: the dialog starts at framing it. @@ -274,8 +298,9 @@ export function ServerView({ server, onChanged, onRemoved }: { server: SavedServ api<{ name: string }>(server.host, "GET", "/api/info"), ]); setRole(me.role); - // A picture chosen while this server was offline, or on first visit. - setMembers((await syncAvatar(server.host, me)) ? await api(server.host, "GET", "/api/members") : list); + // A picture or profile chosen while this server was offline, or on first visit. + const synced = await Promise.all([syncAvatar(server.host, me), syncPicture(server.host, me, "decoration"), syncPicture(server.host, me, "font"), syncProfile(server.host, me)]); + setMembers(synced.some(Boolean) ? await api(server.host, "GET", "/api/members") : list); setName(info.name); // The backend refreshed its cache from these; update the sidebar. if (me.role !== server.role || me.nickname !== server.nickname || info.name !== server.name) onChanged(); @@ -314,6 +339,22 @@ export function ServerView({ server, onChanged, onRemoved }: { server: SavedServ const version = byId.get(id)?.avatar; return version ? avatarUrl(server.host, id, version) : null; }; + // Ours from here too; people in our room from their live metadata, which + // changes the moment they save; the rest from the member list. + const profileFor = (id: string, live?: Profile): Profile | undefined => { + if (id === server.member_id && settings.profile !== undefined) return settings.profile; + return live ?? byId.get(id); + }; + const decorationFor = (id: string, live?: Profile) => { + if (id === server.member_id && settings.decorationFile !== undefined) return settings.decorationFile?.url ?? null; + const version = live?.decoration_file ?? byId.get(id)?.decoration_file; + return version ? decorationUrl(server.host, id, version) : null; + }; + const fontFor = (id: string, live?: Profile) => { + if (id === server.member_id && settings.fontFile !== undefined) return settings.fontFile?.url ?? null; + const version = live?.font_file ?? byId.get(id)?.font_file; + return version ? fontUrl(server.host, id, version) : null; + }; useEffect(() => { if (!here || !v.endReason) return; @@ -567,6 +608,9 @@ export function ServerView({ server, onChanged, onRemoved }: { server: SavedServ key={p.identity} peer={{ ...p, role: m?.role ?? p.role }} avatar={avatarFor(p.identity)} + profile={profileFor(p.identity, p.profile)} + decoration={decorationFor(p.identity, p.profile)} + font={fontFor(p.identity, p.profile)} actions={m && canManage(m) ? memberActions(m) : undefined} onClick={p.isLocal ? () => setMeMenu(!meMenu) : undefined} onGrab={canMove || p.isLocal ? (e) => grab(e, { id: p.identity, name: p.name, from: r.id }) : undefined} @@ -581,8 +625,8 @@ export function ServerView({ server, onChanged, onRemoved }: { server: SavedServ key={p.id} onPointerDown={canMove || p.id === server.member_id ? (e) => grab(e, { id: p.id, name: p.name, from: r.id }) : undefined} > - -
{p.name}
+ +
))} @@ -603,15 +647,21 @@ export function ServerView({ server, onChanged, onRemoved }: { server: SavedServ className={`mrow${self ? " self" : ""}`} key={m.id} onClick={self ? () => setMeMenu(!meMenu) : undefined} - title={self ? "Нажми, чтобы сменить аватар или ник" : undefined} + title={self ? "Нажми, чтобы изменить профиль" : undefined} > - + {online && } -
- {m.nickname} - {m.id === server.member_id && · ты} -
+ {canManage(m) && memberActions(m)} {self && meMenu && meMenuPop()} @@ -668,7 +718,7 @@ export function ServerView({ server, onChanged, onRemoved }: { server: SavedServ {drag && (
- + {drag.name}
)} @@ -676,6 +726,17 @@ export function ServerView({ server, onChanged, onRemoved }: { server: SavedServ {dialog === "invite" && setDialog(null)} />} {dialog === "settings" && setDialog(null)} />} {dialog === "avatar" && setDialog(null)} />} + {dialog === "profile" && ( + setDialog(null)} + /> + )} {dialog === "delete" && setDialog(null)} onDeleted={forget} />} {dialog === "nick" && ( open()} onNickname={nick} />; + const profile = () => { + setMeMenu(false); + setDialog("profile"); + }; + return open()} onNickname={nick} onProfile={profile} />; } function memberActions(m: Member) { diff --git a/client/src/components/ui.tsx b/client/src/components/ui.tsx index 521fe62..f3c251f 100644 --- a/client/src/components/ui.tsx +++ b/client/src/components/ui.tsx @@ -1,8 +1,11 @@ import { AlertTriangle, Crown, Headphones, HelpCircle, ShieldCheck, X } from "lucide-react"; -import { ReactNode, useEffect, useState } from "react"; +import { CSSProperties, ReactNode, useEffect, useRef, useState } from "react"; import { answer, usePendingAsk } from "../lib/confirm"; -import { Role } from "../lib/tauri"; +import { fontCss } from "../lib/fonts"; +import { styleOf } from "../lib/profile"; +import { decorationById } from "../lib/decorations"; +import { Profile, Role } from "../lib/tauri"; export type Tone = "accent" | "danger" | "neutral"; @@ -123,14 +126,99 @@ export function RoleBadge({ role, size = 15 }: { role?: Role; size?: number }) { ); } -/** A member's picture, or their initials on a color of their own (grey when `plain`). */ -export function Avatar(props: { id: string; name: string; src?: string | null; className: string; plain?: boolean; children?: ReactNode }) { +type Vars = CSSProperties & Record<`--${string}`, string>; + +/** + * An image that may move. With `still` a canvas with its first frame + * covers it, and CSS lifts the cover while its owner talks or on hover. + */ +function MaybeMoving({ src, still, onError }: { src: string; still?: boolean; onError?: () => void }) { + const poster = useRef(null); + return ( + <> + { + // Drawn when the picture arrives, so the first frame; a still one + // looks the same either way. Cross-origin only taints the canvas. + const c = poster.current; + if (!c) return; + const k = Math.min(1, 288 / Math.max(e.currentTarget.naturalWidth, e.currentTarget.naturalHeight)); + c.width = Math.round(e.currentTarget.naturalWidth * k); + c.height = Math.round(e.currentTarget.naturalHeight * k); + c.getContext("2d")?.drawImage(e.currentTarget, 0, 0, c.width, c.height); + }} + /> + {still && } + + ); +} + +/** Art over the avatar, Discord's way: a square 1.2 times its size. */ +function Decoration({ id, src, still }: { id: string | null; src?: string | null; still?: boolean }) { + if (id === "custom") return src ?
: null; + const deco = decorationById(id); + if (!deco) return null; + return ( + // The markup comes from our own .deco files, parsed at start-up. + + ); +} + +/** + * A member's picture, or their initials on a color of their own (grey when + * `plain`), under their decoration. With `still` an animated picture and + * the decoration hold still until the row is hovered or the class says + * `speaking`. `decoration` is the URL of their own decoration image. + */ +export function Avatar(props: { + id: string; + name: string; + src?: string | null; + profile?: Profile; + decoration?: string | null; + className: string; + plain?: boolean; + still?: boolean; + children?: ReactNode; +}) { const [broken, setBroken] = useState(false); useEffect(() => setBroken(false), [props.src]); + const look = styleOf(props.profile); + const style: Vars = {}; + if (!props.plain) style.background = colorFor(props.id); + if (look.color) style["--c1"] = look.color; return ( -
- {props.src && !broken ? setBroken(true)} /> : initials(props.name)} +
+ {props.src && !broken ? setBroken(true)} /> : initials(props.name)} + {props.children}
); } + +/** + * A name in the member's font, colors and effect, like Discord's name + * styles. `font` is the URL of their own font file, if they use one. + */ +export function Nick({ name, profile, font, className }: { name: string; profile?: Profile; font?: string | null; className?: string }) { + const look = styleOf(profile); + const css = fontCss(look.font, font); + const style: Vars = {}; + if (look.color) style["--c1"] = look.color; + if (look.color2) style["--c2"] = look.color2; + if (css) { + style.fontFamily = css.family; + if (css.weight) style.fontWeight = css.weight; + style["--k"] = String(css.k); + } + const cls = ["nick", className, look.effect && `fx-${look.effect}`, look.color && "tinted"]; + return ( + + {name} + + ); +} diff --git a/client/src/decorations/README.md b/client/src/decorations/README.md new file mode 100644 index 0000000..2e025e0 --- /dev/null +++ b/client/src/decorations/README.md @@ -0,0 +1,61 @@ +# Decorations + +Each file here is one avatar decoration. The file name is its id (what the +server stores), so never rename a shipped one. `src/lib/deco.ts` turns the +files into SVG when the app starts; a file that does not parse is left out +and its error goes to the console. + +Draw them in the style of `DESIGN.md` ("Decoration art"): flat fills in +two or three tones, a dark outline on every piece, details cut out. + +## Layout of a `.deco` file + +``` +// Comments start with two slashes (in the header). +name: Корона // shown in the picker +order: 40 // place in the picker, smaller first +$gold: #ffd84d // colors, used as $gold below +$me: #c6f36b // fallback for the member's own color +--- + + + +``` + +Below `---` is SVG for a **120 × 120 canvas**; the avatar is the circle +`(60, 60) r 50`, Discord's layout (a square 1.2 times the avatar). Keep the +middle of the circle clear: that is where the face is. `` +comments work there. + +## Colors + +- `$ink` — the outline color, in every file. +- `$me` — the member's own color (their name color), falling back to the + file's `$me` or voicy's lime. +- Anything else you declare in the header. + +## Additions to SVG + +| Write | Get | +|---|---| +| `outline` on any shape | the standard ink outline, 2.4 wide; `outline="1.6"` for another width | +| `anim="sway"`, `delay=".3"` | one of the loops in `styles.css` (`bob sway pulse twinkle flicker twitch flap-l flap-r fall rise blink`), which run only while the owner talks or is hovered | +| `@(deg, r)` in any value | the point `x y` on a circle around the avatar center, 0° up, clockwise | +| `{expr}` in any value | arithmetic on numbers and parameters: `{r * 0.52}`, `{-w/2}` | +| `` | a thick outlined stroke | +| `…` | the drawing plus its mirror image (left and right ears) | +| `…` | only the mirror image | +| `…` | one outline around everything inside, not one per piece | +| `…` | `n` copies turned around a point (the avatar center by default), over the whole circle or over `span` degrees from `turn`; `{i}` and CSS `--i` are the copy's number | +| `30` | text as in SVG; a shipped font (`Unbounded`, `Press Start 2P`…) loads when it is used | +| `` | a smooth closed shape through the points | +| `` | a twinkling four-point star | +| `` rows `` | pixel art with an outline: `X` fill, `o` light, `d` dark, `.` empty | +| `…` | a template; `` draws it with those parameters | + +Voice-driven parts: CSS `--level` (0..1) is the owner's loudness, and the +classes `voice` (fades in with the voice), `eqbar` (grows with it) and +`mana` (drains with it) are styled in `styles.css`, as is `glow` (a soft +halo in the color of `--glow`). + +Check a new decoration at 26, 34 and 72 px before committing it. diff --git a/client/src/decorations/butterflies.deco b/client/src/decorations/butterflies.deco new file mode 100644 index 0000000..f0db20f --- /dev/null +++ b/client/src/decorations/butterflies.deco @@ -0,0 +1,24 @@ +// Two butterflies, the big one in the member's color. +name: Бабочки +order: 120 +$me: #8cc8ff +$gold: #ffd84d +--- + + + + + + + + + + + + + + + + + + diff --git a/client/src/decorations/cat.deco b/client/src/decorations/cat.deco new file mode 100644 index 0000000..d729a35 --- /dev/null +++ b/client/src/decorations/cat.deco @@ -0,0 +1,13 @@ +// Cat ears in the member's color, rooted on the rim, twitching now and then. +name: Ушки +order: 30 +$me: #f1e4d3 +$inner: #ffb3c8 +--- + + + + + + + diff --git a/client/src/decorations/crown.deco b/client/src/decorations/crown.deco new file mode 100644 index 0000000..edadd6b --- /dev/null +++ b/client/src/decorations/crown.deco @@ -0,0 +1,28 @@ +// A gold crown sitting on the rim, tipped to one side: five points with +// balls on top, a curved band with a ruby in the middle and two sapphires. +name: Корона +order: 40 +$gold: #ffd84d +$shade: #f0a52e +$shine: #fff6c2 +$ruby: #ff4d6d +$sapphire: #5ec4f0 +--- + + + + + + + + + + + + + + + + + + diff --git a/client/src/decorations/crystals.deco b/client/src/decorations/crystals.deco new file mode 100644 index 0000000..3935d1f --- /dev/null +++ b/client/src/decorations/crystals.deco @@ -0,0 +1,34 @@ +// Amethyst clusters growing out of the rim, each shard pointing away from +// the avatar: a big cluster low on one side, a small one high on the other. +name: Кристаллы +order: 90 +$light: #eadfff +$mid: #b89cff +$dark: #7c5cf0 +$tip: #f7f2ff +--- + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/decorations/devil.deco b/client/src/decorations/devil.deco new file mode 100644 index 0000000..3c31486 --- /dev/null +++ b/client/src/decorations/devil.deco @@ -0,0 +1,17 @@ +// Horns on the rim and a tail curling out at the bottom right. +name: Демон +order: 60 +$red: #ff4d5e +$deep: #c42f48 +--- + + + + + + + + + + + diff --git a/client/src/decorations/equalizer.deco b/client/src/decorations/equalizer.deco new file mode 100644 index 0000000..abfd7c9 --- /dev/null +++ b/client/src/decorations/equalizer.deco @@ -0,0 +1,12 @@ +// A ring of level bars that is the voice: each bar grows with --level at +// its own gain (from --i, see .eqbar in styles.css). +name: Эквалайзер +order: 20 +$me: #c6f36b +--- + + + + + + diff --git a/client/src/decorations/fire.deco b/client/src/decorations/fire.deco new file mode 100644 index 0000000..6302e11 --- /dev/null +++ b/client/src/decorations/fire.deco @@ -0,0 +1,46 @@ +// A ring of fire burning along the lower rim: tall tongues up the sides, +// short ones along the bottom, one outline around the whole blaze. +name: Пламя +order: 80 +$flame: #ff4f2e +$mid: #ff9a2e +$core: #ffe066 +--- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/decorations/halo.deco b/client/src/decorations/halo.deco new file mode 100644 index 0000000..b741798 --- /dev/null +++ b/client/src/decorations/halo.deco @@ -0,0 +1,19 @@ +// A halo over the head and two small wings at the sides. +name: Ангел +order: 50 +$gold: #ffd84d +$shine: #fff6c2 +$feather: #fbfbff +$vein: #c9c9da +--- + + + + + + + + + + + diff --git a/client/src/decorations/headphones.deco b/client/src/decorations/headphones.deco new file mode 100644 index 0000000..f434d2f --- /dev/null +++ b/client/src/decorations/headphones.deco @@ -0,0 +1,22 @@ +// Voicy's own: a headset in the member's color. The waves at the top are +// the voice, they show only while the owner talks. +name: Наушники +order: 10 +$band: #2b2b33 +$me: #c6f36b +--- + + + + + + + + + + + + + + diff --git a/client/src/decorations/laurels.deco b/client/src/decorations/laurels.deco new file mode 100644 index 0000000..5df4bc6 --- /dev/null +++ b/client/src/decorations/laurels.deco @@ -0,0 +1,26 @@ +// A ranked look: a gold laurel wreath around the lower half, a medal with a +// star where the branches meet. +name: Лавры +order: 170 +$leaf: #ffd84d +$shade: #e0a232 +$stem: #b8782a +--- + + + + + + + + + + + + + + + + + + diff --git a/client/src/decorations/lycoris.deco b/client/src/decorations/lycoris.deco new file mode 100644 index 0000000..363151e --- /dev/null +++ b/client/src/decorations/lycoris.deco @@ -0,0 +1,28 @@ +// Dead inside, the quiet one: red spider lilies at the rim. +name: 1000-7 +order: 150 +$red: #e5484d +$deep: #a3242d +$stem: #3f5a3a +--- + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/decorations/mid.deco b/client/src/decorations/mid.deco new file mode 100644 index 0000000..4ca3c45 --- /dev/null +++ b/client/src/decorations/mid.deco @@ -0,0 +1,22 @@ +// For the mid players: health and mana over the head, as in any MOBA, and +// a level badge. The mana drains while the owner talks. +name: Мид +order: 160 +$hp: #5fd068 +$hpdark: #2f7a3a +$mana: #4aa3ff +$frame: #1b1b21 +$me: #c6f36b +--- + + + + + + + + + + 30 + + diff --git a/client/src/decorations/mushrooms.deco b/client/src/decorations/mushrooms.deco new file mode 100644 index 0000000..16b9602 --- /dev/null +++ b/client/src/decorations/mushrooms.deco @@ -0,0 +1,35 @@ +// Toadstools in the grass at the bottom left, one more up top. +name: Грибы +order: 100 +$cap: #ff5a4e +$capshade: #c43b33 +$stem: #f5e6c8 +$spot: #fff4e6 +$grass: #6cc56a +$moss: #4fae57 +$spark: #e8ffd6 +--- + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/decorations/pixel.deco b/client/src/decorations/pixel.deco new file mode 100644 index 0000000..6682a67 --- /dev/null +++ b/client/src/decorations/pixel.deco @@ -0,0 +1,33 @@ +// Health from an old game over the head; the empty heart blinks. +name: Пиксели +order: 110 +$heart: #ff4d5e +$shine: #ffd0d6 +$empty: #3a3a44 +$gold: #ffd84d +$cyan: #8cf0ff +--- + + + .XX.XX. + XoXXXXX + XXXXXXX + .XXXXX. + ..XXX.. + ...X... + + + + + + + + .X. + XXX + .X. + + + .X. + XXX + .X. + diff --git a/client/src/decorations/sakura.deco b/client/src/decorations/sakura.deco new file mode 100644 index 0000000..f3cbf13 --- /dev/null +++ b/client/src/decorations/sakura.deco @@ -0,0 +1,35 @@ +// A cherry branch along the lower left of the rim, petals falling off it. +name: Сакура +order: 70 +$bark: #5a3a2e +$petal: #ffc6d9 +$heart: #ff6f9a +--- + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/decorations/snow.deco b/client/src/decorations/snow.deco new file mode 100644 index 0000000..d94e3b1 --- /dev/null +++ b/client/src/decorations/snow.deco @@ -0,0 +1,29 @@ +// A cap of snow on the head, dripping over the rim, flakes falling past. +name: Зима +order: 130 +$snow: #f7fbff +$shadow: #cfe3f5 +--- + + + + + + + + + + + + + + + + + + diff --git a/client/src/decorations/zxc.deco b/client/src/decorations/zxc.deco new file mode 100644 index 0000000..a9de7fa --- /dev/null +++ b/client/src/decorations/zxc.deco @@ -0,0 +1,22 @@ +// Dead inside: barbed wire stitched along the rim, black and white with +// one drop of red. +name: zxc +order: 140 +$wire: #8a8a94 +$light: #d4d4d8 +$white: #f4f4f5 +$red: #e5484d +--- + + + + + + + + + + + + diff --git a/client/src/gifenc.d.ts b/client/src/gifenc.d.ts new file mode 100644 index 0000000..c9543f4 --- /dev/null +++ b/client/src/gifenc.d.ts @@ -0,0 +1,21 @@ +// gifenc ships no types; just the parts lib/avatar.ts uses. +declare module "gifenc" { + export type Format = "rgb565" | "rgb444" | "rgba4444"; + export type Palette = number[][]; + + export function quantize(rgba: Uint8Array | Uint8ClampedArray, maxColors: number, options?: { format?: Format; oneBitAlpha?: boolean | number }): Palette; + export function applyPalette(rgba: Uint8Array | Uint8ClampedArray, palette: Palette, format?: Format): Uint8Array; + + export interface Encoder { + writeFrame( + index: Uint8Array, + width: number, + height: number, + opts?: { palette?: Palette; delay?: number; repeat?: number; transparent?: boolean; transparentIndex?: number; dispose?: number }, + ): void; + finish(): void; + bytes(): Uint8Array; + } + + export function GIFEncoder(opts?: { auto?: boolean; initialCapacity?: number }): Encoder; +} diff --git a/client/src/lib/avatar.ts b/client/src/lib/avatar.ts index b96e90f..407bd4e 100644 --- a/client/src/lib/avatar.ts +++ b/client/src/lib/avatar.ts @@ -1,8 +1,10 @@ +import { applyPalette, GIFEncoder, quantize } from "gifenc"; + import { getSettings } from "./settings"; import { api, listServers, Member } from "./tauri"; export interface LocalAvatar { - /** The cropped picture as a data: URL (WebP, or JPEG as a fallback). */ + /** The cropped picture as a data: URL (WebP, or JPEG as a fallback; GIF when animated). */ url: string; /** The content hash the server derives too, to tell when a server is behind. */ version: string; @@ -48,6 +50,11 @@ export async function renderAvatar(img: HTMLImageElement, crop: Crop): Promise((ok) => canvas.toBlob(ok, "image/webp", 0.95)); if (!blob || blob.type !== "image/webp") blob = await new Promise((ok) => canvas.toBlob(ok, "image/jpeg", 0.93)); if (!blob) throw new Error("не удалось сохранить картинку"); + return localAvatar(blob); +} + +/** A file as we keep and send it: a data: URL and the server's content hash. */ +export async function localAvatar(blob: Blob): Promise { const bytes = new Uint8Array(await blob.arrayBuffer()); const url = await new Promise((ok, fail) => { const r = new FileReader(); @@ -58,9 +65,177 @@ export async function renderAvatar(img: HTMLImageElement, crop: Crop): Promise String.fromCharCode(...b.slice(from, to)); + if (text(0, 4) === "GIF8") return "image/gif"; + if (text(0, 4) === "RIFF" && text(8, 12) === "WEBP") return "image/webp"; + if (b[0] === 0x89 && text(1, 4) === "PNG") return "image/png"; // APNG + return null; +} + +/** WebCodecs' decoder for the picture when it has more than one frame, else null. */ +async function animationDecoder(blob: Blob) { + const type = await motionType(blob); + if (!type || typeof ImageDecoder === "undefined" || !(await ImageDecoder.isTypeSupported(type))) return null; + const decoder = new ImageDecoder({ data: await blob.arrayBuffer(), type }); + try { + // Tracks can still be empty when the data is in; wait for both. + await Promise.all([decoder.tracks.ready, decoder.completed]); + const track = decoder.tracks.selectedTrack; + if (track?.animated && track.frameCount > 1) return decoder; + } catch { + // Not decodable here: treat it as a still picture. + } + decoder.close(); + return null; +} + +export async function isAnimated(blob: Blob) { + const decoder = await animationDecoder(blob); + decoder?.close(); + return Boolean(decoder); +} + +/** + * The server keeps up to 512 KB (`avatar::MAX_BYTES`), servers that + * predate animation too, so a GIF is made to fit rather than the limit + * raised. Tried in order until one fits: side in px, shortest frame in ms. + */ +const MAX_UPLOAD = 512 * 1024; +const GIF_TRIES = [ + [160, 40], + [128, 40], + [128, 70], + [96, 70], + [96, 120], +] as const; +/** Frames kept at most; longer animations drop every other one and so on. */ +const MAX_FRAMES = 150; + +interface Frame { + canvas: HTMLCanvasElement; + /** Milliseconds. */ + delay: number; +} + +/** + * Crops every frame of an animated GIF, WebP or APNG and saves it as a GIF, + * the one moving format a canvas can be turned into here. + */ +export async function renderAnimatedAvatar(blob: Blob, crop: Crop): Promise { + const decoder = await animationDecoder(blob); + if (!decoder) throw new Error("это не анимация"); + const base = GIF_TRIES[0][0]; + const frames: Frame[] = []; + let total = 0; + try { + const count = decoder.tracks.selectedTrack!.frameCount; + for (let i = 0; i < count; i++) { + const { image } = await decoder.decode({ frameIndex: i, completeFramesOnly: true }); + // Browsers play 0-10 ms frames at 100 ms; do the same. + const delay = image.duration && image.duration > 10_000 ? image.duration / 1000 : 100; + // Two steps down, as in `renderAvatar`, for big sources. + const mid = square(Math.max(base, Math.min(base * 2, Math.round(crop.size)))); + mid.ctx.drawImage(image, crop.x, crop.y, crop.size, crop.size, 0, 0, mid.canvas.width, mid.canvas.width); + image.close(); + const frame = square(base); + frame.ctx.drawImage(mid.canvas, 0, 0, base, base); + frames.push({ canvas: frame.canvas, delay }); + total += delay; + } + } finally { + decoder.close(); + } + for (const [px, minDelay] of GIF_TRIES) { + const bytes = encodeGif(frames, px, Math.max(minDelay, total / MAX_FRAMES)); + if (bytes.length <= MAX_UPLOAD) return localAvatar(new Blob([bytes as BlobPart], { type: "image/gif" })); + } + throw new Error("гифка слишком тяжёлая даже в маленьком размере, возьми покороче"); +} + +/** Frames shorter than `minDelay` are merged into the one before, so the timing stays. */ +function encodeGif(frames: Frame[], px: number, minDelay: number) { + const gif = GIFEncoder(); + const { ctx } = square(px); + let pending: { frame: Frame; delay: number } | null = null; + const write = ({ frame, delay }: { frame: Frame; delay: number }) => { + ctx.clearRect(0, 0, px, px); + ctx.drawImage(frame.canvas, 0, 0, px, px); + const { data } = ctx.getImageData(0, 0, px, px); + let holes = false; + for (let i = 3; i < data.length; i += 4) if (data[i] < 128) { holes = true; break; } + const format = holes ? "rgba4444" : "rgb565"; + const palette = quantize(data, 256, { format, oneBitAlpha: holes }); + const clear = holes ? palette.findIndex((c) => c[3] === 0) : -1; + gif.writeFrame(applyPalette(data, palette, format), px, px, { + palette, + delay: Math.round(delay), + transparent: clear >= 0, + transparentIndex: Math.max(clear, 0), + // Frames come whole: clear the last one so its pixels don't show through. + dispose: 2, + }); + }; + for (const frame of frames) { + if (pending && pending.delay < minDelay) pending.delay += frame.delay; + else { + if (pending) write(pending); + pending = { frame, delay: frame.delay }; + } + } + if (pending) write(pending); + gif.finish(); + return gif.bytes(); +} + +/** Side of a still decoration: what Discord uses too. */ +const DECORATION_PX = 288; +/** `profile::DECORATION_MAX_BYTES` on the server. */ +const DECORATION_MAX = 1024 * 1024; + +/** + * A decoration of one's own, in Discord's layout: a square 1.2 times the + * avatar. Still pictures are cut to a centered square and made small; + * moving ones go up as they are, since nothing here writes APNG. + */ +export async function renderDecoration(blob: Blob): Promise { + if (await isAnimated(blob)) { + if (blob.size > DECORATION_MAX) throw new Error("анимированная декорация больше 1 МБ, сожми её или возьми поменьше"); + return localAvatar(blob); + } + const url = URL.createObjectURL(blob); + try { + const img = new Image(); + img.src = url; + await img.decode(); + const side = Math.min(img.naturalWidth, img.naturalHeight); + const px = Math.min(DECORATION_PX, side); + const { canvas, ctx } = square(px); + ctx.drawImage(img, (img.naturalWidth - side) / 2, (img.naturalHeight - side) / 2, side, side, 0, 0, px, px); + // WebP keeps the see-through parts, which a decoration is mostly made of. + let out = await new Promise((ok) => canvas.toBlob(ok, "image/webp", 0.92)); + if (!out || out.type !== "image/webp") out = await new Promise((ok) => canvas.toBlob(ok, "image/png")); + if (!out) throw new Error("не удалось сохранить картинку"); + return localAvatar(out); + } finally { + URL.revokeObjectURL(url); + } +} + +/** Files we upload: the avatar, a decoration and a name font of our own. */ +const PICTURES = { + avatar: { setting: "avatar", field: "avatar", path: "/api/me/avatar" }, + decoration: { setting: "decorationFile", field: "decoration_file", path: "/api/me/decoration" }, + font: { setting: "fontFile", field: "font_file", path: "/api/me/font" }, +} as const; + +export type PictureKind = keyof typeof PICTURES; + /** * When an upload last failed, by host and picture. A server may be too old - * for avatars, or restarting mid-upload; either way try again a bit later + * for it, or restarting mid-upload; either way try again a bit later * instead of hammering it on every poll or giving up for good. */ const failedAt = new Map(); @@ -70,34 +245,39 @@ const RETRY_MS = 60_000; * Brings our picture on `host` in line with the one chosen here. Returns * true when it changed something, so the caller can reload the members. */ -export async function syncAvatar(host: string, me: Member): Promise { - const mine = getSettings().avatar; +export async function syncPicture(host: string, me: Member, kind: PictureKind): Promise { + const { setting, field, path } = PICTURES[kind]; + const mine = getSettings()[setting]; if (mine === undefined) return false; const want = mine?.version ?? null; - const key = `${host} ${want}`; - if ((me.avatar ?? null) === want || Date.now() - (failedAt.get(key) ?? 0) < RETRY_MS) return false; + const key = `${kind} ${host} ${want}`; + if ((me[field] ?? null) === want || Date.now() - (failedAt.get(key) ?? 0) < RETRY_MS) return false; try { - if (mine) await api(host, "PUT", "/api/me/avatar", { data: mine.url.slice(mine.url.indexOf(",") + 1) }); - else await api(host, "DELETE", "/api/me/avatar"); + if (mine) await api(host, "PUT", path, { data: mine.url.slice(mine.url.indexOf(",") + 1) }); + else await api(host, "DELETE", path); failedAt.delete(key); return true; } catch (e) { failedAt.set(key, Date.now()); - console.warn(`avatar sync with ${host} failed`, e); + console.warn(`${kind} sync with ${host} failed`, e); return false; } } +export const syncAvatar = (host: string, me: Member) => syncPicture(host, me, "avatar"); + /** After a new pick: every server, not just the open one, so friends see it right away. */ -export async function syncAvatarEverywhere() { +export async function syncPictureEverywhere(kind: PictureKind) { const servers = await listServers().catch(() => []); await Promise.all( servers.map(async (s) => { try { - await syncAvatar(s.host, await api(s.host, "GET", "/api/me")); + await syncPicture(s.host, await api(s.host, "GET", "/api/me"), kind); } catch { // Offline or gone: the next visit to that server syncs it. } }), ); } + +export const syncAvatarEverywhere = () => syncPictureEverywhere("avatar"); diff --git a/client/src/lib/deco.ts b/client/src/lib/deco.ts new file mode 100644 index 0000000..2667ceb --- /dev/null +++ b/client/src/lib/deco.ts @@ -0,0 +1,368 @@ +// Parser for `.deco` files: avatar decorations as SVG with a few macros, so +// each one reads like a drawing instead of a pile of numbers. The format is +// described in src/decorations/README.md; this turns a file into plain SVG +// markup for a 120 x 120 canvas with the avatar at (60, 60) r 50. + +export interface Deco { + id: string; + name: string; + order: number; + /** Inner markup for ``. */ + svg: string; +} + +const INK = "#0b0b0e"; +/** Outline width, see DESIGN.md. */ +const LINE = 2.4; +const CENTER = 60; + +type Params = Record; + +/* ─── Arithmetic in {…} and @(…) ─── */ + +/** `+ - * /`, parentheses, numbers and parameter names; nothing else. */ +function evaluate(src: string, params: Params): number { + const tokens = src.match(/\d*\.\d+|\d+|[A-Za-z_]\w*|[-+*/()]/g) ?? []; + if (tokens.join("") !== src.replace(/\s+/g, "")) throw new Error(`bad expression "${src}"`); + let at = 0; + const peek = () => tokens[at]; + const atom = (): number => { + const t = tokens[at++]; + if (t === "(") { + const v = sum(); + if (tokens[at++] !== ")") throw new Error(`missing ) in "${src}"`); + return v; + } + if (t === "-") return -atom(); + if (t === undefined) throw new Error(`unfinished "${src}"`); + if (/^[\d.]/.test(t)) return Number(t); + const v = params[t]; + if (typeof v !== "number") throw new Error(`unknown number "${t}" in "${src}"`); + return v; + }; + const product = (): number => { + let v = atom(); + while (peek() === "*" || peek() === "/") v = tokens[at++] === "*" ? v * atom() : v / atom(); + return v; + }; + const sum = (): number => { + let v = product(); + while (peek() === "+" || peek() === "-") v = tokens[at++] === "+" ? v + product() : v - product(); + return v; + }; + const v = sum(); + if (at !== tokens.length) throw new Error(`bad expression "${src}"`); + return v; +} + +const num = (n: number) => String(+n.toFixed(2)); + +/** A point on a circle around the avatar center; 0 degrees is up, clockwise. */ +function polar(deg: number, r: number): [number, number] { + const a = (deg * Math.PI) / 180; + return [CENTER + r * Math.sin(a), CENTER - r * Math.cos(a)]; +} + +/** Replaces `@(deg, r)` with "x y" and `{expr}` with its value. */ +function interpolate(value: string, params: Params): string { + let out = ""; + for (let i = 0; i < value.length; i++) { + if (value[i] === "@" && value[i + 1] === "(") { + let depth = 0; + let j = i + 1; + for (; j < value.length; j++) { + if (value[j] === "(") depth++; + if (value[j] === ")" && --depth === 0) break; + } + const args = splitTop(value.slice(i + 2, j)).map((a) => evaluate(interpolate(a, params), params)); + if (args.length !== 2) throw new Error(`@() takes an angle and a radius: "${value.slice(i, j + 1)}"`); + const [x, y] = polar(args[0], args[1]); + out += `${num(x)} ${num(y)}`; + i = j; + } else if (value[i] === "{") { + const j = value.indexOf("}", i); + if (j < 0) throw new Error(`missing } in "${value}"`); + const expr = value.slice(i + 1, j).trim(); + // {fill}: a template's text parameter goes in as it is. + out += typeof params[expr] === "string" ? params[expr] : num(evaluate(expr, params)); + i = j; + } else out += value[i]; + } + return out; +} + +/** Splits on commas outside parentheses. */ +function splitTop(s: string) { + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < s.length; i++) { + if (s[i] === "(") depth++; + if (s[i] === ")") depth--; + if (s[i] === "," && depth === 0) { + parts.push(s.slice(start, i)); + start = i + 1; + } + } + return [...parts, s.slice(start)]; +} + +/* ─── Shapes the macros draw ─── */ + +/** A smooth closed outline through the points (Catmull-Rom as cubic Béziers). */ +function blobPath(p: [number, number][]) { + const n = p.length; + let d = `M${num(p[0][0])} ${num(p[0][1])}`; + for (let i = 0; i < n; i++) { + const [p0, p1, p2, p3] = [p[(i - 1 + n) % n], p[i], p[(i + 1) % n], p[(i + 2) % n]]; + d += ` C${num(p1[0] + (p2[0] - p0[0]) / 6)} ${num(p1[1] + (p2[1] - p0[1]) / 6)}`; + d += ` ${num(p2[0] - (p3[0] - p1[0]) / 6)} ${num(p2[1] - (p3[1] - p1[1]) / 6)} ${num(p2[0])} ${num(p2[1])}`; + } + return d + "Z"; +} + +/** Four-point glint. */ +function glintPath(x: number, y: number, r: number) { + const k = r * 0.28; + return `M${num(x)} ${num(y - r)}Q${num(x + k)} ${num(y - k)} ${num(x + r)} ${num(y)}Q${num(x + k)} ${num(y + k)} ${num(x)} ${num(y + r)}Q${num(x - k)} ${num(y + k)} ${num(x - r)} ${num(y)}Q${num(x - k)} ${num(y - k)} ${num(x)} ${num(y - r)}Z`; +} + +/* ─── Expansion ─── */ + +interface Template { + params: Params; + body: Element[]; +} + +interface Ctx { + params: Params; + templates: Map; + /** Inside the first pass of a : every shape turns into its outline. */ + silhouette: boolean; +} + +const esc = (s: string) => s.replace(/&/g, "&").replace(/"/g, """).replace(/ { + const out: Record = {}; + for (const a of Array.from(el.attributes)) if (!skip.includes(a.name)) out[a.name] = interpolate(a.value, ctx.params); + // outline, outline="1.6": the ink stroke every piece gets. + if ("outline" in out) { + const w = out.outline && out.outline !== "outline" ? out.outline : String(LINE); + delete out.outline; + out.stroke = INK; + out["stroke-width"] = w; + out["stroke-linejoin"] ??= "round"; + out["stroke-linecap"] ??= "round"; + } + // anim="flicker" delay="0.3": one of the loops in styles.css. + if (out.anim) { + out.class = [out.class, `a-${out.anim}`].filter(Boolean).join(" "); + delete out.anim; + } + if (out.delay) { + out.style = [out.style, `animation-delay:${out.delay}s`].filter(Boolean).join(";"); + delete out.delay; + } + if (ctx.silhouette && SHAPES.has(el.localName)) { + const w = out.stroke && out.stroke !== "none" ? Number(out["stroke-width"] ?? 1) : 0; + if (out.fill !== "none") out.fill = INK; + out.stroke = INK; + out["stroke-width"] = num(w + LINE * 2); + out["stroke-linejoin"] = "round"; + out["stroke-linecap"] ??= "round"; + } + return out; +} + +function tag(name: string, attrs: Record, inner = "") { + const a = Object.entries(attrs) + .map(([k, v]) => ` ${k}="${esc(v)}"`) + .join(""); + return inner ? `<${name}${a}>${inner}` : `<${name}${a}/>`; +} + +const kids = (el: Element) => Array.from(el.children); + +function expandAll(els: Element[], ctx: Ctx): string { + return els.map((el) => expand(el, ctx)).join(""); +} + +function number(attrs: Record, key: string, fallback?: number): number { + const v = attrs[key]; + if (v === undefined) { + if (fallback === undefined) throw new Error(`missing ${key}=""`); + return fallback; + } + const n = Number(v); + if (Number.isNaN(n)) throw new Error(`${key}="${v}" is not a number`); + return n; +} + +/** Attributes a macro passes on to the around what it draws. */ +function rest(attrs: Record, used: string[]) { + return Object.fromEntries(Object.entries(attrs).filter(([k]) => !used.includes(k))); +} + +const wrap = (attrs: Record, inner: string) => (Object.keys(attrs).length ? tag("g", attrs, inner) : inner); + +function expand(el: Element, ctx: Ctx): string { + const name = el.localName; + const template = ctx.templates.get(name); + if (template) { + const params: Params = { ...ctx.params, ...template.params }; + for (const a of Array.from(el.attributes)) { + const v = interpolate(a.value, ctx.params); + params[a.name] = v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : v; + } + return expandAll(template.body, { ...ctx, params }); + } + switch (name) { + case "band": { + const a = attrsOf(el, ctx); + const w = number(a, "width"); + const line = { d: a.d, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" }; + const under = tag("path", { ...line, stroke: INK, "stroke-width": num(w + LINE * 2) }); + const over = ctx.silhouette ? "" : tag("path", { ...line, stroke: a.color ?? INK, "stroke-width": num(w) }); + return wrap(rest(a, ["d", "width", "color"]), under + over); + } + case "sticker": { + const inner = kids(el); + const a = attrsOf(el, ctx); + return wrap(a, expandAll(inner, { ...ctx, silhouette: true }) + (ctx.silhouette ? "" : expandAll(inner, ctx))); + } + case "pair": + case "mirror": { + const inner = expandAll(kids(el), ctx); + const flipped = tag("g", { transform: `translate(${CENTER * 2} 0) scale(-1 1)` }, inner); + return wrap(attrsOf(el, ctx), name === "pair" ? inner + flipped : flipped); + } + case "ring": { + const a = attrsOf(el, ctx); + const n = number(a, "n"); + const [cx, cy] = (a.around ?? `${CENTER} ${CENTER}`).trim().split(/[\s,]+/).map(Number); + const turn = number(a, "turn", 0); + // span="120": copies spread over an arc, both ends included. + const span = number(a, "span", 360); + const step = span >= 360 || n < 2 ? span / n : span / (n - 1); + let out = ""; + for (let i = 0; i < n; i++) { + const inner = expandAll(kids(el), { ...ctx, params: { ...ctx.params, i } }); + out += tag("g", { transform: `rotate(${num(turn + i * step)} ${cx} ${cy})`, style: `--i:${i}` }, inner); + } + return wrap(rest(a, ["n", "around", "turn", "span"]), out); + } + case "glint": { + const a = attrsOf(el, ctx); + const d = glintPath(number(a, "x"), number(a, "y"), number(a, "r")); + const cls = ["a-twinkle", a.class].filter(Boolean).join(" "); + const fill = ctx.silhouette ? INK : (a.color ?? "#fff"); + return tag("path", { ...rest(a, ["x", "y", "r", "color", "class"]), class: cls, d, fill, stroke: INK, "stroke-width": ctx.silhouette ? "4.8" : "1.6", "stroke-linejoin": "round" }); + } + case "blob": { + const a = attrsOf(el, ctx); + const n = (a.points ?? "").trim().split(/[\s,]+/).map(Number); + if (n.length < 6 || n.length % 2 || n.some(Number.isNaN)) throw new Error(` needs three or more "x y" points`); + const pts = Array.from({ length: n.length / 2 }, (_, i) => [n[i * 2], n[i * 2 + 1]] as [number, number]); + return tag("path", { ...rest(a, ["points"]), d: blobPath(pts) }); + } + case "pixels": { + const a = attrsOf(el, ctx); + const [x, y, px] = [number(a, "x"), number(a, "y"), number(a, "size", 2.4)]; + const rows = (el.textContent ?? "").split("\n").map((r) => r.trim()).filter(Boolean); + const at = (c: number, r: number) => rows[r]?.[c] ?? "."; + const filled = (c: number, r: number) => at(c, r) !== "."; + const colors: Record = { X: a.fill ?? "#fff", o: a.light ?? "#fff", d: a.dark ?? INK }; + let cells = ""; + const width = Math.max(...rows.map((r) => r.length)); + for (let r = -1; r <= rows.length; r++) { + for (let c = -1; c <= width; c++) { + const on = filled(c, r); + const edge = !on && [[0, 1], [0, -1], [1, 0], [-1, 0]].some(([dc, dr]) => filled(c + dc, r + dr)); + if (!on && !edge) continue; + const fill = on && !ctx.silhouette ? (colors[at(c, r)] ?? colors.X) : INK; + cells += tag("rect", { x: num(x + c * px), y: num(y + r * px), width: num(px + 0.05), height: num(px + 0.05), fill }); + } + } + return tag("g", { ...rest(a, ["x", "y", "size", "fill", "light", "dark"]), "shape-rendering": "crispEdges" }, cells); + } + default: { + const a = attrsOf(el, ctx); + // zxc: the words go in as they are, with {expr} worked out. + const words = !el.children.length && el.textContent?.trim() ? esc(interpolate(el.textContent.trim(), ctx.params)) : ""; + if (words && ctx.silhouette) { + a.fill = INK; + a.stroke = INK; + a["stroke-width"] = num(Number(a["stroke-width"] ?? 0) + LINE * 2); + a["stroke-linejoin"] = "round"; + } + return tag(name, a, words || expandAll(kids(el), ctx)); + } + } +} + +/* ─── Files ─── */ + +/** Header lines: `name: …`, `order: …`, `$color: #hex`. `//` starts a comment. */ +function header(text: string) { + const meta: Record = {}; + const palette: Record = {}; + for (const raw of text.split("\n")) { + const line = raw.replace(/\/\/.*$/, "").trim(); + if (!line) continue; + const m = line.match(/^(\$?[\w-]+)\s*:\s*(.+)$/); + if (!m) throw new Error(`header line "${line}" should be "key: value"`); + if (m[1].startsWith("$")) palette[m[1].slice(1)] = m[2]; + else meta[m[1]] = m[2]; + } + return { meta, palette }; +} + +/** Parses one `.deco` file; `id` is its file name without the extension. */ +export function parseDeco(id: string, text: string): Deco { + const src = text.replace(/\r\n/g, "\n"); + const cut = src.search(/^---\s*$/m); + if (cut < 0) throw new Error(`${id}.deco: no "---" between the header and the drawing`); + const { meta, palette } = header(src.slice(0, cut)); + if (!meta.name) throw new Error(`${id}.deco: no "name:"`); + // $ink and $me are there in every file; $me falls back to the file's own. + const colors: Record = { ink: INK, ...palette, me: `var(--c1, ${palette.me ?? "#c6f36b"})` }; + const body = src + .slice(src.indexOf("\n", cut) + 1) + .replace(//g, "") + .replace(/\$([A-Za-z][\w-]*)/g, (_, key: string) => { + if (!(key in colors)) throw new Error(`${id}.deco: unknown color $${key}`); + return colors[key]; + }) + // A bare `outline` is the usual case; XML wants a value. + .replace(/(\s)outline(?=[\s/>])/g, '$1outline="outline"'); + const doc = new DOMParser().parseFromString(`${body}`, "application/xml"); + const error = doc.querySelector("parsererror"); + // Chromium wraps the message: "This page contains the following errors:\nBelow is…". + if (error) throw new Error(`${id}.deco: ${error.textContent?.replace(/^[\s\S]*?errors:/, "").split("\n")[0].trim() || "bad markup"}`); + + const templates = new Map(); + const top: Element[] = []; + for (const el of Array.from(doc.documentElement.children)) { + if (el.localName !== "define") { + top.push(el); + continue; + } + const tname = el.getAttribute("name"); + if (!tname) throw new Error(`${id}.deco: without name=""`); + const params: Params = {}; + for (const p of (el.getAttribute("params") ?? "").split(/\s+/).filter(Boolean)) { + const [k, v] = p.split("="); + if (v !== undefined) params[k] = Number.isNaN(Number(v)) ? v : Number(v); + } + templates.set(tname, { params, body: Array.from(el.children) }); + } + try { + const svg = expandAll(top, { params: {}, templates, silhouette: false }); + return { id, name: meta.name, order: Number(meta.order ?? 1000), svg }; + } catch (e) { + throw new Error(`${id}.deco: ${e instanceof Error ? e.message : e}`); + } +} diff --git a/client/src/lib/decorations.ts b/client/src/lib/decorations.ts new file mode 100644 index 0000000..3ab9feb --- /dev/null +++ b/client/src/lib/decorations.ts @@ -0,0 +1,22 @@ +// The decorations in src/decorations, one `.deco` file each (the format is +// in src/decorations/README.md), parsed once when the app starts. +import { Deco, parseDeco } from "./deco"; + +const files = import.meta.glob("../decorations/*.deco", { query: "?raw", import: "default", eager: true }) as Record; + +export const DECORATIONS: Deco[] = Object.entries(files) + .flatMap(([path, text]) => { + const id = path.slice(path.lastIndexOf("/") + 1, -".deco".length); + try { + return [parseDeco(id, text)]; + } catch (e) { + // One broken drawing must not take the app down with it. + console.error(e); + return []; + } + }) + .sort((a, b) => a.order - b.order || a.id.localeCompare(b.id)); + +const byId = new Map(DECORATIONS.map((d) => [d.id, d])); + +export const decorationById = (id?: string | null) => (id ? byId.get(id) : undefined); diff --git a/client/src/lib/fonts.ts b/client/src/lib/fonts.ts new file mode 100644 index 0000000..177800f --- /dev/null +++ b/client/src/lib/fonts.ts @@ -0,0 +1,186 @@ +// Name fonts. Three kinds: ones shipped with the app (OFL, from +// @fontsource, WOFF2 only, downloaded by the page only once some name uses +// them), any Google Fonts family by name, and a member's own file. +import unboundedLatin from "@fontsource/unbounded/files/unbounded-latin-600-normal.woff2"; +import unboundedCyrillic from "@fontsource/unbounded/files/unbounded-cyrillic-600-normal.woff2"; +import montserratAlternatesLatin from "@fontsource/montserrat-alternates/files/montserrat-alternates-latin-600-normal.woff2"; +import montserratAlternatesCyrillic from "@fontsource/montserrat-alternates/files/montserrat-alternates-cyrillic-600-normal.woff2"; +import tekturLatin from "@fontsource/tektur/files/tektur-latin-600-normal.woff2"; +import tekturCyrillic from "@fontsource/tektur/files/tektur-cyrillic-600-normal.woff2"; +import comfortaaLatin from "@fontsource/comfortaa/files/comfortaa-latin-600-normal.woff2"; +import comfortaaCyrillic from "@fontsource/comfortaa/files/comfortaa-cyrillic-600-normal.woff2"; +import delaGothicOneLatin from "@fontsource/dela-gothic-one/files/dela-gothic-one-latin-400-normal.woff2"; +import delaGothicOneCyrillic from "@fontsource/dela-gothic-one/files/dela-gothic-one-cyrillic-400-normal.woff2"; +import rubikMonoOneLatin from "@fontsource/rubik-mono-one/files/rubik-mono-one-latin-400-normal.woff2"; +import rubikMonoOneCyrillic from "@fontsource/rubik-mono-one/files/rubik-mono-one-cyrillic-400-normal.woff2"; +import russoOneLatin from "@fontsource/russo-one/files/russo-one-latin-400-normal.woff2"; +import russoOneCyrillic from "@fontsource/russo-one/files/russo-one-cyrillic-400-normal.woff2"; +import yesevaOneLatin from "@fontsource/yeseva-one/files/yeseva-one-latin-400-normal.woff2"; +import yesevaOneCyrillic from "@fontsource/yeseva-one/files/yeseva-one-cyrillic-400-normal.woff2"; +import caveatLatin from "@fontsource/caveat/files/caveat-latin-600-normal.woff2"; +import caveatCyrillic from "@fontsource/caveat/files/caveat-cyrillic-600-normal.woff2"; +import marckScriptLatin from "@fontsource/marck-script/files/marck-script-latin-400-normal.woff2"; +import marckScriptCyrillic from "@fontsource/marck-script/files/marck-script-cyrillic-400-normal.woff2"; +import greatVibesLatin from "@fontsource/great-vibes/files/great-vibes-latin-400-normal.woff2"; +import greatVibesCyrillic from "@fontsource/great-vibes/files/great-vibes-cyrillic-400-normal.woff2"; +import pacificoLatin from "@fontsource/pacifico/files/pacifico-latin-400-normal.woff2"; +import pacificoCyrillic from "@fontsource/pacifico/files/pacifico-cyrillic-400-normal.woff2"; +import lobsterLatin from "@fontsource/lobster/files/lobster-latin-400-normal.woff2"; +import lobsterCyrillic from "@fontsource/lobster/files/lobster-cyrillic-400-normal.woff2"; +import pressStart2pLatin from "@fontsource/press-start-2p/files/press-start-2p-latin-400-normal.woff2"; +import pressStart2pCyrillic from "@fontsource/press-start-2p/files/press-start-2p-cyrillic-400-normal.woff2"; +import pixelifySansLatin from "@fontsource/pixelify-sans/files/pixelify-sans-latin-600-normal.woff2"; +import pixelifySansCyrillic from "@fontsource/pixelify-sans/files/pixelify-sans-cyrillic-600-normal.woff2"; +import rubikGlitchLatin from "@fontsource/rubik-glitch/files/rubik-glitch-latin-400-normal.woff2"; +import rubikGlitchCyrillic from "@fontsource/rubik-glitch/files/rubik-glitch-cyrillic-400-normal.woff2"; +import rubikWetPaintLatin from "@fontsource/rubik-wet-paint/files/rubik-wet-paint-latin-400-normal.woff2"; +import rubikWetPaintCyrillic from "@fontsource/rubik-wet-paint/files/rubik-wet-paint-cyrillic-400-normal.woff2"; +import rubikBubblesLatin from "@fontsource/rubik-bubbles/files/rubik-bubbles-latin-400-normal.woff2"; +import rubikBubblesCyrillic from "@fontsource/rubik-bubbles/files/rubik-bubbles-cyrillic-400-normal.woff2"; +export interface ShippedFont { + id: string; + family: string; + weight: number; + /** Size factor, so every font reads about as big as the UI font. */ + k: number; + group: "clean" | "bold" | "hand" | "play"; + /** Latin, then Cyrillic subset. */ + files: [string, string]; +} + +export const SHIPPED_FONTS: ShippedFont[] = [ + { id: "unbounded", family: "Unbounded", weight: 600, k: 0.88, group: "clean", files: [unboundedLatin, unboundedCyrillic] }, + { id: "montserrat-alternates", family: "Montserrat Alternates", weight: 600, k: 1, group: "clean", files: [montserratAlternatesLatin, montserratAlternatesCyrillic] }, + { id: "tektur", family: "Tektur", weight: 600, k: 1, group: "clean", files: [tekturLatin, tekturCyrillic] }, + { id: "comfortaa", family: "Comfortaa", weight: 600, k: 1, group: "clean", files: [comfortaaLatin, comfortaaCyrillic] }, + { id: "dela-gothic-one", family: "Dela Gothic One", weight: 400, k: 0.9, group: "bold", files: [delaGothicOneLatin, delaGothicOneCyrillic] }, + { id: "rubik-mono-one", family: "Rubik Mono One", weight: 400, k: 0.8, group: "bold", files: [rubikMonoOneLatin, rubikMonoOneCyrillic] }, + { id: "russo-one", family: "Russo One", weight: 400, k: 1, group: "bold", files: [russoOneLatin, russoOneCyrillic] }, + { id: "yeseva-one", family: "Yeseva One", weight: 400, k: 1.02, group: "bold", files: [yesevaOneLatin, yesevaOneCyrillic] }, + { id: "caveat", family: "Caveat", weight: 600, k: 1.3, group: "hand", files: [caveatLatin, caveatCyrillic] }, + { id: "marck-script", family: "Marck Script", weight: 400, k: 1.18, group: "hand", files: [marckScriptLatin, marckScriptCyrillic] }, + { id: "great-vibes", family: "Great Vibes", weight: 400, k: 1.35, group: "hand", files: [greatVibesLatin, greatVibesCyrillic] }, + { id: "pacifico", family: "Pacifico", weight: 400, k: 0.95, group: "hand", files: [pacificoLatin, pacificoCyrillic] }, + { id: "lobster", family: "Lobster", weight: 400, k: 1.08, group: "hand", files: [lobsterLatin, lobsterCyrillic] }, + { id: "press-start-2p", family: "Press Start 2P", weight: 400, k: 0.62, group: "play", files: [pressStart2pLatin, pressStart2pCyrillic] }, + { id: "pixelify-sans", family: "Pixelify Sans", weight: 600, k: 1.08, group: "play", files: [pixelifySansLatin, pixelifySansCyrillic] }, + { id: "rubik-glitch", family: "Rubik Glitch", weight: 400, k: 1, group: "play", files: [rubikGlitchLatin, rubikGlitchCyrillic] }, + { id: "rubik-wet-paint", family: "Rubik Wet Paint", weight: 400, k: 1, group: "play", files: [rubikWetPaintLatin, rubikWetPaintCyrillic] }, + { id: "rubik-bubbles", family: "Rubik Bubbles", weight: 400, k: 1, group: "play", files: [rubikBubblesLatin, rubikBubblesCyrillic] }, +]; + +export const FONT_GROUPS: { id: ShippedFont["group"]; name: string }[] = [ + { id: "clean", name: "Чистые" }, + { id: "bold", name: "С характером" }, + { id: "hand", name: "Рукописные" }, + { id: "play", name: "Игровые" }, +]; + +/** Google Fonts families come as "g:Family Name"; see `profile::font` on the server. */ +export const GOOGLE_PREFIX = "g:"; + +const LATIN = "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"; +const CYRILLIC = "U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116"; + +/** Declared once; the browser downloads a file only when a name needs its letters. */ +(function declareShipped() { + const css = SHIPPED_FONTS.flatMap((f) => + f.files.map( + (url, i) => + `@font-face { font-family: "${f.family}"; font-weight: ${f.weight}; font-display: swap; ` + + `src: url("${url}") format("woff2"); unicode-range: ${i ? CYRILLIC : LATIN}; }`, + ), + ); + const style = document.createElement("style"); + style.dataset.voicy = "fonts"; + style.textContent = css.join("\n"); + document.head.append(style); +})(); + +const byId = new Map(SHIPPED_FONTS.map((f) => [f.id, f])); +export const shippedFont = (id?: string | null) => (id ? byId.get(id) : undefined); + +export const googleFamily = (font?: string | null) => + font?.startsWith(GOOGLE_PREFIX) ? font.slice(GOOGLE_PREFIX.length).trim() || null : null; + +const cssUrl = (family: string) => `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family).replace(/%20/g, "+")}&display=swap`; + +const linked = new Set(); + +/** Adds the family's stylesheet once; names in it fall back to the UI font until it arrives. */ +function linkGoogle(family: string) { + if (linked.has(family)) return; + linked.add(family); + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = cssUrl(family); + document.head.append(link); +} + +/** Whether Google Fonts has this family; they answer 400 for unknown ones. */ +export async function googleFontExists(family: string) { + try { + return (await fetch(cssUrl(family))).ok; + } catch { + return false; + } +} + +const loaded = new Map(); + +/** A member's own font, registered under a family named after its URL. */ +function loadOwn(url: string) { + let family = loaded.get(url); + if (family) return family; + family = `voicy-own-${loaded.size + 1}`; + loaded.set(url, family); + const face = new FontFace(family, `url("${url}")`, { display: "swap" }); + document.fonts.add(face); + void face.load().catch(() => { + // A broken file: the name stays in the UI font. + }); + return family; +} + +/** + * CSS for a name in this font: the family (UI font as the fallback) and the + * size factor. `own` is the URL of the member's own font file. + */ +export function fontCss(font?: string | null, own?: string | null): { family: string; weight?: number; k: number } | null { + const shipped = shippedFont(font); + if (shipped) return { family: `"${shipped.family}", var(--ui)`, weight: shipped.weight, k: shipped.k }; + const google = googleFamily(font); + if (google) { + linkGoogle(google); + return { family: `"${google}", var(--ui)`, k: 1 }; + } + if (font === "custom" && own) return { family: `"${loadOwn(own)}", var(--ui)`, k: 1 }; + return null; +} + +/** Magic bytes of the formats the server keeps; see `profile::sniff_font`. */ +function fontType(b: Uint8Array) { + const tag = String.fromCharCode(...b.slice(0, 4)); + if (tag === "wOF2") return "font/woff2"; + if (tag === "wOFF") return "font/woff"; + if (tag === "OTTO") return "font/otf"; + if ((b[0] === 0 && b[1] === 1 && b[2] === 0 && b[3] === 0) || tag === "true") return "font/ttf"; + return null; +} + +/** `profile::FONT_MAX_BYTES` on the server. */ +const FONT_MAX = 512 * 1024; + +/** Checks a font file the member picked: size, kind, and that it actually parses. */ +export async function readFontFile(file: File): Promise { + if (file.size > FONT_MAX) throw new Error("шрифт больше 512 КБ; возьми WOFF2 или шрифт с меньшим набором букв"); + const bytes = new Uint8Array(await file.arrayBuffer()); + const type = fontType(bytes); + if (!type) throw new Error("это не шрифт: подойдут TTF, OTF, WOFF и WOFF2"); + try { + await new FontFace("voicy-probe", bytes).load(); + } catch { + throw new Error("этот шрифт не открывается"); + } + return new Blob([bytes], { type }); +} diff --git a/client/src/lib/profile.ts b/client/src/lib/profile.ts new file mode 100644 index 0000000..7540e42 --- /dev/null +++ b/client/src/lib/profile.ts @@ -0,0 +1,123 @@ +import { decorationById } from "./decorations"; +import { googleFamily, shippedFont } from "./fonts"; +import { getSettings } from "./settings"; +import { api, listServers, Member, Profile } from "./tauri"; + +/** Name effects, as in Discord's name styles; `two` ones use a second color. */ +export const EFFECTS: { id: string | null; name: string; two?: boolean }[] = [ + { id: null, name: "Обычный" }, + { id: "gradient", name: "Градиент", two: true }, + { id: "neon", name: "Неон" }, + { id: "toon", name: "Мульт", two: true }, + { id: "pop", name: "Поп", two: true }, +]; + +/** Bright enough to read on the dark background. */ +export const COLORS = ["#c6f36b", "#7fe3b5", "#5ee0f0", "#8cb8ff", "#b69cff", "#f08cff", "#ff8fb8", "#ff7a7a", "#ffab5c", "#ffd84d"]; + +export const STATUS_MAX = 60; + +export const STATUS_IDEAS = ["🎮 Играю", "😴 АФК", "🔕 Не беспокоить", "🍕 Ем", "📚 Учусь", "🎧 Слушаю музыку"]; + + +/** + * What to draw, with anything this app does not know dropped: a newer app + * may have more fonts, effects or decorations. + */ +export function styleOf(p?: Profile) { + const effect = EFFECTS.find((e) => e.id === p?.effect) ?? EFFECTS[0]; + const font = p?.font && (shippedFont(p.font) || googleFamily(p.font) || p.font === "custom") ? p.font : null; + const decoration = p?.decoration === "custom" ? "custom" : (decorationById(p?.decoration)?.id ?? null); + return { + font, + effect: effect.id, + color: p?.color ?? null, + color2: effect.two ? (p?.color2 ?? null) : null, + decoration, + }; +} + +/** Unix seconds the status should stop showing at, from a choice in minutes (0 = never). */ +export function statusUntil(minutes: number | "day"): number | null { + if (minutes === 0) return null; + if (minutes === "day") { + const end = new Date(); + end.setHours(24, 0, 0, 0); + return Math.floor(end.getTime() / 1000); + } + return Math.floor(Date.now() / 1000) + minutes * 60; +} + +/** The status if it has not run out yet. */ +export function statusOf(p?: Profile): string | null { + if (!p?.status) return null; + return p.status_until && p.status_until * 1000 <= Date.now() ? null : p.status; +} + +const FIELDS = ["color", "color2", "font", "effect", "status", "status_until", "decoration"] as const; + +/** The same trimming and checks the server does, so the two compare equal after a save. */ +export function cleanProfile(p: Profile): Profile { + const color = (c?: string | null) => { + const v = c?.trim().toLowerCase(); + return v && /^#[0-9a-f]{6}$/.test(v) ? v : null; + }; + const status = p.status?.trim() || null; + return { + color: color(p.color), + color2: color(p.color2), + font: p.font || null, + effect: p.effect || null, + status: status ? [...status].slice(0, STATUS_MAX).join("") : null, + status_until: status ? (p.status_until ?? null) : null, + decoration: p.decoration || null, + }; +} + +/** As shown now: without a status that has run out. */ +function current(p: Profile): Profile { + const c = cleanProfile(p); + return statusOf(c) ? c : { ...c, status: null, status_until: null }; +} + +const same = (a: Profile, b: Profile) => FIELDS.every((f) => a[f] === b[f]); + +/** When an upload last failed, by host and profile; see `syncPicture`. */ +const failedAt = new Map(); +const RETRY_MS = 60_000; + +/** + * Brings our profile on `host` in line with the one chosen here. Returns + * true when it changed something, so the caller can reload the members. + */ +export async function syncProfile(host: string, me: Member): Promise { + const mine = getSettings().profile; + if (mine === undefined) return false; + const want = current(mine); + const key = `${host} ${JSON.stringify(want)}`; + if (same(current(me), want) || Date.now() - (failedAt.get(key) ?? 0) < RETRY_MS) return false; + try { + await api(host, "PUT", "/api/me/profile", want); + failedAt.delete(key); + return true; + } catch (e) { + // Servers before profiles answer 404; they get it once they update. + failedAt.set(key, Date.now()); + console.warn(`profile sync with ${host} failed`, e); + return false; + } +} + +/** After a change: every server, so friends see it right away. */ +export async function syncProfileEverywhere() { + const servers = await listServers().catch(() => []); + await Promise.all( + servers.map(async (s) => { + try { + await syncProfile(s.host, await api(s.host, "GET", "/api/me")); + } catch { + // Offline or gone: the next visit to that server syncs it. + } + }), + ); +} diff --git a/client/src/lib/settings.ts b/client/src/lib/settings.ts index 3ce89f1..30620a0 100644 --- a/client/src/lib/settings.ts +++ b/client/src/lib/settings.ts @@ -2,6 +2,7 @@ import { useSyncExternalStore } from "react"; import type { LocalAvatar } from "./avatar"; import type { NoiseMode } from "./noise"; +import type { Profile } from "./tauri"; export interface AudioSettings { inputDevice: string; @@ -20,6 +21,11 @@ export interface AudioSettings { * removes it everywhere. */ avatar?: LocalAvatar | null; + /** Name style, status and decoration, pushed to every server like the avatar; `undefined` until set here. */ + profile?: Profile; + /** Our own decoration image and name font, kept like `avatar`. */ + decorationFile?: LocalAvatar | null; + fontFile?: LocalAvatar | null; /** Global shortcuts in accelerator form ("Ctrl+Shift+KeyM"); null = unset. */ hotkeys: Hotkeys; /** Push-to-talk mode: the mic is live only while the ptt key is held. */ diff --git a/client/src/lib/tauri.ts b/client/src/lib/tauri.ts index 6f8166e..c85f695 100644 --- a/client/src/lib/tauri.ts +++ b/client/src/lib/tauri.ts @@ -10,7 +10,29 @@ export interface SavedServer { role: Role; } -export interface Member { +/** + * What a member shows besides the name and picture; older servers send + * none of it. Ids are from the lists in lib/profile.ts and lib/decorations.ts. + */ +export interface Profile { + /** Name color, `#rrggbb`; also their glow while talking. */ + color?: string | null; + /** Second name color, for gradient, toon and pop. */ + color2?: string | null; + font?: string | null; + effect?: string | null; + status?: string | null; + /** When the status stops showing, unix seconds. */ + status_until?: number | null; + /** A decoration id, or "custom" for `decoration_file`. */ + decoration?: string | null; + /** Version of their own decoration image, see `decorationUrl`; the server sets it. */ + decoration_file?: string | null; + /** Version of their own name font, see `fontUrl`; used when `font` is "custom". */ + font_file?: string | null; +} + +export interface Member extends Profile { id: string; nickname: string; role: Role; @@ -112,6 +134,14 @@ export function uninstallServer(ssh: SshCreds, onLog: (line: string) => void) { export const avatarUrl = (host: string, memberId: string, version: string) => `https://${host}/api/avatars/${encodeURIComponent(memberId)}?v=${encodeURIComponent(version)}`; +/** A member's own decoration image, public like the avatar. */ +export const decorationUrl = (host: string, memberId: string, version: string) => + `https://${host}/api/decorations/${encodeURIComponent(memberId)}?v=${encodeURIComponent(version)}`; + +/** A member's own name font, public and CORS-open so `FontFace` can load it. */ +export const fontUrl = (host: string, memberId: string, version: string) => + `https://${host}/api/fonts/${encodeURIComponent(memberId)}?v=${encodeURIComponent(version)}`; + export interface Pin { id: string; thumb: string; diff --git a/client/src/lib/voice.ts b/client/src/lib/voice.ts index 0234f45..3ab0508 100644 --- a/client/src/lib/voice.ts +++ b/client/src/lib/voice.ts @@ -22,7 +22,7 @@ import { flushLogs, log, logTo } from "./log"; import { DFN_MAX_REALTIME_FACTOR, dfnRealtimeFactor, VoicyNoiseProcessor, watchTrack } from "./noise"; import { SCREEN_CAPTURE, SCREEN_PUBLISH } from "./screen"; import { getSettings } from "./settings"; -import { api, Role } from "./tauri"; +import { api, Profile, Role } from "./tauri"; export type ConnState = "idle" | "connecting" | "connected" | "reconnecting"; @@ -33,6 +33,8 @@ export interface Peer { identity: string; name: string; role?: Role; + /** From the participant's metadata; `undefined` on servers that do not send it. */ + profile?: Profile; isLocal: boolean; speaking: boolean; muted: boolean; @@ -228,11 +230,14 @@ function cue(kind: keyof typeof CUES, callCtx?: AudioContext | null) { } } -function roleOf(p: Participant): Role | undefined { +/** Role and profile the server put in the participant's metadata. */ +function metaOf(p: Participant): { role?: Role; profile?: Profile } { try { - return p.metadata ? (JSON.parse(p.metadata).role as Role) : undefined; + if (!p.metadata) return {}; + const { role, ...rest } = JSON.parse(p.metadata); + return { role, profile: "decoration" in rest ? (rest as Profile) : undefined }; } catch { - return undefined; + return {}; } } @@ -266,7 +271,7 @@ class VoiceSession { const peers = all.map((p) => ({ identity: p.identity, name: p.name || p.identity, - role: roleOf(p), + ...metaOf(p), isLocal: p === room.localParticipant, speaking: this.speaking.has(p.identity), muted: !p.isMicrophoneEnabled, diff --git a/client/src/styles.css b/client/src/styles.css index 98ca4f0..0dcc72f 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -117,14 +117,15 @@ button { font: inherit; color: inherit; } .prow:hover .vol, .prow .vol.show { opacity: 1; } .av { - flex: none; width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; + position: relative; flex: none; width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; color: var(--accent-ink); font-weight: 700; font-size: 12px; transition: box-shadow .08s linear; } -/* --level (0..1) is how loud the voice is, set from voice.ts 20 times a second. */ +/* --level (0..1) is how loud the voice is, set from voice.ts 20 times a + second. --c1 is the member's own name color, when they picked one. */ .av.speaking { - box-shadow: 0 0 0 2px var(--surface), 0 0 0 calc(3px + 4px * var(--level, 0)) var(--accent), - 0 0 calc(12px + 18px * var(--level, 0)) calc(4px * var(--level, 0)) color-mix(in oklch, var(--accent) 30%, transparent); + box-shadow: 0 0 0 2px var(--surface), 0 0 0 calc(3px + 4px * var(--level, 0)) var(--c1, var(--accent)), + 0 0 calc(12px + 18px * var(--level, 0)) calc(4px * var(--level, 0)) color-mix(in oklch, var(--c1, var(--accent)) 30%, transparent); } .av.sm { width: 26px; height: 26px; font-size: 11px; } .av.off { width: 26px; height: 26px; font-size: 11px; font-weight: 600; background: var(--line); color: var(--muted); } @@ -276,6 +277,146 @@ input[type=range] { accent-color: var(--accent); } /* ─── Avatars ─────────────────────────────────────────────── */ .av > img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; } +/* The first frame over a moving picture; it steps aside while they talk or on hover. */ +.av > canvas { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: inherit; } +.av.speaking > canvas, .prow:hover .av > canvas, .mrow:hover .av > canvas { visibility: hidden; } + +/* ─── Decorations ─────────────────────────────────────────── */ +/* Drawn in src/decorations (see DESIGN.md): a square 1.2 times the avatar, centered on it. */ +.deco { position: absolute; left: -10%; top: -10%; width: 120%; height: 120%; pointer-events: none; z-index: 1; overflow: visible; } +/* Presence stays readable over any decoration. */ +.av > .online-dot { z-index: 2; } +.deco.own > img, .deco.own > canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; } +.av.speaking .deco > canvas, .prow:hover .deco > canvas, .mrow:hover .deco > canvas, .deco-tile:hover .deco > canvas { visibility: hidden; } +/* Loops hold still in lists, as Discord's do, until the owner talks or the row is hovered. */ +.deco [class^="a-"], .deco [class*=" a-"] { + transform-box: fill-box; transform-origin: 50% 100%; + animation: 2.4s ease-in-out infinite paused; +} +.av:not(.still) .deco [class^="a-"], .av:not(.still) .deco [class*=" a-"], +.av.speaking .deco [class^="a-"], .av.speaking .deco [class*=" a-"], +.prow:hover .deco [class^="a-"], .prow:hover .deco [class*=" a-"], +.mrow:hover .deco [class^="a-"], .mrow:hover .deco [class*=" a-"], +.deco-tile:hover .deco [class^="a-"], .deco-tile:hover .deco [class*=" a-"] { animation-play-state: running; } +.deco .a-bob { animation-name: deco-bob; } +.deco .a-sway { animation-name: deco-sway; } +.deco .a-pulse { animation-name: deco-pulse; animation-duration: 1.6s; transform-origin: 50% 50%; } +.deco .a-twinkle { animation-name: deco-twinkle; animation-duration: 1.8s; transform-origin: 50% 50%; } +.deco .a-flicker { animation-name: deco-flicker; animation-duration: .9s; } +.deco .a-twitch { animation-name: deco-twitch; animation-duration: 3.2s; } +.deco .a-flap-l { animation-name: deco-flap; animation-duration: .7s; transform-origin: 100% 50%; } +.deco .a-flap-r { animation-name: deco-flap; animation-duration: .7s; transform-origin: 0 50%; } +.deco .a-fall { animation-name: deco-fall; animation-duration: 3s; animation-timing-function: linear; transform-origin: 50% 50%; } +.deco .a-rise { animation-name: deco-rise; animation-duration: 2.2s; animation-timing-function: linear; } +.deco .a-blink { animation-name: flick; animation-duration: 1.2s; } +@keyframes deco-bob { 50% { transform: translateY(-6%); } } +@keyframes deco-sway { 0%, 100% { transform: rotate(-5deg); } 50% { transform: rotate(5deg); } } +@keyframes deco-pulse { 50% { transform: scale(1.1); } } +@keyframes deco-twinkle { 0%, 100% { transform: scale(.7); opacity: .6; } 50% { transform: scale(1.1); opacity: 1; } } +@keyframes deco-flicker { 0%, 100% { transform: scale(1, 1); } 35% { transform: scale(.93, 1.12); } 70% { transform: scale(1.05, .95); } } +@keyframes deco-twitch { 0%, 84%, 100% { transform: rotate(0); } 88% { transform: rotate(-10deg); } 92% { transform: rotate(5deg); } } +@keyframes deco-flap { 50% { transform: scaleX(.45); } } +@keyframes deco-fall { from { transform: translate(0, -6px) rotate(0); opacity: 0; } 20% { opacity: 1; } to { transform: translate(4px, 12px) rotate(160deg); opacity: 0; } } +@keyframes deco-rise { from { transform: translateY(0); opacity: 0; } 25% { opacity: 1; } to { transform: translateY(-16px); opacity: 0; } } +/* Parts that are the voice itself: they need no loop, --level moves them. */ +.deco .voice { opacity: calc(var(--level, 0) * 1.6); transition: opacity .08s linear; } +.deco .eqbar { + --k: calc(1 + .45 * sin(var(--i, 0) * 2.4rad)); + transform-box: fill-box; transform-origin: 50% 100%; + scale: 1 calc(.55 + var(--level, 0) * var(--k) * 1.3); transition: scale .08s linear; +} +.deco .halo { filter: drop-shadow(0 0 3px rgba(255, 216, 77, .55)); } +.deco .glow { filter: drop-shadow(0 0 3px var(--glow)); } +/* Mid: the mana bar drains while its owner talks. */ +.deco .mana { transform-box: fill-box; transform-origin: 0 50%; scale: calc(1 - var(--level, 0) * .75) 1; transition: scale .08s linear; } +@media (prefers-reduced-motion: reduce) { .deco * { animation: none !important; } } + +/* ─── Names ───────────────────────────────────────────────── */ +/* Discord's name styles: a font (set inline, with its size factor --k), an + effect, one or two colors (--c1, --c2). A place that wants a bigger name + sets --nick-size. */ +.nick { + display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; + font-size: calc(var(--nick-size, 1em) * var(--k, 1)); +} +.nick.tinted { color: var(--c1); } +.nick.fx-gradient { + background: linear-gradient(90deg, var(--c1, #fff), var(--c2, oklch(from var(--c1, #8cb8ff) l c calc(h + 80)))); + -webkit-background-clip: text; background-clip: text; color: transparent; +} +.nick.fx-neon { + color: color-mix(in oklch, var(--c1, #5ee0f0) 40%, white); padding-inline: 2px; + text-shadow: 0 0 3px var(--c1, #5ee0f0), 0 0 9px var(--c1, #5ee0f0), 0 0 16px color-mix(in oklch, var(--c1, #5ee0f0) 55%, transparent); +} +.nick.fx-toon { + --o: var(--c2, #16161a); color: var(--c1, #fff); padding: 0 2px 2px; + text-shadow: -1px -1px 0 var(--o), 1px -1px 0 var(--o), -1px 1px 0 var(--o), 1px 1px 0 var(--o), 0 2px 0 var(--o), 1px 2px 0 var(--o); +} +.nick.fx-pop { color: var(--c1, #fff); padding: 0 2px 2px 0; text-shadow: 2px 2px 0 var(--c2, oklch(from var(--c1, #ff8fb8) calc(l - .35) c h)); } + +/* Status under the name in the call, after it in the one-line rows. */ +.prow .status { font-size: 12.5px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mrow .name .me { color: var(--faint); } +.mrow .name .status { color: var(--faint); font-size: 12.5px; margin-left: 8px; } + +/* ─── Profile dialog ──────────────────────────────────────── */ +.av.lg { width: 44px; height: 44px; font-size: 14px; } +.av.xl { width: 64px; height: 64px; font-size: 20px; } +/* The preview sits on the main panel's dot grid: this is how the room shows you. */ +.pv { position: relative; margin: 2px 0 16px; border-radius: 16px; border: 1px solid var(--line-soft); background: var(--surface); overflow: hidden; } +.pv::before { content: ""; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(rgba(255, 255, 255, .05) 1px, transparent 1.3px) 0 0 / 18px 18px; } +.pv > * { position: relative; } +.pv-big { display: flex; align-items: center; gap: 20px; padding: 24px 12px 14px 24px; } +.pv-who { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; } +.pv-name { --nick-size: 19px; font-weight: 700; letter-spacing: -.01em; } +.pv-status { color: var(--faint); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.icon-btn.pv-mic { align-self: flex-start; background: rgba(255, 255, 255, .06); --cut: #202024; } +.icon-btn.pv-mic.on { background: color-mix(in oklch, var(--accent) 18%, transparent); color: var(--accent); } +.pv-row { margin: 0 8px 8px; background: rgba(255, 255, 255, .03); } +.pv-row:hover { background: rgba(255, 255, 255, .03); } +.font-list { max-height: 272px; overflow-y: auto; } +.font-row { + width: 100%; border: none; background: transparent; cursor: pointer; text-align: left; font: inherit; color: var(--text); + display: flex; align-items: center; gap: 12px; transition: background .12s; +} +.card > .font-row, .font-group > .font-row { padding: 10px 14px; border-top: 1px solid var(--line-soft); } +.card > .font-row:first-child { border-top: none; } +.font-row:hover { background: rgba(255, 255, 255, .03); } +.font-row.on { background: color-mix(in oklch, var(--accent) 7%, transparent); } +.font-sample { flex: 1; min-width: 0; overflow: hidden; } +.font-sample .nick { --nick-size: 16px; } +.font-name { flex: none; font-family: var(--mono); font-size: 11px; color: var(--faint); } +.font-check { flex: none; color: var(--accent); } +.font-group-label { + padding: 12px 14px 6px; border-top: 1px solid var(--line-soft); + font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: var(--faint); +} +.font-more { margin-top: 8px; } +.font-more input { flex: 1; min-width: 0; } +.font-more .btn { flex: none; } +.font-file { flex: 1; min-width: 0; color: var(--faint); font-size: 12.5px; } +.swatches { display: flex; flex-wrap: wrap; gap: 6px; } +.swatches + .swatches { margin-top: 10px; } +.swatch { + position: relative; width: 26px; height: 26px; border-radius: 50%; border: none; padding: 0; cursor: pointer; + background: var(--panel-2); transition: transform .12s, box-shadow .12s; +} +.swatch:hover { transform: scale(1.1); } +.swatch.on { box-shadow: 0 0 0 2px var(--panel), 0 0 0 4px var(--text); } +.swatch.none { background: linear-gradient(135deg, transparent 44%, var(--faint) 44% 56%, transparent 56%), var(--panel-2); } +.swatch.custom { background: conic-gradient(#ff6b6b, #ffe45c, #7ef08a, #5cc8ff, #a17cff, #ff6b6b); } +.swatch.custom input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; } +.deco-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; } +.deco-tile { + display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 18px 6px 10px; cursor: pointer; font: inherit; + border: 1px solid var(--line); border-radius: 12px; background: rgba(255, 255, 255, .025); color: var(--muted); + transition: border-color .12s, background .12s; +} +.deco-tile:hover { border-color: #34343b; } +.deco-tile.on { border-color: var(--accent); background: color-mix(in oklch, var(--accent) 8%, transparent); color: var(--text); } +.deco-tile small { font-size: 12px; } +.tile-add { width: 44px; height: 44px; border-radius: 50%; border: 1px dashed #3a3a42; display: grid; place-items: center; color: var(--faint); } +.status-field { margin-top: 18px; margin-bottom: 10px; } .prow.self, .mrow.self { position: relative; cursor: pointer; } .menu-pop.me-pop { left: 10px; right: auto; top: calc(100% - 4px); z-index: 6; } diff --git a/server/src/api.rs b/server/src/api.rs index b1b1c19..b89ed85 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -14,9 +14,10 @@ use serde_json::{json, Value}; use crate::{ auth::{hash, random_secret, AuthMember}, avatar, diag, - db::{now, Invite, Member, Redeem, Role}, + db::{now, Invite, Member, Profile, Redeem, Role, UploadKind}, error::{ApiError, ApiResult}, livekit::ECHO_SUFFIX, + profile, rooms::{layout, parse_room, room_id, RoomPeer, RoomView}, SharedState, }; @@ -30,6 +31,11 @@ pub fn router(state: SharedState) -> Router { .route("/api/join", post(join)) .route("/api/me", get(me).patch(update_me).delete(leave)) .route("/api/me/avatar", put(set_avatar).delete(clear_avatar)) + .route("/api/me/profile", put(set_profile)) + .route("/api/me/decoration", put(set_decoration).delete(clear_decoration)) + .route("/api/decorations/{id}", get(decoration)) + .route("/api/me/font", put(set_font).delete(clear_font)) + .route("/api/fonts/{id}", get(font)) .route("/api/avatars/{id}", get(avatar)) .route("/api/token", post(token)) .route("/api/rooms", get(rooms)) @@ -119,46 +125,141 @@ async fn update_me( } #[derive(Deserialize)] -struct AvatarReq { +struct UploadReq { /// The image file, base64. data: String, } -async fn set_avatar( - State(s): State, - AuthMember(mut m): AuthMember, - Json(req): Json, -) -> ApiResult> { +/// Checks and stores an uploaded file; returns its version. +fn store_upload(s: &SharedState, kind: UploadKind, m: &Member, req: UploadReq) -> ApiResult { + let (max, big) = match kind { + UploadKind::Avatar => (avatar::MAX_BYTES, "avatar is too large"), + UploadKind::Decoration => (profile::DECORATION_MAX_BYTES, "decoration is too large"), + UploadKind::Font => (profile::FONT_MAX_BYTES, "font is too large"), + }; let data = STANDARD .decode(req.data.trim()) - .map_err(|_| ApiError::BadRequest("avatar must be base64"))?; - if data.len() > avatar::MAX_BYTES { - return Err(ApiError::BadRequest("avatar is too large")); + .map_err(|_| ApiError::BadRequest("file must be base64"))?; + if data.len() > max { + return Err(ApiError::BadRequest(big)); } - let mime = avatar::sniff(&data).ok_or(ApiError::BadRequest("avatar must be a PNG, JPEG, WebP or GIF image"))?; + let mime = match kind { + UploadKind::Font => profile::sniff_font(&data).ok_or(ApiError::BadRequest("font must be TTF, OTF, WOFF or WOFF2"))?, + _ => avatar::sniff(&data).ok_or(ApiError::BadRequest("image must be a PNG, JPEG, WebP or GIF"))?, + }; let version = avatar::version(&data); - s.db.set_avatar(&m.id, &version, mime, &data)?; - m.avatar = Some(version); + s.db.set_upload(kind, &m.id, &version, mime, &data)?; + Ok(version) +} + +async fn set_avatar( + State(s): State, + AuthMember(mut m): AuthMember, + Json(req): Json, +) -> ApiResult> { + m.avatar = Some(store_upload(&s, UploadKind::Avatar, &m, req)?); Ok(Json(m)) } async fn clear_avatar(State(s): State, AuthMember(mut m): AuthMember) -> ApiResult> { - s.db.clear_avatar(&m.id)?; + s.db.clear_upload(UploadKind::Avatar, &m.id)?; m.avatar = None; Ok(Json(m)) } -/// Public, so the app can show it with a plain ``: member ids are -/// random and only other members see them. Versioned URLs never change. +async fn set_decoration( + State(s): State, + AuthMember(mut m): AuthMember, + Json(req): Json, +) -> ApiResult> { + m.decoration_file = Some(store_upload(&s, UploadKind::Decoration, &m, req)?); + push_metadata(&s, &m).await; + Ok(Json(m)) +} + +async fn clear_decoration(State(s): State, AuthMember(mut m): AuthMember) -> ApiResult> { + s.db.clear_upload(UploadKind::Decoration, &m.id)?; + m.decoration_file = None; + push_metadata(&s, &m).await; + Ok(Json(m)) +} + +async fn set_font( + State(s): State, + AuthMember(mut m): AuthMember, + Json(req): Json, +) -> ApiResult> { + m.font_file = Some(store_upload(&s, UploadKind::Font, &m, req)?); + push_metadata(&s, &m).await; + Ok(Json(m)) +} + +async fn clear_font(State(s): State, AuthMember(mut m): AuthMember) -> ApiResult> { + s.db.clear_upload(UploadKind::Font, &m.id)?; + m.font_file = None; + push_metadata(&s, &m).await; + Ok(Json(m)) +} + +async fn set_profile( + State(s): State, + AuthMember(mut m): AuthMember, + Json(req): Json, +) -> ApiResult> { + m.profile = profile::clean(req).map_err(ApiError::BadRequest)?.at(now()); + s.db.set_profile(&m.id, &m.profile)?; + push_metadata(&s, &m).await; + Ok(Json(m)) +} + +/// What everyone in a room knows about a participant without asking the +/// server: role and profile. Set in the join token, then kept current. +fn metadata(m: &Member) -> String { + let mut v = serde_json::to_value(&m.profile).unwrap_or_else(|_| json!({})); + v["role"] = json!(m.role); + v["decoration_file"] = json!(m.decoration_file); + v["font_file"] = json!(m.font_file); + v.to_string() +} + +/// Updates a member's metadata in the rooms they are in right now. +async fn push_metadata(s: &SharedState, m: &Member) { + let metadata = metadata(m); + let (lk, id, metadata) = (&s.lk, &m.id, &metadata); + each_room(s, "update metadata", |room| async move { + if lk.list_participants(&room).await?.iter().any(|(p, _)| p == id) { + lk.update_metadata(&room, id, metadata).await?; + } + Ok(()) + }) + .await; +} + async fn avatar(State(s): State, Path(id): Path) -> ApiResult { + serve_upload(&s, UploadKind::Avatar, &id) +} + +async fn decoration(State(s): State, Path(id): Path) -> ApiResult { + serve_upload(&s, UploadKind::Decoration, &id) +} + +async fn font(State(s): State, Path(id): Path) -> ApiResult { + serve_upload(&s, UploadKind::Font, &id) +} + +/// Public, so the app can show it with a plain `` or `FontFace`: +/// member ids are random and only other members see them. Versioned URLs +/// never change. Fonts load only with CORS, hence the open origin. +fn serve_upload(s: &SharedState, kind: UploadKind, id: &str) -> ApiResult { if s.db.is_deleted()? { return Err(ApiError::Gone); } - let a = s.db.avatar(&id)?.ok_or(ApiError::NotFound)?; + let a = s.db.upload(kind, id)?.ok_or(ApiError::NotFound)?; let headers = [ (header::CONTENT_TYPE, a.mime), (header::CACHE_CONTROL, "public, max-age=31536000, immutable".to_owned()), (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_owned()), + (header::ACCESS_CONTROL_ALLOW_ORIGIN, "*".to_owned()), ]; Ok((headers, a.data).into_response()) } @@ -195,8 +296,7 @@ async fn token( let token = if req.echo { s.lk.echo_token(&room, &m.id)? } else { - let metadata = json!({ "role": m.role }).to_string(); - s.lk.join_token(&room, &m.id, &m.nickname, metadata)? + s.lk.join_token(&room, &m.id, &m.nickname, metadata(&m))? }; Ok(Json(json!({ "url": s.cfg.livekit_url(), "room": room, "token": token }))) } @@ -389,9 +489,7 @@ async fn set_role( s.db.set_role(&target.id, req.role)?; target.role = req.role; // Tokens carry the role as metadata; update it for a live session too. - let metadata = json!({ "role": target.role }).to_string(); - let (lk, id, metadata) = (&s.lk, &target.id, &metadata); - each_room(&s, "update the role", |room| async move { lk.update_metadata(&room, id, metadata).await }).await; + push_metadata(&s, &target).await; Ok(Json(target)) } diff --git a/server/src/db.rs b/server/src/db.rs index c06b4ea..90d85d6 100644 --- a/server/src/db.rs +++ b/server/src/db.rs @@ -49,6 +49,49 @@ pub struct Member { pub created_at: i64, /// Version of the member's picture, for `/api/avatars/{id}?v=`. pub avatar: Option, + /// Version of their own decoration image, for `/api/decorations/{id}?v=`; + /// shown when `profile.decoration` is "custom". + pub decoration_file: Option, + /// Version of their own name font, for `/api/fonts/{id}?v=`; + /// used when `profile.font` is "custom". + pub font_file: Option, + #[serde(flatten)] + pub profile: Profile, +} + +/// What a member shows besides the name and picture. All of it optional, +/// so an empty profile and a server without profiles look the same. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Profile { + /// Name color, `#rrggbb`; also the glow while they talk. + pub color: Option, + /// Second name color, for the effects that use two. + pub color2: Option, + /// Name font: one the app ships, "g:" from Google Fonts, or + /// "custom" for `font_file`. Then the effect (gradient, neon…). + pub font: Option, + pub effect: Option, + /// A line under the name: "afk", "playing dota". + pub status: Option, + /// When the status stops showing, unix seconds; `None` keeps it. + pub status_until: Option, + /// Decoration over the picture: one the app ships, or "custom" for + /// `decoration_file`. The app owns the lists of fonts, effects and + /// decorations, so new ones need no server update; unknown ones show + /// as the default. + pub decoration: Option, +} + +impl Profile { + /// Without a status that has run out. + pub fn at(mut self, now: i64) -> Self { + if self.status_until.is_some_and(|until| until <= now) { + self.status = None; + self.status_until = None; + } + self + } } impl Member { @@ -62,14 +105,47 @@ impl Member { secret_hash: r.get("secret_hash")?, created_at: r.get("created_at")?, avatar: r.get("avatar")?, + decoration_file: r.get("decoration_file")?, + font_file: r.get("font_file")?, + profile: Profile { + color: r.get("color")?, + color2: r.get("color2")?, + font: r.get("font")?, + effect: r.get("effect")?, + status: r.get("status")?, + status_until: r.get("status_until")?, + decoration: r.get("decoration")?, + } + .at(now()), }) } } -/// Members joined with their avatar version, the shape `Member::from_row` reads. -const MEMBER_SELECT: &str = "SELECT m.*, a.version AS avatar FROM members m LEFT JOIN avatars a ON a.member_id = m.id"; +/// Members joined with their picture versions and profile, the shape `Member::from_row` reads. +const MEMBER_SELECT: &str = "SELECT m.*, a.version AS avatar, d.version AS decoration_file, f.version AS font_file, + p.color, p.color2, p.font, p.effect, p.status, p.status_until, p.decoration + FROM members m LEFT JOIN avatars a ON a.member_id = m.id LEFT JOIN decorations d ON d.member_id = m.id + LEFT JOIN fonts f ON f.member_id = m.id LEFT JOIN profiles p ON p.member_id = m.id"; + +/// Files a member uploads, each kept like the avatar: one of a kind per member. +#[derive(Clone, Copy, Debug)] +pub enum UploadKind { + Avatar, + Decoration, + Font, +} -pub struct Avatar { +impl UploadKind { + fn table(self) -> &'static str { + match self { + UploadKind::Avatar => "avatars", + UploadKind::Decoration => "decorations", + UploadKind::Font => "fonts", + } + } +} + +pub struct Upload { pub mime: String, pub data: Vec, } @@ -120,6 +196,28 @@ CREATE TABLE IF NOT EXISTS avatars ( mime TEXT NOT NULL, data BLOB NOT NULL ); +CREATE TABLE IF NOT EXISTS decorations ( + member_id TEXT PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE, + version TEXT NOT NULL, + mime TEXT NOT NULL, + data BLOB NOT NULL +); +CREATE TABLE IF NOT EXISTS fonts ( + member_id TEXT PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE, + version TEXT NOT NULL, + mime TEXT NOT NULL, + data BLOB NOT NULL +); +CREATE TABLE IF NOT EXISTS profiles ( + member_id TEXT PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE, + color TEXT, + color2 TEXT, + font TEXT, + effect TEXT, + status TEXT, + status_until INTEGER, + decoration TEXT +); "; /// `created_by` of an owner invite made by a redeploy while an owner exists. @@ -211,6 +309,9 @@ impl Db { secret_hash: secret_hash.to_owned(), created_at: now, avatar: None, + decoration_file: None, + font_file: None, + profile: Profile::default(), }; tx.execute( "INSERT INTO members (id, nickname, role, secret_hash, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", @@ -250,28 +351,37 @@ impl Db { Ok(()) } - pub fn set_avatar(&self, id: &str, version: &str, mime: &str, data: &[u8]) -> Result<()> { + pub fn set_upload(&self, kind: UploadKind, id: &str, version: &str, mime: &str, data: &[u8]) -> Result<()> { self.conn().execute( - "INSERT OR REPLACE INTO avatars (member_id, version, mime, data) VALUES (?1, ?2, ?3, ?4)", + &format!("INSERT OR REPLACE INTO {} (member_id, version, mime, data) VALUES (?1, ?2, ?3, ?4)", kind.table()), params![id, version, mime, data], )?; Ok(()) } - pub fn clear_avatar(&self, id: &str) -> Result<()> { - self.conn().execute("DELETE FROM avatars WHERE member_id = ?1", [id])?; + pub fn clear_upload(&self, kind: UploadKind, id: &str) -> Result<()> { + self.conn().execute(&format!("DELETE FROM {} WHERE member_id = ?1", kind.table()), [id])?; Ok(()) } - pub fn avatar(&self, id: &str) -> Result> { + pub fn upload(&self, kind: UploadKind, id: &str) -> Result> { Ok(self .conn() - .query_row("SELECT mime, data FROM avatars WHERE member_id = ?1", [id], |r| { - Ok(Avatar { mime: r.get(0)?, data: r.get(1)? }) + .query_row(&format!("SELECT mime, data FROM {} WHERE member_id = ?1", kind.table()), [id], |r| { + Ok(Upload { mime: r.get(0)?, data: r.get(1)? }) }) .optional()?) } + pub fn set_profile(&self, id: &str, p: &Profile) -> Result<()> { + self.conn().execute( + "INSERT OR REPLACE INTO profiles (member_id, color, color2, font, effect, status, status_until, decoration) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![id, p.color, p.color2, p.font, p.effect, p.status, p.status_until, p.decoration], + )?; + Ok(()) + } + pub fn delete_member(&self, id: &str) -> Result<()> { self.conn().execute("DELETE FROM members WHERE id = ?1", [id])?; Ok(()) @@ -327,6 +437,9 @@ impl Db { let tx = conn.transaction()?; tx.execute_batch( "DELETE FROM avatars; + DELETE FROM decorations; + DELETE FROM fonts; + DELETE FROM profiles; DELETE FROM members; DELETE FROM invites; INSERT OR REPLACE INTO meta (key, value) VALUES ('deleted', '1');", @@ -397,16 +510,50 @@ mod tests { } #[test] - fn avatars_follow_their_member() { + fn uploads_follow_their_member() { let db = db(); db.ensure_owner_invite("code", 0).unwrap(); let Redeem::Ok(m) = db.redeem_invite("code", "izzy", "s", 1).unwrap() else { panic!() }; assert_eq!(db.member(&m.id).unwrap().unwrap().avatar, None); - db.set_avatar(&m.id, "v1", "image/webp", b"a").unwrap(); - db.set_avatar(&m.id, "v2", "image/png", b"b").unwrap(); - assert_eq!(db.members().unwrap()[0].avatar.as_deref(), Some("v2")); - assert_eq!(db.avatar(&m.id).unwrap().unwrap().mime, "image/png"); + db.set_upload(UploadKind::Avatar, &m.id, "v1", "image/webp", b"a").unwrap(); + db.set_upload(UploadKind::Avatar, &m.id, "v2", "image/png", b"b").unwrap(); + db.set_upload(UploadKind::Decoration, &m.id, "d1", "image/gif", b"c").unwrap(); + let listed = &db.members().unwrap()[0]; + assert_eq!((listed.avatar.as_deref(), listed.decoration_file.as_deref()), (Some("v2"), Some("d1"))); + assert_eq!(db.upload(UploadKind::Avatar, &m.id).unwrap().unwrap().mime, "image/png"); + db.clear_upload(UploadKind::Decoration, &m.id).unwrap(); + assert_eq!(db.member(&m.id).unwrap().unwrap().decoration_file, None); + db.set_upload(UploadKind::Decoration, &m.id, "d2", "image/png", b"d").unwrap(); + db.set_upload(UploadKind::Font, &m.id, "f1", "font/woff2", b"e").unwrap(); + assert_eq!(db.member(&m.id).unwrap().unwrap().font_file.as_deref(), Some("f1")); + db.delete_member(&m.id).unwrap(); + assert!(db.upload(UploadKind::Font, &m.id).unwrap().is_none()); + assert!(db.upload(UploadKind::Avatar, &m.id).unwrap().is_none()); + assert!(db.upload(UploadKind::Decoration, &m.id).unwrap().is_none()); + } + + #[test] + fn profiles_follow_their_member_and_statuses_run_out() { + let db = db(); + db.ensure_owner_invite("code", 0).unwrap(); + let Redeem::Ok(m) = db.redeem_invite("code", "izzy", "s", 1).unwrap() else { panic!() }; + assert_eq!(db.member(&m.id).unwrap().unwrap().profile, Profile::default()); + let p = Profile { + color: Some("#ff8800".into()), + color2: Some("#00ff88".into()), + font: Some("caveat".into()), + effect: Some("gradient".into()), + status: Some("afk".into()), + status_until: Some(now() + 3600), + decoration: Some("crown".into()), + }; + db.set_profile(&m.id, &p).unwrap(); + assert_eq!(db.members().unwrap()[0].profile, p); + db.set_profile(&m.id, &Profile { status_until: Some(now() - 1), ..p.clone() }).unwrap(); + let shown = db.member(&m.id).unwrap().unwrap().profile; + assert_eq!(shown, Profile { status: None, status_until: None, ..p }); db.delete_member(&m.id).unwrap(); - assert!(db.avatar(&m.id).unwrap().is_none()); + let left: i64 = db.conn().query_row("SELECT COUNT(*) FROM profiles", [], |r| r.get(0)).unwrap(); + assert_eq!(left, 0); } } diff --git a/server/src/main.rs b/server/src/main.rs index 8dcc19c..2159169 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -7,6 +7,7 @@ mod error; mod invite_page; mod livekit; mod presence; +mod profile; mod rooms; use std::{env, net::SocketAddr, sync::Arc}; diff --git a/server/src/profile.rs b/server/src/profile.rs new file mode 100644 index 0000000..3e9dbc0 --- /dev/null +++ b/server/src/profile.rs @@ -0,0 +1,170 @@ +//! Name style, status line and decoration. The app draws them; the server +//! only keeps them short and well-formed. + +use crate::db::Profile; + +const STATUS_MAX_CHARS: usize = 60; +const ID_MAX_CHARS: usize = 24; + +/// A decoration image with some animation; Discord's are 288 px APNGs of +/// a few hundred KB, a picture of a crown needs far less. +pub const DECORATION_MAX_BYTES: usize = 1024 * 1024; + +/// A display font with Latin and Cyrillic fits in 100–300 KB as WOFF2. +pub const FONT_MAX_BYTES: usize = 512 * 1024; + +const GOOGLE_PREFIX: &str = "g:"; +const FAMILY_MAX_CHARS: usize = 40; + +/// Content type of a font file by its magic bytes; `None` for anything else. +pub fn sniff_font(data: &[u8]) -> Option<&'static str> { + match data.get(..4)? { + b"wOF2" => Some("font/woff2"), + b"wOFF" => Some("font/woff"), + b"OTTO" => Some("font/otf"), + [0, 1, 0, 0] | b"true" => Some("font/ttf"), + _ => None, + } +} + +fn blank_is_none(v: &Option) -> Option<&str> { + v.as_deref().map(str::trim).filter(|s| !s.is_empty()) +} + +fn color(v: &Option) -> Result, &'static str> { + match blank_is_none(v) { + None => Ok(None), + Some(c) if c.len() == 7 && c.starts_with('#') && c[1..].chars().all(|ch| ch.is_ascii_hexdigit()) => { + Ok(Some(c.to_ascii_lowercase())) + } + Some(_) => Err("colors must look like #rrggbb"), + } +} + +/// Font, effect and decoration ids: the app knows what they mean. +fn id(v: &Option) -> Result, &'static str> { + match blank_is_none(v) { + None => Ok(None), + Some(s) if s.len() <= ID_MAX_CHARS && s.chars().all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') => { + Ok(Some(s.to_owned())) + } + Some(_) => Err("font, effect and decoration must be short style ids"), + } +} + +/// A shipped font id, or a Google Fonts family as "g:Family Name". +fn font(v: &Option) -> Result, &'static str> { + match blank_is_none(v) { + Some(s) if s.starts_with(GOOGLE_PREFIX) => { + let family = s[GOOGLE_PREFIX.len()..].trim(); + let ok = !family.is_empty() + && family.chars().count() <= FAMILY_MAX_CHARS + && family.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == ' '); + if ok { + Ok(Some(format!("{GOOGLE_PREFIX}{family}"))) + } else { + Err("a Google font must be a family name of letters, digits and spaces") + } + } + _ => id(v), + } +} + +/// Trimmed and checked; blank fields become `None`. +pub fn clean(p: Profile) -> Result { + let status = match blank_is_none(&p.status) { + None => None, + Some(s) if s.chars().count() <= STATUS_MAX_CHARS && !s.chars().any(char::is_control) => Some(s.to_owned()), + Some(_) => return Err("status must be up to 60 printable characters"), + }; + Ok(Profile { + color: color(&p.color)?, + color2: color(&p.color2)?, + font: font(&p.font)?, + effect: id(&p.effect)?, + status_until: status.as_ref().and(p.status_until), + status, + decoration: id(&p.decoration)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn full() -> Profile { + Profile { + color: Some(" #FFaa00 ".into()), + color2: Some("#00ff88".into()), + font: Some("press-start-2p".into()), + effect: Some("neon".into()), + status: Some(" катаю в дотку 🎮 ".into()), + status_until: Some(100), + decoration: Some("crown".into()), + } + } + + #[test] + fn keeps_good_values_tidy() { + let p = clean(full()).unwrap(); + assert_eq!(p.color.as_deref(), Some("#ffaa00")); + assert_eq!(p.status.as_deref(), Some("катаю в дотку 🎮")); + assert_eq!(p.status_until, Some(100)); + assert_eq!(p.font.as_deref(), Some("press-start-2p")); + assert_eq!(p.decoration.as_deref(), Some("crown")); + } + + #[test] + fn blank_means_none_and_drops_the_expiry() { + let blank = |_| Some(" ".to_owned()); + let p = Profile { + color: blank(0), + color2: blank(0), + font: blank(0), + effect: blank(0), + status: blank(0), + status_until: Some(100), + decoration: blank(0), + }; + assert_eq!(clean(p).unwrap(), Profile::default()); + assert_eq!(clean(Profile::default()).unwrap(), Profile::default()); + } + + #[test] + fn google_fonts_by_family() { + let with = |f: &str| clean(Profile { font: Some(f.into()), ..full() }); + assert_eq!(with("g: Rubik Wet Paint ").unwrap().font.as_deref(), Some("g:Rubik Wet Paint")); + assert!(with("g:").is_err()); + assert!(with("g:Comic").is_err()); + assert!(with(&format!("g:{}", "A".repeat(41))).is_err()); + assert_eq!(with("custom").unwrap().font.as_deref(), Some("custom")); + } + + #[test] + fn sniffs_only_fonts() { + assert_eq!(sniff_font(b"wOF2...."), Some("font/woff2")); + assert_eq!(sniff_font(b"wOFF...."), Some("font/woff")); + assert_eq!(sniff_font(b"OTTO...."), Some("font/otf")); + assert_eq!(sniff_font(&[0, 1, 0, 0, 9]), Some("font/ttf")); + assert_eq!(sniff_font(b"GIF89a"), None); + assert_eq!(sniff_font(b"wO"), None); + } + + #[test] + fn rejects_the_rest() { + let bad = [ + Profile { color: Some("red".into()), ..full() }, + Profile { color2: Some("#12345".into()), ..full() }, + Profile { color: Some("#12345g".into()), ..full() }, + Profile { status: Some("x".repeat(61)), ..full() }, + Profile { status: Some("a\nb".into()), ..full() }, + Profile { font: Some("Caveat".into()), ..full() }, + Profile { effect: Some("x".repeat(25)), ..full() }, + Profile { decoration: Some("