Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eac972c546 |
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": [
|
||||
"matrix.lotusguild.org"
|
||||
],
|
||||
"allowCustomHomeservers": false,
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [],
|
||||
"rooms": [],
|
||||
"servers": []
|
||||
},
|
||||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": [
|
||||
"matrix.lotusguild.org"
|
||||
],
|
||||
"allowCustomHomeservers": false,
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [],
|
||||
"rooms": [],
|
||||
"servers": []
|
||||
},
|
||||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
}
|
||||
}
|
||||
@@ -21,15 +21,9 @@ jobs:
|
||||
workflow: ${{ github.event.workflow.id }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: pr
|
||||
- name: Validate and output pr number
|
||||
- name: Output pr number
|
||||
id: pr
|
||||
run: |
|
||||
PR_ID=$(<pr.txt)
|
||||
if ! [[ "${PR_ID}" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::pr.txt contains non-numeric content: ${PR_ID}"
|
||||
exit 1
|
||||
fi
|
||||
echo "id=${PR_ID}" >> "${GITHUB_OUTPUT}"
|
||||
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
|
||||
- name: Download artifact
|
||||
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
|
||||
with:
|
||||
@@ -48,7 +42,7 @@ jobs:
|
||||
enable-pull-request-comment: false
|
||||
enable-commit-comment: false
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN_PR }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
|
||||
timeout-minutes: 1
|
||||
- name: Comment preview on PR
|
||||
|
||||
@@ -1,160 +1,115 @@
|
||||
# Lotus Chat
|
||||
# Cinny
|
||||
<p>
|
||||
<a href="https://github.com/ajbura/cinny/releases">
|
||||
<img alt="GitHub release downloads" src="https://img.shields.io/github/downloads/ajbura/cinny/total?logo=github&style=social"></a>
|
||||
<a href="https://hub.docker.com/r/ajbura/cinny">
|
||||
<img alt="DockerHub downloads" src="https://img.shields.io/docker/pulls/ajbura/cinny?logo=docker&style=social"></a>
|
||||
<a href="https://fosstodon.org/@cinnyapp">
|
||||
<img alt="Follow on Mastodon" src="https://img.shields.io/mastodon/follow/106845779685925461?domain=https%3A%2F%2Ffosstodon.org&logo=mastodon&style=social"></a>
|
||||
<a href="https://twitter.com/intent/follow?screen_name=cinnyapp">
|
||||
<img alt="Follow on Twitter" src="https://img.shields.io/twitter/follow/cinnyapp?logo=twitter&style=social"></a>
|
||||
<a href="https://cinny.in/#sponsor">
|
||||
<img alt="Sponsor Cinny" src="https://img.shields.io/opencollective/all/cinny?logo=opencollective&style=social"></a>
|
||||
</p>
|
||||
|
||||
A Matrix client for [Lotus Guild](https://lotusguild.org) — forked from [Cinny](https://github.com/cinnyapp/cinny) v4.12.1.
|
||||
A Matrix client focusing primarily on simple, elegant and secure interface. The main goal is to have an instant messaging application that is easy on people and has a modern touch.
|
||||
- [Roadmap](https://github.com/orgs/cinnyapp/projects/1)
|
||||
- [Contributing](./CONTRIBUTING.md)
|
||||
|
||||
Deployed at [chat.lotusguild.org](https://chat.lotusguild.org).
|
||||
> [!IMPORTANT]
|
||||
We are currently in the [process of replacing the matrix-js-sdk](https://github.com/cinnyapp/cinny/issues/257#issuecomment-3714406704) with our own SDK. As a result, we will not be accepting any pull requests until further notice.
|
||||
Thank you for your understanding.
|
||||
|
||||
---
|
||||
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380">
|
||||
|
||||
## Changes from upstream Cinny
|
||||
## Getting started
|
||||
The web app is available at [app.cinny.in](https://app.cinny.in/) and gets updated on each new release. The `dev` branch is continuously deployed at [dev.cinny.in](https://dev.cinny.in) but keep in mind that it could have things broken.
|
||||
|
||||
### Branding & Identity
|
||||
You can also download our desktop app from the [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop).
|
||||
|
||||
- Package renamed to `lotus-chat`, description updated to "Lotus Chat — Matrix client for Lotus Guild"
|
||||
- App title changed from "Cinny" to "Lotus Chat" throughout
|
||||
- Favicon, PWA icons, and all icon sizes (57×57 → 180×180 Apple touch icons) replaced with Lotus.png variants
|
||||
- Logo in About dialog and Auth page replaced with official Lotus.png
|
||||
- Auth footer rewritten: shows dynamic version from `package.json`, links to lotusguild.org, chat.lotusguild.org, and matrix.lotusguild.org
|
||||
- Welcome page tagline changed from "Yet another matrix client" to "A Matrix client for Lotus Guild"
|
||||
- Encryption key export filename changed from `cinny-keys.txt` to `lotus-keys.txt`
|
||||
- `manifest.json` updated with Lotus name, description, and branding colors
|
||||
## Self-hosting
|
||||
To host Cinny on your own, simply download the tarball from [GitHub releases](https://github.com/cinnyapp/cinny/releases/latest), and serve the files from `dist/` using your preferred webserver. Alternatively, you can just pull the docker image from [DockerHub](https://hub.docker.com/r/ajbura/cinny) or [GitHub Container Registry](https://github.com/cinnyapp/cinny/pkgs/container/cinny).
|
||||
|
||||
### LotusGuild Terminal Design System (TDS) v1.2
|
||||
* The default homeservers and explore pages are defined in [`config.json`](config.json).
|
||||
|
||||
A full custom theme engine layered on top of Cinny's vanilla-extract theming:
|
||||
* You need to set up redirects to serve the assests. Example configurations; [netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf), [caddy](contrib/caddy/caddyfile).
|
||||
* If you have trouble configuring redirects you can [enable hash routing](config.json#L35) — the url in the browser will have a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/` instead of `app.cinny.in/home/`) but you won't have to configure your webserver.
|
||||
|
||||
**Dark mode** (`LotusTerminalTheme`):
|
||||
- CRT terminal aesthetic: scanline overlay, vignette, phosphor glow
|
||||
- Palette: bg `#030508`, orange `#FF6B00`, cyan `#00D4FF`, green `#00FF88`, text `#c4d9ee`
|
||||
- Monospace font stack, terminal-style scrollbars
|
||||
- Custom hex-grid and circuit-board CSS background patterns
|
||||
- Matrix-style boot messages on the welcome page (press Escape to skip)
|
||||
- CSS variables: `--lt-*` family covering colors, glow effects, borders, animations
|
||||
* To deploy on subdirectory, you need to rebuild the app youself after updating the `base` path in [`build.config.ts`](build.config.ts).
|
||||
* For example, if you want to deploy on `https://cinny.in/app`, then set `base: '/app'`.
|
||||
|
||||
**Light mode** (`LotusTerminalLightTheme`):
|
||||
- Full light palette: bg `#edf0f5`, orange `#c44e00`, cyan `#0062b8`, green `#006d35`, text `#111827`
|
||||
- No CRT effects (scanlines, vignette disabled)
|
||||
- Light-mode scrollbars, adjusted code block colors, semantic color overrides
|
||||
- Scoped to `html[data-theme="light"] body.lotusTerminalBodyClass`
|
||||
- `ThemeManager.tsx` sets `data-theme` attribute based on active theme kind
|
||||
<details><summary><b>PGP Public Key to verify tarball</b></summary>
|
||||
|
||||
**Chat Backgrounds** (20+ custom patterns, all TDS-aware):
|
||||
- Blueprint grid, carbon fiber, starfield, topographic contours, herringbone, crosshatch
|
||||
- Chevron, polka dots, triangles, plaid
|
||||
- All patterns use CSS custom properties — adapt to both TDS dark and light themes
|
||||
- Settings toggle for showing per-message sender profiles
|
||||
```
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
### Voice / Video Call Improvements
|
||||
mQGNBGJw/g0BDAC8qQeLqDMzYzfPyOmRlHVEoguVTo+eo1aVdQH2X7OELdjjBlyj
|
||||
6d6c1adv/uF2g83NNMoQY7GEeHjRnXE4m8kYSaarb840pxrYUagDc0dAbJOGaCBY
|
||||
FKTo7U1Kvg0vdiaRuus0pvc1NVdXSxRNQbFXBSwduD+zn66TI3HfcEHNN62FG1cE
|
||||
K1jWDwLAU0P3kKmj8+CAc3h9ZklPu0k/+t5bf/LJkvdBJAUzGZpehbPL5f3u3BZ0
|
||||
leZLIrR8uV7PiV5jKFahxlKR5KQHld8qQm+qVhYbUzpuMBGmh419I6UvTzxuRcvU
|
||||
Frn9ttCEzV55Y+so4X2e4ZnB+5gOnNw+ecifGVdj/+UyWnqvqqDvLrEjjK890nLb
|
||||
Pil4siecNMEpiwAN6WSmKpWaCwQAHEGDVeZCc/kT0iYfj5FBcsTVqWiO6eaxkUlm
|
||||
jnulqWqRrlB8CJQQvih/g//uSEBdzIibo+ro+3Jpe120U/XVUH62i9HoRQEm6ADG
|
||||
4zS5hIq4xyA8fL8AEQEAAbQdQ2lubnlBcHAgPGNpbm55YXBwQGdtYWlsLmNvbT6J
|
||||
AdQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQSRri2MHidaaZv+
|
||||
vvuUMwx6UK/M8wUCZqEDwAUJFvwIswAKCRCUMwx6UK/M877qC/4lxXOQIoWnLLkK
|
||||
YiRCTkGsH6NdxgeYr6wpXT4xuQ45ZxCytwHpOGQmO/5up5961TxWW8D1frRIJHjj
|
||||
AZGoRCL3EKEuY8nt3D99fpf3DvZrs1uoVAhiyn737hRlZAg+QsJheeGCmdSJ0hX5
|
||||
Yud8SE+9zxLS1+CEjMrsUd/RGre/phme+wNXfaHfREAC9ewolgVChPIbMxG2f+vs
|
||||
K8Xv52BFng7ta9fgsl1XuOjpuaSbQv6g+4ONk/lxKF0SmnhEGM3dmIYPONxW47Yf
|
||||
atnIjRra/YhPTNwrNBGMmG4IFKaOsMbjW/eakjWTWOVKKJNBMoDdRcYYWIMCpLy8
|
||||
AQUrMtQEsHSnqCwrw818S5A6rrhcfVGk36RGm0nOy6LS5g5jmqaYsvbCcBGY9B2c
|
||||
SUAVNm17oo7TtEajk8hcSXoZod1t++pyjcVKEmSn3nFK7v5m3V+cPhNTxZMK459P
|
||||
3x1Ucqj/kTqrxKw6s2Uknuk0ajmw0ljV+BQwgL6maguo9BKgCNW5AY0EYnD+DQEM
|
||||
ANOu/d6ZMF8bW+Df9RDCUQKytbaZfa+ZbIHBus7whCD/SQMOhPKntv3HX7SmMCs+
|
||||
5i27kJMu4YN623JCS7hdCoXVO1R5kXCEcneW/rPBMDutaM472YvIWMIqK9Wwl5+0
|
||||
Piu2N+uTkKhe9uS2u7eN+Khef3d7xfjGRxoppM+xI9dZO+jhYiy8LuC0oBohTjJq
|
||||
QPqfGDpowBwRkkOsGz/XVcesJ1Pzg4bKivTS9kZjZSyT9RRSY8As0sVUN57AwYul
|
||||
s1+eh00n/tVpi2Jj9pCm7S0csSXvXj8v2OTdK1jt4YjpzR0/rwh4+/xlOjDjZEqH
|
||||
vMPhpzpbgnwkxZ3X8BFne9dJ3maC5zQ3LAeCP5m1W0hXzagYhfyjo74slJgD1O8c
|
||||
LDf2Oxc5MyM8Y/UK497zfqSPfgT3NhQmhHzk83DjXw3I6Z3A3U+Jp61w0eBRI1nx
|
||||
H1UIG+gldcAKUTcfwL0lghoT3nmi9JAbvek0Smhz00Bbo8/dx8vwQRxDUxlt7Exx
|
||||
NwARAQABiQG8BBgBCAAmAhsMFiEEka4tjB4nWmmb/r77lDMMelCvzPMFAmahA9IF
|
||||
CRb8CMUACgkQlDMMelCvzPPQgQv/d5/z+fxgKqgfhQX+V49X4WgTVxZ/CzztDoJ1
|
||||
XAq1dzTNEy8AFguXIo6eVXPSpMxec7ZreN3+UPQBnCf3eR5YxWNYOYKmk0G4E8D2
|
||||
KGUJept7TSA42/8N2ov6tToXFg4CgzKZj0fYLwgutly7K8eiWmSU6ptaO8aEQBHB
|
||||
gTGIOO3h6vJMGVycmoeRnHjv4wV84YWSVFSoJ7cY0he4Z9UznJBbE/KHZjrkXsPo
|
||||
N+Gg5lDuOP5xjKzM5SogV9lhxBAhMWAg3URUF15yruZBiA8uV1FOK8sal/9C1G7V
|
||||
M6ygA6uOZqXlZtcdA94RoSsW2pZ9eLVPsxz2B3Zko7tu11MpNP/wYmfGTI3KxZBj
|
||||
n/eodvwjJSgHpGOFSmbNzvPJo3to5nNlp7wH1KxIMc6Uuu9hgfDfwkFZgV2bnFIa
|
||||
Q6gyF548Ub48z7Dz83+WwLgbX19ve4oZx+dqSdczP6ILHRQomtrzrkkP2LU52oI5
|
||||
mxFo+ioe/ABCufSmyqFye0psX3Sp
|
||||
=WtqZ
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
```
|
||||
</details>
|
||||
|
||||
- **Element Call 0.19.3**: Upgraded from 0.16.3. Dist copied to `public/element-call/` by vite at build time.
|
||||
- **Camera default OFF**: Camera no longer persists across sessions via localStorage. Always starts disabled. Optional `cameraOnJoin` setting for explicit opt-in.
|
||||
- **Deafen button**: Tooltip corrected to "Deafen" / "Undeafen" (was "Turn Off Sound" / "Turn On Sound")
|
||||
- **Screenshare confirmation**: A confirm dialog appears before screenshare is broadcast to call participants
|
||||
- **Auto-revert spotlight on screenshare**: When someone starts screensharing, EC normally forces all participants into spotlight view. Patched in `CallControl.ts` `onControlMutation()` — detects the screenshare button going `primary` and clicks `gridButton` after 600ms to revert to grid layout. Participants choose to watch screenshare manually.
|
||||
- **Push to Talk (PTT)**:
|
||||
- Configurable keybind (default: Space) via Settings > General > Calls
|
||||
- Mic activates on keydown, deactivates on keyup; mic muted on tab blur/focus to prevent stuck-on mic
|
||||
- Visual indicator: plain folds `Chip` by default; when LotusGuild TDS is active: orange `PTT — Hold SPACE` / green `● LIVE` in JetBrains Mono
|
||||
- Listens on both main window and EC iframe `contentWindow` for reliable key capture
|
||||
- Implemented via `CallControl.setMicrophone()` public method on the widget bridge
|
||||
- **Mic state preservation**: when enabling PTT mode mid-call, the user's previous mic state is saved and restored when PTT is disabled — prevents unwanted unmute if the user had manually muted before switching to PTT.
|
||||
- **Noise suppression toggle**: Settings > General > Calls — passes `noiseSuppression` URL parameter to the embedded Element Call widget
|
||||
- **Call button scoping**: The upstream Cinny 4.12.1 call button (voice + video dropdown) is restricted to DMs and private group chats only. Specifically: direct messages, or invite-only rooms that have no `m.space.parent` state event (i.e. not a space/guild text channel). Public rooms and space channels are excluded to prevent accidental mass-notifications. `Room.tsx` switches to CallView layout when a call embed is active in the current room.
|
||||
- **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`.
|
||||
- **Picture-in-picture (PiP)**: When navigating away from a call room while in an active call, the call embed shrinks to a 280x158px floating window in the bottom-right corner. The PiP window is **draggable** — drag it anywhere on screen to move it out of the way. Clicking (without dragging) navigates back to the call room. Drag vs click distinguished by a 5px movement threshold; touch drag supported. Imperative style overrides on `callEmbedRef.current` via `useEffect` — a wrapper div cannot be used because `useCallEmbedPlacementSync` writes `top/left/width/height` directly onto that element.
|
||||
## Local development
|
||||
> [!TIP]
|
||||
> We recommend using a version manager as versions change very quickly. You will likely need to switch between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Krypton LTS (v24.13.1).
|
||||
|
||||
### Messaging Enhancements
|
||||
|
||||
- **GIF picker**: Giphy-powered GIF search and send. Button appears in the message composer only when `gifApiKey` is set in `config.json`. Sends GIF as `m.image` — fetches blob, uploads via `mx.uploadContent`, sends with `mx.sendMessage`. `FocusTrap` handles click-outside / Escape to close. When TDS is active: dark navy background (`#060c14`), orange dim border, `// GIF_SEARCH` header, CSS overrides for Giphy SDK search bar (dark bg, orange border/focus ring, JetBrains Mono), custom orange scrollbar. All TDS styles live in `lotus-terminal.css.ts` — no runtime `<style>` injection, eliminating flash of unstyled content.
|
||||
- **Message forwarding**: Forward any message to any room from the message context menu.
|
||||
- **Image/video captions**: Caption text field on image and video upload — sent as a single event with the media.
|
||||
- **Location sharing**: Map embed view for incoming location events + static share button. Renders `m.location` events inline with a map tile.
|
||||
- **Deleted message placeholders**: Redacted `m.room.message`, `m.room.encrypted`, and `m.sticker` events render as "This message has been deleted" with reason (if provided) rather than disappearing. One-line change in the `eventRenderer` filter in `RoomTimeline.tsx`.
|
||||
|
||||
### Per-Message Read Receipts
|
||||
|
||||
Full per-message read receipt system — shows who has read each message directly in the timeline.
|
||||
|
||||
**Architecture:**
|
||||
- `useRoomReadPositions(room)` hook — computes a `Map<eventId, userId[]>` from all joined members' `room.getEventReadUpTo()` positions. Subscribes to `RoomEvent.Receipt` for live updates (debounced at 150ms to batch burst updates from mass-read events).
|
||||
- `nearestRenderableId(liveEvents, evtId)` — receipts can land on reaction/edit events that `RoomTimeline` skips (renders `null`). This walks backwards from the receipt event through the live timeline until it finds a non-reaction/non-edit event to attach to.
|
||||
- `ReadPositionsContext` — React context providing the positions map from `RoomTimeline` down to all `Message` instances without prop drilling.
|
||||
- `ReadReceiptAvatars` component — renders a pill-shaped row of overlapping `StackedAvatar` circles (24px, `SurfaceVariant` outline) below messages with readers. Pill uses `color.SurfaceVariant.Container` background for visibility on any wallpaper. Max 5 avatars shown + `+N` overflow count. Avatar fallback uses `colorMXID(userId)` for distinctive per-user color.
|
||||
- Clicking the pill opens the **"Seen by" modal** (`EventReaders`) listing all readers with their avatar, display name, and a formatted read timestamp ("Today at 3:42 PM", "Yesterday at 10:15 AM", "May 14 at 9:00 AM"). Timestamps use `room.getReadReceiptForUserId(userId)?.data.ts` and respect the user's 24-hour clock setting.
|
||||
- Authenticated media (`mxcUrlToHttp` utility) used for all avatar loads, matching the correct Lotus utility signature.
|
||||
|
||||
### Delivery Status Indicators
|
||||
|
||||
Own messages display a small status marker below the message content (when no read receipts are visible yet):
|
||||
- `⟳` — message is being sent / encrypting
|
||||
- `✓` — message confirmed sent (local echo)
|
||||
- `✕` — message failed to send (shown in red; orange glow in TDS mode)
|
||||
- Status hidden once the server confirms receipt (`status === null`) — read receipts take over at that point
|
||||
|
||||
### URL Preview Cards (TDS)
|
||||
|
||||
URL preview cards (`UrlPreviewCard`) styled for terminal mode:
|
||||
- Dark transparent background with cyan border-left accent (Anduril Orange)
|
||||
- Link text in cyan, hover switches to orange with glow
|
||||
- Light TDS variant: off-white background with blue accent
|
||||
|
||||
### Reaction Chips (TDS)
|
||||
|
||||
Emoji reaction buttons styled for terminal mode via `button[data-reaction-key]` selector:
|
||||
- Unselected: `rgba(0,212,255,0.06)` background, cyan border
|
||||
- Hover: brighter background + box-shadow glow
|
||||
- Own reaction (aria-pressed=true): orange tint `rgba(255,107,0,0.12)`, orange border
|
||||
- Light TDS: equivalent blue/orange variants
|
||||
|
||||
### DM Call Improvements
|
||||
|
||||
|
||||
- **Incoming call ring**: DM calls trigger a ring tone with Answer/Decline UI. 30-second auto-dismiss if unanswered. Implemented in `Room.tsx` and `RoomViewHeader.tsx`.
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- **Authenticated media**: All avatar/media loads use `mxcUrlToHttp(mx, mxcUrl, useAuthentication, w, h, 'crop')` from `../../utils/matrix` — the Lotus utility that handles MSC3916 authenticated media. (Upstream Cinny uses the SDK method with incorrect argument order for authenticated endpoints.)
|
||||
- **Upstream tracking**: `git remote add upstream https://github.com/cinnyapp/cinny.git`. Merge strategy: `git fetch upstream && git merge upstream/main`. Daily check via `cinny-upstream-check.sh` on LXC 106 — notifies Matrix on new upstream commits.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run build # outputs to dist/
|
||||
Execute the following commands to start a development server:
|
||||
```sh
|
||||
npm ci # Installs all dependencies
|
||||
npm start # Serve a development version
|
||||
```
|
||||
|
||||
Vite's render-chunks phase requires ~6 GB Node heap. If OOM killed, set:
|
||||
```bash
|
||||
NODE_OPTIONS=--max_old_space_size=6144 npm run build
|
||||
To build the app:
|
||||
```sh
|
||||
npm run build # Compiles the app into the dist/ directory
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Built files are served from `/var/www/html/` on LXC 106 (nginx). Config lives at `/opt/lotus-cinny/config.json` (vite copies it to `dist/`):
|
||||
|
||||
```json
|
||||
{
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": ["matrix.lotusguild.org"],
|
||||
"allowCustomHomeservers": false,
|
||||
"gifApiKey": "<giphy_key>"
|
||||
}
|
||||
### 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 .
|
||||
```
|
||||
|
||||
## Key Custom Files
|
||||
You can then run the container you've built with a command similar to this:
|
||||
```
|
||||
docker run -p 8080:80 cinny:latest
|
||||
```
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/lotus-terminal.css.ts` | All TDS CSS tokens, global styles, light/dark variants |
|
||||
| `src/lotus-boot.ts` | Boot sequence animation (runs once per session) |
|
||||
| `src/app/hooks/useRoomReadPositions.ts` | Per-message read receipt position map |
|
||||
| `src/app/features/room/ReadPositionsContext.ts` | React context for read positions |
|
||||
| `src/app/components/read-receipt-avatars/` | Read receipt avatar pill component |
|
||||
| `src/app/components/event-readers/EventReaders.tsx` | "Seen by" modal with timestamps |
|
||||
| `src/app/components/GifPicker.tsx` | GIF search + send |
|
||||
| `src/app/features/call/CallControls.tsx` | PTT badge + keybind logic |
|
||||
| `src/app/plugins/call/CallControl.ts` | EC widget bridge (screenshare revert, PTT mic) |
|
||||
| `src/app/components/CallEmbedProvider.tsx` | PiP + draggable call embed |
|
||||
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`.
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
{
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": [
|
||||
"matrix.lotusguild.org"
|
||||
],
|
||||
"allowCustomHomeservers": false,
|
||||
"defaultHomeserver": 1,
|
||||
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
|
||||
"allowCustomHomeservers": true,
|
||||
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [],
|
||||
"rooms": [],
|
||||
"servers": []
|
||||
"spaces": [
|
||||
"#cinny-space:matrix.org",
|
||||
"#community:matrix.org",
|
||||
"#space:unredacted.org",
|
||||
"#science-space:matrix.org",
|
||||
"#libregaming-games:tchncs.de",
|
||||
"#mathematics-on:matrix.org",
|
||||
"#stickers-and-emojis:tastytea.de"
|
||||
],
|
||||
"rooms": [
|
||||
"#cinny:matrix.org",
|
||||
"#freesoftware:matrix.org",
|
||||
"#pcapdroid:matrix.org",
|
||||
"#gentoo:matrix.org",
|
||||
"#PrivSec.dev:arcticfoxes.net",
|
||||
"#disroot:aria-net.org"
|
||||
],
|
||||
"servers": ["matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"]
|
||||
},
|
||||
|
||||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
},
|
||||
"gifApiKey": "AqqDuQwZNjYttz7Mn6ME4JH1bJIuZ5CO"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,37 +3,34 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lotus Chat</title>
|
||||
<meta name="name" content="Lotus Chat" />
|
||||
<meta name="author" content="Lotus Guild" />
|
||||
<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" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Lotus Chat — the Lotus Guild Matrix client. Secure, fast, and built for our community."
|
||||
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="lotus chat, lotus guild, matrix, matrix client"
|
||||
content="cinny, cinnyapp, cinnychat, matrix, matrix client, matrix.org, element"
|
||||
/>
|
||||
|
||||
<meta property="og:title" content="Lotus Chat" />
|
||||
<meta property="og:url" content="https://chat.lotusguild.org" />
|
||||
<meta property="og:image" content="https://chat.lotusguild.org/public/res/android/android-chrome-192x192.png" />
|
||||
<meta property="og:title" content="Cinny" />
|
||||
<meta property="og:url" content="https://cinny.in" />
|
||||
<meta property="og:image" content="https://cinny.in/assets/favicon-48x48.png" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Lotus Chat — the Lotus Guild Matrix client. Secure, fast, and built for our community."
|
||||
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap" rel="stylesheet">
|
||||
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="application-name" content="Lotus Chat" />
|
||||
<meta name="apple-mobile-web-app-title" content="Lotus Chat" />
|
||||
<meta name="application-name" content="Cinny" />
|
||||
<meta name="apple-mobile-web-app-title" content="Cinny" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
{
|
||||
"name": "lotus-chat",
|
||||
"version": "4.12.1-lotus",
|
||||
"name": "cinny",
|
||||
"version": "4.11.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lotus-chat",
|
||||
"version": "4.12.1-lotus",
|
||||
"name": "cinny",
|
||||
"version": "4.11.1",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||
"@fontsource/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",
|
||||
@@ -40,14 +37,14 @@
|
||||
"html-react-parser": "4.2.0",
|
||||
"i18next": "23.12.2",
|
||||
"i18next-browser-languagedetector": "8.0.0",
|
||||
"i18next-http-backend": "2.5.2",
|
||||
"i18next-http-backend": "3.0.5",
|
||||
"immer": "9.0.16",
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.6.0",
|
||||
"linkify-react": "4.3.2",
|
||||
"linkifyjs": "4.3.2",
|
||||
"matrix-js-sdk": "38.2.0",
|
||||
"matrix-widget-api": "1.16.1",
|
||||
"matrix-widget-api": "1.13.0",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "4.2.67",
|
||||
"prismjs": "1.30.0",
|
||||
@@ -61,16 +58,15 @@
|
||||
"react-i18next": "15.0.0",
|
||||
"react-range": "1.8.14",
|
||||
"react-router-dom": "6.30.3",
|
||||
"sanitize-html": "2.17.4",
|
||||
"sanitize-html": "2.12.1",
|
||||
"slate": "0.123.0",
|
||||
"slate-dom": "0.123.0",
|
||||
"slate-history": "0.113.1",
|
||||
"slate-react": "0.123.0",
|
||||
"styled-components": "6.4.1",
|
||||
"ua-parser-js": "1.0.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-call-embedded": "0.19.3",
|
||||
"@element-hq/element-call-embedded": "0.16.3",
|
||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||
"@rollup/plugin-inject": "5.0.3",
|
||||
"@rollup/plugin-wasm": "6.1.1",
|
||||
@@ -84,7 +80,7 @@
|
||||
"@types/react": "18.2.39",
|
||||
"@types/react-dom": "18.2.17",
|
||||
"@types/react-google-recaptcha": "2.1.8",
|
||||
"@types/sanitize-html": "2.16.1",
|
||||
"@types/sanitize-html": "2.9.0",
|
||||
"@types/ua-parser-js": "0.7.36",
|
||||
"@typescript-eslint/eslint-plugin": "5.46.1",
|
||||
"@typescript-eslint/parser": "5.46.1",
|
||||
@@ -1841,9 +1837,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@element-hq/element-call-embedded": {
|
||||
"version": "0.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.19.3.tgz",
|
||||
"integrity": "sha512-ZwAK6PrMDiIFf3uDZuYpRR+Ho7OgfCSnENd9hxRH6LhWlRwuo42/4Nhn6wknqRjAeMJtJo/yFIn0Zh+jZ/Gf7Q==",
|
||||
"version": "0.16.3",
|
||||
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.16.3.tgz",
|
||||
"integrity": "sha512-OViKJonDaDNVBUW9WdV9mk78/Ruh34C7XsEgt3O8D9z+64C39elbIgllHSoH5S12IRlv9RYrrV37FZLo6QWsDQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@emotion/hash": {
|
||||
@@ -1851,21 +1847,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="
|
||||
},
|
||||
"node_modules/@emotion/is-prop-valid": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
|
||||
"integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/memoize": "^0.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/memoize": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
|
||||
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild-plugins/node-globals-polyfill": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz",
|
||||
@@ -2321,84 +2302,6 @@
|
||||
"tslib": "2"
|
||||
}
|
||||
},
|
||||
"node_modules/@giphy/colors": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@giphy/colors/-/colors-1.0.1.tgz",
|
||||
"integrity": "sha512-CMUY2UR8Ujl/wTRVjwu9spss6Cf2qkQNATKfoWCTWQp7CXdSfPWUDepp0c0/F0n7lYphLFvD9qzOC4NSdBt7nw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@giphy/js-analytics": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@giphy/js-analytics/-/js-analytics-5.0.0.tgz",
|
||||
"integrity": "sha512-jBZG6OqyMWB6meLi8Sz3iLplXYnhkbj+DJhS4ChmRX8Y6UA7i5dbbsUN/So1s7tTjhZOvu0rxA6rWJE73S1FvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@giphy/js-types": "*",
|
||||
"@giphy/js-util": "*",
|
||||
"append-query": "^2.1.0",
|
||||
"throttle-debounce": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@giphy/js-fetch-api": {
|
||||
"version": "5.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@giphy/js-fetch-api/-/js-fetch-api-5.8.0.tgz",
|
||||
"integrity": "sha512-DWdNauYaBSLPeAe/O+uzXtPIoUvmSnYV8Q49p+cPjEQ7Ncj1mDg1fuSKyl0OmPxCm6fzGJ4kuJjsWzyWn9ubbA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@giphy/js-types": "*",
|
||||
"@giphy/js-util": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@giphy/js-types": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@giphy/js-types/-/js-types-5.1.0.tgz",
|
||||
"integrity": "sha512-BZYCDtYNRR7cUWkbDLB4wmm3qmWMsVCQdUiBNOfmZ3yAazCgygKJoDI/5Rq4CK5MBaOc5LVdF8viC2WtoBdaPA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@giphy/js-util": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@giphy/js-util/-/js-util-5.2.0.tgz",
|
||||
"integrity": "sha512-Qt7pGh2cqiNmXLeWAgb459wK8+BuMLtIxTfg4ZksnPHPsLthiHT9hhzs2QhqUh7Pp/HOq+Cbv2etGDfnq+xiKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@giphy/js-types": "*",
|
||||
"uuid": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@giphy/js-util/node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@giphy/react-components": {
|
||||
"version": "10.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@giphy/react-components/-/react-components-10.1.2.tgz",
|
||||
"integrity": "sha512-DVyIgf5az0uzHwnkS8tUaqO4qNx8DoxJupnRLhOl8M5CcvV9ykeZBLDxLj+jyDTySPYaIIJtrlR+8SeV2dHZwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@giphy/colors": "*",
|
||||
"@giphy/js-analytics": "*",
|
||||
"@giphy/js-fetch-api": "*",
|
||||
"@giphy/js-types": "*",
|
||||
"@giphy/js-util": "*",
|
||||
"intersection-observer": "^0.12.2",
|
||||
"react-use": "17.6.0",
|
||||
"throttle-debounce": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "18 - 19",
|
||||
"styled-components": ">= 5"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.11.14",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
|
||||
@@ -5710,12 +5613,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/js-cookie": {
|
||||
"version": "2.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz",
|
||||
"integrity": "sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -5794,13 +5691,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/sanitize-html": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
|
||||
"integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.9.0.tgz",
|
||||
"integrity": "sha512-4fP/kEcKNj2u39IzrxWYuf/FnCCwwQCpif6wwY6ROUS1EPRIfWJjGkY3HIowY1EX/VbX5e86yq8AAE7UPMgATg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"htmlparser2": "^10.1"
|
||||
"htmlparser2": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/scheduler": {
|
||||
@@ -6141,12 +6037,6 @@
|
||||
"vite": "^4.2.0 || ^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xobotyi/scrollbar-width": {
|
||||
"version": "1.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz",
|
||||
"integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||
@@ -6309,15 +6199,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/append-query": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/append-query/-/append-query-2.1.1.tgz",
|
||||
"integrity": "sha512-adm0E8o1o7ay+HbkWvGIpNNeciLB/rxJ0heThHuzSSVq5zcdQ5/ZubFnUoY0imFmk6gZVghSpwoubLVtwi9EHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/aproba": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
|
||||
@@ -6898,15 +6779,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelize": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
|
||||
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001692",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001692.tgz",
|
||||
@@ -7521,15 +7393,6 @@
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
|
||||
},
|
||||
"node_modules/copy-to-clipboard": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
|
||||
"integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"toggle-selection": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.40.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.40.0.tgz",
|
||||
@@ -7626,6 +7489,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
|
||||
"integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -7649,57 +7521,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-color-keywords": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
|
||||
"integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/css-in-js-utils": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
|
||||
"integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hyphenate-style-name": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/css-to-react-native": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
|
||||
"integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"camelize": "^1.0.0",
|
||||
"css-color-keywords": "^1.0.0",
|
||||
"postcss-value-parser": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
|
||||
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.0.14",
|
||||
"source-map": "^0.6.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree/node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-5.1.0.tgz",
|
||||
@@ -7723,10 +7544,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
},
|
||||
"node_modules/cz-conventional-changelog": {
|
||||
"version": "3.3.0",
|
||||
@@ -8484,15 +8304,6 @@
|
||||
"is-arrayish": "^0.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/error-stack-parser": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
|
||||
"integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"stackframe": "^1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
"version": "1.23.9",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz",
|
||||
@@ -9288,12 +9099,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/external-editor": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
|
||||
@@ -9329,7 +9134,8 @@
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
@@ -9371,11 +9177,6 @@
|
||||
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fast-shallow-equal": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz",
|
||||
"integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw=="
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.5.tgz",
|
||||
@@ -9392,12 +9193,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/fastest-stable-stringify": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz",
|
||||
"integrity": "sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz",
|
||||
@@ -10383,9 +10178,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
@@ -10393,24 +10188,11 @@
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"entities": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
@@ -10476,12 +10258,6 @@
|
||||
"url": "https://github.com/sponsors/typicode"
|
||||
}
|
||||
},
|
||||
"node_modules/hyphenate-style-name": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
|
||||
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "23.12.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-23.12.2.tgz",
|
||||
@@ -10513,19 +10289,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-http-backend": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-2.5.2.tgz",
|
||||
"integrity": "sha512-+K8HbDfrvc1/2X8jpb7RLhI9ZxBDpx3xogYkQwGKlWAUXLSEGXzgdt3EcUjLlBCdMwdQY+K+EUF6oh8oB6rwHw==",
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.5.tgz",
|
||||
"integrity": "sha512-QaWHnsxieEDcqKe+vo/RFqpiIFRi/KBqlOSPcUlvinBaISCeiTRCbtrazHAjtHtsLC66oDsROAH8frWkQzfMMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-fetch": "4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-http-backend/node_modules/cross-fetch": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
|
||||
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.6.12"
|
||||
"cross-fetch": "4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
@@ -10691,15 +10460,6 @@
|
||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz",
|
||||
"integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q=="
|
||||
},
|
||||
"node_modules/inline-style-prefixer": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz",
|
||||
"integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-in-js-utils": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inquirer": {
|
||||
"version": "8.2.5",
|
||||
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.5.tgz",
|
||||
@@ -10767,13 +10527,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/intersection-observer": {
|
||||
"version": "0.12.2",
|
||||
"resolved": "https://registry.npmjs.org/intersection-observer/-/intersection-observer-0.12.2.tgz",
|
||||
"integrity": "sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==",
|
||||
"deprecated": "The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019.",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/intl-messageformat": {
|
||||
"version": "10.7.12",
|
||||
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.12.tgz",
|
||||
@@ -11387,12 +11140,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
|
||||
"integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-sdsl": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.2.tgz",
|
||||
@@ -11559,15 +11306,6 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/launder": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
|
||||
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.7"
|
||||
}
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
@@ -12384,21 +12122,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/matrix-widget-api": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.16.1.tgz",
|
||||
"integrity": "sha512-oCfTV4xNPo02qIgveqdkIyKQjOPpsjhF3bmJBotHrhr8TsrhVa7kx8PtuiUPnQTjz0tdBle7falR2Fw8VKsedw==",
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.0.tgz",
|
||||
"integrity": "sha512-+LrvwkR1izL4h2euX8PDrvG/3PZZDEd6As+lmnR3jAVwbFJtU5iTnwmZGnCca9ddngCvXvAHkcpJBEPyPTZneQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/events": "^3.0.0",
|
||||
"events": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.0.14",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
||||
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/media-query-parser": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/media-query-parser/-/media-query-parser-2.0.2.tgz",
|
||||
@@ -12649,26 +12381,6 @@
|
||||
"integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nano-css": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/nano-css/-/nano-css-5.6.2.tgz",
|
||||
"integrity": "sha512-+6bHaC8dSDGALM1HJjOHVXpuastdu2xFoZlC77Jh4cg+33Zcgm+Gxd+1xsnpZK14eyHObSp82+ll5y3SX75liw==",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15",
|
||||
"css-tree": "^1.1.2",
|
||||
"csstype": "^3.1.2",
|
||||
"fastest-stable-stringify": "^2.0.2",
|
||||
"inline-style-prefixer": "^7.0.1",
|
||||
"rtl-css-js": "^1.16.1",
|
||||
"stacktrace-js": "^2.0.2",
|
||||
"stylis": "^4.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-dom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.8",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
|
||||
@@ -15572,12 +15284,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-value-parser": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -15944,41 +15650,6 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-universal-interface": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz",
|
||||
"integrity": "sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==",
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"tslib": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-use": {
|
||||
"version": "17.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-use/-/react-use-17.6.0.tgz",
|
||||
"integrity": "sha512-OmedEScUMKFfzn1Ir8dBxiLLSOzhKe/dPZwVxcujweSj45aNM7BEGPb9BEVIgVEqEXx6f3/TsXzwIktNgUR02g==",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@types/js-cookie": "^2.2.6",
|
||||
"@xobotyi/scrollbar-width": "^1.9.5",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-shallow-equal": "^1.0.0",
|
||||
"js-cookie": "^2.2.1",
|
||||
"nano-css": "^5.6.2",
|
||||
"react-universal-interface": "^0.6.2",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"screenfull": "^5.1.0",
|
||||
"set-harmonic-interval": "^1.0.1",
|
||||
"throttle-debounce": "^3.0.1",
|
||||
"ts-easing": "^0.2.0",
|
||||
"tslib": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-dom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/read-package-up": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz",
|
||||
@@ -16271,12 +15942,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.10",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||
@@ -16413,15 +16078,6 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rtl-css-js": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz",
|
||||
"integrity": "sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/run-async": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz",
|
||||
@@ -16545,16 +16201,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.17.4",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.4.tgz",
|
||||
"integrity": "sha512-2HW7v2ol/uAM7sX4hbD8Z59OGWmAPrvjL8E71UWlBcj6m+kcF6ilQBLny+cIgY214QJeJT5tQuxKKqX0SQqjGQ==",
|
||||
"license": "MIT",
|
||||
"version": "2.12.1",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.12.1.tgz",
|
||||
"integrity": "sha512-Plh+JAn0UVDpBRP/xEjsk+xDCoOvMBwQUf/K+/cBAVuTbtX8bj2VB7S1sL1dssVpykqp0/KPSesHrqXtokVBpA==",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.2.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"htmlparser2": "^8.0.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"launder": "^1.7.1",
|
||||
"parse-srcset": "^1.0.2",
|
||||
"postcss": "^8.3.11"
|
||||
}
|
||||
@@ -16567,18 +16221,6 @@
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/screenfull": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/screenfull/-/screenfull-5.2.0.tgz",
|
||||
"integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/scroll-into-view-if-needed": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
|
||||
@@ -16935,15 +16577,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/set-harmonic-interval": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/set-harmonic-interval/-/set-harmonic-interval-1.0.1.tgz",
|
||||
"integrity": "sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==",
|
||||
"license": "Unlicense",
|
||||
"engines": {
|
||||
"node": ">=6.9"
|
||||
}
|
||||
},
|
||||
"node_modules/set-proto": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz",
|
||||
@@ -17449,51 +17082,6 @@
|
||||
"through2": "~2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stack-generator": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.10.tgz",
|
||||
"integrity": "sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"stackframe": "^1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/stackframe": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
|
||||
"integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stacktrace-gps": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.1.2.tgz",
|
||||
"integrity": "sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"source-map": "0.5.6",
|
||||
"stackframe": "^1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/stacktrace-gps/node_modules/source-map": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz",
|
||||
"integrity": "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stacktrace-js": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz",
|
||||
"integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"error-stack-parser": "^2.0.6",
|
||||
"stack-generator": "^2.0.5",
|
||||
"stacktrace-gps": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/stream-combiner2": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz",
|
||||
@@ -17749,54 +17337,6 @@
|
||||
"inline-style-parser": "0.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/styled-components": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.4.1.tgz",
|
||||
"integrity": "sha512-ADu2dF53esUzzM4I0ewxhxFtsDd6v4V6dNkg3vG0iFKhnt06sJneTZnRvujAosZwW0XD58IKgGMQoqri4wHRqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/is-prop-valid": "1.4.0",
|
||||
"css-to-react-native": "3.2.0",
|
||||
"csstype": "3.2.3",
|
||||
"stylis": "4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/styled-components"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"css-to-react-native": ">= 3.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"react-dom": ">= 16.8.0",
|
||||
"react-native": ">= 0.68.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-to-react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/styled-components/node_modules/stylis": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
|
||||
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stylis": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz",
|
||||
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/super-regex": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz",
|
||||
@@ -17970,15 +17510,6 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/throttle-debounce": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-3.0.1.tgz",
|
||||
"integrity": "sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/through": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
||||
@@ -18111,12 +17642,6 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/toggle-selection": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
|
||||
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
@@ -18135,12 +17660,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-easing": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz",
|
||||
"integrity": "sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/tsconfig-paths": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "lotus-chat",
|
||||
"version": "4.12.1-lotus",
|
||||
"description": "Lotus Chat — Matrix client for Lotus Guild",
|
||||
"name": "cinny",
|
||||
"version": "4.11.1",
|
||||
"description": "Yet another matrix client",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -66,9 +66,6 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||
"@fontsource/inter": "4.5.14",
|
||||
"@giphy/js-fetch-api": "5.8.0",
|
||||
"@giphy/js-types": "5.1.0",
|
||||
"@giphy/react-components": "10.1.2",
|
||||
"@tanstack/react-query": "5.24.1",
|
||||
"@tanstack/react-query-devtools": "5.24.1",
|
||||
"@tanstack/react-virtual": "3.2.0",
|
||||
@@ -93,14 +90,14 @@
|
||||
"html-react-parser": "4.2.0",
|
||||
"i18next": "23.12.2",
|
||||
"i18next-browser-languagedetector": "8.0.0",
|
||||
"i18next-http-backend": "2.5.2",
|
||||
"i18next-http-backend": "3.0.5",
|
||||
"immer": "9.0.16",
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.6.0",
|
||||
"linkify-react": "4.3.2",
|
||||
"linkifyjs": "4.3.2",
|
||||
"matrix-js-sdk": "38.2.0",
|
||||
"matrix-widget-api": "1.16.1",
|
||||
"matrix-widget-api": "1.13.0",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "4.2.67",
|
||||
"prismjs": "1.30.0",
|
||||
@@ -114,16 +111,15 @@
|
||||
"react-i18next": "15.0.0",
|
||||
"react-range": "1.8.14",
|
||||
"react-router-dom": "6.30.3",
|
||||
"sanitize-html": "2.17.4",
|
||||
"sanitize-html": "2.12.1",
|
||||
"slate": "0.123.0",
|
||||
"slate-dom": "0.123.0",
|
||||
"slate-history": "0.113.1",
|
||||
"slate-react": "0.123.0",
|
||||
"styled-components": "6.4.1",
|
||||
"ua-parser-js": "1.0.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-call-embedded": "0.19.3",
|
||||
"@element-hq/element-call-embedded": "0.16.3",
|
||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||
"@rollup/plugin-inject": "5.0.3",
|
||||
"@rollup/plugin-wasm": "6.1.1",
|
||||
@@ -137,7 +133,7 @@
|
||||
"@types/react": "18.2.39",
|
||||
"@types/react-dom": "18.2.17",
|
||||
"@types/react-google-recaptcha": "2.1.8",
|
||||
"@types/sanitize-html": "2.16.1",
|
||||
"@types/sanitize-html": "2.9.0",
|
||||
"@types/ua-parser-js": "0.7.36",
|
||||
"@typescript-eslint/eslint-plugin": "5.46.1",
|
||||
"@typescript-eslint/parser": "5.46.1",
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": [
|
||||
"matrix.lotusguild.org"
|
||||
],
|
||||
"allowCustomHomeservers": false,
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [],
|
||||
"rooms": [],
|
||||
"servers": []
|
||||
},
|
||||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
},
|
||||
"gifApiKey": "AqqDuQwZNjYttz7Mn6ME4JH1bJIuZ5CO"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 631 B After Width: | Height: | Size: 32 KiB |
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "Lotus Chat",
|
||||
"short_name": "Lotus Chat",
|
||||
"description": "Lotus Chat \u2014 the Lotus Guild Matrix client",
|
||||
"name": "Cinny",
|
||||
"short_name": "Cinny",
|
||||
"description": "Yet another matrix client",
|
||||
"dir": "auto",
|
||||
"lang": "en-US",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"start_url": "./",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#980000",
|
||||
"background_color": "#fff",
|
||||
"theme_color": "#fff",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./public/android/android-chrome-36x36.png",
|
||||
@@ -55,24 +55,5 @@
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
"social",
|
||||
"communication",
|
||||
"productivity"
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "New Message",
|
||||
"short_name": "DM",
|
||||
"description": "Open a new direct message",
|
||||
"url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "res/android/android-chrome-96x96.png",
|
||||
"sizes": "96x96"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,14 +1,13 @@
|
||||
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="#980000" fill-opacity="0.88">
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(0,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(45,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(90,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(135,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(180,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(225,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(270,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(315,9,9)"/>
|
||||
</g>
|
||||
<circle cx="9" cy="9" r="2.2" fill="#cc2000"/>
|
||||
<circle cx="14.5" cy="14.5" r="3" fill="#45B83B"/>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2707_1961)">
|
||||
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
|
||||
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
|
||||
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#45B83B"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2707_1961">
|
||||
<rect width="18" height="18" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 841 B After Width: | Height: | Size: 1.5 KiB |
@@ -1,14 +1,13 @@
|
||||
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="#980000" fill-opacity="0.88">
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(0,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(45,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(90,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(135,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(180,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(225,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(270,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(315,9,9)"/>
|
||||
</g>
|
||||
<circle cx="9" cy="9" r="2.2" fill="#cc2000"/>
|
||||
<circle cx="14.5" cy="14.5" r="3" fill="#989898"/>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2707_2015)">
|
||||
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
|
||||
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
|
||||
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#989898"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2707_2015">
|
||||
<rect width="18" height="18" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 841 B After Width: | Height: | Size: 1.5 KiB |
@@ -1,13 +1,19 @@
|
||||
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="#980000" fill-opacity="0.88">
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(0,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(45,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(90,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(135,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(180,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(225,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(270,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(315,9,9)"/>
|
||||
<!-- 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>
|
||||
<circle cx="9" cy="9" r="2.2" fill="#cc2000"/>
|
||||
<g>
|
||||
<path d="M9,0C4,0,0,4,0,9c0,5,4,9,9,9c5,0,9-4,9-9C18,4,14,0,9,0z M1.2,10.8l3.5-2.3c0-0.1,0-0.2,0-0.3c0-1.8,1.3-3.2,3.1-3.4
|
||||
c0.1,0,0.2,0,0.4,0c1.2,0,2.3,0.6,2.9,1.6c0.3-0.1,0.6-0.1,0.9-0.1c0.4,0,0.8,0,1.2,0.1c0.7,0.2,1.4,0.5,2,0.9
|
||||
C14.6,7.1,14,7,13.3,7c-1.2,0-2.2,0.4-2.9,1.4c-0.7,0.9-1.1,2-1.1,3.2c0,1.5-0.4,2.9-1.3,4.2c-0.3,0.4-0.5,0.7-0.8,1
|
||||
C4.2,16.1,1.9,13.8,1.2,10.8z"/>
|
||||
<circle cx="9.5" cy="6.4" r="0.5"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 788 B After Width: | Height: | Size: 871 B |
@@ -49,7 +49,7 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load().catch(() => {});
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
|
||||
|
||||
@@ -1,32 +1,5 @@
|
||||
/* eslint-disable jsx-a11y/media-has-caption */
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { ReactNode, useCallback, useRef } from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { MatrixRTCSession } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
color,
|
||||
config,
|
||||
Dialog,
|
||||
Icon,
|
||||
Icons,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import {
|
||||
EventTimelineSetHandlerMap,
|
||||
EventType,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
import { IRTCNotificationContent, RTCNotificationType } from 'matrix-js-sdk/lib/matrixrtc/types';
|
||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||
import {
|
||||
CallEmbedContextProvider,
|
||||
CallEmbedRefContextProvider,
|
||||
@@ -34,346 +7,11 @@ import {
|
||||
useCallJoined,
|
||||
useCallThemeSync,
|
||||
useCallMemberSoundSync,
|
||||
useCallStart,
|
||||
} from '../hooks/useCallEmbed';
|
||||
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
||||
import { CallEmbed } from '../plugins/call';
|
||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import CallSound from '../../../public/sound/call.ogg';
|
||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
||||
import { mDirectAtom } from '../state/mDirectList';
|
||||
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
|
||||
import { mxcUrlToHttp } from '../utils/matrix';
|
||||
import { RoomAvatar, RoomIcon } from './room-avatar';
|
||||
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
||||
import { getStateEvent, getMemberDisplayName } from '../utils/room';
|
||||
import { getMxIdLocalPart } from '../utils/matrix';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
|
||||
import { useLivekitSupport } from '../hooks/useLivekitSupport';
|
||||
import { CallAvatarAnimation } from '../styles/Animations.css';
|
||||
import { webRTCSupported } from '../utils/rtc';
|
||||
|
||||
const PIP_MIN_W = 200;
|
||||
const PIP_MIN_H = 112;
|
||||
|
||||
type Corner = 'se' | 'sw' | 'ne' | 'nw';
|
||||
|
||||
/** Normalise the element to top/left positioning so resize math is uniform. */
|
||||
function normaliseToTopLeft(el: HTMLElement) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
el.style.left = `${rect.left}px`;
|
||||
el.style.top = `${rect.top}px`;
|
||||
el.style.width = `${rect.width}px`;
|
||||
el.style.height = `${rect.height}px`;
|
||||
el.style.right = 'auto';
|
||||
el.style.bottom = 'auto';
|
||||
}
|
||||
|
||||
type IncomingCallInfo = {
|
||||
room: Room;
|
||||
sender: string;
|
||||
senderTs: number;
|
||||
lifetime: number;
|
||||
intent?: string;
|
||||
notificationType: RTCNotificationType;
|
||||
refEventId: string;
|
||||
};
|
||||
type IncomingCallProps = {
|
||||
dm: boolean;
|
||||
info: IncomingCallInfo;
|
||||
onIgnore: () => void;
|
||||
onAnswer: (room: Room, video: boolean) => void;
|
||||
onReject: (room: Room, eventId: string) => void;
|
||||
};
|
||||
function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const livekitSupported = useLivekitSupport();
|
||||
const rtcSupported = webRTCSupported();
|
||||
const canAnswer = livekitSupported && rtcSupported;
|
||||
const { room } = info;
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
const roomName = useRoomName(room);
|
||||
const roomAvatar = useRoomAvatar(room, dm);
|
||||
const avatarUrl = roomAvatar
|
||||
? mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||
: undefined;
|
||||
|
||||
const session = useCallSession(room);
|
||||
useCallMembersChange(
|
||||
session,
|
||||
useCallback(() => {
|
||||
const members = MatrixRTCSession.sessionMembershipsForRoom(room, session.sessionDescription);
|
||||
if (members.length === 0) {
|
||||
onIgnore();
|
||||
}
|
||||
}, [room, session, onIgnore])
|
||||
);
|
||||
|
||||
const playSound = useCallback(() => {
|
||||
const audioElement = audioRef.current;
|
||||
audioElement?.play().catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (info.notificationType === 'ring') {
|
||||
playSound();
|
||||
}
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
}
|
||||
};
|
||||
}, [playSound, info.notificationType]);
|
||||
|
||||
useEffect(() => {
|
||||
const remaining = info.senderTs + info.lifetime - Date.now();
|
||||
if (remaining <= 0) { onIgnore(); return; }
|
||||
const id = setTimeout(onIgnore, remaining);
|
||||
return () => clearTimeout(id);
|
||||
}, [info.senderTs, info.lifetime, onIgnore]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => onIgnore(),
|
||||
clickOutsideDeactivates: false,
|
||||
escapeDeactivates: false,
|
||||
}}
|
||||
>
|
||||
<Dialog style={{ maxWidth: toRem(324) }}>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
|
||||
<Text size="T200" align="Center">
|
||||
{getMemberDisplayName(info.room, info.sender) ?? getMxIdLocalPart(info.sender) ?? info.sender}
|
||||
</Text>
|
||||
<Box direction="Column" gap="500" alignItems="Center">
|
||||
<Box shrink="No">
|
||||
<Avatar size="500" className={CallAvatarAnimation}>
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={avatarUrl}
|
||||
alt={roomName}
|
||||
renderFallback={() => (
|
||||
<RoomIcon
|
||||
roomType={room.getType()}
|
||||
size="400"
|
||||
joinRule={room.getJoinRule()}
|
||||
filled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="H3" align="Center" truncate>
|
||||
{roomName}
|
||||
</Text>
|
||||
<Text size="T300">Incoming Call</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{!livekitSupported && (
|
||||
<Text
|
||||
style={{ margin: 'auto', color: color.Critical.Main }}
|
||||
size="L400"
|
||||
align="Center"
|
||||
>
|
||||
Your homeserver does not support calling.
|
||||
</Text>
|
||||
)}
|
||||
{!webRTCSupported() && (
|
||||
<Text
|
||||
style={{ margin: 'auto', color: color.Critical.Main }}
|
||||
size="L400"
|
||||
align="Center"
|
||||
>
|
||||
Your browser does not support WebRTC, which is required for calling.
|
||||
</Text>
|
||||
)}
|
||||
<Box direction="Column" gap="300">
|
||||
<Button
|
||||
style={{ flexGrow: 1 }}
|
||||
variant="Success"
|
||||
size="400"
|
||||
radii="400"
|
||||
onClick={() => onAnswer(room, info.intent === 'video')}
|
||||
before={
|
||||
<Icon
|
||||
size="200"
|
||||
src={info.intent === 'video' ? Icons.VideoCamera : Icons.Phone}
|
||||
filled
|
||||
/>
|
||||
}
|
||||
disabled={!canAnswer}
|
||||
>
|
||||
<Text as="span" size="B400">
|
||||
Answer
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
style={{ flexGrow: 1 }}
|
||||
variant="Success"
|
||||
fill="Soft"
|
||||
size="400"
|
||||
radii="400"
|
||||
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
||||
before={<Icon size="200" src={Icons.Cross} filled />}
|
||||
>
|
||||
<Text as="span" size="B400">
|
||||
{dm ? 'Reject' : 'Ignore'}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
<audio ref={audioRef} loop style={{ display: 'none' }}>
|
||||
<source src={CallSound} type="audio/ogg" />
|
||||
</audio>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type IncomingCallListenerProps = {
|
||||
callEmbed?: CallEmbed;
|
||||
joined?: boolean;
|
||||
};
|
||||
function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) {
|
||||
const mx = useMatrixClient();
|
||||
const directs = useAtomValue(mDirectAtom);
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
|
||||
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
|
||||
const startCall = useCallStart(dm);
|
||||
|
||||
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
||||
async (event, room, toStartOfTimeline, removed, data) => {
|
||||
// only process rtc notification reference events.
|
||||
// we do not want to wait to decrypt all events.
|
||||
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
|
||||
|
||||
if (event.isEncrypted()) {
|
||||
if (!event.isBeingDecrypted()) {
|
||||
await event.attemptDecryption(mx.getCrypto() as CryptoBackend);
|
||||
}
|
||||
await event.getDecryptionPromise();
|
||||
}
|
||||
|
||||
if (
|
||||
!room ||
|
||||
event.getType() !== EventType.RTCNotification ||
|
||||
event.getSender() === mx.getSafeUserId() ||
|
||||
!data.liveEvent
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sender = event.getSender();
|
||||
const content = event.getContent<IRTCNotificationContent>();
|
||||
const senderTs =
|
||||
content.sender_ts - event.getTs() > 20000 ? event.getTs() : content.sender_ts;
|
||||
const lifetime = Math.min(content.lifetime, 120000);
|
||||
const notificationType = content.notification_type;
|
||||
const relation =
|
||||
event.getRelation()?.rel_type === RelationType.Reference ? event.getRelation() : undefined;
|
||||
const refEventId = relation?.event_id;
|
||||
|
||||
const mention =
|
||||
content['m.mentions']?.room || content['m.mentions']?.user_ids?.includes(mx.getSafeUserId());
|
||||
if (!sender || !refEventId || !mention || Date.now() >= senderTs + lifetime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const powerLevelsEvent = getStateEvent(room, StateEvent.RoomPowerLevels);
|
||||
const powerLevels = getPowersLevelFromMatrixEvent(powerLevelsEvent);
|
||||
const creators = getRoomCreatorsForRoomId(mx, room.roomId);
|
||||
const permissions = getRoomPermissionsAPI(creators, powerLevels);
|
||||
|
||||
const hasCallPermission = permissions.stateEvent(
|
||||
StateEvent.GroupCallMemberPrefix,
|
||||
mx.getSafeUserId()
|
||||
);
|
||||
if (!hasCallPermission) return;
|
||||
|
||||
const info: IncomingCallInfo = {
|
||||
room,
|
||||
sender,
|
||||
senderTs,
|
||||
lifetime,
|
||||
intent:
|
||||
'm.call.intent' in content && typeof content['m.call.intent'] === 'string'
|
||||
? content['m.call.intent']
|
||||
: undefined,
|
||||
notificationType,
|
||||
refEventId,
|
||||
};
|
||||
|
||||
setCallInfo(info);
|
||||
},
|
||||
[mx]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||
return () => {
|
||||
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
||||
};
|
||||
}, [mx, handleTimelineEvent]);
|
||||
|
||||
const handleIgnore = useCallback(() => {
|
||||
setCallInfo(undefined);
|
||||
}, []);
|
||||
|
||||
const handleReject = useCallback(
|
||||
(room: Room, eventId: string) => {
|
||||
mx.sendEvent(room.roomId, EventType.RTCDecline, {
|
||||
'm.relates_to': {
|
||||
rel_type: RelationType.Reference,
|
||||
event_id: eventId,
|
||||
},
|
||||
});
|
||||
setCallInfo(undefined);
|
||||
},
|
||||
[mx]
|
||||
);
|
||||
|
||||
const handleAnswer = useCallback(
|
||||
(room: Room, video: boolean) => {
|
||||
startCall(room, { microphone: true, video, sound: true });
|
||||
setCallInfo(undefined);
|
||||
navigateRoom(room.roomId);
|
||||
},
|
||||
[startCall, navigateRoom]
|
||||
);
|
||||
|
||||
if (callInfo && callEmbed?.roomId === callInfo.room.roomId) {
|
||||
return null;
|
||||
}
|
||||
return !joined && callInfo ? (
|
||||
<IncomingCall
|
||||
dm={dm}
|
||||
info={callInfo}
|
||||
onIgnore={handleIgnore}
|
||||
onAnswer={handleAnswer}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
@@ -403,131 +41,13 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
const screenSize = useScreenSizeContext();
|
||||
|
||||
const chatOnlyView = chat && screenSize !== ScreenSize.Desktop;
|
||||
const inCallRoom = callEmbed && selectedRoom === callEmbed.roomId;
|
||||
const callActive = callEmbed && joined;
|
||||
const callVisible = inCallRoom && callActive && !chatOnlyView;
|
||||
const pipMode = callActive && !inCallRoom;
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
const pipDragRef = React.useRef<{
|
||||
startX: number; startY: number; origLeft: number; origTop: number; dragged: boolean;
|
||||
} | null>(null);
|
||||
const activeDragCleanupRef = React.useRef<(() => void) | null>(null);
|
||||
React.useEffect(() => () => { activeDragCleanupRef.current?.(); }, []);
|
||||
|
||||
// Track previous pipMode to only reset position when first entering pip (not on callVisible changes)
|
||||
const prevPipModeRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
const el = callEmbedRef.current;
|
||||
if (!el) return;
|
||||
if (pipMode) {
|
||||
if (!prevPipModeRef.current) {
|
||||
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';
|
||||
}
|
||||
prevPipModeRef.current = pipMode;
|
||||
}, [pipMode, callVisible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pipMode) return;
|
||||
const onPipWindowResize = (): void => {
|
||||
const el = callEmbedRef.current;
|
||||
if (!el) return;
|
||||
const l = parseFloat(el.style.left);
|
||||
const t = parseFloat(el.style.top);
|
||||
if (!isNaN(l)) el.style.left = `${Math.max(0, Math.min(l, window.innerWidth - el.offsetWidth))}px`;
|
||||
if (!isNaN(t)) el.style.top = `${Math.max(0, Math.min(t, window.innerHeight - el.offsetHeight))}px`;
|
||||
};
|
||||
window.addEventListener('resize', onPipWindowResize);
|
||||
return () => window.removeEventListener('resize', onPipWindowResize);
|
||||
}, [pipMode, callEmbedRef]);
|
||||
|
||||
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 = ''; activeDragCleanupRef.current = null; setTimeout(() => { if (pipDragRef.current) pipDragRef.current.dragged = false; }, 0); };
|
||||
activeDragCleanupRef.current = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; };
|
||||
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
const handlePipTouchStart = (e: React.TouchEvent) => {
|
||||
const el = callEmbedRef.current;
|
||||
if (!el || e.touches.length !== 1) return;
|
||||
const touch = e.touches[0];
|
||||
const rect = el.getBoundingClientRect();
|
||||
pipDragRef.current = { startX: touch.clientX, startY: touch.clientY, origLeft: rect.left, origTop: rect.top, dragged: false };
|
||||
const onTouchMove = (ev: TouchEvent) => {
|
||||
if (!pipDragRef.current || !el || ev.touches.length !== 1) return;
|
||||
ev.preventDefault();
|
||||
const t = ev.touches[0];
|
||||
const dx = t.clientX - pipDragRef.current.startX;
|
||||
const dy = t.clientY - pipDragRef.current.startY;
|
||||
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) pipDragRef.current.dragged = true;
|
||||
if (pipDragRef.current.dragged) {
|
||||
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 onTouchEnd = () => {
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
document.removeEventListener('touchend', onTouchEnd);
|
||||
activeDragCleanupRef.current = null;
|
||||
setTimeout(() => { if (pipDragRef.current) pipDragRef.current.dragged = false; }, 0);
|
||||
};
|
||||
activeDragCleanupRef.current = () => { document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); };
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
|
||||
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=''; activeDragCleanupRef.current = null; };
|
||||
activeDragCleanupRef.current = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor=''; document.body.style.userSelect=''; };
|
||||
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
|
||||
};
|
||||
const callVisible = callEmbed && selectedRoom === callEmbed.roomId && joined && !chatOnlyView;
|
||||
|
||||
return (
|
||||
<CallEmbedContextProvider value={callEmbed}>
|
||||
{callEmbed && <CallUtils embed={callEmbed} />}
|
||||
<CallEmbedRefContextProvider value={callEmbedRef}>
|
||||
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
|
||||
{children}
|
||||
</CallEmbedRefContextProvider>
|
||||
<CallEmbedRefContextProvider value={callEmbedRef}>{children}</CallEmbedRefContextProvider>
|
||||
<div
|
||||
data-call-embed-container
|
||||
style={{
|
||||
@@ -539,40 +59,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
height: '50%',
|
||||
}}
|
||||
ref={callEmbedRef}
|
||||
>
|
||||
{pipMode && callEmbed && (
|
||||
<>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Return to call"
|
||||
onMouseDown={handlePipMouseDown}
|
||||
onTouchStart={handlePipTouchStart}
|
||||
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) => {
|
||||
const s = corner.includes('s'); const e2 = corner.includes('e');
|
||||
const dots = [[2,2],[2,7],[7,2]].map(([a,b]) => ({
|
||||
position:'absolute' as const, width:4, height:4, borderRadius:'50%',
|
||||
background:'rgba(255,255,255,0.45)',
|
||||
[s?'bottom':'top']:a, [e2?'right':'left']:b,
|
||||
}));
|
||||
return (
|
||||
<div key={corner} onMouseDown={(ev) => handleResizeMouseDown(ev, corner)} onClick={(ev) => ev.stopPropagation()}
|
||||
style={{ position:'absolute', width:'18px', height:'18px', [s?'bottom':'top']:0, [e2?'right':'left']:0, cursor:`${corner}-resize`, zIndex:2 }}>
|
||||
{dots.map((style, i) => <div key={i} style={style} />)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
</CallEmbedContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) {
|
||||
const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(), [mx]));
|
||||
|
||||
useEffect(() => {
|
||||
load().catch(() => {});
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return children(state.status === AsyncStatus.Success ? state.data : undefined);
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
|
||||
import { IGif } from '@giphy/js-types';
|
||||
import { Box } from 'folds';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
|
||||
const PICKER_WIDTH = 312;
|
||||
|
||||
|
||||
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}
|
||||
>
|
||||
<SearchContextManager apiKey={apiKey} initialTerm="">
|
||||
<GifPickerInner onSelect={onSelect} requestClose={requestClose} lotusTerminal={!!lotusTerminal} />
|
||||
</SearchContextManager>
|
||||
</Box>
|
||||
</FocusTrap>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export function MediaConfigLoader({ children }: MediaConfigLoaderProps) {
|
||||
const [state, load] = useAsyncCallback(useCallback(() => mx.getMediaConfig(), [mx]));
|
||||
|
||||
useEffect(() => {
|
||||
load().catch(() => {});
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return children(state.status === AsyncStatus.Success ? state.data : undefined);
|
||||
|
||||
@@ -22,7 +22,7 @@ export function SpecVersionsLoader({
|
||||
const ignoreCallback = useCallback(() => setIgnoreError(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
load().catch(() => {});
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
|
||||
|
||||
@@ -140,8 +140,6 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
data-editable-name={editableName}
|
||||
className={css.EditorTextarea}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder ?? 'Message input'}
|
||||
aria-multiline="true"
|
||||
renderPlaceholder={renderPlaceholder}
|
||||
renderElement={renderElement}
|
||||
renderLeaf={renderLeaf}
|
||||
|
||||
@@ -255,67 +255,10 @@ const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElemen
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const parseListMarkdown = (
|
||||
const parseListNode = (
|
||||
node: Element,
|
||||
processText: ProcessTextCallback,
|
||||
depth = 0
|
||||
): ParagraphElement[] => {
|
||||
const md = isTag(node) && node.name === 'ul' ? '*' : '-';
|
||||
const prefix = node.attribs['data-md'] ?? md;
|
||||
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
|
||||
const [digitOrChar] = prefix.match(/^[\da-zA-Z]/) ?? [];
|
||||
|
||||
const digit = digitOrChar ? parseInt(digitOrChar, 10) : undefined;
|
||||
|
||||
const lines: ParagraphElement[] = [];
|
||||
let lineNo = digit === undefined || Number.isNaN(digit) ? digitOrChar ?? 1 : digit;
|
||||
const pushLine = (line: InlineElement[]) => {
|
||||
lines.push({
|
||||
type: BlockType.Paragraph,
|
||||
children: [
|
||||
{
|
||||
text: `${Array(depth + 1).join(' ')}${starOrHyphen ? `${starOrHyphen} ` : `${lineNo}. `}`,
|
||||
},
|
||||
...line,
|
||||
],
|
||||
});
|
||||
if (typeof lineNo === 'string') {
|
||||
lineNo = String.fromCharCode(lineNo.charCodeAt(0) + 1);
|
||||
} else {
|
||||
lineNo += 1;
|
||||
}
|
||||
};
|
||||
|
||||
node.children.forEach((child) => {
|
||||
if (isText(child)) {
|
||||
pushLine([{ text: processText(child.data) }]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTag(child)) {
|
||||
if (child.name === 'ul' || child.name === 'ol') {
|
||||
lines.push(...parseListMarkdown(child, processText, depth + 1));
|
||||
return;
|
||||
}
|
||||
if (child.name === 'li') {
|
||||
child.children.forEach((c) => {
|
||||
if (isTag(c) && (c.name === 'ul' || c.name === 'ol')) {
|
||||
lines.push(...parseListMarkdown(c, processText, depth + 1));
|
||||
return;
|
||||
}
|
||||
pushLine(getInlineElement(c, processText));
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
pushLine(getInlineElement(child, processText));
|
||||
});
|
||||
|
||||
return lines;
|
||||
};
|
||||
const parseListLines = (children: ChildNode[], processText: ProcessTextCallback) => {
|
||||
processText: ProcessTextCallback
|
||||
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
|
||||
const listLines: Array<InlineElement[]> = [];
|
||||
let lineHolder: InlineElement[] = [];
|
||||
|
||||
@@ -326,7 +269,7 @@ const parseListLines = (children: ChildNode[], processText: ProcessTextCallback)
|
||||
lineHolder = [];
|
||||
};
|
||||
|
||||
children.forEach((child) => {
|
||||
node.children.forEach((child) => {
|
||||
if (isText(child)) {
|
||||
lineHolder.push({ text: processText(child.data) });
|
||||
return;
|
||||
@@ -349,23 +292,24 @@ const parseListLines = (children: ChildNode[], processText: ProcessTextCallback)
|
||||
});
|
||||
appendLine();
|
||||
|
||||
return listLines;
|
||||
};
|
||||
const parseListNode = (
|
||||
node: Element,
|
||||
processText: ProcessTextCallback
|
||||
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
|
||||
if (node.attribs['data-md'] !== undefined) {
|
||||
return parseListMarkdown(node, processText);
|
||||
const mdSequence = node.attribs['data-md'];
|
||||
if (mdSequence !== undefined) {
|
||||
const prefix = mdSequence || '-';
|
||||
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
|
||||
return listLines.map((lineChildren) => ({
|
||||
type: BlockType.Paragraph,
|
||||
children: [
|
||||
{ text: `${starOrHyphen ? `${starOrHyphen} ` : `${prefix}. `} ` },
|
||||
...lineChildren,
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
const lines = parseListLines(node.childNodes, processText);
|
||||
|
||||
if (node.name === 'ol') {
|
||||
return [
|
||||
{
|
||||
type: BlockType.OrderedList,
|
||||
children: lines.map((lineChildren) => ({
|
||||
children: listLines.map((lineChildren) => ({
|
||||
type: BlockType.ListItem,
|
||||
children: lineChildren,
|
||||
})),
|
||||
@@ -376,7 +320,7 @@ const parseListNode = (
|
||||
return [
|
||||
{
|
||||
type: BlockType.UnorderedList,
|
||||
children: lines.map((lineChildren) => ({
|
||||
children: listLines.map((lineChildren) => ({
|
||||
type: BlockType.ListItem,
|
||||
children: lineChildren,
|
||||
})),
|
||||
|
||||
@@ -52,8 +52,6 @@ import {
|
||||
} from './components';
|
||||
import { EmojiBoardTab, EmojiType } from './types';
|
||||
import { VirtualTile } from '../virtualizer';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
|
||||
const RECENT_GROUP_ID = 'recent_group';
|
||||
const SEARCH_GROUP_ID = 'search_group';
|
||||
@@ -505,7 +503,6 @@ export function EmojiBoard({
|
||||
}}
|
||||
>
|
||||
<EmojiBoardLayout
|
||||
data-emoji-board=""
|
||||
header={
|
||||
<Box direction="Column" gap="200">
|
||||
{onTabChange && <EmojiBoardTabs tab={tab} onTabChange={onTabChange} />}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import * as css from './EventReaders.css';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { UserAvatar } from '../user-avatar';
|
||||
@@ -24,19 +24,6 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { getMouseEventCords } from '../../utils/dom';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { today, yesterday, timeHourMinute, timeMon, timeDay, timeYear } from '../../utils/time';
|
||||
|
||||
function formatReadTs(ts: number, hour24Clock: boolean): string {
|
||||
const timeStr = timeHourMinute(ts, hour24Clock);
|
||||
if (today(ts)) return `Today at ${timeStr}`;
|
||||
if (yesterday(ts)) return `Yesterday at ${timeStr}`;
|
||||
const sameYear = timeYear(ts) === timeYear(Date.now());
|
||||
return sameYear
|
||||
? `${timeMon(ts)} ${timeDay(ts)} at ${timeStr}`
|
||||
: `${timeMon(ts)} ${timeDay(ts)} ${timeYear(ts)} at ${timeStr}`;
|
||||
}
|
||||
|
||||
export type EventReadersProps = {
|
||||
room: Room;
|
||||
@@ -47,12 +34,9 @@ export const EventReaders = as<'div', EventReadersProps>(
|
||||
({ className, room, eventId, requestClose, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const myUserId = mx.getUserId();
|
||||
const latestEventReaders = useRoomEventReaders(room, eventId).filter((id) => id !== myUserId);
|
||||
const latestEventReaders = useRoomEventReaders(room, eventId);
|
||||
const openProfile = useOpenUserRoomProfile();
|
||||
const space = useSpaceOptionally();
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
|
||||
const getName = (userId: string) =>
|
||||
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||
@@ -64,14 +48,9 @@ export const EventReaders = as<'div', EventReadersProps>(
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Header
|
||||
className={css.Header}
|
||||
variant="Surface"
|
||||
size="600"
|
||||
style={lotusTerminal ? { borderBottom: '1px solid rgba(0,212,255,0.30)', boxShadow: '0 2px 12px rgba(0,212,255,0.08)' } : undefined}
|
||||
>
|
||||
<Header className={css.Header} variant="Surface" size="600">
|
||||
<Box grow="Yes">
|
||||
<Text size="H3" style={lotusTerminal ? { color: '#00D4FF', textShadow: '0 0 6px rgba(0,212,255,0.45)', letterSpacing: '0.05em' } : undefined}>Seen by</Text>
|
||||
<Text size="H3">Seen by</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={requestClose}>
|
||||
<Icon src={Icons.Cross} />
|
||||
@@ -84,9 +63,16 @@ export const EventReaders = as<'div', EventReadersProps>(
|
||||
const name = getName(readerId);
|
||||
const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl();
|
||||
const avatarUrl = avatarMxcUrl
|
||||
? mxcUrlToHttp(mx, avatarMxcUrl, useAuthentication, 100, 100, 'crop') ?? undefined
|
||||
? mx.mxcUrlToHttp(
|
||||
avatarMxcUrl,
|
||||
100,
|
||||
100,
|
||||
'crop',
|
||||
undefined,
|
||||
false,
|
||||
useAuthentication
|
||||
)
|
||||
: undefined;
|
||||
const receiptTs = room.getReadReceiptForUserId(readerId)?.data.ts;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
@@ -106,28 +92,16 @@ export const EventReaders = as<'div', EventReadersProps>(
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={readerId}
|
||||
src={avatarUrl}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={name}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Box direction="Column" grow="Yes">
|
||||
<Text size="T400" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
{receiptTs !== undefined && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={lotusTerminal
|
||||
? { color: '#FFB300', textShadow: '0 0 6px #FFB300, 0 0 14px rgba(255,179,0,0.40)', fontSize: '0.72rem' }
|
||||
: { opacity: 0.6 }}
|
||||
>
|
||||
{formatReadTs(receiptTs, hour24Clock)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -391,30 +391,9 @@ export function MLocation({ content }: MLocationProps) {
|
||||
const location = parseGeoUri(geoUri);
|
||||
if (!location) return <BrokenContent />;
|
||||
|
||||
const lat = parseFloat(location.latitude);
|
||||
const lon = parseFloat(location.longitude);
|
||||
if (!isFinite(lat) || !isFinite(lon)) return <BrokenContent />;
|
||||
const mapSrc = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.007},${lat - 0.004},${lon + 0.007},${lat + 0.004}&layer=mapnik&marker=${lat},${lon}`;
|
||||
|
||||
return (
|
||||
<Box direction="Column" alignItems="Start" gap="200">
|
||||
<iframe
|
||||
title="Location"
|
||||
src={mapSrc}
|
||||
style={{
|
||||
width: '280px',
|
||||
height: '160px',
|
||||
border: '1px solid var(--bg-surface-border)',
|
||||
borderRadius: '8px',
|
||||
display: 'block',
|
||||
}}
|
||||
scrolling="no"
|
||||
loading="lazy"
|
||||
sandbox="allow-scripts"
|
||||
/>
|
||||
<Text size="T300" style={{ opacity: 0.65 }}>
|
||||
{`${lat.toFixed(5)}, ${lon.toFixed(5)}`}
|
||||
</Text>
|
||||
<Box direction="Column" alignItems="Start" gap="100">
|
||||
<Text size="T400">{geoUri}</Text>
|
||||
<Chip
|
||||
as="a"
|
||||
size="400"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable jsx-a11y/media-has-caption */
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import { Range } from 'react-range';
|
||||
@@ -65,19 +65,6 @@ export function AudioContent({
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (
|
||||
srcState.status === AsyncStatus.Success &&
|
||||
typeof srcState.data === 'string' &&
|
||||
srcState.data.startsWith('blob:')
|
||||
) {
|
||||
URL.revokeObjectURL(srcState.data);
|
||||
}
|
||||
},
|
||||
[srcState]
|
||||
);
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
// duration in seconds. (NOTE: info.duration is in milliseconds)
|
||||
const infoDuration = info.duration ?? 0;
|
||||
|
||||
@@ -8,7 +8,11 @@ export const MessageDeletedContent = as<'div', { children?: never; reason?: stri
|
||||
({ reason, ...props }, ref) => (
|
||||
<Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
|
||||
<Icon size="50" src={Icons.Delete} />
|
||||
<i>{reason ? `This message has been deleted — ${reason}` : 'This message has been deleted'}</i>
|
||||
{reason ? (
|
||||
<i>This message has been deleted. {reason}</i>
|
||||
) : (
|
||||
<i>This message has been deleted</i>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Text } from 'folds';
|
||||
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
||||
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[];
|
||||
};
|
||||
|
||||
type VoteState = {
|
||||
counts: Map<string, number>;
|
||||
myVote: string | null;
|
||||
total: number;
|
||||
};
|
||||
|
||||
function computeVotes(
|
||||
mx: ReturnType<typeof useMatrixClient>,
|
||||
roomId: string,
|
||||
eventId: string,
|
||||
isStable: boolean
|
||||
): VoteState {
|
||||
const empty: VoteState = { counts: new Map(), myVote: null, total: 0 };
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return empty;
|
||||
|
||||
const timelineSet = room.getUnfilteredTimelineSet();
|
||||
const stableRels = timelineSet.relations.getChildEventsForEvent(
|
||||
eventId,
|
||||
'm.reference',
|
||||
'm.poll.response'
|
||||
);
|
||||
const unstableRels = timelineSet.relations.getChildEventsForEvent(
|
||||
eventId,
|
||||
'org.matrix.msc3381.poll.response' as any,
|
||||
'org.matrix.msc3381.poll.response'
|
||||
);
|
||||
|
||||
// Collect all response events; per-sender keep only latest
|
||||
const latestBySender = new Map<string, { ts: number; answerId: string }>();
|
||||
const myUserId = mx.getSafeUserId();
|
||||
|
||||
const processRelations = (rels: typeof stableRels, stable: boolean) => {
|
||||
const events = rels?.getRelations() ?? [];
|
||||
for (const ev of events) {
|
||||
if (ev.isRedacted()) continue;
|
||||
const sender = ev.getSender();
|
||||
if (!sender) continue;
|
||||
const content = ev.getContent();
|
||||
let answerId: string | undefined;
|
||||
if (stable) {
|
||||
answerId = (content['m.selections'] as string[] | undefined)?.[0];
|
||||
} else {
|
||||
answerId = (
|
||||
(content['org.matrix.msc3381.poll.response'] as any)?.answers as string[] | undefined
|
||||
)?.[0];
|
||||
}
|
||||
if (!answerId) continue;
|
||||
const ts = ev.getTs();
|
||||
const existing = latestBySender.get(sender);
|
||||
if (!existing || ts > existing.ts) {
|
||||
latestBySender.set(sender, { ts, answerId });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
processRelations(stableRels, true);
|
||||
processRelations(unstableRels, false);
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
let myVote: string | null = null;
|
||||
for (const [sender, { answerId }] of latestBySender) {
|
||||
counts.set(answerId, (counts.get(answerId) ?? 0) + 1);
|
||||
if (sender === myUserId) myVote = answerId;
|
||||
}
|
||||
|
||||
return { counts, myVote, total: latestBySender.size };
|
||||
}
|
||||
|
||||
export function PollContent({
|
||||
content,
|
||||
roomId,
|
||||
eventId,
|
||||
}: {
|
||||
content: Record<string, unknown>;
|
||||
roomId?: string;
|
||||
eventId?: string;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const isStable = !!content['m.poll'];
|
||||
|
||||
const poll = (
|
||||
content['m.poll'] ?? content['org.matrix.msc3381.poll.start']
|
||||
) as PollData | undefined;
|
||||
|
||||
const [votes, setVotes] = useState<VoteState>(() => {
|
||||
if (!roomId || !eventId) return { counts: new Map(), myVote: null, total: 0 };
|
||||
return computeVotes(mx, roomId, eventId, isStable);
|
||||
});
|
||||
|
||||
// Refresh votes whenever Relations events fire
|
||||
const refresh = useCallback(() => {
|
||||
if (!roomId || !eventId) return;
|
||||
setVotes(computeVotes(mx, roomId, eventId, isStable));
|
||||
}, [mx, roomId, eventId, isStable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomId || !eventId) return;
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return;
|
||||
const timelineSet = room.getUnfilteredTimelineSet();
|
||||
|
||||
const stableRels = timelineSet.relations.getChildEventsForEvent(
|
||||
eventId,
|
||||
'm.reference',
|
||||
'm.poll.response'
|
||||
);
|
||||
const unstableRels = timelineSet.relations.getChildEventsForEvent(
|
||||
eventId,
|
||||
'org.matrix.msc3381.poll.response' as any,
|
||||
'org.matrix.msc3381.poll.response'
|
||||
);
|
||||
|
||||
stableRels?.on(RelationsEvent.Add, refresh);
|
||||
stableRels?.on(RelationsEvent.Remove, refresh);
|
||||
stableRels?.on(RelationsEvent.Redaction, refresh);
|
||||
unstableRels?.on(RelationsEvent.Add, refresh);
|
||||
unstableRels?.on(RelationsEvent.Remove, refresh);
|
||||
unstableRels?.on(RelationsEvent.Redaction, refresh);
|
||||
return () => {
|
||||
stableRels?.off(RelationsEvent.Add, refresh);
|
||||
stableRels?.off(RelationsEvent.Remove, refresh);
|
||||
stableRels?.off(RelationsEvent.Redaction, refresh);
|
||||
unstableRels?.off(RelationsEvent.Add, refresh);
|
||||
unstableRels?.off(RelationsEvent.Remove, refresh);
|
||||
unstableRels?.off(RelationsEvent.Redaction, refresh);
|
||||
};
|
||||
}, [mx, roomId, eventId, refresh]);
|
||||
|
||||
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 { counts, myVote, total } = votes;
|
||||
|
||||
const handleVote = (answerId: string) => {
|
||||
if (!roomId || !eventId) return;
|
||||
// Optimistic local update
|
||||
setVotes((prev) => {
|
||||
const next = new Map(prev.counts);
|
||||
if (prev.myVote) {
|
||||
const prevCount = next.get(prev.myVote) ?? 1;
|
||||
if (prevCount <= 1) next.delete(prev.myVote);
|
||||
else next.set(prev.myVote, prevCount - 1);
|
||||
}
|
||||
next.set(answerId, (next.get(answerId) ?? 0) + 1);
|
||||
return { counts: next, myVote: answerId, total: prev.myVote ? prev.total : prev.total + 1 };
|
||||
});
|
||||
if (isStable) {
|
||||
mx.sendEvent(roomId, 'm.poll.response' as any, {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.selections': [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] },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const answers = poll.answers ?? [];
|
||||
const maxVotes = answers.reduce((m, a, i) => {
|
||||
const id = a['m.id'] ?? a.id ?? String(i);
|
||||
return Math.max(m, counts.get(id) ?? 0);
|
||||
}, 0);
|
||||
|
||||
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' }}>
|
||||
{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;
|
||||
const voteCount = counts.get(id) ?? 0;
|
||||
const pct = total > 0 ? Math.round((voteCount / total) * 100) : 0;
|
||||
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',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* vote progress bar */}
|
||||
{total > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: `${pct}%`,
|
||||
background: selected
|
||||
? 'rgba(255,255,255,0.10)'
|
||||
: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: '8px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '8px', position: 'relative' }}>
|
||||
<span style={{ flexGrow: 1 }}>{text}</span>
|
||||
{selected && (
|
||||
<span style={{ opacity: 0.8, fontSize: '1rem', flexShrink: 0 }}>✓</span>
|
||||
)}
|
||||
{total > 0 && (
|
||||
<span style={{ opacity: 0.6, fontSize: '0.78rem', flexShrink: 0 }}>
|
||||
{pct}%
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Text size="T200" style={{ opacity: 0.5, marginTop: '2px' }}>
|
||||
<i>
|
||||
{total > 0 ? `${total} vote${total === 1 ? '' : 's'} — ` : ''}
|
||||
{canVote
|
||||
? myVote
|
||||
? 'click another to change'
|
||||
: 'click an option to vote'
|
||||
: 'voting not available'}
|
||||
</i>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -5,4 +5,3 @@ export * from './AudioContent';
|
||||
export * from './FileContent';
|
||||
export * from './FallbackContent';
|
||||
export * from './EventContent';
|
||||
export * from './PollContent';
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { Icon, Icons, Modal, Overlay, OverlayBackdrop, OverlayCenter, Text, color } from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { UserAvatar } from '../user-avatar';
|
||||
import { StackedAvatar } from '../stacked-avatar';
|
||||
import { EventReaders } from '../event-readers';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
|
||||
const MAX_DISPLAY = 5;
|
||||
|
||||
export function ReadReceiptAvatars({
|
||||
room,
|
||||
eventId,
|
||||
userIds,
|
||||
}: {
|
||||
room: Room;
|
||||
eventId: string;
|
||||
userIds: string[];
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
|
||||
if (userIds.length === 0) return null;
|
||||
|
||||
const displayed = userIds.slice(0, MAX_DISPLAY);
|
||||
const extra = userIds.length - MAX_DISPLAY;
|
||||
const tooltipNames =
|
||||
userIds
|
||||
.slice(0, 5)
|
||||
.map((id) => getMemberDisplayName(room, id) ?? getMxIdLocalPart(id) ?? id)
|
||||
.join(', ') + (extra > 0 ? ` +${extra} more` : '');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Overlay open={open} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setOpen(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal variant="Surface" size="300">
|
||||
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
title={tooltipNames}
|
||||
aria-label={tooltipNames}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
marginLeft: 'auto',
|
||||
marginTop: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
{/* Pill wrapper ensures visibility on any wallpaper/background */}
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: lotusTerminal ? 'rgba(0,212,255,0.07)' : color.SurfaceVariant.Container,
|
||||
border: lotusTerminal ? '1px solid rgba(0,212,255,0.30)' : '1px solid transparent',
|
||||
boxShadow: lotusTerminal ? '0 0 10px rgba(0,212,255,0.12)' : 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '2px 6px 2px 2px',
|
||||
gap: '0px',
|
||||
}}
|
||||
>
|
||||
{displayed.map((userId) => {
|
||||
const name =
|
||||
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const avatarMxc = room.getMember(userId)?.getMxcAvatarUrl();
|
||||
const avatarUrl = avatarMxc
|
||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ?? undefined
|
||||
: undefined;
|
||||
return (
|
||||
<StackedAvatar
|
||||
key={userId}
|
||||
title={name}
|
||||
variant="SurfaceVariant"
|
||||
size="200"
|
||||
radii="Pill"
|
||||
>
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</StackedAvatar>
|
||||
);
|
||||
})}
|
||||
{extra > 0 && (
|
||||
<Text
|
||||
size="T100"
|
||||
style={{ paddingLeft: '4px', color: color.SurfaceVariant.OnContainer }}
|
||||
>
|
||||
+{extra}
|
||||
</Text>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { ReadReceiptAvatars } from './ReadReceiptAvatars';
|
||||
@@ -21,7 +21,7 @@ export function SplashScreen({ children }: SplashScreenProps) {
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Text size="H2" align="Center">
|
||||
Lotus Chat
|
||||
Cinny
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -182,27 +182,6 @@ export function UploadCardRenderer({
|
||||
<PreviewVideo fileItem={fileItem} />
|
||||
</MediaPreview>
|
||||
)}
|
||||
{(fileItem.originalFile.type.startsWith('image') ||
|
||||
fileItem.originalFile.type.startsWith('video')) && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a caption… (optional)"
|
||||
value={metadata.caption ?? ''}
|
||||
onChange={(e) => setMetadata(fileItem, { ...metadata, caption: e.target.value })}
|
||||
style={{
|
||||
marginTop: '6px',
|
||||
width: '100%',
|
||||
background: 'var(--bg-surface-low)',
|
||||
border: '1px solid var(--bg-surface-border)',
|
||||
borderRadius: '6px',
|
||||
padding: '5px 8px',
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-primary)',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
|
||||
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Box, as } from 'folds';
|
||||
import * as css from './UrlPreview.css';
|
||||
|
||||
export const UrlPreview = as<'div'>(({ className, ...props }, ref) => (
|
||||
<Box shrink="No" data-url-preview="" className={classNames(css.UrlPreview, className)} {...props} ref={ref} />
|
||||
<Box shrink="No" className={classNames(css.UrlPreview, className)} {...props} ref={ref} />
|
||||
));
|
||||
|
||||
export const UrlPreviewImg = as<'img'>(({ className, alt, ...props }, ref) => (
|
||||
|
||||
@@ -28,7 +28,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadPreview().catch(() => {});
|
||||
loadPreview();
|
||||
}, [loadPreview]);
|
||||
|
||||
if (previewStatus.status === AsyncStatus.Error) return null;
|
||||
|
||||
@@ -28,11 +28,7 @@ import { copyToClipboard } from '../../utils/dom';
|
||||
import { getExploreServerPath } from '../../pages/pathUtils';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { factoryRoomIdByAtoZ } from '../../utils/sort';
|
||||
import {
|
||||
useMutualRooms,
|
||||
useMutualRoomsSupport,
|
||||
useUnstableMutualRoomsSupport,
|
||||
} from '../../hooks/useMutualRooms';
|
||||
import { useMutualRooms, useMutualRoomsSupport } from '../../hooks/useMutualRooms';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { useDirectRooms } from '../../pages/client/direct/useDirectRooms';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
@@ -237,7 +233,6 @@ type MutualRoomsData = {
|
||||
export function MutualRoomsChip({ userId }: { userId: string }) {
|
||||
const mx = useMatrixClient();
|
||||
const mutualRoomSupported = useMutualRoomsSupport();
|
||||
const mutualRoomUnstable = useUnstableMutualRoomsSupport();
|
||||
const mutualRoomsState = useMutualRooms(userId);
|
||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||
const closeUserRoomProfile = useCloseUserRoomProfile();
|
||||
@@ -284,7 +279,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) {
|
||||
|
||||
if (
|
||||
userId === mx.getSafeUserId() ||
|
||||
(!mutualRoomSupported && !mutualRoomUnstable) ||
|
||||
!mutualRoomSupported ||
|
||||
mutualRoomsState.status === AsyncStatus.Error
|
||||
) {
|
||||
return null;
|
||||
|
||||
@@ -157,6 +157,7 @@ export function CallControl({
|
||||
}) {
|
||||
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
|
||||
const [hangupState, hangup] = useAsyncCallback(
|
||||
useCallback(() => callEmbed.hangup(), [callEmbed])
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { Chip, Icon, Icons, Text } from 'folds';
|
||||
import { Chip, Text } from 'folds';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useRoomName } from '../../hooks/useRoomMeta';
|
||||
import { RoomIcon } from '../../components/room-avatar';
|
||||
@@ -38,11 +38,7 @@ export function CallRoomName({ room }: CallRoomNameProps) {
|
||||
variant="Background"
|
||||
radii="Pill"
|
||||
before={
|
||||
dm ? (
|
||||
<Icon size="200" src={Icons.VolumeHigh} filled />
|
||||
) : (
|
||||
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} filled />
|
||||
)
|
||||
}
|
||||
onClick={() => navigateRoom(room.roomId)}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
config,
|
||||
Icon,
|
||||
IconButton,
|
||||
@@ -27,8 +26,6 @@ import {
|
||||
VideoButton,
|
||||
} from './Controls';
|
||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
@@ -54,29 +51,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
);
|
||||
|
||||
const [cords, setCords] = useState<RectCords>();
|
||||
const [shareConfirm, setShareConfirm] = useState(false);
|
||||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
const [pttActive, setPttActive] = useState(false);
|
||||
|
||||
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
||||
const microphoneRef = useRef(microphone);
|
||||
useEffect(() => { microphoneRef.current = microphone; }, [microphone]);
|
||||
|
||||
// Handle PTT mode toggle mid-call — save/restore mic state (I-4)
|
||||
const pttModeRef = useRef(pttMode);
|
||||
const micBeforePTTRef = useRef<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
if (pttMode && !pttModeRef.current) {
|
||||
micBeforePTTRef.current = microphoneRef.current;
|
||||
callEmbed.control.setMicrophone(false);
|
||||
} else if (!pttMode && pttModeRef.current) {
|
||||
callEmbed.control.setMicrophone(micBeforePTTRef.current ?? true);
|
||||
micBeforePTTRef.current = null;
|
||||
}
|
||||
pttModeRef.current = pttMode;
|
||||
}, [pttMode, callEmbed]);
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setCords(evt.currentTarget.getBoundingClientRect());
|
||||
@@ -97,86 +71,12 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
setCords(undefined);
|
||||
};
|
||||
|
||||
const pttActiveRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pttMode) return;
|
||||
const iframeWindow = callEmbed.iframe.contentWindow;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code !== pttKey || e.repeat) return;
|
||||
const target = e.target as HTMLElement;
|
||||
// BUG-7: use ownerDocument.body so isEditable works inside the EC iframe
|
||||
const isEditable = (el: HTMLElement): boolean => {
|
||||
const tag = el.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
let node: HTMLElement | null = el;
|
||||
while (node && node !== el.ownerDocument.body) {
|
||||
if (node.contentEditable === 'true') return true;
|
||||
if (node.contentEditable === 'false') return false;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (isEditable(target)) return;
|
||||
e.preventDefault();
|
||||
if (!microphoneRef.current) callEmbed.control.setMicrophone(true);
|
||||
pttActiveRef.current = true;
|
||||
setPttActive(true);
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code !== pttKey) return;
|
||||
callEmbed.control.setMicrophone(false);
|
||||
pttActiveRef.current = false;
|
||||
setPttActive(false);
|
||||
};
|
||||
const onBlur = () => {
|
||||
callEmbed.control.setMicrophone(false);
|
||||
pttActiveRef.current = false;
|
||||
setPttActive(false);
|
||||
};
|
||||
const onFocus = () => {
|
||||
callEmbed.control.setMicrophone(false);
|
||||
pttActiveRef.current = false;
|
||||
setPttActive(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener('blur', onBlur);
|
||||
window.addEventListener('focus', onFocus);
|
||||
// BUG-9: also wire iframe blur/focus so stuck-mic release works when focus moves to iframe
|
||||
iframeWindow?.addEventListener('keydown', onKeyDown);
|
||||
iframeWindow?.addEventListener('keyup', onKeyUp);
|
||||
iframeWindow?.addEventListener('blur', onBlur);
|
||||
iframeWindow?.addEventListener('focus', onFocus);
|
||||
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);
|
||||
iframeWindow?.removeEventListener('blur', onBlur);
|
||||
iframeWindow?.removeEventListener('focus', onFocus);
|
||||
// BUG-8: if callEmbed changes while PTT is active, release mic on cleanup
|
||||
if (pttActiveRef.current) {
|
||||
callEmbed.control.setMicrophone(false);
|
||||
pttActiveRef.current = false;
|
||||
setPttActive(false);
|
||||
}
|
||||
};
|
||||
// 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}
|
||||
@@ -184,82 +84,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
justifyContent="Center"
|
||||
alignItems="Center"
|
||||
>
|
||||
{pttMode && (
|
||||
lotusTerminal ? (
|
||||
<Box style={{
|
||||
position: 'absolute',
|
||||
top: '-2.5rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: pttActive ? 'rgba(0,255,136,0.18)' : 'rgba(255,107,0,0.12)',
|
||||
border: `1px solid ${pttActive ? 'rgba(0,255,136,0.55)' : 'rgba(255,107,0,0.35)'}`,
|
||||
borderRadius: '99px',
|
||||
padding: '0.2rem 0.9rem',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<Text size="T200" style={{ color: pttActive ? '#00FF88' : '#FF6B00', fontWeight: 700, letterSpacing: '0.08em', fontFamily: 'JetBrains Mono, monospace' }}>
|
||||
{pttActive ? '● LIVE' : `PTT — Hold ${pttKeyLabel}`}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Chip
|
||||
variant={pttActive ? 'Success' : 'Warning'}
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-2.2rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
outlined
|
||||
>
|
||||
<Text size="T200" style={{ fontWeight: 700 }}>
|
||||
{pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`}
|
||||
</Text>
|
||||
</Chip>
|
||||
)
|
||||
)}
|
||||
{shareConfirm && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '110%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '0.75rem',
|
||||
padding: '1rem 1.25rem',
|
||||
zIndex: 100,
|
||||
minWidth: '260px',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<Text size="T300" style={{ fontWeight: 600 }}>Share your screen?</Text>
|
||||
<Text size="T200" style={{ opacity: 0.75 }}>Your screen will be visible to all participants in this call.</Text>
|
||||
<Box gap="200">
|
||||
<Button
|
||||
size="300" variant="Success" fill="Solid" radii="300"
|
||||
onClick={() => { callEmbed.control.toggleScreenshare(); setShareConfirm(false); }}
|
||||
>
|
||||
<Text size="B300">Share</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300" variant="Secondary" fill="Soft" radii="300" outlined
|
||||
onClick={() => setShareConfirm(false)}
|
||||
>
|
||||
<Text size="B300">Cancel</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<SequenceCard
|
||||
className={css.ControlCard}
|
||||
variant="SurfaceVariant"
|
||||
@@ -281,10 +105,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} />
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() => screenshare
|
||||
? callEmbed.control.toggleScreenshare()
|
||||
: setShareConfirm(true)
|
||||
}
|
||||
onToggle={() => callEmbed.control.toggleScreenshare()}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -14,20 +14,11 @@ import { CallMemberRenderer } from './CallMemberCard';
|
||||
import * as css from './styles.css';
|
||||
import { CallControls } from './CallControls';
|
||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
|
||||
function LivekitServerMissingMessage() {
|
||||
return (
|
||||
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
|
||||
Your homeserver does not support calling.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function WebRTCMissingError() {
|
||||
return (
|
||||
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
|
||||
Your browser does not support WebRTC, which is required for calling.
|
||||
Your homeserver does not support calling. But you can still join call started by others.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -35,22 +26,16 @@ function WebRTCMissingError() {
|
||||
function JoinMessage({
|
||||
hasParticipant,
|
||||
livekitSupported,
|
||||
rtcSupported,
|
||||
}: {
|
||||
hasParticipant?: boolean;
|
||||
livekitSupported?: boolean;
|
||||
rtcSupported?: boolean;
|
||||
}) {
|
||||
if (rtcSupported === false) {
|
||||
return <WebRTCMissingError />;
|
||||
}
|
||||
if (hasParticipant) return null;
|
||||
|
||||
if (livekitSupported === false) {
|
||||
return <LivekitServerMissingMessage />;
|
||||
}
|
||||
|
||||
if (hasParticipant) return null;
|
||||
|
||||
return (
|
||||
<Text style={{ margin: 'auto' }} size="L400" align="Center">
|
||||
Voice chat’s empty — Be the first to hop in!
|
||||
@@ -78,16 +63,12 @@ function CallPrescreen() {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const livekitSupported = useLivekitSupport();
|
||||
const rtcSupported = webRTCSupported();
|
||||
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const creators = useRoomCreators(room);
|
||||
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
const hasPermission = permissions.stateEvent(
|
||||
StateEvent.GroupCallMemberPrefix,
|
||||
mx.getSafeUserId()
|
||||
);
|
||||
const hasPermission = permissions.event(StateEvent.GroupCallMemberPrefix, mx.getSafeUserId());
|
||||
|
||||
const callSession = useCallSession(room);
|
||||
const callMembers = useCallMembers(room, callSession);
|
||||
@@ -96,7 +77,7 @@ function CallPrescreen() {
|
||||
const callEmbed = useCallEmbed();
|
||||
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
|
||||
|
||||
const canJoin = hasPermission && livekitSupported && rtcSupported;
|
||||
const canJoin = hasPermission && (livekitSupported || hasParticipant);
|
||||
|
||||
return (
|
||||
<Scroll variant="Surface" hideTrack>
|
||||
@@ -119,11 +100,7 @@ function CallPrescreen() {
|
||||
<Box className={css.PrescreenMessage} alignItems="Center">
|
||||
{!inOtherCall &&
|
||||
(hasPermission ? (
|
||||
<JoinMessage
|
||||
hasParticipant={hasParticipant}
|
||||
livekitSupported={livekitSupported}
|
||||
rtcSupported={rtcSupported}
|
||||
/>
|
||||
<JoinMessage hasParticipant={hasParticipant} livekitSupported={livekitSupported} />
|
||||
) : (
|
||||
<NoPermissionMessage />
|
||||
))}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
|
||||
delay={500}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text size="T200">{enabled ? 'Deafen' : 'Undeafen'}</Text>
|
||||
<Text size="T200">{enabled ? 'Turn Off Sound' : 'Turn On Sound'}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
@@ -81,18 +81,15 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
|
||||
type VideoButtonProps = {
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
export function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
|
||||
export function VideoButton({ enabled, onToggle }: VideoButtonProps) {
|
||||
return (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
delay={500}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text size="T200">
|
||||
{disabled ? 'Camera disabled in settings' : enabled ? 'Stop Camera' : 'Start Camera'}
|
||||
</Text>
|
||||
<Text size="T200">{enabled ? 'Stop Camera' : 'Start Camera'}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
@@ -105,8 +102,6 @@ export function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
|
||||
size="400"
|
||||
onClick={() => onToggle()}
|
||||
outlined
|
||||
disabled={disabled}
|
||||
style={disabled ? { opacity: 0.4, cursor: 'not-allowed' } : undefined}
|
||||
>
|
||||
<Icon
|
||||
size="400"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import * as css from './styles.css';
|
||||
@@ -6,30 +6,6 @@ import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton
|
||||
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||
import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed';
|
||||
import { useCallPreferences } from '../../state/hooks/callPreferences';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
|
||||
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;
|
||||
@@ -45,14 +21,10 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
||||
const startCall = useCallStart(direct);
|
||||
const joining = callEmbed?.roomId === room.roomId && !callJoined;
|
||||
|
||||
const micPermission = useMediaPermissions();
|
||||
const micDenied = micPermission === 'denied';
|
||||
|
||||
const disabled = inOtherCall || !canJoin || micDenied;
|
||||
const disabled = inOtherCall || !canJoin;
|
||||
|
||||
const { microphone, video, sound, toggleMicrophone, toggleVideo, toggleSound } =
|
||||
useCallPreferences();
|
||||
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
@@ -70,15 +42,10 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
||||
</Box>
|
||||
<ControlDivider />
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="SpaceBetween" gap="200">
|
||||
<VideoButton enabled={video} onToggle={toggleVideo} disabled={!cameraOnJoin} />
|
||||
<VideoButton enabled={video} onToggle={toggleVideo} />
|
||||
<ChatButton />
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column" gap="200">
|
||||
{micDenied && (
|
||||
<Text size="T200" style={{ color: 'var(--tc-critical-high, #e53e3e)', textAlign: 'center' }}>
|
||||
Microphone access is blocked. Enable it in your browser settings to join.
|
||||
</Text>
|
||||
)}
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Button
|
||||
variant={disabled ? 'Secondary' : 'Success'}
|
||||
fill={disabled ? 'Soft' : 'Solid'}
|
||||
|
||||
@@ -21,7 +21,6 @@ export const CallMemberCard = style({
|
||||
|
||||
export const CallControlContainer = style({
|
||||
padding: config.space.S400,
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const PrescreenMessage = style({
|
||||
|
||||
@@ -1,344 +0,0 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { ChatBackground } from '../../state/settings';
|
||||
|
||||
export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'blueprint', label: 'Blueprint' },
|
||||
{ value: 'carbon', label: 'Carbon' },
|
||||
{ value: 'stars', label: 'Stars' },
|
||||
{ value: 'topographic', label: 'Topographic' },
|
||||
{ value: 'herringbone', label: 'Herringbone' },
|
||||
{ value: 'crosshatch', label: 'Crosshatch' },
|
||||
{ value: 'chevron', label: 'Chevron' },
|
||||
{ value: 'polka', label: 'Polka' },
|
||||
{ value: 'triangles', label: 'Triangles' },
|
||||
{ value: 'plaid', label: 'Plaid' },
|
||||
{ value: 'tactical', label: 'Tactical' },
|
||||
{ value: 'circuit', label: 'Circuit' },
|
||||
{ value: 'hexgrid', label: 'Hex Grid' },
|
||||
{ value: 'waves', label: 'Waves' },
|
||||
{ value: 'neon', label: 'Neon Grid' },
|
||||
{ value: 'aurora', label: 'Aurora' },
|
||||
];
|
||||
|
||||
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',
|
||||
},
|
||||
|
||||
// Flowing sine-wave lines
|
||||
waves: {
|
||||
backgroundColor: '#080c18',
|
||||
backgroundImage: [
|
||||
'repeating-radial-gradient(ellipse at 0% 50%, transparent 0, transparent 18px, rgba(80,130,255,0.07) 19px, transparent 20px)',
|
||||
'repeating-radial-gradient(ellipse at 100% 50%, transparent 0, transparent 28px, rgba(80,130,255,0.05) 29px, transparent 30px)',
|
||||
'repeating-radial-gradient(ellipse at 50% 0%, transparent 0, transparent 22px, rgba(100,60,200,0.06) 23px, transparent 24px)',
|
||||
].join(','),
|
||||
},
|
||||
|
||||
// Neon cyberpunk grid — orange/cyan TDS colors
|
||||
neon: {
|
||||
backgroundColor: '#020408',
|
||||
backgroundImage: [
|
||||
'linear-gradient(rgba(255,107,0,0.10) 1px, transparent 1px)',
|
||||
'linear-gradient(90deg, rgba(255,107,0,0.10) 1px, transparent 1px)',
|
||||
'linear-gradient(rgba(0,212,255,0.05) 1px, transparent 1px)',
|
||||
'linear-gradient(90deg, rgba(0,212,255,0.05) 1px, transparent 1px)',
|
||||
].join(','),
|
||||
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
|
||||
},
|
||||
|
||||
// Aurora borealis — flowing gradient bands
|
||||
aurora: {
|
||||
backgroundColor: '#030810',
|
||||
backgroundImage: [
|
||||
'radial-gradient(ellipse at 20% 30%, rgba(0,255,136,0.08) 0%, transparent 55%)',
|
||||
'radial-gradient(ellipse at 80% 70%, rgba(0,100,255,0.08) 0%, transparent 55%)',
|
||||
'radial-gradient(ellipse at 50% 10%, rgba(120,0,255,0.06) 0%, transparent 50%)',
|
||||
'radial-gradient(ellipse at 60% 90%, rgba(0,212,255,0.06) 0%, transparent 50%)',
|
||||
].join(','),
|
||||
},
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
|
||||
waves: {
|
||||
backgroundColor: '#eef3ff',
|
||||
backgroundImage: [
|
||||
'repeating-radial-gradient(ellipse at 0% 50%, transparent 0, transparent 18px, rgba(50,100,220,0.09) 19px, transparent 20px)',
|
||||
'repeating-radial-gradient(ellipse at 100% 50%, transparent 0, transparent 28px, rgba(50,100,220,0.07) 29px, transparent 30px)',
|
||||
'repeating-radial-gradient(ellipse at 50% 0%, transparent 0, transparent 22px, rgba(80,40,180,0.07) 23px, transparent 24px)',
|
||||
].join(','),
|
||||
},
|
||||
|
||||
neon: {
|
||||
backgroundColor: '#fafafa',
|
||||
backgroundImage: [
|
||||
'linear-gradient(rgba(196,78,0,0.12) 1px, transparent 1px)',
|
||||
'linear-gradient(90deg, rgba(196,78,0,0.12) 1px, transparent 1px)',
|
||||
'linear-gradient(rgba(0,98,184,0.06) 1px, transparent 1px)',
|
||||
'linear-gradient(90deg, rgba(0,98,184,0.06) 1px, transparent 1px)',
|
||||
].join(','),
|
||||
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
|
||||
},
|
||||
|
||||
aurora: {
|
||||
backgroundColor: '#f4faf8',
|
||||
backgroundImage: [
|
||||
'radial-gradient(ellipse at 20% 30%, rgba(0,160,80,0.09) 0%, transparent 55%)',
|
||||
'radial-gradient(ellipse at 80% 70%, rgba(0,80,200,0.09) 0%, transparent 55%)',
|
||||
'radial-gradient(ellipse at 50% 10%, rgba(100,0,200,0.07) 0%, transparent 50%)',
|
||||
'radial-gradient(ellipse at 60% 90%, rgba(0,160,200,0.07) 0%, transparent 50%)',
|
||||
].join(','),
|
||||
},
|
||||
};
|
||||
|
||||
export const getChatBg = (bg: ChatBackground, isDark: boolean): CSSProperties =>
|
||||
isDark ? DARK[bg] : LIGHT[bg];
|
||||
@@ -60,7 +60,6 @@ import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
|
||||
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
|
||||
import { livekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
|
||||
type RoomNavItemMenuProps = {
|
||||
room: Room;
|
||||
@@ -294,13 +293,13 @@ export function RoomNavItem({
|
||||
const creators = getRoomCreatorsForRoomId(mx, room.roomId);
|
||||
const permissions = getRoomPermissionsAPI(creators, powerLevels);
|
||||
|
||||
const hasCallPermission = permissions.stateEvent(
|
||||
const hasCallPermission = permissions.event(
|
||||
StateEvent.GroupCallMemberPrefix,
|
||||
mx.getSafeUserId()
|
||||
);
|
||||
|
||||
// Do not join if missing permissions or no livekit support or no webRTC support
|
||||
if (!hasCallPermission || !livekitSupport(autoDiscoveryInfo) || !webRTCSupported()) {
|
||||
// Do not join if missing permissions or no livekit support and call is not started by others
|
||||
if (!hasCallPermission || (!livekitSupport(autoDiscoveryInfo) && callMembers.length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -379,7 +378,7 @@ export function RoomNavItem({
|
||||
aria-label={notificationMode}
|
||||
/>
|
||||
)}
|
||||
{callMembers.length > 0 && (
|
||||
{room.isCallRoom() && callMembers.length > 0 && (
|
||||
<Badge variant="Critical" fill="Solid" size="400">
|
||||
<Text as="span" size="L400" truncate>
|
||||
{callMembers.length} Live
|
||||
|
||||
@@ -23,7 +23,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
|
||||
|
||||
const canEditPowers = permissions.stateEvent(StateEvent.PowerLevelTags, mx.getSafeUserId());
|
||||
const canEditPermissions = permissions.stateEvent(StateEvent.RoomPowerLevels, mx.getSafeUserId());
|
||||
const permissionGroups = usePermissionGroups();
|
||||
const permissionGroups = usePermissionGroups(room.isCallRoom());
|
||||
|
||||
const [powerEditor, setPowerEditor] = useState(false);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import { MessageEvent, StateEvent } from '../../../../types/matrix/room';
|
||||
import { PermissionGroup } from '../../common-settings/permissions';
|
||||
|
||||
export const usePermissionGroups = (): PermissionGroup[] => {
|
||||
export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => {
|
||||
const groups: PermissionGroup[] = useMemo(() => {
|
||||
const messagesGroup: PermissionGroup = {
|
||||
name: 'Messages',
|
||||
@@ -54,7 +54,7 @@ export const usePermissionGroups = (): PermissionGroup[] => {
|
||||
state: true,
|
||||
key: StateEvent.GroupCallMemberPrefix,
|
||||
},
|
||||
name: 'Start or Join Call',
|
||||
name: 'Join Call',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -216,13 +216,13 @@ export const usePermissionGroups = (): PermissionGroup[] => {
|
||||
|
||||
return [
|
||||
messagesGroup,
|
||||
callSettingsGroup,
|
||||
...(isCallRoom ? [callSettingsGroup] : []),
|
||||
moderationGroup,
|
||||
roomOverviewGroup,
|
||||
roomSettingsGroup,
|
||||
otherSettingsGroup,
|
||||
];
|
||||
}, []);
|
||||
}, [isCallRoom]);
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export const ReadPositionsContext = createContext<Map<string, string[]>>(new Map());
|
||||
export const useReadPositions = () => useContext(ReadPositionsContext);
|
||||
@@ -9,7 +9,7 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
import { useRoom, useIsDirectRoom } from '../../hooks/useRoom';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
@@ -18,25 +18,18 @@ import { CallView } from '../call/CallView';
|
||||
import { RoomViewHeader } from './RoomViewHeader';
|
||||
import { callChatAtom } from '../../state/callEmbed';
|
||||
import { CallChatView } from './CallChatView';
|
||||
import { useCallEmbed } from '../../hooks/useCallEmbed';
|
||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
|
||||
export function Room() {
|
||||
const { eventId } = useParams();
|
||||
const room = useRoom();
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const callSession = useCallSession(room);
|
||||
const callMembers = useCallMembers(room, callSession);
|
||||
const callEmbed = useCallEmbed();
|
||||
|
||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const screenSize = useScreenSizeContext();
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const members = useRoomMembers(mx, room.roomId);
|
||||
const chat = useAtomValue(callChatAtom);
|
||||
const isDirect = useIsDirectRoom();
|
||||
|
||||
useKeyDown(
|
||||
window,
|
||||
@@ -50,7 +43,7 @@ export function Room() {
|
||||
)
|
||||
);
|
||||
|
||||
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
|
||||
const callView = room.isCallRoom();
|
||||
|
||||
return (
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
|
||||
@@ -30,8 +30,6 @@ import {
|
||||
} from 'folds';
|
||||
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { GifPicker } from '../../components/GifPicker';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import {
|
||||
CustomEditor,
|
||||
Toolbar,
|
||||
@@ -173,26 +171,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
|
||||
|
||||
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [locating, setLocating] = React.useState(false);
|
||||
const handleShareLocation = () => {
|
||||
if (!navigator.geolocation) return;
|
||||
setLocating(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setLocating(false);
|
||||
const { latitude, longitude } = pos.coords;
|
||||
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
|
||||
mx.sendMessage(roomId, {
|
||||
msgtype: 'm.location',
|
||||
body: `Location: ${geoUri}`,
|
||||
geo_uri: geoUri,
|
||||
} as any);
|
||||
},
|
||||
() => setLocating(false),
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
};
|
||||
|
||||
const [autocompleteQuery, setAutocompleteQuery] =
|
||||
useState<AutocompleteQuery<AutocompletePrefix>>();
|
||||
|
||||
@@ -238,10 +216,7 @@ 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 [gifError, setGifError] = React.useState<string | null>(null);
|
||||
|
||||
const isComposing = useComposingCheck();
|
||||
|
||||
@@ -455,48 +430,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
moveCursor(editor);
|
||||
};
|
||||
|
||||
const handleGifSelect = useCallback(
|
||||
async (gifUrl: string, w: number, h: number) => {
|
||||
try {
|
||||
// Only fetch from trusted Giphy CDN domains
|
||||
const allowed = ['media.giphy.com', 'i.giphy.com', 'media0.giphy.com',
|
||||
'media1.giphy.com', 'media2.giphy.com', 'media3.giphy.com', 'media4.giphy.com'];
|
||||
const { hostname } = new URL(gifUrl);
|
||||
if (!allowed.includes(hostname)) return;
|
||||
|
||||
const res = await fetch(gifUrl);
|
||||
if (!res.ok) return;
|
||||
const contentType = res.headers.get('content-type') ?? '';
|
||||
if (!contentType.startsWith('image/')) return;
|
||||
|
||||
const blob = await res.blob();
|
||||
if (blob.size > 20 * 1024 * 1024) {
|
||||
setGifError('GIF is too large (max 20 MB).');
|
||||
setTimeout(() => setGifError(null), 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
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 { content_uri: string }).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);
|
||||
setGifError('Failed to send GIF. Please try again.');
|
||||
setTimeout(() => setGifError(null), 4000);
|
||||
}
|
||||
},
|
||||
[mx, roomId]
|
||||
);
|
||||
|
||||
const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => {
|
||||
const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
|
||||
if (!stickerUrl) return;
|
||||
@@ -736,73 +669,7 @@ 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>
|
||||
)}
|
||||
{gifError && (
|
||||
<Text size="T100" style={{ color: 'var(--tc-danger-normal)', padding: '2px 6px', alignSelf: 'center', whiteSpace: 'nowrap' }}>
|
||||
{gifError}
|
||||
</Text>
|
||||
)}
|
||||
<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" aria-label="Send message">
|
||||
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
|
||||
<Icon src={Icons.Send} />
|
||||
</IconButton>
|
||||
</>
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
MessageNotDecryptedContent,
|
||||
RedactedContent,
|
||||
MSticker,
|
||||
PollContent,
|
||||
ImageContent,
|
||||
EventContent,
|
||||
} from '../../components/message';
|
||||
@@ -89,8 +88,6 @@ import { useSetting } from '../../state/hooks/settings';
|
||||
import { MessageLayout, settingsAtom } from '../../state/settings';
|
||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
||||
import { Reactions, Message, Event, EncryptedContent } from './message';
|
||||
import { ReadPositionsContext } from './ReadPositionsContext';
|
||||
import { useRoomReadPositions } from '../../hooks/useRoomReadPositions';
|
||||
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
||||
import * as customHtmlCss from '../../styles/CustomHtml.css';
|
||||
import { RoomIntro } from '../../components/room-intro';
|
||||
@@ -341,7 +338,7 @@ const useTimelinePagination = (
|
||||
})
|
||||
);
|
||||
if (err) {
|
||||
fetching = false;
|
||||
// TODO: handle pagination error.
|
||||
return;
|
||||
}
|
||||
const fetchedTimeline =
|
||||
@@ -439,11 +436,9 @@ 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();
|
||||
const readPositions = useRoomReadPositions(room);
|
||||
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
||||
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
@@ -622,8 +617,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
// Check if the document is in focus (user is actively viewing the app),
|
||||
// and either there are no unread messages or the latest message is from the current user.
|
||||
// If either condition is met, trigger the markAsRead function to send a read receipt.
|
||||
const _roomId = mEvt.getRoomId();
|
||||
if (_roomId) requestAnimationFrame(() => markAsRead(mx, _roomId, hideActivity));
|
||||
requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideActivity));
|
||||
}
|
||||
|
||||
if (!document.hasFocus() && !unreadInfo) {
|
||||
@@ -827,7 +821,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const evtTimeline = getEventTimeline(room, readUptoEventId);
|
||||
const absoluteIndex =
|
||||
evtTimeline && getEventIdAbsoluteIndex(linkedTimelines, evtTimeline, readUptoEventId);
|
||||
if (typeof absoluteIndex === 'number') {
|
||||
if (absoluteIndex) {
|
||||
scrollToItem(absoluteIndex, {
|
||||
behavior: 'instant',
|
||||
align: 'start',
|
||||
@@ -1222,11 +1216,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (
|
||||
mEvent.getType() === 'm.poll.start' ||
|
||||
mEvent.getType() === 'org.matrix.msc3381.poll.start'
|
||||
)
|
||||
return <PollContent content={mEvent.getContent()} roomId={room.roomId} eventId={mEvent.getId() ?? undefined} />;
|
||||
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
|
||||
return (
|
||||
<Text>
|
||||
@@ -1307,112 +1296,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
</Message>
|
||||
);
|
||||
},
|
||||
'org.matrix.msc3381.poll.start': (mEventId, mEvent, item, timelineSet, collapse) => {
|
||||
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
||||
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
||||
const hasReactions = reactions && reactions.length > 0;
|
||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||
return (
|
||||
<Message
|
||||
key={mEvent.getId()}
|
||||
data-message-item={item}
|
||||
data-message-id={mEventId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
messageSpacing={messageSpacing}
|
||||
messageLayout={messageLayout}
|
||||
collapse={collapse}
|
||||
highlight={highlighted}
|
||||
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||
canSendReaction={canSendReaction}
|
||||
canPinEvent={canPinEvent}
|
||||
imagePackRooms={imagePackRooms}
|
||||
relations={hasReactions ? reactionRelations : undefined}
|
||||
onUserClick={handleUserClick}
|
||||
onUsernameClick={handleUsernameClick}
|
||||
onReplyClick={handleReplyClick}
|
||||
onReactionToggle={handleReactionToggle}
|
||||
reactions={
|
||||
reactionRelations && (
|
||||
<Reactions
|
||||
style={{ marginTop: config.space.S200 }}
|
||||
room={room}
|
||||
relations={reactionRelations}
|
||||
mEventId={mEventId}
|
||||
canSendReaction={canSendReaction}
|
||||
onReactionToggle={handleReactionToggle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
hideReadReceipts={hideActivity}
|
||||
showDeveloperTools={showDeveloperTools}
|
||||
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
>
|
||||
{mEvent.isRedacted() ? (
|
||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||
) : (
|
||||
<PollContent content={mEvent.getContent()} roomId={room.roomId} eventId={mEvent.getId() ?? undefined} />
|
||||
)}
|
||||
</Message>
|
||||
);
|
||||
},
|
||||
'm.poll.start': (mEventId, mEvent, item, timelineSet, collapse) => {
|
||||
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
||||
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
||||
const hasReactions = reactions && reactions.length > 0;
|
||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||
return (
|
||||
<Message
|
||||
key={mEvent.getId()}
|
||||
data-message-item={item}
|
||||
data-message-id={mEventId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
messageSpacing={messageSpacing}
|
||||
messageLayout={messageLayout}
|
||||
collapse={collapse}
|
||||
highlight={highlighted}
|
||||
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||
canSendReaction={canSendReaction}
|
||||
canPinEvent={canPinEvent}
|
||||
imagePackRooms={imagePackRooms}
|
||||
relations={hasReactions ? reactionRelations : undefined}
|
||||
onUserClick={handleUserClick}
|
||||
onUsernameClick={handleUsernameClick}
|
||||
onReplyClick={handleReplyClick}
|
||||
onReactionToggle={handleReactionToggle}
|
||||
reactions={
|
||||
reactionRelations && (
|
||||
<Reactions
|
||||
style={{ marginTop: config.space.S200 }}
|
||||
room={room}
|
||||
relations={reactionRelations}
|
||||
mEventId={mEventId}
|
||||
canSendReaction={canSendReaction}
|
||||
onReactionToggle={handleReactionToggle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
hideReadReceipts={hideActivity}
|
||||
showDeveloperTools={showDeveloperTools}
|
||||
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
>
|
||||
{mEvent.isRedacted() ? (
|
||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||
) : (
|
||||
<PollContent content={mEvent.getContent()} roomId={room.roomId} eventId={mEvent.getId() ?? undefined} />
|
||||
)}
|
||||
</Message>
|
||||
);
|
||||
},
|
||||
[StateEvent.RoomMember]: (mEventId, mEvent, item) => {
|
||||
const membershipChanged = isMembershipChanged(mEvent);
|
||||
if (membershipChanged && hideMembershipEvents) return null;
|
||||
@@ -1587,70 +1470,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
</Event>
|
||||
);
|
||||
},
|
||||
[StateEvent.RoomEncryption]: (mEventId, mEvent, item) => {
|
||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||
const timeJSX = (
|
||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} hour24Clock={hour24Clock} dateFormatString={dateFormatString} />
|
||||
);
|
||||
return (
|
||||
<Event key={mEvent.getId()} data-message-item={item} data-message-id={mEventId} room={room} mEvent={mEvent} highlight={highlighted} messageSpacing={messageSpacing} canDelete={canRedact || mEvent.getSender() === mx.getUserId()} hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools}>
|
||||
<EventContent messageLayout={messageLayout} time={timeJSX} iconSrc={Icons.Lock}
|
||||
content={<Box grow="Yes" direction="Column"><Text size="T300" priority="300"><b>{senderName}</b>{' enabled end-to-end encryption'}</Text></Box>}
|
||||
/>
|
||||
</Event>
|
||||
);
|
||||
},
|
||||
[StateEvent.RoomJoinRules]: (mEventId, mEvent, item) => {
|
||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||
const joinRule = mEvent.getContent<{ join_rule?: string }>().join_rule ?? 'unknown';
|
||||
const ruleLabel: Record<string, string> = { public: 'public', invite: 'invite-only', knock: 'knock', restricted: 'restricted' };
|
||||
const timeJSX = (
|
||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} hour24Clock={hour24Clock} dateFormatString={dateFormatString} />
|
||||
);
|
||||
return (
|
||||
<Event key={mEvent.getId()} data-message-item={item} data-message-id={mEventId} room={room} mEvent={mEvent} highlight={highlighted} messageSpacing={messageSpacing} canDelete={canRedact || mEvent.getSender() === mx.getUserId()} hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools}>
|
||||
<EventContent messageLayout={messageLayout} time={timeJSX} iconSrc={Icons.Settings}
|
||||
content={<Box grow="Yes" direction="Column"><Text size="T300" priority="300"><b>{senderName}</b>{` set room join rule to ${ruleLabel[joinRule] ?? joinRule}`}</Text></Box>}
|
||||
/>
|
||||
</Event>
|
||||
);
|
||||
},
|
||||
[StateEvent.RoomGuestAccess]: (mEventId, mEvent, item) => {
|
||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||
const access = mEvent.getContent<{ guest_access?: string }>().guest_access ?? 'unknown';
|
||||
const timeJSX = (
|
||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} hour24Clock={hour24Clock} dateFormatString={dateFormatString} />
|
||||
);
|
||||
return (
|
||||
<Event key={mEvent.getId()} data-message-item={item} data-message-id={mEventId} room={room} mEvent={mEvent} highlight={highlighted} messageSpacing={messageSpacing} canDelete={canRedact || mEvent.getSender() === mx.getUserId()} hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools}>
|
||||
<EventContent messageLayout={messageLayout} time={timeJSX} iconSrc={Icons.Settings}
|
||||
content={<Box grow="Yes" direction="Column"><Text size="T300" priority="300"><b>{senderName}</b>{access === 'can_join' ? ' allowed guest access' : ' disabled guest access'}</Text></Box>}
|
||||
/>
|
||||
</Event>
|
||||
);
|
||||
},
|
||||
[StateEvent.RoomCanonicalAlias]: (mEventId, mEvent, item) => {
|
||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||
const alias = mEvent.getContent<{ alias?: string }>().alias;
|
||||
const timeJSX = (
|
||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} hour24Clock={hour24Clock} dateFormatString={dateFormatString} />
|
||||
);
|
||||
return (
|
||||
<Event key={mEvent.getId()} data-message-item={item} data-message-id={mEventId} room={room} mEvent={mEvent} highlight={highlighted} messageSpacing={messageSpacing} canDelete={canRedact || mEvent.getSender() === mx.getUserId()} hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools}>
|
||||
<EventContent messageLayout={messageLayout} time={timeJSX} iconSrc={Icons.Hash}
|
||||
content={<Box grow="Yes" direction="Column"><Text size="T300" priority="300"><b>{senderName}</b>{alias ? ` set room address to ${alias}` : ' removed room address'}</Text></Box>}
|
||||
/>
|
||||
</Event>
|
||||
);
|
||||
},
|
||||
[StateEvent.GroupCallMemberPrefix]: (mEventId, mEvent, item) => {
|
||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
@@ -1819,15 +1638,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
return null;
|
||||
}
|
||||
if (mEvent.isRedacted() && !showHiddenEvents) {
|
||||
const t = mEvent.getType();
|
||||
if (
|
||||
t !== MessageEvent.RoomMessage &&
|
||||
t !== MessageEvent.RoomMessageEncrypted &&
|
||||
t !== MessageEvent.Sticker
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!newDivider && readUptoEventIdRef.current) {
|
||||
newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
|
||||
@@ -1837,7 +1649,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
}
|
||||
|
||||
const collapsed =
|
||||
!perMessageProfiles &&
|
||||
isPrevRendered &&
|
||||
!dayDivider &&
|
||||
(!newDivider || eventSender === mx.getUserId()) &&
|
||||
@@ -1905,7 +1716,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
};
|
||||
|
||||
return (
|
||||
<ReadPositionsContext.Provider value={readPositions}>
|
||||
<Box grow="Yes" style={{ position: 'relative' }}>
|
||||
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
|
||||
<TimelineFloat position="Top">
|
||||
@@ -2031,6 +1841,5 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
</TimelineFloat>
|
||||
)}
|
||||
</Box>
|
||||
</ReadPositionsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { Box, Text, config } from 'folds';
|
||||
import { EventType } from 'matrix-js-sdk';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
@@ -15,12 +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';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
@@ -56,15 +54,9 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
|
||||
export function RoomView({ eventId }: { eventId?: string }) {
|
||||
const roomInputRef = useRef<HTMLDivElement>(null);
|
||||
const roomViewRef = useRef<HTMLDivElement>(null);
|
||||
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
const theme = useTheme();
|
||||
const isDark = theme.kind === ThemeKind.Dark;
|
||||
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
|
||||
@@ -98,13 +90,8 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
||||
)
|
||||
);
|
||||
|
||||
const chatBgStyle = useMemo(
|
||||
() => getChatBg(lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground, isDark),
|
||||
[chatBackground, lotusTerminal, isDark]
|
||||
);
|
||||
|
||||
return (
|
||||
<Page ref={roomViewRef} style={chatBgStyle}>
|
||||
<Page ref={roomViewRef}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomTimeline
|
||||
key={roomId}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
RectCords,
|
||||
Badge,
|
||||
Spinner,
|
||||
Button,
|
||||
} from 'folds';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
@@ -38,7 +37,6 @@ import { settingsAtom } from '../../state/settings';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
|
||||
import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { getStateEvents } from '../../utils/room';
|
||||
import { _SearchPathSearchParams } from '../../pages/paths';
|
||||
import * as css from './RoomViewHeader.css';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
@@ -70,9 +68,6 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
|
||||
type RoomMenuProps = {
|
||||
room: Room;
|
||||
@@ -258,132 +253,6 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
||||
);
|
||||
});
|
||||
|
||||
type CallMenuProps = {
|
||||
onVoiceCall: () => void;
|
||||
onVideoCall: () => void;
|
||||
requestClose: () => void;
|
||||
};
|
||||
const CallMenu = forwardRef<HTMLDivElement, CallMenuProps>(
|
||||
({ requestClose, onVoiceCall, onVideoCall }, ref) => {
|
||||
const handleVoice = () => {
|
||||
onVoiceCall();
|
||||
requestClose();
|
||||
};
|
||||
const handleVideo = () => {
|
||||
onVideoCall();
|
||||
requestClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu ref={ref} style={{ padding: config.space.S200, minWidth: toRem(150) }}>
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="L400">Start Call</Text>
|
||||
<Box direction="Column" gap="200">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Success"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
before={<Icon size="100" src={Icons.Phone} filled />}
|
||||
onClick={handleVoice}
|
||||
>
|
||||
<Text size="B300">Voice</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Success"
|
||||
radii="300"
|
||||
before={<Icon size="100" src={Icons.VideoCamera} filled />}
|
||||
onClick={handleVideo}
|
||||
>
|
||||
<Text size="B300">Video</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function CallButton() {
|
||||
const room = useRoom();
|
||||
const direct = useIsDirectRoom();
|
||||
|
||||
const callEmbed = useCallEmbed();
|
||||
const startCall = useCallStart(direct);
|
||||
const callStarted = callEmbed && callEmbed.roomId === room.roomId;
|
||||
const inAnotherCall = callEmbed && !callStarted;
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
{inAnotherCall ? (
|
||||
<Text size="L400">Already in another call — End the current call to join!</Text>
|
||||
) : (
|
||||
<Text>Call</Text>
|
||||
)}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
onClick={handleOpenMenu}
|
||||
onContextMenu={(evt) => {
|
||||
evt.preventDefault();
|
||||
startCall(room, {
|
||||
microphone: true,
|
||||
video: true,
|
||||
sound: true,
|
||||
});
|
||||
}}
|
||||
disabled={inAnotherCall || callStarted}
|
||||
aria-pressed={!!menuAnchor}
|
||||
>
|
||||
<Icon size="400" src={Icons.VideoCamera} filled={!!menuAnchor} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Bottom"
|
||||
align="Center"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<CallMenu
|
||||
onVideoCall={() => startCall(room, { microphone: true, video: true, sound: true })}
|
||||
onVoiceCall={() => startCall(room, { microphone: true, video: false, sound: true })}
|
||||
requestClose={() => setMenuAnchor(undefined)}
|
||||
/>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const mx = useMatrixClient();
|
||||
@@ -391,17 +260,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
const screenSize = useScreenSizeContext();
|
||||
const room = useRoom();
|
||||
const space = useSpaceOptionally();
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const creators = useRoomCreators(room);
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
|
||||
const hasCallPermission = permissions.stateEvent(
|
||||
StateEvent.GroupCallMemberPrefix,
|
||||
mx.getSafeUserId()
|
||||
);
|
||||
const livekitSupported = useLivekitSupport();
|
||||
const rtcSupported = webRTCSupported();
|
||||
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||
const direct = useIsDirectRoom();
|
||||
@@ -595,11 +453,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
{!room.isCallRoom() && livekitSupported && rtcSupported && hasCallPermission &&
|
||||
(direct || (room.getJoinRule() === 'invite' &&
|
||||
getStateEvents(room, StateEvent.SpaceParent).length === 0)) && (
|
||||
<CallButton />
|
||||
)}
|
||||
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Text, as } from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import classNames from 'classnames';
|
||||
@@ -20,18 +20,12 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
||||
const mx = useMatrixClient();
|
||||
const typingMembers = useRoomTypingMember(room.roomId);
|
||||
|
||||
const myUserId = mx.getUserId();
|
||||
const typingNames = useMemo(
|
||||
() =>
|
||||
typingMembers
|
||||
.filter((receipt) => receipt.userId !== myUserId)
|
||||
const typingNames = typingMembers
|
||||
.filter((receipt) => receipt.userId !== mx.getUserId())
|
||||
.map(
|
||||
(receipt) =>
|
||||
getMemberDisplayName(room, receipt.userId) ?? getMxIdLocalPart(receipt.userId)
|
||||
(receipt) => getMemberDisplayName(room, receipt.userId) ?? getMxIdLocalPart(receipt.userId)
|
||||
)
|
||||
.reverse(),
|
||||
[typingMembers, myUserId, room]
|
||||
);
|
||||
.reverse();
|
||||
|
||||
if (typingNames.length === 0) {
|
||||
return null;
|
||||
@@ -50,7 +44,7 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }} aria-live="polite" aria-atomic="false">
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Box
|
||||
className={classNames(css.RoomViewTyping, className)}
|
||||
alignItems="Center"
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import React, { ChangeEvent, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
config,
|
||||
Input,
|
||||
Line,
|
||||
MenuItem,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Scroll,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { MatrixEvent } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
|
||||
type Props = {
|
||||
mEvent: MatrixEvent;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
const mx = useMatrixClient();
|
||||
const [query, setQuery] = useState('');
|
||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||
|
||||
const allRooms = mx
|
||||
.getRooms()
|
||||
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
|
||||
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0));
|
||||
|
||||
const filtered = query
|
||||
? allRooms.filter((r) => r.name.toLowerCase().includes(query.toLowerCase()))
|
||||
: allRooms;
|
||||
|
||||
const forward = (roomId: string, roomName: string) => {
|
||||
const fwdContent: Record<string, unknown> = { ...mEvent.getContent() };
|
||||
delete fwdContent['m.relates_to'];
|
||||
mx.sendEvent(roomId, mEvent.getType(), 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>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ import React, {
|
||||
} from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useHover, useFocusWithin } from 'react-aria';
|
||||
import { MatrixEvent, Room, EventStatus } from 'matrix-js-sdk';
|
||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import { Relations } from 'matrix-js-sdk/lib/models/relations';
|
||||
import classNames from 'classnames';
|
||||
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
||||
@@ -60,13 +60,9 @@ import {
|
||||
} from '../../../utils/matrix';
|
||||
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||
import * as css from './styles.css';
|
||||
import { EventReaders } from '../../../components/event-readers';
|
||||
import { ReadReceiptAvatars } from '../../../components/read-receipt-avatars';
|
||||
import { useReadPositions } from '../ReadPositionsContext';
|
||||
import { TextViewer } from '../../../components/text-viewer';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { EmojiBoard } from '../../../components/emoji-board';
|
||||
@@ -84,46 +80,6 @@ import { PowerIcon } from '../../../components/power';
|
||||
import colorMXID from '../../../../util/colorMXID';
|
||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||
|
||||
|
||||
// Delivery status indicator for own messages
|
||||
function DeliveryStatus({ status, lotusTerminal }: { status: string | null; lotusTerminal: boolean }) {
|
||||
if (status === null) return null; // confirmed by server — read receipts take over
|
||||
let icon: string;
|
||||
let label: string;
|
||||
let colorStyle: string;
|
||||
if (status === EventStatus.NOT_SENT || status === EventStatus.CANCELLED) {
|
||||
icon = '✕'; label = 'Failed to send'; colorStyle = lotusTerminal ? '#FF3B3B' : color.Critical.Main;
|
||||
} else if (status === EventStatus.QUEUED) {
|
||||
icon = '⏳'; label = 'Queued'; colorStyle = lotusTerminal ? 'rgba(0,212,255,0.45)' : color.Secondary.Main;
|
||||
} else if (status === EventStatus.SENDING || status === EventStatus.ENCRYPTING) {
|
||||
icon = '⟳'; label = 'Sending...'; colorStyle = lotusTerminal ? 'rgba(0,212,255,0.60)' : color.Secondary.Main;
|
||||
} else {
|
||||
icon = '✓'; label = 'Sent'; colorStyle = lotusTerminal ? 'rgba(0,212,255,0.70)' : color.Secondary.Main;
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
as="span"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
marginTop: '2px',
|
||||
fontSize: '10px',
|
||||
lineHeight: 1,
|
||||
color: colorStyle,
|
||||
opacity: 0.85,
|
||||
userSelect: 'none',
|
||||
...(lotusTerminal && status === EventStatus.NOT_SENT
|
||||
? { textShadow: '0 0 6px #FF3B3B' }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
|
||||
|
||||
type MessageQuickReactionsProps = {
|
||||
@@ -765,19 +721,12 @@ export const Message = as<'div', MessageProps>(
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
const readPositions = useReadPositions();
|
||||
const readReceiptUsers = hideReadReceipts
|
||||
? []
|
||||
: (readPositions.get(mEvent.getId() ?? '') ?? []);
|
||||
const isMine = mEvent.getSender() === mx.getUserId();
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
|
||||
const [hover, setHover] = useState(false);
|
||||
const { hoverProps } = useHover({ onHoverChange: setHover });
|
||||
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;
|
||||
@@ -883,16 +832,6 @@ export const Message = as<'div', MessageProps>(
|
||||
children
|
||||
)}
|
||||
{reactions}
|
||||
{readReceiptUsers.length > 0 && (
|
||||
<ReadReceiptAvatars
|
||||
room={room}
|
||||
eventId={mEvent.getId() ?? ''}
|
||||
userIds={readReceiptUsers}
|
||||
/>
|
||||
)}
|
||||
{isMine && !mEvent.isState() && readReceiptUsers.length === 0 && (
|
||||
<DeliveryStatus status={mEvent.status} lotusTerminal={!!lotusTerminal} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1089,26 +1028,6 @@ export const Message = as<'div', MessageProps>(
|
||||
Reply
|
||||
</Text>
|
||||
</MenuItem>
|
||||
{!mEvent.isRedacted() && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon src={Icons.ArrowRight} />}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
setForwardOpen(true);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={css.MessageMenuItemText}
|
||||
as="span"
|
||||
size="T300"
|
||||
truncate
|
||||
>
|
||||
Forward
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{!isThreadedMessage && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
@@ -1226,9 +1145,6 @@ export const Message = as<'div', MessageProps>(
|
||||
{msgContentJSX}
|
||||
</ModernLayout>
|
||||
)}
|
||||
{forwardOpen && (
|
||||
<ForwardMessageDialog mEvent={mEvent} onClose={() => setForwardOpen(false)} />
|
||||
)}
|
||||
</MessageBase>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export const getImageMsgContent = async (
|
||||
const content: IContent = {
|
||||
msgtype: MsgType.Image,
|
||||
filename: file.name,
|
||||
body: metadata.caption?.trim() || file.name,
|
||||
body: file.name,
|
||||
[MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
|
||||
};
|
||||
if (imgEl) {
|
||||
@@ -90,7 +90,7 @@ export const getVideoMsgContent = async (
|
||||
const content: IContent = {
|
||||
msgtype: MsgType.Video,
|
||||
filename: file.name,
|
||||
body: metadata.caption?.trim() || file.name,
|
||||
body: file.name,
|
||||
[MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
|
||||
};
|
||||
if (videoEl) {
|
||||
|
||||
@@ -4,8 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import LotusLogo from '../../../../../public/res/Lotus.png';
|
||||
import pkg from '../../../../../package.json';
|
||||
import CinnySVG from '../../../../../public/res/svg/cinny.svg';
|
||||
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
|
||||
@@ -39,23 +38,23 @@ export function About({ requestClose }: AboutProps) {
|
||||
<Box shrink="No">
|
||||
<img
|
||||
style={{ width: toRem(60), height: toRem(60) }}
|
||||
src={LotusLogo}
|
||||
alt="Lotus Chat logo"
|
||||
src={CinnySVG}
|
||||
alt="Cinny logo"
|
||||
/>
|
||||
</Box>
|
||||
<Box direction="Column" gap="300">
|
||||
<Box direction="Column" gap="100">
|
||||
<Box gap="100" alignItems="End">
|
||||
<Text size="H3">Lotus Chat</Text>
|
||||
<Text size="T200">v{pkg.version}</Text>
|
||||
<Text size="H3">Cinny</Text>
|
||||
<Text size="T200">v4.11.1</Text>
|
||||
</Box>
|
||||
<Text>A Matrix client for Lotus Guild.</Text>
|
||||
<Text>Yet another matrix client.</Text>
|
||||
</Box>
|
||||
|
||||
<Box gap="200" wrap="Wrap">
|
||||
<Button
|
||||
as="a"
|
||||
href="https://code.lotusguild.org/LotusGuild/cinny"
|
||||
href="https://github.com/cinnyapp/cinny"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
variant="Secondary"
|
||||
@@ -68,7 +67,7 @@ export function About({ requestClose }: AboutProps) {
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href="https://matrix.lotusguild.org"
|
||||
href="https://cinny.in/#sponsor"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
variant="Critical"
|
||||
|
||||
@@ -28,7 +28,7 @@ function ExportKeys() {
|
||||
const blob = new Blob([encKeys], {
|
||||
type: 'text/plain;charset=us-ascii',
|
||||
});
|
||||
FileSaver.saveAs(blob, 'lotus-keys.txt');
|
||||
FileSaver.saveAs(blob, 'cinny-keys.txt');
|
||||
},
|
||||
[mx]
|
||||
)
|
||||
|
||||
@@ -3,9 +3,7 @@ import React, {
|
||||
FormEventHandler,
|
||||
KeyboardEventHandler,
|
||||
MouseEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -34,7 +32,7 @@ import FocusTrap from 'focus-trap-react';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { ChatBackground, DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
|
||||
import { DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { KeySymbol } from '../../../utils/key-symbol';
|
||||
import { isMacOS } from '../../../utils/user-agent';
|
||||
@@ -44,13 +42,10 @@ import {
|
||||
Theme,
|
||||
ThemeKind,
|
||||
useSystemThemeKind,
|
||||
useTheme,
|
||||
useThemeNames,
|
||||
useThemes,
|
||||
} from '../../../hooks/useTheme';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { BG_OPTIONS, getChatBg } from '../../lotus/chatBackground';
|
||||
import { resetBootSequence, runLotusBootSequence } from '../../../../lotus-boot';
|
||||
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||
@@ -312,8 +307,6 @@ function Appearance() {
|
||||
const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
|
||||
const [monochromeMode, setMonochromeMode] = useSetting(settingsAtom, 'monochromeMode');
|
||||
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||||
const [perMessageProfiles, setPerMessageProfiles] = useSetting(settingsAtom, 'perMessageProfiles');
|
||||
const [lotusTerminal, setLotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
@@ -357,56 +350,6 @@ function Appearance() {
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Page Zoom" after={<PageZoomInput />} />
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column" gap="200">
|
||||
<SettingTile
|
||||
title="Chat Background"
|
||||
description="Pattern applied behind the message timeline."
|
||||
/>
|
||||
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
|
||||
<ChatBgGrid />
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Show Profile on Every Message"
|
||||
description="Display avatar and name on each message instead of grouping consecutive messages."
|
||||
after={<Switch variant="Primary" value={perMessageProfiles} onChange={setPerMessageProfiles} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Lotus Terminal Mode"
|
||||
description="LotusGuild Terminal Design System: Anduril Orange + Ice Cyan + Matrix Green, dot-grid background, CRT scanlines, and boot sequence animation (press Escape to skip)."
|
||||
after={
|
||||
<Box direction="Row" gap="200" alignItems="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>
|
||||
);
|
||||
}
|
||||
@@ -768,7 +711,6 @@ function Editor() {
|
||||
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
@@ -788,13 +730,6 @@ function Editor() {
|
||||
after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Formatting Toolbar"
|
||||
description="Show bold, italic, code and other formatting buttons above the message input."
|
||||
after={<Switch variant="Primary" value={editorToolbar} onChange={setEditorToolbar} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Hide Typing & Read Receipts"
|
||||
@@ -806,133 +741,6 @@ function Editor() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function Calls() {
|
||||
const [cameraOnJoin, setCameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
|
||||
const [callNoiseSuppression, setCallNoiseSuppression] = useSetting(settingsAtom, 'callNoiseSuppression');
|
||||
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
||||
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
||||
const [listeningForKey, setListeningForKey] = useState(false);
|
||||
const keyListenerRef = useRef<((e: KeyboardEvent) => void) | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (keyListenerRef.current)
|
||||
window.removeEventListener('keydown', keyListenerRef.current, true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleKeyBind = useCallback(() => {
|
||||
if (listeningForKey) return;
|
||||
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);
|
||||
keyListenerRef.current = null;
|
||||
};
|
||||
keyListenerRef.current = onKey;
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
}, [listeningForKey, setPttKey]);
|
||||
|
||||
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');
|
||||
@@ -1198,7 +1006,6 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<DateAndTime />
|
||||
<Editor />
|
||||
<Messages />
|
||||
<Calls />
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
|
||||
@@ -27,7 +27,7 @@ function EmailNotification() {
|
||||
device_display_name: email,
|
||||
lang: 'en',
|
||||
data: {
|
||||
brand: 'Lotus Chat',
|
||||
brand: 'Cinny',
|
||||
},
|
||||
append: true,
|
||||
});
|
||||
|
||||
@@ -15,8 +15,6 @@ import { useResizeObserver } from './useResizeObserver';
|
||||
import { CallControlState } from '../plugins/call/CallControlState';
|
||||
import { useCallMembersChange, useCallSession } from './useCall';
|
||||
import { CallPreferences } from '../state/callPreferences';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
|
||||
const CallEmbedContext = createContext<CallEmbed | undefined>(undefined);
|
||||
|
||||
@@ -44,19 +42,15 @@ export const createCallEmbed = (
|
||||
dm: boolean,
|
||||
themeKind: ElementCallThemeKind,
|
||||
container: HTMLElement,
|
||||
pref?: CallPreferences,
|
||||
noiseSuppression = true,
|
||||
forceAudioOff = false
|
||||
pref?: CallPreferences
|
||||
): CallEmbed => {
|
||||
const rtcSession = mx.matrixRTC.getRoomSession(room);
|
||||
const ongoing =
|
||||
MatrixRTCSession.sessionMembershipsForRoom(room, rtcSession.sessionDescription).length > 0;
|
||||
|
||||
const intent = CallEmbed.getIntent(dm, ongoing, pref?.video);
|
||||
const initialAudio = forceAudioOff ? false : (pref?.microphone ?? true);
|
||||
const initialVideo = pref?.video ?? false;
|
||||
const widget = CallEmbed.getWidget(mx, room, intent, themeKind, noiseSuppression, initialAudio, initialVideo);
|
||||
const controlState = pref && new CallControlState(forceAudioOff ? false : pref.microphone, pref.video, pref.sound);
|
||||
const intent = CallEmbed.getIntent(dm, ongoing);
|
||||
const widget = CallEmbed.getWidget(mx, room, intent, themeKind);
|
||||
const controlState = pref && new CallControlState(pref.microphone, pref.video, pref.sound);
|
||||
|
||||
const embed = new CallEmbed(mx, room, widget, container, controlState);
|
||||
|
||||
@@ -68,8 +62,6 @@ export const useCallStart = (dm = false) => {
|
||||
const theme = useTheme();
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
const callEmbedRef = useCallEmbedRef();
|
||||
const [callNoiseSuppression] = useSetting(settingsAtom, 'callNoiseSuppression');
|
||||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||
|
||||
const startCall = useCallback(
|
||||
(room: Room, pref?: CallPreferences) => {
|
||||
@@ -77,11 +69,11 @@ export const useCallStart = (dm = false) => {
|
||||
if (!container) {
|
||||
throw new Error('Failed to start call, No embed container element found!');
|
||||
}
|
||||
const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref, callNoiseSuppression ?? true, !!pttMode);
|
||||
const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref);
|
||||
|
||||
setCallEmbed(callEmbed);
|
||||
},
|
||||
[mx, dm, theme, setCallEmbed, callEmbedRef, callNoiseSuppression, pttMode]
|
||||
[mx, dm, theme, setCallEmbed, callEmbedRef]
|
||||
);
|
||||
|
||||
return startCall;
|
||||
@@ -109,7 +101,6 @@ export const useCallJoined = (embed?: CallEmbed): boolean => {
|
||||
|
||||
export const useCallHangupEvent = (embed: CallEmbed, callback: () => void) => {
|
||||
useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback);
|
||||
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback);
|
||||
};
|
||||
|
||||
export const useCallMemberSoundSync = (embed: CallEmbed) => {
|
||||
|
||||
@@ -18,7 +18,6 @@ export type ClientConfig = {
|
||||
};
|
||||
|
||||
hashRouter?: HashRouterConfig;
|
||||
gifApiKey?: string;
|
||||
};
|
||||
|
||||
const ClientConfigContext = createContext<ClientConfig | null>(null);
|
||||
|
||||
@@ -232,7 +232,9 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
||||
const roomIdOrAliases = rawIds.filter(
|
||||
(idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias)
|
||||
);
|
||||
await Promise.all(roomIdOrAliases.map((idOrAlias) => mx.joinRoom(idOrAlias)));
|
||||
roomIdOrAliases.forEach(async (idOrAlias) => {
|
||||
await mx.joinRoom(idOrAlias);
|
||||
});
|
||||
},
|
||||
},
|
||||
[Command.Leave]: {
|
||||
|
||||
@@ -21,7 +21,7 @@ export function useCompositionEndTracking(): void {
|
||||
return () => {
|
||||
window.removeEventListener('compositionend', recordCompositionEnd, { capture: true });
|
||||
};
|
||||
}, [recordCompositionEnd]);
|
||||
});
|
||||
}
|
||||
|
||||
interface IsComposingLike {
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
export type IntervalCallback = () => void;
|
||||
|
||||
export const useInterval = (callback: IntervalCallback, ms: number): void => {
|
||||
useEffect(() => {
|
||||
/**
|
||||
* @param callback interval callback.
|
||||
* @param ms interval time in milliseconds. negative value will stop the interval.
|
||||
* @returns interval id or undefined if not running.
|
||||
*/
|
||||
export const useInterval = (callback: IntervalCallback, ms: number): number | undefined => {
|
||||
const id = useMemo(() => {
|
||||
if (ms < 0) return undefined;
|
||||
const id = window.setInterval(callback, ms);
|
||||
return () => window.clearInterval(id);
|
||||
return window.setInterval(callback, ms);
|
||||
}, [callback, ms]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearInterval(id);
|
||||
},
|
||||
[id]
|
||||
);
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
import { MatrixClient, Method } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { AsyncState, useAsyncCallbackValue } from './useAsyncCallback';
|
||||
import { useSpecVersions } from './useSpecVersions';
|
||||
|
||||
export const useUnstableMutualRoomsSupport = (): boolean => {
|
||||
export const useMutualRoomsSupport = (): boolean => {
|
||||
const { unstable_features: unstableFeatures } = useSpecVersions();
|
||||
|
||||
const supported =
|
||||
@@ -15,59 +14,16 @@ export const useUnstableMutualRoomsSupport = (): boolean => {
|
||||
return !!supported;
|
||||
};
|
||||
|
||||
export const useMutualRoomsSupport = (): boolean => {
|
||||
const { unstable_features: unstableFeatures, versions } = useSpecVersions();
|
||||
|
||||
const supported =
|
||||
versions.includes('v1.19') ||
|
||||
unstableFeatures?.['uk.half-shot.msc2666.query_mutual_rooms.stable'];
|
||||
|
||||
return !!supported;
|
||||
};
|
||||
|
||||
type MutualRoomsOK = {
|
||||
joined: string[];
|
||||
next_batch?: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
const fetchAllMutualRooms = async (mx: MatrixClient, userId: string): Promise<string[]> => {
|
||||
const mutualRooms: Set<string> = new Set();
|
||||
|
||||
let nextBatch: string | undefined;
|
||||
do {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const result = await mx.http.authedRequest<MutualRoomsOK>(
|
||||
Method.Get,
|
||||
'/mutual_rooms',
|
||||
{
|
||||
user_id: userId,
|
||||
from: nextBatch,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
prefix: '/_matrix/client/v1',
|
||||
}
|
||||
);
|
||||
result.joined.forEach((r) => mutualRooms.add(r));
|
||||
nextBatch = result.next_batch;
|
||||
} while (typeof nextBatch === 'string');
|
||||
|
||||
return Array.from(mutualRooms);
|
||||
};
|
||||
|
||||
export const useMutualRooms = (userId: string): AsyncState<string[], unknown> => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const unstableSupport = useUnstableMutualRoomsSupport();
|
||||
const support = useMutualRoomsSupport();
|
||||
const supported = useMutualRoomsSupport();
|
||||
|
||||
const [mutualRoomsState] = useAsyncCallbackValue(
|
||||
useCallback(() => {
|
||||
if (support) return fetchAllMutualRooms(mx, userId);
|
||||
if (unstableSupport) return mx._unstable_getSharedRooms(userId);
|
||||
return Promise.resolve([]);
|
||||
}, [mx, userId, unstableSupport, support])
|
||||
useCallback(
|
||||
() => (supported ? mx._unstable_getSharedRooms(userId) : Promise.resolve([])),
|
||||
[mx, userId, supported]
|
||||
)
|
||||
);
|
||||
|
||||
return mutualRoomsState;
|
||||
|
||||
@@ -54,13 +54,6 @@ export const usePan = (active: boolean) => {
|
||||
if (!active) setPan(INITIAL_PAN);
|
||||
}, [active]);
|
||||
|
||||
// Clean up document listeners if component unmounts during an active drag
|
||||
useEffect(() => () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return {
|
||||
pan,
|
||||
cursor,
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Room, RoomEvent, RoomMemberEvent, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { reactionOrEditEvent } from '../utils/room';
|
||||
|
||||
// Receipts can land on reaction/edit events which RoomTimeline skips (renders null).
|
||||
// Walk backwards from the receipt event to find the nearest event that IS rendered.
|
||||
function nearestRenderableId(
|
||||
liveEvents: MatrixEvent[],
|
||||
eventIndex: Map<string, number>,
|
||||
evtId: string
|
||||
): string | null {
|
||||
const idx = eventIndex.get(evtId) ?? -1;
|
||||
if (idx === -1) return null;
|
||||
for (let i = idx; i >= 0; i--) {
|
||||
const e = liveEvents[i];
|
||||
if (!reactionOrEditEvent(e)) return e.getId() ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function computePositions(room: Room, myUserId: string): Map<string, string[]> {
|
||||
const map = new Map<string, string[]>();
|
||||
const liveEvents = room.getLiveTimeline().getEvents();
|
||||
// Build O(1) index once instead of O(T) findIndex per member
|
||||
const eventIndex = new Map<string, number>(
|
||||
liveEvents.map((e, i) => [e.getId() ?? '', i])
|
||||
);
|
||||
for (const member of room.getJoinedMembers()) {
|
||||
if (member.userId === myUserId) continue;
|
||||
const evtId = room.getEventReadUpTo(member.userId);
|
||||
if (!evtId) continue;
|
||||
const targetId = nearestRenderableId(liveEvents, eventIndex, evtId);
|
||||
if (!targetId) continue;
|
||||
const arr = map.get(targetId);
|
||||
if (arr) arr.push(member.userId);
|
||||
else map.set(targetId, [member.userId]);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function useRoomReadPositions(room: Room): Map<string, string[]> {
|
||||
const mx = useMatrixClient();
|
||||
const myUserId = mx.getUserId() ?? '';
|
||||
const [positions, setPositions] = useState(() => computePositions(room, myUserId));
|
||||
|
||||
useEffect(() => {
|
||||
setPositions(computePositions(room, myUserId));
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const onReceipt = (): void => {
|
||||
if (debounceTimer !== null) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
setPositions(computePositions(room, myUserId));
|
||||
debounceTimer = null;
|
||||
}, 150);
|
||||
};
|
||||
const onMembership = (): void => setPositions(computePositions(room, myUserId));
|
||||
room.on(RoomEvent.Receipt, onReceipt);
|
||||
room.on(RoomMemberEvent.Membership, onMembership);
|
||||
return () => {
|
||||
if (debounceTimer !== null) clearTimeout(debounceTimer);
|
||||
room.removeListener(RoomEvent.Receipt, onReceipt);
|
||||
room.removeListener(RoomMemberEvent.Membership, onMembership);
|
||||
};
|
||||
}, [room, myUserId]);
|
||||
|
||||
return positions;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { lightTheme } from 'folds';
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
|
||||
import { butterTheme, darkTheme, lotusTerminalLightTheme, lotusTerminalTheme, silverTheme } from '../../colors.css';
|
||||
import { butterTheme, darkTheme, silverTheme } from '../../colors.css';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
|
||||
@@ -37,16 +37,6 @@ export const ButterTheme: Theme = {
|
||||
kind: ThemeKind.Dark,
|
||||
classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'],
|
||||
};
|
||||
export const LotusTerminalTheme: Theme = {
|
||||
id: 'lotus-terminal-theme',
|
||||
kind: ThemeKind.Dark,
|
||||
classNames: ['lotus-terminal-theme', lotusTerminalTheme, onDarkFontWeight, 'prism-dark'],
|
||||
};
|
||||
export const LotusTerminalLightTheme: Theme = {
|
||||
id: 'lotus-terminal-light-theme',
|
||||
kind: ThemeKind.Light,
|
||||
classNames: ['lotus-terminal-light-theme', lotusTerminalLightTheme, onLightFontWeight, 'prism-light'],
|
||||
};
|
||||
|
||||
export const useThemes = (): Theme[] => {
|
||||
const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme], []);
|
||||
|
||||
@@ -3,39 +3,27 @@ import { configClass, varsClass } from 'folds';
|
||||
import {
|
||||
DarkTheme,
|
||||
LightTheme,
|
||||
LotusTerminalLightTheme,
|
||||
LotusTerminalTheme,
|
||||
ThemeContextProvider,
|
||||
ThemeKind,
|
||||
useActiveTheme,
|
||||
useSystemThemeKind,
|
||||
} from '../hooks/useTheme';
|
||||
import { lotusTerminalBodyClass } from '../../lotus-terminal.css';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { runLotusBootSequence } from '../../lotus-boot';
|
||||
|
||||
export function UnAuthRouteThemeManager() {
|
||||
const systemThemeKind = useSystemThemeKind();
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
|
||||
useEffect(() => {
|
||||
document.body.className = '';
|
||||
document.body.classList.add(configClass, varsClass);
|
||||
if (lotusTerminal) {
|
||||
const isLight = systemThemeKind === ThemeKind.Light;
|
||||
document.documentElement.setAttribute('data-theme', isLight ? 'light' : 'dark');
|
||||
document.body.classList.add(...(isLight ? LotusTerminalLightTheme : LotusTerminalTheme).classNames);
|
||||
document.body.classList.add(lotusTerminalBodyClass);
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
if (systemThemeKind === ThemeKind.Dark) {
|
||||
document.body.classList.add(...DarkTheme.classNames);
|
||||
} else {
|
||||
}
|
||||
if (systemThemeKind === ThemeKind.Light) {
|
||||
document.body.classList.add(...LightTheme.classNames);
|
||||
}
|
||||
}
|
||||
}, [systemThemeKind, lotusTerminal]);
|
||||
}, [systemThemeKind]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -43,34 +31,19 @@ export function UnAuthRouteThemeManager() {
|
||||
export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
|
||||
const activeTheme = useActiveTheme();
|
||||
const [monochromeMode] = useSetting(settingsAtom, 'monochromeMode');
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
|
||||
const terminalIsLight = lotusTerminal && activeTheme.kind === ThemeKind.Light;
|
||||
const effectiveTheme = lotusTerminal
|
||||
? (terminalIsLight ? LotusTerminalLightTheme : LotusTerminalTheme)
|
||||
: activeTheme;
|
||||
|
||||
// Boot animation only fires when lotusTerminal is toggled on, not on every theme change
|
||||
useEffect(() => {
|
||||
if (lotusTerminal) runLotusBootSequence();
|
||||
}, [lotusTerminal]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.className = '';
|
||||
document.body.classList.add(configClass, varsClass);
|
||||
document.body.classList.add(...effectiveTheme.classNames);
|
||||
if (lotusTerminal) {
|
||||
document.documentElement.setAttribute('data-theme', terminalIsLight ? 'light' : 'dark');
|
||||
document.body.classList.add(lotusTerminalBodyClass);
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
if (monochromeMode && !lotusTerminal) {
|
||||
|
||||
document.body.classList.add(...activeTheme.classNames);
|
||||
|
||||
if (monochromeMode) {
|
||||
document.body.style.filter = 'grayscale(1)';
|
||||
} else {
|
||||
document.body.style.filter = '';
|
||||
}
|
||||
}, [effectiveTheme, monochromeMode, lotusTerminal, terminalIsLight]);
|
||||
}, [activeTheme, monochromeMode]);
|
||||
|
||||
return <ThemeContextProvider value={effectiveTheme}>{children}</ThemeContextProvider>;
|
||||
return <ThemeContextProvider value={activeTheme}>{children}</ThemeContextProvider>;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'folds';
|
||||
import * as css from './styles.css';
|
||||
import pkg from '../../../../package.json';
|
||||
|
||||
export function AuthFooter() {
|
||||
return (
|
||||
<Box className={css.AuthFooter} justifyContent="Center" gap="400" wrap="Wrap">
|
||||
<Text as="a" size="T300" href="https://lotusguild.org" target="_blank" rel="noreferrer">
|
||||
<Text as="a" size="T300" href="https://cinny.in" target="_blank" rel="noreferrer">
|
||||
About
|
||||
</Text>
|
||||
<Text
|
||||
as="a"
|
||||
size="T300"
|
||||
href="https://code.lotusguild.org/LotusGuild/cinny/releases"
|
||||
href="https://github.com/ajbura/cinny/releases"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
v{pkg.version}
|
||||
v4.11.1
|
||||
</Text>
|
||||
<Text as="a" size="T300" href="https://matrix.lotusguild.org" target="_blank" rel="noreferrer">
|
||||
Community
|
||||
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
|
||||
Twitter
|
||||
</Text>
|
||||
<Text as="a" size="T300" href="https://matrix.org" target="_blank" rel="noreferrer">
|
||||
Powered by Matrix
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from '../../hooks/useClientConfig';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
|
||||
import LotusLogo from '../../../../public/res/Lotus.png';
|
||||
import CinnySVG from '../../../../public/res/svg/cinny.svg';
|
||||
import { ServerPicker } from './ServerPicker';
|
||||
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
|
||||
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
|
||||
@@ -134,8 +134,8 @@ export function AuthLayout() {
|
||||
<Box direction="Column" className={css.AuthCard}>
|
||||
<Header className={css.AuthHeader} size="600" variant="Surface">
|
||||
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
|
||||
<img className={css.AuthLogo} src={LotusLogo} alt="Lotus Chat Logo" />
|
||||
<Text size="H3">Lotus Chat</Text>
|
||||
<img className={css.AuthLogo} src={CinnySVG} alt="Cinny Logo" />
|
||||
<Text size="H3">Cinny</Text>
|
||||
</Box>
|
||||
</Header>
|
||||
<Box className={css.AuthCardContent} direction="Column">
|
||||
|
||||
@@ -133,7 +133,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
|
||||
user: username,
|
||||
},
|
||||
password,
|
||||
initial_device_display_name: 'Lotus Chat Web',
|
||||
initial_device_display_name: 'Cinny Web',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -151,7 +151,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
|
||||
user: mxIdUsername,
|
||||
},
|
||||
password,
|
||||
initial_device_display_name: 'Lotus Chat Web',
|
||||
initial_device_display_name: 'Cinny Web',
|
||||
});
|
||||
};
|
||||
const handleEmailLogin = (email: string, password: string) => {
|
||||
@@ -163,7 +163,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
|
||||
address: email,
|
||||
},
|
||||
password,
|
||||
initial_device_display_name: 'Lotus Chat Web',
|
||||
initial_device_display_name: 'Cinny Web',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export function TokenLogin({ token }: TokenLoginProps) {
|
||||
startLogin(baseUrl, {
|
||||
type: 'm.login.token',
|
||||
token,
|
||||
initial_device_display_name: 'Lotus Chat Web',
|
||||
initial_device_display_name: 'Cinny Web',
|
||||
});
|
||||
}, [baseUrl, token, startLogin]);
|
||||
|
||||
|
||||
@@ -117,9 +117,7 @@ export const useLoginComplete = (data?: CustomLoginResponse) => {
|
||||
setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl);
|
||||
const afterLoginRedirectUrl = getAfterLoginRedirectPath();
|
||||
deleteAfterLoginRedirectPath();
|
||||
const _redir = afterLoginRedirectUrl;
|
||||
const _safePath = (_redir && /^\/(?!\/)/.test(_redir)) ? _redir : getHomePath();
|
||||
navigate(_safePath, { replace: true });
|
||||
navigate(afterLoginRedirectUrl ?? getHomePath(), { replace: true });
|
||||
}
|
||||
}, [data, navigate]);
|
||||
};
|
||||
|
||||