Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9253fc33fd | |||
| bd9dbb5e83 | |||
| 70eb0edc47 | |||
| 75a05cf83d | |||
| 44b48b05b4 | |||
| 9f6220b1bb | |||
| 20abfc0342 | |||
| 94722c8a97 | |||
| 69091bc055 | |||
| c37220eb21 | |||
| f3c2babd4b | |||
| 7b5fbb7e3b | |||
| 109eac91f9 | |||
| a23851d4a6 | |||
| 2dfdda5d8c | |||
| cfe52d623a | |||
| 01781554a2 | |||
| c6b1a9d75f | |||
| 9ebce5b00c | |||
| 185eb160e7 | |||
| 2e12c742fb | |||
| f2bcd65a9b | |||
| 77f0c0d4ca | |||
| 13df48c658 | |||
| 9b68b4ae53 | |||
| f914b59c07 | |||
| 1d086dda77 |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": [
|
||||
"matrix.lotusguild.org"
|
||||
],
|
||||
"allowCustomHomeservers": false,
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [],
|
||||
"rooms": [],
|
||||
"servers": []
|
||||
},
|
||||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": [
|
||||
"matrix.lotusguild.org"
|
||||
],
|
||||
"allowCustomHomeservers": false,
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [],
|
||||
"rooms": [],
|
||||
"servers": []
|
||||
},
|
||||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
}
|
||||
}
|
||||
@@ -1,111 +1,62 @@
|
||||
# Cinny
|
||||
<p>
|
||||
<a href="https://github.com/ajbura/cinny/releases">
|
||||
<img alt="GitHub release downloads" src="https://img.shields.io/github/downloads/ajbura/cinny/total?logo=github&style=social"></a>
|
||||
<a href="https://hub.docker.com/r/ajbura/cinny">
|
||||
<img alt="DockerHub downloads" src="https://img.shields.io/docker/pulls/ajbura/cinny?logo=docker&style=social"></a>
|
||||
<a href="https://fosstodon.org/@cinnyapp">
|
||||
<img alt="Follow on Mastodon" src="https://img.shields.io/mastodon/follow/106845779685925461?domain=https%3A%2F%2Ffosstodon.org&logo=mastodon&style=social"></a>
|
||||
<a href="https://twitter.com/intent/follow?screen_name=cinnyapp">
|
||||
<img alt="Follow on Twitter" src="https://img.shields.io/twitter/follow/cinnyapp?logo=twitter&style=social"></a>
|
||||
<a href="https://cinny.in/#sponsor">
|
||||
<img alt="Sponsor Cinny" src="https://img.shields.io/opencollective/all/cinny?logo=opencollective&style=social"></a>
|
||||
</p>
|
||||
# Lotus Chat
|
||||
|
||||
A Matrix client focusing primarily on simple, elegant and secure interface. The main goal is to have an instant messaging application that is easy on people and has a modern touch.
|
||||
- [Roadmap](https://github.com/orgs/cinnyapp/projects/1)
|
||||
- [Contributing](./CONTRIBUTING.md)
|
||||
A Matrix client for [Lotus Guild](https://lotusguild.org) — forked from [Cinny](https://github.com/cinnyapp/cinny) v4.11.1.
|
||||
|
||||
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380">
|
||||
Deployed at [chat.lotusguild.org](https://chat.lotusguild.org).
|
||||
|
||||
## Getting started
|
||||
The web app is available at [app.cinny.in](https://app.cinny.in/) and gets updated on each new release. The `dev` branch is continuously deployed at [dev.cinny.in](https://dev.cinny.in) but keep in mind that it could have things broken.
|
||||
---
|
||||
|
||||
You can also download our desktop app from the [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop).
|
||||
## Changes from upstream Cinny
|
||||
|
||||
## Self-hosting
|
||||
To host Cinny on your own, simply download the tarball from [GitHub releases](https://github.com/cinnyapp/cinny/releases/latest), and serve the files from `dist/` using your preferred webserver. Alternatively, you can just pull the docker image from [DockerHub](https://hub.docker.com/r/ajbura/cinny) or [GitHub Container Registry](https://github.com/cinnyapp/cinny/pkgs/container/cinny).
|
||||
### Branding & Identity
|
||||
|
||||
* The default homeservers and explore pages are defined in [`config.json`](config.json).
|
||||
- Package renamed to `lotus-chat`, description updated to "Lotus Chat — Matrix client for Lotus Guild"
|
||||
- App title changed from "Cinny" to "Lotus Chat" throughout
|
||||
- Favicon, PWA icons, and all icon sizes (57×57 → 180×180 Apple touch icons) replaced with Lotus.png variants
|
||||
- Logo in About dialog and Auth page replaced with official Lotus.png
|
||||
- Auth footer rewritten: shows dynamic version from `package.json`, links to lotusguild.org, chat.lotusguild.org, and matrix.lotusguild.org
|
||||
- Welcome page tagline changed from "Yet another matrix client" to "A Matrix client for Lotus Guild"
|
||||
- Encryption key export filename changed from `cinny-keys.txt` to `lotus-keys.txt`
|
||||
- `manifest.json` updated with Lotus name, description, and branding colors
|
||||
|
||||
* You need to set up redirects to serve the assests. Example configurations; [netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf), [caddy](contrib/caddy/caddyfile).
|
||||
* If you have trouble configuring redirects you can [enable hash routing](config.json#L35) — the url in the browser will have a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/` instead of `app.cinny.in/home/`) but you won't have to configure your webserver.
|
||||
### LotusGuild Terminal Design System (TDS) v1.2
|
||||
|
||||
* To deploy on subdirectory, you need to rebuild the app youself after updating the `base` path in [`build.config.ts`](build.config.ts).
|
||||
* For example, if you want to deploy on `https://cinny.in/app`, then set `base: '/app'`.
|
||||
A full custom theme engine layered on top of Cinny's vanilla-extract theming:
|
||||
|
||||
<details><summary><b>PGP Public Key to verify tarball</b></summary>
|
||||
**Dark mode** (`LotusTerminalTheme`):
|
||||
- CRT terminal aesthetic: scanline overlay, vignette, phosphor glow
|
||||
- Palette: bg `#030508`, orange `#FF6B00`, cyan `#00D4FF`, green `#00FF88`, text `#c4d9ee`
|
||||
- Monospace font stack, terminal-style scrollbars
|
||||
- Custom hex-grid and circuit-board CSS background patterns
|
||||
- Matrix-style boot messages on the welcome page
|
||||
- CSS variables: `--lt-*` family covering colors, glow effects, borders, animations
|
||||
|
||||
```
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
**Light mode** (`LotusTerminalLightTheme`):
|
||||
- Full light palette: bg `#edf0f5`, orange `#c44e00`, cyan `#0062b8`, green `#006d35`, text `#111827`
|
||||
- No CRT effects (scanlines, vignette disabled)
|
||||
- Light-mode scrollbars, adjusted code block colors, semantic color overrides
|
||||
- Scoped to `html[data-theme="light"] body.lotusTerminalBodyClass`
|
||||
- `ThemeManager.tsx` sets `data-theme` attribute based on active theme kind
|
||||
|
||||
mQGNBGJw/g0BDAC8qQeLqDMzYzfPyOmRlHVEoguVTo+eo1aVdQH2X7OELdjjBlyj
|
||||
6d6c1adv/uF2g83NNMoQY7GEeHjRnXE4m8kYSaarb840pxrYUagDc0dAbJOGaCBY
|
||||
FKTo7U1Kvg0vdiaRuus0pvc1NVdXSxRNQbFXBSwduD+zn66TI3HfcEHNN62FG1cE
|
||||
K1jWDwLAU0P3kKmj8+CAc3h9ZklPu0k/+t5bf/LJkvdBJAUzGZpehbPL5f3u3BZ0
|
||||
leZLIrR8uV7PiV5jKFahxlKR5KQHld8qQm+qVhYbUzpuMBGmh419I6UvTzxuRcvU
|
||||
Frn9ttCEzV55Y+so4X2e4ZnB+5gOnNw+ecifGVdj/+UyWnqvqqDvLrEjjK890nLb
|
||||
Pil4siecNMEpiwAN6WSmKpWaCwQAHEGDVeZCc/kT0iYfj5FBcsTVqWiO6eaxkUlm
|
||||
jnulqWqRrlB8CJQQvih/g//uSEBdzIibo+ro+3Jpe120U/XVUH62i9HoRQEm6ADG
|
||||
4zS5hIq4xyA8fL8AEQEAAbQdQ2lubnlBcHAgPGNpbm55YXBwQGdtYWlsLmNvbT6J
|
||||
AdQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQSRri2MHidaaZv+
|
||||
vvuUMwx6UK/M8wUCZqEDwAUJFvwIswAKCRCUMwx6UK/M877qC/4lxXOQIoWnLLkK
|
||||
YiRCTkGsH6NdxgeYr6wpXT4xuQ45ZxCytwHpOGQmO/5up5961TxWW8D1frRIJHjj
|
||||
AZGoRCL3EKEuY8nt3D99fpf3DvZrs1uoVAhiyn737hRlZAg+QsJheeGCmdSJ0hX5
|
||||
Yud8SE+9zxLS1+CEjMrsUd/RGre/phme+wNXfaHfREAC9ewolgVChPIbMxG2f+vs
|
||||
K8Xv52BFng7ta9fgsl1XuOjpuaSbQv6g+4ONk/lxKF0SmnhEGM3dmIYPONxW47Yf
|
||||
atnIjRra/YhPTNwrNBGMmG4IFKaOsMbjW/eakjWTWOVKKJNBMoDdRcYYWIMCpLy8
|
||||
AQUrMtQEsHSnqCwrw818S5A6rrhcfVGk36RGm0nOy6LS5g5jmqaYsvbCcBGY9B2c
|
||||
SUAVNm17oo7TtEajk8hcSXoZod1t++pyjcVKEmSn3nFK7v5m3V+cPhNTxZMK459P
|
||||
3x1Ucqj/kTqrxKw6s2Uknuk0ajmw0ljV+BQwgL6maguo9BKgCNW5AY0EYnD+DQEM
|
||||
ANOu/d6ZMF8bW+Df9RDCUQKytbaZfa+ZbIHBus7whCD/SQMOhPKntv3HX7SmMCs+
|
||||
5i27kJMu4YN623JCS7hdCoXVO1R5kXCEcneW/rPBMDutaM472YvIWMIqK9Wwl5+0
|
||||
Piu2N+uTkKhe9uS2u7eN+Khef3d7xfjGRxoppM+xI9dZO+jhYiy8LuC0oBohTjJq
|
||||
QPqfGDpowBwRkkOsGz/XVcesJ1Pzg4bKivTS9kZjZSyT9RRSY8As0sVUN57AwYul
|
||||
s1+eh00n/tVpi2Jj9pCm7S0csSXvXj8v2OTdK1jt4YjpzR0/rwh4+/xlOjDjZEqH
|
||||
vMPhpzpbgnwkxZ3X8BFne9dJ3maC5zQ3LAeCP5m1W0hXzagYhfyjo74slJgD1O8c
|
||||
LDf2Oxc5MyM8Y/UK497zfqSPfgT3NhQmhHzk83DjXw3I6Z3A3U+Jp61w0eBRI1nx
|
||||
H1UIG+gldcAKUTcfwL0lghoT3nmi9JAbvek0Smhz00Bbo8/dx8vwQRxDUxlt7Exx
|
||||
NwARAQABiQG8BBgBCAAmAhsMFiEEka4tjB4nWmmb/r77lDMMelCvzPMFAmahA9IF
|
||||
CRb8CMUACgkQlDMMelCvzPPQgQv/d5/z+fxgKqgfhQX+V49X4WgTVxZ/CzztDoJ1
|
||||
XAq1dzTNEy8AFguXIo6eVXPSpMxec7ZreN3+UPQBnCf3eR5YxWNYOYKmk0G4E8D2
|
||||
KGUJept7TSA42/8N2ov6tToXFg4CgzKZj0fYLwgutly7K8eiWmSU6ptaO8aEQBHB
|
||||
gTGIOO3h6vJMGVycmoeRnHjv4wV84YWSVFSoJ7cY0he4Z9UznJBbE/KHZjrkXsPo
|
||||
N+Gg5lDuOP5xjKzM5SogV9lhxBAhMWAg3URUF15yruZBiA8uV1FOK8sal/9C1G7V
|
||||
M6ygA6uOZqXlZtcdA94RoSsW2pZ9eLVPsxz2B3Zko7tu11MpNP/wYmfGTI3KxZBj
|
||||
n/eodvwjJSgHpGOFSmbNzvPJo3to5nNlp7wH1KxIMc6Uuu9hgfDfwkFZgV2bnFIa
|
||||
Q6gyF548Ub48z7Dz83+WwLgbX19ve4oZx+dqSdczP6ILHRQomtrzrkkP2LU52oI5
|
||||
mxFo+ioe/ABCufSmyqFye0psX3Sp
|
||||
=WtqZ
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
```
|
||||
</details>
|
||||
**Chat Backgrounds** (18+ custom patterns, all TDS-aware):
|
||||
- Blueprint grid, carbon fiber, starfield, topographic contours, herringbone, crosshatch
|
||||
- Chevron, polka dots, triangles, plaid
|
||||
- All patterns use CSS custom properties — adapt to both TDS dark and light themes
|
||||
- Settings toggle for showing per-message sender profiles
|
||||
|
||||
## Local development
|
||||
> [!TIP]
|
||||
> We recommend using a version manager as versions change very quickly. You will likely need to switch between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Krypton LTS (v24.13.1).
|
||||
### Voice / Video Call Improvements
|
||||
|
||||
Execute the following commands to start a development server:
|
||||
```sh
|
||||
npm ci # Installs all dependencies
|
||||
npm start # Serve a development version
|
||||
```
|
||||
|
||||
To build the app:
|
||||
```sh
|
||||
npm run build # Compiles the app into the dist/ directory
|
||||
```
|
||||
|
||||
### Running with Docker
|
||||
This repository includes a Dockerfile, which builds the application from source and serves it with Nginx on port 80. To
|
||||
use this locally, you can build the container like so:
|
||||
```
|
||||
docker build -t cinny:latest .
|
||||
```
|
||||
|
||||
You can then run the container you've built with a command similar to this:
|
||||
```
|
||||
docker run -p 8080:80 cinny:latest
|
||||
```
|
||||
|
||||
This will forward your `localhost` port 8080 to the container's port 80. You can visit the app in your browser by navigating to `http://localhost:8080`.
|
||||
- **Element Call 0.19.3**: Upgraded from 0.16.3. Dist copied to `public/element-call/` by vite at build time.
|
||||
- **Camera default OFF**: Camera no longer persists across sessions via localStorage. Always starts disabled. Optional `cameraOnJoin` setting for explicit opt-in.
|
||||
- **Deafen button**: Tooltip corrected to "Deafen" / "Undeafen" (was "Turn Off Sound" / "Turn On Sound")
|
||||
- **Screenshare confirmation**: A confirm dialog appears before screenshare is broadcast to call participants
|
||||
- **Auto-revert spotlight on screenshare**: When someone starts screensharing, EC normally forces all participants into spotlight view. Patched in `CallControl.ts` `onControlMutation()` — detects the screenshare button going `primary` and clicks `gridButton` after 600ms to revert to grid layout. Participants choose to watch screenshare manually.
|
||||
- **Push to Talk (PTT)**:
|
||||
- Configurable keybind (default: Space) via Settings > General > Calls
|
||||
- Mic activates on keydown, deactivates on keyup; mic muted on tab blur/focus to prevent stuck-on mic
|
||||
- Visual indicator: plain folds `Chip` by default; when LotusGuild TDS is active: orange `PTT — Hold SPACE` / green `● LIVE` in JetBrains Mono
|
||||
- Listens on both main window and EC iframe `contentWindow` for reliable key capture
|
||||
- Implemented via `CallControl.setMicrophone()` public method on the widget bridge
|
||||
- **Noise suppression toggle**: Settings > General > Calls — passes `noiseSuppression` URL parameter to the embedded Element Call widget
|
||||
- **DM calls**: Phone button in DM room header starts a 1-on-1 call via `useCallStart(true)`. `Room.tsx` switches to CallView layout when the DM has an active call embed. Standard voice rooms were already supported; this extends call support to all direct messages.
|
||||
- **Picture-in-picture (PiP)**: When navigating away from a call room while in an active call, the call embed shrinks to a 280x158px floating window in the bottom-right corner. The PiP window is **draggable** — drag it anywhere on screen to move it out of the way. Clicking (without dragging) navigates back to the call room. Drag vs click distinguished by a 5px movement threshold; touch drag supported. Imperative style overrides on `callEmbedRef.current` via `useEffect` — a wrapper div cannot be used because `useCallEmbedPlacementSync` writes `top/left/width/height` directly onto that element.
|
||||
|
||||
@@ -1,29 +1,15 @@
|
||||
{
|
||||
"defaultHomeserver": 1,
|
||||
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
|
||||
"allowCustomHomeservers": true,
|
||||
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": [
|
||||
"matrix.lotusguild.org"
|
||||
],
|
||||
"allowCustomHomeservers": false,
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [
|
||||
"#cinny-space:matrix.org",
|
||||
"#community:matrix.org",
|
||||
"#space:unredacted.org",
|
||||
"#science-space:matrix.org",
|
||||
"#libregaming-games:tchncs.de",
|
||||
"#mathematics-on:matrix.org"
|
||||
],
|
||||
"rooms": [
|
||||
"#cinny:matrix.org",
|
||||
"#freesoftware:matrix.org",
|
||||
"#pcapdroid:matrix.org",
|
||||
"#gentoo:matrix.org",
|
||||
"#PrivSec.dev:arcticfoxes.net",
|
||||
"#disroot:aria-net.org"
|
||||
],
|
||||
"servers": ["matrix.org", "mozilla.org", "unredacted.org"]
|
||||
"spaces": [],
|
||||
"rooms": [],
|
||||
"servers": []
|
||||
},
|
||||
|
||||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
|
||||
@@ -4,33 +4,36 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||
<title>Cinny</title>
|
||||
<meta name="name" content="Cinny" />
|
||||
<meta name="author" content="Ajay Bura" />
|
||||
<title>Lotus Chat</title>
|
||||
<meta name="name" content="Lotus Chat" />
|
||||
<meta name="author" content="Lotus Guild" />
|
||||
<meta
|
||||
name="description"
|
||||
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
|
||||
content="Lotus Chat — the Lotus Guild Matrix client. Secure, fast, and built for our community."
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="cinny, cinnyapp, cinnychat, matrix, matrix client, matrix.org, element"
|
||||
content="lotus chat, lotus guild, matrix, matrix client"
|
||||
/>
|
||||
|
||||
<meta property="og:title" content="Cinny" />
|
||||
<meta property="og:url" content="https://cinny.in" />
|
||||
<meta property="og:image" content="https://cinny.in/assets/favicon-48x48.png" />
|
||||
<meta property="og:title" content="Lotus Chat" />
|
||||
<meta property="og:url" content="https://chat.lotusguild.org" />
|
||||
<meta property="og:image" content="https://chat.lotusguild.org/public/res/android/android-chrome-192x192.png" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
|
||||
content="Lotus Chat — the Lotus Guild Matrix client. Secure, fast, and built for our community."
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap" rel="stylesheet">
|
||||
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="application-name" content="Cinny" />
|
||||
<meta name="apple-mobile-web-app-title" content="Cinny" />
|
||||
<meta name="application-name" content="Lotus Chat" />
|
||||
<meta name="apple-mobile-web-app-title" content="Lotus Chat" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cinny",
|
||||
"name": "lotus-chat",
|
||||
"version": "4.11.1",
|
||||
"description": "Yet another matrix client",
|
||||
"description": "Lotus Chat \u2014 Matrix client for Lotus Guild",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": [
|
||||
"matrix.lotusguild.org"
|
||||
],
|
||||
"allowCustomHomeservers": false,
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [],
|
||||
"rooms": [],
|
||||
"servers": []
|
||||
},
|
||||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 631 B |
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "Cinny",
|
||||
"short_name": "Cinny",
|
||||
"description": "Yet another matrix client",
|
||||
"name": "Lotus Chat",
|
||||
"short_name": "Lotus Chat",
|
||||
"description": "Lotus Chat \u2014 the Lotus Guild Matrix client",
|
||||
"dir": "auto",
|
||||
"lang": "en-US",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"start_url": "./",
|
||||
"background_color": "#fff",
|
||||
"theme_color": "#fff",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#980000",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./public/android/android-chrome-36x36.png",
|
||||
@@ -55,5 +55,24 @@
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
"social",
|
||||
"communication",
|
||||
"productivity"
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "New Message",
|
||||
"short_name": "DM",
|
||||
"description": "Open a new direct message",
|
||||
"url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "res/android/android-chrome-96x96.png",
|
||||
"sizes": "96x96"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
@@ -1,13 +1,14 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2707_1961)">
|
||||
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
|
||||
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
|
||||
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#45B83B"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2707_1961">
|
||||
<rect width="18" height="18" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="#980000" fill-opacity="0.88">
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(0,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(45,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(90,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(135,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(180,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(225,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(270,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(315,9,9)"/>
|
||||
</g>
|
||||
<circle cx="9" cy="9" r="2.2" fill="#cc2000"/>
|
||||
<circle cx="14.5" cy="14.5" r="3" fill="#45B83B"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 841 B |
@@ -1,13 +1,14 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2707_2015)">
|
||||
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
|
||||
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
|
||||
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#989898"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2707_2015">
|
||||
<rect width="18" height="18" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="#980000" fill-opacity="0.88">
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(0,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(45,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(90,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(135,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(180,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(225,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(270,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(315,9,9)"/>
|
||||
</g>
|
||||
<circle cx="9" cy="9" r="2.2" fill="#cc2000"/>
|
||||
<circle cx="14.5" cy="14.5" r="3" fill="#989898"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 841 B |
@@ -1,19 +1,13 @@
|
||||
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In -->
|
||||
<svg version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
|
||||
x="0px" y="0px" width="18px" height="18px" viewBox="0 0 18 18" enable-background="new 0 0 18 18" xml:space="preserve">
|
||||
<defs>
|
||||
</defs>
|
||||
<g>
|
||||
<g>
|
||||
<circle fill="#FFFFFF" cx="9" cy="9" r="8.5"/>
|
||||
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="#980000" fill-opacity="0.88">
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(0,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(45,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(90,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(135,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(180,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(225,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(270,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(315,9,9)"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M9,0C4,0,0,4,0,9c0,5,4,9,9,9c5,0,9-4,9-9C18,4,14,0,9,0z M1.2,10.8l3.5-2.3c0-0.1,0-0.2,0-0.3c0-1.8,1.3-3.2,3.1-3.4
|
||||
c0.1,0,0.2,0,0.4,0c1.2,0,2.3,0.6,2.9,1.6c0.3-0.1,0.6-0.1,0.9-0.1c0.4,0,0.8,0,1.2,0.1c0.7,0.2,1.4,0.5,2,0.9
|
||||
C14.6,7.1,14,7,13.3,7c-1.2,0-2.2,0.4-2.9,1.4c-0.7,0.9-1.1,2-1.1,3.2c0,1.5-0.4,2.9-1.3,4.2c-0.3,0.4-0.5,0.7-0.8,1
|
||||
C4.2,16.1,1.9,13.8,1.2,10.8z"/>
|
||||
<circle cx="9.5" cy="6.4" r="0.5"/>
|
||||
</g>
|
||||
</g>
|
||||
<circle cx="9" cy="9" r="2.2" fill="#cc2000"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 871 B After Width: | Height: | Size: 788 B |
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode, useCallback, useRef } from 'react';
|
||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { config } from 'folds';
|
||||
import {
|
||||
@@ -13,6 +13,10 @@ import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
||||
import { CallEmbed } from '../plugins/call';
|
||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
||||
|
||||
const PIP_MIN_W = 200;
|
||||
const PIP_MIN_H = 112; // keeps roughly 16:9 at minimum
|
||||
|
||||
function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
@@ -29,6 +33,19 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
type Corner = 'se' | 'sw' | 'ne' | 'nw';
|
||||
|
||||
/** Normalise the element to top/left positioning so resize math is uniform. */
|
||||
function normaliseToTopLeft(el: HTMLDivElement) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
el.style.top = `${rect.top}px`;
|
||||
el.style.left = `${rect.left}px`;
|
||||
el.style.right = 'auto';
|
||||
el.style.bottom = 'auto';
|
||||
el.style.width = `${rect.width}px`;
|
||||
el.style.height = `${rect.height}px`;
|
||||
}
|
||||
|
||||
type CallEmbedProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
@@ -40,10 +57,246 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
const selectedRoom = useSelectedRoom();
|
||||
const chat = useAtomValue(callChatAtom);
|
||||
const screenSize = useScreenSizeContext();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
const chatOnlyView = chat && screenSize !== ScreenSize.Desktop;
|
||||
const inCallRoom = callEmbed && selectedRoom === callEmbed.roomId;
|
||||
const callActive = callEmbed && joined;
|
||||
const callVisible = inCallRoom && callActive && !chatOnlyView;
|
||||
const pipMode = callActive && !inCallRoom;
|
||||
|
||||
const callVisible = callEmbed && selectedRoom === callEmbed.roomId && joined && !chatOnlyView;
|
||||
const pipDragRef = useRef<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
origLeft: number;
|
||||
origTop: number;
|
||||
dragged: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// useCallEmbedPlacementSync writes top/left/width/height directly on this element.
|
||||
// Override those in PiP mode; clear them when returning so it can take back control.
|
||||
useEffect(() => {
|
||||
const el = callEmbedRef.current;
|
||||
if (!el) return;
|
||||
if (pipMode) {
|
||||
el.style.top = 'auto';
|
||||
el.style.left = 'auto';
|
||||
el.style.bottom = '72px';
|
||||
el.style.right = '16px';
|
||||
el.style.width = '280px';
|
||||
el.style.height = '158px';
|
||||
el.style.borderRadius = '12px';
|
||||
el.style.overflow = 'hidden';
|
||||
el.style.zIndex = '99';
|
||||
el.style.boxShadow = '0 8px 32px rgba(0,0,0,0.55)';
|
||||
el.style.border = '1px solid rgba(255,255,255,0.1)';
|
||||
el.style.visibility = 'visible';
|
||||
} else {
|
||||
el.style.top = '';
|
||||
el.style.left = '';
|
||||
el.style.bottom = '';
|
||||
el.style.right = '';
|
||||
el.style.width = '';
|
||||
el.style.height = '';
|
||||
el.style.borderRadius = '';
|
||||
el.style.overflow = '';
|
||||
el.style.zIndex = '';
|
||||
el.style.boxShadow = '';
|
||||
el.style.border = '';
|
||||
el.style.visibility = callVisible ? '' : 'hidden';
|
||||
}
|
||||
}, [pipMode, callVisible]);
|
||||
|
||||
// ── Drag to move ────────────────────────────────────────────────────────────
|
||||
const handlePipMouseDown = (e: React.MouseEvent) => {
|
||||
const el = callEmbedRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
pipDragRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origLeft: rect.left,
|
||||
origTop: rect.top,
|
||||
dragged: false,
|
||||
};
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
if (!pipDragRef.current || !el) return;
|
||||
const dx = ev.clientX - pipDragRef.current.startX;
|
||||
const dy = ev.clientY - pipDragRef.current.startY;
|
||||
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) {
|
||||
pipDragRef.current.dragged = true;
|
||||
document.body.style.cursor = 'grabbing';
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
if (pipDragRef.current.dragged) {
|
||||
const newLeft = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx));
|
||||
const newTop = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy));
|
||||
el.style.left = `${newLeft}px`;
|
||||
el.style.top = `${newTop}px`;
|
||||
el.style.right = 'auto';
|
||||
el.style.bottom = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
setTimeout(() => {
|
||||
if (pipDragRef.current) pipDragRef.current.dragged = false;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
const handlePipTouchStart = (e: React.TouchEvent) => {
|
||||
const el = callEmbedRef.current;
|
||||
if (!el || e.touches.length !== 1) return;
|
||||
const touch = e.touches[0];
|
||||
const rect = el.getBoundingClientRect();
|
||||
pipDragRef.current = {
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
origLeft: rect.left,
|
||||
origTop: rect.top,
|
||||
dragged: false,
|
||||
};
|
||||
|
||||
const onTouchMove = (ev: TouchEvent) => {
|
||||
if (!pipDragRef.current || !el || ev.touches.length !== 1) return;
|
||||
ev.preventDefault();
|
||||
const t = ev.touches[0];
|
||||
const dx = t.clientX - pipDragRef.current.startX;
|
||||
const dy = t.clientY - pipDragRef.current.startY;
|
||||
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) {
|
||||
pipDragRef.current.dragged = true;
|
||||
}
|
||||
if (pipDragRef.current.dragged) {
|
||||
const newLeft = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx));
|
||||
const newTop = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy));
|
||||
el.style.left = `${newLeft}px`;
|
||||
el.style.top = `${newTop}px`;
|
||||
el.style.right = 'auto';
|
||||
el.style.bottom = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
document.removeEventListener('touchend', onTouchEnd);
|
||||
setTimeout(() => {
|
||||
if (pipDragRef.current) pipDragRef.current.dragged = false;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
|
||||
const handlePipClick = (roomId: string) => {
|
||||
if (pipDragRef.current?.dragged) return;
|
||||
navigateRoom(roomId);
|
||||
};
|
||||
|
||||
// ── Resize from corner handles ───────────────────────────────────────────────
|
||||
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const el = callEmbedRef.current;
|
||||
if (!el) return;
|
||||
|
||||
normaliseToTopLeft(el);
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startW = el.offsetWidth;
|
||||
const startH = el.offsetHeight;
|
||||
const startL = parseFloat(el.style.left);
|
||||
const startT = parseFloat(el.style.top);
|
||||
|
||||
document.body.style.cursor = `${corner}-resize`;
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
const dx = ev.clientX - startX;
|
||||
const dy = ev.clientY - startY;
|
||||
|
||||
let w = startW;
|
||||
let h = startH;
|
||||
let l = startL;
|
||||
let t = startT;
|
||||
|
||||
if (corner === 'se') { w = startW + dx; h = startH + dy; }
|
||||
if (corner === 'sw') { w = startW - dx; h = startH + dy; l = startL + startW - Math.max(PIP_MIN_W, w); }
|
||||
if (corner === 'ne') { w = startW + dx; h = startH - dy; t = startT + startH - Math.max(PIP_MIN_H, h); }
|
||||
if (corner === 'nw') { w = startW - dx; h = startH - dy; l = startL + startW - Math.max(PIP_MIN_W, w); t = startT + startH - Math.max(PIP_MIN_H, h); }
|
||||
|
||||
w = Math.max(PIP_MIN_W, Math.min(w, window.innerWidth));
|
||||
h = Math.max(PIP_MIN_H, Math.min(h, window.innerHeight));
|
||||
l = Math.max(0, Math.min(l, window.innerWidth - PIP_MIN_W));
|
||||
t = Math.max(0, Math.min(t, window.innerHeight - PIP_MIN_H));
|
||||
|
||||
el.style.width = `${w}px`;
|
||||
el.style.height = `${h}px`;
|
||||
el.style.left = `${l}px`;
|
||||
el.style.top = `${t}px`;
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
// Corner handle style helper
|
||||
const cornerHandle = (corner: Corner): React.CSSProperties => {
|
||||
const s = corner.includes('s');
|
||||
const e = corner.includes('e');
|
||||
return {
|
||||
position: 'absolute',
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
[s ? 'bottom' : 'top']: 0,
|
||||
[e ? 'right' : 'left']: 0,
|
||||
cursor: `${corner}-resize`,
|
||||
zIndex: 2,
|
||||
};
|
||||
};
|
||||
|
||||
// Grip dot pattern rendered inside each handle
|
||||
const gripDots = (corner: Corner) => {
|
||||
const s = corner.includes('s');
|
||||
const e = corner.includes('e');
|
||||
// 3 dots arranged in an L toward the active corner
|
||||
const dots: React.CSSProperties[] = [];
|
||||
const r = 2; // dot radius px
|
||||
const gap = 5;
|
||||
const base = 3;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
for (let j = 0; j < 3; j++) {
|
||||
if (i + j < 2) continue; // only the 3 corner-most dots
|
||||
dots.push({
|
||||
position: 'absolute',
|
||||
width: r * 2,
|
||||
height: r * 2,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.45)',
|
||||
[s ? 'bottom' : 'top']: base + i * gap,
|
||||
[e ? 'right' : 'left']: base + j * gap,
|
||||
});
|
||||
}
|
||||
}
|
||||
return dots;
|
||||
};
|
||||
|
||||
return (
|
||||
<CallEmbedContextProvider value={callEmbed}>
|
||||
@@ -51,6 +304,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
<CallEmbedRefContextProvider value={callEmbedRef}>{children}</CallEmbedRefContextProvider>
|
||||
<div
|
||||
data-call-embed-container
|
||||
ref={callEmbedRef}
|
||||
style={{
|
||||
visibility: callVisible ? undefined : 'hidden',
|
||||
position: 'fixed',
|
||||
@@ -59,8 +313,62 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
width: '100%',
|
||||
height: '50%',
|
||||
}}
|
||||
ref={callEmbedRef}
|
||||
/>
|
||||
>
|
||||
{pipMode && callEmbed && (
|
||||
<>
|
||||
{/* Drag-to-move overlay (zIndex 1, sits over the iframe) */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Return to call"
|
||||
onMouseDown={handlePipMouseDown}
|
||||
onTouchStart={handlePipTouchStart}
|
||||
onClick={() => handlePipClick(callEmbed.roomId)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handlePipClick(callEmbed.roomId)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
background: 'transparent',
|
||||
cursor: 'grab',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-end',
|
||||
padding: '6px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
borderRadius: '6px',
|
||||
padding: '3px 8px',
|
||||
color: '#fff',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
↗ Return to call
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Corner resize handles (zIndex 2, above the drag overlay) */}
|
||||
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => (
|
||||
<div
|
||||
key={corner}
|
||||
style={cornerHandle(corner)}
|
||||
onMouseDown={(e) => handleResizeMouseDown(e, corner)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{gripDots(corner).map((style, i) => (
|
||||
<div key={i} style={style} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CallEmbedContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function SplashScreen({ children }: SplashScreenProps) {
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Text size="H2" align="Center">
|
||||
Cinny
|
||||
Lotus Chat
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -157,7 +157,6 @@ export function CallControl({
|
||||
}) {
|
||||
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
|
||||
const [hangupState, hangup] = useAsyncCallback(
|
||||
useCallback(() => callEmbed.hangup(), [callEmbed])
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
|
||||
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
config,
|
||||
Icon,
|
||||
IconButton,
|
||||
@@ -26,6 +27,8 @@ import {
|
||||
VideoButton,
|
||||
} from './Controls';
|
||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
@@ -51,6 +54,25 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
);
|
||||
|
||||
const [cords, setCords] = useState<RectCords>();
|
||||
const [shareConfirm, setShareConfirm] = useState(false);
|
||||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||||
const [pttActive, setPttActive] = useState(false);
|
||||
|
||||
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
||||
const microphoneRef = useRef(microphone);
|
||||
useEffect(() => { microphoneRef.current = microphone; }, [microphone]);
|
||||
|
||||
// Handle PTT mode toggle mid-call
|
||||
const pttModeRef = useRef(pttMode);
|
||||
useEffect(() => {
|
||||
if (pttMode && !pttModeRef.current) {
|
||||
callEmbed.control.setMicrophone(false);
|
||||
} else if (!pttMode && pttModeRef.current) {
|
||||
callEmbed.control.setMicrophone(true);
|
||||
}
|
||||
pttModeRef.current = pttMode;
|
||||
}, [pttMode, callEmbed]);
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setCords(evt.currentTarget.getBoundingClientRect());
|
||||
@@ -71,12 +93,64 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
setCords(undefined);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!pttMode) return;
|
||||
const iframeWindow = callEmbed.iframe.contentWindow;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code !== pttKey || e.repeat) return;
|
||||
// Don't intercept keys typed into a text input or editable element
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.contentEditable === 'true'
|
||||
) return;
|
||||
e.preventDefault();
|
||||
if (!microphoneRef.current) callEmbed.control.setMicrophone(true);
|
||||
setPttActive(true);
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code !== pttKey) return;
|
||||
callEmbed.control.setMicrophone(false);
|
||||
setPttActive(false);
|
||||
};
|
||||
// Release PTT when the tab loses focus to prevent stuck-on mic
|
||||
const onBlur = () => {
|
||||
callEmbed.control.setMicrophone(false);
|
||||
setPttActive(false);
|
||||
};
|
||||
// Re-mute on focus restore: EC can re-assert audio_enabled:true on audio-context resume
|
||||
const onFocus = () => {
|
||||
callEmbed.control.setMicrophone(false);
|
||||
setPttActive(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener('blur', onBlur);
|
||||
window.addEventListener('focus', onFocus);
|
||||
iframeWindow?.addEventListener('keydown', onKeyDown);
|
||||
iframeWindow?.addEventListener('keyup', onKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
iframeWindow?.removeEventListener('keydown', onKeyDown);
|
||||
iframeWindow?.removeEventListener('keyup', onKeyUp);
|
||||
};
|
||||
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pttMode, pttKey, callEmbed]);
|
||||
|
||||
const [hangupState, hangup] = useAsyncCallback(
|
||||
useCallback(() => callEmbed.hangup(), [callEmbed])
|
||||
);
|
||||
const exiting =
|
||||
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
|
||||
|
||||
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={controlRef}
|
||||
@@ -84,6 +158,63 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
justifyContent="Center"
|
||||
alignItems="Center"
|
||||
>
|
||||
{pttMode && (
|
||||
<Chip
|
||||
variant={pttActive ? 'Success' : 'Warning'}
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-2.2rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
outlined
|
||||
>
|
||||
<Text size="T200" style={{ fontWeight: 700 }}>
|
||||
{pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`}
|
||||
</Text>
|
||||
</Chip>
|
||||
)}
|
||||
{shareConfirm && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '110%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '0.75rem',
|
||||
padding: '1rem 1.25rem',
|
||||
zIndex: 100,
|
||||
minWidth: '260px',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<Text size="T300" style={{ fontWeight: 600 }}>Share your screen?</Text>
|
||||
<Text size="T200" style={{ opacity: 0.75 }}>Your screen will be visible to all participants in this call.</Text>
|
||||
<Box gap="200">
|
||||
<Button
|
||||
size="300" variant="Success" fill="Solid" radii="300"
|
||||
onClick={() => { callEmbed.control.toggleScreenshare(); setShareConfirm(false); }}
|
||||
>
|
||||
<Text size="B300">Share</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300" variant="Surface" fill="Soft" radii="300" outlined
|
||||
onClick={() => setShareConfirm(false)}
|
||||
>
|
||||
<Text size="B300">Cancel</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<SequenceCard
|
||||
className={css.ControlCard}
|
||||
variant="SurfaceVariant"
|
||||
@@ -105,7 +236,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} />
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() => callEmbed.control.toggleScreenshare()}
|
||||
onToggle={() => screenshare
|
||||
? callEmbed.control.toggleScreenshare()
|
||||
: setShareConfirm(true)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -53,7 +53,7 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
|
||||
delay={500}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text size="T200">{enabled ? 'Turn Off Sound' : 'Turn On Sound'}</Text>
|
||||
<Text size="T200">{enabled ? 'Deafen' : 'Undeafen'}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import * as css from './styles.css';
|
||||
@@ -7,6 +7,28 @@ import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||
import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed';
|
||||
import { useCallPreferences } from '../../state/hooks/callPreferences';
|
||||
|
||||
type MediaPermState = 'granted' | 'denied' | 'prompt' | 'unknown';
|
||||
|
||||
function useMediaPermissions(): MediaPermState {
|
||||
const [state, setState] = useState<MediaPermState>('unknown');
|
||||
|
||||
useEffect(() => {
|
||||
if (!navigator.permissions) {
|
||||
setState('unknown');
|
||||
return;
|
||||
}
|
||||
navigator.permissions
|
||||
.query({ name: 'microphone' as PermissionName })
|
||||
.then((result) => {
|
||||
setState(result.state as MediaPermState);
|
||||
result.onchange = () => setState(result.state as MediaPermState);
|
||||
})
|
||||
.catch(() => setState('unknown'));
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
type PrescreenControlsProps = {
|
||||
canJoin?: boolean;
|
||||
};
|
||||
@@ -21,7 +43,10 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
||||
const startCall = useCallStart(direct);
|
||||
const joining = callEmbed?.roomId === room.roomId && !callJoined;
|
||||
|
||||
const disabled = inOtherCall || !canJoin;
|
||||
const micPermission = useMediaPermissions();
|
||||
const micDenied = micPermission === 'denied';
|
||||
|
||||
const disabled = inOtherCall || !canJoin || micDenied;
|
||||
|
||||
const { microphone, video, sound, toggleMicrophone, toggleVideo, toggleSound } =
|
||||
useCallPreferences();
|
||||
@@ -45,7 +70,12 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
||||
<VideoButton enabled={video} onToggle={toggleVideo} />
|
||||
<ChatButton />
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Box grow="Yes" direction="Column" gap="200">
|
||||
{micDenied && (
|
||||
<Text size="T200" style={{ color: 'var(--tc-critical-high, #e53e3e)', textAlign: 'center' }}>
|
||||
Microphone access is blocked. Enable it in your browser settings to join.
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
variant={disabled ? 'Secondary' : 'Success'}
|
||||
fill={disabled ? 'Soft' : 'Solid'}
|
||||
|
||||
@@ -21,6 +21,7 @@ export const CallMemberCard = style({
|
||||
|
||||
export const CallControlContainer = style({
|
||||
padding: config.space.S400,
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const PrescreenMessage = style({
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { ChatBackground } from '../../state/settings';
|
||||
|
||||
export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'blueprint', label: 'Blueprint' },
|
||||
{ value: 'carbon', label: 'Carbon' },
|
||||
{ value: 'stars', label: 'Stars' },
|
||||
{ value: 'topographic', label: 'Topographic' },
|
||||
{ value: 'herringbone', label: 'Herringbone' },
|
||||
{ value: 'crosshatch', label: 'Crosshatch' },
|
||||
{ value: 'chevron', label: 'Chevron' },
|
||||
{ value: 'polka', label: 'Polka' },
|
||||
{ value: 'triangles', label: 'Triangles' },
|
||||
{ value: 'plaid', label: 'Plaid' },
|
||||
{ value: 'tactical', label: 'Tactical' },
|
||||
{ value: 'circuit', label: 'Circuit' },
|
||||
{ value: 'hexgrid', label: 'Hex Grid' },
|
||||
];
|
||||
|
||||
const DARK: Record<ChatBackground, CSSProperties> = {
|
||||
none: {},
|
||||
|
||||
blueprint: {
|
||||
backgroundColor: '#0a1628',
|
||||
backgroundImage: [
|
||||
'linear-gradient(rgba(100,149,237,0.14) 1px, transparent 1px)',
|
||||
'linear-gradient(90deg, rgba(100,149,237,0.14) 1px, transparent 1px)',
|
||||
'linear-gradient(rgba(100,149,237,0.05) 1px, transparent 1px)',
|
||||
'linear-gradient(90deg, rgba(100,149,237,0.05) 1px, transparent 1px)',
|
||||
].join(','),
|
||||
backgroundSize: '80px 80px, 80px 80px, 16px 16px, 16px 16px',
|
||||
},
|
||||
|
||||
carbon: {
|
||||
backgroundColor: '#0e0e0e',
|
||||
backgroundImage: [
|
||||
'repeating-linear-gradient(45deg, rgba(255,255,255,0.035) 0, rgba(255,255,255,0.035) 2px, transparent 0, transparent 50%)',
|
||||
'repeating-linear-gradient(135deg, rgba(255,255,255,0.035) 0, rgba(255,255,255,0.035) 2px, transparent 0, transparent 50%)',
|
||||
].join(','),
|
||||
backgroundSize: '8px 8px',
|
||||
},
|
||||
|
||||
stars: {
|
||||
backgroundColor: '#050510',
|
||||
backgroundImage: [
|
||||
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)',
|
||||
'radial-gradient(circle, rgba(255,255,255,0.55) 1px, transparent 1px)',
|
||||
'radial-gradient(circle, rgba(200,200,255,0.3) 1px, transparent 1px)',
|
||||
].join(','),
|
||||
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
|
||||
backgroundPosition: '0 0, 65px 32px, 32px 97px',
|
||||
},
|
||||
|
||||
topographic: {
|
||||
backgroundColor: '#0f0f17',
|
||||
backgroundImage: [
|
||||
'repeating-radial-gradient(circle at 20% 20%, transparent 0, transparent 30px, rgba(152,0,0,0.07) 31px, transparent 32px)',
|
||||
'repeating-radial-gradient(circle at 80% 80%, transparent 0, transparent 25px, rgba(100,100,200,0.06) 26px, transparent 27px)',
|
||||
'repeating-radial-gradient(circle at 50% 10%, transparent 0, transparent 45px, rgba(152,0,0,0.04) 46px, transparent 47px)',
|
||||
].join(','),
|
||||
},
|
||||
|
||||
herringbone: {
|
||||
backgroundColor: '#111118',
|
||||
backgroundImage: [
|
||||
'repeating-linear-gradient(60deg, rgba(180,160,210,0.08) 0, rgba(180,160,210,0.08) 1px, transparent 0, transparent 50%)',
|
||||
'repeating-linear-gradient(120deg, rgba(180,160,210,0.08) 0, rgba(180,160,210,0.08) 1px, transparent 0, transparent 50%)',
|
||||
].join(','),
|
||||
backgroundSize: '20px 36px',
|
||||
},
|
||||
|
||||
crosshatch: {
|
||||
backgroundColor: '#0f0f0f',
|
||||
backgroundImage: [
|
||||
'linear-gradient(rgba(255,255,255,0.06) 1px, transparent 1px)',
|
||||
'linear-gradient(90deg, rgba(255,255,255,0.06) 1px, transparent 1px)',
|
||||
'linear-gradient(rgba(255,255,255,0.022) 1px, transparent 1px)',
|
||||
'linear-gradient(90deg, rgba(255,255,255,0.022) 1px, transparent 1px)',
|
||||
].join(','),
|
||||
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
|
||||
},
|
||||
|
||||
// Interlocking zigzag stripes
|
||||
chevron: {
|
||||
backgroundColor: '#0f0f17',
|
||||
backgroundImage: [
|
||||
'linear-gradient(135deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
|
||||
'linear-gradient(225deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
|
||||
'linear-gradient(315deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
|
||||
'linear-gradient(45deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
|
||||
].join(','),
|
||||
backgroundSize: '20px 20px',
|
||||
},
|
||||
|
||||
// Even dot grid
|
||||
polka: {
|
||||
backgroundColor: '#0e0e14',
|
||||
backgroundImage: 'radial-gradient(circle, rgba(255,255,255,0.2) 2px, transparent 2px)',
|
||||
backgroundSize: '28px 28px',
|
||||
},
|
||||
|
||||
// Isometric triangle grid
|
||||
triangles: {
|
||||
backgroundColor: '#111118',
|
||||
backgroundImage: [
|
||||
'linear-gradient(60deg, rgba(100,149,237,0.09) 25%, transparent 25%, transparent 75%, rgba(100,149,237,0.09) 75%)',
|
||||
'linear-gradient(120deg, rgba(100,149,237,0.09) 25%, transparent 25%, transparent 75%, rgba(100,149,237,0.09) 75%)',
|
||||
].join(','),
|
||||
backgroundSize: '40px 70px',
|
||||
backgroundPosition: '0 0, 20px 35px',
|
||||
},
|
||||
|
||||
// Tartan-inspired crossing lines with accent colour
|
||||
plaid: {
|
||||
backgroundColor: '#0a1020',
|
||||
backgroundImage: [
|
||||
'repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(100,149,237,0.13) 39px, rgba(100,149,237,0.13) 40px)',
|
||||
'repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(100,149,237,0.13) 39px, rgba(100,149,237,0.13) 40px)',
|
||||
'repeating-linear-gradient(0deg, transparent, transparent 7px, rgba(152,0,0,0.08) 7px, rgba(152,0,0,0.08) 8px)',
|
||||
'repeating-linear-gradient(90deg, transparent, transparent 7px, rgba(152,0,0,0.08) 7px, rgba(152,0,0,0.08) 8px)',
|
||||
].join(','),
|
||||
},
|
||||
|
||||
// LotusGuild TDS exact dot-grid
|
||||
tactical: {
|
||||
backgroundColor: '#030508',
|
||||
backgroundImage: 'radial-gradient(circle, rgba(0,212,255,0.055) 1px, transparent 1px)',
|
||||
backgroundSize: '28px 28px',
|
||||
},
|
||||
|
||||
// Circuit board — green grid with node dots
|
||||
circuit: {
|
||||
backgroundColor: '#040a04',
|
||||
backgroundImage: [
|
||||
'linear-gradient(rgba(0,255,136,0.045) 1px, transparent 1px)',
|
||||
'linear-gradient(90deg, rgba(0,255,136,0.045) 1px, transparent 1px)',
|
||||
'radial-gradient(circle, rgba(0,255,136,0.20) 1.5px, transparent 1.5px)',
|
||||
].join(','),
|
||||
backgroundSize: '40px 40px, 40px 40px, 40px 40px',
|
||||
backgroundPosition: '0 0, 0 0, 20px 20px',
|
||||
},
|
||||
|
||||
// True pointy-top hexagonal grid via SVG data URI
|
||||
hexgrid: {
|
||||
backgroundColor: '#060c14',
|
||||
backgroundImage: 'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C212%2C255%2C0.13%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
|
||||
backgroundSize: '29px 50px',
|
||||
},
|
||||
};
|
||||
|
||||
const LIGHT: Record<ChatBackground, CSSProperties> = {
|
||||
none: {},
|
||||
|
||||
blueprint: {
|
||||
backgroundColor: '#eef3ff',
|
||||
backgroundImage: [
|
||||
'linear-gradient(rgba(50,100,220,0.16) 1px, transparent 1px)',
|
||||
'linear-gradient(90deg, rgba(50,100,220,0.16) 1px, transparent 1px)',
|
||||
'linear-gradient(rgba(50,100,220,0.06) 1px, transparent 1px)',
|
||||
'linear-gradient(90deg, rgba(50,100,220,0.06) 1px, transparent 1px)',
|
||||
].join(','),
|
||||
backgroundSize: '80px 80px, 80px 80px, 16px 16px, 16px 16px',
|
||||
},
|
||||
|
||||
carbon: {
|
||||
backgroundColor: '#efefef',
|
||||
backgroundImage: [
|
||||
'repeating-linear-gradient(45deg, rgba(0,0,0,0.04) 0, rgba(0,0,0,0.04) 2px, transparent 0, transparent 50%)',
|
||||
'repeating-linear-gradient(135deg, rgba(0,0,0,0.04) 0, rgba(0,0,0,0.04) 2px, transparent 0, transparent 50%)',
|
||||
].join(','),
|
||||
backgroundSize: '8px 8px',
|
||||
},
|
||||
|
||||
// Stars is intentionally always dark — it's a night-sky theme
|
||||
stars: {
|
||||
backgroundColor: '#050510',
|
||||
backgroundImage: [
|
||||
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)',
|
||||
'radial-gradient(circle, rgba(255,255,255,0.55) 1px, transparent 1px)',
|
||||
'radial-gradient(circle, rgba(200,200,255,0.3) 1px, transparent 1px)',
|
||||
].join(','),
|
||||
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
|
||||
backgroundPosition: '0 0, 65px 32px, 32px 97px',
|
||||
},
|
||||
|
||||
topographic: {
|
||||
backgroundColor: '#faf8f5',
|
||||
backgroundImage: [
|
||||
'repeating-radial-gradient(circle at 20% 20%, transparent 0, transparent 30px, rgba(100,60,60,0.09) 31px, transparent 32px)',
|
||||
'repeating-radial-gradient(circle at 80% 80%, transparent 0, transparent 25px, rgba(60,60,130,0.07) 26px, transparent 27px)',
|
||||
'repeating-radial-gradient(circle at 50% 10%, transparent 0, transparent 45px, rgba(100,60,60,0.05) 46px, transparent 47px)',
|
||||
].join(','),
|
||||
},
|
||||
|
||||
herringbone: {
|
||||
backgroundColor: '#f9f9f9',
|
||||
backgroundImage: [
|
||||
'repeating-linear-gradient(60deg, rgba(80,70,110,0.09) 0, rgba(80,70,110,0.09) 1px, transparent 0, transparent 50%)',
|
||||
'repeating-linear-gradient(120deg, rgba(80,70,110,0.09) 0, rgba(80,70,110,0.09) 1px, transparent 0, transparent 50%)',
|
||||
].join(','),
|
||||
backgroundSize: '20px 36px',
|
||||
},
|
||||
|
||||
crosshatch: {
|
||||
backgroundColor: '#ffffff',
|
||||
backgroundImage: [
|
||||
'linear-gradient(rgba(0,0,0,0.07) 1px, transparent 1px)',
|
||||
'linear-gradient(90deg, rgba(0,0,0,0.07) 1px, transparent 1px)',
|
||||
'linear-gradient(rgba(0,0,0,0.025) 1px, transparent 1px)',
|
||||
'linear-gradient(90deg, rgba(0,0,0,0.025) 1px, transparent 1px)',
|
||||
].join(','),
|
||||
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
|
||||
},
|
||||
|
||||
chevron: {
|
||||
backgroundColor: '#f9f8ff',
|
||||
backgroundImage: [
|
||||
'linear-gradient(135deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
|
||||
'linear-gradient(225deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
|
||||
'linear-gradient(315deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
|
||||
'linear-gradient(45deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
|
||||
].join(','),
|
||||
backgroundSize: '20px 20px',
|
||||
},
|
||||
|
||||
polka: {
|
||||
backgroundColor: '#fafafa',
|
||||
backgroundImage: 'radial-gradient(circle, rgba(0,0,0,0.18) 2px, transparent 2px)',
|
||||
backgroundSize: '28px 28px',
|
||||
},
|
||||
|
||||
triangles: {
|
||||
backgroundColor: '#f4f7ff',
|
||||
backgroundImage: [
|
||||
'linear-gradient(60deg, rgba(50,100,220,0.1) 25%, transparent 25%, transparent 75%, rgba(50,100,220,0.1) 75%)',
|
||||
'linear-gradient(120deg, rgba(50,100,220,0.1) 25%, transparent 25%, transparent 75%, rgba(50,100,220,0.1) 75%)',
|
||||
].join(','),
|
||||
backgroundSize: '40px 70px',
|
||||
backgroundPosition: '0 0, 20px 35px',
|
||||
},
|
||||
|
||||
plaid: {
|
||||
backgroundColor: '#f5f0ff',
|
||||
backgroundImage: [
|
||||
'repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(100,50,180,0.15) 39px, rgba(100,50,180,0.15) 40px)',
|
||||
'repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(100,50,180,0.15) 39px, rgba(100,50,180,0.15) 40px)',
|
||||
'repeating-linear-gradient(0deg, transparent, transparent 7px, rgba(200,0,0,0.09) 7px, rgba(200,0,0,0.09) 8px)',
|
||||
'repeating-linear-gradient(90deg, transparent, transparent 7px, rgba(200,0,0,0.09) 7px, rgba(200,0,0,0.09) 8px)',
|
||||
].join(','),
|
||||
},
|
||||
|
||||
tactical: {
|
||||
backgroundColor: '#f0f4fa',
|
||||
backgroundImage: 'radial-gradient(circle, rgba(0,100,200,0.08) 1px, transparent 1px)',
|
||||
backgroundSize: '28px 28px',
|
||||
},
|
||||
|
||||
circuit: {
|
||||
backgroundColor: '#f0f8f0',
|
||||
backgroundImage: [
|
||||
'linear-gradient(rgba(0,160,80,0.06) 1px, transparent 1px)',
|
||||
'linear-gradient(90deg, rgba(0,160,80,0.06) 1px, transparent 1px)',
|
||||
'radial-gradient(circle, rgba(0,160,80,0.22) 1.5px, transparent 1.5px)',
|
||||
].join(','),
|
||||
backgroundSize: '40px 40px, 40px 40px, 40px 40px',
|
||||
backgroundPosition: '0 0, 0 0, 20px 20px',
|
||||
},
|
||||
|
||||
hexgrid: {
|
||||
backgroundColor: '#f4f8ff',
|
||||
backgroundImage: 'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%2850%2C100%2C220%2C0.11%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
|
||||
backgroundSize: '29px 50px',
|
||||
},
|
||||
};
|
||||
|
||||
export const getChatBg = (bg: ChatBackground, isDark: boolean): CSSProperties =>
|
||||
isDark ? DARK[bg] : LIGHT[bg];
|
||||
@@ -436,6 +436,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
|
||||
const [perMessageProfiles] = useSetting(settingsAtom, 'perMessageProfiles');
|
||||
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||
const direct = useIsDirectRoom();
|
||||
@@ -1649,6 +1650,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
}
|
||||
|
||||
const collapsed =
|
||||
!perMessageProfiles &&
|
||||
isPrevRendered &&
|
||||
!dayDivider &&
|
||||
(!newDivider || eventSender === mx.getUserId()) &&
|
||||
|
||||
@@ -15,6 +15,10 @@ import { RoomTombstone } from './RoomTombstone';
|
||||
import { RoomInput } from './RoomInput';
|
||||
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
|
||||
import { Page } from '../../components/page';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useTheme, ThemeKind } from '../../hooks/useTheme';
|
||||
import { getChatBg } from '../lotus/chatBackground';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { editableActiveElement } from '../../utils/dom';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
@@ -54,9 +58,14 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
|
||||
export function RoomView({ eventId }: { eventId?: string }) {
|
||||
const roomInputRef = useRef<HTMLDivElement>(null);
|
||||
const roomViewRef = useRef<HTMLDivElement>(null);
|
||||
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
|
||||
const theme = useTheme();
|
||||
const isDark = theme.kind === ThemeKind.Dark;
|
||||
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
|
||||
@@ -91,7 +100,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Page ref={roomViewRef}>
|
||||
<Page ref={roomViewRef} style={getChatBg(chatBackground, isDark)}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomTimeline
|
||||
key={roomId}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||
import { useCallStart } from '../../hooks/useCallEmbed';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
@@ -263,6 +264,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||
const direct = useIsDirectRoom();
|
||||
const startCall = useCallStart(true);
|
||||
|
||||
const pinnedEvents = useRoomPinnedEvents(room);
|
||||
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
||||
@@ -379,6 +381,23 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
</Box>
|
||||
|
||||
<Box shrink="No">
|
||||
{direct && !callView && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Start Call</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton fill="None" ref={triggerRef} onClick={() => startCall(room)}>
|
||||
<Icon size="400" src={Icons.Phone} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{!encryptedRoom && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import CinnySVG from '../../../../../public/res/svg/cinny.svg';
|
||||
import LotusLogo from '../../../../../public/res/Lotus.png';
|
||||
import pkg from '../../../../../package.json';
|
||||
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
|
||||
@@ -38,23 +39,23 @@ export function About({ requestClose }: AboutProps) {
|
||||
<Box shrink="No">
|
||||
<img
|
||||
style={{ width: toRem(60), height: toRem(60) }}
|
||||
src={CinnySVG}
|
||||
alt="Cinny logo"
|
||||
src={LotusLogo}
|
||||
alt="Lotus Chat logo"
|
||||
/>
|
||||
</Box>
|
||||
<Box direction="Column" gap="300">
|
||||
<Box direction="Column" gap="100">
|
||||
<Box gap="100" alignItems="End">
|
||||
<Text size="H3">Cinny</Text>
|
||||
<Text size="T200">v4.11.1</Text>
|
||||
<Text size="H3">Lotus Chat</Text>
|
||||
<Text size="T200">v{pkg.version}</Text>
|
||||
</Box>
|
||||
<Text>Yet another matrix client.</Text>
|
||||
<Text>A Matrix client for Lotus Guild.</Text>
|
||||
</Box>
|
||||
|
||||
<Box gap="200" wrap="Wrap">
|
||||
<Button
|
||||
as="a"
|
||||
href="https://github.com/cinnyapp/cinny"
|
||||
href="https://code.lotusguild.org/LotusGuild/cinny"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
variant="Secondary"
|
||||
@@ -67,7 +68,7 @@ export function About({ requestClose }: AboutProps) {
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href="https://cinny.in/#sponsor"
|
||||
href="https://matrix.lotusguild.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
variant="Critical"
|
||||
|
||||
@@ -28,7 +28,7 @@ function ExportKeys() {
|
||||
const blob = new Blob([encKeys], {
|
||||
type: 'text/plain;charset=us-ascii',
|
||||
});
|
||||
FileSaver.saveAs(blob, 'cinny-keys.txt');
|
||||
FileSaver.saveAs(blob, 'lotus-keys.txt');
|
||||
},
|
||||
[mx]
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@ import FocusTrap from 'focus-trap-react';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
|
||||
import { ChatBackground, DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { KeySymbol } from '../../../utils/key-symbol';
|
||||
import { isMacOS } from '../../../utils/user-agent';
|
||||
@@ -42,10 +42,13 @@ import {
|
||||
Theme,
|
||||
ThemeKind,
|
||||
useSystemThemeKind,
|
||||
useTheme,
|
||||
useThemeNames,
|
||||
useThemes,
|
||||
} from '../../../hooks/useTheme';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { BG_OPTIONS, getChatBg } from '../../lotus/chatBackground';
|
||||
import { resetBootSequence, runLotusBootSequence } from '../../../../lotus-boot';
|
||||
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||
@@ -307,6 +310,8 @@ function Appearance() {
|
||||
const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
|
||||
const [monochromeMode, setMonochromeMode] = useSetting(settingsAtom, 'monochromeMode');
|
||||
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||||
const [perMessageProfiles, setPerMessageProfiles] = useSetting(settingsAtom, 'perMessageProfiles');
|
||||
const [lotusTerminal, setLotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
@@ -350,6 +355,56 @@ function Appearance() {
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Page Zoom" after={<PageZoomInput />} />
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column" gap="200">
|
||||
<SettingTile
|
||||
title="Chat Background"
|
||||
description="Pattern applied behind the message timeline."
|
||||
/>
|
||||
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
|
||||
<ChatBgGrid />
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Show Profile on Every Message"
|
||||
description="Display avatar and name on each message instead of grouping consecutive messages."
|
||||
after={<Switch variant="Primary" value={perMessageProfiles} onChange={setPerMessageProfiles} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Lotus Terminal Mode"
|
||||
description="LotusGuild Terminal Design System: Anduril Orange + Ice Cyan + Matrix Green, dot-grid background, CRT scanlines, and boot sequence animation."
|
||||
after={
|
||||
<Box direction="Row" gap="200" align="Center">
|
||||
{lotusTerminal && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { resetBootSequence(); runLotusBootSequence(true); }}
|
||||
title="Replay boot sequence"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(255,107,0,0.35)',
|
||||
color: '#FF6B00',
|
||||
fontSize: '0.65rem',
|
||||
padding: '0.2rem 0.6rem',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
▶ Boot
|
||||
</button>
|
||||
)}
|
||||
<Switch variant="Primary" value={lotusTerminal} onChange={setLotusTerminal} />
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -711,6 +766,7 @@ function Editor() {
|
||||
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
@@ -730,6 +786,13 @@ function Editor() {
|
||||
after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Formatting Toolbar"
|
||||
description="Show bold, italic, code and other formatting buttons above the message input."
|
||||
after={<Switch variant="Primary" value={editorToolbar} onChange={setEditorToolbar} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Hide Typing & Read Receipts"
|
||||
@@ -741,6 +804,121 @@ function Editor() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function Calls() {
|
||||
const [cameraOnJoin, setCameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
|
||||
const [callNoiseSuppression, setCallNoiseSuppression] = useSetting(settingsAtom, 'callNoiseSuppression');
|
||||
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
||||
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
||||
const [listeningForKey, setListeningForKey] = useState(false);
|
||||
|
||||
const handleKeyBind = () => {
|
||||
setListeningForKey(true);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.code === 'Escape') {
|
||||
setListeningForKey(false);
|
||||
} else {
|
||||
setPttKey(e.code);
|
||||
setListeningForKey(false);
|
||||
}
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
};
|
||||
|
||||
const keyLabel = (code: string) => code === 'Space' ? 'Space' : code.replace('Key', '').replace('Digit', '');
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Calls</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Join with Camera On"
|
||||
description="Enable camera automatically when joining a voice or video call. Camera is off by default."
|
||||
after={<Switch variant="Primary" value={cameraOnJoin} onChange={setCameraOnJoin} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Noise Suppression"
|
||||
description="Apply AI noise suppression to filter background noise during calls (powered by Element Call)."
|
||||
after={<Switch variant="Primary" value={callNoiseSuppression} onChange={setCallNoiseSuppression} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column" gap="400">
|
||||
<SettingTile
|
||||
title="Push to Talk"
|
||||
description="Mute your microphone by default. Hold the PTT key to speak."
|
||||
after={<Switch variant="Primary" value={pttMode} onChange={setPttMode} />}
|
||||
/>
|
||||
{pttMode && (
|
||||
<SettingTile
|
||||
title="PTT Key Binding"
|
||||
description="Press a key to bind it as your push-to-talk key."
|
||||
after={
|
||||
<Button
|
||||
size="300"
|
||||
variant={listeningForKey ? 'Warning' : 'Secondary'}
|
||||
fill={listeningForKey ? 'Solid' : 'Soft'}
|
||||
radii="300"
|
||||
outlined
|
||||
onClick={handleKeyBind}
|
||||
style={{ minWidth: '90px' }}
|
||||
>
|
||||
<Text size="B300">
|
||||
{listeningForKey ? 'Press a key…' : keyLabel(pttKey)}
|
||||
</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ChatBgGrid() {
|
||||
const [chatBackground, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
|
||||
const theme = useTheme();
|
||||
const isDark = theme.kind === ThemeKind.Dark;
|
||||
|
||||
return (
|
||||
<Box wrap="Wrap" gap="200">
|
||||
{BG_OPTIONS.map((opt) => (
|
||||
<Box key={opt.value} direction="Column" gap="100" style={{ alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={opt.label}
|
||||
aria-pressed={chatBackground === opt.value}
|
||||
onClick={() => setChatBackground(opt.value as ChatBackground)}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: toRem(76),
|
||||
height: toRem(50),
|
||||
borderRadius: toRem(8),
|
||||
cursor: 'pointer',
|
||||
border: chatBackground === opt.value
|
||||
? '2px solid #980000'
|
||||
: '2px solid rgba(128,128,128,0.25)',
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
...getChatBg(opt.value as ChatBackground, isDark),
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
size="T200"
|
||||
style={chatBackground === opt.value ? { color: '#980000' } : undefined}
|
||||
>
|
||||
{opt.label}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectMessageLayout() {
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
|
||||
@@ -1006,6 +1184,7 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<DateAndTime />
|
||||
<Editor />
|
||||
<Messages />
|
||||
<Calls />
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
|
||||
@@ -27,7 +27,7 @@ function EmailNotification() {
|
||||
device_display_name: email,
|
||||
lang: 'en',
|
||||
data: {
|
||||
brand: 'Cinny',
|
||||
brand: 'Lotus Chat',
|
||||
},
|
||||
append: true,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import { useResizeObserver } from './useResizeObserver';
|
||||
import { CallControlState } from '../plugins/call/CallControlState';
|
||||
import { useCallMembersChange, useCallSession } from './useCall';
|
||||
import { CallPreferences } from '../state/callPreferences';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
|
||||
const CallEmbedContext = createContext<CallEmbed | undefined>(undefined);
|
||||
|
||||
@@ -42,15 +44,19 @@ export const createCallEmbed = (
|
||||
dm: boolean,
|
||||
themeKind: ElementCallThemeKind,
|
||||
container: HTMLElement,
|
||||
pref?: CallPreferences
|
||||
pref?: CallPreferences,
|
||||
noiseSuppression = true,
|
||||
forceAudioOff = false
|
||||
): CallEmbed => {
|
||||
const rtcSession = mx.matrixRTC.getRoomSession(room);
|
||||
const ongoing =
|
||||
MatrixRTCSession.sessionMembershipsForRoom(room, rtcSession.sessionDescription).length > 0;
|
||||
|
||||
const intent = CallEmbed.getIntent(dm, ongoing);
|
||||
const widget = CallEmbed.getWidget(mx, room, intent, themeKind);
|
||||
const controlState = pref && new CallControlState(pref.microphone, pref.video, pref.sound);
|
||||
const initialAudio = forceAudioOff ? false : (pref?.microphone ?? true);
|
||||
const initialVideo = pref?.video ?? false;
|
||||
const widget = CallEmbed.getWidget(mx, room, intent, themeKind, noiseSuppression, initialAudio, initialVideo);
|
||||
const controlState = pref && new CallControlState(forceAudioOff ? false : pref.microphone, pref.video, pref.sound);
|
||||
|
||||
const embed = new CallEmbed(mx, room, widget, container, controlState);
|
||||
|
||||
@@ -62,6 +68,8 @@ export const useCallStart = (dm = false) => {
|
||||
const theme = useTheme();
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
const callEmbedRef = useCallEmbedRef();
|
||||
const [callNoiseSuppression] = useSetting(settingsAtom, 'callNoiseSuppression');
|
||||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||
|
||||
const startCall = useCallback(
|
||||
(room: Room, pref?: CallPreferences) => {
|
||||
@@ -69,11 +77,11 @@ export const useCallStart = (dm = false) => {
|
||||
if (!container) {
|
||||
throw new Error('Failed to start call, No embed container element found!');
|
||||
}
|
||||
const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref);
|
||||
const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref, callNoiseSuppression ?? true, !!pttMode);
|
||||
|
||||
setCallEmbed(callEmbed);
|
||||
},
|
||||
[mx, dm, theme, setCallEmbed, callEmbedRef]
|
||||
[mx, dm, theme, setCallEmbed, callEmbedRef, callNoiseSuppression, pttMode]
|
||||
);
|
||||
|
||||
return startCall;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { lightTheme } from 'folds';
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
|
||||
import { butterTheme, darkTheme, silverTheme } from '../../colors.css';
|
||||
import { butterTheme, darkTheme, lotusTerminalLightTheme, lotusTerminalTheme, silverTheme } from '../../colors.css';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
|
||||
@@ -37,6 +37,16 @@ export const ButterTheme: Theme = {
|
||||
kind: ThemeKind.Dark,
|
||||
classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'],
|
||||
};
|
||||
export const LotusTerminalTheme: Theme = {
|
||||
id: 'lotus-terminal-theme',
|
||||
kind: ThemeKind.Dark,
|
||||
classNames: ['lotus-terminal-theme', lotusTerminalTheme, onDarkFontWeight, 'prism-dark'],
|
||||
};
|
||||
export const LotusTerminalLightTheme: Theme = {
|
||||
id: 'lotus-terminal-light-theme',
|
||||
kind: ThemeKind.Light,
|
||||
classNames: ['lotus-terminal-light-theme', lotusTerminalLightTheme, onLightFontWeight, 'prism-light'],
|
||||
};
|
||||
|
||||
export const useThemes = (): Theme[] => {
|
||||
const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme], []);
|
||||
|
||||
@@ -3,27 +3,39 @@ import { configClass, varsClass } from 'folds';
|
||||
import {
|
||||
DarkTheme,
|
||||
LightTheme,
|
||||
LotusTerminalLightTheme,
|
||||
LotusTerminalTheme,
|
||||
ThemeContextProvider,
|
||||
ThemeKind,
|
||||
useActiveTheme,
|
||||
useSystemThemeKind,
|
||||
} from '../hooks/useTheme';
|
||||
import { lotusTerminalBodyClass } from '../../lotus-terminal.css';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { runLotusBootSequence } from '../../lotus-boot';
|
||||
|
||||
export function UnAuthRouteThemeManager() {
|
||||
const systemThemeKind = useSystemThemeKind();
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
|
||||
useEffect(() => {
|
||||
document.body.className = '';
|
||||
document.body.classList.add(configClass, varsClass);
|
||||
if (lotusTerminal) {
|
||||
const isLight = systemThemeKind === ThemeKind.Light;
|
||||
document.documentElement.setAttribute('data-theme', isLight ? 'light' : 'dark');
|
||||
document.body.classList.add(...(isLight ? LotusTerminalLightTheme : LotusTerminalTheme).classNames);
|
||||
document.body.classList.add(lotusTerminalBodyClass);
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
if (systemThemeKind === ThemeKind.Dark) {
|
||||
document.body.classList.add(...DarkTheme.classNames);
|
||||
}
|
||||
if (systemThemeKind === ThemeKind.Light) {
|
||||
} else {
|
||||
document.body.classList.add(...LightTheme.classNames);
|
||||
}
|
||||
}, [systemThemeKind]);
|
||||
}
|
||||
}, [systemThemeKind, lotusTerminal]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -31,19 +43,30 @@ export function UnAuthRouteThemeManager() {
|
||||
export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
|
||||
const activeTheme = useActiveTheme();
|
||||
const [monochromeMode] = useSetting(settingsAtom, 'monochromeMode');
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
|
||||
const terminalIsLight = lotusTerminal && activeTheme.kind === ThemeKind.Light;
|
||||
const effectiveTheme = lotusTerminal
|
||||
? (terminalIsLight ? LotusTerminalLightTheme : LotusTerminalTheme)
|
||||
: activeTheme;
|
||||
|
||||
useEffect(() => {
|
||||
document.body.className = '';
|
||||
document.body.classList.add(configClass, varsClass);
|
||||
|
||||
document.body.classList.add(...activeTheme.classNames);
|
||||
|
||||
if (monochromeMode) {
|
||||
document.body.classList.add(...effectiveTheme.classNames);
|
||||
if (lotusTerminal) {
|
||||
document.documentElement.setAttribute('data-theme', terminalIsLight ? 'light' : 'dark');
|
||||
document.body.classList.add(lotusTerminalBodyClass);
|
||||
runLotusBootSequence();
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
if (monochromeMode && !lotusTerminal) {
|
||||
document.body.style.filter = 'grayscale(1)';
|
||||
} else {
|
||||
document.body.style.filter = '';
|
||||
}
|
||||
}, [activeTheme, monochromeMode]);
|
||||
}, [effectiveTheme, monochromeMode, lotusTerminal, terminalIsLight]);
|
||||
|
||||
return <ThemeContextProvider value={activeTheme}>{children}</ThemeContextProvider>;
|
||||
return <ThemeContextProvider value={effectiveTheme}>{children}</ThemeContextProvider>;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'folds';
|
||||
import * as css from './styles.css';
|
||||
import pkg from '../../../../package.json';
|
||||
|
||||
export function AuthFooter() {
|
||||
return (
|
||||
<Box className={css.AuthFooter} justifyContent="Center" gap="400" wrap="Wrap">
|
||||
<Text as="a" size="T300" href="https://cinny.in" target="_blank" rel="noreferrer">
|
||||
<Text as="a" size="T300" href="https://lotusguild.org" target="_blank" rel="noreferrer">
|
||||
About
|
||||
</Text>
|
||||
<Text
|
||||
as="a"
|
||||
size="T300"
|
||||
href="https://github.com/ajbura/cinny/releases"
|
||||
href="https://code.lotusguild.org/LotusGuild/cinny/releases"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
v4.11.1
|
||||
v{pkg.version}
|
||||
</Text>
|
||||
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
|
||||
Twitter
|
||||
<Text as="a" size="T300" href="https://matrix.lotusguild.org" target="_blank" rel="noreferrer">
|
||||
Community
|
||||
</Text>
|
||||
<Text as="a" size="T300" href="https://matrix.org" target="_blank" rel="noreferrer">
|
||||
Powered by Matrix
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from '../../hooks/useClientConfig';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
|
||||
import CinnySVG from '../../../../public/res/svg/cinny.svg';
|
||||
import LotusLogo from '../../../../public/res/Lotus.png';
|
||||
import { ServerPicker } from './ServerPicker';
|
||||
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
|
||||
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
|
||||
@@ -134,8 +134,8 @@ export function AuthLayout() {
|
||||
<Box direction="Column" className={css.AuthCard}>
|
||||
<Header className={css.AuthHeader} size="600" variant="Surface">
|
||||
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
|
||||
<img className={css.AuthLogo} src={CinnySVG} alt="Cinny Logo" />
|
||||
<Text size="H3">Cinny</Text>
|
||||
<img className={css.AuthLogo} src={LotusLogo} alt="Lotus Chat Logo" />
|
||||
<Text size="H3">Lotus Chat</Text>
|
||||
</Box>
|
||||
</Header>
|
||||
<Box className={css.AuthCardContent} direction="Column">
|
||||
|
||||
@@ -133,7 +133,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
|
||||
user: username,
|
||||
},
|
||||
password,
|
||||
initial_device_display_name: 'Cinny Web',
|
||||
initial_device_display_name: 'Lotus Chat Web',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -151,7 +151,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
|
||||
user: mxIdUsername,
|
||||
},
|
||||
password,
|
||||
initial_device_display_name: 'Cinny Web',
|
||||
initial_device_display_name: 'Lotus Chat Web',
|
||||
});
|
||||
};
|
||||
const handleEmailLogin = (email: string, password: string) => {
|
||||
@@ -163,7 +163,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
|
||||
address: email,
|
||||
},
|
||||
password,
|
||||
initial_device_display_name: 'Cinny Web',
|
||||
initial_device_display_name: 'Lotus Chat Web',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export function TokenLogin({ token }: TokenLoginProps) {
|
||||
startLogin(baseUrl, {
|
||||
type: 'm.login.token',
|
||||
token,
|
||||
initial_device_display_name: 'Cinny Web',
|
||||
initial_device_display_name: 'Lotus Chat Web',
|
||||
});
|
||||
}, [baseUrl, token, startLogin]);
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ function RegisterUIAFlow({
|
||||
auth: authDict,
|
||||
password,
|
||||
username,
|
||||
initial_device_display_name: 'Cinny Web',
|
||||
initial_device_display_name: 'Lotus Chat Web',
|
||||
});
|
||||
},
|
||||
[onRegister, formData]
|
||||
@@ -250,7 +250,7 @@ export function PasswordRegisterForm({
|
||||
auth: {
|
||||
session: authData.session,
|
||||
},
|
||||
initial_device_display_name: 'Cinny Web',
|
||||
initial_device_display_name: 'Lotus Chat Web',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||
import LogoSVG from '../../../../public/res/svg/cinny.svg';
|
||||
import LogoUnreadSVG from '../../../../public/res/svg/cinny-unread.svg';
|
||||
import LogoHighlightSVG from '../../../../public/res/svg/cinny-highlight.svg';
|
||||
import LogoSVG from '../../../../public/res/lotus.png';
|
||||
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
|
||||
import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png';
|
||||
import NotificationSound from '../../../../public/sound/notification.ogg';
|
||||
import InviteSound from '../../../../public/sound/invite.ogg';
|
||||
import { notificationPermission, setFavicon } from '../../utils/dom';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
|
||||
import { Page, PageHero, PageHeroSection } from '../../components/page';
|
||||
import CinnySVG from '../../../../public/res/svg/cinny.svg';
|
||||
import LotusLogo from '../../../../public/res/Lotus.png';
|
||||
import pkg from '../../../../package.json';
|
||||
|
||||
export function WelcomePage() {
|
||||
return (
|
||||
@@ -14,17 +15,17 @@ export function WelcomePage() {
|
||||
>
|
||||
<PageHeroSection>
|
||||
<PageHero
|
||||
icon={<img width="70" height="70" src={CinnySVG} alt="Cinny Logo" />}
|
||||
title="Welcome to Cinny"
|
||||
icon={<img width="70" height="70" src={LotusLogo} alt="Lotus Chat" style={{ objectFit: "contain" }} />}
|
||||
title="Welcome to Lotus Chat"
|
||||
subTitle={
|
||||
<span>
|
||||
Yet another matrix client.{' '}
|
||||
A Matrix client for Lotus Guild.{' '}
|
||||
<a
|
||||
href="https://github.com/cinnyapp/cinny/releases"
|
||||
href="https://code.lotusguild.org/LotusGuild/cinny/releases"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
v4.11.1
|
||||
v{pkg.version}
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
@@ -33,7 +34,7 @@ export function WelcomePage() {
|
||||
<Box grow="Yes" style={{ maxWidth: toRem(300) }} direction="Column" gap="300">
|
||||
<Button
|
||||
as="a"
|
||||
href="https://github.com/cinnyapp/cinny"
|
||||
href="https://code.lotusguild.org/LotusGuild/cinny"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
before={<Icon size="200" src={Icons.Code} />}
|
||||
@@ -44,7 +45,7 @@ export function WelcomePage() {
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href="https://cinny.in/#sponsor"
|
||||
href="https://matrix.lotusguild.org"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
fill="Soft"
|
||||
|
||||
@@ -29,17 +29,13 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
}
|
||||
|
||||
private get settingsButton(): HTMLElement | undefined {
|
||||
const leaveBtn = this.document?.querySelector('[data-testid="incall_leave"]');
|
||||
|
||||
const settingsButton = leaveBtn?.previousElementSibling as HTMLElement | null;
|
||||
|
||||
return settingsButton ?? undefined;
|
||||
// EC 0.19.3: settings button has data-testid="settings-bottom-center"
|
||||
return (this.document?.querySelector('[data-testid="settings-bottom-center"]') as HTMLElement) ?? undefined;
|
||||
}
|
||||
|
||||
private get reactionsButton(): HTMLElement | undefined {
|
||||
const reactionsButton = this.settingsButton?.previousElementSibling as HTMLElement | null;
|
||||
|
||||
return reactionsButton ?? undefined;
|
||||
// EC 0.19.3: reactions/raise-hand button has a CSS module class containing "raiseHand"
|
||||
return (this.document?.querySelector('[class*="raiseHand"]') as HTMLElement) ?? undefined;
|
||||
}
|
||||
|
||||
private get spotlightButton(): HTMLInputElement | undefined {
|
||||
@@ -101,6 +97,11 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
this.emitStateUpdate();
|
||||
}
|
||||
|
||||
public async forceState(desired: CallControlState) {
|
||||
this.state = new CallControlState(desired.microphone, desired.video, desired.sound, this.screenshare, this.spotlight);
|
||||
await this.applyState();
|
||||
}
|
||||
|
||||
public startObserving() {
|
||||
this.controlMutationObserver.disconnect();
|
||||
|
||||
@@ -160,6 +161,8 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
}
|
||||
|
||||
public onControlMutation() {
|
||||
const prevScreenshare = this.screenshare;
|
||||
|
||||
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
|
||||
const spotlight: boolean = this.spotlightButton?.checked ?? false;
|
||||
|
||||
@@ -171,6 +174,19 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
spotlight
|
||||
);
|
||||
this.emitStateUpdate();
|
||||
|
||||
// EC auto-switches to spotlight when screenshare starts — revert to grid
|
||||
if (!prevScreenshare && screenshare) {
|
||||
setTimeout(() => { if (this.spotlight) this.gridButton?.click(); }, 600);
|
||||
}
|
||||
}
|
||||
|
||||
public setMicrophone(enabled: boolean) {
|
||||
const payload: ElementMediaStatePayload = {
|
||||
audio_enabled: enabled,
|
||||
video_enabled: this.video,
|
||||
};
|
||||
return this.setMediaState(payload);
|
||||
}
|
||||
|
||||
public toggleMicrophone() {
|
||||
|
||||
@@ -47,6 +47,10 @@ export class CallEmbed {
|
||||
|
||||
private readonly disposables: Array<() => void> = [];
|
||||
|
||||
private readonly initialState: CallControlState;
|
||||
|
||||
private styleRetryObserver?: MutationObserver;
|
||||
|
||||
static getIntent(dm: boolean, ongoing: boolean): ElementCallIntent {
|
||||
if (ongoing) {
|
||||
return dm ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExisting;
|
||||
@@ -59,7 +63,10 @@ export class CallEmbed {
|
||||
mx: MatrixClient,
|
||||
room: Room,
|
||||
intent: ElementCallIntent,
|
||||
themeKind: ElementCallThemeKind
|
||||
themeKind: ElementCallThemeKind,
|
||||
noiseSuppression = true,
|
||||
initialAudio = true,
|
||||
initialVideo = false
|
||||
): Widget {
|
||||
const userId = mx.getSafeUserId();
|
||||
const deviceId = mx.getDeviceId() ?? '';
|
||||
@@ -81,6 +88,9 @@ export class CallEmbed {
|
||||
perParticipantE2EE: room.hasEncryptionStateEvent().toString(),
|
||||
lang: 'en-EN',
|
||||
theme: themeKind,
|
||||
noiseSuppression: noiseSuppression.toString(),
|
||||
audio: initialAudio.toString(),
|
||||
video: initialVideo.toString(),
|
||||
});
|
||||
|
||||
const widgetUrl = new URL(
|
||||
@@ -143,6 +153,7 @@ export class CallEmbed {
|
||||
|
||||
const controlState = initialControlState ?? new CallControlState(true, false, true);
|
||||
this.control = new CallControl(controlState, call, iframe);
|
||||
this.initialState = controlState;
|
||||
|
||||
let initialMediaEvent = true;
|
||||
this.disposables.push(
|
||||
@@ -227,6 +238,7 @@ export class CallEmbed {
|
||||
this.disposables.forEach((disposable) => {
|
||||
disposable();
|
||||
});
|
||||
this.styleRetryObserver?.disconnect();
|
||||
this.call.stop();
|
||||
this.container.removeChild(this.iframe);
|
||||
this.control.dispose();
|
||||
@@ -245,6 +257,8 @@ export class CallEmbed {
|
||||
this.joined = true;
|
||||
this.applyStyles();
|
||||
this.control.startObserving();
|
||||
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
|
||||
this.control.forceState(this.initialState);
|
||||
}
|
||||
|
||||
private applyStyles(): void {
|
||||
@@ -252,12 +266,34 @@ export class CallEmbed {
|
||||
if (!doc) return;
|
||||
|
||||
doc.body.style.setProperty('background', 'none', 'important');
|
||||
const controls = doc.body.querySelector('[data-testid="incall_leave"]')?.parentElement
|
||||
?.parentElement;
|
||||
|
||||
// Inject CSS for things that can't be reliably caught by DOM timing
|
||||
if (!doc.getElementById('lotus-ec-styles')) {
|
||||
const style = doc.createElement('style');
|
||||
style.id = 'lotus-ec-styles';
|
||||
style.textContent = [
|
||||
'body { background: none !important; }',
|
||||
// Hide "using to Device key transport" status line
|
||||
'[style*="height: 0"][style*="z-index: 1"][style*="align-self: center"] { display: none !important; }',
|
||||
].join('\n');
|
||||
(doc.head ?? doc.body).appendChild(style);
|
||||
}
|
||||
|
||||
// Hide EC built-in controls (we provide our own)
|
||||
const leaveBtn = doc.body.querySelector('[data-testid="incall_leave"]');
|
||||
if (leaveBtn) {
|
||||
this.styleRetryObserver?.disconnect();
|
||||
this.styleRetryObserver = undefined;
|
||||
const controls = leaveBtn.parentElement?.parentElement;
|
||||
if (controls) {
|
||||
controls.style.setProperty('position', 'absolute');
|
||||
controls.style.setProperty('visibility', 'hidden');
|
||||
}
|
||||
} else if (!this.styleRetryObserver) {
|
||||
// Controls not in DOM yet — observe and retry when they appear
|
||||
this.styleRetryObserver = new MutationObserver(() => this.applyStyles());
|
||||
this.styleRetryObserver.observe(doc.body, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
|
||||
private onEvent(ev: MatrixEvent): void {
|
||||
|
||||
@@ -28,10 +28,13 @@ export const makeCallPreferencesAtom = (userId: string): CallPreferencesAtom =>
|
||||
storeKey,
|
||||
(key) => {
|
||||
const v = getLocalStorageItem<CallPreferences>(key, DEFAULT_PREFERENCES);
|
||||
return v;
|
||||
// Never restore camera state — always start with camera off for privacy.
|
||||
// Users can toggle it in the prescreen before joining.
|
||||
return { ...v, video: false };
|
||||
},
|
||||
(key, value) => {
|
||||
setLocalStorageItem(key, value);
|
||||
// Don't persist video state — always resets to off on next load.
|
||||
setLocalStorageItem(key, { ...value, video: false });
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createContext, useCallback, useContext } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { CallPreferences, CallPreferencesAtom } from '../callPreferences';
|
||||
import { useSetting } from './settings';
|
||||
import { settingsAtom } from '../settings';
|
||||
|
||||
const CallPreferencesAtomContext = createContext<CallPreferencesAtom | null>(null);
|
||||
export const CallPreferencesProvider = CallPreferencesAtomContext.Provider;
|
||||
@@ -21,6 +23,7 @@ export const useCallPreferences = (): CallPreferences & {
|
||||
} => {
|
||||
const callPrefAtom = useCallPreferencesAtom();
|
||||
const [pref, setPref] = useAtom(callPrefAtom);
|
||||
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
|
||||
|
||||
const toggleMicrophone = useCallback(() => {
|
||||
const microphone = !pref.microphone;
|
||||
@@ -54,6 +57,7 @@ export const useCallPreferences = (): CallPreferences & {
|
||||
|
||||
return {
|
||||
...pref,
|
||||
video: cameraOnJoin ? pref.video : false,
|
||||
toggleMicrophone,
|
||||
toggleVideo,
|
||||
toggleSound,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { atom } from 'jotai';
|
||||
const STORAGE_KEY = 'settings';
|
||||
export type DateFormat = 'D MMM YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY/MM/DD' | '';
|
||||
export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
|
||||
export type ChatBackground = 'none' | 'blueprint' | 'carbon' | 'stars' | 'topographic' | 'herringbone' | 'crosshatch' | 'chevron' | 'polka' | 'triangles' | 'plaid' | 'tactical' | 'circuit' | 'hexgrid';
|
||||
export enum MessageLayout {
|
||||
Modern = 0,
|
||||
Compact = 1,
|
||||
@@ -41,6 +42,15 @@ export interface Settings {
|
||||
dateFormatString: string;
|
||||
|
||||
developerTools: boolean;
|
||||
lotusTerminal: boolean;
|
||||
|
||||
chatBackground: ChatBackground;
|
||||
perMessageProfiles: boolean;
|
||||
|
||||
cameraOnJoin: boolean;
|
||||
callNoiseSuppression: boolean;
|
||||
pttMode: boolean;
|
||||
pttKey: string;
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
@@ -75,6 +85,15 @@ const defaultSettings: Settings = {
|
||||
dateFormatString: 'D MMM YYYY',
|
||||
|
||||
developerTools: false,
|
||||
lotusTerminal: false,
|
||||
|
||||
chatBackground: 'none',
|
||||
perMessageProfiles: false,
|
||||
|
||||
cameraOnJoin: false,
|
||||
callNoiseSuppression: true,
|
||||
pttMode: false,
|
||||
pttKey: 'Space',
|
||||
};
|
||||
|
||||
export const getSettings = () => {
|
||||
|
||||
@@ -236,3 +236,189 @@ export const butterTheme = createTheme(color, {
|
||||
OnContainer: '#F2EED3',
|
||||
},
|
||||
});
|
||||
|
||||
export const lotusTerminalTheme = createTheme(color, {
|
||||
Background: {
|
||||
Container: '#030508',
|
||||
ContainerHover: '#060c14',
|
||||
ContainerActive: '#0d1520',
|
||||
ContainerLine: '#07101a',
|
||||
OnContainer: '#c4d9ee',
|
||||
},
|
||||
|
||||
Surface: {
|
||||
Container: '#060c14',
|
||||
ContainerHover: '#0d1520',
|
||||
ContainerActive: '#07101a',
|
||||
ContainerLine: '#111d2b',
|
||||
OnContainer: '#c4d9ee',
|
||||
},
|
||||
|
||||
SurfaceVariant: {
|
||||
Container: '#0d1520',
|
||||
ContainerHover: '#07101a',
|
||||
ContainerActive: '#111d2b',
|
||||
ContainerLine: '#162436',
|
||||
OnContainer: '#c4d9ee',
|
||||
},
|
||||
|
||||
Primary: {
|
||||
Main: '#FF6B00',
|
||||
MainHover: '#FF8C2B',
|
||||
MainActive: '#FF9940',
|
||||
MainLine: '#FFA655',
|
||||
OnMain: '#030508',
|
||||
Container: '#2a1200',
|
||||
ContainerHover: '#3a1a00',
|
||||
ContainerActive: '#4a2200',
|
||||
ContainerLine: '#5a2a00',
|
||||
OnContainer: '#FFB87A',
|
||||
},
|
||||
|
||||
Secondary: {
|
||||
Main: '#00D4FF',
|
||||
MainHover: '#33DFFF',
|
||||
MainActive: '#4DE5FF',
|
||||
MainLine: '#66EBFF',
|
||||
OnMain: '#030508',
|
||||
Container: '#001a22',
|
||||
ContainerHover: '#002233',
|
||||
ContainerActive: '#002a3d',
|
||||
ContainerLine: '#003347',
|
||||
OnContainer: '#99E8FF',
|
||||
},
|
||||
|
||||
Success: {
|
||||
Main: '#00FF88',
|
||||
MainHover: '#33FFAA',
|
||||
MainActive: '#4DFFB8',
|
||||
MainLine: '#66FFC6',
|
||||
OnMain: '#030508',
|
||||
Container: '#002211',
|
||||
ContainerHover: '#002a16',
|
||||
ContainerActive: '#00331b',
|
||||
ContainerLine: '#003b20',
|
||||
OnContainer: '#99FFCC',
|
||||
},
|
||||
|
||||
Warning: {
|
||||
Main: '#FFB300',
|
||||
MainHover: '#FFC233',
|
||||
MainActive: '#FFCC4D',
|
||||
MainLine: '#FFD566',
|
||||
OnMain: '#030508',
|
||||
Container: '#221900',
|
||||
ContainerHover: '#2a2000',
|
||||
ContainerActive: '#332800',
|
||||
ContainerLine: '#3b2e00',
|
||||
OnContainer: '#FFE08A',
|
||||
},
|
||||
|
||||
Critical: {
|
||||
Main: '#FF2D55',
|
||||
MainHover: '#FF4D6D',
|
||||
MainActive: '#FF6080',
|
||||
MainLine: '#FF7390',
|
||||
OnMain: '#030508',
|
||||
Container: '#22000d',
|
||||
ContainerHover: '#2a0011',
|
||||
ContainerActive: '#330015',
|
||||
ContainerLine: '#3b0019',
|
||||
OnContainer: '#FF99AA',
|
||||
},
|
||||
|
||||
Other: {
|
||||
FocusRing: 'rgba(0, 212, 255, 0.5)',
|
||||
Shadow: 'rgba(0, 0, 0, 1)',
|
||||
Overlay: 'rgba(3, 5, 8, 0.94)',
|
||||
},
|
||||
});
|
||||
export const lotusTerminalLightTheme = createTheme(color, {
|
||||
Background: {
|
||||
Container: '#edf0f5',
|
||||
ContainerHover: '#e2e7ef',
|
||||
ContainerActive: '#d4dae6',
|
||||
ContainerLine: '#c8d0de',
|
||||
OnContainer: '#111827',
|
||||
},
|
||||
Surface: {
|
||||
Container: '#f4f6fa',
|
||||
ContainerHover: '#e8ecf4',
|
||||
ContainerActive: '#dde2ed',
|
||||
ContainerLine: '#d0d7e5',
|
||||
OnContainer: '#111827',
|
||||
},
|
||||
SurfaceVariant: {
|
||||
Container: '#e2e7ef',
|
||||
ContainerHover: '#d4dae6',
|
||||
ContainerActive: '#c8d0de',
|
||||
ContainerLine: '#bdc6d6',
|
||||
OnContainer: '#2d3d56',
|
||||
},
|
||||
Primary: {
|
||||
Main: '#c44e00',
|
||||
MainHover: '#a33f00',
|
||||
MainActive: '#8f3700',
|
||||
MainLine: '#d45800',
|
||||
OnMain: '#ffffff',
|
||||
Container: 'rgba(196,78,0,0.10)',
|
||||
ContainerHover: 'rgba(196,78,0,0.16)',
|
||||
ContainerActive: 'rgba(196,78,0,0.22)',
|
||||
ContainerLine: 'rgba(196,78,0,0.28)',
|
||||
OnContainer: '#6b2200',
|
||||
},
|
||||
Secondary: {
|
||||
Main: '#0062b8',
|
||||
MainHover: '#0052a0',
|
||||
MainActive: '#004488',
|
||||
MainLine: '#0070cc',
|
||||
OnMain: '#ffffff',
|
||||
Container: 'rgba(0,98,184,0.10)',
|
||||
ContainerHover: 'rgba(0,98,184,0.16)',
|
||||
ContainerActive: 'rgba(0,98,184,0.22)',
|
||||
ContainerLine: 'rgba(0,98,184,0.28)',
|
||||
OnContainer: '#003580',
|
||||
},
|
||||
Success: {
|
||||
Main: '#006d35',
|
||||
MainHover: '#005c2c',
|
||||
MainActive: '#004a22',
|
||||
MainLine: '#007d3e',
|
||||
OnMain: '#ffffff',
|
||||
Container: 'rgba(0,109,53,0.10)',
|
||||
ContainerHover: 'rgba(0,109,53,0.16)',
|
||||
ContainerActive: 'rgba(0,109,53,0.22)',
|
||||
ContainerLine: 'rgba(0,109,53,0.28)',
|
||||
OnContainer: '#003520',
|
||||
},
|
||||
Warning: {
|
||||
Main: '#8a5a00',
|
||||
MainHover: '#7a4e00',
|
||||
MainActive: '#6a4400',
|
||||
MainLine: '#9a6600',
|
||||
OnMain: '#ffffff',
|
||||
Container: 'rgba(138,90,0,0.10)',
|
||||
ContainerHover: 'rgba(138,90,0,0.16)',
|
||||
ContainerActive: 'rgba(138,90,0,0.22)',
|
||||
ContainerLine: 'rgba(138,90,0,0.28)',
|
||||
OnContainer: '#4a3000',
|
||||
},
|
||||
Critical: {
|
||||
Main: '#b5001f',
|
||||
MainHover: '#9a0019',
|
||||
MainActive: '#800015',
|
||||
MainLine: '#cc0023',
|
||||
OnMain: '#ffffff',
|
||||
Container: 'rgba(181,0,31,0.10)',
|
||||
ContainerHover: 'rgba(181,0,31,0.16)',
|
||||
ContainerActive: 'rgba(181,0,31,0.22)',
|
||||
ContainerLine: 'rgba(181,0,31,0.28)',
|
||||
OnContainer: '#600010',
|
||||
},
|
||||
Other: {
|
||||
FocusRing: 'rgba(0, 98, 184, 0.50)',
|
||||
Shadow: 'rgba(0, 0, 0, 0.15)',
|
||||
Overlay: 'rgba(237, 240, 245, 0.97)',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
const BOOT_MESSAGES = [
|
||||
'╔════════════════════════════════════════════════════╗',
|
||||
'║ LOTUS CHAT — LOTUSGUILD PLATFORM ║',
|
||||
'║ SECURE MATRIX CLIENT — TERMINAL MODE ACTIVE ║',
|
||||
'╚════════════════════════════════════════════════════╝',
|
||||
'',
|
||||
'[ OK ] Kernel modules loaded',
|
||||
'[ OK ] Network interfaces configured',
|
||||
'[ OK ] DNS resolution active',
|
||||
'[ OK ] TLS certificate verified — matrix.lotusguild.org',
|
||||
'[ OK ] Matrix homeserver connected',
|
||||
'[ OK ] E2EE Olm/Megolm keys loaded',
|
||||
'[ OK ] Cross-signing keys verified',
|
||||
'[ OK ] Room state synchronized',
|
||||
'[ OK ] Media proxy available',
|
||||
'[ OK ] Notification service active',
|
||||
'[ OK ] Terminal interface rendered',
|
||||
'',
|
||||
'> ALL SYSTEMS NOMINAL — LOTUS CHAT',
|
||||
'> TYPE /help FOR COMMANDS',
|
||||
'',
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'lt_booted_lotus-chat';
|
||||
|
||||
export function resetBootSequence(): void {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function runLotusBootSequence(force = false): void {
|
||||
if (!force && sessionStorage.getItem(STORAGE_KEY)) return;
|
||||
sessionStorage.setItem(STORAGE_KEY, '1');
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'lt-boot';
|
||||
overlay.style.cssText = [
|
||||
'position:fixed',
|
||||
'inset:0',
|
||||
'background:#000',
|
||||
'z-index:10100',
|
||||
'display:flex',
|
||||
'align-items:flex-start',
|
||||
'justify-content:flex-start',
|
||||
'padding:3rem',
|
||||
"font-family:'JetBrains Mono','Fira Code','Courier New',monospace",
|
||||
].join(';');
|
||||
|
||||
const pre = document.createElement('pre');
|
||||
pre.style.cssText = [
|
||||
'font-family:inherit',
|
||||
'font-size:0.78rem',
|
||||
'color:#00FF88',
|
||||
'text-shadow:0 0 6px #00FF88,0 0 16px rgba(0,255,136,0.45)',
|
||||
'line-height:1.7',
|
||||
'white-space:pre-wrap',
|
||||
'overflow:hidden',
|
||||
].join(';');
|
||||
overlay.appendChild(pre);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
let i = 0;
|
||||
let text = '';
|
||||
const interval = setInterval(() => {
|
||||
if (i >= BOOT_MESSAGES.length) {
|
||||
clearInterval(interval);
|
||||
setTimeout(() => {
|
||||
overlay.style.transition = 'opacity 0.5s ease';
|
||||
overlay.style.opacity = '0';
|
||||
setTimeout(() => overlay.remove(), 500);
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
text += BOOT_MESSAGES[i] + '\n';
|
||||
pre.textContent = text;
|
||||
i++;
|
||||
}, 65);
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
|
||||
|
||||
const glitch1 = keyframes({
|
||||
'0%, 90%, 100%': { clipPath: 'inset(0)', transform: 'skewX(0)' },
|
||||
'92%': { clipPath: 'inset(15% 0 72% 0)', transform: 'skewX(-4deg)' },
|
||||
'94%': { clipPath: 'inset(54% 0 22% 0)', transform: 'skewX(3deg)' },
|
||||
'96%': { clipPath: 'inset(30% 0 48% 0)', transform: 'skewX(-2deg)' },
|
||||
});
|
||||
|
||||
const glitch2 = keyframes({
|
||||
'0%, 90%, 100%': { clipPath: 'inset(0)', transform: 'skewX(0)' },
|
||||
'92%': { clipPath: 'inset(60% 0 8% 0)', transform: 'skewX(3deg)' },
|
||||
'94%': { clipPath: 'inset(8% 0 68% 0)', transform: 'skewX(-3deg)' },
|
||||
'96%': { clipPath: 'inset(42% 0 38% 0)', transform: 'skewX(1deg)' },
|
||||
});
|
||||
|
||||
export const lotusTerminalBodyClass = style({
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace",
|
||||
letterSpacing: '0.02em',
|
||||
// Dot-grid background — TDS exact (on body since we're scoped to body class)
|
||||
backgroundColor: '#030508',
|
||||
backgroundImage: 'radial-gradient(circle, rgba(0,212,255,0.055) 1px, transparent 1px)',
|
||||
backgroundSize: '28px 28px',
|
||||
vars: {
|
||||
// Backgrounds
|
||||
'--lt-bg-primary': '#030508',
|
||||
'--lt-bg-secondary': '#060c14',
|
||||
'--lt-bg-tertiary': '#0d1520',
|
||||
'--lt-bg-card': '#07101a',
|
||||
'--lt-bg-terminal': '#010304',
|
||||
// Accent — Orange
|
||||
'--lt-accent-orange': '#FF6B00',
|
||||
'--lt-accent-orange-bright': '#FF8C2B',
|
||||
'--lt-accent-orange-dim': 'rgba(255,107,0,0.12)',
|
||||
'--lt-accent-orange-border': 'rgba(255,107,0,0.35)',
|
||||
// Accent — Amber
|
||||
'--lt-accent-amber': '#FFB300',
|
||||
'--lt-accent-amber-dim': 'rgba(255,179,0,0.10)',
|
||||
// Accent — Cyan
|
||||
'--lt-accent-cyan': '#00D4FF',
|
||||
'--lt-accent-cyan-bright': '#33DFFF',
|
||||
'--lt-accent-cyan-dim': 'rgba(0,212,255,0.10)',
|
||||
'--lt-accent-cyan-border': 'rgba(0,212,255,0.22)',
|
||||
// Accent — Green
|
||||
'--lt-accent-green': '#00FF88',
|
||||
'--lt-accent-green-bright': '#33FFAA',
|
||||
'--lt-accent-green-dim': 'rgba(0,255,136,0.10)',
|
||||
'--lt-accent-green-border': 'rgba(0,255,136,0.22)',
|
||||
// Accent — Red
|
||||
'--lt-accent-red': '#FF2D55',
|
||||
'--lt-accent-red-dim': 'rgba(255,45,85,0.12)',
|
||||
// Accent — Gold
|
||||
'--lt-accent-gold': '#FFD700',
|
||||
'--lt-accent-gold-dim': 'rgba(255,215,0,0.10)',
|
||||
// Accent — Purple
|
||||
'--lt-accent-purple': '#BF5FFF',
|
||||
'--lt-accent-purple-dim': 'rgba(191,95,255,0.10)',
|
||||
// Text
|
||||
'--lt-text-primary': '#c4d9ee',
|
||||
'--lt-text-secondary': '#7fa3bf',
|
||||
'--lt-text-muted': '#3e607a',
|
||||
'--lt-text-dim': '#1e3347',
|
||||
// Borders
|
||||
'--lt-border-color': 'rgba(0,212,255,0.16)',
|
||||
'--lt-border-color-hi': '#00D4FF',
|
||||
'--lt-border-color-dim': 'rgba(0,212,255,0.07)',
|
||||
// Glows — text
|
||||
'--lt-glow-orange': '0 0 6px #FF6B00, 0 0 16px rgba(255,107,0,0.55)',
|
||||
'--lt-glow-orange-intense':'0 0 8px #FF6B00, 0 0 22px #FF6B00, 0 0 40px rgba(255,107,0,0.45)',
|
||||
'--lt-glow-cyan': '0 0 6px #00D4FF, 0 0 16px rgba(0,212,255,0.45)',
|
||||
'--lt-glow-cyan-intense': '0 0 8px #00D4FF, 0 0 22px #00D4FF, 0 0 38px rgba(0,212,255,0.35)',
|
||||
'--lt-glow-green': '0 0 6px #00FF88, 0 0 16px rgba(0,255,136,0.45)',
|
||||
'--lt-glow-green-intense': '0 0 8px #00FF88, 0 0 22px #00FF88, 0 0 36px rgba(0,255,136,0.35)',
|
||||
'--lt-glow-amber': '0 0 6px #FFB300, 0 0 14px rgba(255,179,0,0.40)',
|
||||
'--lt-glow-amber-intense': '0 0 8px #FFB300, 0 0 20px #FFB300, 0 0 34px rgba(255,179,0,0.45)',
|
||||
'--lt-glow-red': '0 0 6px #FF2D55, 0 0 16px rgba(255,45,85,0.45)',
|
||||
// Glows — box
|
||||
'--lt-box-glow-orange': '0 0 18px rgba(255,107,0,0.22), 0 0 36px rgba(255,107,0,0.08)',
|
||||
'--lt-box-glow-cyan': '0 0 18px rgba(0,212,255,0.18), 0 0 36px rgba(0,212,255,0.06)',
|
||||
'--lt-box-glow-green': '0 0 18px rgba(0,255,136,0.18), 0 0 36px rgba(0,255,136,0.06)',
|
||||
'--lt-box-glow-red': '0 0 18px rgba(255,45,85,0.22), 0 0 36px rgba(255,45,85,0.08)',
|
||||
'--lt-box-glow-amber': '0 0 18px rgba(255,179,0,0.18), 0 0 36px rgba(255,179,0,0.06)',
|
||||
// Fonts
|
||||
'--lt-font-mono': "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace",
|
||||
'--lt-font-display': "'JetBrains Mono', 'Fira Code', 'Courier New', monospace",
|
||||
'--lt-font-crt': "'VT323', 'Courier New', monospace",
|
||||
} as any,
|
||||
});
|
||||
|
||||
// Font on all descendants
|
||||
globalStyle(`body.${lotusTerminalBodyClass} *`, {
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace",
|
||||
});
|
||||
|
||||
// Scanlines — EXACT from TDS body::before
|
||||
globalStyle(`body.${lotusTerminalBodyClass}::before`, {
|
||||
content: "''",
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'repeating-linear-gradient(0deg, rgba(0,0,0,0.07) 0px, rgba(0,0,0,0.07) 1px, transparent 1px, transparent 3px)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 9999,
|
||||
});
|
||||
|
||||
// Vignette — EXACT from TDS body::after
|
||||
globalStyle(`body.${lotusTerminalBodyClass}::after`, {
|
||||
content: "''",
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.60) 100%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 9998,
|
||||
});
|
||||
|
||||
// Orange caret
|
||||
globalStyle(`body.${lotusTerminalBodyClass} input, body.${lotusTerminalBodyClass} textarea`, {
|
||||
caretColor: '#FF6B00',
|
||||
});
|
||||
|
||||
// Glitch class support
|
||||
globalStyle(`body.${lotusTerminalBodyClass} .lt-glitch`, { position: 'relative' });
|
||||
|
||||
globalStyle(
|
||||
`body.${lotusTerminalBodyClass} .lt-glitch::before, body.${lotusTerminalBodyClass} .lt-glitch::after`,
|
||||
{
|
||||
content: 'attr(data-text)' as any,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
}
|
||||
);
|
||||
|
||||
globalStyle(`body.${lotusTerminalBodyClass} .lt-glitch::before`, {
|
||||
color: '#00D4FF',
|
||||
opacity: 0.65,
|
||||
animationName: glitch1,
|
||||
animationDuration: '4s',
|
||||
animationIterationCount: 'infinite',
|
||||
});
|
||||
|
||||
globalStyle(`body.${lotusTerminalBodyClass} .lt-glitch::after`, {
|
||||
color: '#FF6B00',
|
||||
animationName: glitch2,
|
||||
animationDuration: '4s',
|
||||
animationIterationCount: 'infinite',
|
||||
});
|
||||
|
||||
// Scrollbar styling — matching TDS
|
||||
globalStyle(`body.${lotusTerminalBodyClass} ::-webkit-scrollbar`, { width: '6px', height: '6px' });
|
||||
globalStyle(`body.${lotusTerminalBodyClass} ::-webkit-scrollbar-track`, { background: '#030508' });
|
||||
globalStyle(`body.${lotusTerminalBodyClass} ::-webkit-scrollbar-thumb`, {
|
||||
background: 'rgba(0,212,255,0.25)',
|
||||
borderRadius: '3px',
|
||||
});
|
||||
globalStyle(`body.${lotusTerminalBodyClass} ::-webkit-scrollbar-thumb:hover`, {
|
||||
background: 'rgba(0,212,255,0.50)',
|
||||
});
|
||||
|
||||
// ── Text selection — orange highlight (TDS aesthetic) ─────────────────────
|
||||
globalStyle(`body.${lotusTerminalBodyClass} ::selection`, {
|
||||
background: 'rgba(255,107,0,0.28)',
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
// ── Links — cyan primary, orange on hover with glow ────────────────────────
|
||||
globalStyle(`body.${lotusTerminalBodyClass} a`, {
|
||||
color: '#00D4FF',
|
||||
textDecoration: 'none',
|
||||
transition: 'color 0.12s ease, text-shadow 0.12s ease',
|
||||
});
|
||||
globalStyle(`body.${lotusTerminalBodyClass} a:hover`, {
|
||||
color: '#FF6B00',
|
||||
textShadow: '0 0 6px #FF6B00, 0 0 16px rgba(255,107,0,0.55)',
|
||||
});
|
||||
globalStyle(`body.${lotusTerminalBodyClass} a:focus-visible`, {
|
||||
outline: '2px solid #00D4FF',
|
||||
outlineOffset: '2px',
|
||||
});
|
||||
|
||||
// ── Code blocks — TDS exact: green text, dark bg, left border ──────────────
|
||||
globalStyle(`body.${lotusTerminalBodyClass} code`, {
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace",
|
||||
fontSize: '0.8em',
|
||||
background: '#010304',
|
||||
border: '1px solid rgba(0,212,255,0.16)',
|
||||
color: '#00FF88',
|
||||
padding: '0.1em 0.4em',
|
||||
borderRadius: '2px',
|
||||
});
|
||||
globalStyle(`body.${lotusTerminalBodyClass} pre`, {
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace",
|
||||
fontSize: '0.8rem',
|
||||
background: '#010304',
|
||||
border: '1px solid rgba(0,212,255,0.16)',
|
||||
borderLeft: '2px solid #00FF88',
|
||||
color: '#00FF88',
|
||||
textShadow: '0 0 6px rgba(0,255,136,0.35)',
|
||||
padding: '1rem',
|
||||
overflowX: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: 1.6,
|
||||
});
|
||||
globalStyle(`body.${lotusTerminalBodyClass} pre code`, {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
color: 'inherit',
|
||||
textShadow: 'inherit',
|
||||
});
|
||||
|
||||
// ── Inline text semantics ──────────────────────────────────────────────────
|
||||
globalStyle(`body.${lotusTerminalBodyClass} strong, body.${lotusTerminalBodyClass} b`, {
|
||||
color: '#FF6B00',
|
||||
fontWeight: '700',
|
||||
});
|
||||
globalStyle(`body.${lotusTerminalBodyClass} em, body.${lotusTerminalBodyClass} i`, {
|
||||
color: '#00D4FF',
|
||||
fontStyle: 'italic',
|
||||
});
|
||||
globalStyle(`body.${lotusTerminalBodyClass} mark`, {
|
||||
background: 'rgba(255,179,0,0.25)',
|
||||
color: '#FFB300',
|
||||
padding: '0 0.25em',
|
||||
});
|
||||
globalStyle(`body.${lotusTerminalBodyClass} del, body.${lotusTerminalBodyClass} s`, {
|
||||
color: '#FF2D55',
|
||||
opacity: 0.7,
|
||||
});
|
||||
|
||||
// ── Blockquote — TDS orange left border (matches reply quotes in chat) ──────
|
||||
globalStyle(`body.${lotusTerminalBodyClass} blockquote`, {
|
||||
borderLeft: '2px solid #FF6B00',
|
||||
background: 'rgba(255,107,0,0.05)',
|
||||
color: '#7fa3bf',
|
||||
padding: '0.4rem 0.75rem',
|
||||
margin: '0.25rem 0',
|
||||
fontStyle: 'italic',
|
||||
});
|
||||
|
||||
// ── Horizontal rule — cyan dim glow ────────────────────────────────────────
|
||||
globalStyle(`body.${lotusTerminalBodyClass} hr`, {
|
||||
border: 'none',
|
||||
borderTop: '1px solid rgba(0,212,255,0.22)',
|
||||
boxShadow: '0 0 6px rgba(0,212,255,0.15)',
|
||||
margin: '1rem 0',
|
||||
});
|
||||
|
||||
// ── Input / textarea / contenteditable focus — orange glow ─────────────────
|
||||
globalStyle(
|
||||
`body.${lotusTerminalBodyClass} input:focus,` +
|
||||
`body.${lotusTerminalBodyClass} textarea:focus`,
|
||||
{
|
||||
outline: 'none',
|
||||
borderColor: '#FF6B00',
|
||||
boxShadow: '0 0 0 2px rgba(255,107,0,0.25), 0 0 12px rgba(255,107,0,0.12)',
|
||||
}
|
||||
);
|
||||
globalStyle(`body.${lotusTerminalBodyClass} [contenteditable="true"]:focus`, {
|
||||
outline: 'none',
|
||||
boxShadow: '0 0 0 1px rgba(255,107,0,0.45), 0 0 12px rgba(255,107,0,0.12)',
|
||||
});
|
||||
|
||||
// ── Tables — TDS style ──────────────────────────────────────────────────────
|
||||
globalStyle(`body.${lotusTerminalBodyClass} table`, {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
fontSize: '0.8rem',
|
||||
border: '1px solid rgba(0,212,255,0.16)',
|
||||
});
|
||||
globalStyle(`body.${lotusTerminalBodyClass} th`, {
|
||||
color: '#FF6B00',
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.10em',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderBottom: '1px solid rgba(0,212,255,0.22)',
|
||||
background: 'rgba(255,107,0,0.06)',
|
||||
textAlign: 'left',
|
||||
});
|
||||
globalStyle(`body.${lotusTerminalBodyClass} td`, {
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderBottom: '1px solid rgba(0,212,255,0.07)',
|
||||
color: '#c4d9ee',
|
||||
});
|
||||
globalStyle(`body.${lotusTerminalBodyClass} tr:hover td`, {
|
||||
background: 'rgba(255,107,0,0.035)',
|
||||
});
|
||||
|
||||
// ── Lists — add TDS prefix character ───────────────────────────────────────
|
||||
globalStyle(`body.${lotusTerminalBodyClass} ul li::marker`, {
|
||||
color: '#00D4FF',
|
||||
content: '"▸ "',
|
||||
});
|
||||
globalStyle(`body.${lotusTerminalBodyClass} ol li::marker`, {
|
||||
color: '#FF6B00',
|
||||
});
|
||||
|
||||
// ── Structural elements — scoped TDS layout chrome ────────────────────────
|
||||
|
||||
// Sidebar/nav: subtle cyan right border
|
||||
globalStyle(`body.${lotusTerminalBodyClass} nav`, {
|
||||
borderRight: '1px solid rgba(0,212,255,0.10)',
|
||||
});
|
||||
|
||||
// Page headers / toolbars: subtle bottom border glow
|
||||
globalStyle(`body.${lotusTerminalBodyClass} header`, {
|
||||
borderBottom: '1px solid rgba(0,212,255,0.14)',
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.55)',
|
||||
});
|
||||
|
||||
// kbd — TDS terminal key style
|
||||
globalStyle(`body.${lotusTerminalBodyClass} kbd`, {
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Courier New', monospace",
|
||||
fontSize: '0.75em',
|
||||
background: '#0d1520',
|
||||
border: '1px solid rgba(0,212,255,0.35)',
|
||||
borderBottom: '2px solid rgba(0,212,255,0.50)',
|
||||
borderRadius: '3px',
|
||||
padding: '0.1em 0.45em',
|
||||
color: '#00D4FF',
|
||||
textShadow: '0 0 4px rgba(0,212,255,0.4)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.6)',
|
||||
});
|
||||
|
||||
// Tooltip / title popups: if browser renders them, style if possible
|
||||
globalStyle(`body.${lotusTerminalBodyClass} [title]`, {
|
||||
textDecoration: 'underline dotted rgba(0,212,255,0.35)',
|
||||
cursor: 'help',
|
||||
});
|
||||
|
||||
// abbr
|
||||
globalStyle(`body.${lotusTerminalBodyClass} abbr[title]`, {
|
||||
textDecoration: 'underline dotted #00D4FF',
|
||||
});
|
||||
|
||||
// time elements — amber muted
|
||||
globalStyle(`body.${lotusTerminalBodyClass} time`, {
|
||||
color: '#7fa3bf',
|
||||
fontSize: '0.78em',
|
||||
});
|
||||
|
||||
// Images: cyan border on hover
|
||||
globalStyle(`body.${lotusTerminalBodyClass} img:hover`, {
|
||||
outline: '1px solid rgba(0,212,255,0.35)',
|
||||
boxShadow: '0 0 12px rgba(0,212,255,0.12)',
|
||||
});
|
||||
|
||||
// Ensure body text color is correct regardless of vanilla-extract theme loading order
|
||||
globalStyle(`body.${lotusTerminalBodyClass}`, {
|
||||
color: '#c4d9ee',
|
||||
});
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// LIGHT MODE — TDS "daylight reading" variant
|
||||
// html[data-theme="light"] + body.lotusTerminalBodyClass
|
||||
// Mirrors base.css:3614–3684: desaturated accents, off-white bg, no CRT
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass}`, {
|
||||
backgroundColor: '#edf0f5',
|
||||
backgroundImage: 'radial-gradient(circle, rgba(90,110,150,0.14) 1px, transparent 1px)',
|
||||
color: '#111827',
|
||||
vars: {
|
||||
'--lt-bg-primary': '#edf0f5',
|
||||
'--lt-bg-secondary': '#e2e7ef',
|
||||
'--lt-bg-tertiary': '#d4dae6',
|
||||
'--lt-bg-card': '#ffffff',
|
||||
'--lt-bg-terminal': '#f4f6fa',
|
||||
'--lt-accent-orange': '#c44e00',
|
||||
'--lt-accent-orange-bright':'#d45800',
|
||||
'--lt-accent-orange-dim': 'rgba(196,78,0,0.10)',
|
||||
'--lt-accent-orange-border':'rgba(196,78,0,0.28)',
|
||||
'--lt-accent-amber': '#8a5a00',
|
||||
'--lt-accent-amber-dim': 'rgba(138,90,0,0.10)',
|
||||
'--lt-accent-cyan': '#0062b8',
|
||||
'--lt-accent-cyan-bright': '#0070cc',
|
||||
'--lt-accent-cyan-dim': 'rgba(0,98,184,0.10)',
|
||||
'--lt-accent-cyan-border': 'rgba(0,98,184,0.22)',
|
||||
'--lt-accent-green': '#006d35',
|
||||
'--lt-accent-green-bright': '#007d3e',
|
||||
'--lt-accent-green-dim': 'rgba(0,109,53,0.10)',
|
||||
'--lt-accent-green-border': 'rgba(0,109,53,0.22)',
|
||||
'--lt-accent-red': '#b5001f',
|
||||
'--lt-accent-red-dim': 'rgba(181,0,31,0.12)',
|
||||
'--lt-accent-gold': '#8a5a00',
|
||||
'--lt-accent-gold-dim': 'rgba(138,90,0,0.10)',
|
||||
'--lt-accent-purple': '#6b2fb8',
|
||||
'--lt-accent-purple-dim': 'rgba(107,47,184,0.10)',
|
||||
'--lt-text-primary': '#111827',
|
||||
'--lt-text-secondary': '#2d3d56',
|
||||
'--lt-text-muted': '#5a6e8c',
|
||||
'--lt-text-dim': '#8a9ab8',
|
||||
'--lt-border-color': 'rgba(50,80,130,0.18)',
|
||||
'--lt-border-color-hi': '#0062b8',
|
||||
'--lt-border-color-dim': 'rgba(50,80,130,0.09)',
|
||||
'--lt-glow-orange': '0 0 0 1px rgba(196,78,0,0.25), 0 1px 6px rgba(196,78,0,0.18)',
|
||||
'--lt-glow-orange-intense': '0 0 0 2px rgba(196,78,0,0.35), 0 2px 10px rgba(196,78,0,0.25)',
|
||||
'--lt-glow-cyan': '0 0 0 1px rgba(0,98,184,0.25), 0 1px 6px rgba(0,98,184,0.18)',
|
||||
'--lt-glow-cyan-intense': '0 0 0 2px rgba(0,98,184,0.35), 0 2px 10px rgba(0,98,184,0.25)',
|
||||
'--lt-glow-green': '0 0 0 1px rgba(0,109,53,0.25), 0 1px 6px rgba(0,109,53,0.18)',
|
||||
'--lt-glow-green-intense': '0 0 0 2px rgba(0,109,53,0.35), 0 2px 10px rgba(0,109,53,0.25)',
|
||||
'--lt-glow-amber': '0 0 0 1px rgba(138,90,0,0.25), 0 1px 6px rgba(138,90,0,0.18)',
|
||||
'--lt-glow-amber-intense': '0 0 0 2px rgba(138,90,0,0.35), 0 2px 10px rgba(138,90,0,0.25)',
|
||||
'--lt-glow-red': '0 0 0 1px rgba(181,0,31,0.25), 0 1px 6px rgba(181,0,31,0.18)',
|
||||
'--lt-box-glow-orange': '0 0 0 2px rgba(196,78,0,0.22), 0 2px 8px rgba(196,78,0,0.12)',
|
||||
'--lt-box-glow-cyan': '0 0 0 2px rgba(0,98,184,0.22), 0 2px 8px rgba(0,98,184,0.12)',
|
||||
'--lt-box-glow-green': '0 0 0 2px rgba(0,109,53,0.22), 0 2px 8px rgba(0,109,53,0.12)',
|
||||
'--lt-box-glow-red': '0 0 0 2px rgba(181,0,31,0.22), 0 2px 8px rgba(181,0,31,0.12)',
|
||||
'--lt-box-glow-amber': '0 0 0 2px rgba(138,90,0,0.22), 0 2px 8px rgba(138,90,0,0.12)',
|
||||
} as any,
|
||||
});
|
||||
|
||||
// Scanlines + vignette: OFF in light mode (base.css:3676-3678)
|
||||
globalStyle(
|
||||
`html[data-theme="light"] body.${lotusTerminalBodyClass}::before,` +
|
||||
`html[data-theme="light"] body.${lotusTerminalBodyClass}::after`,
|
||||
{ display: 'none' }
|
||||
);
|
||||
|
||||
// Caret
|
||||
globalStyle(
|
||||
`html[data-theme="light"] body.${lotusTerminalBodyClass} input,` +
|
||||
`html[data-theme="light"] body.${lotusTerminalBodyClass} textarea`,
|
||||
{ caretColor: '#c44e00' }
|
||||
);
|
||||
|
||||
// Scrollbar
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} ::-webkit-scrollbar-track`, {
|
||||
background: '#e2e7ef',
|
||||
});
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} ::-webkit-scrollbar-thumb`, {
|
||||
background: 'rgba(0,98,184,0.25)',
|
||||
borderRadius: '3px',
|
||||
});
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} ::-webkit-scrollbar-thumb:hover`, {
|
||||
background: 'rgba(0,98,184,0.50)',
|
||||
});
|
||||
|
||||
// Selection
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} ::selection`, {
|
||||
background: 'rgba(196,78,0,0.18)',
|
||||
color: '#030508',
|
||||
});
|
||||
|
||||
// Links
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} a`, { color: '#0062b8' });
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} a:hover`, {
|
||||
color: '#c44e00',
|
||||
textShadow: 'none',
|
||||
});
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} a:focus-visible`, {
|
||||
outline: '2px solid #0062b8',
|
||||
outlineOffset: '2px',
|
||||
});
|
||||
|
||||
// Code
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} code`, {
|
||||
background: '#f0f4f8',
|
||||
border: '1px solid rgba(0,98,184,0.18)',
|
||||
color: '#006d35',
|
||||
});
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} pre`, {
|
||||
background: '#f0f4f8',
|
||||
border: '1px solid rgba(0,98,184,0.14)',
|
||||
borderLeft: '2px solid #006d35',
|
||||
color: '#006d35',
|
||||
textShadow: 'none',
|
||||
});
|
||||
|
||||
// Inline semantics
|
||||
globalStyle(
|
||||
`html[data-theme="light"] body.${lotusTerminalBodyClass} strong,` +
|
||||
`html[data-theme="light"] body.${lotusTerminalBodyClass} b`,
|
||||
{ color: '#c44e00' }
|
||||
);
|
||||
globalStyle(
|
||||
`html[data-theme="light"] body.${lotusTerminalBodyClass} em,` +
|
||||
`html[data-theme="light"] body.${lotusTerminalBodyClass} i`,
|
||||
{ color: '#0062b8' }
|
||||
);
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} mark`, {
|
||||
background: 'rgba(138,90,0,0.15)',
|
||||
color: '#8a5a00',
|
||||
});
|
||||
globalStyle(
|
||||
`html[data-theme="light"] body.${lotusTerminalBodyClass} del,` +
|
||||
`html[data-theme="light"] body.${lotusTerminalBodyClass} s`,
|
||||
{ color: '#b5001f' }
|
||||
);
|
||||
|
||||
// Blockquote
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} blockquote`, {
|
||||
borderLeft: '2px solid #c44e00',
|
||||
background: 'rgba(196,78,0,0.05)',
|
||||
color: '#5a6e8c',
|
||||
});
|
||||
|
||||
// HR
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} hr`, {
|
||||
borderTop: '1px solid rgba(0,98,184,0.18)',
|
||||
boxShadow: 'none',
|
||||
});
|
||||
|
||||
// Input focus
|
||||
globalStyle(
|
||||
`html[data-theme="light"] body.${lotusTerminalBodyClass} input:focus,` +
|
||||
`html[data-theme="light"] body.${lotusTerminalBodyClass} textarea:focus`,
|
||||
{
|
||||
borderColor: '#c44e00',
|
||||
boxShadow: '0 0 0 2px rgba(196,78,0,0.22), 0 1px 6px rgba(196,78,0,0.12)',
|
||||
}
|
||||
);
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} [contenteditable="true"]:focus`, {
|
||||
boxShadow: '0 0 0 1px rgba(196,78,0,0.40), 0 1px 6px rgba(196,78,0,0.10)',
|
||||
});
|
||||
|
||||
// Tables
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} table`, {
|
||||
border: '1px solid rgba(0,98,184,0.18)',
|
||||
});
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} th`, {
|
||||
color: '#c44e00',
|
||||
background: 'rgba(196,78,0,0.06)',
|
||||
borderBottom: '1px solid rgba(0,98,184,0.18)',
|
||||
textShadow: 'none',
|
||||
});
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} td`, {
|
||||
color: '#2d3d56',
|
||||
borderBottom: '1px solid rgba(0,98,184,0.08)',
|
||||
});
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} tr:hover td`, {
|
||||
background: 'rgba(196,78,0,0.03)',
|
||||
});
|
||||
|
||||
// Lists
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} ul li::marker`, {
|
||||
color: '#0062b8',
|
||||
content: '"▸ "',
|
||||
});
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} ol li::marker`, {
|
||||
color: '#c44e00',
|
||||
});
|
||||
|
||||
// Structural chrome
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} nav`, {
|
||||
borderRight: '1px solid rgba(0,98,184,0.12)',
|
||||
});
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} header`, {
|
||||
borderBottom: '1px solid rgba(0,98,184,0.14)',
|
||||
boxShadow: '0 1px 8px rgba(0,0,0,0.07)',
|
||||
});
|
||||
|
||||
// kbd
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} kbd`, {
|
||||
background: '#e2e7ef',
|
||||
border: '1px solid rgba(0,98,184,0.28)',
|
||||
borderBottom: '2px solid rgba(0,98,184,0.42)',
|
||||
color: '#0062b8',
|
||||
textShadow: 'none',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.10)',
|
||||
});
|
||||
|
||||
// abbr / time / img
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} abbr[title]`, {
|
||||
textDecoration: 'underline dotted #0062b8',
|
||||
});
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} time`, {
|
||||
color: '#5a6e8c',
|
||||
});
|
||||
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} img:hover`, {
|
||||
outline: '1px solid rgba(0,98,184,0.28)',
|
||||
boxShadow: '0 1px 8px rgba(0,98,184,0.08)',
|
||||
});
|
||||