Compare commits

..

4 Commits

Author SHA1 Message Date
Ajay Bura a95962f991 allow nested list in markdown 2026-05-13 07:28:37 +05:30
Ajay Bura 0295f785e6 allow codeblock plaintext inside codeblock by extending fence count 2026-05-09 19:22:57 +05:30
Ajay Bura 1fb7e0ebf1 remove unused imports 2026-05-09 18:41:34 +05:30
Ajay Bura 7c349978ac fix crash when editing message with empty trailing heading 2026-05-09 18:41:15 +05:30
99 changed files with 363 additions and 3909 deletions
-17
View File
@@ -1,17 +0,0 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"matrix.lotusguild.org"
],
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [],
"rooms": [],
"servers": []
},
"hashRouter": {
"enabled": false,
"basename": "/"
}
}
-17
View File
@@ -1,17 +0,0 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"matrix.lotusguild.org"
],
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [],
"rooms": [],
"servers": []
},
"hashRouter": {
"enabled": false,
"basename": "/"
}
}
+5 -11
View File
@@ -16,22 +16,16 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Download pr number
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }}
name: pr
- name: Validate and output pr number
- name: Output pr number
id: pr
run: |
PR_ID=$(<pr.txt)
if ! [[ "${PR_ID}" =~ ^[0-9]+$ ]]; then
echo "::error::pr.txt contains non-numeric content: ${PR_ID}"
exit 1
fi
echo "id=${PR_ID}" >> "${GITHUB_OUTPUT}"
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
- name: Download artifact
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }}
@@ -48,7 +42,7 @@ jobs:
enable-pull-request-comment: false
enable-commit-comment: false
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN_PR }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
timeout-minutes: 1
- name: Comment preview on PR
+102 -52
View File
@@ -1,65 +1,115 @@
# Lotus Chat
# 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>
A Matrix client for [Lotus Guild](https://lotusguild.org) — forked from [Cinny](https://github.com/cinnyapp/cinny) v4.11.1.
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)
Deployed at [chat.lotusguild.org](https://chat.lotusguild.org).
> [!IMPORTANT]
We are currently in the [process of replacing the matrix-js-sdk](https://github.com/cinnyapp/cinny/issues/257#issuecomment-3714406704) with our own SDK. As a result, we will not be accepting any pull requests until further notice.
Thank you for your understanding.
---
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380">
## Changes from upstream Cinny
## 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.
### Branding & Identity
You can also download our desktop app from the [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop).
- 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
## 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).
### LotusGuild Terminal Design System (TDS) v1.2
* The default homeservers and explore pages are defined in [`config.json`](config.json).
A full custom theme engine layered on top of Cinny's vanilla-extract theming:
* 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.
**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
* 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'`.
**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
<details><summary><b>PGP Public Key to verify tarball</b></summary>
**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
```
-----BEGIN PGP PUBLIC KEY BLOCK-----
### Voice / Video Call Improvements
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>
- **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.
- **Poll display**: `m.poll.start` events (both stable Matrix 1.7 `m.poll` content key and MSC3381 unstable `org.matrix.msc3381.poll.start`) render as read-only poll cards inside the standard message bubble — question and answer options shown. Registered as top-level event renderers AND inside the `EncryptedContent` callback so encrypted polls also display after decryption. "Open in Element to vote" note displayed. Implemented in `PollContent.tsx`.
- **Deleted message placeholder**: Redacted `m.room.message`, `m.room.encrypted`, and `m.sticker` events no longer disappear from the timeline. Instead they reach the existing `RedactedContent` component (trash icon + italic "This message has been deleted" with reason if provided), matching Element, FluffyChat, Commet, and Nheko behaviour. One-line change in the `eventRenderer` filter in `RoomTimeline.tsx`.
- **Incoming DM call notification**: When another user starts a call in a DM room, a fixed-position notification appears with the caller's avatar and display name, Answer and Decline buttons, and a Web Audio API double-pulse ring tone. The notification auto-dismisses after 30 seconds or when the caller leaves. Fully themed for both LotusGuild Terminal TDS (dark navy `#060c14` background, orange border, neon-green Answer button, JetBrains Mono font, pulsing indicator dot) and standard Cinny dark/light mode (CSS variable theming, folds `Button` with `variant="Success"/"Critical"`). Implemented in `useIncomingDmCall` hook (watches `MatrixRTCSessionManagerEvents.SessionStarted`, filters encrypted ≤2-member rooms, ignores self) and `IncomingCallNotification` component mounted inside `CallEmbedProvider` in `Router.tsx`.
- **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.
## 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).
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`.
+24 -10
View File
@@ -1,18 +1,32 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"matrix.lotusguild.org"
],
"allowCustomHomeservers": false,
"defaultHomeserver": 1,
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
"allowCustomHomeservers": true,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [],
"rooms": [],
"servers": []
"spaces": [
"#cinny-space:matrix.org",
"#community:matrix.org",
"#space:unredacted.org",
"#science-space:matrix.org",
"#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org",
"#stickers-and-emojis:tastytea.de"
],
"rooms": [
"#cinny:matrix.org",
"#freesoftware:matrix.org",
"#pcapdroid:matrix.org",
"#gentoo:matrix.org",
"#PrivSec.dev:arcticfoxes.net",
"#disroot:aria-net.org"
],
"servers": ["matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"]
},
"hashRouter": {
"enabled": false,
"basename": "/"
},
"gifApiKey": "AqqDuQwZNjYttz7Mn6ME4JH1bJIuZ5CO"
}
}
+11 -14
View File
@@ -4,36 +4,33 @@
<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>Lotus Chat</title>
<meta name="name" content="Lotus Chat" />
<meta name="author" content="Lotus Guild" />
<title>Cinny</title>
<meta name="name" content="Cinny" />
<meta name="author" content="Ajay Bura" />
<meta
name="description"
content="Lotus Chat — the Lotus Guild Matrix client. Secure, fast, and built for our community."
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."
/>
<meta
name="keywords"
content="lotus chat, lotus guild, matrix, matrix client"
content="cinny, cinnyapp, cinnychat, matrix, matrix client, matrix.org, element"
/>
<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: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:description"
content="Lotus Chat — the Lotus Guild Matrix client. Secure, fast, and built for our community."
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."
/>
<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="Lotus Chat" />
<meta name="apple-mobile-web-app-title" content="Lotus Chat" />
<meta name="application-name" content="Cinny" />
<meta name="apple-mobile-web-app-title" content="Cinny" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+38 -64
View File
@@ -1,12 +1,12 @@
{
"name": "cinny",
"version": "4.12.1",
"version": "4.11.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cinny",
"version": "4.12.1",
"version": "4.11.1",
"license": "AGPL-3.0-only",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
@@ -44,7 +44,7 @@
"linkify-react": "4.3.2",
"linkifyjs": "4.3.2",
"matrix-js-sdk": "38.2.0",
"matrix-widget-api": "1.16.1",
"matrix-widget-api": "1.13.0",
"millify": "6.1.0",
"pdfjs-dist": "4.2.67",
"prismjs": "1.30.0",
@@ -58,7 +58,7 @@
"react-i18next": "15.0.0",
"react-range": "1.8.14",
"react-router-dom": "6.30.3",
"sanitize-html": "2.17.4",
"sanitize-html": "2.12.1",
"slate": "0.123.0",
"slate-dom": "0.123.0",
"slate-history": "0.113.1",
@@ -66,7 +66,7 @@
"ua-parser-js": "1.0.35"
},
"devDependencies": {
"@element-hq/element-call-embedded": "0.19.1",
"@element-hq/element-call-embedded": "0.16.3",
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1",
@@ -80,7 +80,7 @@
"@types/react": "18.2.39",
"@types/react-dom": "18.2.17",
"@types/react-google-recaptcha": "2.1.8",
"@types/sanitize-html": "2.16.1",
"@types/sanitize-html": "2.9.0",
"@types/ua-parser-js": "0.7.36",
"@typescript-eslint/eslint-plugin": "5.46.1",
"@typescript-eslint/parser": "5.46.1",
@@ -1837,9 +1837,9 @@
}
},
"node_modules/@element-hq/element-call-embedded": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.19.1.tgz",
"integrity": "sha512-RDZY3P3LTx10ACaGhzkwh2+boNB3x54zHF/7v/cCyoQlAVfEYMhgMEb4CRTwJFwwYFe1r++6Higa0A0G5XxZ8Q==",
"version": "0.16.3",
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.16.3.tgz",
"integrity": "sha512-OViKJonDaDNVBUW9WdV9mk78/Ruh34C7XsEgt3O8D9z+64C39elbIgllHSoH5S12IRlv9RYrrV37FZLo6QWsDQ==",
"dev": true
},
"node_modules/@emotion/hash": {
@@ -5691,13 +5691,12 @@
"license": "MIT"
},
"node_modules/@types/sanitize-html": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
"integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.9.0.tgz",
"integrity": "sha512-4fP/kEcKNj2u39IzrxWYuf/FnCCwwQCpif6wwY6ROUS1EPRIfWJjGkY3HIowY1EX/VbX5e86yq8AAE7UPMgATg==",
"dev": true,
"license": "MIT",
"dependencies": {
"htmlparser2": "^10.1"
"htmlparser2": "^8.0.0"
}
},
"node_modules/@types/scheduler": {
@@ -6042,7 +6041,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"optional": true
"devOptional": true
},
"node_modules/acorn": {
"version": "8.14.0",
@@ -6886,7 +6885,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
"optional": true,
"devOptional": true,
"engines": {
"node": ">=10"
}
@@ -9499,7 +9498,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
"optional": true,
"devOptional": true,
"dependencies": {
"minipass": "^3.0.0"
},
@@ -9511,7 +9510,7 @@
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"optional": true,
"devOptional": true,
"dependencies": {
"yallist": "^4.0.0"
},
@@ -9523,7 +9522,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"optional": true
"devOptional": true
},
"node_modules/fs.realpath": {
"version": "1.0.0",
@@ -10170,9 +10169,9 @@
}
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
@@ -10180,24 +10179,11 @@
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/http-proxy-agent": {
@@ -10462,7 +10448,6 @@
"integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==",
"dev": true,
"license": "ISC",
"optional": true,
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
@@ -11318,15 +11303,6 @@
"node": ">=0.10"
}
},
"node_modules/launder": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.11.7"
}
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -12143,9 +12119,9 @@
}
},
"node_modules/matrix-widget-api": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.16.1.tgz",
"integrity": "sha512-oCfTV4xNPo02qIgveqdkIyKQjOPpsjhF3bmJBotHrhr8TsrhVa7kx8PtuiUPnQTjz0tdBle7falR2Fw8VKsedw==",
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.0.tgz",
"integrity": "sha512-+LrvwkR1izL4h2euX8PDrvG/3PZZDEd6As+lmnR3jAVwbFJtU5iTnwmZGnCca9ddngCvXvAHkcpJBEPyPTZneQ==",
"license": "Apache-2.0",
"dependencies": {
"@types/events": "^3.0.0",
@@ -12308,7 +12284,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
"optional": true,
"devOptional": true,
"engines": {
"node": ">=8"
}
@@ -12317,7 +12293,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
"optional": true,
"devOptional": true,
"dependencies": {
"minipass": "^3.0.0",
"yallist": "^4.0.0"
@@ -12330,7 +12306,7 @@
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"optional": true,
"devOptional": true,
"dependencies": {
"yallist": "^4.0.0"
},
@@ -12342,13 +12318,13 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"optional": true
"devOptional": true
},
"node_modules/mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
"optional": true,
"devOptional": true,
"bin": {
"mkdirp": "bin/cmd.js"
},
@@ -12489,7 +12465,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
"integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
"optional": true,
"devOptional": true,
"dependencies": {
"abbrev": "1"
},
@@ -16222,16 +16198,14 @@
"license": "MIT"
},
"node_modules/sanitize-html": {
"version": "2.17.4",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.4.tgz",
"integrity": "sha512-2HW7v2ol/uAM7sX4hbD8Z59OGWmAPrvjL8E71UWlBcj6m+kcF6ilQBLny+cIgY214QJeJT5tQuxKKqX0SQqjGQ==",
"license": "MIT",
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.12.1.tgz",
"integrity": "sha512-Plh+JAn0UVDpBRP/xEjsk+xDCoOvMBwQUf/K+/cBAVuTbtX8bj2VB7S1sL1dssVpykqp0/KPSesHrqXtokVBpA==",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^10.1.0",
"htmlparser2": "^8.0.0",
"is-plain-object": "^5.0.0",
"launder": "^1.7.1",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
@@ -17440,7 +17414,7 @@
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
"optional": true,
"devOptional": true,
"dependencies": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
@@ -17457,7 +17431,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"optional": true
"devOptional": true
},
"node_modules/temp-dir": {
"version": "2.0.0",
+7 -11
View File
@@ -1,7 +1,7 @@
{
"name": "lotus-chat",
"version": "4.12.1-lotus",
"description": "Lotus Chat — Matrix client for Lotus Guild",
"name": "cinny",
"version": "4.11.1",
"description": "Yet another matrix client",
"main": "index.js",
"type": "module",
"engines": {
@@ -66,9 +66,6 @@
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource/inter": "4.5.14",
"@giphy/js-fetch-api": "5.8.0",
"@giphy/js-types": "5.1.0",
"@giphy/react-components": "10.1.2",
"@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0",
@@ -100,7 +97,7 @@
"linkify-react": "4.3.2",
"linkifyjs": "4.3.2",
"matrix-js-sdk": "38.2.0",
"matrix-widget-api": "1.16.1",
"matrix-widget-api": "1.13.0",
"millify": "6.1.0",
"pdfjs-dist": "4.2.67",
"prismjs": "1.30.0",
@@ -114,16 +111,15 @@
"react-i18next": "15.0.0",
"react-range": "1.8.14",
"react-router-dom": "6.30.3",
"sanitize-html": "2.17.4",
"sanitize-html": "2.12.1",
"slate": "0.123.0",
"slate-dom": "0.123.0",
"slate-history": "0.113.1",
"slate-react": "0.123.0",
"styled-components": "6.4.1",
"ua-parser-js": "1.0.35"
},
"devDependencies": {
"@element-hq/element-call-embedded": "0.19.3",
"@element-hq/element-call-embedded": "0.16.3",
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1",
@@ -137,7 +133,7 @@
"@types/react": "18.2.39",
"@types/react-dom": "18.2.17",
"@types/react-google-recaptcha": "2.1.8",
"@types/sanitize-html": "2.16.1",
"@types/sanitize-html": "2.9.0",
"@types/ua-parser-js": "0.7.36",
"@typescript-eslint/eslint-plugin": "5.46.1",
"@typescript-eslint/parser": "5.46.1",
-18
View File
@@ -1,18 +0,0 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"matrix.lotusguild.org"
],
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [],
"rooms": [],
"servers": []
},
"hashRouter": {
"enabled": false,
"basename": "/"
},
"gifApiKey": "AqqDuQwZNjYttz7Mn6ME4JH1bJIuZ5CO"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 631 B

After

Width:  |  Height:  |  Size: 32 KiB

+5 -24
View File
@@ -1,14 +1,14 @@
{
"name": "Lotus Chat",
"short_name": "Lotus Chat",
"description": "Lotus Chat \u2014 the Lotus Guild Matrix client",
"name": "Cinny",
"short_name": "Cinny",
"description": "Yet another matrix client",
"dir": "auto",
"lang": "en-US",
"display": "standalone",
"orientation": "portrait",
"start_url": "./",
"background_color": "#0a0a0a",
"theme_color": "#980000",
"background_color": "#fff",
"theme_color": "#fff",
"icons": [
{
"src": "./public/android/android-chrome-36x36.png",
@@ -55,24 +55,5 @@
"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"
}
]
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

+12 -13
View File
@@ -1,14 +1,13 @@
<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 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>

Before

Width:  |  Height:  |  Size: 841 B

After

Width:  |  Height:  |  Size: 1.5 KiB

+12 -13
View File
@@ -1,14 +1,13 @@
<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 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>

Before

Width:  |  Height:  |  Size: 841 B

After

Width:  |  Height:  |  Size: 1.5 KiB

+18 -12
View File
@@ -1,13 +1,19 @@
<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"/>
<!-- 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"/>
</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>
</svg>

Before

Width:  |  Height:  |  Size: 788 B

After

Width:  |  Height:  |  Size: 871 B

Binary file not shown.
+4 -436
View File
@@ -1,32 +1,5 @@
/* eslint-disable jsx-a11y/media-has-caption */
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import React, { ReactNode, useCallback, useRef } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { MatrixRTCSession } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
import FocusTrap from 'focus-trap-react';
import {
Avatar,
Box,
Button,
color,
config,
Dialog,
Icon,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
toRem,
} from 'folds';
import {
EventTimelineSetHandlerMap,
EventType,
RelationType,
Room,
RoomEvent,
} from 'matrix-js-sdk';
import { IRTCNotificationContent, RTCNotificationType } from 'matrix-js-sdk/lib/matrixrtc/types';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import {
CallEmbedContextProvider,
CallEmbedRefContextProvider,
@@ -34,330 +7,11 @@ import {
useCallJoined,
useCallThemeSync,
useCallMemberSoundSync,
useCallStart,
} from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { CallEmbed } from '../plugins/call';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { useMatrixClient } from '../hooks/useMatrixClient';
import CallSound from '../../../public/sound/call.ogg';
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
import { mDirectAtom } from '../state/mDirectList';
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../utils/matrix';
import { RoomAvatar, RoomIcon } from './room-avatar';
import { useRoomNavigate } from '../hooks/useRoomNavigate';
import { getStateEvent } from '../utils/room';
import { StateEvent } from '../../types/matrix/room';
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
import { useLivekitSupport } from '../hooks/useLivekitSupport';
import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc';
const PIP_MIN_W = 200;
const PIP_MIN_H = 112;
type Corner = 'se' | 'sw' | 'ne' | 'nw';
/** Normalise the element to top/left positioning so resize math is uniform. */
function normaliseToTopLeft(el: HTMLElement) {
const rect = el.getBoundingClientRect();
el.style.left = `${rect.left}px`;
el.style.top = `${rect.top}px`;
el.style.right = 'auto';
el.style.bottom = 'auto';
}
type IncomingCallInfo = {
room: Room;
sender: string;
senderTs: number;
lifetime: number;
intent?: string;
notificationType: RTCNotificationType;
refEventId: string;
};
type IncomingCallProps = {
dm: boolean;
info: IncomingCallInfo;
onIgnore: () => void;
onAnswer: (room: Room, video: boolean) => void;
onReject: (room: Room, eventId: string) => void;
};
function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported();
const canAnswer = livekitSupported && rtcSupported;
const { room } = info;
const audioRef = useRef<HTMLAudioElement>(null);
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room, dm);
const avatarUrl = roomAvatar
? mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const session = useCallSession(room);
useCallMembersChange(
session,
useCallback(() => {
const members = MatrixRTCSession.sessionMembershipsForRoom(room, session.sessionDescription);
if (members.length === 0) {
onIgnore();
}
}, [room, session, onIgnore])
);
const playSound = useCallback(() => {
const audioElement = audioRef.current;
audioElement?.play();
}, []);
useEffect(() => {
if (info.notificationType === 'ring') {
playSound();
}
}, [playSound, info.notificationType]);
return (
<>
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => onIgnore(),
clickOutsideDeactivates: false,
escapeDeactivates: false,
}}
>
<Dialog style={{ maxWidth: toRem(324) }}>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
<Text size="T200" align="Center">
{info.sender}
</Text>
<Box direction="Column" gap="500" alignItems="Center">
<Box shrink="No">
<Avatar size="500" className={CallAvatarAnimation}>
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={roomName}
renderFallback={() => (
<RoomIcon
roomType={room.getType()}
size="400"
joinRule={room.getJoinRule()}
filled
/>
)}
/>
</Avatar>
</Box>
<Box grow="Yes" direction="Column" gap="100">
<Text size="H3" align="Center" truncate>
{roomName}
</Text>
<Text size="T300">Incoming Call</Text>
</Box>
</Box>
{!livekitSupported && (
<Text
style={{ margin: 'auto', color: color.Critical.Main }}
size="L400"
align="Center"
>
Your homeserver does not support calling.
</Text>
)}
{!webRTCSupported && (
<Text
style={{ margin: 'auto', color: color.Critical.Main }}
size="L400"
align="Center"
>
Your browser does not support WebRTC, which is required for calling.
</Text>
)}
<Box direction="Column" gap="300">
<Button
style={{ flexGrow: 1 }}
variant="Success"
size="400"
radii="400"
onClick={() => onAnswer(room, info.intent === 'video')}
before={
<Icon
size="200"
src={info.intent === 'video' ? Icons.VideoCamera : Icons.Phone}
filled
/>
}
disabled={!canAnswer}
>
<Text as="span" size="B400">
Answer
</Text>
</Button>
<Button
style={{ flexGrow: 1 }}
variant="Success"
fill="Soft"
size="400"
radii="400"
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
before={<Icon size="200" src={Icons.Cross} filled />}
>
<Text as="span" size="B400">
{dm ? 'Reject' : 'Ignore'}
</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
<audio ref={audioRef} loop style={{ display: 'none' }}>
<source src={CallSound} type="audio/ogg" />
</audio>
</>
);
}
type IncomingCallListenerProps = {
callEmbed?: CallEmbed;
joined?: boolean;
};
function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) {
const mx = useMatrixClient();
const directs = useAtomValue(mDirectAtom);
const { navigateRoom } = useRoomNavigate();
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
const startCall = useCallStart(dm);
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
async (event, room, toStartOfTimeline, removed, data) => {
// only process rtc notification reference events.
// we do not want to wait to decrypt all events.
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
if (event.isEncrypted()) {
if (!event.isBeingDecrypted()) {
await event.attemptDecryption(mx.getCrypto() as CryptoBackend);
}
await event.getDecryptionPromise();
}
if (
!room ||
event.getType() !== EventType.RTCNotification ||
event.getSender() === mx.getSafeUserId() ||
!data.liveEvent
) {
return;
}
const sender = event.getSender();
const content = event.getContent<IRTCNotificationContent>();
const senderTs =
content.sender_ts - event.getTs() > 20000 ? event.getTs() : content.sender_ts;
const lifetime = Math.min(content.lifetime, 120000);
const notificationType = content.notification_type;
const relation =
event.getRelation()?.rel_type === RelationType.Reference ? event.getRelation() : undefined;
const refEventId = relation?.event_id;
const mention =
content['m.mentions'].room || content['m.mentions'].user_ids?.includes(mx.getSafeUserId());
if (!sender || !refEventId || !mention || Date.now() >= senderTs + lifetime) {
return;
}
const powerLevelsEvent = getStateEvent(room, StateEvent.RoomPowerLevels);
const powerLevels = getPowersLevelFromMatrixEvent(powerLevelsEvent);
const creators = getRoomCreatorsForRoomId(mx, room.roomId);
const permissions = getRoomPermissionsAPI(creators, powerLevels);
const hasCallPermission = permissions.stateEvent(
StateEvent.GroupCallMemberPrefix,
mx.getSafeUserId()
);
if (!hasCallPermission) return;
const info: IncomingCallInfo = {
room,
sender,
senderTs,
lifetime,
intent:
'm.call.intent' in content && typeof content['m.call.intent'] === 'string'
? content['m.call.intent']
: undefined,
notificationType,
refEventId,
};
setCallInfo(info);
},
[mx]
);
useEffect(() => {
mx.on(RoomEvent.Timeline, handleTimelineEvent);
return () => {
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
};
}, [mx, handleTimelineEvent]);
const handleIgnore = useCallback(() => {
setCallInfo(undefined);
}, []);
const handleReject = useCallback(
(room: Room, eventId: string) => {
mx.sendEvent(room.roomId, EventType.RTCDecline, {
'm.relates_to': {
rel_type: RelationType.Reference,
event_id: eventId,
},
});
setCallInfo(undefined);
},
[mx]
);
const handleAnswer = useCallback(
(room: Room, video: boolean) => {
startCall(room, { microphone: true, video, sound: true });
setCallInfo(undefined);
navigateRoom(room.roomId);
},
[startCall, navigateRoom]
);
if (callInfo && callEmbed?.roomId === callInfo.room.roomId) {
return null;
}
return !joined && callInfo ? (
<IncomingCall
dm={dm}
info={callInfo}
onIgnore={handleIgnore}
onAnswer={handleAnswer}
onReject={handleReject}
/>
) : null;
}
function CallUtils({ embed }: { embed: CallEmbed }) {
const setCallEmbed = useSetAtom(callEmbedAtom);
@@ -387,77 +41,13 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const screenSize = useScreenSizeContext();
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 { navigateRoom } = useRoomNavigate();
const pipDragRef = React.useRef<{
startX: number; startY: number; origLeft: number; origTop: number; dragged: boolean;
} | null>(null);
React.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 {
['top','left','bottom','right','width','height','borderRadius','overflow','zIndex','boxShadow','border'].forEach(p => { (el.style as any)[p] = ''; });
el.style.visibility = callVisible ? '' : 'hidden';
}
}, [pipMode, callVisible]);
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 onMove = (ev: MouseEvent) => {
if (!pipDragRef.current || !el) return;
const dx = ev.clientX - pipDragRef.current.startX, 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) {
el.style.left = `${Math.max(0, Math.min(window.innerWidth-el.offsetWidth, pipDragRef.current.origLeft+dx))}px`;
el.style.top = `${Math.max(0, Math.min(window.innerHeight-el.offsetHeight, pipDragRef.current.origTop+dy))}px`;
el.style.right = 'auto'; el.style.bottom = 'auto';
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; setTimeout(() => { if (pipDragRef.current) pipDragRef.current.dragged = false; }, 0); };
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
};
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
e.stopPropagation(); e.preventDefault();
const el = callEmbedRef.current; if (!el) return;
normaliseToTopLeft(el);
const sx = e.clientX, sy = e.clientY, sw = el.offsetWidth, sh = el.offsetHeight;
const sl = parseFloat(el.style.left), st = parseFloat(el.style.top);
document.body.style.cursor = `${corner}-resize`; document.body.style.userSelect = 'none';
const onMove = (ev: MouseEvent) => {
const dx = ev.clientX-sx, dy = ev.clientY-sy;
let w = sw, h = sh, l = sl, t = st;
if (corner==='se'){w=sw+dx;h=sh+dy;} if (corner==='sw'){w=sw-dx;h=sh+dy;l=sl+sw-Math.max(PIP_MIN_W,w);}
if (corner==='ne'){w=sw+dx;h=sh-dy;t=st+sh-Math.max(PIP_MIN_H,h);} if (corner==='nw'){w=sw-dx;h=sh-dy;l=sl+sw-Math.max(PIP_MIN_W,w);t=st+sh-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 onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor=''; document.body.style.userSelect=''; };
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
};
const callVisible = callEmbed && selectedRoom === callEmbed.roomId && joined && !chatOnlyView;
return (
<CallEmbedContextProvider value={callEmbed}>
{callEmbed && <CallUtils embed={callEmbed} />}
<CallEmbedRefContextProvider value={callEmbedRef}>
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
{children}
</CallEmbedRefContextProvider>
<CallEmbedRefContextProvider value={callEmbedRef}>{children}</CallEmbedRefContextProvider>
<div
data-call-embed-container
style={{
@@ -469,29 +59,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
height: '50%',
}}
ref={callEmbedRef}
>
{pipMode && callEmbed && (
<>
<div
role="button"
tabIndex={0}
aria-label="Return to call"
onMouseDown={handlePipMouseDown}
onClick={() => { if (!pipDragRef.current?.dragged) navigateRoom(callEmbed.roomId); }}
onKeyDown={(e) => e.key === 'Enter' && navigateRoom(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>
{(['se','sw','ne','nw'] as Corner[]).map((corner) => (
<div key={corner} onMouseDown={(e) => handleResizeMouseDown(e, corner)} onClick={(e) => e.stopPropagation()}
style={{ position:'absolute', width:'18px', height:'18px', [corner.includes('s')?'bottom':'top']:0, [corner.includes('e')?'right':'left']:0, cursor:`${corner}-resize`, zIndex:2 }} />
))}
</>
)}
</div>
/>
</CallEmbedContextProvider>
);
}
-152
View File
@@ -1,152 +0,0 @@
import React, { useCallback } from 'react';
import FocusTrap from 'focus-trap-react';
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
import { IGif } from '@giphy/js-types';
import { Box } from 'folds';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
const PICKER_WIDTH = 312;
const TERMINAL_CSS = `
[data-gif-terminal] input,
[data-gif-terminal] form {
background: #030c14 !important;
color: #e8edf5 !important;
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace !important;
border: 1px solid rgba(255,107,0,0.35) !important;
border-radius: 4px !important;
font-size: 12px !important;
box-shadow: none !important;
}
[data-gif-terminal] input:focus {
border-color: rgba(255,107,0,0.7) !important;
box-shadow: 0 0 0 2px rgba(255,107,0,0.12) !important;
outline: none !important;
}
[data-gif-terminal] input::placeholder {
color: rgba(255,107,0,0.4) !important;
font-family: 'JetBrains Mono', monospace !important;
}
[data-gif-terminal] svg,
[data-gif-terminal] button[type="reset"] {
display: none !important;
}
[data-gif-terminal] ::-webkit-scrollbar {
width: 4px;
}
[data-gif-terminal] ::-webkit-scrollbar-track {
background: #030508;
}
[data-gif-terminal] ::-webkit-scrollbar-thumb {
background: rgba(255,107,0,0.4);
border-radius: 2px;
}
`;
type GifPickerInnerProps = {
onSelect: (url: string, width: number, height: number) => void;
requestClose: () => void;
lotusTerminal: boolean;
};
function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInnerProps) {
const { fetchGifs, searchKey } = React.useContext(SearchContext);
const handleClick = useCallback(
(gif: IGif, e: React.SyntheticEvent) => {
e.preventDefault();
const r = gif.images.downsized ?? gif.images.original;
const url = r.url;
const width = Number(r.width) || 200;
const height = Number(r.height) || 200;
onSelect(url, width, height);
requestClose();
},
[onSelect, requestClose]
);
return (
<Box direction="Column" style={{ width: `${PICKER_WIDTH}px` }}>
{lotusTerminal && (
<div style={{
padding: '5px 10px 4px',
borderBottom: '1px solid rgba(255,107,0,0.2)',
fontFamily: "'JetBrains Mono', 'Cascadia Code', monospace",
fontSize: '10px',
fontWeight: 700,
letterSpacing: '0.1em',
color: '#FF6B00',
userSelect: 'none',
}}>
// GIF_SEARCH
</div>
)}
<Box style={{ padding: '8px 8px 4px' }}>
<SearchBar style={{ width: '100%', borderRadius: lotusTerminal ? '4px' : '8px' }} />
</Box>
<div style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}>
<Grid
key={searchKey}
fetchGifs={fetchGifs}
width={PICKER_WIDTH - 16}
columns={2}
gutter={4}
onGifClick={handleClick}
hideAttribution={false}
noLink
/>
</div>
</Box>
);
}
type GifPickerProps = {
apiKey: string;
onSelect: (url: string, width: number, height: number) => void;
requestClose: () => void;
};
export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const containerStyle = lotusTerminal
? {
background: '#060c14',
border: '1px solid rgba(255,107,0,0.35)',
borderRadius: '4px',
overflow: 'hidden',
boxShadow: '0 4px 24px rgba(255,107,0,0.10), 0 0 0 1px rgba(255,107,0,0.08)',
width: `${PICKER_WIDTH}px`,
}
: {
background: 'var(--bg-surface)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
width: `${PICKER_WIDTH}px`,
};
return (
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: requestClose,
clickOutsideDeactivates: true,
allowOutsideClick: true,
}}
>
<Box
direction="Column"
data-gif-terminal={lotusTerminal ? '' : undefined}
style={containerStyle}
>
{lotusTerminal && <style>{TERMINAL_CSS}</style>}
<SearchContextManager apiKey={apiKey} initialTerm="">
<GifPickerInner onSelect={onSelect} requestClose={requestClose} lotusTerminal={!!lotusTerminal} />
</SearchContextManager>
</Box>
</FocusTrap>
);
}
@@ -1,310 +0,0 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { Avatar, Box, Button, Text } from 'folds';
import { UserAvatar } from './user-avatar';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useCallStart } from '../hooks/useCallEmbed';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { useIncomingDmCall } from '../hooks/useIncomingDmCall';
import { mxcUrlToHttp } from '../utils/matrix';
function useRingTone(active: boolean) {
const stopRef = useRef<(() => void) | null>(null);
useEffect(() => {
if (!active) {
stopRef.current?.();
stopRef.current = null;
return;
}
let ctx: AudioContext;
try {
ctx = new AudioContext();
} catch {
return;
}
let cancelled = false;
const pulse = (t: number, freq: number, dur: number) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = freq;
osc.type = 'sine';
gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(0.22, t + 0.04);
gain.gain.setValueAtTime(0.22, t + dur - 0.04);
gain.gain.linearRampToValueAtTime(0, t + dur);
osc.start(t);
osc.stop(t + dur);
};
let ringTimer: ReturnType<typeof setTimeout> | null = null;
const ring = () => {
if (cancelled) return;
const now = ctx.currentTime;
pulse(now, 880, 0.18);
pulse(now + 0.28, 880, 0.18);
ringTimer = setTimeout(ring, 2200);
};
ring();
const stop = () => {
cancelled = true;
if (ringTimer !== null) clearTimeout(ringTimer);
ctx.close();
};
stopRef.current = stop;
return () => {
stop();
stopRef.current = null;
};
}, [active]);
return stopRef;
}
function IncomingCallCard() {
const mx = useMatrixClient();
const startCall = useCallStart(true);
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [incoming, dismiss] = useIncomingDmCall();
const stopRingRef = useRingTone(!!incoming);
const stopAndDismiss = useCallback(() => {
stopRingRef.current?.();
dismiss();
}, [dismiss, stopRingRef]);
const handleAnswer = useCallback(() => {
if (!incoming) return;
stopRingRef.current?.();
startCall(incoming.room);
dismiss();
}, [incoming, startCall, dismiss, stopRingRef]);
if (!incoming) return null;
const callerUser = mx.getUser(incoming.callerId);
const callerName = callerUser?.displayName ?? incoming.callerId;
const avatarMxc = callerUser?.avatarUrl;
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, undefined, 64, 64, 'crop') ?? undefined : undefined;
if (lotusTerminal) {
return (
<div
style={{
position: 'fixed',
bottom: '80px',
right: '24px',
zIndex: 9999,
background: '#060c14',
border: '1px solid rgba(255,107,0,0.45)',
borderRadius: '4px',
boxShadow: '0 4px 32px rgba(255,107,0,0.18), 0 0 0 1px rgba(255,107,0,0.08)',
padding: '16px',
width: '272px',
fontFamily: "'JetBrains Mono', monospace",
}}
>
<div
style={{
fontSize: '10px',
letterSpacing: '0.1em',
color: '#00D4FF',
marginBottom: '10px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<span
style={{
display: 'inline-block',
width: '7px',
height: '7px',
borderRadius: '50%',
background: '#00FF88',
boxShadow: '0 0 6px #00FF88',
animation: 'lotus-ring-pulse 1s ease-in-out infinite',
}}
/>
INCOMING CALL
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '14px' }}>
<Avatar size="300" style={{ border: '1px solid rgba(0,212,255,0.3)', borderRadius: '2px', overflow: 'hidden' }}>
<UserAvatar
userId={incoming.callerId}
src={avatarUrl}
alt={callerName}
renderFallback={() => (
<span style={{ fontSize: '16px', color: '#00D4FF', fontFamily: 'JetBrains Mono, monospace' }}>
{callerName[0]?.toUpperCase() ?? '?'}
</span>
)}
/>
</Avatar>
<div>
<div style={{ fontSize: '13px', color: '#e8edf5', fontWeight: 600 }}>{callerName}</div>
<div style={{ fontSize: '10px', color: 'rgba(232,237,245,0.45)', marginTop: '2px' }}>
{incoming.callerId}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={handleAnswer}
style={{
flex: 1,
background: 'transparent',
border: '1px solid #00FF88',
borderRadius: '3px',
color: '#00FF88',
fontFamily: 'JetBrains Mono, monospace',
fontSize: '11px',
fontWeight: 700,
letterSpacing: '0.08em',
padding: '9px 4px',
cursor: 'pointer',
boxShadow: '0 0 10px rgba(0,255,136,0.2)',
transition: 'box-shadow 0.15s, background 0.15s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(0,255,136,0.08)';
(e.currentTarget as HTMLButtonElement).style.boxShadow = '0 0 16px rgba(0,255,136,0.35)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'transparent';
(e.currentTarget as HTMLButtonElement).style.boxShadow = '0 0 10px rgba(0,255,136,0.2)';
}}
>
ANSWER
</button>
<button
onClick={stopAndDismiss}
style={{
flex: 1,
background: 'transparent',
border: '1px solid rgba(255,107,0,0.5)',
borderRadius: '3px',
color: '#FF6B00',
fontFamily: 'JetBrains Mono, monospace',
fontSize: '11px',
fontWeight: 700,
letterSpacing: '0.08em',
padding: '9px 4px',
cursor: 'pointer',
transition: 'opacity 0.15s, background 0.15s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(255,107,0,0.08)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'transparent';
}}
>
DECLINE
</button>
</div>
<style>{`
@keyframes lotus-ring-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.7); }
}
`}</style>
</div>
);
}
return (
<div
style={{
position: 'fixed',
bottom: '80px',
right: '24px',
zIndex: 9999,
background: 'var(--bg-surface)',
border: '1px solid var(--bg-surface-border)',
borderRadius: '16px',
boxShadow: '0 8px 40px rgba(0,0,0,0.45)',
padding: '16px',
width: '272px',
}}
>
<div
style={{
fontSize: '11px',
color: 'var(--text-secondary)',
marginBottom: '10px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<span
style={{
display: 'inline-block',
width: '7px',
height: '7px',
borderRadius: '50%',
background: 'var(--clr-success-main)',
animation: 'lotus-ring-pulse 1s ease-in-out infinite',
}}
/>
Incoming call
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px' }}>
<Avatar size="300">
<UserAvatar
userId={incoming.callerId}
src={avatarUrl}
alt={callerName}
renderFallback={() => (
<Text size="H4">{callerName[0]?.toUpperCase() ?? '?'}</Text>
)}
/>
</Avatar>
<div>
<Text size="T400" style={{ fontWeight: 600 }}>{callerName}</Text>
<Text size="T200" style={{ opacity: 0.5 }}>{incoming.callerId}</Text>
</div>
</div>
<Box direction="Row" gap="200">
<Button
onClick={handleAnswer}
variant="Success"
fill="Solid"
size="400"
style={{ flex: 1 }}
>
<Text size="B400">Answer</Text>
</Button>
<Button
onClick={stopAndDismiss}
variant="Critical"
fill="Solid"
size="400"
style={{ flex: 1 }}
>
<Text size="B400">Decline</Text>
</Button>
</Box>
<style>{`
@keyframes lotus-ring-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.7); }
}
`}</style>
</div>
);
}
export function IncomingCallNotification() {
return <IncomingCallCard />;
}
@@ -391,28 +391,9 @@ export function MLocation({ content }: MLocationProps) {
const location = parseGeoUri(geoUri);
if (!location) return <BrokenContent />;
const lat = parseFloat(location.latitude);
const lon = parseFloat(location.longitude);
const mapSrc = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.007},${lat - 0.004},${lon + 0.007},${lat + 0.004}&layer=mapnik&marker=${lat},${lon}`;
return (
<Box direction="Column" alignItems="Start" gap="200">
<iframe
title="Location"
src={mapSrc}
style={{
width: '280px',
height: '160px',
border: '1px solid var(--bg-surface-border)',
borderRadius: '8px',
display: 'block',
}}
scrolling="no"
loading="lazy"
/>
<Text size="T300" style={{ opacity: 0.65 }}>
{`${lat.toFixed(5)}, ${lon.toFixed(5)}`}
</Text>
<Box direction="Column" alignItems="Start" gap="100">
<Text size="T400">{geoUri}</Text>
<Chip
as="a"
size="400"
@@ -1,144 +0,0 @@
import React, { useState } from 'react';
import { Box, Text } from 'folds';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
type PollTextValue = Array<{ body: string }> | string;
function extractText(val: PollTextValue | undefined): string {
if (!val) return '';
if (typeof val === 'string') return val;
return val[0]?.body ?? '';
}
type PollAnswer = {
'm.id'?: string;
id?: string;
'm.text'?: PollTextValue;
'org.matrix.msc3381.poll.answer'?: { body: string };
};
type PollData = {
question?: { body?: string; 'm.text'?: PollTextValue };
answers?: PollAnswer[];
};
export function PollContent({
content,
roomId,
eventId,
}: {
content: Record<string, unknown>;
roomId?: string;
eventId?: string;
}) {
const mx = useMatrixClient();
const [myVote, setMyVote] = useState<string | null>(null);
const isStable = !!content['m.poll'];
const poll = (
content['m.poll'] ?? content['org.matrix.msc3381.poll.start']
) as PollData | undefined;
if (!poll) {
return (
<Text style={{ opacity: 0.6 }}>
<i>Poll (unreadable format)</i>
</Text>
);
}
const questionText =
extractText((poll.question as any)?.['m.text']) ||
(poll.question as any)?.body ||
'Untitled poll';
const canVote = !!roomId && !!eventId;
const handleVote = (answerId: string) => {
if (!roomId || !eventId) return;
setMyVote(answerId);
if (isStable) {
mx.sendEvent(roomId, 'm.poll.response' as any, {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.responses': [{ 'm.id': answerId }],
});
} else {
mx.sendEvent(roomId, 'org.matrix.msc3381.poll.response' as any, {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'org.matrix.msc3381.poll.response': { answers: [answerId] },
});
}
};
return (
<Box
direction="Column"
gap="200"
style={{ maxWidth: '340px', paddingTop: '2px', paddingBottom: '4px' }}
>
<Box
alignItems="Center"
gap="100"
style={{
fontSize: '0.68rem',
fontWeight: 700,
letterSpacing: '0.12em',
textTransform: 'uppercase',
opacity: 0.55,
marginBottom: '2px',
}}
>
Poll
</Box>
<Text size="T400" style={{ fontWeight: 600 }}>
{questionText}
</Text>
<Box direction="Column" gap="100" style={{ marginTop: '2px' }}>
{(poll.answers ?? []).map((answer, i) => {
const text =
extractText((answer as any)['m.text']) ||
(answer as any)['org.matrix.msc3381.poll.answer']?.body ||
`Option ${i + 1}`;
const id = answer['m.id'] ?? answer.id ?? String(i);
const selected = myVote === id;
return (
<button
key={id}
type="button"
onClick={canVote ? () => handleVote(id) : undefined}
style={{
padding: '7px 12px',
borderRadius: '8px',
background: selected ? 'var(--bg-surface-active)' : 'var(--bg-surface-low)',
border: `1px solid ${selected ? 'var(--text-primary)' : 'var(--bg-surface-border)'}`,
fontSize: '0.88rem',
lineHeight: 1.4,
textAlign: 'left',
cursor: canVote ? 'pointer' : 'default',
color: 'var(--text-primary)',
display: 'flex',
alignItems: 'center',
gap: '8px',
width: '100%',
}}
>
<span style={{ flexGrow: 1 }}>{text}</span>
{selected && (
<span style={{ opacity: 0.8, fontSize: '1rem', flexShrink: 0 }}></span>
)}
</button>
);
})}
</Box>
{canVote ? (
<Text size="T200" style={{ opacity: 0.5, marginTop: '2px' }}>
<i>{myVote ? 'Vote cast — click another to change' : 'Click an option to vote'}</i>
</Text>
) : (
<Text size="T200" style={{ opacity: 0.4, marginTop: '2px' }}>
<i>Open in Element to vote</i>
</Text>
)}
</Box>
);
}
@@ -5,4 +5,3 @@ export * from './AudioContent';
export * from './FileContent';
export * from './FallbackContent';
export * from './EventContent';
export * from './PollContent';
@@ -21,7 +21,7 @@ export function SplashScreen({ children }: SplashScreenProps) {
justifyContent="Center"
>
<Text size="H2" align="Center">
Lotus Chat
Cinny
</Text>
</Box>
</Box>
@@ -182,27 +182,6 @@ export function UploadCardRenderer({
<PreviewVideo fileItem={fileItem} />
</MediaPreview>
)}
{(fileItem.originalFile.type.startsWith('image') ||
fileItem.originalFile.type.startsWith('video')) && (
<input
type="text"
placeholder="Add a caption… (optional)"
value={metadata.caption ?? ''}
onChange={(e) => setMetadata(fileItem, { ...metadata, caption: e.target.value })}
style={{
marginTop: '6px',
width: '100%',
background: 'var(--bg-surface-low)',
border: '1px solid var(--bg-surface-border)',
borderRadius: '6px',
padding: '5px 8px',
fontSize: '0.85rem',
color: 'var(--text-primary)',
outline: 'none',
boxSizing: 'border-box',
}}
/>
)}
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
)}
@@ -28,11 +28,7 @@ import { copyToClipboard } from '../../utils/dom';
import { getExploreServerPath } from '../../pages/pathUtils';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { factoryRoomIdByAtoZ } from '../../utils/sort';
import {
useMutualRooms,
useMutualRoomsSupport,
useUnstableMutualRoomsSupport,
} from '../../hooks/useMutualRooms';
import { useMutualRooms, useMutualRoomsSupport } from '../../hooks/useMutualRooms';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useDirectRooms } from '../../pages/client/direct/useDirectRooms';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@@ -237,9 +233,7 @@ type MutualRoomsData = {
export function MutualRoomsChip({ userId }: { userId: string }) {
const mx = useMatrixClient();
const mutualRoomSupported = useMutualRoomsSupport();
const mutualRoomUnstable = useUnstableMutualRoomsSupport();
const mutualRoomsState = useMutualRooms(userId);
console.log(mutualRoomSupported, mutualRoomsState);
const { navigateRoom, navigateSpace } = useRoomNavigate();
const closeUserRoomProfile = useCloseUserRoomProfile();
const directs = useDirectRooms();
@@ -285,7 +279,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) {
if (
userId === mx.getSafeUserId() ||
(!mutualRoomSupported && !mutualRoomUnstable) ||
!mutualRoomSupported ||
mutualRoomsState.status === AsyncStatus.Error
) {
return null;
@@ -157,6 +157,7 @@ export function CallControl({
}) {
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
const setCallEmbed = useSetAtom(callEmbedAtom);
const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed])
);
@@ -1,6 +1,6 @@
import React from 'react';
import { Room } from 'matrix-js-sdk';
import { Chip, Icon, Icons, Text } from 'folds';
import { Chip, Text } from 'folds';
import { useAtomValue } from 'jotai';
import { useRoomName } from '../../hooks/useRoomMeta';
import { RoomIcon } from '../../components/room-avatar';
@@ -38,11 +38,7 @@ export function CallRoomName({ room }: CallRoomNameProps) {
variant="Background"
radii="Pill"
before={
dm ? (
<Icon size="200" src={Icons.VolumeHigh} filled />
) : (
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} filled />
)
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} filled />
}
onClick={() => navigateRoom(room.roomId)}
>
+2 -156
View File
@@ -1,8 +1,7 @@
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
import {
Box,
Button,
Chip,
config,
Icon,
IconButton,
@@ -27,8 +26,6 @@ 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';
@@ -54,26 +51,6 @@ 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 [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
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());
@@ -94,64 +71,12 @@ 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}
@@ -159,82 +84,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
justifyContent="Center"
alignItems="Center"
>
{pttMode && (
lotusTerminal ? (
<Box style={{
position: 'absolute',
top: '-2.5rem',
left: '50%',
transform: 'translateX(-50%)',
background: pttActive ? 'rgba(0,255,136,0.18)' : 'rgba(255,107,0,0.12)',
border: `1px solid ${pttActive ? 'rgba(0,255,136,0.55)' : 'rgba(255,107,0,0.35)'}`,
borderRadius: '99px',
padding: '0.2rem 0.9rem',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}>
<Text size="T200" style={{ color: pttActive ? '#00FF88' : '#FF6B00', fontWeight: 700, letterSpacing: '0.08em', fontFamily: 'JetBrains Mono, monospace' }}>
{pttActive ? '● LIVE' : `PTT — Hold ${pttKeyLabel}`}
</Text>
</Box>
) : (
<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"
@@ -256,10 +105,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} />
<ScreenShareButton
enabled={screenshare}
onToggle={() => screenshare
? callEmbed.control.toggleScreenshare()
: setShareConfirm(true)
}
onToggle={() => callEmbed.control.toggleScreenshare()}
/>
</Box>
</Box>
+5 -28
View File
@@ -14,20 +14,11 @@ import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
import { CallControls } from './CallControls';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
function LivekitServerMissingMessage() {
return (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Your homeserver does not support calling.
</Text>
);
}
function WebRTCMissingError() {
return (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Your browser does not support WebRTC, which is required for calling.
Your homeserver does not support calling. But you can still join call started by others.
</Text>
);
}
@@ -35,22 +26,16 @@ function WebRTCMissingError() {
function JoinMessage({
hasParticipant,
livekitSupported,
rtcSupported,
}: {
hasParticipant?: boolean;
livekitSupported?: boolean;
rtcSupported?: boolean;
}) {
if (rtcSupported === false) {
return <WebRTCMissingError />;
}
if (hasParticipant) return null;
if (livekitSupported === false) {
return <LivekitServerMissingMessage />;
}
if (hasParticipant) return null;
return (
<Text style={{ margin: 'auto' }} size="L400" align="Center">
Voice chats empty Be the first to hop in!
@@ -78,16 +63,12 @@ function CallPrescreen() {
const mx = useMatrixClient();
const room = useRoom();
const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported();
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const hasPermission = permissions.stateEvent(
StateEvent.GroupCallMemberPrefix,
mx.getSafeUserId()
);
const hasPermission = permissions.event(StateEvent.GroupCallMemberPrefix, mx.getSafeUserId());
const callSession = useCallSession(room);
const callMembers = useCallMembers(room, callSession);
@@ -96,7 +77,7 @@ function CallPrescreen() {
const callEmbed = useCallEmbed();
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
const canJoin = hasPermission && livekitSupported && rtcSupported;
const canJoin = hasPermission && (livekitSupported || hasParticipant);
return (
<Scroll variant="Surface" hideTrack>
@@ -119,11 +100,7 @@ function CallPrescreen() {
<Box className={css.PrescreenMessage} alignItems="Center">
{!inOtherCall &&
(hasPermission ? (
<JoinMessage
hasParticipant={hasParticipant}
livekitSupported={livekitSupported}
rtcSupported={rtcSupported}
/>
<JoinMessage hasParticipant={hasParticipant} livekitSupported={livekitSupported} />
) : (
<NoPermissionMessage />
))}
+1 -1
View File
@@ -53,7 +53,7 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Deafen' : 'Undeafen'}</Text>
<Text size="T200">{enabled ? 'Turn Off Sound' : 'Turn On Sound'}</Text>
</Tooltip>
}
>
+3 -33
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
import { SequenceCard } from '../../components/sequence-card';
import * as css from './styles.css';
@@ -7,28 +7,6 @@ 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;
};
@@ -43,10 +21,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
const startCall = useCallStart(direct);
const joining = callEmbed?.roomId === room.roomId && !callJoined;
const micPermission = useMediaPermissions();
const micDenied = micPermission === 'denied';
const disabled = inOtherCall || !canJoin || micDenied;
const disabled = inOtherCall || !canJoin;
const { microphone, video, sound, toggleMicrophone, toggleVideo, toggleSound } =
useCallPreferences();
@@ -70,12 +45,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
<VideoButton enabled={video} onToggle={toggleVideo} />
<ChatButton />
</Box>
<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>
)}
<Box grow="Yes" direction="Column">
<Button
variant={disabled ? 'Secondary' : 'Success'}
fill={disabled ? 'Soft' : 'Solid'}
-1
View File
@@ -21,7 +21,6 @@ export const CallMemberCard = style({
export const CallControlContainer = style({
padding: config.space.S400,
position: 'relative',
});
export const PrescreenMessage = style({
-278
View File
@@ -1,278 +0,0 @@
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];
+4 -5
View File
@@ -60,7 +60,6 @@ import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
import { livekitSupport } from '../../hooks/useLivekitSupport';
import { StateEvent } from '../../../types/matrix/room';
import { webRTCSupported } from '../../utils/rtc';
type RoomNavItemMenuProps = {
room: Room;
@@ -294,13 +293,13 @@ export function RoomNavItem({
const creators = getRoomCreatorsForRoomId(mx, room.roomId);
const permissions = getRoomPermissionsAPI(creators, powerLevels);
const hasCallPermission = permissions.stateEvent(
const hasCallPermission = permissions.event(
StateEvent.GroupCallMemberPrefix,
mx.getSafeUserId()
);
// Do not join if missing permissions or no livekit support or no webRTC support
if (!hasCallPermission || !livekitSupport(autoDiscoveryInfo) || !webRTCSupported()) {
// Do not join if missing permissions or no livekit support and call is not started by others
if (!hasCallPermission || (!livekitSupport(autoDiscoveryInfo) && callMembers.length === 0)) {
return;
}
@@ -379,7 +378,7 @@ export function RoomNavItem({
aria-label={notificationMode}
/>
)}
{callMembers.length > 0 && (
{room.isCallRoom() && callMembers.length > 0 && (
<Badge variant="Critical" fill="Solid" size="400">
<Text as="span" size="L400" truncate>
{callMembers.length} Live
@@ -23,7 +23,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
const canEditPowers = permissions.stateEvent(StateEvent.PowerLevelTags, mx.getSafeUserId());
const canEditPermissions = permissions.stateEvent(StateEvent.RoomPowerLevels, mx.getSafeUserId());
const permissionGroups = usePermissionGroups();
const permissionGroups = usePermissionGroups(room.isCallRoom());
const [powerEditor, setPowerEditor] = useState(false);
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { MessageEvent, StateEvent } from '../../../../types/matrix/room';
import { PermissionGroup } from '../../common-settings/permissions';
export const usePermissionGroups = (): PermissionGroup[] => {
export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => {
const groups: PermissionGroup[] = useMemo(() => {
const messagesGroup: PermissionGroup = {
name: 'Messages',
@@ -54,7 +54,7 @@ export const usePermissionGroups = (): PermissionGroup[] => {
state: true,
key: StateEvent.GroupCallMemberPrefix,
},
name: 'Start or Join Call',
name: 'Join Call',
},
],
};
@@ -216,13 +216,13 @@ export const usePermissionGroups = (): PermissionGroup[] => {
return [
messagesGroup,
callSettingsGroup,
...(isCallRoom ? [callSettingsGroup] : []),
moderationGroup,
roomOverviewGroup,
roomSettingsGroup,
otherSettingsGroup,
];
}, []);
}, [isCallRoom]);
return groups;
};
+2 -9
View File
@@ -9,7 +9,7 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
import { useRoom, useIsDirectRoom } from '../../hooks/useRoom';
import { useRoom } from '../../hooks/useRoom';
import { useKeyDown } from '../../hooks/useKeyDown';
import { markAsRead } from '../../utils/notifications';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -18,25 +18,18 @@ import { CallView } from '../call/CallView';
import { RoomViewHeader } from './RoomViewHeader';
import { callChatAtom } from '../../state/callEmbed';
import { CallChatView } from './CallChatView';
import { useCallEmbed } from '../../hooks/useCallEmbed';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
export function Room() {
const { eventId } = useParams();
const room = useRoom();
const mx = useMatrixClient();
const callSession = useCallSession(room);
const callMembers = useCallMembers(room, callSession);
const callEmbed = useCallEmbed();
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext();
const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId);
const chat = useAtomValue(callChatAtom);
const isDirect = useIsDirectRoom();
useKeyDown(
window,
@@ -50,7 +43,7 @@ export function Room() {
)
);
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
const callView = room.isCallRoom();
return (
<PowerLevelsContextProvider value={powerLevels}>
-109
View File
@@ -30,8 +30,6 @@ import {
} from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { GifPicker } from '../../components/GifPicker';
import { useClientConfig } from '../../hooks/useClientConfig';
import {
CustomEditor,
Toolbar,
@@ -173,26 +171,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [locating, setLocating] = React.useState(false);
const handleShareLocation = () => {
if (!navigator.geolocation) return;
setLocating(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setLocating(false);
const { latitude, longitude } = pos.coords;
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
mx.sendMessage(roomId, {
msgtype: 'm.location',
body: `Location: ${geoUri}`,
geo_uri: geoUri,
} as any);
},
() => setLocating(false),
{ timeout: 10000 }
);
};
const [autocompleteQuery, setAutocompleteQuery] =
useState<AutocompleteQuery<AutocompletePrefix>>();
@@ -238,8 +216,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const pickFile = useFilePicker(handleFiles, true);
const handlePaste = useFilePasteHandler(handleFiles);
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
const { gifApiKey } = useClientConfig();
const gifBtnRef = useRef<HTMLButtonElement>(null);
const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500);
const isComposing = useComposingCheck();
@@ -454,30 +430,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
moveCursor(editor);
};
const handleGifSelect = useCallback(
async (gifUrl: string, w: number, h: number) => {
try {
const res = await fetch(gifUrl);
const blob = await res.blob();
const uploadRes = await mx.uploadContent(
new File([blob], 'image.gif', { type: 'image/gif' }),
{ type: 'image/gif', name: 'image.gif', includeFilename: false }
);
const mxcUrl = (uploadRes as any).content_uri;
if (!mxcUrl) return;
mx.sendMessage(roomId, {
msgtype: MsgType.Image,
body: 'image.gif',
url: mxcUrl,
info: { mimetype: 'image/gif', w, h, size: blob.size },
});
} catch (e) {
console.error('GIF send failed', e);
}
},
[mx, roomId]
);
const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => {
const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
if (!stickerUrl) return;
@@ -717,67 +669,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</PopOut>
)}
</UseStateProvider>
{!!gifApiKey && (
<UseStateProvider initial={false}>
{(gifOpen: boolean, setGifOpen) => (
<PopOut
offset={16}
alignOffset={-44}
position="Top"
align="End"
anchor={
gifOpen
? gifBtnRef.current?.getBoundingClientRect() ?? undefined
: undefined
}
content={
<GifPicker
apiKey={gifApiKey}
onSelect={handleGifSelect}
requestClose={() => setGifOpen(false)}
/>
}
>
<IconButton
ref={gifBtnRef}
aria-pressed={gifOpen}
onClick={() => setGifOpen(!gifOpen)}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Text
size="T200"
style={{
fontWeight: 800,
fontSize: '11px',
letterSpacing: '0.04em',
lineHeight: 1,
}}
>
GIF
</Text>
</IconButton>
</PopOut>
)}
</UseStateProvider>
)}
<IconButton
onClick={handleShareLocation}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Share location"
title="Share location"
>
{locating ? (
<Text size="T200" style={{ fontWeight: 800, fontSize: '10px', letterSpacing: '0.04em', lineHeight: 1 }}>
...
</Text>
) : (
<Icon src={Icons.Pin} size="100" />
)}
</IconButton>
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.Send} />
</IconButton>
+1 -122
View File
@@ -63,7 +63,6 @@ import {
MessageNotDecryptedContent,
RedactedContent,
MSticker,
PollContent,
ImageContent,
EventContent,
} from '../../components/message';
@@ -437,7 +436,6 @@ 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();
@@ -1218,11 +1216,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
/>
);
}
if (
mEvent.getType() === 'm.poll.start' ||
mEvent.getType() === 'org.matrix.msc3381.poll.start'
)
return <PollContent content={mEvent.getContent()} roomId={room.roomId} eventId={mEvent.getId() ?? undefined} />;
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
return (
<Text>
@@ -1303,112 +1296,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</Message>
);
},
'org.matrix.msc3381.poll.start': (mEventId, mEvent, item, timelineSet, collapse) => {
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0;
const highlighted = focusItem?.index === item && focusItem.highlight;
return (
<Message
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={collapse}
highlight={highlighted}
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
reactions={
reactionRelations && (
<Reactions
style={{ marginTop: config.space.S200 }}
room={room}
relations={reactionRelations}
mEventId={mEventId}
canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle}
/>
)
}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : (
<PollContent content={mEvent.getContent()} roomId={room.roomId} eventId={mEvent.getId() ?? undefined} />
)}
</Message>
);
},
'm.poll.start': (mEventId, mEvent, item, timelineSet, collapse) => {
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0;
const highlighted = focusItem?.index === item && focusItem.highlight;
return (
<Message
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={collapse}
highlight={highlighted}
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
reactions={
reactionRelations && (
<Reactions
style={{ marginTop: config.space.S200 }}
room={room}
relations={reactionRelations}
mEventId={mEventId}
canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle}
/>
)
}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : (
<PollContent content={mEvent.getContent()} roomId={room.roomId} eventId={mEvent.getId() ?? undefined} />
)}
</Message>
);
},
[StateEvent.RoomMember]: (mEventId, mEvent, item) => {
const membershipChanged = isMembershipChanged(mEvent);
if (membershipChanged && hideMembershipEvents) return null;
@@ -1751,14 +1638,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
return null;
}
if (mEvent.isRedacted() && !showHiddenEvents) {
const t = mEvent.getType();
if (
t !== MessageEvent.RoomMessage &&
t !== MessageEvent.RoomMessageEncrypted &&
t !== MessageEvent.Sticker
) {
return null;
}
return null;
}
if (!newDivider && readUptoEventIdRef.current) {
@@ -1769,7 +1649,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}
const collapsed =
!perMessageProfiles &&
isPrevRendered &&
!dayDivider &&
(!newDivider || eventSender === mx.getUserId()) &&
+1 -10
View File
@@ -15,10 +15,6 @@ 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';
@@ -58,14 +54,9 @@ 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');
@@ -100,7 +91,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
);
return (
<Page ref={roomViewRef} style={getChatBg(chatBackground, isDark)}>
<Page ref={roomViewRef}>
<Box grow="Yes" direction="Column">
<RoomTimeline
key={roomId}
+1 -163
View File
@@ -21,7 +21,6 @@ import {
RectCords,
Badge,
Spinner,
Button,
} from 'folds';
import { useNavigate } from 'react-router-dom';
import { Room } from 'matrix-js-sdk';
@@ -33,7 +32,6 @@ 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';
@@ -70,9 +68,6 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { RoomSettingsPage } from '../../state/roomSettings';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
type RoomMenuProps = {
room: Room;
@@ -258,132 +253,6 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
);
});
type CallMenuProps = {
onVoiceCall: () => void;
onVideoCall: () => void;
requestClose: () => void;
};
const CallMenu = forwardRef<HTMLDivElement, CallMenuProps>(
({ requestClose, onVoiceCall, onVideoCall }, ref) => {
const handleVoice = () => {
onVoiceCall();
requestClose();
};
const handleVideo = () => {
onVideoCall();
requestClose();
};
return (
<Menu ref={ref} style={{ padding: config.space.S200, minWidth: toRem(150) }}>
<Box direction="Column" gap="200">
<Text size="L400">Start Call</Text>
<Box direction="Column" gap="200">
<Button
size="300"
variant="Success"
fill="Soft"
outlined
radii="300"
before={<Icon size="100" src={Icons.Phone} filled />}
onClick={handleVoice}
>
<Text size="B300">Voice</Text>
</Button>
<Button
size="300"
variant="Success"
radii="300"
before={<Icon size="100" src={Icons.VideoCamera} filled />}
onClick={handleVideo}
>
<Text size="B300">Video</Text>
</Button>
</Box>
</Box>
</Menu>
);
}
);
function CallButton() {
const room = useRoom();
const direct = useIsDirectRoom();
const callEmbed = useCallEmbed();
const startCall = useCallStart(direct);
const callStarted = callEmbed && callEmbed.roomId === room.roomId;
const inAnotherCall = callEmbed && !callStarted;
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
return (
<>
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
{inAnotherCall ? (
<Text size="L400">Already in another call End the current call to join!</Text>
) : (
<Text>Call</Text>
)}
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
variant="Surface"
fill="None"
ref={triggerRef}
onClick={handleOpenMenu}
onContextMenu={(evt) => {
evt.preventDefault();
startCall(room, {
microphone: true,
video: true,
sound: true,
});
}}
disabled={inAnotherCall || callStarted}
aria-pressed={!!menuAnchor}
>
<Icon size="400" src={Icons.VideoCamera} filled={!!menuAnchor} />
</IconButton>
)}
</TooltipProvider>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<CallMenu
onVideoCall={() => startCall(room, { microphone: true, video: true, sound: true })}
onVoiceCall={() => startCall(room, { microphone: true, video: false, sound: true })}
requestClose={() => setMenuAnchor(undefined)}
/>
</FocusTrap>
}
/>
</>
);
}
export function RoomViewHeader({ callView }: { callView?: boolean }) {
const navigate = useNavigate();
const mx = useMatrixClient();
@@ -391,21 +260,9 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
const screenSize = useScreenSizeContext();
const room = useRoom();
const space = useSpaceOptionally();
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const hasCallPermission = permissions.stateEvent(
StateEvent.GroupCallMemberPrefix,
mx.getSafeUserId()
);
const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported();
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);
@@ -522,23 +379,6 @@ 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"
@@ -613,9 +453,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
</FocusTrap>
}
/>
{!room.isCallRoom() && livekitSupported && rtcSupported && hasCallPermission && (
<CallButton />
)}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
@@ -1,129 +0,0 @@
import React, { ChangeEvent, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
config,
Input,
Line,
MenuItem,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Text,
} from 'folds';
import { MatrixEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { stopPropagation } from '../../../utils/keyboard';
type Props = {
mEvent: MatrixEvent;
onClose: () => void;
};
export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const mx = useMatrixClient();
const [query, setQuery] = useState('');
const [sentTo, setSentTo] = useState<string | null>(null);
const allRooms = mx
.getRooms()
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0));
const filtered = query
? allRooms.filter((r) => r.name.toLowerCase().includes(query.toLowerCase()))
: allRooms;
const forward = (roomId: string, roomName: string) => {
const fwdContent: Record<string, unknown> = { ...mEvent.getContent() };
delete fwdContent['m.relates_to'];
mx.sendMessage(roomId, fwdContent as any);
setSentTo(roomName);
setTimeout(onClose, 1200);
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal
size="400"
style={{ maxHeight: '440px', borderRadius: config.radii.R500, display: 'flex', flexDirection: 'column' }}
>
<Box
direction="Column"
gap="200"
shrink="No"
style={{ padding: config.space.S400, paddingBottom: config.space.S200 }}
>
<Text size="H5">Forward message</Text>
<Input
variant="Background"
size="400"
radii="400"
outlined
placeholder="Search rooms…"
value={query}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
/>
</Box>
<Line size="300" />
{sentTo ? (
<Box
grow="Yes"
alignItems="Center"
justifyContent="Center"
style={{ padding: config.space.S400 }}
>
<Text size="T300"> Forwarded to {sentTo}</Text>
</Box>
) : (
<Box grow="Yes" style={{ minHeight: 0 }}>
<Scroll size="300" hideTrack visibility="Hover">
<Box
direction="Column"
gap="100"
style={{ padding: config.space.S200 }}
>
{filtered.slice(0, 60).map((room) => (
<MenuItem
key={room.roomId}
size="300"
radii="300"
onClick={() => forward(room.roomId, room.name)}
>
<Text size="T300" truncate>
{room.name}
</Text>
</MenuItem>
))}
{filtered.length === 0 && (
<Box
alignItems="Center"
justifyContent="Center"
style={{ padding: config.space.S400 }}
>
<Text size="T300" priority="300">
No rooms found
</Text>
</Box>
)}
</Box>
</Scroll>
</Box>
)}
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
-24
View File
@@ -727,7 +727,6 @@ export const Message = as<'div', MessageProps>(
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();
const [forwardOpen, setForwardOpen] = useState(false);
const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
@@ -1029,26 +1028,6 @@ export const Message = as<'div', MessageProps>(
Reply
</Text>
</MenuItem>
{!mEvent.isRedacted() && (
<MenuItem
size="300"
after={<Icon src={Icons.ArrowRight} />}
radii="300"
onClick={() => {
setForwardOpen(true);
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Forward
</Text>
</MenuItem>
)}
{!isThreadedMessage && (
<MenuItem
size="300"
@@ -1166,9 +1145,6 @@ export const Message = as<'div', MessageProps>(
{msgContentJSX}
</ModernLayout>
)}
{forwardOpen && (
<ForwardMessageDialog mEvent={mEvent} onClose={() => setForwardOpen(false)} />
)}
</MessageBase>
);
}
+2 -2
View File
@@ -55,7 +55,7 @@ export const getImageMsgContent = async (
const content: IContent = {
msgtype: MsgType.Image,
filename: file.name,
body: metadata.caption?.trim() || file.name,
body: file.name,
[MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
};
if (imgEl) {
@@ -90,7 +90,7 @@ export const getVideoMsgContent = async (
const content: IContent = {
msgtype: MsgType.Video,
filename: file.name,
body: metadata.caption?.trim() || file.name,
body: file.name,
[MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
};
if (videoEl) {
+8 -9
View File
@@ -4,8 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import LotusLogo from '../../../../../public/res/Lotus.png';
import pkg from '../../../../../package.json';
import CinnySVG from '../../../../../public/res/svg/cinny.svg';
import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -39,23 +38,23 @@ export function About({ requestClose }: AboutProps) {
<Box shrink="No">
<img
style={{ width: toRem(60), height: toRem(60) }}
src={LotusLogo}
alt="Lotus Chat logo"
src={CinnySVG}
alt="Cinny logo"
/>
</Box>
<Box direction="Column" gap="300">
<Box direction="Column" gap="100">
<Box gap="100" alignItems="End">
<Text size="H3">Lotus Chat</Text>
<Text size="T200">v{pkg.version}</Text>
<Text size="H3">Cinny</Text>
<Text size="T200">v4.11.1</Text>
</Box>
<Text>A Matrix client for Lotus Guild.</Text>
<Text>Yet another matrix client.</Text>
</Box>
<Box gap="200" wrap="Wrap">
<Button
as="a"
href="https://code.lotusguild.org/LotusGuild/cinny"
href="https://github.com/cinnyapp/cinny"
rel="noreferrer noopener"
target="_blank"
variant="Secondary"
@@ -68,7 +67,7 @@ export function About({ requestClose }: AboutProps) {
</Button>
<Button
as="a"
href="https://matrix.lotusguild.org"
href="https://cinny.in/#sponsor"
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, 'lotus-keys.txt');
FileSaver.saveAs(blob, 'cinny-keys.txt');
},
[mx]
)
+1 -180
View File
@@ -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 { ChatBackground, DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
import { 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,13 +42,10 @@ 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';
@@ -310,8 +307,6 @@ 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">
@@ -355,56 +350,6 @@ 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>
);
}
@@ -766,7 +711,6 @@ 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">
@@ -786,13 +730,6 @@ 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"
@@ -804,121 +741,6 @@ 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');
@@ -1184,7 +1006,6 @@ 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: 'Lotus Chat',
brand: 'Cinny',
},
append: true,
});
+6 -15
View File
@@ -15,8 +15,6 @@ 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);
@@ -44,19 +42,15 @@ export const createCallEmbed = (
dm: boolean,
themeKind: ElementCallThemeKind,
container: HTMLElement,
pref?: CallPreferences,
noiseSuppression = true,
forceAudioOff = false
pref?: CallPreferences
): CallEmbed => {
const rtcSession = mx.matrixRTC.getRoomSession(room);
const ongoing =
MatrixRTCSession.sessionMembershipsForRoom(room, rtcSession.sessionDescription).length > 0;
const intent = CallEmbed.getIntent(dm, ongoing, pref?.video);
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 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 embed = new CallEmbed(mx, room, widget, container, controlState);
@@ -68,8 +62,6 @@ 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) => {
@@ -77,11 +69,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, callNoiseSuppression ?? true, !!pttMode);
const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref);
setCallEmbed(callEmbed);
},
[mx, dm, theme, setCallEmbed, callEmbedRef, callNoiseSuppression, pttMode]
[mx, dm, theme, setCallEmbed, callEmbedRef]
);
return startCall;
@@ -109,7 +101,6 @@ export const useCallJoined = (embed?: CallEmbed): boolean => {
export const useCallHangupEvent = (embed: CallEmbed, callback: () => void) => {
useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback);
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback);
};
export const useCallMemberSoundSync = (embed: CallEmbed) => {
-1
View File
@@ -18,7 +18,6 @@ export type ClientConfig = {
};
hashRouter?: HashRouterConfig;
gifApiKey?: string;
};
const ClientConfigContext = createContext<ClientConfig | null>(null);
-87
View File
@@ -1,87 +0,0 @@
import { Room } from 'matrix-js-sdk';
import {
MatrixRTCSession,
MatrixRTCSessionEvent,
} from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
import { MatrixRTCSessionManagerEvents } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSessionManager';
import { useEffect, useRef, useState } from 'react';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from './useMatrixClient';
import { callEmbedAtom } from '../state/callEmbed';
export type IncomingDmCall = {
room: Room;
callerId: string;
};
const isDmRoom = (room: Room): boolean =>
room.hasEncryptionStateEvent() && room.getMembers().length <= 2;
export const useIncomingDmCall = (): [IncomingDmCall | null, () => void] => {
const mx = useMatrixClient();
const callEmbed = useAtomValue(callEmbedAtom);
const [incoming, setIncoming] = useState<IncomingDmCall | null>(null);
const sessionRef = useRef<MatrixRTCSession | null>(null);
useEffect(() => {
const myUserId = mx.getUserId();
const handleSessionStarted = (roomId: string, session: MatrixRTCSession) => {
if (callEmbed) return;
const room = mx.getRoom(roomId);
if (!room || !isDmRoom(room)) return;
const memberships = session.memberships ?? [];
if (memberships.length === 0) return;
const callerMembership = memberships.find((m) => m.sender !== myUserId);
if (!callerMembership) return;
if (memberships.find((m) => m.sender === myUserId)) return;
sessionRef.current = session;
setIncoming({ room, callerId: callerMembership.sender ?? callerMembership.deviceId });
};
const handleSessionEnded = (roomId: string) => {
setIncoming((prev) => (prev?.room.roomId === roomId ? null : prev));
};
mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, handleSessionStarted);
mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded);
return () => {
mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, handleSessionStarted);
mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded);
};
}, [mx, callEmbed]);
// Dismiss if caller leaves before answered
useEffect(() => {
const session = sessionRef.current;
if (!session || !incoming) return;
const myUserId = mx.getUserId();
const check = () => {
const memberships = session.memberships ?? [];
if (!memberships.some((m) => m.sender !== myUserId)) setIncoming(null);
};
session.on(MatrixRTCSessionEvent.MembershipsChanged, check);
return () => {
session.off(MatrixRTCSessionEvent.MembershipsChanged, check);
};
}, [incoming, mx]);
// Auto-dismiss after 30 seconds
useEffect(() => {
if (!incoming) return;
const t = setTimeout(() => setIncoming(null), 30_000);
return () => clearTimeout(t);
}, [incoming]);
// Dismiss when user joins a call
useEffect(() => {
if (callEmbed) setIncoming(null);
}, [callEmbed]);
const dismiss = () => {
sessionRef.current = null;
setIncoming(null);
};
return [incoming, dismiss];
};
+6 -50
View File
@@ -1,10 +1,9 @@
import { useCallback } from 'react';
import { MatrixClient, Method } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { AsyncState, useAsyncCallbackValue } from './useAsyncCallback';
import { useSpecVersions } from './useSpecVersions';
export const useUnstableMutualRoomsSupport = (): boolean => {
export const useMutualRoomsSupport = (): boolean => {
const { unstable_features: unstableFeatures } = useSpecVersions();
const supported =
@@ -15,59 +14,16 @@ export const useUnstableMutualRoomsSupport = (): boolean => {
return !!supported;
};
export const useMutualRoomsSupport = (): boolean => {
const { unstable_features: unstableFeatures, versions } = useSpecVersions();
const supported =
versions.includes('v1.19') ||
unstableFeatures?.['uk.half-shot.msc2666.query_mutual_rooms.stable'];
return !!supported;
};
type MutualRoomsOK = {
joined: string[];
next_batch?: string;
count: number;
};
const fetchAllMutualRooms = async (mx: MatrixClient, userId: string): Promise<string[]> => {
const mutualRooms: Set<string> = new Set();
let nextBatch: string | undefined;
do {
// eslint-disable-next-line no-await-in-loop
const result = await mx.http.authedRequest<MutualRoomsOK>(
Method.Get,
'/mutual_rooms',
{
user_id: userId,
from: nextBatch,
},
undefined,
{
prefix: '/_matrix/client/v1',
}
);
result.joined.forEach((r) => mutualRooms.add(r));
nextBatch = result.next_batch;
} while (typeof nextBatch === 'string');
return Array.from(mutualRooms);
};
export const useMutualRooms = (userId: string): AsyncState<string[], unknown> => {
const mx = useMatrixClient();
const unstableSupport = useUnstableMutualRoomsSupport();
const support = useMutualRoomsSupport();
const supported = useMutualRoomsSupport();
const [mutualRoomsState] = useAsyncCallbackValue(
useCallback(() => {
if (support) return fetchAllMutualRooms(mx, userId);
if (unstableSupport) return mx._unstable_getSharedRooms(userId);
return Promise.resolve([]);
}, [mx, userId, unstableSupport, support])
useCallback(
() => (supported ? mx._unstable_getSharedRooms(userId) : Promise.resolve([])),
[mx, userId, supported]
)
);
return mutualRoomsState;
+1 -11
View File
@@ -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, lotusTerminalLightTheme, lotusTerminalTheme, silverTheme } from '../../colors.css';
import { butterTheme, darkTheme, silverTheme } from '../../colors.css';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
@@ -37,16 +37,6 @@ 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], []);
-2
View File
@@ -69,7 +69,6 @@ import { CreateSpaceModalRenderer } from '../features/create-space';
import { SearchModalRenderer } from '../features/search';
import { getFallbackSession } from '../state/sessions';
import { CallStatusRenderer } from './CallStatusRenderer';
import { IncomingCallNotification } from '../components/IncomingCallNotification';
import { CallEmbedProvider } from '../components/CallEmbedProvider';
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
@@ -138,7 +137,6 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Outlet />
</ClientLayout>
<CallStatusRenderer />
<IncomingCallNotification />
</CallEmbedProvider>
<SearchModalRenderer />
<UserRoomProfileRenderer />
+12 -35
View File
@@ -3,39 +3,27 @@ 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);
} else {
document.body.classList.add(...LightTheme.classNames);
}
if (systemThemeKind === ThemeKind.Dark) {
document.body.classList.add(...DarkTheme.classNames);
}
}, [systemThemeKind, lotusTerminal]);
if (systemThemeKind === ThemeKind.Light) {
document.body.classList.add(...LightTheme.classNames);
}
}, [systemThemeKind]);
return null;
}
@@ -43,30 +31,19 @@ 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(...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.classList.add(...activeTheme.classNames);
if (monochromeMode) {
document.body.style.filter = 'grayscale(1)';
} else {
document.body.style.filter = '';
}
}, [effectiveTheme, monochromeMode, lotusTerminal, terminalIsLight]);
}, [activeTheme, monochromeMode]);
return <ThemeContextProvider value={effectiveTheme}>{children}</ThemeContextProvider>;
return <ThemeContextProvider value={activeTheme}>{children}</ThemeContextProvider>;
}
+5 -6
View File
@@ -1,25 +1,24 @@
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://lotusguild.org" target="_blank" rel="noreferrer">
<Text as="a" size="T300" href="https://cinny.in" target="_blank" rel="noreferrer">
About
</Text>
<Text
as="a"
size="T300"
href="https://code.lotusguild.org/LotusGuild/cinny/releases"
href="https://github.com/ajbura/cinny/releases"
target="_blank"
rel="noreferrer"
>
v{pkg.version}
v4.11.1
</Text>
<Text as="a" size="T300" href="https://matrix.lotusguild.org" target="_blank" rel="noreferrer">
Community
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
Twitter
</Text>
<Text as="a" size="T300" href="https://matrix.org" target="_blank" rel="noreferrer">
Powered by Matrix
+3 -3
View File
@@ -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 LotusLogo from '../../../../public/res/Lotus.png';
import CinnySVG from '../../../../public/res/svg/cinny.svg';
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={LotusLogo} alt="Lotus Chat Logo" />
<Text size="H3">Lotus Chat</Text>
<img className={css.AuthLogo} src={CinnySVG} alt="Cinny Logo" />
<Text size="H3">Cinny</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: 'Lotus Chat Web',
initial_device_display_name: 'Cinny Web',
});
};
@@ -151,7 +151,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
user: mxIdUsername,
},
password,
initial_device_display_name: 'Lotus Chat Web',
initial_device_display_name: 'Cinny Web',
});
};
const handleEmailLogin = (email: string, password: string) => {
@@ -163,7 +163,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
address: email,
},
password,
initial_device_display_name: 'Lotus Chat Web',
initial_device_display_name: 'Cinny Web',
});
};
+1 -1
View File
@@ -57,7 +57,7 @@ export function TokenLogin({ token }: TokenLoginProps) {
startLogin(baseUrl, {
type: 'm.login.token',
token,
initial_device_display_name: 'Lotus Chat Web',
initial_device_display_name: 'Cinny Web',
});
}, [baseUrl, token, startLogin]);
@@ -109,7 +109,7 @@ function RegisterUIAFlow({
auth: authDict,
password,
username,
initial_device_display_name: 'Lotus Chat Web',
initial_device_display_name: 'Cinny Web',
});
},
[onRegister, formData]
@@ -250,7 +250,7 @@ export function PasswordRegisterForm({
auth: {
session: authData.session,
},
initial_device_display_name: 'Lotus Chat Web',
initial_device_display_name: 'Cinny Web',
});
};
+3 -3
View File
@@ -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/lotus.png';
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png';
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 NotificationSound from '../../../../public/sound/notification.ogg';
import InviteSound from '../../../../public/sound/invite.ogg';
import { notificationPermission, setFavicon } from '../../utils/dom';
+8 -9
View File
@@ -1,8 +1,7 @@
import React from 'react';
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
import { Page, PageHero, PageHeroSection } from '../../components/page';
import LotusLogo from '../../../../public/res/Lotus.png';
import pkg from '../../../../package.json';
import CinnySVG from '../../../../public/res/svg/cinny.svg';
export function WelcomePage() {
return (
@@ -15,17 +14,17 @@ export function WelcomePage() {
>
<PageHeroSection>
<PageHero
icon={<img width="70" height="70" src={LotusLogo} alt="Lotus Chat" style={{ objectFit: "contain" }} />}
title="Welcome to Lotus Chat"
icon={<img width="70" height="70" src={CinnySVG} alt="Cinny Logo" />}
title="Welcome to Cinny"
subTitle={
<span>
A Matrix client for Lotus Guild.{' '}
Yet another matrix client.{' '}
<a
href="https://code.lotusguild.org/LotusGuild/cinny/releases"
href="https://github.com/cinnyapp/cinny/releases"
target="_blank"
rel="noreferrer noopener"
>
v{pkg.version}
v4.11.1
</a>
</span>
}
@@ -34,7 +33,7 @@ export function WelcomePage() {
<Box grow="Yes" style={{ maxWidth: toRem(300) }} direction="Column" gap="300">
<Button
as="a"
href="https://code.lotusguild.org/LotusGuild/cinny"
href="https://github.com/cinnyapp/cinny"
target="_blank"
rel="noreferrer noopener"
before={<Icon size="200" src={Icons.Code} />}
@@ -45,7 +44,7 @@ export function WelcomePage() {
</Button>
<Button
as="a"
href="https://matrix.lotusguild.org"
href="https://cinny.in/#sponsor"
target="_blank"
rel="noreferrer noopener"
fill="Soft"
+8 -24
View File
@@ -29,13 +29,17 @@ export class CallControl extends EventEmitter implements CallControlState {
}
private get settingsButton(): HTMLElement | 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;
const leaveBtn = this.document?.querySelector('[data-testid="incall_leave"]');
const settingsButton = leaveBtn?.previousElementSibling as HTMLElement | null;
return settingsButton ?? undefined;
}
private get reactionsButton(): HTMLElement | 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;
const reactionsButton = this.settingsButton?.previousElementSibling as HTMLElement | null;
return reactionsButton ?? undefined;
}
private get spotlightButton(): HTMLInputElement | undefined {
@@ -97,11 +101,6 @@ 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();
@@ -161,8 +160,6 @@ 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;
@@ -174,19 +171,6 @@ 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() {
+9 -74
View File
@@ -47,50 +47,19 @@ export class CallEmbed {
private readonly disposables: Array<() => void> = [];
private readonly initialState: CallControlState;
private styleRetryObserver?: MutationObserver;
static getIntent(dm: boolean, ongoing: boolean, video?: boolean): ElementCallIntent {
if (dm && ongoing) {
return video ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExistingDMVoice;
}
if (dm) {
return video ? ElementCallIntent.StartCallDM : ElementCallIntent.StartCallDMVoice;
}
static getIntent(dm: boolean, ongoing: boolean): ElementCallIntent {
if (ongoing) {
return video ? ElementCallIntent.JoinExisting : ElementCallIntent.JoinExistingVoice;
return dm ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExisting;
}
return video ? ElementCallIntent.StartCall : ElementCallIntent.StartCallVoice;
}
static dmCall(intent: ElementCallIntent): boolean {
return (
intent === ElementCallIntent.JoinExistingDM ||
intent === ElementCallIntent.JoinExistingDMVoice ||
intent === ElementCallIntent.StartCallDM ||
intent === ElementCallIntent.StartCallDMVoice
);
}
static startingCall(intent: ElementCallIntent): boolean {
return (
intent === ElementCallIntent.StartCallDM ||
intent === ElementCallIntent.StartCallDMVoice ||
intent === ElementCallIntent.StartCall ||
intent === ElementCallIntent.StartCallVoice
);
return dm ? ElementCallIntent.StartCallDM : ElementCallIntent.StartCall;
}
static getWidget(
mx: MatrixClient,
room: Room,
intent: ElementCallIntent,
themeKind: ElementCallThemeKind,
noiseSuppression = true,
initialAudio = true,
initialVideo = false
themeKind: ElementCallThemeKind
): Widget {
const userId = mx.getSafeUserId();
const deviceId = mx.getDeviceId() ?? '';
@@ -112,16 +81,8 @@ export class CallEmbed {
perParticipantE2EE: room.hasEncryptionStateEvent().toString(),
lang: 'en-EN',
theme: themeKind,
noiseSuppression: noiseSuppression.toString(),
audio: initialAudio.toString(),
video: initialVideo.toString(),
header: 'none',
});
if (!room.isCallRoom() && CallEmbed.startingCall(intent)) {
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
}
const widgetUrl = new URL(
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`,
window.location.origin
@@ -182,7 +143,6 @@ 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(
@@ -267,7 +227,6 @@ export class CallEmbed {
this.disposables.forEach((disposable) => {
disposable();
});
this.styleRetryObserver?.disconnect();
this.call.stop();
this.container.removeChild(this.iframe);
this.control.dispose();
@@ -286,8 +245,6 @@ 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 {
@@ -295,33 +252,11 @@ export class CallEmbed {
if (!doc) return;
doc.body.style.setProperty('background', 'none', 'important');
// 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 });
const controls = doc.body.querySelector('[data-testid="incall_leave"]')?.parentElement
?.parentElement;
if (controls) {
controls.style.setProperty('position', 'absolute');
controls.style.setProperty('visibility', 'hidden');
}
}
-2
View File
@@ -1,8 +1,6 @@
export enum ElementCallIntent {
StartCall = 'start_call',
JoinExisting = 'join_existing',
StartCallVoice = 'start_call_voice',
JoinExistingVoice = 'join_existing_voice',
StartCallDM = 'start_call_dm',
JoinExistingDM = 'join_existing_dm',
StartCallDMVoice = 'start_call_dm_voice',
+7 -3
View File
@@ -15,8 +15,6 @@ export function getCallCapabilities(
capabilities.add(MatrixCapabilities.Screenshots);
capabilities.add(MatrixCapabilities.AlwaysOnScreen);
capabilities.add(MatrixCapabilities.MSC4039UploadFile);
capabilities.add(MatrixCapabilities.MSC4039DownloadFile);
capabilities.add(MatrixCapabilities.MSC3846TurnServers);
capabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent);
capabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent);
@@ -80,13 +78,19 @@ export function getCallCapabilities(
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomCreate).raw
);
capabilities.add(
WidgetEventCapability.forRoomEvent(
EventDirection.Receive,
'org.matrix.msc4075.rtc.notification'
).raw
);
[
'io.element.call.encryption_keys',
'org.matrix.rageshake_request',
EventType.Reaction,
EventType.RoomRedaction,
'io.element.call.reaction',
'org.matrix.msc4075.rtc.notification',
'org.matrix.msc4310.rtc.decline',
].forEach((type) => {
capabilities.add(WidgetEventCapability.forRoomEvent(EventDirection.Send, type).raw);
+2 -5
View File
@@ -28,13 +28,10 @@ export const makeCallPreferencesAtom = (userId: string): CallPreferencesAtom =>
storeKey,
(key) => {
const v = getLocalStorageItem<CallPreferences>(key, DEFAULT_PREFERENCES);
// Never restore camera state — always start with camera off for privacy.
// Users can toggle it in the prescreen before joining.
return { ...v, video: false };
return v;
},
(key, value) => {
// Don't persist video state — always resets to off on next load.
setLocalStorageItem(key, { ...value, video: false });
setLocalStorageItem(key, value);
}
);
-4
View File
@@ -1,8 +1,6 @@
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;
@@ -23,7 +21,6 @@ export const useCallPreferences = (): CallPreferences & {
} => {
const callPrefAtom = useCallPreferencesAtom();
const [pref, setPref] = useAtom(callPrefAtom);
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
const toggleMicrophone = useCallback(() => {
const microphone = !pref.microphone;
@@ -57,7 +54,6 @@ export const useCallPreferences = (): CallPreferences & {
return {
...pref,
video: cameraOnJoin ? pref.video : false,
toggleMicrophone,
toggleVideo,
toggleSound,
-1
View File
@@ -9,7 +9,6 @@ import { createListAtom } from '../list';
export type TUploadMetadata = {
markedAsSpoiler: boolean;
caption?: string;
};
export type TUploadItem = {
-19
View File
@@ -9,7 +9,6 @@ export type DateFormat =
| '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,
@@ -48,15 +47,6 @@ export interface Settings {
dateFormatString: string;
developerTools: boolean;
lotusTerminal: boolean;
chatBackground: ChatBackground;
perMessageProfiles: boolean;
cameraOnJoin: boolean;
callNoiseSuppression: boolean;
pttMode: boolean;
pttKey: string;
}
const defaultSettings: Settings = {
@@ -91,15 +81,6 @@ 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 = () => {
-47
View File
@@ -1,47 +0,0 @@
import { keyframes, style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
const wobble = keyframes({
'0%': {
transform: 'translateX(0) rotateZ(0deg)',
},
'20%': {
transform: `translateX(-${toRem(4)}) rotateZ(-4deg)`,
},
'40%': {
transform: `translateX(${toRem(4)}) rotateZ(4deg)`,
},
'60%': {
transform: `translateX(-${toRem(3)}) rotateZ(-3deg)`,
},
'80%': {
transform: `translateX(${toRem(3)}) rotateZ(3deg)`,
},
'100%': {
transform: 'translateX(0) rotateZ(0deg)',
},
});
const glowPulse = keyframes({
'0%': {
boxShadow: `0 0 0 ${toRem(0)} ${color.Success.ContainerActive}`,
},
'100%': {
boxShadow: `0 0 0 ${toRem(8)} ${color.Success.ContainerActive}`,
},
});
export const WobbleAnimation = style({
animation: `${wobble} 2000ms ease-in-out`,
animationIterationCount: 'infinite',
});
export const GlowAnimation = style({
animation: `${glowPulse} 2000ms ease-out`,
animationIterationCount: 'infinite',
});
export const CallAvatarAnimation = style({
animation: `${wobble} 2000ms ease-in-out, ${glowPulse} 2000ms ease-out`,
animationIterationCount: 'infinite',
});
+1 -9
View File
@@ -233,15 +233,7 @@ export const notificationPermission = (permission: NotificationPermission) => {
if ('Notification' in window) {
return window.Notification.permission === permission;
}
try {
// https://stackoverflow.com/questions/29774836/failed-to-construct-notification-illegal-constructor
// https://issues.chromium.org/issues/40415865
// eslint-disable-next-line no-new
new Notification('');
} catch {
return false;
}
return true;
return false;
};
export const getMouseEventCords = (event: MouseEvent) => ({
-5
View File
@@ -31,7 +31,6 @@ export const APPLICATION_MIME_TYPES = [
'application/javascript',
'application/xhtml+xml',
'application/xml',
'application/ogg',
];
export const TEXT_MIME_TYPE = [
@@ -116,10 +115,6 @@ export const getBlobSafeMimeType = (mimeType: string) => {
if (type === 'video/quicktime') {
return 'video/mp4';
}
// Fixes missing playback for Ogg audio
if (type === 'application/ogg') {
return 'audio/ogg';
}
return type;
};
-4
View File
@@ -1,4 +0,0 @@
export const webRTCSupported = () =>
['RTCPeerConnection', 'webkitRTCPeerConnection', 'mozRTCPeerConnection', 'RTCIceGatherer'].some(
(item) => item in window
);
-186
View File
@@ -236,189 +236,3 @@ 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)',
},
});
-77
View File
@@ -1,77 +0,0 @@
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);
}
-577
View File
@@ -1,577 +0,0 @@
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:36143684: 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)',
});