Compare commits

..

5 Commits

Author SHA1 Message Date
Ajay Bura 1e1c487642 fix success and critical color hover+active color 2026-03-12 13:29:14 +05:30
Ajay Bura 43e3a9c290 add bluish tone to text color 2026-03-12 12:50:25 +05:30
Ajay Bura 66a18bdc10 make call room name transparent 2026-03-12 12:38:32 +05:30
Ajay Bura 649c30bcc7 change gradient direction toward top 2026-03-12 12:33:54 +05:30
Ajay Bura e039299e83 Gradient Theme - Moonlight 2026-03-12 12:16:03 +05:30
565 changed files with 9093 additions and 33080 deletions
-15
View File
@@ -1,15 +0,0 @@
{
"defaultHomeserver": 0,
"homeserverList": ["matrix.lotusguild.org"],
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [],
"rooms": [],
"servers": []
},
"hashRouter": {
"enabled": false,
"basename": "/"
}
}
-17
View File
@@ -1,17 +0,0 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"matrix.lotusguild.org"
],
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [],
"rooms": [],
"servers": []
},
"hashRouter": {
"enabled": false,
"basename": "/"
}
}
-2
View File
@@ -1,2 +0,0 @@
VITE_SENTRY_DSN=https://264a5e95c5d31fe080a2e92fb008294d@o4511430568378368.ingest.us.sentry.io/4511430571982849
VITE_APP_VERSION=lotus
+2
View File
@@ -0,0 +1,2 @@
experiment
node_modules
+72
View File
@@ -0,0 +1,72 @@
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
'airbnb',
'prettier',
],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 'latest',
sourceType: 'module',
},
"globals": {
JSX: "readonly"
},
plugins: [
'react',
'@typescript-eslint'
],
rules: {
'linebreak-style': 0,
'no-underscore-dangle': 0,
"no-shadow": "off",
"import/prefer-default-export": "off",
"import/extensions": "off",
"import/no-unresolved": "off",
"import/no-extraneous-dependencies": [
"error",
{
devDependencies: true,
},
],
'react/no-unstable-nested-components': [
'error',
{ allowAsProps: true },
],
"react/jsx-filename-extension": [
"error",
{
extensions: [".tsx", ".jsx"],
},
],
"react/require-default-props": "off",
"react/jsx-props-no-spreading": "off",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-shadow": "error"
},
overrides: [
{
files: ['*.ts'],
rules: {
'no-undef': 'off',
},
},
],
};
-64
View File
@@ -1,64 +0,0 @@
name: CI
on:
push:
branches: [lotus]
pull_request:
branches: [lotus]
jobs:
build:
name: Build & Quality Checks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.node-version'
cache: npm
- name: Install dependencies
run: npm ci
# ── Critical gate — if this fails, nothing deploys ──────────────────
- name: Build
run: npm run build
env:
NODE_OPTIONS: '--max_old_space_size=4096'
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
VITE_APP_VERSION: ${{ github.sha }}
# ── Quality checks (informational — pre-existing issues exist) ───────
- name: TypeScript
run: npm run typecheck
continue-on-error: true
- name: ESLint
run: npm run check:eslint
continue-on-error: true
- name: Prettier
run: npm run check:prettier
continue-on-error: true
# ── Security ─────────────────────────────────────────────────────────
- name: Audit (high/critical)
run: npm audit --audit-level=high --omit=dev
continue-on-error: true
# ── Bundle size report ───────────────────────────────────────────────
- name: Report bundle sizes
run: |
echo "### Bundle sizes" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| File | Size | Gzip |" >> $GITHUB_STEP_SUMMARY
echo "|------|------|------|" >> $GITHUB_STEP_SUMMARY
find dist/assets -name "*.js" -not -name "*.map" | sort | while read f; do
name=$(basename "$f")
size=$(du -sh "$f" | cut -f1)
gzip_size=$(gzip -c "$f" | wc -c | awk '{printf "%.1f kB", $1/1024}')
echo "| $name | $size | $gzip_size |" >> $GITHUB_STEP_SUMMARY
done
@@ -1,127 +0,0 @@
labels: ['needs-confirmation']
body:
- type: markdown #add faqs in future
attributes:
value: |
> [!IMPORTANT]
> Please read through [the Discussion rules](https://github.com/cinnyapp/cinny/discussions/2653) and check for both existing [Discussions](https://github.com/cinnyapp/cinny/discussions?discussions_q=) and [Issues](https://github.com/cinnyapp/cinny/issues?q=sort%3Areactions-desc) prior to opening a new Discussion.
- type: markdown
attributes:
value: '# Issue Details'
- type: textarea
attributes:
label: Issue Description
description: |
Provide a detailed description of the issue. Include relevant information, such as:
- The feature or configuration option you encounter the issue with.
- Screenshots, screen recordings, or other supporting media (as needed).
- If this is a regression of an existing issue that was closed or resolved, please include the previous item reference (Discussion, Issue, PR, commit) in your description.
placeholder: |
When I try to send a message in a room, the message doesn't appear in the timeline.
OR
The application crashes when I click on the settings button.
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: |
Describe how you expect Cinny to behave in this situation.
placeholder: |
I expected the message to appear in the room timeline immediately after sending.
OR
The settings panel should open smoothly without any crashes.
validations:
required: true
- type: textarea
attributes:
label: Actual Behavior
description: |
Describe how Cinny actually behaves in this situation. If it is not immediately obvious how the actual behavior differs from the expected behavior described above, please be sure to mention the deviation specifically.
placeholder: |
The application freezes for 3 seconds and then shows a white screen.
validations:
required: true
- type: textarea
attributes:
label: Reproduction Steps
description: |
Provide a detailed set of step-by-step instructions for reproducing this issue.
placeholder: |
1. Open Cinny and log in to my account
2. Navigate to the #general room
3. Type a message in the message box
4. Press Enter to send
5. Notice that the message doesn't appear in the timeline
validations:
required: true
- type: textarea
attributes:
label: Environement
description: |
Please provide information about your environment. Include the following:
- OS:
- Browser:
- Cinny Web Version: (app.cinny.in or self hosted)
- Cinny desktop Version: (appimage or deb or flatpak)
- Matrix Homeserver:
placeholder: |
- OS: Windows 11
- Browser: Chrome 120.0.6099.109
- Cinny Web Version: 3.2.0 (app.cinny.in or self hosted)
- Cinny desktop Version: 3.2.0 (appimage or deb or flatpak)
- Matrix Homeserver: matrix.org (Synapse 1.97.0)
render: text
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant Logs
description: |
If applicable, add browser console logs to help explain your problem.
**To get browser console logs:**
- Chrome/Edge: Press F12 → Console tab
- Firefox: Press F12 → Console tab
- Safari: Develop → Show Web Inspector → Console
Please wrap large log outputs in code blocks with triple backticks (```).
placeholder: |
```
Error: Failed to send message
at MessageComposer.sendMessage (composer.js:245)
at HTMLButtonElement.onClick (composer.js:189)
TypeError: Cannot read property 'content' of undefined
at RoomTimeline.render (timeline.js:567)
```
render: shell
validations:
required: false
- type: textarea
attributes:
label: Additional context
description: |
Add any other context about the problem here (e.g., when did this start happening, does it happen on different homeservers, etc.)
placeholder: |
- This started happening after I updated to version 3.2.0
- It only happens in encrypted rooms, not in public rooms
- I've tried on both Firefox and Chrome with the same result
- It works fine on my phone using the same account
- This happens on all homeservers I've tested (matrix.org, mozilla.org)
validations:
required: false
- type: markdown
attributes:
value: |
# User Acknowledgements
> [!TIP]
> Use these links to review the existing Cinny [Discussions](https://github.com/cinnyapp/cinny/discussions?discussions_q=) and [Issues](https://github.com/cinnyapp/cinny/issues?q=sort%3Areactions-desc).
- type: checkboxes #add faqs in future
attributes:
label: 'I acknowledge that:'
options:
- label: I have searched the Cinny repository (both open and closed Discussions and Issues) and confirm this is not a duplicate of an existing issue or discussion.
required: true
- label: I have checked the "Preview" tab on all text fields to ensure that everything looks right, and have wrapped all configuration and code in code blocks with a group of three backticks (` ``` `) on separate lines.
required: true
+57
View File
@@ -0,0 +1,57 @@
name: 🐞 Bug Report
description: Report a bug
body:
- type: markdown
attributes:
value: |
## First of all
1. Please search for [existing issues](https://github.com/ajbura/cinny/issues?q=is%3Aissue) about this problem first.
2. Make sure Cinny is up to date.
3. Make sure it's an issue with Cinny and not something else you are using.
4. Remember to be friendly.
- type: textarea
id: description
attributes:
label: Describe the bug
description: A clear description of what the bug is. Include screenshots if applicable.
placeholder: Bug description
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction
description: Steps to reproduce the behavior.
placeholder: |
1. Go to ...
2. Click on ...
3. See error
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: A clear description of what you expected to happen.
- type: textarea
id: info
attributes:
label: Platform and versions
description: "Provide OS, browser and Cinny version with your Homeserver."
placeholder: |
1. OS: [e.g. Windows 10, MacOS]
2. Browser: [e.g. chrome 99.5, firefox 97.2]
3. Cinny version: [e.g. 1.8.1 (app.cinny.in)]
4. Matrix homeserver: [e.g. matrix.org]
render: shell
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context about the problem here.
+3 -4
View File
@@ -1,5 +1,4 @@
blank_issues_enabled: false
contact_links: contact_links:
- name: Features, Bug Reports, Questions - name: 💬 Matrix Chat
url: https://github.com/cinnyapp/cinny/discussions/new/choose url: https://matrix.to/#/#cinny:matrix.org
about: Our preferred starting point if you have any questions or suggestions about features or behavior. about: Ask questions and talk to other Cinny users and the maintainers
@@ -0,0 +1,33 @@
name: 💡 Feature Request
description: Suggest an idea
body:
- type: textarea
id: problem
attributes:
label: Describe the problem
description: A clear description of the problem this feature would solve
placeholder: "I'm always frustrated when..."
validations:
required: true
- type: textarea
id: solution
attributes:
label: "Describe the solution you'd like"
description: A clear description of what change you would like
placeholder: "I would like to..."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: "Any alternative solutions you've considered"
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context about the problem here.
-9
View File
@@ -1,9 +0,0 @@
---
name: Pre-Discussed and Approved Topics
about: |-
Only for topics already discussed and approved in the GitHub Discussions section.
---
**DO NOT OPEN A NEW ISSUE. PLEASE USE THE DISCUSSIONS SECTION.**
**I DIDN'T READ THE ABOVE LINE. PLEASE CLOSE THIS ISSUE.**
+22
View File
@@ -0,0 +1,22 @@
<!-- Please read https://github.com/ajbura/cinny/blob/dev/CONTRIBUTING.md before submitting your pull request -->
### Description
<!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
Fixes #
#### Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
### Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
+3
View File
@@ -0,0 +1,3 @@
# Reporting a Vulnerability
**If you've found a security vulnerability, please report it to cinnyapp@gmail.com**
+14 -14
View File
@@ -2,29 +2,29 @@
version: 2 version: 2
updates: updates:
# - package-ecosystem: npm # - package-ecosystem: npm
# directory: / # directory: /
# schedule: # schedule:
# interval: weekly # interval: weekly
# day: "tuesday" # day: "tuesday"
# time: "01:00" # time: "01:00"
# timezone: "Asia/Kolkata" # timezone: "Asia/Kolkata"
# open-pull-requests-limit: 15 # open-pull-requests-limit: 15
- package-ecosystem: github-actions - package-ecosystem: github-actions
directory: / directory: /
schedule: schedule:
interval: weekly interval: weekly
day: 'tuesday' day: "tuesday"
time: '01:00' time: "01:00"
timezone: 'Asia/Kolkata' timezone: "Asia/Kolkata"
open-pull-requests-limit: 5 open-pull-requests-limit: 5
- package-ecosystem: docker - package-ecosystem: docker
directory: / directory: /
schedule: schedule:
interval: weekly interval: weekly
day: 'tuesday' day: "tuesday"
time: '01:00' time: "01:00"
timezone: 'Asia/Kolkata' timezone: "Asia/Kolkata"
open-pull-requests-limit: 5 open-pull-requests-limit: 5
+1 -9
View File
@@ -3,20 +3,12 @@
"extends": [ "extends": [
"config:recommended", "config:recommended",
":dependencyDashboardApproval", ":dependencyDashboardApproval",
":semanticCommits", ":semanticCommits"
"group:monorepos"
], ],
"labels": ["Dependencies"], "labels": ["Dependencies"],
"rebaseWhen": "conflicted",
"packageRules": [ "packageRules": [
{ {
"matchUpdateTypes": ["lockFileMaintenance"] "matchUpdateTypes": ["lockFileMaintenance"]
},
{
"matchPackageNames": ["slate", "slate-dom", "slate-history", "slate-react"]
},
{
"matchPackageNames": ["linkifyjs", "linkify-react"]
} }
], ],
"lockFileMaintenance": { "lockFileMaintenance": {
+4 -4
View File
@@ -14,9 +14,9 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup node - name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version-file: '.node-version' node-version-file: ".node-version"
package-manager-cache: false package-manager-cache: false
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -25,7 +25,7 @@ jobs:
NODE_OPTIONS: '--max_old_space_size=4096' NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build run: npm run build
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: preview name: preview
path: dist path: dist
@@ -33,7 +33,7 @@ jobs:
- name: Save pr number - name: Save pr number
run: echo ${PR_NUMBER} > ./pr.txt run: echo ${PR_NUMBER} > ./pr.txt
- name: Upload pr number - name: Upload pr number
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: pr name: pr
path: ./pr.txt path: ./pr.txt
+8 -14
View File
@@ -1,9 +1,9 @@
name: Deploy PR to Netlify name: Deploy PR to Netlify
run-name: 'Deploy PR to Netlify (${{ github.event.workflow_run.head_branch }})' run-name: "Deploy PR to Netlify (${{ github.event.workflow_run.head_branch }})"
on: on:
workflow_run: workflow_run:
workflows: ['Build pull request'] workflows: ["Build pull request"]
types: [completed] types: [completed]
jobs: jobs:
@@ -16,22 +16,16 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }} if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps: steps:
- name: Download pr number - name: Download pr number
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21 uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
with: with:
workflow: ${{ github.event.workflow.id }} workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }} run_id: ${{ github.event.workflow_run.id }}
name: pr name: pr
- name: Validate and output pr number - name: Output pr number
id: pr id: pr
run: | run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
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}"
- name: Download artifact - name: Download artifact
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21 uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
with: with:
workflow: ${{ github.event.workflow.id }} workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }} run_id: ${{ github.event.workflow_run.id }}
@@ -42,13 +36,13 @@ jobs:
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0 uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
with: with:
publish-dir: dist publish-dir: dist
deploy-message: 'Deploy PR ${{ steps.pr.outputs.id }}' deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}"
alias: ${{ steps.pr.outputs.id }} alias: ${{ steps.pr.outputs.id }}
# These don't work because we're in workflow_run # These don't work because we're in workflow_run
enable-pull-request-comment: false enable-pull-request-comment: false
enable-commit-comment: false enable-commit-comment: false
env: 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 }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
timeout-minutes: 1 timeout-minutes: 1
- name: Comment preview on PR - name: Comment preview on PR
+3 -3
View File
@@ -26,7 +26,7 @@ jobs:
- name: Login to Docker Hub #Do not update this action from a outside PR - name: Login to Docker Hub #Do not update this action from a outside PR
if: github.event.pull_request.head.repo.fork == false if: github.event.pull_request.head.repo.fork == false
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
@@ -34,7 +34,7 @@ jobs:
- name: Login to the Github Container registry #Do not update this action from a outside PR - name: Login to the Github Container registry #Do not update this action from a outside PR
if: github.event.pull_request.head.repo.fork == false if: github.event.pull_request.head.repo.fork == false
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -50,7 +50,7 @@ jobs:
ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}
- name: Build Docker image (no push) - name: Build Docker image (no push)
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with: with:
context: . context: .
platforms: linux/amd64 platforms: linux/amd64
+2 -2
View File
@@ -13,9 +13,9 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup node - name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version-file: '.node-version' node-version-file: ".node-version"
package-manager-cache: false package-manager-cache: false
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
+6 -6
View File
@@ -12,9 +12,9 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup node - name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version-file: '.node-version' node-version-file: ".node-version"
package-manager-cache: false package-manager-cache: false
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -52,7 +52,7 @@ jobs:
gpg --export | xxd -p gpg --export | xxd -p
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
- name: Upload tagged release - name: Upload tagged release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
with: with:
files: | files: |
cinny-${{ steps.vars.outputs.tag }}.tar.gz cinny-${{ steps.vars.outputs.tag }}.tar.gz
@@ -72,12 +72,12 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to Docker Hub #Do not update this action from a outside PR - name: Login to Docker Hub #Do not update this action from a outside PR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to the Github Container registry #Do not update this action from a outside PR - name: Login to the Github Container registry #Do not update this action from a outside PR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -90,7 +90,7 @@ jobs:
${{ secrets.DOCKER_USERNAME }}/cinny ${{ secrets.DOCKER_USERNAME }}/cinny
ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
+1 -1
View File
@@ -4,4 +4,4 @@ node_modules
devAssets devAssets
.DS_Store .DS_Store
.ideapackage-lock.json .idea
-3
View File
@@ -1,3 +0,0 @@
# These are commented until we enable lint and typecheck
# npx tsc -p tsconfig.json --noEmit
# npx lint-staged
+10 -10
View File
@@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
Examples of behavior that contributes to a positive environment for our Examples of behavior that contributes to a positive environment for our
community include: community include:
- Demonstrating empathy and kindness toward other people * Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences * Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback * Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes, * Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience and learning from the experience
- Focusing on what is best not just for us as individuals, but for the * Focusing on what is best not just for us as individuals, but for the
overall community overall community
Examples of unacceptable behavior include: Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or * The use of sexualized language or imagery, and sexual attention or
advances of any kind advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks * Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment * Public or private harassment
- Publishing others' private information, such as a physical or email * Publishing others' private information, such as a physical or email
address, without their explicit permission address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a * Other conduct which could reasonably be considered inappropriate in a
professional setting professional setting
## Enforcement Responsibilities ## Enforcement Responsibilities
+4 -7
View File
@@ -5,7 +5,6 @@ First off, thanks for taking the time to contribute! ❤️
All types of contributions are encouraged and valued. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 All types of contributions are encouraged and valued. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: > And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
>
> - Star the project > - Star the project
> - Tweet about it (tag @cinnyapp) > - Tweet about it (tag @cinnyapp)
> - Refer this project in your project's readme > - Refer this project in your project's readme
@@ -19,8 +18,7 @@ Bug reports and feature suggestions must use descriptive and concise titles and
## Pull requests ## Pull requests
> ### Legal Notice > ### Legal Notice
> > When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license.
> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. You will also be asked to [sign the CLA](https://github.com/cinnyapp/cla) upon submiting your pull request.
**NOTE: If you want to add new features, please discuss with maintainers before coding or opening a pull request.** This is to ensure that we are on same track and following our roadmap. **NOTE: If you want to add new features, please discuss with maintainers before coding or opening a pull request.** This is to ensure that we are on same track and following our roadmap.
@@ -28,9 +26,9 @@ Bug reports and feature suggestions must use descriptive and concise titles and
Example: Example:
| Not ideal | Better | |Not ideal|Better|
| ----------------------------------- | --------------------------------------------- | |---|----|
| Fixed markAllAsRead in RoomTimeline | Fix read marker when paginating room timeline | |Fixed markAllAsRead in RoomTimeline|Fix read marker when paginating room timeline|
It is not always possible to phrase every change in such a manner, but it is desired. It is not always possible to phrase every change in such a manner, but it is desired.
@@ -41,7 +39,6 @@ Also, we use [ESLint](https://eslint.org/) for clean and stylistically consisten
**For any query or design discussion, join our [Matrix room](https://matrix.to/#/#cinny:matrix.org).** **For any query or design discussion, join our [Matrix room](https://matrix.to/#/#cinny:matrix.org).**
## Helpful links ## Helpful links
- [BEM methodology](http://getbem.com/introduction/) - [BEM methodology](http://getbem.com/introduction/)
- [Atomic design](https://bradfrost.com/blog/post/atomic-web-design/) - [Atomic design](https://bradfrost.com/blog/post/atomic-web-design/)
- [Matrix JavaScript SDK documentation](https://matrix-org.github.io/matrix-js-sdk/index.html) - [Matrix JavaScript SDK documentation](https://matrix-org.github.io/matrix-js-sdk/index.html)
+1 -1
View File
@@ -11,7 +11,7 @@ RUN npm run build
## App ## App
FROM nginx:1.29.8-alpine FROM nginx:1.29.5-alpine
COPY --from=builder /src/dist /app COPY --from=builder /src/dist /app
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf
-1295
View File
File diff suppressed because it is too large Load Diff
+95 -264
View File
@@ -1,280 +1,111 @@
# 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). <img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380">
--- ## Getting started
The web app is available at [app.cinny.in](https://app.cinny.in/) and gets updated on each new release. The `dev` branch is continuously deployed at [dev.cinny.in](https://dev.cinny.in) but keep in mind that it could have things broken.
## Changes from upstream Cinny You can also download our desktop app from the [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop).
### Branding & Identity ## Self-hosting
To host Cinny on your own, simply download the tarball from [GitHub releases](https://github.com/cinnyapp/cinny/releases/latest), and serve the files from `dist/` using your preferred webserver. Alternatively, you can just pull the docker image from [DockerHub](https://hub.docker.com/r/ajbura/cinny) or [GitHub Container Registry](https://github.com/cinnyapp/cinny/pkgs/container/cinny).
- Package renamed to `lotus-chat`, description updated to "Lotus Chat — Matrix client for Lotus Guild" * The default homeservers and explore pages are defined in [`config.json`](config.json).
- 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
### LotusGuild Terminal Design System (TDS) v1.2 * You need to set up redirects to serve the assests. Example configurations; [netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf), [caddy](contrib/caddy/caddyfile).
* If you have trouble configuring redirects you can [enable hash routing](config.json#L35) — the url in the browser will have a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/` instead of `app.cinny.in/home/`) but you won't have to configure your webserver.
A full custom theme engine layered on top of Cinny's vanilla-extract theming: * To deploy on subdirectory, you need to rebuild the app youself after updating the `base` path in [`build.config.ts`](build.config.ts).
* For example, if you want to deploy on `https://cinny.in/app`, then set `base: '/app'`.
**Dark mode** (`LotusTerminalTheme`): <details><summary><b>PGP Public Key to verify tarball</b></summary>
- 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
**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
**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
### Voice / Video Call Improvements
- **Element Call 0.19.4**: 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.
- **Call embed positioning**: `useCallEmbedPlacementSync` uses `getBoundingClientRect()` (not `offsetTop/Left`) for accurate viewport-relative coordinates on the `position:fixed` container. Position is synced immediately on mount via `useEffect` in addition to the ResizeObserver, so the embed is placed correctly the instant the call view renders. The `[pipMode, callVisible]` effect in `CallEmbedProvider` only clears pip-specific styles when actually exiting pip mode — no longer clobbers the position set by `syncCallEmbedPlacement` on every `callVisible` toggle.
- **Dark mode in element-call**: After joining, `CallEmbed.applyStyles()` injects `:root { color-scheme: dark|light }` into the iframe document so `@media (prefers-color-scheme)` rules inside element-call resolve to the correct Cinny theme regardless of the OS system preference. `themeKind` is stored on the `CallEmbed` instance and updated on every `setTheme()` call, so live theme switching also re-injects the CSS. Without this, users with OS light mode would see a white background even when Cinny is in dark mode.
- **Call embed wallpaper**: The user's `chatBackground` pattern (Blueprint, Carbon, Stars…) is applied as the `backgroundImage`/`backgroundColor` of `div[data-call-embed-container]` when the call is in full view (not PiP). The iframe `html, body` is forced to `background: none !important` so the pattern shows through. When `chatBackground` is `none`, behaviour is unchanged.
### Moderation
- **Report Room**: A "Report Room" option in the room header menu (⋮) allows users to report a room to homeserver admins with a reason and abuse category (Spam / Harassment / Inappropriate Content / Other). Calls `POST /_matrix/client/v3/rooms/{roomId}/report` (MSC4151, confirmed supported on matrix.lotusguild.org). Implemented in `ReportRoomModal.tsx` with loading/success/error states.
- **Policy List / Ban List Viewer (MSC2313)**: A "Policy Lists" tab in Room / Space Settings (admin-only, power-level gated) shows all subscribed `m.policy.rule.*` rooms and their contents — banned users, banned rooms, and banned servers — each with entity, reason, and recommendation fields. Subscribe (join the policy room) and Unsubscribe (leave) actions are provided. Enforcement remains solely with the Draupnir bot; this UI is a read-only complement.
### Messaging Enhancements
- **Rich room topics**: Room topics that contain formatted text (bold, links, italic) are now rendered with full HTML formatting. Falls back to plain text if no `formatted_body` is present. Activates when any room admin sets a formatted topic.
- **Edit history viewer**: Clicking the "edited" label on any edited message opens a modal showing every prior version with timestamps. Fetches all `m.replace` relations for the event and displays them oldest-to-newest. Previously the "edited" label was visible but unclickable. E2EE fix: the "Original" entry now uses `getClearContent()` (bypasses the replacing-event chain, returns the decrypted pre-edit body) instead of `event.content` which is still raw ciphertext for encrypted messages — fixes "(no text)" shown for almost all E2EE message originals.
- **Inline GIF preview**: Giphy and Tenor share links sent as plain text auto-embed as animated GIFs inline in the timeline. URL patterns are detected client-side; the image is fetched via the homeserver's `/_matrix/media/v3/preview_url` proxy (no direct contact with Giphy/Tenor from the client). Rendered as `<img loading="lazy">` — respects the existing URL preview enabled/disabled setting.
- **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.
- **Draft persistence**: Unsent message drafts survive page reload via `localStorage` (`draft-msg-<roomId>`). Jotai in-memory atom is primary; localStorage is used as fallback on reload and cleared on send.
- **Message search date range**: From/To date pickers in the search filter bar. Sends `from_ts`/`to_ts` epoch ms to the Matrix `/search` endpoint. Chip shows active range with X to clear.
- **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`.
- **Message bookmarks / saved messages**: Right-click any message → "Bookmark" / "Remove Bookmark". A star icon in the sidebar nav opens `BookmarksPanel.tsx` — a right-side panel listing all saved messages with room name, preview text, relative timestamp, filter input, "Jump to message" deep-link, and individual remove buttons. Stored in `io.lotus.bookmarks` account data (max 500 entries); syncs across devices. Implemented in `src/app/features/bookmarks/BookmarksPanel.tsx` + `src/app/hooks/useBookmarks.ts`.
- **Message scheduling (MSC4140)**: Clock button next to send opens `ScheduleMessageModal.tsx` with a message textarea and datetime picker. Messages sent via the MSC4140 delayed events API (`org.matrix.msc4140`), confirmed supported on `matrix.lotusguild.org`. A collapsible tray above the composer lists pending scheduled messages with Cancel buttons. Utilities in `src/app/utils/scheduledMessages.ts`.
- **Richer link preview cards**: `UrlPreviewCard.tsx` renders domain-specific cards for 13 sites: YouTube (thumbnail + ▶ play overlay), Vimeo, GitHub (repo parse), Twitter/X (tweet text + media parse), Reddit (subreddit + upvotes + comments), Spotify (artwork), Twitch (LIVE badge + game), Steam, Wikipedia, Discord (server invite), npm, Stack Overflow, and IMDb (poster). Generic cards gain a favicon from Google's S2 service. Cards that produce no renderable content are suppressed.
- **File upload compression (opt-in)**: JPEG and PNG files in the upload preview show a "Compress" checkbox. When checked, a Canvas API call (`toBlob(..., 'image/jpeg', 0.82)`) compresses the image client-side. Original and compressed sizes are shown side-by-side. Compression is strictly opt-in — unchecked by default, skipped for GIF/SVG/WebP.
### Room Customization
- **Personal room name overrides**: Right-click any room in the sidebar → "Rename for me…" to set a local display name visible only to you. Other members see the original name unchanged. A small pencil icon marks rooms with a custom local name. Stored in Matrix account data (`io.lotus.room_names`). Uses `io.lotus.room_names` account data key (based on MSC4431).
- **Export room history**: Room Settings → Export tab. Supports Plain Text, JSON, and HTML formats with optional start/end date range filters. Paginates backwards via `mx.paginateEventTimeline()` with a live progress counter. E2EE-aware — events that failed decryption are skipped rather than exported as garbled ciphertext. Downloads via `Blob` + `<a download>`. Implemented in `src/app/features/room-settings/ExportRoomHistory.tsx`.
- **Room activity / mod log**: Room Settings → Activity tab. Filterable log of `m.room.member` (join/leave/kick/ban/unban/invite), `m.room.power_levels`, `m.room.name`, `m.room.topic`, `m.room.avatar`, and `m.room.server_acl` events. Human-readable descriptions, relative timestamps, type-filter dropdown, Load More pagination, and auto-paginate on mount. Implemented in `src/app/features/room-settings/RoomActivityLog.tsx`.
- **Server ACL editor**: Room Settings → Server ACL tab. Reads and writes `m.room.server_acl` state events. Editable allow and deny server lists with wildcard pattern validation (`*.example.com`). "Allow IP literal addresses" toggle. Read-only view shown to users without the required power level. Implemented in `src/app/features/room-settings/RoomServerACL.tsx`.
- **Room stats / insights**: Room Settings → Insights tab (not the default tab). Derives all statistics from the local timeline cache only; a disclaimer banner clarifies this. Shows top 5 active members (bar chart), top 5 reactions (emoji chips), media breakdown (images/videos/audio/files tiles), and a 24-hour activity heatmap (CSS bar chart). Implemented in `src/app/features/room-settings/RoomInsights.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`.
### Room Customization
- **Room emoji prefix**: A leading emoji in a room name (e.g. 🎮 general) renders at 1.15× size in the sidebar for visual impact. Matrix room names already support Unicode — this is purely a rendering enhancement in `RoomNavItem.tsx`. All three room-name inputs (Create Room, Room Settings, "Rename for me…" dialog) now include a 😊 emoji picker button that prepends the selected emoji to the name field.
### Presence
- **Discord-style presence selector**: Clicking your avatar in the bottom-left sidebar opens a popout with five status options — Online (green), Idle (yellow), Do Not Disturb (red, broadcasts `unavailable` with `status_msg: 'dnd'`), Invisible (grey outline, broadcasts `offline`), and Auto (activity-tracking, the original behaviour). The selected status persists across reloads via the settings atom. A colored badge on the avatar reflects the current status at a glance. `usePresenceUpdater` short-circuits immediately for manual modes; full idle-timer and visibility-change logic only runs in Auto mode. Settings also exposed via `src/app/state/settings.ts` (`presenceStatus` field).
- **Custom status message**: Set a short status text (up to 64 characters) with an emoji picker, shown below your display name in member lists and presence displays. Accessible via Settings → Account → Profile. Includes an **auto-clear timer** (options: 30 minutes, 1 hour, 4 hours, 1 day, 3 days, 7 days) — after the timer expires, the status is automatically cleared by setting `status_msg: ''` via `mx.setPresence`. A character counter (shown when ≥ 56/64 chars) prevents overflow. Implemented in `src/app/features/settings/account/Profile.tsx`.
- **Presence badges on members**: Online/busy/away dots shown next to users in the room members drawer and settings members panel (`PresenceBadge` component from `src/app/components/presence/Presence.tsx`).
- **Presence avatar border ring**: A 2px colored `outline` ring on user avatars throughout the app shows presence at a glance — green (online), yellow (idle), red (DND), no ring (offline). Implemented as `PresenceRingAvatar` component (`src/app/components/presence/PresenceRingAvatar.tsx`) using `React.cloneElement` to inject `outline` + `outlineOffset` directly onto the child `Avatar` element — the ring follows the avatar's actual `border-radius` regardless of shape. Applied to: message timeline sender avatars, members drawer, @mention autocomplete, and inbox notification senders.
- **Document title unread count**: Tab title updates to `(N) Lotus Chat` for mentions, `· Lotus Chat` for unreads, `Lotus Chat` when clear.
- **Extended profile fields (MSC4133)**: Settings → Account → Profile includes Pronouns (`m.pronouns`) and Timezone (`m.tz`) fields, saved via MSC4133 `PUT /_matrix/client/unstable/uk.tcpip.msc4133/{userId}/{field}`. Both fields are displayed in user profile panels. Implemented via `src/app/hooks/useExtendedProfile.ts`.
- **User local time in profile**: When a user has `m.tz` set, their profile panel shows a clock icon, their current local time, and the timezone abbreviation (e.g. EST, JST). Updates every 60 seconds. Respects the viewer's `hour24Clock` setting. Implemented via `src/app/hooks/useLocalTime.ts`.
### UX & Composer
- **Message length counter**: A muted character counter appears just left of the send button while typing, disappearing when the composer is empty. Resets on room switch.
- **Quick emoji reactions on hover**: The 3 most-recently-used emoji reactions appear directly in the message hover toolbar (between the emoji-board button and Reply), so reacting requires a single click rather than opening the 3-dots menu first. Clicking a quick-reaction also closes any open emoji picker. Powered by `useRecentEmoji` sourced from Matrix account data.
- **In-app notification toasts**: When a message or invite notification fires and the browser window is focused, a slim TDS-styled toast card slides in from the bottom-right instead of triggering an OS notification. Card shows: 24px avatar (initials fallback), sender name in orange, truncated message body, room name, × dismiss, 4 s auto-dismiss. Clicking navigates directly to the correct room (DM or home path) or the invites inbox. OS notifications are unchanged when the window is not focused. Implemented in `src/app/features/toast/LotusToastContainer.tsx` + `src/app/state/toast.ts`.
- **Collapsible long messages**: Messages exceeding ~20 lines are auto-collapsed with a "Read more ↓" button. Click to expand inline; a "Collapse ↑" button re-folds. Threshold (in lines) configurable in Settings → Appearance. Uses CSS `max-height` + `overflow: hidden` — works correctly with code blocks and embedded media. Respects `prefers-reduced-motion`.
- **Message send animation**: Own sent messages fade and scale into the timeline (0.15 s ease-out: `scale(0.97)→scale(1)`, `opacity 0.4→1`). Incoming messages are unaffected. Respects `prefers-reduced-motion`.
- **Right-click room context menu**: Expanded sidebar room context menu — **Mute** now opens a duration submenu (15 min / 1 hr / 8 hr / 24 hr / Indefinite) with auto-restore after the selected window; **Copy Room Link** copies the `matrix.to` URL with a "Copied!" flash; **Mark as Read** marks the room read to the latest event; plus Leave Room and Room Settings shortcuts.
- **Unverified device warning**: `warnOnUnverifiedDevices` setting (default off). When enabled via Settings → General → Privacy, a warning banner appears above the composer in encrypted rooms that contain unverified devices, showing the count. Sending is never blocked — the banner is informational only. Uses the existing `useUnverifiedDeviceCount()` hook.
- **Sidebar room filter**: A search-icon input at the top of the Home and DMs sidebar tabs filters rooms by display name in real time. Clears on tab switch. Styled to match the members-drawer search bar (`size="400"`, search prefix icon).
- **DM last message preview**: Each DM row in the sidebar shows a truncated message body (48 chars) and relative timestamp (`Xm`, `Xhr`, `Yesterday`, `D MMM`) below the room name, sourced reactively from `useRoomLatestRenderedEvent`. Encrypted rooms show "Encrypted message" only on actual decryption failure.
- **Room sort order**: Sort icon in the Rooms sidebar header lets users sort non-space rooms by Recent Activity (default), A→Z, or Unread First. Persists via `homeRoomSort` setting.
- **Favorite rooms**: Right-click any room → "Add to Favorites" / "Remove from Favorites". Favorited rooms (using the standard Matrix `m.favourite` tag) appear in a collapsible "Favorites" section above the main room list on the Home tab. Syncs across devices via account data.
- **Poll creation**: Polls can be created directly from the composer — `Icons.OrderList` button opens a modal with question field, 210 answer options (add/remove), and Single/Multiple choice toggle. Sends a stable `m.poll.start` event. (Poll display & voting were already supported.)
- **Voice message playback speed**: `0.75×``1×``1.5×``2×` speed toggle pill on voice message player — cycles on click via `playbackRate` on the `<audio>` element.
- **Invite link + QR code**: Room settings → General shows a "Share Room" tile with the `matrix.to` invite URL and a QR code. The Invite modal also has a `⊞` toggle button showing a QR panel when clicked. Both use `api.qrserver.com` (added to CSP on LXC 106).
- **Private read receipts**: Settings → General → Privacy — "Private Read Receipts" toggle. When on, sends `m.read.private` instead of `m.read` so other room members can't see when you've read messages.
- **Media gallery**: A right-side drawer (photo icon in room header, Desktop only) showing Images | Videos | Files tabs. Reads already-decrypted timeline events — works in E2EE rooms. Encrypted-blob images show a lock-icon placeholder. Load More paginates backwards via `mx.paginateEventTimeline()`.
- **Knock-to-join**: When a room's join rule is `knock`, RoomIntro shows "Request to Join" (calls `mx.knockRoom()`) with "Request sent" pending state. Room admins see a "Pending Requests" section in the members drawer with Approve / Deny buttons.
- **Code syntax highlighting** (TDS mode): Fenced code blocks in messages highlight keywords (cyan), strings (green), numbers (orange), comments (italic dim), function names (purple) using inline `--lt-accent-*` CSS variables. Custom tokenizer in `syntaxHighlight.ts` — supports JS/TS/JSX/TSX, Python, Rust. Falls back to ReactPrism for other languages.
### Settings (Appearance)
- **Animated Chat Backgrounds**: Five CSS-only animated wallpapers added to the background picker — Digital Rain (two-layer vertical stripe scroll with parallax), Star Drift (three-layer radial-gradient star field drifting diagonally), Grid Pulse (neon grid lines expanding/contracting via `backgroundSize` keyframe), Aurora Flow (four radial gradient ellipses sweeping across a 200% canvas), Fireflies (three layers of glowing dots drifting). All use vanilla-extract `keyframes()` — no canvas, GPU-composited. Respects `prefers-reduced-motion: reduce` (animation stripped at call time). "Pause Background Animations" toggle in Settings → Appearance provides an in-app override. Implemented in `src/app/styles/Animations.css.ts` + `src/app/features/lotus/chatBackground.ts`.
- **Glassmorphism Sidebar**: Settings → Appearance toggle (off by default). When enabled, the left sidebar becomes semi-transparent (`background: rgba(3,5,8,0.55)`) with `backdrop-filter: blur(12px)` so chat background patterns show through as a frosted glass effect. Fix: the active chat background is mirrored onto `document.body` via `useEffect` in `SidebarNav.tsx` so the blur has content to work through (previously the sidebar was a flex sibling with nothing physically behind it). Implemented as a vanilla-extract `SidebarGlass` class applied to the `<Sidebar>` container in `SidebarNav.tsx`.
- **Night Light / Blue Light Filter**: Warm orange overlay (`rgba(255,140,0,N%)`) across the entire UI. Toggle + intensity slider (580%) in Settings → Appearance. `position:fixed; pointer-events:none; z-index:9998`. Persists across sessions.
### Notification Enhancements
- **Custom notification sounds**: `messageSoundId` / `inviteSoundId` settings select per-category notification sound (`notification.ogg`, `invite.ogg`, `call.ogg`, or None). Settings → Notifications expands the sound toggle with Message Sound + Invite Sound selects and ▶ preview buttons. Shared `notificationSounds.ts` module.
- **Notification quiet hours**: `quietHoursEnabled` / `quietHoursStart` / `quietHoursEnd` settings suppress all desktop notifications and sounds during a configured time window. Handles overnight spans (e.g. 23:0008:00). Settings → Notifications: Quiet Hours card with toggle + start/end time pickers.
- **Full push rule editor**: Settings → Notifications → Advanced Push Rules section. Covers override, room, sender, and underride rule kinds. Each row has a human-readable label for built-in rules, an enable/disable toggle, and a delete button for custom rules. An add-rule form at the bottom of the room and sender sections lets users create new per-room or per-sender push rules by entering the room/user ID.
### Calls (Extended)
- **Push-to-Deafen**: Press `M` during a call to toggle speaker mute (deafen). Configurable in Settings → General → Calls alongside the PTT key. Skips editable elements; guards `e.repeat`; uses `el.ownerDocument.body` for iframe safety.
- **TDS typing indicator dots**: When Lotus Terminal mode is active, the animated typing indicator dots turn TDS orange (`var(--lt-accent-orange)`) via `color: currentColor` inheritance.
### Server Integration
- **Server support contact (MSC1929)**: Settings → Help & About displays the homeserver admin contact fetched from `/.well-known/matrix/support`. Shows the admin's Matrix ID and a link to the support page when the homeserver has configured this endpoint. Degrades gracefully when not configured (section is hidden on 404 or network error). In TDS mode the contact text and link render in `--lt-accent-cyan`. Implemented in `src/app/features/settings/about/About.tsx`.
- **Server notices**: Rooms of type `m.server_notice` (system messages from the homeserver) now render with a distinct "Server Notice" `<Chip variant="Warning">` badge in the room header and a disabled composer showing "This is a server notice room — you cannot send messages here." Previously indistinguishable from regular DMs. Badge in `src/app/features/room/RoomViewHeader.tsx`; composer guard in `src/app/features/room/RoomInput.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.
- **Rolldown CJS interop — millify**: `src/app/plugins/millify.ts` uses a named import (`import { millify as millifyPlugin } from 'millify'`) instead of a default import. Rolldown's `__toESM` helper with `mode=1` sets `a.default = module_object` (not the function itself) when `hasOwnProperty` prevents the copy — calling `millifyPlugin()` would throw `(0, zc.default) is not a function`. Named import bypasses the interop entirely.
- **Sentry noise filter**: `ignoreErrors: ['Request timed out']` added to `Sentry.init` in `src/index.tsx` to suppress unhandled rejections from the matrixRTC delayed-event heartbeat (matrix-sdk) and the widget PostmessageTransport initial-load race (matrix-widget-api). Neither is actionable from client code.
- **URL preview default in encrypted rooms**: `encUrlPreview` default changed from `false` to `true` in `src/app/state/settings.ts`. A security note is shown next to the toggle in Settings → General explaining that the homeserver fetches the URL (and sees it) but not the message content.
---
## Build
```bash
npm ci
npm run build # outputs to dist/
```
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
```
## Development workflow
All code changes should be made in the local clone at `/root/code/cinny` on the dev box, then committed and pushed to `origin/lotus`. The CI/CD pipeline handles everything from there — no manual build or deploy steps needed.
``` ```
edit → commit → git push # ~11 minutes → auto-deployed to chat.lotusguild.org -----BEGIN PGP PUBLIC KEY BLOCK-----
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>
## Local development
> [!TIP]
> We recommend using a version manager as versions change very quickly. You will likely need to switch between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Krypton LTS (v24.13.1).
Execute the following commands to start a development server:
```sh
npm ci # Installs all dependencies
npm start # Serve a development version
``` ```
Pipeline (`.gitea/workflows/ci.yml` + `lotus_deploy.sh` on LXC 106): To build the app:
1. Push triggers a Gitea Actions build — TypeScript check, ESLint, Prettier, bundle size report ```sh
2. Build must pass as the CI gate; quality checks are informational (`continue-on-error`) npm run build # Compiles the app into the dist/ directory
3. A Gitea webhook fires `lotus_deploy.sh` on LXC 106, which polls the API until CI passes (up to 15 min), then pulls `origin/lotus`, runs `npm ci && npm run build`, and rsyncs to `/var/www/html/`
LXC 106's stored Gitea credential is **read-only** — it can only pull. Pushes must be done from the dev box with your personal credentials (entered manually, never cached).
## 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>"
}
``` ```
## Key Custom Files ### 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 .
```
| File | Purpose | You can then run the container you've built with a command similar to this:
|------|---------| ```
| `src/lotus-terminal.css.ts` | All TDS CSS tokens, global styles, light/dark variants | docker run -p 8080:80 cinny:latest
| `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 | 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`.
| `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, call wallpaper carry-over |
| `src/app/plugins/call/CallEmbed.ts` | EC widget bridge: iframe setup, `color-scheme` dark/light injection, built-in control hiding, theme sync |
| `src/app/plugins/millify.ts` | Named import fix for Rolldown CJS interop (prevents `zc.default is not a function` crash) |
| `src/app/features/room/MediaGallery.tsx` | Right-side media gallery drawer (images/videos/files) |
| `src/app/features/room/PollCreator.tsx` | Poll creation modal (single/multiple choice, 210 options) |
| `src/app/features/common-settings/general/RoomShareInvite.tsx` | Invite link + QR code tile for room settings |
| `src/app/utils/syntaxHighlight.ts` | TDS code syntax tokenizer (JS/TS/Python/Rust → inline CSS vars) |
| `src/app/features/room-settings/ExportRoomHistory.tsx` | Export room messages to plain text / JSON / HTML with date range filter and E2EE awareness |
| `src/app/features/room-settings/RoomActivityLog.tsx` | Filterable mod log of room state events (joins, kicks, bans, power level changes, etc.) |
| `src/app/features/room-settings/RoomServerACL.tsx` | Server ACL viewer/editor (allow/deny lists, IP literal toggle, power-level gated) |
| `src/app/features/room-settings/RoomInsights.tsx` | Room stats panel: top members bar chart, top reactions, media breakdown, 24h heatmap |
| `src/app/features/bookmarks/BookmarksPanel.tsx` | Saved messages sidebar panel with filter, jump-to-message, and remove |
| `src/app/hooks/useBookmarks.ts` | Read/write `io.lotus.bookmarks` account data for message bookmarks |
| `src/app/features/room/ScheduleMessageModal.tsx` | Schedule-message modal with datetime picker; sends via MSC4140 delayed events API |
| `src/app/utils/scheduledMessages.ts` | Utilities for MSC4140 scheduled message state and cancel endpoint |
| `src/app/hooks/useExtendedProfile.ts` | Read/write MSC4133 extended profile fields (`m.pronouns`, `m.tz`) |
| `src/app/hooks/useLocalTime.ts` | Formats user local time from `m.tz` IANA zone; updates every 60 s |
| `src/app/components/url-preview/UrlPreviewCard.tsx` | Domain-specific URL preview cards for 13 sites + generic favicon fallback |
+23 -8
View File
@@ -1,16 +1,31 @@
{ {
"defaultHomeserver": 0, "defaultHomeserver": 1,
"homeserverList": ["matrix.lotusguild.org"], "homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
"allowCustomHomeservers": false, "allowCustomHomeservers": true,
"featuredCommunities": { "featuredCommunities": {
"openAsDefault": false, "openAsDefault": false,
"spaces": [], "spaces": [
"rooms": [], "#cinny-space:matrix.org",
"servers": [] "#community:matrix.org",
"#space:unredacted.org",
"#science-space:matrix.org",
"#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org"
],
"rooms": [
"#cinny:matrix.org",
"#freesoftware:matrix.org",
"#pcapdroid:matrix.org",
"#gentoo:matrix.org",
"#PrivSec.dev:arcticfoxes.net",
"#disroot:aria-net.org"
],
"servers": ["matrix.org", "mozilla.org", "unredacted.org"]
}, },
"hashRouter": { "hashRouter": {
"enabled": false, "enabled": false,
"basename": "/" "basename": "/"
}, }
"gifApiKey": "AqqDuQwZNjYttz7Mn6ME4JH1bJIuZ5CO"
} }
-126
View File
@@ -1,126 +0,0 @@
import { FlatCompat } from '@eslint/eslintrc';
import js from '@eslint/js';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
import eslintConfigPrettier from 'eslint-config-prettier';
import globals from 'globals';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default [
{ ignores: ['node_modules/**', 'dist/**', 'experiment/**'] },
js.configs.recommended,
tsPlugin.configs['flat/eslint-recommended'],
...tsPlugin.configs['flat/recommended'],
reactPlugin.configs.flat.recommended,
reactHooksPlugin.configs.flat['recommended'],
// Register jsx-a11y plugin (rules selectively enabled below)
{ plugins: { 'jsx-a11y': jsxA11yPlugin } },
// airbnb-base via FlatCompat (JS/import rules; no React plugin, no getFilename issue)
...compat.extends('airbnb-base'),
eslintConfigPrettier,
{
languageOptions: {
parser: tsParser,
globals: {
...globals.browser,
...globals.es2021,
JSX: 'readonly',
},
parserOptions: {
ecmaFeatures: { jsx: true },
ecmaVersion: 'latest',
sourceType: 'module',
},
},
settings: {
react: {
version: '18.2.0',
},
},
rules: {
'linebreak-style': 0,
'no-unused-vars': 'off', // handled by @typescript-eslint/no-unused-vars
'no-underscore-dangle': 0,
'no-shadow': 'off',
// Stylistic rules — off for this codebase
'no-console': 'off',
'no-continue': 'off',
'no-nested-ternary': 'off',
'no-plusplus': 'off',
'no-param-reassign': 'off',
'no-restricted-syntax': 'off',
'no-restricted-globals': 'off',
'no-constant-condition': 'off',
'prefer-destructuring': 'off',
'no-useless-assignment': 'off',
'preserve-caught-error': 'off',
'consistent-return': 'off',
'no-use-before-define': 'off',
'import/prefer-default-export': 'off',
'import/extensions': 'off',
'import/no-unresolved': 'off',
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: true,
},
],
'react/no-unstable-nested-components': ['error', { allowAsProps: true }],
'react/jsx-filename-extension': [
'error',
{
extensions: ['.tsx', '.jsx'],
},
],
'react/display-name': 'off',
'react/require-default-props': 'off',
'react/jsx-props-no-spreading': 'off',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
// React Compiler rules added in react-hooks v7 — disabled until React Compiler is adopted
'react-hooks/react-compiler': 'off',
'react-hooks/incompatible-library': 'off',
'react-hooks/refs': 'off',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/set-state-in-render': 'off',
'react-hooks/immutability': 'off',
'react-hooks/purity': 'off',
'react-hooks/use-memo': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
],
'@typescript-eslint/no-shadow': 'error',
'@typescript-eslint/no-explicit-any': 'warn',
// jsx-a11y — media captions not required for this app
'jsx-a11y/media-has-caption': 'off',
'jsx-a11y/no-noninteractive-element-interactions': 'off',
'jsx-a11y/alt-text': 'off',
},
},
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
'no-undef': 'off',
},
},
];
+15 -23
View File
@@ -1,44 +1,36 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<title>Lotus Chat</title> <title>Cinny</title>
<meta name="name" content="Lotus Chat" /> <meta name="name" content="Cinny" />
<meta name="author" content="Lotus Guild" /> <meta name="author" content="Ajay Bura" />
<meta <meta
name="description" 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" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Lotus Chat" />
<meta property="og:url" content="https://chat.lotusguild.org" />
<meta <meta
property="og:image" name="keywords"
content="https://chat.lotusguild.org/public/res/android/android-chrome-192x192.png" content="cinny, cinnyapp, cinnychat, matrix, matrix client, matrix.org, element"
/> />
<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 <meta
property="og:description" 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" /> <meta name="theme-color" content="#000000" />
<meta name="color-scheme" content="dark light" />
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
<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 id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="application-name" content="Lotus Chat" /> <meta name="application-name" content="Cinny" />
<meta name="apple-mobile-web-app-title" content="Lotus Chat" /> <meta name="apple-mobile-web-app-title" content="Cinny" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+4690 -6417
View File
File diff suppressed because it is too large Load Diff
+79 -111
View File
@@ -1,7 +1,7 @@
{ {
"name": "lotus-chat", "name": "cinny",
"version": "4.12.2-lotus", "version": "4.11.0",
"description": "Lotus Chat — Matrix client for Lotus Guild", "description": "Yet another matrix client",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
"engines": { "engines": {
@@ -11,135 +11,103 @@
"start": "vite", "start": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "npm run check:eslint && npm run check:prettier", "lint": "yarn check:eslint && yarn check:prettier",
"check:eslint": "eslint src/*", "check:eslint": "eslint src/*",
"check:prettier": "prettier --check .", "check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .", "fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit"
"prepare": "husky",
"commit": "git-cz",
"postinstall": "node scripts/patch-folds.mjs"
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": "eslint",
"*": "prettier --ignore-unknown --write"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}, },
"keywords": [], "keywords": [],
"author": "Ajay Bura", "author": "Ajay Bura",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.8.1", "@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@eslint/eslintrc": "3.3.5", "@fontsource/inter": "4.5.14",
"@eslint/js": "10.0.1", "@tanstack/react-query": "5.24.1",
"@fontsource-variable/inter": "5.2.8", "@tanstack/react-query-devtools": "5.24.1",
"@giphy/js-fetch-api": "5.8.0", "@tanstack/react-virtual": "3.2.0",
"@giphy/js-types": "5.1.0", "@vanilla-extract/css": "1.9.3",
"@giphy/js-util": "5.2.0", "@vanilla-extract/recipes": "0.3.0",
"@giphy/react-components": "10.1.2", "@vanilla-extract/vite-plugin": "3.7.1",
"@sentry/react": "10.53.1",
"@tanstack/react-query": "5.100.13",
"@tanstack/react-query-devtools": "5.100.13",
"@tanstack/react-virtual": "3.13.25",
"@types/dompurify": "3.2.0",
"await-to-js": "3.0.0", "await-to-js": "3.0.0",
"badwords-list": "2.0.1-4", "badwords-list": "2.0.1-4",
"blurhash": "2.0.5", "blurhash": "2.0.4",
"browser-encrypt-attachment": "0.3.0", "browser-encrypt-attachment": "0.3.0",
"chroma-js": "3.2.0", "chroma-js": "3.1.2",
"classnames": "2.5.1", "classnames": "2.3.2",
"dateformat": "5.0.3", "dateformat": "5.0.3",
"dayjs": "1.11.20", "dayjs": "1.11.10",
"domhandler": "6.0.1", "domhandler": "5.0.3",
"dompurify": "3.4.5", "emojibase": "15.3.1",
"emojibase": "17.0.0", "emojibase-data": "15.3.2",
"emojibase-data": "17.0.0",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"focus-trap-react": "12.0.2", "focus-trap-react": "10.0.2",
"folds": "2.6.2", "folds": "2.6.2",
"globals": "17.6.0", "html-dom-parser": "4.0.0",
"html-dom-parser": "7.1.0", "html-react-parser": "4.2.0",
"html-react-parser": "6.1.2", "i18next": "23.12.2",
"i18next": "26.2.0", "i18next-browser-languagedetector": "8.0.0",
"i18next-browser-languagedetector": "8.2.1", "i18next-http-backend": "2.5.2",
"i18next-http-backend": "4.0.0", "immer": "9.0.16",
"immer": "11.1.8",
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"jotai": "2.20.0", "jotai": "2.6.0",
"linkify-react": "4.3.3", "linkify-react": "4.3.2",
"linkifyjs": "4.3.3", "linkifyjs": "4.3.2",
"lodash": "4.18.1", "matrix-js-sdk": "38.2.0",
"matrix-js-sdk": "41.6.0-rc.0", "matrix-widget-api": "1.13.0",
"matrix-widget-api": "1.17.0",
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "5.7.284", "pdfjs-dist": "4.2.67",
"prismjs": "1.30.0", "prismjs": "1.30.0",
"react": "19.2.6", "react": "18.2.0",
"react-aria": "3.48.0", "react-aria": "3.29.1",
"react-blurhash": "0.3.0", "react-blurhash": "0.2.0",
"react-colorful": "5.7.0", "react-colorful": "5.6.1",
"react-dom": "19.2.6", "react-dom": "18.2.0",
"react-error-boundary": "6.1.1", "react-error-boundary": "4.0.13",
"react-google-recaptcha": "3.1.0", "react-google-recaptcha": "2.1.0",
"react-i18next": "17.0.8", "react-i18next": "15.0.0",
"react-range": "1.10.0", "react-range": "1.8.14",
"react-router-dom": "7.15.1", "react-router-dom": "6.30.3",
"sanitize-html": "2.17.4", "sanitize-html": "2.12.1",
"slate": "0.124.1", "slate": "0.112.0",
"slate-dom": "0.124.1", "slate-dom": "0.112.2",
"slate-history": "0.113.1", "slate-history": "0.110.3",
"slate-react": "0.124.2", "slate-react": "0.112.1",
"styled-components": "6.4.2", "ua-parser-js": "1.0.35"
"ua-parser-js": "2.0.10"
}, },
"devDependencies": { "devDependencies": {
"@element-hq/element-call-embedded": "0.19.4", "@element-hq/element-call-embedded": "0.16.3",
"@rollup/plugin-inject": "5.0.5", "@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@rollup/plugin-wasm": "6.2.2", "@rollup/plugin-inject": "5.0.3",
"@sentry/vite-plugin": "5.3.0", "@rollup/plugin-wasm": "6.1.1",
"@types/chroma-js": "3.1.2", "@types/chroma-js": "3.1.1",
"@types/file-saver": "2.0.7", "@types/file-saver": "2.0.5",
"@types/is-hotkey": "0.1.10", "@types/is-hotkey": "0.1.10",
"@types/node": "25.9.1", "@types/node": "18.11.18",
"@types/prismjs": "1.26.6", "@types/prismjs": "1.26.0",
"@types/react": "19.2.15", "@types/react": "18.2.39",
"@types/react-dom": "19.2.3", "@types/react-dom": "18.2.17",
"@types/react-google-recaptcha": "2.1.9", "@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.39", "@types/ua-parser-js": "0.7.36",
"@typescript-eslint/eslint-plugin": "8.59.4", "@typescript-eslint/eslint-plugin": "5.46.1",
"@typescript-eslint/parser": "8.59.4", "@typescript-eslint/parser": "5.46.1",
"@vanilla-extract/css": "1.20.1", "@vitejs/plugin-react": "4.2.0",
"@vanilla-extract/recipes": "0.5.7",
"@vanilla-extract/vite-plugin": "5.2.2",
"@vitejs/plugin-react": "6.0.2",
"buffer": "6.0.3", "buffer": "6.0.3",
"cz-conventional-changelog": "3.3.0", "eslint": "8.29.0",
"eslint": "9.39.4",
"eslint-config-airbnb": "19.0.4", "eslint-config-airbnb": "19.0.4",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "8.5.0",
"eslint-plugin-import": "2.32.0", "eslint-plugin-import": "2.29.1",
"eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-jsx-a11y": "6.6.1",
"eslint-plugin-react": "7.37.5", "eslint-plugin-react": "7.31.11",
"eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-hooks": "4.6.0",
"husky": "9.1.7", "prettier": "2.8.1",
"lint-staged": "17.0.5", "typescript": "4.9.4",
"prettier": "3.8.3", "vite": "5.4.19",
"typescript": "6.0.3", "vite-plugin-pwa": "0.20.5",
"vite": "8.0.14", "vite-plugin-static-copy": "1.0.4",
"vite-plugin-pwa": "1.3.0", "vite-plugin-top-level-await": "1.4.4"
"vite-plugin-static-copy": "4.1.0"
},
"overrides": {
"@giphy/js-util": {
"dompurify": ">=3.3.4"
},
"js-cookie": ">=3.0.6"
} }
} }
-16
View File
@@ -1,16 +0,0 @@
{
"defaultHomeserver": 0,
"homeserverList": ["matrix.lotusguild.org"],
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [],
"rooms": [],
"servers": []
},
"hashRouter": {
"enabled": false,
"basename": "/"
},
"gifApiKey": "AqqDuQwZNjYttz7Mn6ME4JH1bJIuZ5CO"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 631 B

After

Width:  |  Height:  |  Size: 32 KiB

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

Before

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

+12 -13
View File
@@ -1,14 +1,13 @@
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g fill="#980000" fill-opacity="0.88"> <g clip-path="url(#clip0_2707_1961)">
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(0,9,9)"/> <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="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(45,9,9)"/> <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 d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(90,9,9)"/> <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="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(135,9,9)"/> <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"/>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(180,9,9)"/> </g>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(225,9,9)"/> <defs>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(270,9,9)"/> <clipPath id="clip0_2707_1961">
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(315,9,9)"/> <rect width="18" height="18" fill="white"/>
</g> </clipPath>
<circle cx="9" cy="9" r="2.2" fill="#cc2000"/> </defs>
<circle cx="14.5" cy="14.5" r="3" fill="#45B83B"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 841 B

After

Width:  |  Height:  |  Size: 1.5 KiB

+12 -13
View File
@@ -1,14 +1,13 @@
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g fill="#980000" fill-opacity="0.88"> <g clip-path="url(#clip0_2707_2015)">
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(0,9,9)"/> <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="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(45,9,9)"/> <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 d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(90,9,9)"/> <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="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(135,9,9)"/> <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"/>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(180,9,9)"/> </g>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(225,9,9)"/> <defs>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(270,9,9)"/> <clipPath id="clip0_2707_2015">
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(315,9,9)"/> <rect width="18" height="18" fill="white"/>
</g> </clipPath>
<circle cx="9" cy="9" r="2.2" fill="#cc2000"/> </defs>
<circle cx="14.5" cy="14.5" r="3" fill="#989898"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 841 B

After

Width:  |  Height:  |  Size: 1.5 KiB

+17 -11
View File
@@ -1,13 +1,19 @@
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"> <!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In -->
<g fill="#980000" fill-opacity="0.88"> <svg version="1.1"
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(0,9,9)"/> xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(45,9,9)"/> x="0px" y="0px" width="18px" height="18px" viewBox="0 0 18 18" enable-background="new 0 0 18 18" xml:space="preserve">
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(90,9,9)"/> <defs>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(135,9,9)"/> </defs>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(180,9,9)"/> <g>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(225,9,9)"/> <g>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(270,9,9)"/> <circle fill="#FFFFFF" cx="9" cy="9" r="8.5"/>
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(315,9,9)"/>
</g> </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> </svg>

Before

Width:  |  Height:  |  Size: 788 B

After

Width:  |  Height:  |  Size: 871 B

Binary file not shown.
-26
View File
@@ -1,26 +0,0 @@
import { readFileSync, writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { join, dirname } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const foldsPath = join(__dirname, '../node_modules/folds/dist/index.js');
try {
let content = readFileSync(foldsPath, 'utf8');
// Defensive guard: if src is not a function, render null instead of crashing
const original = 'children: src(filled)';
const patched = 'children: typeof src === "function" ? src(filled) : null';
if (content.includes(patched)) {
console.log('folds patch already applied.');
} else if (content.includes(original)) {
content = content.replace(original, patched);
writeFileSync(foldsPath, content, 'utf8');
console.log('Applied defensive Icon src guard to folds.');
} else {
console.warn('Warning: folds Icon patch target not found - may need updating.');
}
} catch (e) {
console.warn('Warning: Could not patch folds:', e.message);
}
-48
View File
@@ -1,48 +0,0 @@
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const version = process.argv[2];
if (!version) {
console.error('Version argument missing');
process.exit(1);
}
const root = path.resolve(__dirname, '..');
const newVersionTag = `v${version}`;
// Update package.json + package-lock.json safely
execSync(`npm version ${version} --no-git-tag-version`, {
cwd: root,
stdio: 'inherit',
});
console.log(`Updated package.json and package-lock.json → ${version}`);
// Update UI version references
const files = [
'src/app/features/settings/about/About.tsx',
'src/app/pages/auth/AuthFooter.tsx',
'src/app/pages/client/WelcomePage.tsx',
];
files.forEach((filePath) => {
const absPath = path.join(root, filePath);
if (!fs.existsSync(absPath)) {
console.warn(`File not found: ${filePath}`);
return;
}
const content = fs.readFileSync(absPath, 'utf8');
const updated = content.replace(/v\d+\.\d+\.\d+/g, newVersionTag);
fs.writeFileSync(absPath, updated);
console.log(`Updated ${filePath}${newVersionTag}`);
});
+3 -5
View File
@@ -54,7 +54,7 @@ function AccountDataEdit({
const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor( const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor(
textAreaRef, textAreaRef,
EDITOR_INTENT_SPACE_COUNT, EDITOR_INTENT_SPACE_COUNT
); );
const [submitState, submit] = useAsyncCallback<void, MatrixError, [string, object]>(submitChange); const [submitState, submit] = useAsyncCallback<void, MatrixError, [string, object]>(submitChange);
@@ -127,7 +127,6 @@ function AccountDataEdit({
<Input <Input
variant={type.length > 0 || submitting ? 'SurfaceVariant' : 'Background'} variant={type.length > 0 || submitting ? 'SurfaceVariant' : 'Background'}
name="typeInput" name="typeInput"
aria-label="Account data type"
size="400" size="400"
radii="300" radii="300"
readOnly={type.length > 0 || submitting} readOnly={type.length > 0 || submitting}
@@ -171,7 +170,6 @@ function AccountDataEdit({
<TextAreaComponent <TextAreaComponent
ref={textAreaRef} ref={textAreaRef}
name="contentTextArea" name="contentTextArea"
aria-label="JSON content"
style={{ style={{
fontFamily: 'monospace', fontFamily: 'monospace',
}} }}
@@ -278,7 +276,7 @@ export function AccountDataEditor({
const contentJSONStr = useMemo( const contentJSONStr = useMemo(
() => JSON.stringify(data.content, null, EDITOR_INTENT_SPACE_COUNT), () => JSON.stringify(data.content, null, EDITOR_INTENT_SPACE_COUNT),
[data.content], [data.content]
); );
return ( return (
@@ -296,7 +294,7 @@ export function AccountDataEditor({
</Chip> </Chip>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close"> <IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
+2 -2
View File
@@ -45,11 +45,11 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
}; };
return authFlows; return authFlows;
}, [mx]), }, [mx])
); );
useEffect(() => { useEffect(() => {
load().catch(() => {}); load();
}, [load]); }, [load]);
if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) { if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
-71
View File
@@ -1,71 +0,0 @@
import React, { useId } from 'react';
export function AuthSkeleton() {
const id = useId().replace(/:/g, '');
const shimmerKeyframes = `
@keyframes shimmer-${id} {
0% { background-position: -400px 0; }
100% { background-position: 400px 0; }
}
`;
const shimmer = {
background:
'linear-gradient(90deg, var(--skeleton-base) 25%, var(--skeleton-shine) 50%, var(--skeleton-base) 75%)',
backgroundSize: '800px 100%',
animation: `shimmer-${id} 1.6s ease-in-out infinite`,
borderRadius: '4px',
} as React.CSSProperties;
return (
<>
<style>{shimmerKeyframes}</style>
<div
style={
{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100dvh',
padding: '16px',
'--skeleton-base': 'color-mix(in srgb, currentColor 8%, transparent)',
'--skeleton-shine': 'color-mix(in srgb, currentColor 15%, transparent)',
} as React.CSSProperties
}
>
{/* Card */}
<div
style={{
width: '100%',
maxWidth: '360px',
display: 'flex',
flexDirection: 'column',
gap: '24px',
}}
>
{/* Logo + app name */}
<div
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}
>
<div style={{ ...shimmer, width: '64px', height: '64px', borderRadius: '50%' }} />
<div style={{ ...shimmer, width: '100px', height: '20px' }} />
</div>
{/* Server picker */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ ...shimmer, width: '80px', height: '12px' }} />
<div style={{ ...shimmer, width: '100%', height: '40px', borderRadius: '8px' }} />
</div>
{/* Form fields */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ ...shimmer, width: '100%', height: '40px', borderRadius: '8px' }} />
<div style={{ ...shimmer, width: '100%', height: '40px', borderRadius: '8px' }} />
<div style={{ ...shimmer, width: '100%', height: '40px', borderRadius: '8px' }} />
</div>
</div>
</div>
</>
);
}
+5 -5
View File
@@ -24,7 +24,7 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) {
caseSensitive: true, caseSensitive: true,
end: false, end: false,
}, },
location.pathname, location.pathname
) )
) { ) {
navigate(getHomePath()); navigate(getHomePath());
@@ -37,7 +37,7 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) {
caseSensitive: true, caseSensitive: true,
end: false, end: false,
}, },
location.pathname, location.pathname
) )
) { ) {
navigate(getDirectPath()); navigate(getDirectPath());
@@ -49,7 +49,7 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) {
caseSensitive: true, caseSensitive: true,
end: false, end: false,
}, },
location.pathname, location.pathname
); );
const encodedSpaceIdOrAlias = spaceMatch?.params.spaceIdOrAlias; const encodedSpaceIdOrAlias = spaceMatch?.params.spaceIdOrAlias;
const decodedSpaceIdOrAlias = const decodedSpaceIdOrAlias =
@@ -66,7 +66,7 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) {
caseSensitive: true, caseSensitive: true,
end: false, end: false,
}, },
location.pathname, location.pathname
) )
) { ) {
navigate(getExplorePath()); navigate(getExplorePath());
@@ -79,7 +79,7 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) {
caseSensitive: true, caseSensitive: true,
end: false, end: false,
}, },
location.pathname, location.pathname
) )
) { ) {
navigate(getInboxPath()); navigate(getInboxPath());
+1 -2
View File
@@ -157,7 +157,7 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
setRestoreProgress(progress); setRestoreProgress(progress);
}, },
}); });
}, [crypto, setRestoreProgress]), }, [crypto, setRestoreProgress])
); );
const handleRestore = () => { const handleRestore = () => {
@@ -178,7 +178,6 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
)} )}
<IconButton <IconButton
aria-pressed={!!menuCords} aria-pressed={!!menuCords}
aria-label="Backup options"
size="300" size="300"
variant="Surface" variant="Surface"
radii="300" radii="300"
+8 -824
View File
@@ -1,30 +1,6 @@
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import React, { ReactNode, useCallback, useRef } from 'react';
import { useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import FocusTrap from 'focus-trap-react'; import { config } from 'folds';
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 { import {
CallEmbedContextProvider, CallEmbedContextProvider,
CallEmbedRefContextProvider, CallEmbedRefContextProvider,
@@ -32,361 +8,11 @@ import {
useCallJoined, useCallJoined,
useCallThemeSync, useCallThemeSync,
useCallMemberSoundSync, useCallMemberSoundSync,
useCallStart,
} from '../hooks/useCallEmbed'; } from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed'; import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { CallEmbed, useCallControlState } from '../plugins/call'; import { CallEmbed } from '../plugins/call';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; 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 { useRemoteAllMuted } from '../hooks/useCallSpeakers';
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
import { mDirectAtom } from '../state/mDirectList';
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
import { mxcUrlToHttp, getMxIdLocalPart } from '../utils/matrix';
import { RoomAvatar, RoomIcon } from './room-avatar';
import { useRoomNavigate } from '../hooks/useRoomNavigate';
import { getChatBg } from '../features/lotus/chatBackground';
import { useTheme, ThemeKind } from '../hooks/useTheme';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { getStateEvent, getMemberDisplayName } from '../utils/room';
import { StateEvent } from '../../types/matrix/room';
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
import { useLivekitSupport } from '../hooks/useLivekitSupport';
import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc';
const PIP_MIN_W = 200;
const PIP_MIN_H = 112;
type Corner = 'se' | 'sw' | 'ne' | 'nw';
/** Normalise the element to top/left positioning so resize math is uniform. */
function normaliseToTopLeft(el: HTMLElement) {
const rect = el.getBoundingClientRect();
el.style.left = `${rect.left}px`;
el.style.top = `${rect.top}px`;
el.style.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(
(members) => {
if (members.length === 0) {
onIgnore();
}
},
[onIgnore],
),
);
const playSound = useCallback(() => {
const audioElement = audioRef.current;
audioElement?.play().catch(() => undefined);
}, []);
useEffect(() => {
const audioEl = audioRef.current;
if (info.notificationType === 'ring') {
playSound();
}
return () => {
if (audioEl) {
audioEl.pause();
audioEl.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">
{info.intent === 'video' ? 'Incoming Video Call' : 'Incoming Voice 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={dm ? 'Critical' : 'Secondary'}
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 }) { function CallUtils({ embed }: { embed: CallEmbed }) {
const setCallEmbed = useSetAtom(callEmbedAtom); const setCallEmbed = useSetAtom(callEmbedAtom);
@@ -397,49 +23,18 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
embed, embed,
useCallback(() => { useCallback(() => {
setCallEmbed(undefined); setCallEmbed(undefined);
}, [setCallEmbed]), }, [setCallEmbed])
); );
return null; return null;
} }
/** Shown inside the PiP window when the local microphone is muted. */
function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) {
const allMuted = useRemoteAllMuted(callEmbed);
if (!allMuted) return null;
return (
<div
aria-label="Microphone muted"
style={{
position: 'absolute',
bottom: '8px',
left: '8px',
zIndex: 3,
background: 'rgba(0,0,0,0.60)',
backdropFilter: 'blur(4px)',
borderRadius: '6px',
padding: '3px 7px',
display: 'flex',
alignItems: 'center',
gap: '4px',
pointerEvents: 'none',
color: color.Critical.Main,
fontSize: '13px',
lineHeight: 1,
userSelect: 'none',
}}
>
<Icon size="100" src={Icons.MicMute} filled />
</div>
);
}
type CallEmbedProviderProps = { type CallEmbedProviderProps = {
children?: ReactNode; children?: ReactNode;
}; };
export function CallEmbedProvider({ children }: CallEmbedProviderProps) { export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const callEmbed = useAtomValue(callEmbedAtom); const callEmbed = useAtomValue(callEmbedAtom);
const callEmbedRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>; const callEmbedRef = useRef<HTMLDivElement>(null);
const joined = useCallJoined(callEmbed); const joined = useCallJoined(callEmbed);
const selectedRoom = useSelectedRoom(); const selectedRoom = useSelectedRoom();
@@ -447,341 +42,13 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const chatOnlyView = chat && screenSize !== ScreenSize.Desktop; 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 { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control); const callVisible = callEmbed && selectedRoom === callEmbed.roomId && joined && !chatOnlyView;
// Sync pip mode into CallControl so it can adjust behavior accordingly
useEffect(() => {
if (!callEmbed) return;
callEmbed.control.setPipMode(!!pipMode);
}, [pipMode, callEmbed]);
// When entering pip with screenshare active (or screenshare starts while in pip),
// enable spotlight so the screenshare fills the pip window.
// When screenshare ends, release the spotlight we auto-enabled.
const pipAutoSpotlightRef = React.useRef(false);
useEffect(() => {
if (!pipMode || !callEmbed) return;
if (pipScreenshare) {
if (!callEmbed.control.spotlight) {
callEmbed.control.toggleSpotlight();
pipAutoSpotlightRef.current = true;
}
} else if (pipAutoSpotlightRef.current) {
if (callEmbed.control.spotlight) callEmbed.control.toggleSpotlight();
pipAutoSpotlightRef.current = false;
}
}, [pipMode, pipScreenshare, callEmbed]);
const theme = useTheme();
const isDark = theme.kind === ThemeKind.Dark;
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
const wallpaperStyle = React.useMemo(
() => getChatBg(chatBackground, isDark),
[chatBackground, isDark],
);
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 entering/exiting pip
const prevPipModeRef = React.useRef(false);
React.useEffect(() => {
const el = callEmbedRef.current;
if (!el) return;
const wasInPip = prevPipModeRef.current;
prevPipModeRef.current = !!pipMode;
if (pipMode) {
if (!wasInPip) {
const saved = localStorage.getItem('pip-position');
const savedPos = saved ? (JSON.parse(saved) as { left: number; top: number }) : null;
el.style.right = 'auto';
el.style.bottom = 'auto';
if (savedPos) {
el.style.left = `${Math.max(0, Math.min(savedPos.left, window.innerWidth - 280))}px`;
el.style.top = `${Math.max(0, Math.min(savedPos.top, window.innerHeight - 158))}px`;
} else {
el.style.left = `${window.innerWidth - 280 - 16}px`;
el.style.top = `${window.innerHeight - 158 - 72}px`;
}
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 {
if (wasInPip) {
// Exiting pip: clear all pip styles; syncCallEmbedPlacement will restore correct position
el.style.top = '';
el.style.left = '';
el.style.bottom = '';
el.style.right = '';
el.style.width = '';
el.style.height = '';
el.style.borderRadius = '';
el.style.overflow = '';
el.style.zIndex = '';
el.style.boxShadow = '';
el.style.border = '';
}
// syncCallEmbedPlacement owns top/left/width/height; don't clear them on visibility changes
el.style.visibility = callVisible ? '' : 'hidden';
}
}, [pipMode, callVisible]);
React.useEffect(() => {
if (!pipMode) return;
const onPipWindowResize = (): void => {
const el = callEmbedRef.current;
if (!el) return;
// Normalise bottom/right → top/left so clamp math works regardless of initial position.
if (!el.style.left || el.style.left === 'auto') normaliseToTopLeft(el);
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 handlePipDoubleClick = (e: React.MouseEvent) => {
const el = callEmbedRef.current;
if (!el) return;
e.stopPropagation();
const margin = 16;
const w = el.offsetWidth;
const h = el.offsetHeight;
const elRect = el.getBoundingClientRect();
const cx = elRect.left + w / 2;
const cy = elRect.top + h / 2;
const snapLeft = cx < window.innerWidth / 2 ? margin : window.innerWidth - w - margin;
const snapTop = cy < window.innerHeight / 2 ? margin : window.innerHeight - h - margin;
el.style.left = `${snapLeft}px`;
el.style.top = `${snapTop}px`;
el.style.right = 'auto';
el.style.bottom = 'auto';
el.style.transition = 'left 0.18s ease, top 0.18s ease';
setTimeout(() => {
if (el) el.style.transition = '';
}, 200);
localStorage.setItem('pip-position', JSON.stringify({ left: snapLeft, top: snapTop }));
};
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;
const dy = ev.clientY - pipDragRef.current.startY;
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) {
pipDragRef.current.dragged = true;
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
}
if (pipDragRef.current.dragged) {
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;
if (el && pipDragRef.current?.dragged) {
const savedRect = el.getBoundingClientRect();
localStorage.setItem(
'pip-position',
JSON.stringify({ left: savedRect.left, top: savedRect.top }),
);
}
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;
if (el && pipDragRef.current?.dragged) {
const savedRect = el.getBoundingClientRect();
localStorage.setItem(
'pip-position',
JSON.stringify({ left: savedRect.left, top: savedRect.top }),
);
}
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;
const sy = e.clientY;
const sw = el.offsetWidth;
const sh = el.offsetHeight;
const sl = parseFloat(el.style.left);
const 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;
const dy = ev.clientY - sy;
let w = sw;
let h = sh;
let l = sl;
let 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);
};
return ( return (
<CallEmbedContextProvider value={callEmbed}> <CallEmbedContextProvider value={callEmbed}>
{callEmbed && <CallUtils embed={callEmbed} />} {callEmbed && <CallUtils embed={callEmbed} />}
<CallEmbedRefContextProvider value={callEmbedRef}> <CallEmbedRefContextProvider value={callEmbedRef}>{children}</CallEmbedRefContextProvider>
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
{children}
</CallEmbedRefContextProvider>
<div <div
data-call-embed-container data-call-embed-container
style={{ style={{
@@ -791,92 +58,9 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
left: 0, left: 0,
width: '100%', width: '100%',
height: '50%', height: '50%',
...(callVisible && !pipMode ? wallpaperStyle : {}),
}} }}
ref={callEmbedRef} ref={callEmbedRef}
> />
{pipMode && callEmbed && (
<>
<div
role="button"
tabIndex={0}
aria-label="Return to call"
onMouseDown={handlePipMouseDown}
onTouchStart={handlePipTouchStart}
onDoubleClick={handlePipDoubleClick}
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>
<PipMuteOverlay callEmbed={callEmbed} />
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
const s = corner.includes('s');
const e2 = corner.includes('e');
const dots = [
[3, 3],
[3, 10],
[10, 3],
].map(([a, b]) => ({
position: 'absolute' as const,
width: 5,
height: 5,
borderRadius: '50%',
background: 'rgba(255,255,255,0.65)',
boxShadow: '0 0 3px rgba(0,0,0,0.4)',
[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: '24px',
height: '24px',
[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> </CallEmbedContextProvider>
); );
} }
+1 -1
View File
@@ -12,7 +12,7 @@ export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) {
const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(), [mx])); const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(), [mx]));
useEffect(() => { useEffect(() => {
load().catch(() => {}); load();
}, [load]); }, [load]);
return children(state.status === AsyncStatus.Success ? state.data : undefined); return children(state.status === AsyncStatus.Success ? state.data : undefined);
+1 -1
View File
@@ -21,7 +21,7 @@ export function ClientConfigLoader({ fallback, error, children }: ClientConfigLo
const ignoreCallback = useCallback(() => setIgnoreError(true), []); const ignoreCallback = useCallback(() => setIgnoreError(true), []);
useEffect(() => { useEffect(() => {
load().catch(() => undefined); load();
}, [load]); }, [load]);
if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) { if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
+5 -5
View File
@@ -1,4 +1,4 @@
import React, { ReactNode, RefObject, useCallback, useRef, useState } from 'react'; import { ReactNode, RefObject, useCallback, useRef, useState } from 'react';
import { useDebounce } from '../hooks/useDebounce'; import { useDebounce } from '../hooks/useDebounce';
type ConfirmPasswordMatchProps = { type ConfirmPasswordMatchProps = {
@@ -7,13 +7,13 @@ type ConfirmPasswordMatchProps = {
match: boolean, match: boolean,
doMatch: () => void, doMatch: () => void,
passRef: RefObject<HTMLInputElement>, passRef: RefObject<HTMLInputElement>,
confPassRef: RefObject<HTMLInputElement>, confPassRef: RefObject<HTMLInputElement>
) => ReactNode; ) => ReactNode;
}; };
export function ConfirmPasswordMatch({ initialValue, children }: ConfirmPasswordMatchProps) { export function ConfirmPasswordMatch({ initialValue, children }: ConfirmPasswordMatchProps) {
const [match, setMatch] = useState(initialValue); const [match, setMatch] = useState(initialValue);
const passRef = useRef<HTMLInputElement>(null) as React.RefObject<HTMLInputElement>; const passRef = useRef<HTMLInputElement>(null);
const confPassRef = useRef<HTMLInputElement>(null) as React.RefObject<HTMLInputElement>; const confPassRef = useRef<HTMLInputElement>(null);
const doMatch = useDebounce( const doMatch = useDebounce(
useCallback(() => { useCallback(() => {
@@ -28,7 +28,7 @@ export function ConfirmPasswordMatch({ initialValue, children }: ConfirmPassword
{ {
wait: 500, wait: 500,
immediate: false, immediate: false,
}, }
); );
return children(match, doMatch, passRef, confPassRef); return children(match, doMatch, passRef, confPassRef);
+7 -9
View File
@@ -137,6 +137,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
> >
{sasData.sas.emoji?.map(([emoji, name], index) => ( {sasData.sas.emoji?.map(([emoji, name], index) => (
<Box <Box
// eslint-disable-next-line react/no-array-index-key
key={`${emoji}${name}${index}`} key={`${emoji}${name}${index}`}
direction="Column" direction="Column"
gap="100" gap="100"
@@ -258,16 +259,9 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
<Dialog variant="Surface"> <Dialog variant="Surface">
<Header style={DialogHeaderStyles} variant="Surface" size="500"> <Header style={DialogHeaderStyles} variant="Surface" size="500">
<Box grow="Yes"> <Box grow="Yes">
<Text as="h2" size="H4"> <Text size="H4">Device Verification</Text>
Device Verification
</Text>
</Box> </Box>
<IconButton <IconButton size="300" radii="300" onClick={handleCancel}>
size="300"
radii="300"
onClick={handleCancel}
aria-label="Cancel verification"
>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
@@ -316,5 +310,9 @@ export function ReceiveSelfDeviceVerification() {
if (!request) return null; if (!request) return null;
if (!request.isSelfVerification) {
return null;
}
return <DeviceVerification request={request} onExit={handleExit} />; return <DeviceVerification request={request} onExit={handleExit} />;
} }
+13 -17
View File
@@ -27,7 +27,7 @@ import { useAlive } from '../hooks/useAlive';
import { UseStateProvider } from './UseStateProvider'; import { UseStateProvider } from './UseStateProvider';
type UIACallback<T> = ( type UIACallback<T> = (
authDict: AuthDict | null, authDict: AuthDict | null
) => Promise<[IAuthData, undefined] | [undefined, T]>; ) => Promise<[IAuthData, undefined] | [undefined, T]>;
type PerformAction<T> = (authDict: AuthDict | null) => Promise<T>; type PerformAction<T> = (authDict: AuthDict | null) => Promise<T>;
@@ -42,7 +42,7 @@ function makeUIAAction<T>(
authData: IAuthData, authData: IAuthData,
performAction: PerformAction<T>, performAction: PerformAction<T>,
resolve: (data: T) => void, resolve: (data: T) => void,
reject: (error?: any) => void, reject: (error?: any) => void
): UIAAction<T> { ): UIAAction<T> {
const action: UIAAction<T> = { const action: UIAAction<T> = {
authData, authData,
@@ -91,7 +91,7 @@ function SetupVerification({ onComplete }: SetupVerificationProps) {
setNextAuthData(authData); setNextAuthData(authData);
} }
}, },
[uiaAction, alive], [uiaAction, alive]
); );
const resetUIA = useCallback(() => { const resetUIA = useCallback(() => {
@@ -118,7 +118,7 @@ function SetupVerification({ onComplete }: SetupVerificationProps) {
(err) => { (err) => {
resetUIA(); resetUIA();
reject(err); reject(err);
}, }
); );
if (alive()) { if (alive()) {
setUIAAction(action); setUIAAction(action);
@@ -130,7 +130,7 @@ function SetupVerification({ onComplete }: SetupVerificationProps) {
reject(error); reject(error);
}); });
}), }),
[alive, resetUIA], [alive, resetUIA]
); );
const [setupState, setup] = useAsyncCallback<void, Error, [string | undefined]>( const [setupState, setup] = useAsyncCallback<void, Error, [string | undefined]>(
@@ -159,8 +159,8 @@ function SetupVerification({ onComplete }: SetupVerificationProps) {
onComplete(recoveryKeyData.encodedPrivateKey); onComplete(recoveryKeyData.encodedPrivateKey);
}, },
[mx, onComplete, authUploadDeviceSigningKeys], [mx, onComplete, authUploadDeviceSigningKeys]
), )
); );
const loading = setupState.status === AsyncStatus.Loading; const loading = setupState.status === AsyncStatus.Loading;
@@ -299,11 +299,9 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
size="500" size="500"
> >
<Box grow="Yes"> <Box grow="Yes">
<Text as="h2" size="H4"> <Text size="H4">Setup Device Verification</Text>
Setup Device Verification
</Text>
</Box> </Box>
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel"> <IconButton size="300" radii="300" onClick={onCancel}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
@@ -316,7 +314,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
</Box> </Box>
</Dialog> </Dialog>
); );
}, }
); );
type DeviceVerificationResetProps = { type DeviceVerificationResetProps = {
onCancel: () => void; onCancel: () => void;
@@ -336,11 +334,9 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
size="500" size="500"
> >
<Box grow="Yes"> <Box grow="Yes">
<Text as="h2" size="H4"> <Text size="H4">Reset Device Verification</Text>
Reset Device Verification
</Text>
</Box> </Box>
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel"> <IconButton size="300" radii="300" onClick={onCancel}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
@@ -375,5 +371,5 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
)} )}
</Dialog> </Dialog>
); );
}, }
); );
-125
View File
@@ -1,125 +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;
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' }}>
<div style={{ width: '100%', borderRadius: lotusTerminal ? '4px' : '8px' }}>
<SearchBar />
</div>
</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>
);
}
-45
View File
@@ -1,45 +0,0 @@
import FocusTrap from 'focus-trap-react';
import { as, Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
import React, { ReactNode } from 'react';
import { ModalWide } from '../styles/Modal.css';
import { stopPropagation } from '../utils/keyboard';
export type RenderViewerProps = {
src: string;
alt: string;
requestClose: () => void;
};
type ImageOverlayProps = RenderViewerProps & {
viewer: boolean;
renderViewer: (props: RenderViewerProps) => ReactNode;
};
export const ImageOverlay = as<'div', ImageOverlayProps>(
({ src, alt, viewer, requestClose, renderViewer, ...props }, ref) => (
<Overlay {...props} ref={ref} open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => requestClose(),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
>
{renderViewer({
src,
alt,
requestClose,
})}
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
),
);
+3 -3
View File
@@ -33,7 +33,7 @@ export const useJoinRuleIcons = (roomType?: string): JoinRuleIcons =>
[JoinRule.Public]: getRoomIconSrc(Icons, roomType, JoinRule.Public), [JoinRule.Public]: getRoomIconSrc(Icons, roomType, JoinRule.Public),
[JoinRule.Private]: getRoomIconSrc(Icons, roomType, JoinRule.Private), [JoinRule.Private]: getRoomIconSrc(Icons, roomType, JoinRule.Private),
}), }),
[roomType], [roomType]
); );
type JoinRuleLabels = Record<ExtendedJoinRules, string>; type JoinRuleLabels = Record<ExtendedJoinRules, string>;
@@ -47,7 +47,7 @@ export const useRoomJoinRuleLabel = (): JoinRuleLabels =>
[JoinRule.Public]: 'Public', [JoinRule.Public]: 'Public',
[JoinRule.Private]: 'Invite Only', [JoinRule.Private]: 'Invite Only',
}), }),
[], []
); );
type JoinRulesSwitcherProps<T extends ExtendedJoinRules[]> = { type JoinRulesSwitcherProps<T extends ExtendedJoinRules[]> = {
@@ -79,7 +79,7 @@ export function JoinRulesSwitcher<T extends ExtendedJoinRules[]>({
setCords(undefined); setCords(undefined);
onChange(selectedRule); onChange(selectedRule);
}, },
[onChange], [onChange]
); );
return ( return (
-118
View File
@@ -1,118 +0,0 @@
import React, { useId } from 'react';
const ROOM_ROWS = [
{ w: '160px', indent: false },
{ w: '120px', indent: true },
{ w: '140px', indent: true },
{ w: '130px', indent: true },
{ w: '150px', indent: false },
{ w: '110px', indent: true },
];
export function LobbySkeleton() {
const id = useId().replace(/:/g, '');
const shimmerKeyframes = `
@keyframes shimmer-${id} {
0% { background-position: -400px 0; }
100% { background-position: 400px 0; }
}
`;
const shimmer = {
background:
'linear-gradient(90deg, var(--skeleton-base) 25%, var(--skeleton-shine) 50%, var(--skeleton-base) 75%)',
backgroundSize: '800px 100%',
animation: `shimmer-${id} 1.6s ease-in-out infinite`,
borderRadius: '4px',
} as React.CSSProperties;
return (
<>
<style>{shimmerKeyframes}</style>
<div
style={
{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
height: '100%',
overflow: 'hidden',
'--skeleton-base': 'color-mix(in srgb, currentColor 8%, transparent)',
'--skeleton-shine': 'color-mix(in srgb, currentColor 15%, transparent)',
} as React.CSSProperties
}
>
{/* Header — matches LobbyHeader (56px) */}
<div
style={{
height: '56px',
borderBottom: '1px solid color-mix(in srgb, currentColor 10%, transparent)',
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '0 16px',
flexShrink: 0,
}}
>
<div
style={{
...shimmer,
width: '32px',
height: '32px',
borderRadius: '50%',
flexShrink: 0,
}}
/>
<div style={{ ...shimmer, width: '130px', height: '16px' }} />
<div style={{ flex: 1 }} />
<div style={{ ...shimmer, width: '24px', height: '24px', borderRadius: '4px' }} />
<div style={{ ...shimmer, width: '24px', height: '24px', borderRadius: '4px' }} />
</div>
<div style={{ flex: 1, overflowY: 'hidden', display: 'flex', flexDirection: 'column' }}>
{/* Hero — matches PageHero with large avatar + title + subtitle */}
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '32px 16px 24px',
gap: '12px',
flexShrink: 0,
}}
>
<div style={{ ...shimmer, width: '72px', height: '72px', borderRadius: '50%' }} />
<div style={{ ...shimmer, width: '180px', height: '20px' }} />
<div style={{ ...shimmer, width: '240px', height: '13px' }} />
</div>
{/* Room list rows */}
<div style={{ flex: 1, padding: '8px 0' }}>
{ROOM_ROWS.map((row, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: `6px 16px 6px ${row.indent ? '36px' : '16px'}`,
}}
>
<div
style={{
...shimmer,
width: '18px',
height: '18px',
borderRadius: '4px',
flexShrink: 0,
}}
/>
<div style={{ ...shimmer, width: row.w, height: '14px' }} />
</div>
))}
</div>
</div>
</div>
</>
);
}
+4 -6
View File
@@ -21,13 +21,13 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
const verificationStatus = useDeviceVerificationStatus( const verificationStatus = useDeviceVerificationStatus(
mx.getCrypto(), mx.getCrypto(),
mx.getSafeUserId(), mx.getSafeUserId(),
mx.getDeviceId() ?? undefined, mx.getDeviceId() ?? undefined
); );
const [logoutState, logout] = useAsyncCallback<void, Error, []>( const [logoutState, logout] = useAsyncCallback<void, Error, []>(
useCallback(async () => { useCallback(async () => {
await logoutClient(mx); await logoutClient(mx);
}, [mx]), }, [mx])
); );
const ongoingLogout = logoutState.status === AsyncStatus.Loading; const ongoingLogout = logoutState.status === AsyncStatus.Loading;
@@ -43,9 +43,7 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
size="500" size="500"
> >
<Box grow="Yes"> <Box grow="Yes">
<Text as="h2" size="H4"> <Text size="H4">Logout</Text>
Logout
</Text>
</Box> </Box>
</Header> </Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400"> <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
@@ -87,5 +85,5 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
</Box> </Box>
</Dialog> </Dialog>
); );
}, }
); );
+3 -3
View File
@@ -126,7 +126,7 @@ export function ManualVerificationTile({
const [method, setMethod] = useState( const [method, setMethod] = useState(
hasPassphrase hasPassphrase
? ManualVerificationMethod.RecoveryPassphrase ? ManualVerificationMethod.RecoveryPassphrase
: ManualVerificationMethod.RecoveryKey, : ManualVerificationMethod.RecoveryKey
); );
const verifyAndRestoreBackup = useCallback( const verifyAndRestoreBackup = useCallback(
@@ -143,11 +143,11 @@ export function ManualVerificationTile({
await crypto.loadSessionBackupPrivateKeyFromSecretStorage(); await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
}, },
[mx, secretStorageKeyId], [mx, secretStorageKeyId]
); );
const [verifyState, handleDecodedRecoveryKey] = useAsyncCallback<void, Error, [Uint8Array]>( const [verifyState, handleDecodedRecoveryKey] = useAsyncCallback<void, Error, [Uint8Array]>(
verifyAndRestoreBackup, verifyAndRestoreBackup
); );
const verifying = verifyState.status === AsyncStatus.Loading; const verifying = verifyState.status === AsyncStatus.Loading;
+1 -1
View File
@@ -12,7 +12,7 @@ export function MediaConfigLoader({ children }: MediaConfigLoaderProps) {
const [state, load] = useAsyncCallback(useCallback(() => mx.getMediaConfig(), [mx])); const [state, load] = useAsyncCallback(useCallback(() => mx.getMediaConfig(), [mx]));
useEffect(() => { useEffect(() => {
load().catch(() => {}); load();
}, [load]); }, [load]);
return children(state.status === AsyncStatus.Success ? state.data : undefined); return children(state.status === AsyncStatus.Success ? state.data : undefined);
@@ -1,35 +0,0 @@
import React from 'react';
import { Icon, Icons, Text, Tooltip, TooltipProvider } from 'folds';
import { useUserVerifiedStatus } from '../hooks/useUserVerifiedStatus';
type MemberVerificationBadgeProps = {
userId: string;
};
export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps) {
const vs = useUserVerifiedStatus(userId);
if (vs === 'unknown') return null;
const color =
vs === 'verified' ? 'var(--tc-positive-normal, #5effc4)' : 'var(--tc-warning-normal, #ffcc55)';
const label = vs === 'verified' ? 'Identity verified' : 'Not verified';
return (
<TooltipProvider
position="Top"
tooltip={
<Tooltip>
<Text size="T200">{label}</Text>
</Tooltip>
}
>
{(ref) => (
<span
ref={ref}
title={label}
style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}
>
<Icon size="100" src={Icons.ShieldUser} style={{ color }} />
</span>
)}
</TooltipProvider>
);
}
+5 -3
View File
@@ -1,3 +1,5 @@
/* eslint-disable no-param-reassign */
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React, { FormEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react'; import React, { FormEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { import {
@@ -41,7 +43,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
const [pdfJSState, loadPdfJS] = usePdfJSLoader(); const [pdfJSState, loadPdfJS] = usePdfJSLoader();
const [docState, loadPdfDocument] = usePdfDocumentLoader( const [docState, loadPdfDocument] = usePdfDocumentLoader(
pdfJSState.status === AsyncStatus.Success ? pdfJSState.data : undefined, pdfJSState.status === AsyncStatus.Success ? pdfJSState.data : undefined,
src, src
); );
const isLoading = const isLoading =
pdfJSState.status === AsyncStatus.Loading || docState.status === AsyncStatus.Loading; pdfJSState.status === AsyncStatus.Loading || docState.status === AsyncStatus.Loading;
@@ -106,7 +108,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
<Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}> <Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
<Header className={css.PdfViewerHeader} size="400"> <Header className={css.PdfViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose} aria-label="Close"> <IconButton size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} /> <Icon size="50" src={Icons.ArrowLeft} />
</IconButton> </IconButton>
<Text size="T300" truncate> <Text size="T300" truncate>
@@ -255,5 +257,5 @@ export const PdfViewer = as<'div', PdfViewerProps>(
)} )}
</Box> </Box>
); );
}, }
); );
@@ -22,7 +22,6 @@ import {
RenderBody, RenderBody,
ThumbnailContent, ThumbnailContent,
UnsupportedContent, UnsupportedContent,
VerificationRequestContent,
VideoContent, VideoContent,
} from './message'; } from './message';
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview'; import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
@@ -38,7 +37,6 @@ type RenderMessageContentProps = {
msgType: string; msgType: string;
ts: number; ts: number;
edited?: boolean; edited?: boolean;
onEditHistoryClick?: () => void;
getContent: <T>() => T; getContent: <T>() => T;
mediaAutoLoad?: boolean; mediaAutoLoad?: boolean;
urlPreview?: boolean; urlPreview?: boolean;
@@ -46,14 +44,12 @@ type RenderMessageContentProps = {
htmlReactParserOptions: HTMLReactParserOptions; htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts; linkifyOpts: Opts;
outlineAttachment?: boolean; outlineAttachment?: boolean;
eventId?: string;
}; };
export function RenderMessageContent({ export function RenderMessageContent({
displayName, displayName,
msgType, msgType,
ts, ts,
edited, edited,
onEditHistoryClick,
getContent, getContent,
mediaAutoLoad, mediaAutoLoad,
urlPreview, urlPreview,
@@ -61,7 +57,6 @@ export function RenderMessageContent({
htmlReactParserOptions, htmlReactParserOptions,
linkifyOpts, linkifyOpts,
outlineAttachment, outlineAttachment,
eventId,
}: RenderMessageContentProps) { }: RenderMessageContentProps) {
const renderUrlsPreview = (urls: string[]) => { const renderUrlsPreview = (urls: string[]) => {
const filteredUrls = urls.filter((url) => !testMatrixTo(url)); const filteredUrls = urls.filter((url) => !testMatrixTo(url));
@@ -81,7 +76,6 @@ export function RenderMessageContent({
<MText <MText
style={{ marginTop: config.space.S200 }} style={{ marginTop: config.space.S200 }}
edited={edited} edited={edited}
onEditHistoryClick={onEditHistoryClick}
content={content} content={content}
renderBody={(props) => ( renderBody={(props) => (
<RenderBody <RenderBody
@@ -138,7 +132,6 @@ export function RenderMessageContent({
return ( return (
<MText <MText
edited={edited} edited={edited}
onEditHistoryClick={onEditHistoryClick}
content={getContent()} content={getContent()}
renderBody={(props) => ( renderBody={(props) => (
<RenderBody <RenderBody
@@ -149,7 +142,6 @@ export function RenderMessageContent({
/> />
)} )}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
eventId={eventId}
/> />
); );
} }
@@ -159,7 +151,6 @@ export function RenderMessageContent({
<MEmote <MEmote
displayName={displayName} displayName={displayName}
edited={edited} edited={edited}
onEditHistoryClick={onEditHistoryClick}
content={getContent()} content={getContent()}
renderBody={(props) => ( renderBody={(props) => (
<RenderBody <RenderBody
@@ -170,7 +161,6 @@ export function RenderMessageContent({
/> />
)} )}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
eventId={eventId}
/> />
); );
} }
@@ -179,7 +169,6 @@ export function RenderMessageContent({
return ( return (
<MNotice <MNotice
edited={edited} edited={edited}
onEditHistoryClick={onEditHistoryClick}
content={getContent()} content={getContent()}
renderBody={(props) => ( renderBody={(props) => (
<RenderBody <RenderBody
@@ -190,7 +179,6 @@ export function RenderMessageContent({
/> />
)} )}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
eventId={eventId}
/> />
); );
} }
@@ -276,9 +264,5 @@ export function RenderMessageContent({
return <MBadEncrypted />; return <MBadEncrypted />;
} }
if (msgType === 'm.key.verification.request') {
return <VerificationRequestContent />;
}
return <UnsupportedContent />; return <UnsupportedContent />;
} }
@@ -17,7 +17,7 @@ const useRoomNotificationModes = (): RoomNotificationMode[] =>
RoomNotificationMode.SpecialMessages, RoomNotificationMode.SpecialMessages,
RoomNotificationMode.Mute, RoomNotificationMode.Mute,
], ],
[], []
); );
const useRoomNotificationModeStr = (): Record<RoomNotificationMode, string> => const useRoomNotificationModeStr = (): Record<RoomNotificationMode, string> =>
@@ -28,7 +28,7 @@ const useRoomNotificationModeStr = (): Record<RoomNotificationMode, string> =>
[RoomNotificationMode.SpecialMessages]: 'Mention & Keywords', [RoomNotificationMode.SpecialMessages]: 'Mention & Keywords',
[RoomNotificationMode.Mute]: 'Mute', [RoomNotificationMode.Mute]: 'Mute',
}), }),
[], []
); );
type NotificationModeSwitcherProps = { type NotificationModeSwitcherProps = {
@@ -37,7 +37,7 @@ type NotificationModeSwitcherProps = {
children: ( children: (
handleOpen: MouseEventHandler<HTMLButtonElement>, handleOpen: MouseEventHandler<HTMLButtonElement>,
opened: boolean, opened: boolean,
changing: boolean, changing: boolean
) => ReactNode; ) => ReactNode;
}; };
export function RoomNotificationModeSwitcher({ export function RoomNotificationModeSwitcher({
-125
View File
@@ -1,125 +0,0 @@
import React, { useId } from 'react';
const MESSAGES = [
{ showAvatar: true, lines: [{ w: '55%' }, { w: '35%' }] },
{ showAvatar: false, lines: [{ w: '72%' }] },
{ showAvatar: false, lines: [{ w: '48%' }, { w: '60%' }] },
{ showAvatar: true, lines: [{ w: '80%' }] },
{ showAvatar: false, lines: [{ w: '40%' }] },
{ showAvatar: true, lines: [{ w: '65%' }, { w: '50%' }, { w: '30%' }] },
{ showAvatar: false, lines: [{ w: '58%' }] },
{ showAvatar: true, lines: [{ w: '45%' }] },
{ showAvatar: false, lines: [{ w: '70%' }, { w: '25%' }] },
];
export function RoomSkeleton() {
const id = useId().replace(/:/g, '');
const shimmerKeyframes = `
@keyframes shimmer-${id} {
0% { background-position: -400px 0; }
100% { background-position: 400px 0; }
}
`;
const shimmer = {
background:
'linear-gradient(90deg, var(--skeleton-base) 25%, var(--skeleton-shine) 50%, var(--skeleton-base) 75%)',
backgroundSize: '800px 100%',
animation: `shimmer-${id} 1.6s ease-in-out infinite`,
borderRadius: '4px',
} as React.CSSProperties;
return (
<>
<style>{shimmerKeyframes}</style>
<div
style={
{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
height: '100%',
overflow: 'hidden',
// CSS vars resolve against both light and dark themes automatically
'--skeleton-base': 'color-mix(in srgb, currentColor 8%, transparent)',
'--skeleton-shine': 'color-mix(in srgb, currentColor 15%, transparent)',
} as React.CSSProperties
}
>
{/* Header — matches PageHeader size="600" (56px) */}
<div
style={{
height: '56px',
borderBottom: '1px solid color-mix(in srgb, currentColor 10%, transparent)',
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '0 16px',
flexShrink: 0,
}}
>
{/* Avatar */}
<div
style={{
...shimmer,
width: '32px',
height: '32px',
borderRadius: '50%',
flexShrink: 0,
}}
/>
{/* Room name */}
<div style={{ ...shimmer, width: '140px', height: '16px' }} />
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* Icon buttons */}
<div style={{ ...shimmer, width: '24px', height: '24px', borderRadius: '4px' }} />
<div style={{ ...shimmer, width: '24px', height: '24px', borderRadius: '4px' }} />
</div>
{/* Timeline */}
<div style={{ flex: 1, overflowY: 'hidden', padding: '16px 0' }}>
{MESSAGES.map((msg, i) => (
<div
key={i}
style={{
display: 'flex',
gap: '12px',
padding: '4px 16px',
alignItems: 'flex-start',
marginBottom: msg.showAvatar ? '8px' : '2px',
}}
>
{/* Avatar — only shown on first message in a group */}
<div style={{ width: '36px', flexShrink: 0 }}>
{msg.showAvatar && (
<div style={{ ...shimmer, width: '36px', height: '36px', borderRadius: '50%' }} />
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', flex: 1 }}>
{/* Username on first in group */}
{msg.showAvatar && (
<div style={{ ...shimmer, width: '90px', height: '12px', marginBottom: '2px' }} />
)}
{msg.lines.map((line, j) => (
<div key={j} style={{ ...shimmer, width: line.w, height: '14px' }} />
))}
</div>
</div>
))}
</div>
{/* Input bar */}
<div
style={{
borderTop: '1px solid color-mix(in srgb, currentColor 10%, transparent)',
padding: '12px 16px',
flexShrink: 0,
}}
>
<div style={{ ...shimmer, width: '100%', height: '44px', borderRadius: '8px' }} />
</div>
</div>
</>
);
}
+5 -7
View File
@@ -36,7 +36,7 @@ export function SecretStorageRecoveryPassphrase({
passphrase, passphrase,
salt, salt,
iterations, iterations,
bits, bits
); );
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any); const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
@@ -47,8 +47,8 @@ export function SecretStorageRecoveryPassphrase({
return decodedRecoveryKey; return decodedRecoveryKey;
}, },
[mx, keyContent], [mx, keyContent]
), )
); );
const drivingKey = driveKeyState.status === AsyncStatus.Loading; const drivingKey = driveKeyState.status === AsyncStatus.Loading;
@@ -80,7 +80,6 @@ export function SecretStorageRecoveryPassphrase({
<Text size="L400">Recovery Passphrase</Text> <Text size="L400">Recovery Passphrase</Text>
<PasswordInput <PasswordInput
name="recoveryPassphraseInput" name="recoveryPassphraseInput"
aria-label="Recovery passphrase"
size="400" size="400"
variant="Secondary" variant="Secondary"
radii="300" radii="300"
@@ -140,8 +139,8 @@ export function SecretStorageRecoveryKey({
return decodedRecoveryKey; return decodedRecoveryKey;
}, },
[mx, keyContent], [mx, keyContent]
), )
); );
const drivingKey = driveKeyState.status === AsyncStatus.Loading; const drivingKey = driveKeyState.status === AsyncStatus.Loading;
@@ -171,7 +170,6 @@ export function SecretStorageRecoveryKey({
<Text size="L400">Recovery Key</Text> <Text size="L400">Recovery Key</Text>
<PasswordInput <PasswordInput
name="recoveryKeyInput" name="recoveryKeyInput"
aria-label="Recovery key"
size="400" size="400"
variant="Secondary" variant="Secondary"
radii="300" radii="300"
+1 -5
View File
@@ -31,22 +31,18 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
const authMetadata = promiseFulfilledResult(result[2]); const authMetadata = promiseFulfilledResult(result[2]);
let validatedAuthMetadata: ValidatedAuthMetadata | undefined; let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
// Only validate if the server returned metadata — a rejected promise means no native
// Matrix OIDC (MSC3861/MAS), which is normal for servers using traditional SSO.
if (authMetadata !== undefined) {
try { try {
validatedAuthMetadata = validateAuthMetadata(authMetadata); validatedAuthMetadata = validateAuthMetadata(authMetadata);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
}
return { return {
capabilities, capabilities,
mediaConfig, mediaConfig,
authMetadata: validatedAuthMetadata, authMetadata: validatedAuthMetadata,
}; };
}, [mx]), }, [mx])
); );
const configs: ServerConfigs = const configs: ServerConfigs =
@@ -21,7 +21,7 @@ export function SpaceChildDirectsProvider({
const childDirects = useSpaceChildren( const childDirects = useSpaceChildren(
allRoomsAtom, allRoomsAtom,
spaceId, spaceId,
useChildDirectScopeFactory(mx, mDirects, roomToParents), useChildDirectScopeFactory(mx, mDirects, roomToParents)
); );
return children(childDirects); return children(childDirects);
@@ -21,7 +21,7 @@ export function SpaceChildRoomsProvider({
const childRooms = useSpaceChildren( const childRooms = useSpaceChildren(
allRoomsAtom, allRoomsAtom,
spaceId, spaceId,
useChildRoomScopeFactory(mx, mDirects, roomToParents), useChildRoomScopeFactory(mx, mDirects, roomToParents)
); );
return children(childRooms); return children(childRooms);
+3 -3
View File
@@ -15,14 +15,14 @@ export function SpecVersionsLoader({
children, children,
}: SpecVersionsLoaderProps) { }: SpecVersionsLoaderProps) {
const [state, load] = useAsyncCallback( const [state, load] = useAsyncCallback(
useCallback(() => specVersions(fetch, baseUrl), [baseUrl]), useCallback(() => specVersions(fetch, baseUrl), [baseUrl])
); );
const [ignoreError, setIgnoreError] = useState(false); const [ignoreError, setIgnoreError] = useState(false);
const ignoreCallback = useCallback(() => setIgnoreError(true), []); const ignoreCallback = useCallback(() => setIgnoreError(true), []);
useEffect(() => { useEffect(() => {
load().catch(() => {}); load();
}, [load]); }, [load]);
if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) { if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
@@ -38,6 +38,6 @@ export function SpecVersionsLoader({
? state.data ? state.data
: { : {
versions: [], versions: [],
}, }
); );
} }
-1
View File
@@ -55,7 +55,6 @@ export function UIAFlowOverlay({
<IconButton <IconButton
ref={anchorRef} ref={anchorRef}
variant="Critical" variant="Critical"
aria-label="Cancel authentication"
size="300" size="300"
onClick={onCancel} onClick={onCancel}
radii="Pill" radii="Pill"
-306
View File
@@ -1,306 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Icon, IconButton, Icons, Text, config, toRem } from 'folds';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
type RecorderState = 'idle' | 'recording' | 'preview';
interface VoiceRecorderProps {
onSend: (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => void;
onError?: (err: string) => void;
}
function formatDuration(ms: number): string {
const totalSec = Math.floor(ms / 1000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function normalizeWaveform(samples: number[]): number[] {
if (samples.length === 0) return Array(20).fill(0);
const max = Math.max(...samples, 1);
const count = Math.min(samples.length, 100);
const step = samples.length / count;
const result: number[] = [];
for (let i = 0; i < count; i += 1) {
const idx = Math.floor(i * step);
result.push(Math.round((samples[idx] / max) * 1024));
}
return result;
}
const WAVEFORM_BARS = 40;
export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [state, setState] = useState<RecorderState>('idle');
const [durationMs, setDurationMs] = useState(0);
const [waveformBars, setWaveformBars] = useState<number[]>(Array(WAVEFORM_BARS).fill(0));
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const analyserRef = useRef<AnalyserNode | null>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const rawSamplesRef = useRef<number[]>([]);
const startTimeRef = useRef<number>(0);
const animFrameRef = useRef<number>(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const previewMimeRef = useRef('audio/ogg;codecs=opus');
const previewDurationRef = useRef(0);
const stopAll = useCallback(() => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
if (timerRef.current) clearInterval(timerRef.current);
if (audioCtxRef.current) {
audioCtxRef.current.close();
audioCtxRef.current = null;
}
analyserRef.current = null;
}, []);
useEffect(
() => () => {
stopAll();
if (previewUrl) URL.revokeObjectURL(previewUrl);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
? 'audio/ogg;codecs=opus'
: 'audio/webm;codecs=opus';
previewMimeRef.current = mimeType;
const mr = new MediaRecorder(stream, { mimeType });
mediaRecorderRef.current = mr;
chunksRef.current = [];
rawSamplesRef.current = [];
startTimeRef.current = Date.now();
const audioCtx = new AudioContext();
audioCtxRef.current = audioCtx;
const source = audioCtx.createMediaStreamSource(stream);
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
analyserRef.current = analyser;
const buf = new Uint8Array(analyser.frequencyBinCount);
const tick = () => {
if (!analyserRef.current) return;
analyserRef.current.getByteFrequencyData(buf);
const avg = buf.reduce((a, b) => a + b, 0) / buf.length;
rawSamplesRef.current.push(avg);
setWaveformBars((prev) => {
const next = [...prev.slice(1), Math.round((avg / 255) * 100)];
return next;
});
animFrameRef.current = requestAnimationFrame(tick);
};
animFrameRef.current = requestAnimationFrame(tick);
timerRef.current = setInterval(() => {
setDurationMs(Date.now() - startTimeRef.current);
}, 100);
mr.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
mr.onstop = () => {
stream.getTracks().forEach((t) => t.stop());
const blob = new Blob(chunksRef.current, { type: mimeType });
previewDurationRef.current = Date.now() - startTimeRef.current;
setPreviewBlob(blob);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(blob);
});
setState('preview');
};
mr.start(250);
setState('recording');
} catch {
onError?.('Microphone access denied');
}
}, [onError]);
const stopRecording = useCallback(() => {
stopAll();
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.stop();
}
}, [stopAll]);
const cancelRecording = useCallback(() => {
stopAll();
const mr = mediaRecorderRef.current;
if (mr?.state === 'recording') {
mr.ondataavailable = null;
mr.onstop = null;
mr.stop();
}
setPreviewBlob(null);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
rawSamplesRef.current = [];
setWaveformBars(Array(WAVEFORM_BARS).fill(0));
setDurationMs(0);
setState('idle');
}, [stopAll]);
const sendVoice = useCallback(() => {
if (!previewBlob) return;
const waveform = normalizeWaveform(rawSamplesRef.current);
onSend(previewBlob, previewMimeRef.current, previewDurationRef.current, waveform);
cancelRecording();
}, [previewBlob, onSend, cancelRecording]);
const barMax = Math.max(...waveformBars, 1);
if (state === 'idle') {
return (
<IconButton
onClick={startRecording}
aria-label="Record voice message"
variant="SurfaceVariant"
size="300"
radii="300"
title="Record voice message"
>
<Icon src={Icons.Mic} size="100" />
</IconButton>
);
}
if (state === 'recording') {
return (
<Box
data-voice-recorder="recording"
alignItems="Center"
gap="200"
style={{
background: 'var(--bg-surface-variant)',
borderRadius: config.radii.R300,
padding: `${toRem(4)} ${toRem(8)}`,
}}
>
<Box
data-voice-rec-dot
style={{
width: toRem(8),
height: toRem(8),
borderRadius: '50%',
background: lotusTerminal ? '#FF6B00' : 'var(--tc-danger-normal)',
flexShrink: 0,
animation: 'pttLivePulse 900ms ease-in-out infinite',
}}
/>
<Text
size="T200"
style={{
minWidth: toRem(32),
fontVariantNumeric: 'tabular-nums',
...(lotusTerminal
? { fontFamily: 'JetBrains Mono, monospace', color: '#00FF88', fontWeight: 700 }
: {}),
}}
>
{formatDuration(durationMs)}
</Text>
<Box
data-voice-waveform
alignItems="Center"
gap="100"
style={{ height: toRem(20), overflow: 'hidden', flexShrink: 0 }}
>
{waveformBars.map((h, i) => (
<div
key={i}
style={{
width: toRem(2),
height: toRem(2 + (h / barMax) * 16),
borderRadius: toRem(1),
background: lotusTerminal ? '#00FF88' : 'var(--tc-primary-normal)',
flexShrink: 0,
}}
/>
))}
</Box>
<IconButton
onClick={stopRecording}
aria-label="Stop recording"
variant="Primary"
fill="Soft"
size="300"
radii="300"
title="Stop recording"
>
<Icon src={Icons.Pause} size="100" />
</IconButton>
<IconButton
onClick={cancelRecording}
aria-label="Cancel recording"
variant="SurfaceVariant"
size="300"
radii="300"
title="Cancel"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
</Box>
);
}
return (
<Box
alignItems="Center"
gap="200"
style={{
background: 'var(--bg-surface-variant)',
borderRadius: config.radii.R300,
padding: `${toRem(4)} ${toRem(8)}`,
}}
>
{previewUrl && (
<audio src={previewUrl} controls style={{ height: toRem(28), maxWidth: toRem(180) }} />
)}
<Text size="T200" style={{ fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>
{formatDuration(previewDurationRef.current)}
</Text>
<IconButton
onClick={sendVoice}
aria-label="Send voice message"
variant="Primary"
fill="Soft"
size="300"
radii="300"
title="Send voice message"
>
<Icon src={Icons.Send} size="100" />
</IconButton>
<IconButton
onClick={cancelRecording}
aria-label="Discard voice message"
variant="SurfaceVariant"
size="300"
radii="300"
title="Discard"
>
<Icon src={Icons.Delete} size="100" />
</IconButton>
</Box>
);
}
@@ -35,7 +35,7 @@ import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-ht
export const useAdditionalCreators = (defaultCreators?: string[]) => { export const useAdditionalCreators = (defaultCreators?: string[]) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [additionalCreators, setAdditionalCreators] = useState<string[]>( const [additionalCreators, setAdditionalCreators] = useState<string[]>(
() => defaultCreators?.filter((id) => id !== mx.getSafeUserId()) ?? [], () => defaultCreators?.filter((id) => id !== mx.getSafeUserId()) ?? []
); );
const addAdditionalCreator = (userId: string) => { const addAdditionalCreator = (userId: string) => {
@@ -90,12 +90,12 @@ export function AdditionalCreatorInput({
const [validUserId, setValidUserId] = useState<string>(); const [validUserId, setValidUserId] = useState<string>();
const filteredUsers = useMemo( const filteredUsers = useMemo(
() => directUsers.filter((userId) => !additionalCreators.includes(userId)), () => directUsers.filter((userId) => !additionalCreators.includes(userId)),
[directUsers, additionalCreators], [directUsers, additionalCreators]
); );
const [result, search, resetSearch] = useAsyncSearch( const [result, search, resetSearch] = useAsyncSearch(
filteredUsers, filteredUsers,
getUserIdString, getUserIdString,
SEARCH_OPTIONS, SEARCH_OPTIONS
); );
const queryHighlighRegex = result?.query ? makeHighlightRegex([result.query]) : undefined; const queryHighlighRegex = result?.query ? makeHighlightRegex([result.query]) : undefined;
@@ -42,9 +42,9 @@ export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
throw e; throw e;
} }
}, },
[mx], [mx]
), ),
setAliasAvail, setAliasAvail
); );
const aliasAvailable: boolean | undefined = const aliasAvailable: boolean | undefined =
aliasAvail.status === AsyncStatus.Success ? aliasAvail.data : undefined; aliasAvail.status === AsyncStatus.Success ? aliasAvail.data : undefined;
+4 -4
View File
@@ -15,7 +15,7 @@ import { CreateRoomAccess } from './types';
export const createRoomCreationContent = ( export const createRoomCreationContent = (
type: RoomType | undefined, type: RoomType | undefined,
allowFederation: boolean, allowFederation: boolean,
additionalCreators: string[] | undefined, additionalCreators: string[] | undefined
): object => { ): object => {
const content: Record<string, any> = {}; const content: Record<string, any> = {};
if (typeof type === 'string') { if (typeof type === 'string') {
@@ -34,7 +34,7 @@ export const createRoomCreationContent = (
export const createRoomJoinRulesState = ( export const createRoomJoinRulesState = (
access: CreateRoomAccess, access: CreateRoomAccess,
parent: Room | undefined, parent: Room | undefined,
knock: boolean, knock: boolean
) => { ) => {
let content: RoomJoinRulesEventContent = { let content: RoomJoinRulesEventContent = {
join_rule: knock ? JoinRule.Knock : JoinRule.Invite, join_rule: knock ? JoinRule.Knock : JoinRule.Invite,
@@ -136,7 +136,7 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
creation_content: createRoomCreationContent( creation_content: createRoomCreationContent(
data.type, data.type,
data.allowFederation, data.allowFederation,
data.additionalCreators, data.additionalCreators
), ),
power_level_content_override: power_level_content_override:
data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined, data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined,
@@ -158,7 +158,7 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
suggested: false, suggested: false,
via: [getMxIdServer(mx.getUserId() ?? '') ?? ''], via: [getMxIdServer(mx.getUserId() ?? '') ?? ''],
}, },
result.room_id, result.room_id
); );
} }
@@ -11,5 +11,5 @@ export const CutoutCard = as<'div', { variant?: TContainerColor }>(
{...props} {...props}
ref={ref} ref={ref}
/> />
), )
); );
+4 -14
View File
@@ -23,11 +23,7 @@ export function EditorPreview() {
return ( return (
<> <>
<IconButton <IconButton variant="SurfaceVariant" onClick={() => setOpen(!open)}>
variant="SurfaceVariant"
aria-label="Open editor preview"
onClick={() => setOpen(!open)}
>
<Icon src={Icons.BlockQuote} /> <Icon src={Icons.BlockQuote} />
</IconButton> </IconButton>
<Overlay open={open} backdrop={<OverlayBackdrop />}> <Overlay open={open} backdrop={<OverlayBackdrop />}>
@@ -46,7 +42,7 @@ export function EditorPreview() {
editor={editor} editor={editor}
placeholder="Send a message..." placeholder="Send a message..."
before={ before={
<IconButton variant="SurfaceVariant" size="300" radii="300" aria-label="Attach"> <IconButton variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.PlusCircle} /> <Icon src={Icons.PlusCircle} />
</IconButton> </IconButton>
} }
@@ -57,20 +53,14 @@ export function EditorPreview() {
size="300" size="300"
radii="300" radii="300"
onClick={() => setToolbar(!toolbar)} onClick={() => setToolbar(!toolbar)}
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
aria-pressed={toolbar} aria-pressed={toolbar}
> >
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} /> <Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton> </IconButton>
<IconButton <IconButton variant="SurfaceVariant" size="300" radii="300">
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Insert emoji"
>
<Icon src={Icons.Smile} /> <Icon src={Icons.Smile} />
</IconButton> </IconButton>
<IconButton variant="SurfaceVariant" size="300" radii="300" aria-label="Send"> <IconButton variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.Send} /> <Icon src={Icons.Send} />
</IconButton> </IconButton>
</> </>
+7 -8
View File
@@ -1,3 +1,4 @@
/* eslint-disable no-param-reassign */
import React, { import React, {
ClipboardEventHandler, ClipboardEventHandler,
KeyboardEventHandler, KeyboardEventHandler,
@@ -35,7 +36,7 @@ const withInline = (editor: Editor): Editor => {
editor.isInline = (element) => editor.isInline = (element) =>
[BlockType.Mention, BlockType.Emoticon, BlockType.Link, BlockType.Command].includes( [BlockType.Mention, BlockType.Emoticon, BlockType.Link, BlockType.Command].includes(
element.type, element.type
) || isInline(element); ) || isInline(element);
return editor; return editor;
@@ -87,11 +88,11 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
onChange, onChange,
onPaste, onPaste,
}, },
ref, ref
) => { ) => {
const renderElement = useCallback( const renderElement = useCallback(
(props: RenderElementProps) => <RenderElement {...props} />, (props: RenderElementProps) => <RenderElement {...props} />,
[], []
); );
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []); const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []);
@@ -102,7 +103,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
const shortcutToggled = toggleKeyboardShortcut(editor, evt); const shortcutToggled = toggleKeyboardShortcut(editor, evt);
if (shortcutToggled) evt.preventDefault(); if (shortcutToggled) evt.preventDefault();
}, },
[editor, onKeyDown], [editor, onKeyDown]
); );
const renderPlaceholder = useCallback( const renderPlaceholder = useCallback(
@@ -114,7 +115,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
</Text> </Text>
</span> </span>
), ),
[], []
); );
return ( return (
@@ -139,8 +140,6 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
data-editable-name={editableName} data-editable-name={editableName}
className={css.EditorTextarea} className={css.EditorTextarea}
placeholder={placeholder} placeholder={placeholder}
aria-label={placeholder ?? 'Message input'}
aria-multiline="true"
renderPlaceholder={renderPlaceholder} renderPlaceholder={renderPlaceholder}
renderElement={renderElement} renderElement={renderElement}
renderLeaf={renderLeaf} renderLeaf={renderLeaf}
@@ -159,5 +158,5 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
</Slate> </Slate>
</div> </div>
); );
}, }
); );

Some files were not shown because too many files have changed in this diff Show More