chore: merge v4.12.1 — security, calling, editor, media fixes
Key v4.12.1 changes merged: - Security: sanitize-html updated to v2.17.4 - Calling: video calls in DMs/rooms, user avatars during calls, right-click to start - Calling: IncomingCallListener with ring sound and answer/reject UI - Editor: list crash fixes (Firefox + empty headings), codeblock filename support - Media: URL preview hover state, keyboard nav, click-to-open, OGG audio support - Date: ISO 8601 (YYYY-MM-DD) date format option - Misc: stable mutual rooms endpoint, Android notification crash fix Lotus customisations preserved: - PiP drag/resize, DM call ring notification, PTT, GIF picker, noise suppression - Poll voting, message forwarding, image captions, location sharing - Lotus Terminal design theme
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
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
|
||||
@@ -1,57 +0,0 @@
|
||||
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.
|
||||
@@ -1,4 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 Matrix Chat
|
||||
url: https://matrix.to/#/#cinny:matrix.org
|
||||
about: Ask questions and talk to other Cinny users and the maintainers
|
||||
- name: Features, Bug Reports, Questions
|
||||
url: https://github.com/cinnyapp/cinny/discussions/new/choose
|
||||
about: Our preferred starting point if you have any questions or suggestions about features or behavior.
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
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.**
|
||||
@@ -1,22 +0,0 @@
|
||||
<!-- 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
|
||||
@@ -1,3 +0,0 @@
|
||||
# Reporting a Vulnerability
|
||||
|
||||
**If you've found a security vulnerability, please report it to cinnyapp@gmail.com**
|
||||
@@ -3,12 +3,20 @@
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
":dependencyDashboardApproval",
|
||||
":semanticCommits"
|
||||
":semanticCommits",
|
||||
"group:monorepos"
|
||||
],
|
||||
"labels": ["Dependencies"],
|
||||
"rebaseWhen": "conflicted",
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": ["lockFileMaintenance"]
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["slate", "slate-dom", "slate-history", "slate-react"]
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["linkifyjs", "linkify-react"]
|
||||
}
|
||||
],
|
||||
"lockFileMaintenance": {
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".node-version"
|
||||
package-manager-cache: false
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run build
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: preview
|
||||
path: dist
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
- name: Save pr number
|
||||
run: echo ${PR_NUMBER} > ./pr.txt
|
||||
- name: Upload pr number
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: pr
|
||||
path: ./pr.txt
|
||||
|
||||
@@ -16,16 +16,22 @@ jobs:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Download pr number
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
|
||||
with:
|
||||
workflow: ${{ github.event.workflow.id }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: pr
|
||||
- name: Output pr number
|
||||
- name: Validate and output pr number
|
||||
id: pr
|
||||
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
|
||||
run: |
|
||||
PR_ID=$(<pr.txt)
|
||||
if ! [[ "${PR_ID}" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::pr.txt contains non-numeric content: ${PR_ID}"
|
||||
exit 1
|
||||
fi
|
||||
echo "id=${PR_ID}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Download artifact
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
|
||||
with:
|
||||
workflow: ${{ github.event.workflow.id }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
@@ -42,7 +48,7 @@ jobs:
|
||||
enable-pull-request-comment: false
|
||||
enable-commit-comment: false
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN_PR }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
|
||||
timeout-minutes: 1
|
||||
- name: Comment preview on PR
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub #Do not update this action from a outside PR
|
||||
if: github.event.pull_request.head.repo.fork == false
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
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
|
||||
if: github.event.pull_request.head.repo.fork == false
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
ghcr.io/${{ github.repository }}
|
||||
|
||||
- name: Build Docker image (no push)
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".node-version"
|
||||
package-manager-cache: false
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
name: Production deploy
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy-and-tarball:
|
||||
name: Netlify deploy and tarball
|
||||
outputs:
|
||||
version: ${{ steps.vars.outputs.tag }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".node-version"
|
||||
package-manager-cache: false
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Run semantic release
|
||||
run: npm run semantic-release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GIT_AUTHOR_NAME: ${{ secrets.GIT_AUTHOR_NAME }}
|
||||
GIT_AUTHOR_EMAIL: ${{ secrets.GIT_AUTHOR_EMAIL }}
|
||||
GIT_COMMITTER_NAME: ${{ secrets.GIT_AUTHOR_NAME }}
|
||||
GIT_COMMITTER_EMAIL: ${{ secrets.GIT_AUTHOR_EMAIL }}
|
||||
- name: Get version from tag
|
||||
id: vars
|
||||
run: |
|
||||
TAG=$(git describe --tags --abbrev=0)
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
- name: Build app
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
@@ -26,7 +42,7 @@ jobs:
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: 'Prod deploy ${{ github.ref_name }}'
|
||||
deploy-message: 'Prod deploy ${{ steps.vars.outputs.tag }}'
|
||||
enable-commit-comment: false
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
production-deploy: true
|
||||
@@ -36,9 +52,6 @@ jobs:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_APP }}
|
||||
timeout-minutes: 1
|
||||
- name: Get version from tag
|
||||
id: vars
|
||||
run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
|
||||
- name: Create tar.gz
|
||||
run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist
|
||||
- name: Sign tar.gz
|
||||
@@ -52,14 +65,18 @@ jobs:
|
||||
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
|
||||
- name: Upload tagged release
|
||||
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ steps.vars.outputs.tag }}
|
||||
files: |
|
||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
|
||||
|
||||
publish-image:
|
||||
name: Push Docker image to Docker Hub, GHCR
|
||||
needs: deploy-and-tarball
|
||||
env:
|
||||
VERSION: ${{ needs.deploy-and-tarball.outputs.version }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -67,17 +84,19 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
- name: Login to Docker Hub #Do not update this action from a outside PR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to the Github Container registry #Do not update this action from a outside PR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -89,11 +108,14 @@ jobs:
|
||||
images: |
|
||||
${{ secrets.DOCKER_USERNAME }}/cinny
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=${{ env.VERSION }}
|
||||
type=raw,value=latest
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -0,0 +1,3 @@
|
||||
# These are commented until we enable lint and typecheck
|
||||
# npx tsc -p tsconfig.json --noEmit
|
||||
# npx lint-staged
|
||||
+1
-1
@@ -18,7 +18,7 @@ Bug reports and feature suggestions must use descriptive and concise titles and
|
||||
## Pull requests
|
||||
|
||||
> ### 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.
|
||||
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ RUN npm run build
|
||||
|
||||
|
||||
## App
|
||||
FROM nginx:1.29.5-alpine
|
||||
FROM nginx:1.29.8-alpine
|
||||
|
||||
COPY --from=builder /src/dist /app
|
||||
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
Generated
+7076
-462
File diff suppressed because it is too large
Load Diff
+53
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lotus-chat",
|
||||
"version": "4.11.1",
|
||||
"version": "4.12.1-lotus",
|
||||
"description": "Lotus Chat — Matrix client for Lotus Guild",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
@@ -11,11 +11,52 @@
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "yarn check:eslint && yarn check:prettier",
|
||||
"lint": "npm run check:eslint && npm run check:prettier",
|
||||
"check:eslint": "eslint src/*",
|
||||
"check:prettier": "prettier --check .",
|
||||
"fix:prettier": "prettier --write .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepare": "husky install",
|
||||
"commit": "git-cz",
|
||||
"semantic-release": "semantic-release"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,jsx}": "eslint",
|
||||
"*": "prettier --ignore-unknown --write"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "./node_modules/cz-conventional-changelog"
|
||||
}
|
||||
},
|
||||
"release": {
|
||||
"branches": [
|
||||
"dev"
|
||||
],
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
[
|
||||
"@semantic-release/exec",
|
||||
{
|
||||
"prepareCmd": "node scripts/update-version.js ${nextRelease.version}"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/git",
|
||||
{
|
||||
"assets": [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"src/app/features/settings/about/About.tsx",
|
||||
"src/app/pages/auth/AuthFooter.tsx",
|
||||
"src/app/pages/client/WelcomePage.tsx"
|
||||
],
|
||||
"message": "chore(release): ${nextRelease.version} [skip ci]"
|
||||
}
|
||||
],
|
||||
"@semantic-release/github"
|
||||
]
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Ajay Bura",
|
||||
@@ -59,7 +100,7 @@
|
||||
"linkify-react": "4.3.2",
|
||||
"linkifyjs": "4.3.2",
|
||||
"matrix-js-sdk": "38.2.0",
|
||||
"matrix-widget-api": "1.13.0",
|
||||
"matrix-widget-api": "1.16.1",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "4.2.67",
|
||||
"prismjs": "1.30.0",
|
||||
@@ -73,7 +114,7 @@
|
||||
"react-i18next": "15.0.0",
|
||||
"react-range": "1.8.14",
|
||||
"react-router-dom": "6.30.3",
|
||||
"sanitize-html": "2.12.1",
|
||||
"sanitize-html": "2.17.4",
|
||||
"slate": "0.123.0",
|
||||
"slate-dom": "0.123.0",
|
||||
"slate-history": "0.113.1",
|
||||
@@ -86,6 +127,8 @@
|
||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||
"@rollup/plugin-inject": "5.0.3",
|
||||
"@rollup/plugin-wasm": "6.1.1",
|
||||
"@semantic-release/exec": "7.1.0",
|
||||
"@semantic-release/git": "10.0.1",
|
||||
"@types/chroma-js": "3.1.1",
|
||||
"@types/file-saver": "2.0.5",
|
||||
"@types/is-hotkey": "0.1.10",
|
||||
@@ -94,12 +137,13 @@
|
||||
"@types/react": "18.2.39",
|
||||
"@types/react-dom": "18.2.17",
|
||||
"@types/react-google-recaptcha": "2.1.8",
|
||||
"@types/sanitize-html": "2.9.0",
|
||||
"@types/sanitize-html": "2.16.1",
|
||||
"@types/ua-parser-js": "0.7.36",
|
||||
"@typescript-eslint/eslint-plugin": "5.46.1",
|
||||
"@typescript-eslint/parser": "5.46.1",
|
||||
"@vitejs/plugin-react": "4.2.0",
|
||||
"buffer": "6.0.3",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
"eslint": "8.29.0",
|
||||
"eslint-config-airbnb": "19.0.4",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
@@ -107,7 +151,10 @@
|
||||
"eslint-plugin-jsx-a11y": "6.6.1",
|
||||
"eslint-plugin-react": "7.31.11",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.3.2",
|
||||
"prettier": "2.8.1",
|
||||
"semantic-release": "25.0.3",
|
||||
"typescript": "4.9.4",
|
||||
"vite": "5.4.19",
|
||||
"vite-plugin-pwa": "0.20.5",
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,48 @@
|
||||
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}`);
|
||||
});
|
||||
@@ -1,6 +1,32 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||
/* eslint-disable jsx-a11y/media-has-caption */
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { config } from 'folds';
|
||||
import { MatrixRTCSession } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
color,
|
||||
config,
|
||||
Dialog,
|
||||
Icon,
|
||||
Icons,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import {
|
||||
EventTimelineSetHandlerMap,
|
||||
EventType,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
import { IRTCNotificationContent, RTCNotificationType } from 'matrix-js-sdk/lib/matrixrtc/types';
|
||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||
import {
|
||||
CallEmbedContextProvider,
|
||||
CallEmbedRefContextProvider,
|
||||
@@ -8,15 +34,330 @@ import {
|
||||
useCallJoined,
|
||||
useCallThemeSync,
|
||||
useCallMemberSoundSync,
|
||||
useCallStart,
|
||||
} from '../hooks/useCallEmbed';
|
||||
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
||||
import { CallEmbed } from '../plugins/call';
|
||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import CallSound from '../../../public/sound/call.ogg';
|
||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
||||
import { mDirectAtom } from '../state/mDirectList';
|
||||
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
|
||||
import { mxcUrlToHttp } from '../utils/matrix';
|
||||
import { RoomAvatar, RoomIcon } from './room-avatar';
|
||||
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
||||
import { getStateEvent } from '../utils/room';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
|
||||
import { useLivekitSupport } from '../hooks/useLivekitSupport';
|
||||
import { CallAvatarAnimation } from '../styles/Animations.css';
|
||||
import { webRTCSupported } from '../utils/rtc';
|
||||
|
||||
const PIP_MIN_W = 200;
|
||||
const PIP_MIN_H = 112; // keeps roughly 16:9 at minimum
|
||||
const PIP_MIN_H = 112;
|
||||
|
||||
type Corner = 'se' | 'sw' | 'ne' | 'nw';
|
||||
|
||||
/** Normalise the element to top/left positioning so resize math is uniform. */
|
||||
function normaliseToTopLeft(el: HTMLElement) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
el.style.left = `${rect.left}px`;
|
||||
el.style.top = `${rect.top}px`;
|
||||
el.style.right = 'auto';
|
||||
el.style.bottom = 'auto';
|
||||
}
|
||||
|
||||
type IncomingCallInfo = {
|
||||
room: Room;
|
||||
sender: string;
|
||||
senderTs: number;
|
||||
lifetime: number;
|
||||
intent?: string;
|
||||
notificationType: RTCNotificationType;
|
||||
refEventId: string;
|
||||
};
|
||||
type IncomingCallProps = {
|
||||
dm: boolean;
|
||||
info: IncomingCallInfo;
|
||||
onIgnore: () => void;
|
||||
onAnswer: (room: Room, video: boolean) => void;
|
||||
onReject: (room: Room, eventId: string) => void;
|
||||
};
|
||||
function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const livekitSupported = useLivekitSupport();
|
||||
const rtcSupported = webRTCSupported();
|
||||
const canAnswer = livekitSupported && rtcSupported;
|
||||
const { room } = info;
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
const roomName = useRoomName(room);
|
||||
const roomAvatar = useRoomAvatar(room, dm);
|
||||
const avatarUrl = roomAvatar
|
||||
? mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||
: undefined;
|
||||
|
||||
const session = useCallSession(room);
|
||||
useCallMembersChange(
|
||||
session,
|
||||
useCallback(() => {
|
||||
const members = MatrixRTCSession.sessionMembershipsForRoom(room, session.sessionDescription);
|
||||
if (members.length === 0) {
|
||||
onIgnore();
|
||||
}
|
||||
}, [room, session, onIgnore])
|
||||
);
|
||||
|
||||
const playSound = useCallback(() => {
|
||||
const audioElement = audioRef.current;
|
||||
audioElement?.play();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (info.notificationType === 'ring') {
|
||||
playSound();
|
||||
}
|
||||
}, [playSound, info.notificationType]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => onIgnore(),
|
||||
clickOutsideDeactivates: false,
|
||||
escapeDeactivates: false,
|
||||
}}
|
||||
>
|
||||
<Dialog style={{ maxWidth: toRem(324) }}>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
|
||||
<Text size="T200" align="Center">
|
||||
{info.sender}
|
||||
</Text>
|
||||
<Box direction="Column" gap="500" alignItems="Center">
|
||||
<Box shrink="No">
|
||||
<Avatar size="500" className={CallAvatarAnimation}>
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={avatarUrl}
|
||||
alt={roomName}
|
||||
renderFallback={() => (
|
||||
<RoomIcon
|
||||
roomType={room.getType()}
|
||||
size="400"
|
||||
joinRule={room.getJoinRule()}
|
||||
filled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="H3" align="Center" truncate>
|
||||
{roomName}
|
||||
</Text>
|
||||
<Text size="T300">Incoming Call</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{!livekitSupported && (
|
||||
<Text
|
||||
style={{ margin: 'auto', color: color.Critical.Main }}
|
||||
size="L400"
|
||||
align="Center"
|
||||
>
|
||||
Your homeserver does not support calling.
|
||||
</Text>
|
||||
)}
|
||||
{!webRTCSupported && (
|
||||
<Text
|
||||
style={{ margin: 'auto', color: color.Critical.Main }}
|
||||
size="L400"
|
||||
align="Center"
|
||||
>
|
||||
Your browser does not support WebRTC, which is required for calling.
|
||||
</Text>
|
||||
)}
|
||||
<Box direction="Column" gap="300">
|
||||
<Button
|
||||
style={{ flexGrow: 1 }}
|
||||
variant="Success"
|
||||
size="400"
|
||||
radii="400"
|
||||
onClick={() => onAnswer(room, info.intent === 'video')}
|
||||
before={
|
||||
<Icon
|
||||
size="200"
|
||||
src={info.intent === 'video' ? Icons.VideoCamera : Icons.Phone}
|
||||
filled
|
||||
/>
|
||||
}
|
||||
disabled={!canAnswer}
|
||||
>
|
||||
<Text as="span" size="B400">
|
||||
Answer
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
style={{ flexGrow: 1 }}
|
||||
variant="Success"
|
||||
fill="Soft"
|
||||
size="400"
|
||||
radii="400"
|
||||
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
||||
before={<Icon size="200" src={Icons.Cross} filled />}
|
||||
>
|
||||
<Text as="span" size="B400">
|
||||
{dm ? 'Reject' : 'Ignore'}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
<audio ref={audioRef} loop style={{ display: 'none' }}>
|
||||
<source src={CallSound} type="audio/ogg" />
|
||||
</audio>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type IncomingCallListenerProps = {
|
||||
callEmbed?: CallEmbed;
|
||||
joined?: boolean;
|
||||
};
|
||||
function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) {
|
||||
const mx = useMatrixClient();
|
||||
const directs = useAtomValue(mDirectAtom);
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
|
||||
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
|
||||
const startCall = useCallStart(dm);
|
||||
|
||||
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
||||
async (event, room, toStartOfTimeline, removed, data) => {
|
||||
// only process rtc notification reference events.
|
||||
// we do not want to wait to decrypt all events.
|
||||
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
|
||||
|
||||
if (event.isEncrypted()) {
|
||||
if (!event.isBeingDecrypted()) {
|
||||
await event.attemptDecryption(mx.getCrypto() as CryptoBackend);
|
||||
}
|
||||
await event.getDecryptionPromise();
|
||||
}
|
||||
|
||||
if (
|
||||
!room ||
|
||||
event.getType() !== EventType.RTCNotification ||
|
||||
event.getSender() === mx.getSafeUserId() ||
|
||||
!data.liveEvent
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sender = event.getSender();
|
||||
const content = event.getContent<IRTCNotificationContent>();
|
||||
const senderTs =
|
||||
content.sender_ts - event.getTs() > 20000 ? event.getTs() : content.sender_ts;
|
||||
const lifetime = Math.min(content.lifetime, 120000);
|
||||
const notificationType = content.notification_type;
|
||||
const relation =
|
||||
event.getRelation()?.rel_type === RelationType.Reference ? event.getRelation() : undefined;
|
||||
const refEventId = relation?.event_id;
|
||||
|
||||
const mention =
|
||||
content['m.mentions'].room || content['m.mentions'].user_ids?.includes(mx.getSafeUserId());
|
||||
if (!sender || !refEventId || !mention || Date.now() >= senderTs + lifetime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const powerLevelsEvent = getStateEvent(room, StateEvent.RoomPowerLevels);
|
||||
const powerLevels = getPowersLevelFromMatrixEvent(powerLevelsEvent);
|
||||
const creators = getRoomCreatorsForRoomId(mx, room.roomId);
|
||||
const permissions = getRoomPermissionsAPI(creators, powerLevels);
|
||||
|
||||
const hasCallPermission = permissions.stateEvent(
|
||||
StateEvent.GroupCallMemberPrefix,
|
||||
mx.getSafeUserId()
|
||||
);
|
||||
if (!hasCallPermission) return;
|
||||
|
||||
const info: IncomingCallInfo = {
|
||||
room,
|
||||
sender,
|
||||
senderTs,
|
||||
lifetime,
|
||||
intent:
|
||||
'm.call.intent' in content && typeof content['m.call.intent'] === 'string'
|
||||
? content['m.call.intent']
|
||||
: undefined,
|
||||
notificationType,
|
||||
refEventId,
|
||||
};
|
||||
|
||||
setCallInfo(info);
|
||||
},
|
||||
[mx]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||
return () => {
|
||||
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
||||
};
|
||||
}, [mx, handleTimelineEvent]);
|
||||
|
||||
const handleIgnore = useCallback(() => {
|
||||
setCallInfo(undefined);
|
||||
}, []);
|
||||
|
||||
const handleReject = useCallback(
|
||||
(room: Room, eventId: string) => {
|
||||
mx.sendEvent(room.roomId, EventType.RTCDecline, {
|
||||
'm.relates_to': {
|
||||
rel_type: RelationType.Reference,
|
||||
event_id: eventId,
|
||||
},
|
||||
});
|
||||
setCallInfo(undefined);
|
||||
},
|
||||
[mx]
|
||||
);
|
||||
|
||||
const handleAnswer = useCallback(
|
||||
(room: Room, video: boolean) => {
|
||||
startCall(room, { microphone: true, video, sound: true });
|
||||
setCallInfo(undefined);
|
||||
navigateRoom(room.roomId);
|
||||
},
|
||||
[startCall, navigateRoom]
|
||||
);
|
||||
|
||||
if (callInfo && callEmbed?.roomId === callInfo.room.roomId) {
|
||||
return null;
|
||||
}
|
||||
return !joined && callInfo ? (
|
||||
<IncomingCall
|
||||
dm={dm}
|
||||
info={callInfo}
|
||||
onIgnore={handleIgnore}
|
||||
onAnswer={handleAnswer}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
@@ -33,19 +374,6 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
type Corner = 'se' | 'sw' | 'ne' | 'nw';
|
||||
|
||||
/** Normalise the element to top/left positioning so resize math is uniform. */
|
||||
function normaliseToTopLeft(el: HTMLDivElement) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
el.style.top = `${rect.top}px`;
|
||||
el.style.left = `${rect.left}px`;
|
||||
el.style.right = 'auto';
|
||||
el.style.bottom = 'auto';
|
||||
el.style.width = `${rect.width}px`;
|
||||
el.style.height = `${rect.height}px`;
|
||||
}
|
||||
|
||||
type CallEmbedProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
@@ -57,254 +385,81 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
const selectedRoom = useSelectedRoom();
|
||||
const chat = useAtomValue(callChatAtom);
|
||||
const screenSize = useScreenSizeContext();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
const chatOnlyView = chat && screenSize !== ScreenSize.Desktop;
|
||||
const inCallRoom = callEmbed && selectedRoom === callEmbed.roomId;
|
||||
const callActive = callEmbed && joined;
|
||||
const callVisible = inCallRoom && callActive && !chatOnlyView;
|
||||
const pipMode = callActive && !inCallRoom;
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
const pipDragRef = useRef<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
origLeft: number;
|
||||
origTop: number;
|
||||
dragged: boolean;
|
||||
const pipDragRef = React.useRef<{
|
||||
startX: number; startY: number; origLeft: number; origTop: number; dragged: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// useCallEmbedPlacementSync writes top/left/width/height directly on this element.
|
||||
// Override those in PiP mode; clear them when returning so it can take back control.
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
const el = callEmbedRef.current;
|
||||
if (!el) return;
|
||||
if (pipMode) {
|
||||
el.style.top = 'auto';
|
||||
el.style.left = 'auto';
|
||||
el.style.bottom = '72px';
|
||||
el.style.right = '16px';
|
||||
el.style.width = '280px';
|
||||
el.style.height = '158px';
|
||||
el.style.borderRadius = '12px';
|
||||
el.style.overflow = 'hidden';
|
||||
el.style.zIndex = '99';
|
||||
el.style.boxShadow = '0 8px 32px rgba(0,0,0,0.55)';
|
||||
el.style.border = '1px solid rgba(255,255,255,0.1)';
|
||||
el.style.visibility = 'visible';
|
||||
el.style.top = 'auto'; el.style.left = 'auto';
|
||||
el.style.bottom = '72px'; el.style.right = '16px';
|
||||
el.style.width = '280px'; el.style.height = '158px';
|
||||
el.style.borderRadius = '12px'; el.style.overflow = 'hidden';
|
||||
el.style.zIndex = '99'; el.style.boxShadow = '0 8px 32px rgba(0,0,0,0.55)';
|
||||
el.style.border = '1px solid rgba(255,255,255,0.1)'; el.style.visibility = 'visible';
|
||||
} else {
|
||||
el.style.top = '';
|
||||
el.style.left = '';
|
||||
el.style.bottom = '';
|
||||
el.style.right = '';
|
||||
el.style.width = '';
|
||||
el.style.height = '';
|
||||
el.style.borderRadius = '';
|
||||
el.style.overflow = '';
|
||||
el.style.zIndex = '';
|
||||
el.style.boxShadow = '';
|
||||
el.style.border = '';
|
||||
['top','left','bottom','right','width','height','borderRadius','overflow','zIndex','boxShadow','border'].forEach(p => { (el.style as any)[p] = ''; });
|
||||
el.style.visibility = callVisible ? '' : 'hidden';
|
||||
}
|
||||
}, [pipMode, callVisible]);
|
||||
|
||||
// ── Drag to move ────────────────────────────────────────────────────────────
|
||||
const handlePipMouseDown = (e: React.MouseEvent) => {
|
||||
const el = callEmbedRef.current;
|
||||
if (!el) return;
|
||||
const el = callEmbedRef.current; if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
pipDragRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origLeft: rect.left,
|
||||
origTop: rect.top,
|
||||
dragged: false,
|
||||
};
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
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';
|
||||
}
|
||||
const dx = ev.clientX - pipDragRef.current.startX, dy = ev.clientY - pipDragRef.current.startY;
|
||||
if (!pipDragRef.current.dragged && Math.abs(dx)+Math.abs(dy) > 5) { pipDragRef.current.dragged = true; document.body.style.cursor = 'grabbing'; document.body.style.userSelect = 'none'; }
|
||||
if (pipDragRef.current.dragged) {
|
||||
const newLeft = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx));
|
||||
const newTop = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy));
|
||||
el.style.left = `${newLeft}px`;
|
||||
el.style.top = `${newTop}px`;
|
||||
el.style.right = 'auto';
|
||||
el.style.bottom = 'auto';
|
||||
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 onMouseUp = () => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
setTimeout(() => {
|
||||
if (pipDragRef.current) pipDragRef.current.dragged = false;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; setTimeout(() => { if (pipDragRef.current) pipDragRef.current.dragged = false; }, 0); };
|
||||
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
const handlePipTouchStart = (e: React.TouchEvent) => {
|
||||
const el = callEmbedRef.current;
|
||||
if (!el || e.touches.length !== 1) return;
|
||||
const touch = e.touches[0];
|
||||
const rect = el.getBoundingClientRect();
|
||||
pipDragRef.current = {
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
origLeft: rect.left,
|
||||
origTop: rect.top,
|
||||
dragged: false,
|
||||
};
|
||||
|
||||
const onTouchMove = (ev: TouchEvent) => {
|
||||
if (!pipDragRef.current || !el || ev.touches.length !== 1) return;
|
||||
ev.preventDefault();
|
||||
const t = ev.touches[0];
|
||||
const dx = t.clientX - pipDragRef.current.startX;
|
||||
const dy = t.clientY - pipDragRef.current.startY;
|
||||
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) {
|
||||
pipDragRef.current.dragged = true;
|
||||
}
|
||||
if (pipDragRef.current.dragged) {
|
||||
const newLeft = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx));
|
||||
const newTop = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy));
|
||||
el.style.left = `${newLeft}px`;
|
||||
el.style.top = `${newTop}px`;
|
||||
el.style.right = 'auto';
|
||||
el.style.bottom = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
document.removeEventListener('touchend', onTouchEnd);
|
||||
setTimeout(() => {
|
||||
if (pipDragRef.current) pipDragRef.current.dragged = false;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
|
||||
const handlePipClick = (roomId: string) => {
|
||||
if (pipDragRef.current?.dragged) return;
|
||||
navigateRoom(roomId);
|
||||
};
|
||||
|
||||
// ── Resize from corner handles ───────────────────────────────────────────────
|
||||
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const el = callEmbedRef.current;
|
||||
if (!el) return;
|
||||
|
||||
e.stopPropagation(); e.preventDefault();
|
||||
const el = callEmbedRef.current; if (!el) return;
|
||||
normaliseToTopLeft(el);
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startW = el.offsetWidth;
|
||||
const startH = el.offsetHeight;
|
||||
const startL = parseFloat(el.style.left);
|
||||
const startT = parseFloat(el.style.top);
|
||||
|
||||
document.body.style.cursor = `${corner}-resize`;
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
const dx = ev.clientX - startX;
|
||||
const dy = ev.clientY - startY;
|
||||
|
||||
let w = startW;
|
||||
let h = startH;
|
||||
let l = startL;
|
||||
let t = startT;
|
||||
|
||||
if (corner === 'se') { w = startW + dx; h = startH + dy; }
|
||||
if (corner === 'sw') { w = startW - dx; h = startH + dy; l = startL + startW - Math.max(PIP_MIN_W, w); }
|
||||
if (corner === 'ne') { w = startW + dx; h = startH - dy; t = startT + startH - Math.max(PIP_MIN_H, h); }
|
||||
if (corner === 'nw') { w = startW - dx; h = startH - dy; l = startL + startW - Math.max(PIP_MIN_W, w); t = startT + startH - Math.max(PIP_MIN_H, h); }
|
||||
|
||||
w = Math.max(PIP_MIN_W, Math.min(w, window.innerWidth));
|
||||
h = Math.max(PIP_MIN_H, Math.min(h, window.innerHeight));
|
||||
l = Math.max(0, Math.min(l, window.innerWidth - PIP_MIN_W));
|
||||
t = Math.max(0, Math.min(t, window.innerHeight - PIP_MIN_H));
|
||||
|
||||
el.style.width = `${w}px`;
|
||||
el.style.height = `${h}px`;
|
||||
el.style.left = `${l}px`;
|
||||
el.style.top = `${t}px`;
|
||||
const sx = e.clientX, sy = e.clientY, sw = el.offsetWidth, sh = el.offsetHeight;
|
||||
const sl = parseFloat(el.style.left), st = parseFloat(el.style.top);
|
||||
document.body.style.cursor = `${corner}-resize`; document.body.style.userSelect = 'none';
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const dx = ev.clientX-sx, dy = ev.clientY-sy;
|
||||
let w = sw, h = sh, l = sl, t = st;
|
||||
if (corner==='se'){w=sw+dx;h=sh+dy;} if (corner==='sw'){w=sw-dx;h=sh+dy;l=sl+sw-Math.max(PIP_MIN_W,w);}
|
||||
if (corner==='ne'){w=sw+dx;h=sh-dy;t=st+sh-Math.max(PIP_MIN_H,h);} if (corner==='nw'){w=sw-dx;h=sh-dy;l=sl+sw-Math.max(PIP_MIN_W,w);t=st+sh-Math.max(PIP_MIN_H,h);}
|
||||
w=Math.max(PIP_MIN_W,Math.min(w,window.innerWidth)); h=Math.max(PIP_MIN_H,Math.min(h,window.innerHeight));
|
||||
l=Math.max(0,Math.min(l,window.innerWidth-PIP_MIN_W)); t=Math.max(0,Math.min(t,window.innerHeight-PIP_MIN_H));
|
||||
el.style.width=`${w}px`; el.style.height=`${h}px`; el.style.left=`${l}px`; el.style.top=`${t}px`;
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
// Corner handle style helper
|
||||
const cornerHandle = (corner: Corner): React.CSSProperties => {
|
||||
const s = corner.includes('s');
|
||||
const e = corner.includes('e');
|
||||
return {
|
||||
position: 'absolute',
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
[s ? 'bottom' : 'top']: 0,
|
||||
[e ? 'right' : 'left']: 0,
|
||||
cursor: `${corner}-resize`,
|
||||
zIndex: 2,
|
||||
};
|
||||
};
|
||||
|
||||
// Grip dot pattern rendered inside each handle
|
||||
const gripDots = (corner: Corner) => {
|
||||
const s = corner.includes('s');
|
||||
const e = corner.includes('e');
|
||||
// 3 dots arranged in an L toward the active corner
|
||||
const dots: React.CSSProperties[] = [];
|
||||
const r = 2; // dot radius px
|
||||
const gap = 5;
|
||||
const base = 3;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
for (let j = 0; j < 3; j++) {
|
||||
if (i + j < 2) continue; // only the 3 corner-most dots
|
||||
dots.push({
|
||||
position: 'absolute',
|
||||
width: r * 2,
|
||||
height: r * 2,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.45)',
|
||||
[s ? 'bottom' : 'top']: base + i * gap,
|
||||
[e ? 'right' : 'left']: base + j * gap,
|
||||
});
|
||||
}
|
||||
}
|
||||
return dots;
|
||||
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor=''; document.body.style.userSelect=''; };
|
||||
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<CallEmbedContextProvider value={callEmbed}>
|
||||
{callEmbed && <CallUtils embed={callEmbed} />}
|
||||
<CallEmbedRefContextProvider value={callEmbedRef}>{children}</CallEmbedRefContextProvider>
|
||||
<CallEmbedRefContextProvider value={callEmbedRef}>
|
||||
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
|
||||
{children}
|
||||
</CallEmbedRefContextProvider>
|
||||
<div
|
||||
data-call-embed-container
|
||||
ref={callEmbedRef}
|
||||
style={{
|
||||
visibility: callVisible ? undefined : 'hidden',
|
||||
position: 'fixed',
|
||||
@@ -313,58 +468,26 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
width: '100%',
|
||||
height: '50%',
|
||||
}}
|
||||
ref={callEmbedRef}
|
||||
>
|
||||
{pipMode && callEmbed && (
|
||||
<>
|
||||
{/* Drag-to-move overlay (zIndex 1, sits over the iframe) */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Return to call"
|
||||
onMouseDown={handlePipMouseDown}
|
||||
onTouchStart={handlePipTouchStart}
|
||||
onClick={() => handlePipClick(callEmbed.roomId)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handlePipClick(callEmbed.roomId)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
background: 'transparent',
|
||||
cursor: 'grab',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-end',
|
||||
padding: '6px',
|
||||
}}
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<div style={{ background:'rgba(0,0,0,0.65)', backdropFilter:'blur(4px)', borderRadius:'6px', padding:'3px 8px', color:'#fff', fontSize:'11px', fontWeight:600, pointerEvents:'none' }}>
|
||||
↗ Return to call
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Corner resize handles (zIndex 2, above the drag overlay) */}
|
||||
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => (
|
||||
<div
|
||||
key={corner}
|
||||
style={cornerHandle(corner)}
|
||||
onMouseDown={(e) => handleResizeMouseDown(e, corner)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{gripDots(corner).map((style, i) => (
|
||||
<div key={i} style={style} />
|
||||
))}
|
||||
</div>
|
||||
{(['se','sw','ne','nw'] as Corner[]).map((corner) => (
|
||||
<div key={corner} onMouseDown={(e) => handleResizeMouseDown(e, corner)} onClick={(e) => e.stopPropagation()}
|
||||
style={{ position:'absolute', width:'18px', height:'18px', [corner.includes('s')?'bottom':'top']:0, [corner.includes('e')?'right':'left']:0, cursor:`${corner}-resize`, zIndex:2 }} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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>
|
||||
)
|
||||
);
|
||||
@@ -157,10 +157,12 @@ const getInlineElement = (node: ChildNode, processText: ProcessTextCallback): In
|
||||
return children;
|
||||
}
|
||||
|
||||
return node.childNodes.flatMap((child) => getInlineElement(child, processText));
|
||||
const children = node.childNodes.flatMap((child) => getInlineElement(child, processText));
|
||||
if (children.length === 0) return [{ text: '' }];
|
||||
return children;
|
||||
}
|
||||
|
||||
return [];
|
||||
return [{ text: '' }];
|
||||
};
|
||||
|
||||
const parseBlockquoteNode = (
|
||||
@@ -191,7 +193,7 @@ const parseBlockquoteNode = (
|
||||
|
||||
if (child.name === 'p') {
|
||||
appendLine();
|
||||
quoteLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
|
||||
quoteLines.push(getInlineElement(child, processText));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -228,9 +230,13 @@ const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElemen
|
||||
children: [{ text }],
|
||||
}));
|
||||
const childCode = node.children[0];
|
||||
const className =
|
||||
isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs.class ?? '' : '';
|
||||
const prefix = { text: `${mdSequence}${className.replace('language-', '')}` };
|
||||
const attribs =
|
||||
isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs : undefined;
|
||||
const languageClass = attribs?.class;
|
||||
const customLabel = attribs?.['data-label'];
|
||||
const prefix = {
|
||||
text: `${mdSequence}${customLabel ?? languageClass?.replace('language-', '') ?? ''}`,
|
||||
};
|
||||
const suffix = { text: mdSequence };
|
||||
return [
|
||||
{ type: BlockType.Paragraph, children: [prefix] },
|
||||
@@ -249,10 +255,67 @@ const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElemen
|
||||
},
|
||||
];
|
||||
};
|
||||
const parseListNode = (
|
||||
|
||||
const parseListMarkdown = (
|
||||
node: Element,
|
||||
processText: ProcessTextCallback
|
||||
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
|
||||
processText: ProcessTextCallback,
|
||||
depth = 0
|
||||
): ParagraphElement[] => {
|
||||
const md = isTag(node) && node.name === 'ul' ? '*' : '-';
|
||||
const prefix = node.attribs['data-md'] ?? md;
|
||||
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
|
||||
const [digitOrChar] = prefix.match(/^[\da-zA-Z]/) ?? [];
|
||||
|
||||
const digit = digitOrChar ? parseInt(digitOrChar, 10) : undefined;
|
||||
|
||||
const lines: ParagraphElement[] = [];
|
||||
let lineNo = digit === undefined || Number.isNaN(digit) ? digitOrChar ?? 1 : digit;
|
||||
const pushLine = (line: InlineElement[]) => {
|
||||
lines.push({
|
||||
type: BlockType.Paragraph,
|
||||
children: [
|
||||
{
|
||||
text: `${Array(depth + 1).join(' ')}${starOrHyphen ? `${starOrHyphen} ` : `${lineNo}. `}`,
|
||||
},
|
||||
...line,
|
||||
],
|
||||
});
|
||||
if (typeof lineNo === 'string') {
|
||||
lineNo = String.fromCharCode(lineNo.charCodeAt(0) + 1);
|
||||
} else {
|
||||
lineNo += 1;
|
||||
}
|
||||
};
|
||||
|
||||
node.children.forEach((child) => {
|
||||
if (isText(child)) {
|
||||
pushLine([{ text: processText(child.data) }]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTag(child)) {
|
||||
if (child.name === 'ul' || child.name === 'ol') {
|
||||
lines.push(...parseListMarkdown(child, processText, depth + 1));
|
||||
return;
|
||||
}
|
||||
if (child.name === 'li') {
|
||||
child.children.forEach((c) => {
|
||||
if (isTag(c) && (c.name === 'ul' || c.name === 'ol')) {
|
||||
lines.push(...parseListMarkdown(c, processText, depth + 1));
|
||||
return;
|
||||
}
|
||||
pushLine(getInlineElement(c, processText));
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
pushLine(getInlineElement(child, processText));
|
||||
});
|
||||
|
||||
return lines;
|
||||
};
|
||||
const parseListLines = (children: ChildNode[], processText: ProcessTextCallback) => {
|
||||
const listLines: Array<InlineElement[]> = [];
|
||||
let lineHolder: InlineElement[] = [];
|
||||
|
||||
@@ -263,7 +326,7 @@ const parseListNode = (
|
||||
lineHolder = [];
|
||||
};
|
||||
|
||||
node.children.forEach((child) => {
|
||||
children.forEach((child) => {
|
||||
if (isText(child)) {
|
||||
lineHolder.push({ text: processText(child.data) });
|
||||
return;
|
||||
@@ -277,7 +340,7 @@ const parseListNode = (
|
||||
|
||||
if (child.name === 'li') {
|
||||
appendLine();
|
||||
listLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
|
||||
listLines.push(getInlineElement(child, processText));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -286,24 +349,23 @@ const parseListNode = (
|
||||
});
|
||||
appendLine();
|
||||
|
||||
const mdSequence = node.attribs['data-md'];
|
||||
if (mdSequence !== undefined) {
|
||||
const prefix = mdSequence || '-';
|
||||
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
|
||||
return listLines.map((lineChildren) => ({
|
||||
type: BlockType.Paragraph,
|
||||
children: [
|
||||
{ text: `${starOrHyphen ? `${starOrHyphen} ` : `${prefix}. `} ` },
|
||||
...lineChildren,
|
||||
],
|
||||
}));
|
||||
return listLines;
|
||||
};
|
||||
const parseListNode = (
|
||||
node: Element,
|
||||
processText: ProcessTextCallback
|
||||
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
|
||||
if (node.attribs['data-md'] !== undefined) {
|
||||
return parseListMarkdown(node, processText);
|
||||
}
|
||||
|
||||
const lines = parseListLines(node.childNodes, processText);
|
||||
|
||||
if (node.name === 'ol') {
|
||||
return [
|
||||
{
|
||||
type: BlockType.OrderedList,
|
||||
children: listLines.map((lineChildren) => ({
|
||||
children: lines.map((lineChildren) => ({
|
||||
type: BlockType.ListItem,
|
||||
children: lineChildren,
|
||||
})),
|
||||
@@ -314,7 +376,7 @@ const parseListNode = (
|
||||
return [
|
||||
{
|
||||
type: BlockType.UnorderedList,
|
||||
children: listLines.map((lineChildren) => ({
|
||||
children: lines.map((lineChildren) => ({
|
||||
type: BlockType.ListItem,
|
||||
children: lineChildren,
|
||||
})),
|
||||
@@ -325,7 +387,7 @@ const parseHeadingNode = (
|
||||
node: Element,
|
||||
processText: ProcessTextCallback
|
||||
): HeadingElement | ParagraphElement => {
|
||||
const children = node.children.flatMap((child) => getInlineElement(child, processText));
|
||||
const children = getInlineElement(node, processText);
|
||||
|
||||
const headingMatch = node.name.match(/^h([123456])$/);
|
||||
const [, g1AsLevel] = headingMatch ?? ['h3', '3'];
|
||||
@@ -388,7 +450,7 @@ export const domToEditorInput = (
|
||||
appendLine();
|
||||
children.push({
|
||||
type: BlockType.Paragraph,
|
||||
children: node.children.flatMap((child) => getInlineElement(child, processText)),
|
||||
children: getInlineElement(node, processText),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '../../plugins/markdown';
|
||||
import { findAndReplace } from '../../utils/findAndReplace';
|
||||
import { sanitizeForRegex } from '../../utils/regex';
|
||||
import { getCanonicalAliasOrRoomId, isUserId } from '../../utils/matrix';
|
||||
import { isUserId } from '../../utils/matrix';
|
||||
|
||||
export type OutputOptions = {
|
||||
allowTextFormatting?: boolean;
|
||||
@@ -215,7 +215,7 @@ export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): M
|
||||
if (node.name === '@room') {
|
||||
mentionData.room = true;
|
||||
}
|
||||
|
||||
|
||||
if (isUserId(node.id) && node.id !== mx.getUserId()) {
|
||||
mentionData.users.add(node.id);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ export const UrlPreviewImg = style([
|
||||
objectPosition: 'center',
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
|
||||
':hover': {
|
||||
filter: 'brightness(0.8)',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { IPreviewUrlResponse } from 'matrix-js-sdk';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
|
||||
import { ImageOverlay } from '../ImageOverlay';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
|
||||
@@ -12,6 +13,8 @@ import * as css from './UrlPreviewCard.css';
|
||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { ImageViewer } from '../image-viewer';
|
||||
import { onEnterOrSpace } from '../../utils/keyboard';
|
||||
|
||||
const linkStyles = { color: color.Success.Main };
|
||||
|
||||
@@ -19,6 +22,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
({ url, ts, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [viewer, setViewer] = useState(false);
|
||||
const [previewStatus, loadPreview] = useAsyncCallback(
|
||||
useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx])
|
||||
);
|
||||
@@ -30,7 +34,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
if (previewStatus.status === AsyncStatus.Error) return null;
|
||||
|
||||
const renderContent = (prev: IPreviewUrlResponse) => {
|
||||
const imgUrl = mxcUrlToHttp(
|
||||
const thumbUrl = mxcUrlToHttp(
|
||||
mx,
|
||||
prev['og:image'] || '',
|
||||
useAuthentication,
|
||||
@@ -40,9 +44,31 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
false
|
||||
);
|
||||
|
||||
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
|
||||
|
||||
return (
|
||||
<>
|
||||
{imgUrl && <UrlPreviewImg src={imgUrl} alt={prev['og:title']} title={prev['og:title']} />}
|
||||
{thumbUrl && (
|
||||
<UrlPreviewImg
|
||||
src={thumbUrl}
|
||||
alt={prev['og:title']}
|
||||
title={prev['og:title']}
|
||||
tabIndex={0}
|
||||
onKeyDown={(evt) => onEnterOrSpace(() => setViewer(true))(evt)}
|
||||
onClick={() => setViewer(true)}
|
||||
/>
|
||||
)}
|
||||
{imgUrl && (
|
||||
<ImageOverlay
|
||||
src={imgUrl}
|
||||
alt={prev['og:title']}
|
||||
viewer={viewer}
|
||||
requestClose={() => {
|
||||
setViewer(false);
|
||||
}}
|
||||
renderViewer={(p) => <ImageViewer {...p} />}
|
||||
/>
|
||||
)}
|
||||
<UrlPreviewContent>
|
||||
<Text
|
||||
style={linkStyles}
|
||||
|
||||
@@ -28,7 +28,11 @@ import { copyToClipboard } from '../../utils/dom';
|
||||
import { getExploreServerPath } from '../../pages/pathUtils';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { factoryRoomIdByAtoZ } from '../../utils/sort';
|
||||
import { useMutualRooms, useMutualRoomsSupport } from '../../hooks/useMutualRooms';
|
||||
import {
|
||||
useMutualRooms,
|
||||
useMutualRoomsSupport,
|
||||
useUnstableMutualRoomsSupport,
|
||||
} from '../../hooks/useMutualRooms';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { useDirectRooms } from '../../pages/client/direct/useDirectRooms';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
@@ -233,7 +237,9 @@ type MutualRoomsData = {
|
||||
export function MutualRoomsChip({ userId }: { userId: string }) {
|
||||
const mx = useMatrixClient();
|
||||
const mutualRoomSupported = useMutualRoomsSupport();
|
||||
const mutualRoomUnstable = useUnstableMutualRoomsSupport();
|
||||
const mutualRoomsState = useMutualRooms(userId);
|
||||
console.log(mutualRoomSupported, mutualRoomsState);
|
||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||
const closeUserRoomProfile = useCloseUserRoomProfile();
|
||||
const directs = useDirectRooms();
|
||||
@@ -279,7 +285,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) {
|
||||
|
||||
if (
|
||||
userId === mx.getSafeUserId() ||
|
||||
!mutualRoomSupported ||
|
||||
(!mutualRoomSupported && !mutualRoomUnstable) ||
|
||||
mutualRoomsState.status === AsyncStatus.Error
|
||||
) {
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { Chip, Text } from 'folds';
|
||||
import { Chip, Icon, Icons, Text } from 'folds';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useRoomName } from '../../hooks/useRoomMeta';
|
||||
import { RoomIcon } from '../../components/room-avatar';
|
||||
@@ -38,7 +38,11 @@ export function CallRoomName({ room }: CallRoomNameProps) {
|
||||
variant="Background"
|
||||
radii="Pill"
|
||||
before={
|
||||
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} filled />
|
||||
dm ? (
|
||||
<Icon size="200" src={Icons.VolumeHigh} filled />
|
||||
) : (
|
||||
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} filled />
|
||||
)
|
||||
}
|
||||
onClick={() => navigateRoom(room.roomId)}
|
||||
>
|
||||
|
||||
@@ -14,11 +14,20 @@ import { CallMemberRenderer } from './CallMemberCard';
|
||||
import * as css from './styles.css';
|
||||
import { CallControls } from './CallControls';
|
||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
|
||||
function LivekitServerMissingMessage() {
|
||||
return (
|
||||
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
|
||||
Your homeserver does not support calling. But you can still join call started by others.
|
||||
Your homeserver does not support calling.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function WebRTCMissingError() {
|
||||
return (
|
||||
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
|
||||
Your browser does not support WebRTC, which is required for calling.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -26,16 +35,22 @@ function LivekitServerMissingMessage() {
|
||||
function JoinMessage({
|
||||
hasParticipant,
|
||||
livekitSupported,
|
||||
rtcSupported,
|
||||
}: {
|
||||
hasParticipant?: boolean;
|
||||
livekitSupported?: boolean;
|
||||
rtcSupported?: boolean;
|
||||
}) {
|
||||
if (hasParticipant) return null;
|
||||
if (rtcSupported === false) {
|
||||
return <WebRTCMissingError />;
|
||||
}
|
||||
|
||||
if (livekitSupported === false) {
|
||||
return <LivekitServerMissingMessage />;
|
||||
}
|
||||
|
||||
if (hasParticipant) return null;
|
||||
|
||||
return (
|
||||
<Text style={{ margin: 'auto' }} size="L400" align="Center">
|
||||
Voice chat’s empty — Be the first to hop in!
|
||||
@@ -63,12 +78,16 @@ function CallPrescreen() {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const livekitSupported = useLivekitSupport();
|
||||
const rtcSupported = webRTCSupported();
|
||||
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const creators = useRoomCreators(room);
|
||||
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
const hasPermission = permissions.event(StateEvent.GroupCallMemberPrefix, mx.getSafeUserId());
|
||||
const hasPermission = permissions.stateEvent(
|
||||
StateEvent.GroupCallMemberPrefix,
|
||||
mx.getSafeUserId()
|
||||
);
|
||||
|
||||
const callSession = useCallSession(room);
|
||||
const callMembers = useCallMembers(room, callSession);
|
||||
@@ -77,7 +96,7 @@ function CallPrescreen() {
|
||||
const callEmbed = useCallEmbed();
|
||||
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
|
||||
|
||||
const canJoin = hasPermission && (livekitSupported || hasParticipant);
|
||||
const canJoin = hasPermission && livekitSupported && rtcSupported;
|
||||
|
||||
return (
|
||||
<Scroll variant="Surface" hideTrack>
|
||||
@@ -100,7 +119,11 @@ function CallPrescreen() {
|
||||
<Box className={css.PrescreenMessage} alignItems="Center">
|
||||
{!inOtherCall &&
|
||||
(hasPermission ? (
|
||||
<JoinMessage hasParticipant={hasParticipant} livekitSupported={livekitSupported} />
|
||||
<JoinMessage
|
||||
hasParticipant={hasParticipant}
|
||||
livekitSupported={livekitSupported}
|
||||
rtcSupported={rtcSupported}
|
||||
/>
|
||||
) : (
|
||||
<NoPermissionMessage />
|
||||
))}
|
||||
|
||||
@@ -23,12 +23,12 @@ import { useAtom, useAtomValue } from 'jotai';
|
||||
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
|
||||
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
|
||||
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
import { copyToClipboard } from '../../utils/dom';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
@@ -49,8 +49,8 @@ import {
|
||||
RoomNotificationMode,
|
||||
} from '../../hooks/useRoomsNotificationPreferences';
|
||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||
import { useRoomName } from '../../hooks/useRoomMeta';
|
||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
@@ -59,6 +59,8 @@ import { callChatAtom } from '../../state/callEmbed';
|
||||
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
|
||||
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
|
||||
import { livekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
|
||||
type RoomNavItemMenuProps = {
|
||||
room: Room;
|
||||
@@ -287,8 +289,18 @@ export function RoomNavItem({
|
||||
const autoDiscoveryInfo = useAutoDiscoveryInfo();
|
||||
|
||||
const handleStartCall: MouseEventHandler<HTMLAnchorElement> = (evt) => {
|
||||
// Do not join if no livekit support or call is not started by others
|
||||
if (!livekitSupport(autoDiscoveryInfo) && callMembers.length === 0) {
|
||||
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()
|
||||
);
|
||||
|
||||
// Do not join if missing permissions or no livekit support or no webRTC support
|
||||
if (!hasCallPermission || !livekitSupport(autoDiscoveryInfo) || !webRTCSupported()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -367,7 +379,7 @@ export function RoomNavItem({
|
||||
aria-label={notificationMode}
|
||||
/>
|
||||
)}
|
||||
{room.isCallRoom() && callMembers.length > 0 && (
|
||||
{callMembers.length > 0 && (
|
||||
<Badge variant="Critical" fill="Solid" size="400">
|
||||
<Text as="span" size="L400" truncate>
|
||||
{callMembers.length} Live
|
||||
|
||||
@@ -23,7 +23,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
|
||||
|
||||
const canEditPowers = permissions.stateEvent(StateEvent.PowerLevelTags, mx.getSafeUserId());
|
||||
const canEditPermissions = permissions.stateEvent(StateEvent.RoomPowerLevels, mx.getSafeUserId());
|
||||
const permissionGroups = usePermissionGroups(room.isCallRoom());
|
||||
const permissionGroups = usePermissionGroups();
|
||||
|
||||
const [powerEditor, setPowerEditor] = useState(false);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import { MessageEvent, StateEvent } from '../../../../types/matrix/room';
|
||||
import { PermissionGroup } from '../../common-settings/permissions';
|
||||
|
||||
export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => {
|
||||
export const usePermissionGroups = (): PermissionGroup[] => {
|
||||
const groups: PermissionGroup[] = useMemo(() => {
|
||||
const messagesGroup: PermissionGroup = {
|
||||
name: 'Messages',
|
||||
@@ -54,7 +54,7 @@ export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => {
|
||||
state: true,
|
||||
key: StateEvent.GroupCallMemberPrefix,
|
||||
},
|
||||
name: 'Join Call',
|
||||
name: 'Start or Join Call',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -216,13 +216,13 @@ export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => {
|
||||
|
||||
return [
|
||||
messagesGroup,
|
||||
...(isCallRoom ? [callSettingsGroup] : []),
|
||||
callSettingsGroup,
|
||||
moderationGroup,
|
||||
roomOverviewGroup,
|
||||
roomSettingsGroup,
|
||||
otherSettingsGroup,
|
||||
];
|
||||
}, [isCallRoom]);
|
||||
}, []);
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
@@ -16,21 +16,26 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||
import { CallView } from '../call/CallView';
|
||||
import { RoomViewHeader } from './RoomViewHeader';
|
||||
import { callChatAtom, callEmbedAtom } from '../../state/callEmbed';
|
||||
import { callChatAtom } from '../../state/callEmbed';
|
||||
import { CallChatView } from './CallChatView';
|
||||
import { useCallEmbed } from '../../hooks/useCallEmbed';
|
||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
|
||||
export function Room() {
|
||||
const { eventId } = useParams();
|
||||
const room = useRoom();
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const callSession = useCallSession(room);
|
||||
const callMembers = useCallMembers(room, callSession);
|
||||
const callEmbed = useCallEmbed();
|
||||
|
||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const screenSize = useScreenSizeContext();
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const members = useRoomMembers(mx, room.roomId);
|
||||
const chat = useAtomValue(callChatAtom);
|
||||
const callEmbed = useAtomValue(callEmbedAtom);
|
||||
const isDirect = useIsDirectRoom();
|
||||
|
||||
useKeyDown(
|
||||
@@ -45,7 +50,7 @@ export function Room() {
|
||||
)
|
||||
);
|
||||
|
||||
const callView = room.isCallRoom() || (isDirect && !!callEmbed && callEmbed.roomId === room.roomId);
|
||||
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
|
||||
|
||||
return (
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
RectCords,
|
||||
Badge,
|
||||
Spinner,
|
||||
Button,
|
||||
} from 'folds';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
@@ -69,6 +70,9 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
|
||||
type RoomMenuProps = {
|
||||
room: Room;
|
||||
@@ -254,6 +258,132 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
||||
);
|
||||
});
|
||||
|
||||
type CallMenuProps = {
|
||||
onVoiceCall: () => void;
|
||||
onVideoCall: () => void;
|
||||
requestClose: () => void;
|
||||
};
|
||||
const CallMenu = forwardRef<HTMLDivElement, CallMenuProps>(
|
||||
({ requestClose, onVoiceCall, onVideoCall }, ref) => {
|
||||
const handleVoice = () => {
|
||||
onVoiceCall();
|
||||
requestClose();
|
||||
};
|
||||
const handleVideo = () => {
|
||||
onVideoCall();
|
||||
requestClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu ref={ref} style={{ padding: config.space.S200, minWidth: toRem(150) }}>
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="L400">Start Call</Text>
|
||||
<Box direction="Column" gap="200">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Success"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
before={<Icon size="100" src={Icons.Phone} filled />}
|
||||
onClick={handleVoice}
|
||||
>
|
||||
<Text size="B300">Voice</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Success"
|
||||
radii="300"
|
||||
before={<Icon size="100" src={Icons.VideoCamera} filled />}
|
||||
onClick={handleVideo}
|
||||
>
|
||||
<Text size="B300">Video</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function CallButton() {
|
||||
const room = useRoom();
|
||||
const direct = useIsDirectRoom();
|
||||
|
||||
const callEmbed = useCallEmbed();
|
||||
const startCall = useCallStart(direct);
|
||||
const callStarted = callEmbed && callEmbed.roomId === room.roomId;
|
||||
const inAnotherCall = callEmbed && !callStarted;
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
{inAnotherCall ? (
|
||||
<Text size="L400">Already in another call — End the current call to join!</Text>
|
||||
) : (
|
||||
<Text>Call</Text>
|
||||
)}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
onClick={handleOpenMenu}
|
||||
onContextMenu={(evt) => {
|
||||
evt.preventDefault();
|
||||
startCall(room, {
|
||||
microphone: true,
|
||||
video: true,
|
||||
sound: true,
|
||||
});
|
||||
}}
|
||||
disabled={inAnotherCall || callStarted}
|
||||
aria-pressed={!!menuAnchor}
|
||||
>
|
||||
<Icon size="400" src={Icons.VideoCamera} filled={!!menuAnchor} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Bottom"
|
||||
align="Center"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<CallMenu
|
||||
onVideoCall={() => startCall(room, { microphone: true, video: true, sound: true })}
|
||||
onVoiceCall={() => startCall(room, { microphone: true, video: false, sound: true })}
|
||||
requestClose={() => setMenuAnchor(undefined)}
|
||||
/>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const mx = useMatrixClient();
|
||||
@@ -261,6 +391,17 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
const screenSize = useScreenSizeContext();
|
||||
const room = useRoom();
|
||||
const space = useSpaceOptionally();
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const creators = useRoomCreators(room);
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
|
||||
const hasCallPermission = permissions.stateEvent(
|
||||
StateEvent.GroupCallMemberPrefix,
|
||||
mx.getSafeUserId()
|
||||
);
|
||||
const livekitSupported = useLivekitSupport();
|
||||
const rtcSupported = webRTCSupported();
|
||||
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||
const direct = useIsDirectRoom();
|
||||
@@ -472,7 +613,9 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
|
||||
{!room.isCallRoom() && livekitSupported && rtcSupported && hasCallPermission && (
|
||||
<CallButton />
|
||||
)}
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
|
||||
@@ -286,7 +286,7 @@ export function Search({ requestClose }: SearchProps) {
|
||||
gap="100"
|
||||
>
|
||||
<Text size="H6" align="Center">
|
||||
{result ? 'No Match Found' : `No Rooms'}`}
|
||||
{result ? 'No Match Found' : 'No Rooms'}
|
||||
</Text>
|
||||
<Text size="T200" align="Center">
|
||||
{result
|
||||
|
||||
@@ -52,7 +52,7 @@ export const createCallEmbed = (
|
||||
const ongoing =
|
||||
MatrixRTCSession.sessionMembershipsForRoom(room, rtcSession.sessionDescription).length > 0;
|
||||
|
||||
const intent = CallEmbed.getIntent(dm, ongoing);
|
||||
const intent = CallEmbed.getIntent(dm, ongoing, pref?.video);
|
||||
const initialAudio = forceAudioOff ? false : (pref?.microphone ?? true);
|
||||
const initialVideo = pref?.video ?? false;
|
||||
const widget = CallEmbed.getWidget(mx, room, intent, themeKind, noiseSuppression, initialAudio, initialVideo);
|
||||
@@ -109,6 +109,7 @@ export const useCallJoined = (embed?: CallEmbed): boolean => {
|
||||
|
||||
export const useCallHangupEvent = (embed: CallEmbed, callback: () => void) => {
|
||||
useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback);
|
||||
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback);
|
||||
};
|
||||
|
||||
export const useCallMemberSoundSync = (embed: CallEmbed) => {
|
||||
|
||||
@@ -25,6 +25,10 @@ export const useDateFormatItems = (): DateFormatItem[] =>
|
||||
format: 'YYYY/MM/DD',
|
||||
name: 'YYYY/MM/DD',
|
||||
},
|
||||
{
|
||||
format: 'YYYY-MM-DD',
|
||||
name: 'YYYY-MM-DD',
|
||||
},
|
||||
{
|
||||
format: '',
|
||||
name: 'Custom',
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback } from 'react';
|
||||
import { MatrixClient, Method } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { AsyncState, useAsyncCallbackValue } from './useAsyncCallback';
|
||||
import { useSpecVersions } from './useSpecVersions';
|
||||
|
||||
export const useMutualRoomsSupport = (): boolean => {
|
||||
export const useUnstableMutualRoomsSupport = (): boolean => {
|
||||
const { unstable_features: unstableFeatures } = useSpecVersions();
|
||||
|
||||
const supported =
|
||||
@@ -14,16 +15,59 @@ export const useMutualRoomsSupport = (): boolean => {
|
||||
return !!supported;
|
||||
};
|
||||
|
||||
export const useMutualRoomsSupport = (): boolean => {
|
||||
const { unstable_features: unstableFeatures, versions } = useSpecVersions();
|
||||
|
||||
const supported =
|
||||
versions.includes('v1.19') ||
|
||||
unstableFeatures?.['uk.half-shot.msc2666.query_mutual_rooms.stable'];
|
||||
|
||||
return !!supported;
|
||||
};
|
||||
|
||||
type MutualRoomsOK = {
|
||||
joined: string[];
|
||||
next_batch?: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
const fetchAllMutualRooms = async (mx: MatrixClient, userId: string): Promise<string[]> => {
|
||||
const mutualRooms: Set<string> = new Set();
|
||||
|
||||
let nextBatch: string | undefined;
|
||||
do {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const result = await mx.http.authedRequest<MutualRoomsOK>(
|
||||
Method.Get,
|
||||
'/mutual_rooms',
|
||||
{
|
||||
user_id: userId,
|
||||
from: nextBatch,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
prefix: '/_matrix/client/v1',
|
||||
}
|
||||
);
|
||||
result.joined.forEach((r) => mutualRooms.add(r));
|
||||
nextBatch = result.next_batch;
|
||||
} while (typeof nextBatch === 'string');
|
||||
|
||||
return Array.from(mutualRooms);
|
||||
};
|
||||
|
||||
export const useMutualRooms = (userId: string): AsyncState<string[], unknown> => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const supported = useMutualRoomsSupport();
|
||||
const unstableSupport = useUnstableMutualRoomsSupport();
|
||||
const support = useMutualRoomsSupport();
|
||||
|
||||
const [mutualRoomsState] = useAsyncCallbackValue(
|
||||
useCallback(
|
||||
() => (supported ? mx._unstable_getSharedRooms(userId) : Promise.resolve([])),
|
||||
[mx, userId, supported]
|
||||
)
|
||||
useCallback(() => {
|
||||
if (support) return fetchAllMutualRooms(mx, userId);
|
||||
if (unstableSupport) return mx._unstable_getSharedRooms(userId);
|
||||
return Promise.resolve([]);
|
||||
}, [mx, userId, unstableSupport, support])
|
||||
);
|
||||
|
||||
return mutualRoomsState;
|
||||
|
||||
@@ -57,7 +57,7 @@ const fillMissingPowers = (powerLevels: IPowerLevels): IPowerLevels =>
|
||||
return draftPl;
|
||||
});
|
||||
|
||||
const getPowersLevelFromMatrixEvent = (mEvent?: MatrixEvent): IPowerLevels => {
|
||||
export const getPowersLevelFromMatrixEvent = (mEvent?: MatrixEvent): IPowerLevels => {
|
||||
const plContent = mEvent?.getContent<IPowerLevels>();
|
||||
|
||||
const powerLevels = !plContent ? DEFAULT_POWER_LEVELS : fillMissingPowers(plContent);
|
||||
|
||||
@@ -22,7 +22,7 @@ export function SpecVersions({ baseUrl, children }: { baseUrl: string; children:
|
||||
<Dialog>
|
||||
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
||||
<Text>
|
||||
Failed to connect to homeserver. Either homeserver is down or your internet.
|
||||
Unable to connect to the homeserver. The homeserver or your internet connection may be down.
|
||||
</Text>
|
||||
<Button variant="Critical" onClick={retry}>
|
||||
<Text as="span" size="B400">
|
||||
|
||||
@@ -51,12 +51,36 @@ export class CallEmbed {
|
||||
|
||||
private styleRetryObserver?: MutationObserver;
|
||||
|
||||
static getIntent(dm: boolean, ongoing: boolean): ElementCallIntent {
|
||||
if (ongoing) {
|
||||
return dm ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExisting;
|
||||
static getIntent(dm: boolean, ongoing: boolean, video?: boolean): ElementCallIntent {
|
||||
if (dm && ongoing) {
|
||||
return video ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExistingDMVoice;
|
||||
}
|
||||
if (dm) {
|
||||
return video ? ElementCallIntent.StartCallDM : ElementCallIntent.StartCallDMVoice;
|
||||
}
|
||||
|
||||
return dm ? ElementCallIntent.StartCallDM : ElementCallIntent.StartCall;
|
||||
if (ongoing) {
|
||||
return video ? ElementCallIntent.JoinExisting : ElementCallIntent.JoinExistingVoice;
|
||||
}
|
||||
return video ? ElementCallIntent.StartCall : ElementCallIntent.StartCallVoice;
|
||||
}
|
||||
|
||||
static dmCall(intent: ElementCallIntent): boolean {
|
||||
return (
|
||||
intent === ElementCallIntent.JoinExistingDM ||
|
||||
intent === ElementCallIntent.JoinExistingDMVoice ||
|
||||
intent === ElementCallIntent.StartCallDM ||
|
||||
intent === ElementCallIntent.StartCallDMVoice
|
||||
);
|
||||
}
|
||||
|
||||
static startingCall(intent: ElementCallIntent): boolean {
|
||||
return (
|
||||
intent === ElementCallIntent.StartCallDM ||
|
||||
intent === ElementCallIntent.StartCallDMVoice ||
|
||||
intent === ElementCallIntent.StartCall ||
|
||||
intent === ElementCallIntent.StartCallVoice
|
||||
);
|
||||
}
|
||||
|
||||
static getWidget(
|
||||
@@ -91,8 +115,13 @@ export class CallEmbed {
|
||||
noiseSuppression: noiseSuppression.toString(),
|
||||
audio: initialAudio.toString(),
|
||||
video: initialVideo.toString(),
|
||||
header: 'none',
|
||||
});
|
||||
|
||||
if (!room.isCallRoom() && CallEmbed.startingCall(intent)) {
|
||||
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
|
||||
}
|
||||
|
||||
const widgetUrl = new URL(
|
||||
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`,
|
||||
window.location.origin
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export enum ElementCallIntent {
|
||||
StartCall = 'start_call',
|
||||
JoinExisting = 'join_existing',
|
||||
StartCallVoice = 'start_call_voice',
|
||||
JoinExistingVoice = 'join_existing_voice',
|
||||
StartCallDM = 'start_call_dm',
|
||||
JoinExistingDM = 'join_existing_dm',
|
||||
StartCallDMVoice = 'start_call_dm_voice',
|
||||
|
||||
@@ -15,6 +15,8 @@ export function getCallCapabilities(
|
||||
|
||||
capabilities.add(MatrixCapabilities.Screenshots);
|
||||
capabilities.add(MatrixCapabilities.AlwaysOnScreen);
|
||||
capabilities.add(MatrixCapabilities.MSC4039UploadFile);
|
||||
capabilities.add(MatrixCapabilities.MSC4039DownloadFile);
|
||||
capabilities.add(MatrixCapabilities.MSC3846TurnServers);
|
||||
capabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent);
|
||||
capabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent);
|
||||
@@ -78,19 +80,13 @@ export function getCallCapabilities(
|
||||
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomCreate).raw
|
||||
);
|
||||
|
||||
capabilities.add(
|
||||
WidgetEventCapability.forRoomEvent(
|
||||
EventDirection.Receive,
|
||||
'org.matrix.msc4075.rtc.notification'
|
||||
).raw
|
||||
);
|
||||
|
||||
[
|
||||
'io.element.call.encryption_keys',
|
||||
'org.matrix.rageshake_request',
|
||||
EventType.Reaction,
|
||||
EventType.RoomRedaction,
|
||||
'io.element.call.reaction',
|
||||
'org.matrix.msc4075.rtc.notification',
|
||||
'org.matrix.msc4310.rtc.decline',
|
||||
].forEach((type) => {
|
||||
capabilities.add(WidgetEventCapability.forRoomEvent(EventDirection.Send, type).raw);
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { replaceMatch } from '../internal';
|
||||
import {
|
||||
BlockQuoteRule,
|
||||
CodeBlockRule,
|
||||
ESC_BLOCK_SEQ,
|
||||
HeadingRule,
|
||||
OrderedListRule,
|
||||
UnorderedListRule,
|
||||
} from './rules';
|
||||
import { BlockQuoteRule, CodeBlockRule, ESC_BLOCK_SEQ, HeadingRule, ListRule } from './rules';
|
||||
import { runBlockRule } from './runner';
|
||||
import { BlockMDParser } from './type';
|
||||
|
||||
@@ -23,8 +16,7 @@ export const parseBlockMD: BlockMDParser = (text, parseInline) => {
|
||||
|
||||
if (!result) result = runBlockRule(text, CodeBlockRule, parseBlockMD, parseInline);
|
||||
if (!result) result = runBlockRule(text, BlockQuoteRule, parseBlockMD, parseInline);
|
||||
if (!result) result = runBlockRule(text, OrderedListRule, parseBlockMD, parseInline);
|
||||
if (!result) result = runBlockRule(text, UnorderedListRule, parseBlockMD, parseInline);
|
||||
if (!result) result = runBlockRule(text, ListRule, parseBlockMD, parseInline);
|
||||
if (!result) result = runBlockRule(text, HeadingRule, parseBlockMD, parseInline);
|
||||
|
||||
// replace \n with <br/> because want to preserve empty lines
|
||||
|
||||
@@ -10,14 +10,22 @@ export const HeadingRule: BlockMDRule = {
|
||||
},
|
||||
};
|
||||
|
||||
const CODEBLOCK_MD_1 = '```';
|
||||
const CODEBLOCK_REG_1 = /^`{3}(\S*)\n((?:.*\n)+?)`{3} *(?!.)\n?/m;
|
||||
// opening fence: 3 or more backticks
|
||||
// capture the exact fence length in group 1
|
||||
// optional info string in group 2
|
||||
// code content in group 3
|
||||
// closing fence must match the exact same fence sequence via \1
|
||||
const CODEBLOCK_REG_1 = /^(`{3,})(?!`)(\S*)\n((?:.*\n)+?)\1 *(?!.)\n?/m;
|
||||
export const CodeBlockRule: BlockMDRule = {
|
||||
match: (text) => text.match(CODEBLOCK_REG_1),
|
||||
html: (match) => {
|
||||
const [, g1, g2] = match;
|
||||
const classNameAtt = g1 ? ` class="language-${g1}"` : '';
|
||||
return `<pre data-md="${CODEBLOCK_MD_1}"><code${classNameAtt}>${g2}</code></pre>`;
|
||||
const [, fence, g1, g2] = match;
|
||||
// use last identifier after dot, e.g. for "example.json" gets us "json" as language code.
|
||||
const langCode = g1 ? g1.substring(g1.lastIndexOf('.') + 1) : null;
|
||||
const filename = g1 !== langCode ? g1 : null;
|
||||
const classNameAtt = langCode ? ` class="language-${langCode}"` : '';
|
||||
const filenameAtt = filename ? ` data-label="${filename}"` : '';
|
||||
return `<pre data-md="${fence}"><code${classNameAtt}${filenameAtt}>${g2}</code></pre>`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -44,55 +52,146 @@ export const BlockQuoteRule: BlockMDRule = {
|
||||
};
|
||||
|
||||
const ORDERED_LIST_MD_1 = '-';
|
||||
const O_LIST_ITEM_PREFIX = /^(-|[\da-zA-Z]\.) */;
|
||||
const O_LIST_START = /^([\d])\./;
|
||||
const O_LIST_TYPE = /^([aAiI])\./;
|
||||
const O_LIST_TRAILING_NEWLINE = /\n$/;
|
||||
const ORDERED_LIST_REG_1 = /(^(?:-|[\da-zA-Z]\.) +.+\n?)+/m;
|
||||
export const OrderedListRule: BlockMDRule = {
|
||||
match: (text) => text.match(ORDERED_LIST_REG_1),
|
||||
html: (match, parseInline) => {
|
||||
const [listText] = match;
|
||||
const [, listStart] = listText.match(O_LIST_START) ?? [];
|
||||
const [, listType] = listText.match(O_LIST_TYPE) ?? [];
|
||||
|
||||
const lines = listText
|
||||
.replace(O_LIST_TRAILING_NEWLINE, '')
|
||||
.split('\n')
|
||||
.map((lineText) => {
|
||||
const line = lineText.replace(O_LIST_ITEM_PREFIX, '');
|
||||
const txt = parseInline ? parseInline(line) : line;
|
||||
return `<li><p>${txt}</p></li>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const dataMdAtt = `data-md="${listType || listStart || ORDERED_LIST_MD_1}"`;
|
||||
const startAtt = listStart ? ` start="${listStart}"` : '';
|
||||
const typeAtt = listType ? ` type="${listType}"` : '';
|
||||
return `<ol ${dataMdAtt}${startAtt}${typeAtt}>${lines}</ol>`;
|
||||
},
|
||||
};
|
||||
|
||||
const UNORDERED_LIST_MD_1 = '*';
|
||||
const U_LIST_ITEM_PREFIX = /^\* */;
|
||||
const U_LIST_TRAILING_NEWLINE = /\n$/;
|
||||
const UNORDERED_LIST_REG_1 = /(^\* +.+\n?)+/m;
|
||||
export const UnorderedListRule: BlockMDRule = {
|
||||
match: (text) => text.match(UNORDERED_LIST_REG_1),
|
||||
const LIST_ITEM_REG = /^( *)([-*]|[\da-zA-Z]\.) +(.+)$/;
|
||||
type ListType = 'ol' | 'ul';
|
||||
|
||||
function getListType(marker: string): ListType {
|
||||
return marker === '*' ? 'ul' : 'ol';
|
||||
}
|
||||
|
||||
function getOrderedMeta(marker: string) {
|
||||
const startMatch = marker.match(/^(\d)\./);
|
||||
const typeMatch = marker.match(/^([aAiI])\./);
|
||||
|
||||
return {
|
||||
start: startMatch?.[1],
|
||||
type: typeMatch?.[1],
|
||||
};
|
||||
}
|
||||
|
||||
interface ParsedLine {
|
||||
indent: number;
|
||||
marker: string;
|
||||
content: string;
|
||||
listType: ListType;
|
||||
}
|
||||
|
||||
function parseLines(text: string): ParsedLine[] {
|
||||
return text
|
||||
.replace(/\n$/, '')
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const match = line.match(LIST_ITEM_REG);
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
const [, spaces, marker, content] = match;
|
||||
|
||||
return {
|
||||
indent: spaces.length,
|
||||
marker,
|
||||
content,
|
||||
listType: getListType(marker),
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as ParsedLine[];
|
||||
}
|
||||
|
||||
function openList(line: ParsedLine) {
|
||||
if (line.listType === 'ul') {
|
||||
return `<ul data-md="${UNORDERED_LIST_MD_1}">`;
|
||||
}
|
||||
const { type, start } = getOrderedMeta(line.marker);
|
||||
const dataMdAtt = `data-md="${type || start || ORDERED_LIST_MD_1}"`;
|
||||
const startAtt = start ? ` start="${start}"` : '';
|
||||
const typeAtt = type ? ` type="${type}"` : '';
|
||||
return `<ol ${dataMdAtt}${startAtt}${typeAtt}>`;
|
||||
}
|
||||
|
||||
function closeList(listType: ListType) {
|
||||
return listType === 'ul' ? '</ul>' : '</ol>';
|
||||
}
|
||||
|
||||
function buildList(lines: ParsedLine[], parseInline?: (s: string) => string): string {
|
||||
let html = '';
|
||||
|
||||
const stack: ('ul' | 'ol')[] = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const prev = lines[index - 1];
|
||||
const next = lines[index + 1];
|
||||
|
||||
const content = parseInline ? parseInline(line.content) : line.content;
|
||||
|
||||
// FIRST ITEM
|
||||
if (!prev) {
|
||||
html += openList(line);
|
||||
stack.push(line.listType);
|
||||
}
|
||||
|
||||
// DEEPER INDENT > open nested list
|
||||
else if (line.indent > prev.indent) {
|
||||
html += openList(line);
|
||||
stack.push(line.listType);
|
||||
}
|
||||
|
||||
// SAME LEVEL
|
||||
else if (line.indent === prev.indent) {
|
||||
html += '</li>';
|
||||
|
||||
// different list type
|
||||
if (line.listType !== prev.listType) {
|
||||
html += closeList(stack.pop()!);
|
||||
|
||||
html += openList(line);
|
||||
stack.push(line.listType);
|
||||
}
|
||||
}
|
||||
|
||||
// GOING BACK UP
|
||||
else if (line.indent < prev.indent) {
|
||||
html += '</li>';
|
||||
|
||||
while (stack.length > line.indent + 1) {
|
||||
html += closeList(stack.pop()!);
|
||||
html += '</li>';
|
||||
}
|
||||
|
||||
if (line.listType !== stack[stack.length - 1]) {
|
||||
html += closeList(stack.pop()!);
|
||||
|
||||
html += openList(line);
|
||||
stack.push(line.listType);
|
||||
}
|
||||
}
|
||||
|
||||
html += `<li><p>${content}</p>`;
|
||||
|
||||
// LAST ITEM cleanup
|
||||
if (!next) {
|
||||
html += '</li>';
|
||||
|
||||
while (stack.length) {
|
||||
html += closeList(stack.pop()!);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
const LIST_REG_1 = /^(?: *(?:[-*]|[\da-zA-Z]\.) +.+\n?)+/m;
|
||||
export const ListRule: BlockMDRule = {
|
||||
match: (text) => text.match(LIST_REG_1),
|
||||
html: (match, parseInline) => {
|
||||
const [listText] = match;
|
||||
|
||||
const lines = listText
|
||||
.replace(U_LIST_TRAILING_NEWLINE, '')
|
||||
.split('\n')
|
||||
.map((lineText) => {
|
||||
const line = lineText.replace(U_LIST_ITEM_PREFIX, '');
|
||||
const txt = parseInline ? parseInline(line) : line;
|
||||
return `<li><p>${txt}</p></li>`;
|
||||
})
|
||||
.join('');
|
||||
const lines = parseLines(listText);
|
||||
|
||||
return `<ul data-md="${UNORDERED_LIST_MD_1}">${lines}</ul>`;
|
||||
const html = buildList(lines, parseInline);
|
||||
|
||||
return html;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -232,8 +232,9 @@ export function CodeBlock({
|
||||
opts: HTMLReactParserOptions;
|
||||
}) {
|
||||
const code = children[0];
|
||||
const languageClass =
|
||||
code instanceof Element && code.name === 'code' ? code.attribs.class : undefined;
|
||||
const attribs = code instanceof Element && code.name === 'code' ? code.attribs : undefined;
|
||||
const languageClass = attribs?.class;
|
||||
const customLabel = attribs?.['data-label'];
|
||||
const language =
|
||||
languageClass && languageClass.startsWith('language-')
|
||||
? languageClass.replace('language-', '')
|
||||
@@ -262,7 +263,7 @@ export function CodeBlock({
|
||||
<Header variant="Surface" size="400" className={css.CodeBlockHeader}>
|
||||
<Box grow="Yes">
|
||||
<Text size="L400" truncate>
|
||||
{language ?? 'Code'}
|
||||
{customLabel ?? language ?? 'Code'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No" gap="200">
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
const STORAGE_KEY = 'settings';
|
||||
export type DateFormat = 'D MMM YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY/MM/DD' | '';
|
||||
export type DateFormat =
|
||||
| 'D MMM YYYY'
|
||||
| 'DD/MM/YYYY'
|
||||
| 'MM/DD/YYYY'
|
||||
| 'YYYY/MM/DD'
|
||||
| 'YYYY-MM-DD'
|
||||
| '';
|
||||
export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
|
||||
export type ChatBackground = 'none' | 'blueprint' | 'carbon' | 'stars' | 'topographic' | 'herringbone' | 'crosshatch' | 'chevron' | 'polka' | 'triangles' | 'plaid' | 'tactical' | 'circuit' | 'hexgrid';
|
||||
export enum MessageLayout {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { color, toRem } from 'folds';
|
||||
|
||||
const wobble = keyframes({
|
||||
'0%': {
|
||||
transform: 'translateX(0) rotateZ(0deg)',
|
||||
},
|
||||
'20%': {
|
||||
transform: `translateX(-${toRem(4)}) rotateZ(-4deg)`,
|
||||
},
|
||||
'40%': {
|
||||
transform: `translateX(${toRem(4)}) rotateZ(4deg)`,
|
||||
},
|
||||
'60%': {
|
||||
transform: `translateX(-${toRem(3)}) rotateZ(-3deg)`,
|
||||
},
|
||||
'80%': {
|
||||
transform: `translateX(${toRem(3)}) rotateZ(3deg)`,
|
||||
},
|
||||
'100%': {
|
||||
transform: 'translateX(0) rotateZ(0deg)',
|
||||
},
|
||||
});
|
||||
|
||||
const glowPulse = keyframes({
|
||||
'0%': {
|
||||
boxShadow: `0 0 0 ${toRem(0)} ${color.Success.ContainerActive}`,
|
||||
},
|
||||
'100%': {
|
||||
boxShadow: `0 0 0 ${toRem(8)} ${color.Success.ContainerActive}`,
|
||||
},
|
||||
});
|
||||
|
||||
export const WobbleAnimation = style({
|
||||
animation: `${wobble} 2000ms ease-in-out`,
|
||||
animationIterationCount: 'infinite',
|
||||
});
|
||||
|
||||
export const GlowAnimation = style({
|
||||
animation: `${glowPulse} 2000ms ease-out`,
|
||||
animationIterationCount: 'infinite',
|
||||
});
|
||||
|
||||
export const CallAvatarAnimation = style({
|
||||
animation: `${wobble} 2000ms ease-in-out, ${glowPulse} 2000ms ease-out`,
|
||||
animationIterationCount: 'infinite',
|
||||
});
|
||||
@@ -120,12 +120,23 @@ export const CodeBlockBottomShadow = style({
|
||||
background: `linear-gradient(to top, #00000022, #00000000)`,
|
||||
});
|
||||
|
||||
const BaseList = style({});
|
||||
export const List = style([
|
||||
BaseList,
|
||||
DefaultReset,
|
||||
MarginSpaced,
|
||||
{
|
||||
padding: `0 ${config.space.S100}`,
|
||||
paddingLeft: config.space.S600,
|
||||
selectors: {
|
||||
'& &': {
|
||||
marginTop: config.space.S200,
|
||||
marginBottom: config.space.S200,
|
||||
},
|
||||
'li:last-child &': {
|
||||
marginBottom: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -233,7 +233,15 @@ export const notificationPermission = (permission: NotificationPermission) => {
|
||||
if ('Notification' in window) {
|
||||
return window.Notification.permission === permission;
|
||||
}
|
||||
return false;
|
||||
try {
|
||||
// https://stackoverflow.com/questions/29774836/failed-to-construct-notification-illegal-constructor
|
||||
// https://issues.chromium.org/issues/40415865
|
||||
// eslint-disable-next-line no-new
|
||||
new Notification('');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getMouseEventCords = (event: MouseEvent) => ({
|
||||
|
||||
@@ -31,6 +31,7 @@ export const APPLICATION_MIME_TYPES = [
|
||||
'application/javascript',
|
||||
'application/xhtml+xml',
|
||||
'application/xml',
|
||||
'application/ogg',
|
||||
];
|
||||
|
||||
export const TEXT_MIME_TYPE = [
|
||||
@@ -115,6 +116,10 @@ export const getBlobSafeMimeType = (mimeType: string) => {
|
||||
if (type === 'video/quicktime') {
|
||||
return 'video/mp4';
|
||||
}
|
||||
// Fixes missing playback for Ogg audio
|
||||
if (type === 'application/ogg') {
|
||||
return 'audio/ogg';
|
||||
}
|
||||
return type;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const webRTCSupported = () =>
|
||||
['RTCPeerConnection', 'webkitRTCPeerConnection', 'mozRTCPeerConnection', 'RTCIceGatherer'].some(
|
||||
(item) => item in window
|
||||
);
|
||||
@@ -71,7 +71,7 @@ const permittedTagToAttributes = {
|
||||
ul: ['data-md'],
|
||||
a: ['name', 'target', 'href', 'rel', 'data-md'],
|
||||
img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'],
|
||||
code: ['class', 'data-md'],
|
||||
code: ['class', 'data-md', 'data-label'],
|
||||
strong: ['data-md'],
|
||||
i: ['data-md'],
|
||||
em: ['data-md'],
|
||||
|
||||
@@ -129,3 +129,9 @@ textarea {
|
||||
audio:not([controls]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Fix Firefox rendering lists that have empty items with those items collapsed in on eachother */
|
||||
li p::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user