Compare commits

...

33 Commits

Author SHA1 Message Date
root c8d9906788 chore: merge v4.12.1 — security, calling, editor, media fixes
Key v4.12.1 changes merged:
- Security: sanitize-html updated to v2.17.4
- Calling: video calls in DMs/rooms, user avatars during calls, right-click to start
- Calling: IncomingCallListener with ring sound and answer/reject UI
- Editor: list crash fixes (Firefox + empty headings), codeblock filename support
- Media: URL preview hover state, keyboard nav, click-to-open, OGG audio support
- Date: ISO 8601 (YYYY-MM-DD) date format option
- Misc: stable mutual rooms endpoint, Android notification crash fix

Lotus customisations preserved:
- PiP drag/resize, DM call ring notification, PTT, GIF picker, noise suppression
- Poll voting, message forwarding, image captions, location sharing
- Lotus Terminal design theme
2026-05-15 13:43:04 -04:00
root 5bba52e315 feat: poll voting, location sharing, image captions, message forwarding
- Poll voting: PollContent sends m.poll.response on answer click
- Location: MLocation shows OSM map embed + share-location button in toolbar
- Image captions: caption field on media uploads sets message body
- Message forwarding: ForwardMessageDialog with searchable room picker
- Also includes ring timeout fix and earlier session patches
2026-05-15 13:37:03 -04:00
root e89ba95c08 docs: add poll display and deleted message placeholder to README 2026-05-15 00:49:44 -04:00
root 2958ae9321 fix+feat: bug fixes, deleted message placeholder, poll display
Bug fixes:
- IncomingCallNotification: track ring setTimeout ID and clear it on
  cleanup/dismiss — prevents orphaned callbacks after unmount
- RoomTimeline: allow redacted m.room.message, m.room.encrypted and
  m.sticker events past the early-return filter so they hit the
  existing RedactedContent renderer showing the trash-icon placeholder

New features:
- PollContent component: read-only display of m.poll.start and
  org.matrix.msc3381.poll.start events (both stable Matrix 1.7 and
  MSC3381 unstable content keys); renders poll question + answer
  options inside the standard Message bubble; registered both as
  top-level event renderers and inside EncryptedContent callback
  so encrypted polls also render after decryption
- Deleted message placeholder: m.room.message and m.room.encrypted
  redacted events now show the existing MessageDeletedContent
  component (trash icon + italic notice) instead of disappearing
  entirely — matches Element, FluffyChat, Commet, Nheko behaviour
2026-05-15 00:47:21 -04:00
root a7aa2751a6 docs: add incoming DM call notification to README 2026-05-14 23:37:45 -04:00
root a986eaa1ea feat: incoming DM call notification with ring tone
When another user starts a call in a DM room, show a fixed-position
notification with caller avatar, name, and Answer/Decline buttons.
A Web Audio API double-pulse ring tone plays until answered, declined,
or the 30-second auto-dismiss fires.

- useIncomingDmCall hook: listens to MatrixRTC SessionStarted/SessionEnded,
  filters DM rooms (encrypted, ≤2 members), auto-dismisses after 30s,
  stops if caller leaves or user joins another call
- IncomingCallNotification component: ring tone, caller info, themed UI
  for LotusGuild Terminal TDS (navy bg, orange border, neon-green Answer)
  and standard Cinny dark/light (CSS vars, folds Button Success/Critical)
- Router.tsx: mount IncomingCallNotification inside CallEmbedProvider
2026-05-14 23:37:07 -04:00
root 9253fc33fd feat: resizable PiP call window
Add 4 corner resize handles to the PiP window (SE/SW/NE/NW).
Each handle shows a 3-dot grip indicator and sets the appropriate
resize cursor. Resize handles sit above the drag overlay (zIndex 2)
and stop propagation so they do not trigger drag-to-move.

On resize start the element is normalised to top/left positioning
so math is consistent regardless of whether bottom/right was active.
Minimum size 200x112px. Viewport clamped.
2026-05-14 23:07:29 -04:00
root bd9dbb5e83 fix: correct settings and reactions button selectors for EC 0.19.3
EC 0.19.3 changed the toolbar layout. The old previousElementSibling
traversal from the leave button pointed at wrong elements:
- settingsButton was finding the raise-hand button
- reactionsButton was finding the screenshare button

Fix: use stable selectors instead:
- settingsButton: data-testid=settings-bottom-center (new in EC 0.19.3)
- reactionsButton: [class*=raiseHand] (CSS module class, consistent in 0.19.x)
2026-05-14 23:00:05 -04:00
root 70eb0edc47 fix: pass room to startCall in DM call button
onClick={startCall} was passing the MouseEvent as the room argument,
causing getLiveTimeline is not a function crash in getRoomSession.
2026-05-14 22:51:36 -04:00
root 75a05cf83d feat: draggable PiP call window
Drag the PiP window anywhere on screen to move it out of the way.
Click (without dragging) still navigates back to the call room.
5px movement threshold distinguishes drag from click.
Mouse and touch both supported. Cursor shows grab/grabbing cues.
2026-05-14 22:50:20 -04:00
root 44b48b05b4 docs: update README with recent feature additions
- EC upgraded to 0.19.3
- Auto-revert spotlight on screenshare
- DM calls (phone button + Room.tsx layout switch)
- Picture-in-picture call window
- PTT badge terminal theming
- GIF picker (Giphy SDK, FocusTrap close, terminal theme)
- Technical notes for CallEmbedProvider, GifPicker, useClientConfig
2026-05-14 22:45:04 -04:00
root 9f6220b1bb fix: PTT input guard, listener stability, focus restore mute, single badge 2026-05-14 20:14:06 -04:00
root 20abfc0342 fix: PTT iframe focus, folds-native PTT badge styling 2026-05-14 19:37:19 -04:00
root 94722c8a97 fix: PTT blur/unmute, EC button hiding robustness, PTT status indicator 2026-05-14 19:29:45 -04:00
root 69091bc055 fix: re-apply desired device state after EC joins, support mid-call PTT 2026-05-14 19:14:29 -04:00
root c37220eb21 fix: pass audio/video URL params to EC for correct initial device state
- Camera no longer starts enabled when user disables it in prescreen
- When PTT mode is enabled, call starts muted so PTT works immediately
  without requiring a manual mute first
- CallControlState also updated to match the forced-off audio for PTT

EC 0.16.x ignores io.element.device_mute for initial state at startup,
so audio= and video= URL params are the only reliable way to set the
initial device state before the call begins.
2026-05-14 18:54:09 -04:00
root f3c2babd4b fix(call): show mic-denied error before joining instead of crashing
Check navigator.permissions for microphone state before the call starts.
If the user has blocked microphone access, disable the Join button and
show an inline message explaining how to fix it in browser settings.
Subscribes to permission change events so the UI updates if they grant
access without refreshing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:56:38 -04:00
root 7b5fbb7e3b fix: set config.json to matrix.lotusguild.org by default
Remove upstream Cinny homeserver list and set Lotus Guild homeserver
as the only default. Prevents deploying with wrong homeserver on fresh builds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:47:22 -04:00
root 109eac91f9 docs: replace upstream README with Lotus Chat changes diff
Lists all differences from upstream Cinny: branding, TDS dark/light themes,
chat backgrounds, call improvements (PTT/deafen/screenshare confirm/noise
suppression/camera default), new settings section, and technical changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:27:05 -04:00
root a23851d4a6 feat(call): PTT, deafen label, camera default off, screenshare confirm, noise suppression setting
- Push to Talk: keydown/keyup binds mic to configurable key (default Space)
  with visual PTT indicator and key-binding UI in Settings > General > Calls
- Camera always defaults OFF on join; cameraOnJoin setting for explicit opt-in
- Deafen button tooltip corrected to Deafen/Undeafen instead of Turn Off/On Sound
- Screenshare confirmation dialog before broadcasting to call participants
- Noise suppression toggle wired from settings through CallEmbed URL params
- CallControl.setMicrophone() public method for programmatic mic control
- Calls settings section added to General settings page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:14:04 -04:00
root 2dfdda5d8c Add TDS light mode: LotusTerminalLightTheme, light CSS vars, no CRT effects 2026-05-14 09:25:39 -04:00
root cfe52d623a Audit fixes: Lotus URLs, branding, editor toolbar setting, dynamic version 2026-05-13 23:03:14 -04:00
root 01781554a2 Fix welcome logo, real hex grid, Matrix boot messages, deeper TDS coverage
- WelcomePage: use official Lotus.png instead of generated SVG
- Hex Grid background: proper pointy-top hexagons via SVG data URI (was
  just triangles from linear-gradient trick)
- Boot sequence: Matrix-specific messages (TLS cert, E2EE Olm/Megolm,
  cross-signing, media proxy, /help hint)
- Terminal mode CSS: nav right border, header bottom glow, kbd TDS key
  style, abbr cyan underline, time amber color, img hover cyan outline,
  explicit body color anchor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:52:33 -04:00
root c6b1a9d75f Deepen TDS integration: full terminal CSS coverage + 3 new backgrounds
Terminal Mode:
- Text selection: orange highlight rgba(255,107,0,0.28)
- Links: cyan (#00D4FF) with orange hover glow (#FF6B00)
- Code/pre: TDS green (#00FF88) on terminal bg, left green border
- Strong/b → orange, em/i → cyan, mark → amber, del → red
- Blockquote: orange left border (matches chat reply quotes)
- HR: cyan border with dim glow
- Input/textarea/[contenteditable] focus: orange glow ring
- Tables: TDS headers (orange+uppercase), cyan borders, hover rows
- List markers: cyan ▸ for ul, orange for ol
- Boot box-drawing alignment fixed (51→52 ═)
- data-theme=\"dark\" set on html element when terminal active
- Updated description: correct TDS palette names
- ▶ Boot replay button in settings (visible when terminal on)

Chat backgrounds (+3):
- Tactical: LotusGuild TDS exact cyan dot-grid (28px)
- Circuit: green grid + node dots on dark terminal bg
- Hex Grid: isometric cyan hexagonal outlines

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:44:34 -04:00
root 9ebce5b00c Implement LotusGuild TDS v1.2 terminal mode
- Rewrite lotus-terminal.css.ts: TDS-exact dot-grid bg, scanlines, vignette,
  glitch keyframes, orange caret, cyan scrollbars, all --lt-* CSS vars
- Fix lotusTerminalTheme in colors.css.ts: full TDS color palette
  (Orange primary, Cyan secondary, Green success, Amber warning, Red critical)
- Add lotus-boot.ts: matrix boot sequence at 65ms intervals, green phosphor glow
- Update ThemeManager.tsx: call runLotusBootSequence on terminal mode activate,
  UnAuthRouteThemeManager now supports lotusTerminal setting
- Update index.html: add JetBrains Mono + VT323 from Google Fonts
2026-05-13 22:36:48 -04:00
root 185eb160e7 Add Lotus Terminal Mode + fix all remaining Cinny branding
- New: Lotus Terminal Mode toggle in Appearance settings
  - Red phosphor color scheme (bg #0a0000, primary #ff3300, accent #00dd66)
  - Monospace font override (JetBrains Mono / Fira Code / Cascadia Code)
  - Retro CRT scanline overlay via CSS pseudo-element
  - Wired into ThemeManager with dedicated lotusTerminalBodyClass
- Branding: replace all user-visible Cinny references with Lotus Chat
  - WelcomePage, AuthLayout, SplashScreen, index.html meta tags
  - Device display names in login/register/token flows
  - System notification brand field
  - (Preserved internal Matrix protocol CinnySpaces event type)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:22:06 -04:00
root 2e12c742fb Add chevron, polka, triangles, plaid backgrounds 2026-05-13 22:01:16 -04:00
root f2bcd65a9b Backgrounds: theme-aware patterns and visual preview grid 2026-05-13 21:51:19 -04:00
root 77f0c0d4ca Redesign chat backgrounds: blueprint, carbon, stars, topographic, herringbone, crosshatch 2026-05-13 21:42:12 -04:00
root 13df48c658 Phase 2+3: Chat backgrounds and per-message profiles settings 2026-05-13 21:17:59 -04:00
root 9b68b4ae53 Replace generated SVG icons with official Lotus.png variants 2026-05-13 19:58:52 -04:00
root f914b59c07 Use official Lotus.png logo in About and Auth pages 2026-05-13 19:53:29 -04:00
root 1d086dda77 Phase 1: Lotus Chat branding — title, favicon, logo, meta tags, SVG icons, all icon sizes 2026-05-13 17:56:39 -04:00
83 changed files with 3137 additions and 276 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"matrix.lotusguild.org"
],
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [],
"rooms": [],
"servers": []
},
"hashRouter": {
"enabled": false,
"basename": "/"
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"matrix.lotusguild.org"
],
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [],
"rooms": [],
"servers": []
},
"hashRouter": {
"enabled": false,
"basename": "/"
}
}
+52 -102
View File
@@ -1,115 +1,65 @@
# Cinny
<p>
<a href="https://github.com/ajbura/cinny/releases">
<img alt="GitHub release downloads" src="https://img.shields.io/github/downloads/ajbura/cinny/total?logo=github&style=social"></a>
<a href="https://hub.docker.com/r/ajbura/cinny">
<img alt="DockerHub downloads" src="https://img.shields.io/docker/pulls/ajbura/cinny?logo=docker&style=social"></a>
<a href="https://fosstodon.org/@cinnyapp">
<img alt="Follow on Mastodon" src="https://img.shields.io/mastodon/follow/106845779685925461?domain=https%3A%2F%2Ffosstodon.org&logo=mastodon&style=social"></a>
<a href="https://twitter.com/intent/follow?screen_name=cinnyapp">
<img alt="Follow on Twitter" src="https://img.shields.io/twitter/follow/cinnyapp?logo=twitter&style=social"></a>
<a href="https://cinny.in/#sponsor">
<img alt="Sponsor Cinny" src="https://img.shields.io/opencollective/all/cinny?logo=opencollective&style=social"></a>
</p>
# Lotus Chat
A Matrix client focusing primarily on simple, elegant and secure interface. The main goal is to have an instant messaging application that is easy on people and has a modern touch.
- [Roadmap](https://github.com/orgs/cinnyapp/projects/1)
- [Contributing](./CONTRIBUTING.md)
A Matrix client for [Lotus Guild](https://lotusguild.org) — forked from [Cinny](https://github.com/cinnyapp/cinny) v4.11.1.
> [!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.
Deployed at [chat.lotusguild.org](https://chat.lotusguild.org).
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380">
---
## 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.
## Changes from upstream Cinny
You can also download our desktop app from the [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop).
### Branding & Identity
## 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).
- 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
* The default homeservers and explore pages are defined in [`config.json`](config.json).
### LotusGuild Terminal Design System (TDS) v1.2
* 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.
A full custom theme engine layered on top of Cinny's vanilla-extract theming:
* 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'`.
**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
<details><summary><b>PGP Public Key to verify tarball</b></summary>
**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
```
-----BEGIN PGP PUBLIC KEY BLOCK-----
**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
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>
### Voice / Video Call Improvements
## 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`.
- **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.
+10 -23
View File
@@ -1,31 +1,18 @@
{
"defaultHomeserver": 1,
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
"allowCustomHomeservers": true,
"defaultHomeserver": 0,
"homeserverList": [
"matrix.lotusguild.org"
],
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [
"#cinny:matrix.org",
"#community:matrix.org",
"#space:unredacted.org",
"#librewolf-community:matrix.org",
"#stickers-and-emojis:tastytea.de",
"#videogames:waywardinn.com",
"#science-space:matrix.org",
"#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org"
],
"rooms": [
"#tuwunel:grin.hu",
"#freesoftware:matrix.org",
"#gentoo:matrix.org"
],
"servers": ["matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"]
"spaces": [],
"rooms": [],
"servers": []
},
"hashRouter": {
"enabled": false,
"basename": "/"
}
},
"gifApiKey": "AqqDuQwZNjYttz7Mn6ME4JH1bJIuZ5CO"
}
+14 -11
View File
@@ -4,33 +4,36 @@
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<title>Cinny</title>
<meta name="name" content="Cinny" />
<meta name="author" content="Ajay Bura" />
<title>Lotus Chat</title>
<meta name="name" content="Lotus Chat" />
<meta name="author" content="Lotus Guild" />
<meta
name="description"
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
content="Lotus Chat — the Lotus Guild Matrix client. Secure, fast, and built for our community."
/>
<meta
name="keywords"
content="cinny, cinnyapp, cinnychat, matrix, matrix client, matrix.org, element"
content="lotus chat, lotus guild, matrix, matrix client"
/>
<meta property="og:title" content="Cinny" />
<meta property="og:url" content="https://cinny.in" />
<meta property="og:image" content="https://cinny.in/assets/favicon-48x48.png" />
<meta property="og:title" content="Lotus Chat" />
<meta property="og:url" content="https://chat.lotusguild.org" />
<meta property="og:image" content="https://chat.lotusguild.org/public/res/android/android-chrome-192x192.png" />
<meta
property="og:description"
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
content="Lotus Chat — the Lotus Guild Matrix client. Secure, fast, and built for our community."
/>
<meta name="theme-color" content="#000000" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap" rel="stylesheet">
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="application-name" content="Cinny" />
<meta name="apple-mobile-web-app-title" content="Cinny" />
<meta name="application-name" content="Lotus Chat" />
<meta name="apple-mobile-web-app-title" content="Lotus Chat" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+8 -4
View File
@@ -1,7 +1,7 @@
{
"name": "cinny",
"version": "4.12.1",
"description": "Yet another matrix client",
"name": "lotus-chat",
"version": "4.12.1-lotus",
"description": "Lotus Chat — Matrix client for Lotus Guild",
"main": "index.js",
"type": "module",
"engines": {
@@ -66,6 +66,9 @@
"@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",
@@ -116,10 +119,11 @@
"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.1",
"@element-hq/element-call-embedded": "0.19.3",
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1",
+18
View File
@@ -0,0 +1,18 @@
{
"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: 32 KiB

After

Width:  |  Height:  |  Size: 631 B

+24 -5
View File
@@ -1,14 +1,14 @@
{
"name": "Cinny",
"short_name": "Cinny",
"description": "Yet another matrix client",
"name": "Lotus Chat",
"short_name": "Lotus Chat",
"description": "Lotus Chat \u2014 the Lotus Guild Matrix client",
"dir": "auto",
"lang": "en-US",
"display": "standalone",
"orientation": "portrait",
"start_url": "./",
"background_color": "#fff",
"theme_color": "#fff",
"background_color": "#0a0a0a",
"theme_color": "#980000",
"icons": [
{
"src": "./public/android/android-chrome-36x36.png",
@@ -55,5 +55,24 @@
"sizes": "512x512",
"type": "image/png"
}
],
"categories": [
"social",
"communication",
"productivity"
],
"shortcuts": [
{
"name": "New Message",
"short_name": "DM",
"description": "Open a new direct message",
"url": "/",
"icons": [
{
"src": "res/android/android-chrome-96x96.png",
"sizes": "96x96"
}
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+14 -13
View File
@@ -1,13 +1,14 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2707_1961)">
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#45B83B"/>
</g>
<defs>
<clipPath id="clip0_2707_1961">
<rect width="18" height="18" fill="white"/>
</clipPath>
</defs>
</svg>
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g fill="#980000" fill-opacity="0.88">
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(0,9,9)"/>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(45,9,9)"/>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(90,9,9)"/>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(135,9,9)"/>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(180,9,9)"/>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(225,9,9)"/>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(270,9,9)"/>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(315,9,9)"/>
</g>
<circle cx="9" cy="9" r="2.2" fill="#cc2000"/>
<circle cx="14.5" cy="14.5" r="3" fill="#45B83B"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 841 B

+14 -13
View File
@@ -1,13 +1,14 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2707_2015)">
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#989898"/>
</g>
<defs>
<clipPath id="clip0_2707_2015">
<rect width="18" height="18" fill="white"/>
</clipPath>
</defs>
</svg>
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g fill="#980000" fill-opacity="0.88">
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(0,9,9)"/>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(45,9,9)"/>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(90,9,9)"/>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(135,9,9)"/>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(180,9,9)"/>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(225,9,9)"/>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(270,9,9)"/>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(315,9,9)"/>
</g>
<circle cx="9" cy="9" r="2.2" fill="#cc2000"/>
<circle cx="14.5" cy="14.5" r="3" fill="#989898"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 841 B

+12 -18
View File
@@ -1,19 +1,13 @@
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In -->
<svg version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
x="0px" y="0px" width="18px" height="18px" viewBox="0 0 18 18" enable-background="new 0 0 18 18" xml:space="preserve">
<defs>
</defs>
<g>
<g>
<circle fill="#FFFFFF" cx="9" cy="9" r="8.5"/>
</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 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"/>
</svg>

Before

Width:  |  Height:  |  Size: 871 B

After

Width:  |  Height:  |  Size: 788 B

+99 -2
View File
@@ -58,6 +58,20 @@ 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;
@@ -373,8 +387,69 @@ 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 callVisible = callEmbed && selectedRoom === callEmbed.roomId && joined && !chatOnlyView;
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);
};
return (
<CallEmbedContextProvider value={callEmbed}>
@@ -394,7 +469,29 @@ 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
@@ -0,0 +1,152 @@
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>
);
}
@@ -0,0 +1,310 @@
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,9 +391,28 @@ 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="100">
<Text size="T400">{geoUri}</Text>
<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>
<Chip
as="a"
size="400"
@@ -0,0 +1,144 @@
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,3 +5,4 @@ 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">
Cinny
Lotus Chat
</Text>
</Box>
</Box>
@@ -182,6 +182,27 @@ 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} />
)}
@@ -157,7 +157,6 @@ export function CallControl({
}) {
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
const setCallEmbed = useSetAtom(callEmbedAtom);
const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed])
);
+156 -2
View File
@@ -1,7 +1,8 @@
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
import {
Box,
Button,
Chip,
config,
Icon,
IconButton,
@@ -26,6 +27,8 @@ import {
VideoButton,
} from './Controls';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useResizeObserver } from '../../hooks/useResizeObserver';
import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
@@ -51,6 +54,26 @@ 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());
@@ -71,12 +94,64 @@ export function CallControls({ callEmbed }: CallControlsProps) {
setCords(undefined);
};
useEffect(() => {
if (!pttMode) return;
const iframeWindow = callEmbed.iframe.contentWindow;
const onKeyDown = (e: KeyboardEvent) => {
if (e.code !== pttKey || e.repeat) return;
// Don't intercept keys typed into a text input or editable element
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.contentEditable === 'true'
) return;
e.preventDefault();
if (!microphoneRef.current) callEmbed.control.setMicrophone(true);
setPttActive(true);
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code !== pttKey) return;
callEmbed.control.setMicrophone(false);
setPttActive(false);
};
// Release PTT when the tab loses focus to prevent stuck-on mic
const onBlur = () => {
callEmbed.control.setMicrophone(false);
setPttActive(false);
};
// Re-mute on focus restore: EC can re-assert audio_enabled:true on audio-context resume
const onFocus = () => {
callEmbed.control.setMicrophone(false);
setPttActive(false);
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
window.addEventListener('focus', onFocus);
iframeWindow?.addEventListener('keydown', onKeyDown);
iframeWindow?.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
window.removeEventListener('focus', onFocus);
iframeWindow?.removeEventListener('keydown', onKeyDown);
iframeWindow?.removeEventListener('keyup', onKeyUp);
};
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pttMode, pttKey, callEmbed]);
const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed])
);
const exiting =
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
return (
<Box
ref={controlRef}
@@ -84,6 +159,82 @@ 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"
@@ -105,7 +256,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} />
<ScreenShareButton
enabled={screenshare}
onToggle={() => callEmbed.control.toggleScreenshare()}
onToggle={() => screenshare
? callEmbed.control.toggleScreenshare()
: setShareConfirm(true)
}
/>
</Box>
</Box>
+1 -1
View File
@@ -53,7 +53,7 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Turn Off Sound' : 'Turn On Sound'}</Text>
<Text size="T200">{enabled ? 'Deafen' : 'Undeafen'}</Text>
</Tooltip>
}
>
+33 -3
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
import { SequenceCard } from '../../components/sequence-card';
import * as css from './styles.css';
@@ -7,6 +7,28 @@ import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed';
import { useCallPreferences } from '../../state/hooks/callPreferences';
type MediaPermState = 'granted' | 'denied' | 'prompt' | 'unknown';
function useMediaPermissions(): MediaPermState {
const [state, setState] = useState<MediaPermState>('unknown');
useEffect(() => {
if (!navigator.permissions) {
setState('unknown');
return;
}
navigator.permissions
.query({ name: 'microphone' as PermissionName })
.then((result) => {
setState(result.state as MediaPermState);
result.onchange = () => setState(result.state as MediaPermState);
})
.catch(() => setState('unknown'));
}, []);
return state;
}
type PrescreenControlsProps = {
canJoin?: boolean;
};
@@ -21,7 +43,10 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
const startCall = useCallStart(direct);
const joining = callEmbed?.roomId === room.roomId && !callJoined;
const disabled = inOtherCall || !canJoin;
const micPermission = useMediaPermissions();
const micDenied = micPermission === 'denied';
const disabled = inOtherCall || !canJoin || micDenied;
const { microphone, video, sound, toggleMicrophone, toggleVideo, toggleSound } =
useCallPreferences();
@@ -45,7 +70,12 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
<VideoButton enabled={video} onToggle={toggleVideo} />
<ChatButton />
</Box>
<Box grow="Yes" direction="Column">
<Box grow="Yes" direction="Column" gap="200">
{micDenied && (
<Text size="T200" style={{ color: 'var(--tc-critical-high, #e53e3e)', textAlign: 'center' }}>
Microphone access is blocked. Enable it in your browser settings to join.
</Text>
)}
<Button
variant={disabled ? 'Secondary' : 'Success'}
fill={disabled ? 'Soft' : 'Solid'}
+1
View File
@@ -21,6 +21,7 @@ export const CallMemberCard = style({
export const CallControlContainer = style({
padding: config.space.S400,
position: 'relative',
});
export const PrescreenMessage = style({
+278
View File
@@ -0,0 +1,278 @@
import { CSSProperties } from 'react';
import { ChatBackground } from '../../state/settings';
export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: 'blueprint', label: 'Blueprint' },
{ value: 'carbon', label: 'Carbon' },
{ value: 'stars', label: 'Stars' },
{ value: 'topographic', label: 'Topographic' },
{ value: 'herringbone', label: 'Herringbone' },
{ value: 'crosshatch', label: 'Crosshatch' },
{ value: 'chevron', label: 'Chevron' },
{ value: 'polka', label: 'Polka' },
{ value: 'triangles', label: 'Triangles' },
{ value: 'plaid', label: 'Plaid' },
{ value: 'tactical', label: 'Tactical' },
{ value: 'circuit', label: 'Circuit' },
{ value: 'hexgrid', label: 'Hex Grid' },
];
const DARK: Record<ChatBackground, CSSProperties> = {
none: {},
blueprint: {
backgroundColor: '#0a1628',
backgroundImage: [
'linear-gradient(rgba(100,149,237,0.14) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(100,149,237,0.14) 1px, transparent 1px)',
'linear-gradient(rgba(100,149,237,0.05) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(100,149,237,0.05) 1px, transparent 1px)',
].join(','),
backgroundSize: '80px 80px, 80px 80px, 16px 16px, 16px 16px',
},
carbon: {
backgroundColor: '#0e0e0e',
backgroundImage: [
'repeating-linear-gradient(45deg, rgba(255,255,255,0.035) 0, rgba(255,255,255,0.035) 2px, transparent 0, transparent 50%)',
'repeating-linear-gradient(135deg, rgba(255,255,255,0.035) 0, rgba(255,255,255,0.035) 2px, transparent 0, transparent 50%)',
].join(','),
backgroundSize: '8px 8px',
},
stars: {
backgroundColor: '#050510',
backgroundImage: [
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)',
'radial-gradient(circle, rgba(255,255,255,0.55) 1px, transparent 1px)',
'radial-gradient(circle, rgba(200,200,255,0.3) 1px, transparent 1px)',
].join(','),
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
backgroundPosition: '0 0, 65px 32px, 32px 97px',
},
topographic: {
backgroundColor: '#0f0f17',
backgroundImage: [
'repeating-radial-gradient(circle at 20% 20%, transparent 0, transparent 30px, rgba(152,0,0,0.07) 31px, transparent 32px)',
'repeating-radial-gradient(circle at 80% 80%, transparent 0, transparent 25px, rgba(100,100,200,0.06) 26px, transparent 27px)',
'repeating-radial-gradient(circle at 50% 10%, transparent 0, transparent 45px, rgba(152,0,0,0.04) 46px, transparent 47px)',
].join(','),
},
herringbone: {
backgroundColor: '#111118',
backgroundImage: [
'repeating-linear-gradient(60deg, rgba(180,160,210,0.08) 0, rgba(180,160,210,0.08) 1px, transparent 0, transparent 50%)',
'repeating-linear-gradient(120deg, rgba(180,160,210,0.08) 0, rgba(180,160,210,0.08) 1px, transparent 0, transparent 50%)',
].join(','),
backgroundSize: '20px 36px',
},
crosshatch: {
backgroundColor: '#0f0f0f',
backgroundImage: [
'linear-gradient(rgba(255,255,255,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(255,255,255,0.06) 1px, transparent 1px)',
'linear-gradient(rgba(255,255,255,0.022) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(255,255,255,0.022) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
},
// Interlocking zigzag stripes
chevron: {
backgroundColor: '#0f0f17',
backgroundImage: [
'linear-gradient(135deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
'linear-gradient(225deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
'linear-gradient(315deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
'linear-gradient(45deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
].join(','),
backgroundSize: '20px 20px',
},
// Even dot grid
polka: {
backgroundColor: '#0e0e14',
backgroundImage: 'radial-gradient(circle, rgba(255,255,255,0.2) 2px, transparent 2px)',
backgroundSize: '28px 28px',
},
// Isometric triangle grid
triangles: {
backgroundColor: '#111118',
backgroundImage: [
'linear-gradient(60deg, rgba(100,149,237,0.09) 25%, transparent 25%, transparent 75%, rgba(100,149,237,0.09) 75%)',
'linear-gradient(120deg, rgba(100,149,237,0.09) 25%, transparent 25%, transparent 75%, rgba(100,149,237,0.09) 75%)',
].join(','),
backgroundSize: '40px 70px',
backgroundPosition: '0 0, 20px 35px',
},
// Tartan-inspired crossing lines with accent colour
plaid: {
backgroundColor: '#0a1020',
backgroundImage: [
'repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(100,149,237,0.13) 39px, rgba(100,149,237,0.13) 40px)',
'repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(100,149,237,0.13) 39px, rgba(100,149,237,0.13) 40px)',
'repeating-linear-gradient(0deg, transparent, transparent 7px, rgba(152,0,0,0.08) 7px, rgba(152,0,0,0.08) 8px)',
'repeating-linear-gradient(90deg, transparent, transparent 7px, rgba(152,0,0,0.08) 7px, rgba(152,0,0,0.08) 8px)',
].join(','),
},
// LotusGuild TDS exact dot-grid
tactical: {
backgroundColor: '#030508',
backgroundImage: 'radial-gradient(circle, rgba(0,212,255,0.055) 1px, transparent 1px)',
backgroundSize: '28px 28px',
},
// Circuit board — green grid with node dots
circuit: {
backgroundColor: '#040a04',
backgroundImage: [
'linear-gradient(rgba(0,255,136,0.045) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,255,136,0.045) 1px, transparent 1px)',
'radial-gradient(circle, rgba(0,255,136,0.20) 1.5px, transparent 1.5px)',
].join(','),
backgroundSize: '40px 40px, 40px 40px, 40px 40px',
backgroundPosition: '0 0, 0 0, 20px 20px',
},
// True pointy-top hexagonal grid via SVG data URI
hexgrid: {
backgroundColor: '#060c14',
backgroundImage: 'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C212%2C255%2C0.13%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
backgroundSize: '29px 50px',
},
};
const LIGHT: Record<ChatBackground, CSSProperties> = {
none: {},
blueprint: {
backgroundColor: '#eef3ff',
backgroundImage: [
'linear-gradient(rgba(50,100,220,0.16) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(50,100,220,0.16) 1px, transparent 1px)',
'linear-gradient(rgba(50,100,220,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(50,100,220,0.06) 1px, transparent 1px)',
].join(','),
backgroundSize: '80px 80px, 80px 80px, 16px 16px, 16px 16px',
},
carbon: {
backgroundColor: '#efefef',
backgroundImage: [
'repeating-linear-gradient(45deg, rgba(0,0,0,0.04) 0, rgba(0,0,0,0.04) 2px, transparent 0, transparent 50%)',
'repeating-linear-gradient(135deg, rgba(0,0,0,0.04) 0, rgba(0,0,0,0.04) 2px, transparent 0, transparent 50%)',
].join(','),
backgroundSize: '8px 8px',
},
// Stars is intentionally always dark — it's a night-sky theme
stars: {
backgroundColor: '#050510',
backgroundImage: [
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)',
'radial-gradient(circle, rgba(255,255,255,0.55) 1px, transparent 1px)',
'radial-gradient(circle, rgba(200,200,255,0.3) 1px, transparent 1px)',
].join(','),
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
backgroundPosition: '0 0, 65px 32px, 32px 97px',
},
topographic: {
backgroundColor: '#faf8f5',
backgroundImage: [
'repeating-radial-gradient(circle at 20% 20%, transparent 0, transparent 30px, rgba(100,60,60,0.09) 31px, transparent 32px)',
'repeating-radial-gradient(circle at 80% 80%, transparent 0, transparent 25px, rgba(60,60,130,0.07) 26px, transparent 27px)',
'repeating-radial-gradient(circle at 50% 10%, transparent 0, transparent 45px, rgba(100,60,60,0.05) 46px, transparent 47px)',
].join(','),
},
herringbone: {
backgroundColor: '#f9f9f9',
backgroundImage: [
'repeating-linear-gradient(60deg, rgba(80,70,110,0.09) 0, rgba(80,70,110,0.09) 1px, transparent 0, transparent 50%)',
'repeating-linear-gradient(120deg, rgba(80,70,110,0.09) 0, rgba(80,70,110,0.09) 1px, transparent 0, transparent 50%)',
].join(','),
backgroundSize: '20px 36px',
},
crosshatch: {
backgroundColor: '#ffffff',
backgroundImage: [
'linear-gradient(rgba(0,0,0,0.07) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,0,0,0.07) 1px, transparent 1px)',
'linear-gradient(rgba(0,0,0,0.025) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,0,0,0.025) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
},
chevron: {
backgroundColor: '#f9f8ff',
backgroundImage: [
'linear-gradient(135deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
'linear-gradient(225deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
'linear-gradient(315deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
'linear-gradient(45deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
].join(','),
backgroundSize: '20px 20px',
},
polka: {
backgroundColor: '#fafafa',
backgroundImage: 'radial-gradient(circle, rgba(0,0,0,0.18) 2px, transparent 2px)',
backgroundSize: '28px 28px',
},
triangles: {
backgroundColor: '#f4f7ff',
backgroundImage: [
'linear-gradient(60deg, rgba(50,100,220,0.1) 25%, transparent 25%, transparent 75%, rgba(50,100,220,0.1) 75%)',
'linear-gradient(120deg, rgba(50,100,220,0.1) 25%, transparent 25%, transparent 75%, rgba(50,100,220,0.1) 75%)',
].join(','),
backgroundSize: '40px 70px',
backgroundPosition: '0 0, 20px 35px',
},
plaid: {
backgroundColor: '#f5f0ff',
backgroundImage: [
'repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(100,50,180,0.15) 39px, rgba(100,50,180,0.15) 40px)',
'repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(100,50,180,0.15) 39px, rgba(100,50,180,0.15) 40px)',
'repeating-linear-gradient(0deg, transparent, transparent 7px, rgba(200,0,0,0.09) 7px, rgba(200,0,0,0.09) 8px)',
'repeating-linear-gradient(90deg, transparent, transparent 7px, rgba(200,0,0,0.09) 7px, rgba(200,0,0,0.09) 8px)',
].join(','),
},
tactical: {
backgroundColor: '#f0f4fa',
backgroundImage: 'radial-gradient(circle, rgba(0,100,200,0.08) 1px, transparent 1px)',
backgroundSize: '28px 28px',
},
circuit: {
backgroundColor: '#f0f8f0',
backgroundImage: [
'linear-gradient(rgba(0,160,80,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,160,80,0.06) 1px, transparent 1px)',
'radial-gradient(circle, rgba(0,160,80,0.22) 1.5px, transparent 1.5px)',
].join(','),
backgroundSize: '40px 40px, 40px 40px, 40px 40px',
backgroundPosition: '0 0, 0 0, 20px 20px',
},
hexgrid: {
backgroundColor: '#f4f8ff',
backgroundImage: 'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%2850%2C100%2C220%2C0.11%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
backgroundSize: '29px 50px',
},
};
export const getChatBg = (bg: ChatBackground, isDark: boolean): CSSProperties =>
isDark ? DARK[bg] : LIGHT[bg];
+2 -1
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 } from '../../hooks/useRoom';
import { useRoom, useIsDirectRoom } from '../../hooks/useRoom';
import { useKeyDown } from '../../hooks/useKeyDown';
import { markAsRead } from '../../utils/notifications';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -36,6 +36,7 @@ export function Room() {
const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId);
const chat = useAtomValue(callChatAtom);
const isDirect = useIsDirectRoom();
useKeyDown(
window,
+109
View File
@@ -30,6 +30,8 @@ import {
} from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { GifPicker } from '../../components/GifPicker';
import { useClientConfig } from '../../hooks/useClientConfig';
import {
CustomEditor,
Toolbar,
@@ -171,6 +173,26 @@ 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>>();
@@ -216,6 +238,8 @@ 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();
@@ -430,6 +454,30 @@ 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;
@@ -669,6 +717,67 @@ 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>
+122 -1
View File
@@ -63,6 +63,7 @@ import {
MessageNotDecryptedContent,
RedactedContent,
MSticker,
PollContent,
ImageContent,
EventContent,
} from '../../components/message';
@@ -436,6 +437,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const useAuthentication = useMediaAuthentication();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
const [perMessageProfiles] = useSetting(settingsAtom, 'perMessageProfiles');
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const direct = useIsDirectRoom();
@@ -1216,6 +1218,11 @@ 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>
@@ -1296,6 +1303,112 @@ 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;
@@ -1638,7 +1751,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
return null;
}
if (mEvent.isRedacted() && !showHiddenEvents) {
return null;
const t = mEvent.getType();
if (
t !== MessageEvent.RoomMessage &&
t !== MessageEvent.RoomMessageEncrypted &&
t !== MessageEvent.Sticker
) {
return null;
}
}
if (!newDivider && readUptoEventIdRef.current) {
@@ -1649,6 +1769,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}
const collapsed =
!perMessageProfiles &&
isPrevRendered &&
!dayDivider &&
(!newDivider || eventSender === mx.getUserId()) &&
+10 -1
View File
@@ -15,6 +15,10 @@ import { RoomTombstone } from './RoomTombstone';
import { RoomInput } from './RoomInput';
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
import { Page } from '../../components/page';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useTheme, ThemeKind } from '../../hooks/useTheme';
import { getChatBg } from '../lotus/chatBackground';
import { useKeyDown } from '../../hooks/useKeyDown';
import { editableActiveElement } from '../../utils/dom';
import { settingsAtom } from '../../state/settings';
@@ -54,9 +58,14 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
return true;
};
export function RoomView({ eventId }: { eventId?: string }) {
const roomInputRef = useRef<HTMLDivElement>(null);
const roomViewRef = useRef<HTMLDivElement>(null);
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
const theme = useTheme();
const isDark = theme.kind === ThemeKind.Dark;
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
@@ -91,7 +100,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
);
return (
<Page ref={roomViewRef}>
<Page ref={roomViewRef} style={getChatBg(chatBackground, isDark)}>
<Box grow="Yes" direction="Column">
<RoomTimeline
key={roomId}
+19
View File
@@ -33,6 +33,7 @@ import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { StateEvent } from '../../../types/matrix/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
import { useCallStart } from '../../hooks/useCallEmbed';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useSpaceOptionally } from '../../hooks/useSpace';
@@ -404,6 +405,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const direct = useIsDirectRoom();
const startCall = useCallStart(true);
const pinnedEvents = useRoomPinnedEvents(room);
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
@@ -520,6 +522,23 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
</Box>
<Box shrink="No">
{direct && !callView && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Start Call</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={() => startCall(room)}>
<Icon size="400" src={Icons.Phone} />
</IconButton>
)}
</TooltipProvider>
)}
{!encryptedRoom && (
<TooltipProvider
position="Bottom"
@@ -0,0 +1,129 @@
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,6 +727,7 @@ 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;
@@ -1028,6 +1029,26 @@ 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"
@@ -1145,6 +1166,9 @@ 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: file.name,
body: metadata.caption?.trim() || 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: file.name,
body: metadata.caption?.trim() || file.name,
[MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
};
if (videoEl) {
+9 -8
View File
@@ -4,7 +4,8 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import CinnySVG from '../../../../../public/res/svg/cinny.svg';
import LotusLogo from '../../../../../public/res/Lotus.png';
import pkg from '../../../../../package.json';
import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -38,23 +39,23 @@ export function About({ requestClose }: AboutProps) {
<Box shrink="No">
<img
style={{ width: toRem(60), height: toRem(60) }}
src={CinnySVG}
alt="Cinny logo"
src={LotusLogo}
alt="Lotus Chat logo"
/>
</Box>
<Box direction="Column" gap="300">
<Box direction="Column" gap="100">
<Box gap="100" alignItems="End">
<Text size="H3">Cinny</Text>
<Text size="T200">v4.12.1</Text>
<Text size="H3">Lotus Chat</Text>
<Text size="T200">v{pkg.version}</Text>
</Box>
<Text>Yet another matrix client.</Text>
<Text>A Matrix client for Lotus Guild.</Text>
</Box>
<Box gap="200" wrap="Wrap">
<Button
as="a"
href="https://github.com/cinnyapp/cinny"
href="https://code.lotusguild.org/LotusGuild/cinny"
rel="noreferrer noopener"
target="_blank"
variant="Secondary"
@@ -67,7 +68,7 @@ export function About({ requestClose }: AboutProps) {
</Button>
<Button
as="a"
href="https://cinny.in/#sponsor"
href="https://matrix.lotusguild.org"
rel="noreferrer noopener"
target="_blank"
variant="Critical"
@@ -28,7 +28,7 @@ function ExportKeys() {
const blob = new Blob([encKeys], {
type: 'text/plain;charset=us-ascii',
});
FileSaver.saveAs(blob, 'cinny-keys.txt');
FileSaver.saveAs(blob, 'lotus-keys.txt');
},
[mx]
)
+180 -1
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 { DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
import { ChatBackground, DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
import { SettingTile } from '../../../components/setting-tile';
import { KeySymbol } from '../../../utils/key-symbol';
import { isMacOS } from '../../../utils/user-agent';
@@ -42,10 +42,13 @@ import {
Theme,
ThemeKind,
useSystemThemeKind,
useTheme,
useThemeNames,
useThemes,
} from '../../../hooks/useTheme';
import { stopPropagation } from '../../../utils/keyboard';
import { BG_OPTIONS, getChatBg } from '../../lotus/chatBackground';
import { resetBootSequence, runLotusBootSequence } from '../../../../lotus-boot';
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
import { useDateFormatItems } from '../../../hooks/useDateFormat';
@@ -307,6 +310,8 @@ function Appearance() {
const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
const [monochromeMode, setMonochromeMode] = useSetting(settingsAtom, 'monochromeMode');
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
const [perMessageProfiles, setPerMessageProfiles] = useSetting(settingsAtom, 'perMessageProfiles');
const [lotusTerminal, setLotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
return (
<Box direction="Column" gap="100">
@@ -350,6 +355,56 @@ function Appearance() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Page Zoom" after={<PageZoomInput />} />
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column" gap="200">
<SettingTile
title="Chat Background"
description="Pattern applied behind the message timeline."
/>
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
<ChatBgGrid />
</Box>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Show Profile on Every Message"
description="Display avatar and name on each message instead of grouping consecutive messages."
after={<Switch variant="Primary" value={perMessageProfiles} onChange={setPerMessageProfiles} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Lotus Terminal Mode"
description="LotusGuild Terminal Design System: Anduril Orange + Ice Cyan + Matrix Green, dot-grid background, CRT scanlines, and boot sequence animation."
after={
<Box direction="Row" gap="200" align="Center">
{lotusTerminal && (
<button
type="button"
onClick={() => { resetBootSequence(); runLotusBootSequence(true); }}
title="Replay boot sequence"
style={{
background: 'transparent',
border: '1px solid rgba(255,107,0,0.35)',
color: '#FF6B00',
fontSize: '0.65rem',
padding: '0.2rem 0.6rem',
cursor: 'pointer',
fontFamily: 'inherit',
letterSpacing: '0.1em',
textTransform: 'uppercase',
whiteSpace: 'nowrap',
}}
>
Boot
</button>
)}
<Switch variant="Primary" value={lotusTerminal} onChange={setLotusTerminal} />
</Box>
}
/>
</SequenceCard>
</Box>
);
}
@@ -711,6 +766,7 @@ function Editor() {
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity');
const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar');
return (
<Box direction="Column" gap="100">
@@ -730,6 +786,13 @@ function Editor() {
after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Formatting Toolbar"
description="Show bold, italic, code and other formatting buttons above the message input."
after={<Switch variant="Primary" value={editorToolbar} onChange={setEditorToolbar} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Hide Typing & Read Receipts"
@@ -741,6 +804,121 @@ function Editor() {
);
}
function Calls() {
const [cameraOnJoin, setCameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
const [callNoiseSuppression, setCallNoiseSuppression] = useSetting(settingsAtom, 'callNoiseSuppression');
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
const [listeningForKey, setListeningForKey] = useState(false);
const handleKeyBind = () => {
setListeningForKey(true);
const onKey = (e: KeyboardEvent) => {
e.preventDefault();
if (e.code === 'Escape') {
setListeningForKey(false);
} else {
setPttKey(e.code);
setListeningForKey(false);
}
window.removeEventListener('keydown', onKey, true);
};
window.addEventListener('keydown', onKey, true);
};
const keyLabel = (code: string) => code === 'Space' ? 'Space' : code.replace('Key', '').replace('Digit', '');
return (
<Box direction="Column" gap="100">
<Text size="L400">Calls</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Join with Camera On"
description="Enable camera automatically when joining a voice or video call. Camera is off by default."
after={<Switch variant="Primary" value={cameraOnJoin} onChange={setCameraOnJoin} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Noise Suppression"
description="Apply AI noise suppression to filter background noise during calls (powered by Element Call)."
after={<Switch variant="Primary" value={callNoiseSuppression} onChange={setCallNoiseSuppression} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column" gap="400">
<SettingTile
title="Push to Talk"
description="Mute your microphone by default. Hold the PTT key to speak."
after={<Switch variant="Primary" value={pttMode} onChange={setPttMode} />}
/>
{pttMode && (
<SettingTile
title="PTT Key Binding"
description="Press a key to bind it as your push-to-talk key."
after={
<Button
size="300"
variant={listeningForKey ? 'Warning' : 'Secondary'}
fill={listeningForKey ? 'Solid' : 'Soft'}
radii="300"
outlined
onClick={handleKeyBind}
style={{ minWidth: '90px' }}
>
<Text size="B300">
{listeningForKey ? 'Press a key…' : keyLabel(pttKey)}
</Text>
</Button>
}
/>
)}
</SequenceCard>
</Box>
);
}
function ChatBgGrid() {
const [chatBackground, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
const theme = useTheme();
const isDark = theme.kind === ThemeKind.Dark;
return (
<Box wrap="Wrap" gap="200">
{BG_OPTIONS.map((opt) => (
<Box key={opt.value} direction="Column" gap="100" style={{ alignItems: 'center' }}>
<button
type="button"
aria-label={opt.label}
aria-pressed={chatBackground === opt.value}
onClick={() => setChatBackground(opt.value as ChatBackground)}
style={{
display: 'block',
width: toRem(76),
height: toRem(50),
borderRadius: toRem(8),
cursor: 'pointer',
border: chatBackground === opt.value
? '2px solid #980000'
: '2px solid rgba(128,128,128,0.25)',
padding: 0,
overflow: 'hidden',
...getChatBg(opt.value as ChatBackground, isDark),
}}
/>
<Text
size="T200"
style={chatBackground === opt.value ? { color: '#980000' } : undefined}
>
{opt.label}
</Text>
</Box>
))}
</Box>
);
}
function SelectMessageLayout() {
const [menuCords, setMenuCords] = useState<RectCords>();
const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
@@ -1006,6 +1184,7 @@ export function General({ requestClose }: GeneralProps) {
<DateAndTime />
<Editor />
<Messages />
<Calls />
</Box>
</PageContent>
</Scroll>
@@ -27,7 +27,7 @@ function EmailNotification() {
device_display_name: email,
lang: 'en',
data: {
brand: 'Cinny',
brand: 'Lotus Chat',
},
append: true,
});
+13 -5
View File
@@ -15,6 +15,8 @@ import { useResizeObserver } from './useResizeObserver';
import { CallControlState } from '../plugins/call/CallControlState';
import { useCallMembersChange, useCallSession } from './useCall';
import { CallPreferences } from '../state/callPreferences';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
const CallEmbedContext = createContext<CallEmbed | undefined>(undefined);
@@ -42,15 +44,19 @@ export const createCallEmbed = (
dm: boolean,
themeKind: ElementCallThemeKind,
container: HTMLElement,
pref?: CallPreferences
pref?: CallPreferences,
noiseSuppression = true,
forceAudioOff = false
): CallEmbed => {
const rtcSession = mx.matrixRTC.getRoomSession(room);
const ongoing =
MatrixRTCSession.sessionMembershipsForRoom(room, rtcSession.sessionDescription).length > 0;
const intent = CallEmbed.getIntent(dm, ongoing, pref?.video);
const widget = CallEmbed.getWidget(mx, room, intent, themeKind);
const controlState = pref && new CallControlState(pref.microphone, pref.video, pref.sound);
const initialAudio = forceAudioOff ? false : (pref?.microphone ?? true);
const initialVideo = pref?.video ?? false;
const widget = CallEmbed.getWidget(mx, room, intent, themeKind, noiseSuppression, initialAudio, initialVideo);
const controlState = pref && new CallControlState(forceAudioOff ? false : pref.microphone, pref.video, pref.sound);
const embed = new CallEmbed(mx, room, widget, container, controlState);
@@ -62,6 +68,8 @@ export const useCallStart = (dm = false) => {
const theme = useTheme();
const setCallEmbed = useSetAtom(callEmbedAtom);
const callEmbedRef = useCallEmbedRef();
const [callNoiseSuppression] = useSetting(settingsAtom, 'callNoiseSuppression');
const [pttMode] = useSetting(settingsAtom, 'pttMode');
const startCall = useCallback(
(room: Room, pref?: CallPreferences) => {
@@ -69,11 +77,11 @@ export const useCallStart = (dm = false) => {
if (!container) {
throw new Error('Failed to start call, No embed container element found!');
}
const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref);
const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref, callNoiseSuppression ?? true, !!pttMode);
setCallEmbed(callEmbed);
},
[mx, dm, theme, setCallEmbed, callEmbedRef]
[mx, dm, theme, setCallEmbed, callEmbedRef, callNoiseSuppression, pttMode]
);
return startCall;
+1
View File
@@ -18,6 +18,7 @@ export type ClientConfig = {
};
hashRouter?: HashRouterConfig;
gifApiKey?: string;
};
const ClientConfigContext = createContext<ClientConfig | null>(null);
+87
View File
@@ -0,0 +1,87 @@
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];
};
+11 -1
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, silverTheme } from '../../colors.css';
import { butterTheme, darkTheme, lotusTerminalLightTheme, lotusTerminalTheme, silverTheme } from '../../colors.css';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
@@ -37,6 +37,16 @@ export const ButterTheme: Theme = {
kind: ThemeKind.Dark,
classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'],
};
export const LotusTerminalTheme: Theme = {
id: 'lotus-terminal-theme',
kind: ThemeKind.Dark,
classNames: ['lotus-terminal-theme', lotusTerminalTheme, onDarkFontWeight, 'prism-dark'],
};
export const LotusTerminalLightTheme: Theme = {
id: 'lotus-terminal-light-theme',
kind: ThemeKind.Light,
classNames: ['lotus-terminal-light-theme', lotusTerminalLightTheme, onLightFontWeight, 'prism-light'],
};
export const useThemes = (): Theme[] => {
const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme], []);
+2
View File
@@ -69,6 +69,7 @@ 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) => {
@@ -137,6 +138,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Outlet />
</ClientLayout>
<CallStatusRenderer />
<IncomingCallNotification />
</CallEmbedProvider>
<SearchModalRenderer />
<UserRoomProfileRenderer />
+35 -12
View File
@@ -3,27 +3,39 @@ import { configClass, varsClass } from 'folds';
import {
DarkTheme,
LightTheme,
LotusTerminalLightTheme,
LotusTerminalTheme,
ThemeContextProvider,
ThemeKind,
useActiveTheme,
useSystemThemeKind,
} from '../hooks/useTheme';
import { lotusTerminalBodyClass } from '../../lotus-terminal.css';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { runLotusBootSequence } from '../../lotus-boot';
export function UnAuthRouteThemeManager() {
const systemThemeKind = useSystemThemeKind();
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
useEffect(() => {
document.body.className = '';
document.body.classList.add(configClass, varsClass);
if (systemThemeKind === ThemeKind.Dark) {
document.body.classList.add(...DarkTheme.classNames);
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.Light) {
document.body.classList.add(...LightTheme.classNames);
}
}, [systemThemeKind]);
}, [systemThemeKind, lotusTerminal]);
return null;
}
@@ -31,19 +43,30 @@ export function UnAuthRouteThemeManager() {
export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
const activeTheme = useActiveTheme();
const [monochromeMode] = useSetting(settingsAtom, 'monochromeMode');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const terminalIsLight = lotusTerminal && activeTheme.kind === ThemeKind.Light;
const effectiveTheme = lotusTerminal
? (terminalIsLight ? LotusTerminalLightTheme : LotusTerminalTheme)
: activeTheme;
useEffect(() => {
document.body.className = '';
document.body.classList.add(configClass, varsClass);
document.body.classList.add(...activeTheme.classNames);
if (monochromeMode) {
document.body.classList.add(...effectiveTheme.classNames);
if (lotusTerminal) {
document.documentElement.setAttribute('data-theme', terminalIsLight ? 'light' : 'dark');
document.body.classList.add(lotusTerminalBodyClass);
runLotusBootSequence();
} else {
document.documentElement.removeAttribute('data-theme');
}
if (monochromeMode && !lotusTerminal) {
document.body.style.filter = 'grayscale(1)';
} else {
document.body.style.filter = '';
}
}, [activeTheme, monochromeMode]);
}, [effectiveTheme, monochromeMode, lotusTerminal, terminalIsLight]);
return <ThemeContextProvider value={activeTheme}>{children}</ThemeContextProvider>;
return <ThemeContextProvider value={effectiveTheme}>{children}</ThemeContextProvider>;
}
+6 -5
View File
@@ -1,24 +1,25 @@
import React from 'react';
import { Box, Text } from 'folds';
import * as css from './styles.css';
import pkg from '../../../../package.json';
export function AuthFooter() {
return (
<Box className={css.AuthFooter} justifyContent="Center" gap="400" wrap="Wrap">
<Text as="a" size="T300" href="https://cinny.in" target="_blank" rel="noreferrer">
<Text as="a" size="T300" href="https://lotusguild.org" target="_blank" rel="noreferrer">
About
</Text>
<Text
as="a"
size="T300"
href="https://github.com/ajbura/cinny/releases"
href="https://code.lotusguild.org/LotusGuild/cinny/releases"
target="_blank"
rel="noreferrer"
>
v4.12.1
v{pkg.version}
</Text>
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
Twitter
<Text as="a" size="T300" href="https://matrix.lotusguild.org" target="_blank" rel="noreferrer">
Community
</Text>
<Text as="a" size="T300" href="https://matrix.org" target="_blank" rel="noreferrer">
Powered by Matrix
+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 CinnySVG from '../../../../public/res/svg/cinny.svg';
import LotusLogo from '../../../../public/res/Lotus.png';
import { ServerPicker } from './ServerPicker';
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
@@ -134,8 +134,8 @@ export function AuthLayout() {
<Box direction="Column" className={css.AuthCard}>
<Header className={css.AuthHeader} size="600" variant="Surface">
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
<img className={css.AuthLogo} src={CinnySVG} alt="Cinny Logo" />
<Text size="H3">Cinny</Text>
<img className={css.AuthLogo} src={LotusLogo} alt="Lotus Chat Logo" />
<Text size="H3">Lotus Chat</Text>
</Box>
</Header>
<Box className={css.AuthCardContent} direction="Column">
@@ -133,7 +133,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
user: username,
},
password,
initial_device_display_name: 'Cinny Web',
initial_device_display_name: 'Lotus Chat Web',
});
};
@@ -151,7 +151,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
user: mxIdUsername,
},
password,
initial_device_display_name: 'Cinny Web',
initial_device_display_name: 'Lotus Chat Web',
});
};
const handleEmailLogin = (email: string, password: string) => {
@@ -163,7 +163,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
address: email,
},
password,
initial_device_display_name: 'Cinny Web',
initial_device_display_name: 'Lotus Chat Web',
});
};
+1 -1
View File
@@ -57,7 +57,7 @@ export function TokenLogin({ token }: TokenLoginProps) {
startLogin(baseUrl, {
type: 'm.login.token',
token,
initial_device_display_name: 'Cinny Web',
initial_device_display_name: 'Lotus Chat Web',
});
}, [baseUrl, token, startLogin]);
@@ -109,7 +109,7 @@ function RegisterUIAFlow({
auth: authDict,
password,
username,
initial_device_display_name: 'Cinny Web',
initial_device_display_name: 'Lotus Chat Web',
});
},
[onRegister, formData]
@@ -250,7 +250,7 @@ export function PasswordRegisterForm({
auth: {
session: authData.session,
},
initial_device_display_name: 'Cinny Web',
initial_device_display_name: 'Lotus Chat Web',
});
};
+3 -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/svg/cinny.svg';
import LogoUnreadSVG from '../../../../public/res/svg/cinny-unread.svg';
import LogoHighlightSVG from '../../../../public/res/svg/cinny-highlight.svg';
import LogoSVG from '../../../../public/res/lotus.png';
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png';
import NotificationSound from '../../../../public/sound/notification.ogg';
import InviteSound from '../../../../public/sound/invite.ogg';
import { notificationPermission, setFavicon } from '../../utils/dom';
+9 -8
View File
@@ -1,7 +1,8 @@
import React from 'react';
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
import { Page, PageHero, PageHeroSection } from '../../components/page';
import CinnySVG from '../../../../public/res/svg/cinny.svg';
import LotusLogo from '../../../../public/res/Lotus.png';
import pkg from '../../../../package.json';
export function WelcomePage() {
return (
@@ -14,17 +15,17 @@ export function WelcomePage() {
>
<PageHeroSection>
<PageHero
icon={<img width="70" height="70" src={CinnySVG} alt="Cinny Logo" />}
title="Welcome to Cinny"
icon={<img width="70" height="70" src={LotusLogo} alt="Lotus Chat" style={{ objectFit: "contain" }} />}
title="Welcome to Lotus Chat"
subTitle={
<span>
Yet another matrix client.{' '}
A Matrix client for Lotus Guild.{' '}
<a
href="https://github.com/cinnyapp/cinny/releases"
href="https://code.lotusguild.org/LotusGuild/cinny/releases"
target="_blank"
rel="noreferrer noopener"
>
v4.12.1
v{pkg.version}
</a>
</span>
}
@@ -33,7 +34,7 @@ export function WelcomePage() {
<Box grow="Yes" style={{ maxWidth: toRem(300) }} direction="Column" gap="300">
<Button
as="a"
href="https://github.com/cinnyapp/cinny"
href="https://code.lotusguild.org/LotusGuild/cinny"
target="_blank"
rel="noreferrer noopener"
before={<Icon size="200" src={Icons.Code} />}
@@ -44,7 +45,7 @@ export function WelcomePage() {
</Button>
<Button
as="a"
href="https://cinny.in/#sponsor"
href="https://matrix.lotusguild.org"
target="_blank"
rel="noreferrer noopener"
fill="Soft"
+24 -8
View File
@@ -29,17 +29,13 @@ export class CallControl extends EventEmitter implements CallControlState {
}
private get settingsButton(): HTMLElement | undefined {
const leaveBtn = this.document?.querySelector('[data-testid="incall_leave"]');
const settingsButton = leaveBtn?.previousElementSibling as HTMLElement | null;
return settingsButton ?? undefined;
// EC 0.19.3: settings button has data-testid="settings-bottom-center"
return (this.document?.querySelector('[data-testid="settings-bottom-center"]') as HTMLElement) ?? undefined;
}
private get reactionsButton(): HTMLElement | undefined {
const reactionsButton = this.settingsButton?.previousElementSibling as HTMLElement | null;
return reactionsButton ?? undefined;
// EC 0.19.3: reactions/raise-hand button has a CSS module class containing "raiseHand"
return (this.document?.querySelector('[class*="raiseHand"]') as HTMLElement) ?? undefined;
}
private get spotlightButton(): HTMLInputElement | undefined {
@@ -101,6 +97,11 @@ export class CallControl extends EventEmitter implements CallControlState {
this.emitStateUpdate();
}
public async forceState(desired: CallControlState) {
this.state = new CallControlState(desired.microphone, desired.video, desired.sound, this.screenshare, this.spotlight);
await this.applyState();
}
public startObserving() {
this.controlMutationObserver.disconnect();
@@ -160,6 +161,8 @@ export class CallControl extends EventEmitter implements CallControlState {
}
public onControlMutation() {
const prevScreenshare = this.screenshare;
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
const spotlight: boolean = this.spotlightButton?.checked ?? false;
@@ -171,6 +174,19 @@ export class CallControl extends EventEmitter implements CallControlState {
spotlight
);
this.emitStateUpdate();
// EC auto-switches to spotlight when screenshare starts — revert to grid
if (!prevScreenshare && screenshare) {
setTimeout(() => { if (this.spotlight) this.gridButton?.click(); }, 600);
}
}
public setMicrophone(enabled: boolean) {
const payload: ElementMediaStatePayload = {
audio_enabled: enabled,
video_enabled: this.video,
};
return this.setMediaState(payload);
}
public toggleMicrophone() {
+42 -6
View File
@@ -47,6 +47,10 @@ export class CallEmbed {
private readonly disposables: Array<() => void> = [];
private readonly initialState: CallControlState;
private styleRetryObserver?: MutationObserver;
static getIntent(dm: boolean, ongoing: boolean, video?: boolean): ElementCallIntent {
if (dm && ongoing) {
return video ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExistingDMVoice;
@@ -83,7 +87,10 @@ export class CallEmbed {
mx: MatrixClient,
room: Room,
intent: ElementCallIntent,
themeKind: ElementCallThemeKind
themeKind: ElementCallThemeKind,
noiseSuppression = true,
initialAudio = true,
initialVideo = false
): Widget {
const userId = mx.getSafeUserId();
const deviceId = mx.getDeviceId() ?? '';
@@ -105,6 +112,9 @@ export class CallEmbed {
perParticipantE2EE: room.hasEncryptionStateEvent().toString(),
lang: 'en-EN',
theme: themeKind,
noiseSuppression: noiseSuppression.toString(),
audio: initialAudio.toString(),
video: initialVideo.toString(),
header: 'none',
});
@@ -172,6 +182,7 @@ export class CallEmbed {
const controlState = initialControlState ?? new CallControlState(true, false, true);
this.control = new CallControl(controlState, call, iframe);
this.initialState = controlState;
let initialMediaEvent = true;
this.disposables.push(
@@ -256,6 +267,7 @@ export class CallEmbed {
this.disposables.forEach((disposable) => {
disposable();
});
this.styleRetryObserver?.disconnect();
this.call.stop();
this.container.removeChild(this.iframe);
this.control.dispose();
@@ -274,6 +286,8 @@ export class CallEmbed {
this.joined = true;
this.applyStyles();
this.control.startObserving();
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
this.control.forceState(this.initialState);
}
private applyStyles(): void {
@@ -281,11 +295,33 @@ export class CallEmbed {
if (!doc) return;
doc.body.style.setProperty('background', 'none', 'important');
const controls = doc.body.querySelector('[data-testid="incall_leave"]')?.parentElement
?.parentElement;
if (controls) {
controls.style.setProperty('position', 'absolute');
controls.style.setProperty('visibility', 'hidden');
// 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 });
}
}
+5 -2
View File
@@ -28,10 +28,13 @@ export const makeCallPreferencesAtom = (userId: string): CallPreferencesAtom =>
storeKey,
(key) => {
const v = getLocalStorageItem<CallPreferences>(key, DEFAULT_PREFERENCES);
return v;
// Never restore camera state — always start with camera off for privacy.
// Users can toggle it in the prescreen before joining.
return { ...v, video: false };
},
(key, value) => {
setLocalStorageItem(key, value);
// Don't persist video state — always resets to off on next load.
setLocalStorageItem(key, { ...value, video: false });
}
);
+4
View File
@@ -1,6 +1,8 @@
import { createContext, useCallback, useContext } from 'react';
import { useAtom } from 'jotai';
import { CallPreferences, CallPreferencesAtom } from '../callPreferences';
import { useSetting } from './settings';
import { settingsAtom } from '../settings';
const CallPreferencesAtomContext = createContext<CallPreferencesAtom | null>(null);
export const CallPreferencesProvider = CallPreferencesAtomContext.Provider;
@@ -21,6 +23,7 @@ export const useCallPreferences = (): CallPreferences & {
} => {
const callPrefAtom = useCallPreferencesAtom();
const [pref, setPref] = useAtom(callPrefAtom);
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
const toggleMicrophone = useCallback(() => {
const microphone = !pref.microphone;
@@ -54,6 +57,7 @@ export const useCallPreferences = (): CallPreferences & {
return {
...pref,
video: cameraOnJoin ? pref.video : false,
toggleMicrophone,
toggleVideo,
toggleSound,
+1
View File
@@ -9,6 +9,7 @@ import { createListAtom } from '../list';
export type TUploadMetadata = {
markedAsSpoiler: boolean;
caption?: string;
};
export type TUploadItem = {
+19
View File
@@ -9,6 +9,7 @@ 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,
@@ -47,6 +48,15 @@ export interface Settings {
dateFormatString: string;
developerTools: boolean;
lotusTerminal: boolean;
chatBackground: ChatBackground;
perMessageProfiles: boolean;
cameraOnJoin: boolean;
callNoiseSuppression: boolean;
pttMode: boolean;
pttKey: string;
}
const defaultSettings: Settings = {
@@ -81,6 +91,15 @@ const defaultSettings: Settings = {
dateFormatString: 'D MMM YYYY',
developerTools: false,
lotusTerminal: false,
chatBackground: 'none',
perMessageProfiles: false,
cameraOnJoin: false,
callNoiseSuppression: true,
pttMode: false,
pttKey: 'Space',
};
export const getSettings = () => {
+186
View File
@@ -236,3 +236,189 @@ export const butterTheme = createTheme(color, {
OnContainer: '#F2EED3',
},
});
export const lotusTerminalTheme = createTheme(color, {
Background: {
Container: '#030508',
ContainerHover: '#060c14',
ContainerActive: '#0d1520',
ContainerLine: '#07101a',
OnContainer: '#c4d9ee',
},
Surface: {
Container: '#060c14',
ContainerHover: '#0d1520',
ContainerActive: '#07101a',
ContainerLine: '#111d2b',
OnContainer: '#c4d9ee',
},
SurfaceVariant: {
Container: '#0d1520',
ContainerHover: '#07101a',
ContainerActive: '#111d2b',
ContainerLine: '#162436',
OnContainer: '#c4d9ee',
},
Primary: {
Main: '#FF6B00',
MainHover: '#FF8C2B',
MainActive: '#FF9940',
MainLine: '#FFA655',
OnMain: '#030508',
Container: '#2a1200',
ContainerHover: '#3a1a00',
ContainerActive: '#4a2200',
ContainerLine: '#5a2a00',
OnContainer: '#FFB87A',
},
Secondary: {
Main: '#00D4FF',
MainHover: '#33DFFF',
MainActive: '#4DE5FF',
MainLine: '#66EBFF',
OnMain: '#030508',
Container: '#001a22',
ContainerHover: '#002233',
ContainerActive: '#002a3d',
ContainerLine: '#003347',
OnContainer: '#99E8FF',
},
Success: {
Main: '#00FF88',
MainHover: '#33FFAA',
MainActive: '#4DFFB8',
MainLine: '#66FFC6',
OnMain: '#030508',
Container: '#002211',
ContainerHover: '#002a16',
ContainerActive: '#00331b',
ContainerLine: '#003b20',
OnContainer: '#99FFCC',
},
Warning: {
Main: '#FFB300',
MainHover: '#FFC233',
MainActive: '#FFCC4D',
MainLine: '#FFD566',
OnMain: '#030508',
Container: '#221900',
ContainerHover: '#2a2000',
ContainerActive: '#332800',
ContainerLine: '#3b2e00',
OnContainer: '#FFE08A',
},
Critical: {
Main: '#FF2D55',
MainHover: '#FF4D6D',
MainActive: '#FF6080',
MainLine: '#FF7390',
OnMain: '#030508',
Container: '#22000d',
ContainerHover: '#2a0011',
ContainerActive: '#330015',
ContainerLine: '#3b0019',
OnContainer: '#FF99AA',
},
Other: {
FocusRing: 'rgba(0, 212, 255, 0.5)',
Shadow: 'rgba(0, 0, 0, 1)',
Overlay: 'rgba(3, 5, 8, 0.94)',
},
});
export const lotusTerminalLightTheme = createTheme(color, {
Background: {
Container: '#edf0f5',
ContainerHover: '#e2e7ef',
ContainerActive: '#d4dae6',
ContainerLine: '#c8d0de',
OnContainer: '#111827',
},
Surface: {
Container: '#f4f6fa',
ContainerHover: '#e8ecf4',
ContainerActive: '#dde2ed',
ContainerLine: '#d0d7e5',
OnContainer: '#111827',
},
SurfaceVariant: {
Container: '#e2e7ef',
ContainerHover: '#d4dae6',
ContainerActive: '#c8d0de',
ContainerLine: '#bdc6d6',
OnContainer: '#2d3d56',
},
Primary: {
Main: '#c44e00',
MainHover: '#a33f00',
MainActive: '#8f3700',
MainLine: '#d45800',
OnMain: '#ffffff',
Container: 'rgba(196,78,0,0.10)',
ContainerHover: 'rgba(196,78,0,0.16)',
ContainerActive: 'rgba(196,78,0,0.22)',
ContainerLine: 'rgba(196,78,0,0.28)',
OnContainer: '#6b2200',
},
Secondary: {
Main: '#0062b8',
MainHover: '#0052a0',
MainActive: '#004488',
MainLine: '#0070cc',
OnMain: '#ffffff',
Container: 'rgba(0,98,184,0.10)',
ContainerHover: 'rgba(0,98,184,0.16)',
ContainerActive: 'rgba(0,98,184,0.22)',
ContainerLine: 'rgba(0,98,184,0.28)',
OnContainer: '#003580',
},
Success: {
Main: '#006d35',
MainHover: '#005c2c',
MainActive: '#004a22',
MainLine: '#007d3e',
OnMain: '#ffffff',
Container: 'rgba(0,109,53,0.10)',
ContainerHover: 'rgba(0,109,53,0.16)',
ContainerActive: 'rgba(0,109,53,0.22)',
ContainerLine: 'rgba(0,109,53,0.28)',
OnContainer: '#003520',
},
Warning: {
Main: '#8a5a00',
MainHover: '#7a4e00',
MainActive: '#6a4400',
MainLine: '#9a6600',
OnMain: '#ffffff',
Container: 'rgba(138,90,0,0.10)',
ContainerHover: 'rgba(138,90,0,0.16)',
ContainerActive: 'rgba(138,90,0,0.22)',
ContainerLine: 'rgba(138,90,0,0.28)',
OnContainer: '#4a3000',
},
Critical: {
Main: '#b5001f',
MainHover: '#9a0019',
MainActive: '#800015',
MainLine: '#cc0023',
OnMain: '#ffffff',
Container: 'rgba(181,0,31,0.10)',
ContainerHover: 'rgba(181,0,31,0.16)',
ContainerActive: 'rgba(181,0,31,0.22)',
ContainerLine: 'rgba(181,0,31,0.28)',
OnContainer: '#600010',
},
Other: {
FocusRing: 'rgba(0, 98, 184, 0.50)',
Shadow: 'rgba(0, 0, 0, 0.15)',
Overlay: 'rgba(237, 240, 245, 0.97)',
},
});
+77
View File
@@ -0,0 +1,77 @@
const BOOT_MESSAGES = [
'╔════════════════════════════════════════════════════╗',
'║ LOTUS CHAT — LOTUSGUILD PLATFORM ║',
'║ SECURE MATRIX CLIENT — TERMINAL MODE ACTIVE ║',
'╚════════════════════════════════════════════════════╝',
'',
'[ OK ] Kernel modules loaded',
'[ OK ] Network interfaces configured',
'[ OK ] DNS resolution active',
'[ OK ] TLS certificate verified — matrix.lotusguild.org',
'[ OK ] Matrix homeserver connected',
'[ OK ] E2EE Olm/Megolm keys loaded',
'[ OK ] Cross-signing keys verified',
'[ OK ] Room state synchronized',
'[ OK ] Media proxy available',
'[ OK ] Notification service active',
'[ OK ] Terminal interface rendered',
'',
'> ALL SYSTEMS NOMINAL — LOTUS CHAT',
'> TYPE /help FOR COMMANDS',
'',
];
const STORAGE_KEY = 'lt_booted_lotus-chat';
export function resetBootSequence(): void {
sessionStorage.removeItem(STORAGE_KEY);
}
export function runLotusBootSequence(force = false): void {
if (!force && sessionStorage.getItem(STORAGE_KEY)) return;
sessionStorage.setItem(STORAGE_KEY, '1');
const overlay = document.createElement('div');
overlay.id = 'lt-boot';
overlay.style.cssText = [
'position:fixed',
'inset:0',
'background:#000',
'z-index:10100',
'display:flex',
'align-items:flex-start',
'justify-content:flex-start',
'padding:3rem',
"font-family:'JetBrains Mono','Fira Code','Courier New',monospace",
].join(';');
const pre = document.createElement('pre');
pre.style.cssText = [
'font-family:inherit',
'font-size:0.78rem',
'color:#00FF88',
'text-shadow:0 0 6px #00FF88,0 0 16px rgba(0,255,136,0.45)',
'line-height:1.7',
'white-space:pre-wrap',
'overflow:hidden',
].join(';');
overlay.appendChild(pre);
document.body.appendChild(overlay);
let i = 0;
let text = '';
const interval = setInterval(() => {
if (i >= BOOT_MESSAGES.length) {
clearInterval(interval);
setTimeout(() => {
overlay.style.transition = 'opacity 0.5s ease';
overlay.style.opacity = '0';
setTimeout(() => overlay.remove(), 500);
}, 500);
return;
}
text += BOOT_MESSAGES[i] + '\n';
pre.textContent = text;
i++;
}, 65);
}
+577
View File
@@ -0,0 +1,577 @@
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
const glitch1 = keyframes({
'0%, 90%, 100%': { clipPath: 'inset(0)', transform: 'skewX(0)' },
'92%': { clipPath: 'inset(15% 0 72% 0)', transform: 'skewX(-4deg)' },
'94%': { clipPath: 'inset(54% 0 22% 0)', transform: 'skewX(3deg)' },
'96%': { clipPath: 'inset(30% 0 48% 0)', transform: 'skewX(-2deg)' },
});
const glitch2 = keyframes({
'0%, 90%, 100%': { clipPath: 'inset(0)', transform: 'skewX(0)' },
'92%': { clipPath: 'inset(60% 0 8% 0)', transform: 'skewX(3deg)' },
'94%': { clipPath: 'inset(8% 0 68% 0)', transform: 'skewX(-3deg)' },
'96%': { clipPath: 'inset(42% 0 38% 0)', transform: 'skewX(1deg)' },
});
export const lotusTerminalBodyClass = style({
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace",
letterSpacing: '0.02em',
// Dot-grid background — TDS exact (on body since we're scoped to body class)
backgroundColor: '#030508',
backgroundImage: 'radial-gradient(circle, rgba(0,212,255,0.055) 1px, transparent 1px)',
backgroundSize: '28px 28px',
vars: {
// Backgrounds
'--lt-bg-primary': '#030508',
'--lt-bg-secondary': '#060c14',
'--lt-bg-tertiary': '#0d1520',
'--lt-bg-card': '#07101a',
'--lt-bg-terminal': '#010304',
// Accent — Orange
'--lt-accent-orange': '#FF6B00',
'--lt-accent-orange-bright': '#FF8C2B',
'--lt-accent-orange-dim': 'rgba(255,107,0,0.12)',
'--lt-accent-orange-border': 'rgba(255,107,0,0.35)',
// Accent — Amber
'--lt-accent-amber': '#FFB300',
'--lt-accent-amber-dim': 'rgba(255,179,0,0.10)',
// Accent — Cyan
'--lt-accent-cyan': '#00D4FF',
'--lt-accent-cyan-bright': '#33DFFF',
'--lt-accent-cyan-dim': 'rgba(0,212,255,0.10)',
'--lt-accent-cyan-border': 'rgba(0,212,255,0.22)',
// Accent — Green
'--lt-accent-green': '#00FF88',
'--lt-accent-green-bright': '#33FFAA',
'--lt-accent-green-dim': 'rgba(0,255,136,0.10)',
'--lt-accent-green-border': 'rgba(0,255,136,0.22)',
// Accent — Red
'--lt-accent-red': '#FF2D55',
'--lt-accent-red-dim': 'rgba(255,45,85,0.12)',
// Accent — Gold
'--lt-accent-gold': '#FFD700',
'--lt-accent-gold-dim': 'rgba(255,215,0,0.10)',
// Accent — Purple
'--lt-accent-purple': '#BF5FFF',
'--lt-accent-purple-dim': 'rgba(191,95,255,0.10)',
// Text
'--lt-text-primary': '#c4d9ee',
'--lt-text-secondary': '#7fa3bf',
'--lt-text-muted': '#3e607a',
'--lt-text-dim': '#1e3347',
// Borders
'--lt-border-color': 'rgba(0,212,255,0.16)',
'--lt-border-color-hi': '#00D4FF',
'--lt-border-color-dim': 'rgba(0,212,255,0.07)',
// Glows — text
'--lt-glow-orange': '0 0 6px #FF6B00, 0 0 16px rgba(255,107,0,0.55)',
'--lt-glow-orange-intense':'0 0 8px #FF6B00, 0 0 22px #FF6B00, 0 0 40px rgba(255,107,0,0.45)',
'--lt-glow-cyan': '0 0 6px #00D4FF, 0 0 16px rgba(0,212,255,0.45)',
'--lt-glow-cyan-intense': '0 0 8px #00D4FF, 0 0 22px #00D4FF, 0 0 38px rgba(0,212,255,0.35)',
'--lt-glow-green': '0 0 6px #00FF88, 0 0 16px rgba(0,255,136,0.45)',
'--lt-glow-green-intense': '0 0 8px #00FF88, 0 0 22px #00FF88, 0 0 36px rgba(0,255,136,0.35)',
'--lt-glow-amber': '0 0 6px #FFB300, 0 0 14px rgba(255,179,0,0.40)',
'--lt-glow-amber-intense': '0 0 8px #FFB300, 0 0 20px #FFB300, 0 0 34px rgba(255,179,0,0.45)',
'--lt-glow-red': '0 0 6px #FF2D55, 0 0 16px rgba(255,45,85,0.45)',
// Glows — box
'--lt-box-glow-orange': '0 0 18px rgba(255,107,0,0.22), 0 0 36px rgba(255,107,0,0.08)',
'--lt-box-glow-cyan': '0 0 18px rgba(0,212,255,0.18), 0 0 36px rgba(0,212,255,0.06)',
'--lt-box-glow-green': '0 0 18px rgba(0,255,136,0.18), 0 0 36px rgba(0,255,136,0.06)',
'--lt-box-glow-red': '0 0 18px rgba(255,45,85,0.22), 0 0 36px rgba(255,45,85,0.08)',
'--lt-box-glow-amber': '0 0 18px rgba(255,179,0,0.18), 0 0 36px rgba(255,179,0,0.06)',
// Fonts
'--lt-font-mono': "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace",
'--lt-font-display': "'JetBrains Mono', 'Fira Code', 'Courier New', monospace",
'--lt-font-crt': "'VT323', 'Courier New', monospace",
} as any,
});
// Font on all descendants
globalStyle(`body.${lotusTerminalBodyClass} *`, {
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace",
});
// Scanlines — EXACT from TDS body::before
globalStyle(`body.${lotusTerminalBodyClass}::before`, {
content: "''",
position: 'fixed',
inset: 0,
background: 'repeating-linear-gradient(0deg, rgba(0,0,0,0.07) 0px, rgba(0,0,0,0.07) 1px, transparent 1px, transparent 3px)',
pointerEvents: 'none',
zIndex: 9999,
});
// Vignette — EXACT from TDS body::after
globalStyle(`body.${lotusTerminalBodyClass}::after`, {
content: "''",
position: 'fixed',
inset: 0,
background: 'radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.60) 100%)',
pointerEvents: 'none',
zIndex: 9998,
});
// Orange caret
globalStyle(`body.${lotusTerminalBodyClass} input, body.${lotusTerminalBodyClass} textarea`, {
caretColor: '#FF6B00',
});
// Glitch class support
globalStyle(`body.${lotusTerminalBodyClass} .lt-glitch`, { position: 'relative' });
globalStyle(
`body.${lotusTerminalBodyClass} .lt-glitch::before, body.${lotusTerminalBodyClass} .lt-glitch::after`,
{
content: 'attr(data-text)' as any,
position: 'absolute',
top: 0,
left: 0,
width: '100%',
overflow: 'hidden',
}
);
globalStyle(`body.${lotusTerminalBodyClass} .lt-glitch::before`, {
color: '#00D4FF',
opacity: 0.65,
animationName: glitch1,
animationDuration: '4s',
animationIterationCount: 'infinite',
});
globalStyle(`body.${lotusTerminalBodyClass} .lt-glitch::after`, {
color: '#FF6B00',
animationName: glitch2,
animationDuration: '4s',
animationIterationCount: 'infinite',
});
// Scrollbar styling — matching TDS
globalStyle(`body.${lotusTerminalBodyClass} ::-webkit-scrollbar`, { width: '6px', height: '6px' });
globalStyle(`body.${lotusTerminalBodyClass} ::-webkit-scrollbar-track`, { background: '#030508' });
globalStyle(`body.${lotusTerminalBodyClass} ::-webkit-scrollbar-thumb`, {
background: 'rgba(0,212,255,0.25)',
borderRadius: '3px',
});
globalStyle(`body.${lotusTerminalBodyClass} ::-webkit-scrollbar-thumb:hover`, {
background: 'rgba(0,212,255,0.50)',
});
// ── Text selection — orange highlight (TDS aesthetic) ─────────────────────
globalStyle(`body.${lotusTerminalBodyClass} ::selection`, {
background: 'rgba(255,107,0,0.28)',
color: '#ffffff',
});
// ── Links — cyan primary, orange on hover with glow ────────────────────────
globalStyle(`body.${lotusTerminalBodyClass} a`, {
color: '#00D4FF',
textDecoration: 'none',
transition: 'color 0.12s ease, text-shadow 0.12s ease',
});
globalStyle(`body.${lotusTerminalBodyClass} a:hover`, {
color: '#FF6B00',
textShadow: '0 0 6px #FF6B00, 0 0 16px rgba(255,107,0,0.55)',
});
globalStyle(`body.${lotusTerminalBodyClass} a:focus-visible`, {
outline: '2px solid #00D4FF',
outlineOffset: '2px',
});
// ── Code blocks — TDS exact: green text, dark bg, left border ──────────────
globalStyle(`body.${lotusTerminalBodyClass} code`, {
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace",
fontSize: '0.8em',
background: '#010304',
border: '1px solid rgba(0,212,255,0.16)',
color: '#00FF88',
padding: '0.1em 0.4em',
borderRadius: '2px',
});
globalStyle(`body.${lotusTerminalBodyClass} pre`, {
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace",
fontSize: '0.8rem',
background: '#010304',
border: '1px solid rgba(0,212,255,0.16)',
borderLeft: '2px solid #00FF88',
color: '#00FF88',
textShadow: '0 0 6px rgba(0,255,136,0.35)',
padding: '1rem',
overflowX: 'auto',
whiteSpace: 'pre-wrap',
lineHeight: 1.6,
});
globalStyle(`body.${lotusTerminalBodyClass} pre code`, {
background: 'transparent',
border: 'none',
padding: 0,
fontSize: 'inherit',
color: 'inherit',
textShadow: 'inherit',
});
// ── Inline text semantics ──────────────────────────────────────────────────
globalStyle(`body.${lotusTerminalBodyClass} strong, body.${lotusTerminalBodyClass} b`, {
color: '#FF6B00',
fontWeight: '700',
});
globalStyle(`body.${lotusTerminalBodyClass} em, body.${lotusTerminalBodyClass} i`, {
color: '#00D4FF',
fontStyle: 'italic',
});
globalStyle(`body.${lotusTerminalBodyClass} mark`, {
background: 'rgba(255,179,0,0.25)',
color: '#FFB300',
padding: '0 0.25em',
});
globalStyle(`body.${lotusTerminalBodyClass} del, body.${lotusTerminalBodyClass} s`, {
color: '#FF2D55',
opacity: 0.7,
});
// ── Blockquote — TDS orange left border (matches reply quotes in chat) ──────
globalStyle(`body.${lotusTerminalBodyClass} blockquote`, {
borderLeft: '2px solid #FF6B00',
background: 'rgba(255,107,0,0.05)',
color: '#7fa3bf',
padding: '0.4rem 0.75rem',
margin: '0.25rem 0',
fontStyle: 'italic',
});
// ── Horizontal rule — cyan dim glow ────────────────────────────────────────
globalStyle(`body.${lotusTerminalBodyClass} hr`, {
border: 'none',
borderTop: '1px solid rgba(0,212,255,0.22)',
boxShadow: '0 0 6px rgba(0,212,255,0.15)',
margin: '1rem 0',
});
// ── Input / textarea / contenteditable focus — orange glow ─────────────────
globalStyle(
`body.${lotusTerminalBodyClass} input:focus,` +
`body.${lotusTerminalBodyClass} textarea:focus`,
{
outline: 'none',
borderColor: '#FF6B00',
boxShadow: '0 0 0 2px rgba(255,107,0,0.25), 0 0 12px rgba(255,107,0,0.12)',
}
);
globalStyle(`body.${lotusTerminalBodyClass} [contenteditable="true"]:focus`, {
outline: 'none',
boxShadow: '0 0 0 1px rgba(255,107,0,0.45), 0 0 12px rgba(255,107,0,0.12)',
});
// ── Tables — TDS style ──────────────────────────────────────────────────────
globalStyle(`body.${lotusTerminalBodyClass} table`, {
width: '100%',
borderCollapse: 'collapse',
fontSize: '0.8rem',
border: '1px solid rgba(0,212,255,0.16)',
});
globalStyle(`body.${lotusTerminalBodyClass} th`, {
color: '#FF6B00',
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: '0.10em',
padding: '0.5rem 0.75rem',
borderBottom: '1px solid rgba(0,212,255,0.22)',
background: 'rgba(255,107,0,0.06)',
textAlign: 'left',
});
globalStyle(`body.${lotusTerminalBodyClass} td`, {
padding: '0.5rem 0.75rem',
borderBottom: '1px solid rgba(0,212,255,0.07)',
color: '#c4d9ee',
});
globalStyle(`body.${lotusTerminalBodyClass} tr:hover td`, {
background: 'rgba(255,107,0,0.035)',
});
// ── Lists — add TDS prefix character ───────────────────────────────────────
globalStyle(`body.${lotusTerminalBodyClass} ul li::marker`, {
color: '#00D4FF',
content: '"▸ "',
});
globalStyle(`body.${lotusTerminalBodyClass} ol li::marker`, {
color: '#FF6B00',
});
// ── Structural elements — scoped TDS layout chrome ────────────────────────
// Sidebar/nav: subtle cyan right border
globalStyle(`body.${lotusTerminalBodyClass} nav`, {
borderRight: '1px solid rgba(0,212,255,0.10)',
});
// Page headers / toolbars: subtle bottom border glow
globalStyle(`body.${lotusTerminalBodyClass} header`, {
borderBottom: '1px solid rgba(0,212,255,0.14)',
boxShadow: '0 4px 24px rgba(0,0,0,0.55)',
});
// kbd — TDS terminal key style
globalStyle(`body.${lotusTerminalBodyClass} kbd`, {
fontFamily: "'JetBrains Mono', 'Fira Code', 'Courier New', monospace",
fontSize: '0.75em',
background: '#0d1520',
border: '1px solid rgba(0,212,255,0.35)',
borderBottom: '2px solid rgba(0,212,255,0.50)',
borderRadius: '3px',
padding: '0.1em 0.45em',
color: '#00D4FF',
textShadow: '0 0 4px rgba(0,212,255,0.4)',
boxShadow: '0 1px 3px rgba(0,0,0,0.6)',
});
// Tooltip / title popups: if browser renders them, style if possible
globalStyle(`body.${lotusTerminalBodyClass} [title]`, {
textDecoration: 'underline dotted rgba(0,212,255,0.35)',
cursor: 'help',
});
// abbr
globalStyle(`body.${lotusTerminalBodyClass} abbr[title]`, {
textDecoration: 'underline dotted #00D4FF',
});
// time elements — amber muted
globalStyle(`body.${lotusTerminalBodyClass} time`, {
color: '#7fa3bf',
fontSize: '0.78em',
});
// Images: cyan border on hover
globalStyle(`body.${lotusTerminalBodyClass} img:hover`, {
outline: '1px solid rgba(0,212,255,0.35)',
boxShadow: '0 0 12px rgba(0,212,255,0.12)',
});
// Ensure body text color is correct regardless of vanilla-extract theme loading order
globalStyle(`body.${lotusTerminalBodyClass}`, {
color: '#c4d9ee',
});
// ═══════════════════════════════════════════════════════════════════════════
// LIGHT MODE — TDS "daylight reading" variant
// html[data-theme="light"] + body.lotusTerminalBodyClass
// Mirrors base.css: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)',
});