forked from github/cinny
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a05ff5840 | ||
|
|
919fe8381b | ||
|
|
b76ad3caaf | ||
|
|
409d45857d | ||
|
|
0d1566977a | ||
|
|
0cbfbab5ad | ||
|
|
37e0c2aaac | ||
|
|
296249de32 | ||
|
|
4449e7c6e8 | ||
|
|
2eb5a9a616 | ||
|
|
d679e68501 | ||
|
|
bc6caddcc8 | ||
|
|
55e8306576 | ||
|
|
7953ec80e5 | ||
|
|
c6bb4915bc | ||
|
|
b050cd01f9 | ||
|
|
730670cf52 | ||
|
|
c5c8703699 | ||
|
|
2bd1570d6b | ||
|
|
68b6a09697 | ||
|
|
7b52c921d5 | ||
|
|
bb8b9ab6da | ||
|
|
971f312b46 | ||
|
|
e0d5c63dc5 | ||
|
|
85fcbd84fe | ||
|
|
221bc04754 |
6
.github/renovate.json
vendored
6
.github/renovate.json
vendored
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": ["config:recommended", ":dependencyDashboardApproval"],
|
"extends": [
|
||||||
|
"config:recommended",
|
||||||
|
":dependencyDashboardApproval",
|
||||||
|
":semanticCommits"
|
||||||
|
],
|
||||||
"labels": ["Dependencies"],
|
"labels": ["Dependencies"],
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
|
|||||||
12
.github/workflows/build-pull-request.yml
vendored
12
.github/workflows/build-pull-request.yml
vendored
@@ -12,12 +12,12 @@ jobs:
|
|||||||
PR_NUMBER: ${{github.event.number}}
|
PR_NUMBER: ${{github.event.number}}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.13.1
|
node-version-file: ".node-version"
|
||||||
cache: 'npm'
|
package-manager-cache: false
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Build app
|
- name: Build app
|
||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||||
run: npm run build
|
run: npm run build
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v6.0.0
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: preview
|
name: preview
|
||||||
path: dist
|
path: dist
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
- name: Save pr number
|
- name: Save pr number
|
||||||
run: echo ${PR_NUMBER} > ./pr.txt
|
run: echo ${PR_NUMBER} > ./pr.txt
|
||||||
- name: Upload pr number
|
- name: Upload pr number
|
||||||
uses: actions/upload-artifact@v6.0.0
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: pr
|
name: pr
|
||||||
path: ./pr.txt
|
path: ./pr.txt
|
||||||
|
|||||||
2
.github/workflows/cla.yml
vendored
2
.github/workflows/cla.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: 'CLA Assistant'
|
- name: 'CLA Assistant'
|
||||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||||
# Beta Release
|
# Beta Release
|
||||||
uses: cla-assistant/github-action@v2.6.1
|
uses: cla-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
# the below token should have repo scope and must be manually added by you in the repository's secret
|
# the below token should have repo scope and must be manually added by you in the repository's secret
|
||||||
|
|||||||
17
.github/workflows/deploy-pull-request.yml
vendored
17
.github/workflows/deploy-pull-request.yml
vendored
@@ -1,4 +1,5 @@
|
|||||||
name: Deploy PR to Netlify
|
name: Deploy PR to Netlify
|
||||||
|
run-name: "Deploy PR to Netlify (${{ github.event.workflow_run.head_branch }})"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
@@ -15,7 +16,7 @@ jobs:
|
|||||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download pr number
|
- name: Download pr number
|
||||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5
|
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||||
with:
|
with:
|
||||||
workflow: ${{ github.event.workflow.id }}
|
workflow: ${{ github.event.workflow.id }}
|
||||||
run_id: ${{ github.event.workflow_run.id }}
|
run_id: ${{ github.event.workflow_run.id }}
|
||||||
@@ -24,7 +25,7 @@ jobs:
|
|||||||
id: pr
|
id: pr
|
||||||
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
|
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
|
||||||
- name: Download artifact
|
- name: Download artifact
|
||||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5
|
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||||
with:
|
with:
|
||||||
workflow: ${{ github.event.workflow.id }}
|
workflow: ${{ github.event.workflow.id }}
|
||||||
run_id: ${{ github.event.workflow_run.id }}
|
run_id: ${{ github.event.workflow_run.id }}
|
||||||
@@ -32,7 +33,7 @@ jobs:
|
|||||||
path: dist
|
path: dist
|
||||||
- name: Deploy to Netlify
|
- name: Deploy to Netlify
|
||||||
id: netlify
|
id: netlify
|
||||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
|
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
|
||||||
with:
|
with:
|
||||||
publish-dir: dist
|
publish-dir: dist
|
||||||
deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}"
|
deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}"
|
||||||
@@ -45,12 +46,12 @@ jobs:
|
|||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
|
||||||
timeout-minutes: 1
|
timeout-minutes: 1
|
||||||
- name: Comment preview on PR
|
- name: Comment preview on PR
|
||||||
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6
|
uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b #v3.0.1
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
pr_number: ${{ steps.pr.outputs.id }}
|
pr-number: ${{ steps.pr.outputs.id }}
|
||||||
comment_tag: ${{ steps.pr.outputs.id }}
|
comment-tag: ${{ steps.pr.outputs.id }}
|
||||||
message: |
|
message: |
|
||||||
Preview: ${{ steps.netlify.outputs.deploy-url }}
|
Preview: ${{ steps.netlify.outputs.deploy-url }}
|
||||||
⚠️ Exercise caution. Use test accounts. ⚠️
|
⚠️ Exercise caution. Use test accounts. ⚠️
|
||||||
|
|||||||
49
.github/workflows/docker-pr.yml
vendored
49
.github/workflows/docker-pr.yml
vendored
@@ -10,11 +10,54 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
docker-build:
|
docker-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Build Docker image
|
|
||||||
uses: docker/build-push-action@v6.19.2
|
- 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
|
||||||
|
if: github.event.pull_request.head.repo.fork == false
|
||||||
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- 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
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docker, GHCR
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
ajbura/cinny
|
||||||
|
ghcr.io/${{ github.repository }}
|
||||||
|
|
||||||
|
- name: Build Docker image (no push)
|
||||||
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
push: false
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
- name: Show Docker images
|
||||||
|
run: docker images
|
||||||
|
|||||||
4
.github/workflows/lockfile.yml
vendored
4
.github/workflows/lockfile.yml
vendored
@@ -14,9 +14,9 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: NPM Lockfile Changes
|
- name: NPM Lockfile Changes
|
||||||
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891
|
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891 # v1.0.0
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
# Optional inputs, can be deleted safely if you are happy with default values.
|
# Optional inputs, can be deleted safely if you are happy with default values.
|
||||||
|
|||||||
10
.github/workflows/netlify-dev.yml
vendored
10
.github/workflows/netlify-dev.yml
vendored
@@ -11,12 +11,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.13.1
|
node-version-file: ".node-version"
|
||||||
cache: 'npm'
|
package-manager-cache: false
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Build app
|
- name: Build app
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||||
run: npm run build
|
run: npm run build
|
||||||
- name: Deploy to Netlify
|
- name: Deploy to Netlify
|
||||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
|
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
|
||||||
with:
|
with:
|
||||||
publish-dir: dist
|
publish-dir: dist
|
||||||
deploy-message: 'Dev deploy ${{ github.sha }}'
|
deploy-message: 'Dev deploy ${{ github.sha }}'
|
||||||
|
|||||||
15
.github/workflows/pr-title.yml
vendored
Normal file
15
.github/workflows/pr-title.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
name: Check PR title
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- edited
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
34
.github/workflows/prod-deploy.yml
vendored
34
.github/workflows/prod-deploy.yml
vendored
@@ -10,12 +10,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.13.1
|
node-version-file: ".node-version"
|
||||||
cache: 'npm'
|
package-manager-cache: false
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Build app
|
- name: Build app
|
||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||||
run: npm run build
|
run: npm run build
|
||||||
- name: Deploy to Netlify
|
- name: Deploy to Netlify
|
||||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
|
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
|
||||||
with:
|
with:
|
||||||
publish-dir: dist
|
publish-dir: dist
|
||||||
deploy-message: 'Prod deploy ${{ github.ref_name }}'
|
deploy-message: 'Prod deploy ${{ github.ref_name }}'
|
||||||
@@ -52,45 +52,45 @@ jobs:
|
|||||||
gpg --export | xxd -p
|
gpg --export | xxd -p
|
||||||
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||||
- name: Upload tagged release
|
- name: Upload tagged release
|
||||||
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836
|
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
|
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
|
||||||
|
|
||||||
publish-image:
|
publish-image:
|
||||||
name: Push Docker image to Docker Hub, ghcr
|
name: Push Docker image to Docker Hub, GHCR
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3.7.0
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3.12.0
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub #Do not update this action from a outside PR
|
||||||
uses: docker/login-action@v3.7.0
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Login to the Container registry
|
- name: Login to the Github Container registry #Do not update this action from a outside PR
|
||||||
uses: docker/login-action@v3.7.0
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Extract metadata (tags, labels) for Docker, GHCR
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5.10.0
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ secrets.DOCKER_USERNAME }}/cinny
|
${{ secrets.DOCKER_USERNAME }}/cinny
|
||||||
ghcr.io/${{ github.repository }}
|
ghcr.io/${{ github.repository }}
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6.19.2
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|||||||
1
.node-version
Normal file
1
.node-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
24.13.1
|
||||||
10
config.json
10
config.json
@@ -1,12 +1,6 @@
|
|||||||
{
|
{
|
||||||
"defaultHomeserver": 1,
|
"defaultHomeserver": 1,
|
||||||
"homeserverList": [
|
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
|
||||||
"converser.eu",
|
|
||||||
"matrix.org",
|
|
||||||
"mozilla.org",
|
|
||||||
"unredacted.org",
|
|
||||||
"xmr.se"
|
|
||||||
],
|
|
||||||
"allowCustomHomeservers": true,
|
"allowCustomHomeservers": true,
|
||||||
|
|
||||||
"featuredCommunities": {
|
"featuredCommunities": {
|
||||||
@@ -27,7 +21,7 @@
|
|||||||
"#PrivSec.dev:arcticfoxes.net",
|
"#PrivSec.dev:arcticfoxes.net",
|
||||||
"#disroot:aria-net.org"
|
"#disroot:aria-net.org"
|
||||||
],
|
],
|
||||||
"servers": [ "matrix.org", "mozilla.org", "unredacted.org" ]
|
"servers": ["matrix.org", "mozilla.org", "unredacted.org"]
|
||||||
},
|
},
|
||||||
|
|
||||||
"hashRouter": {
|
"hashRouter": {
|
||||||
|
|||||||
88
package-lock.json
generated
88
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "cinny",
|
"name": "cinny",
|
||||||
"version": "4.10.5",
|
"version": "4.11.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cinny",
|
"name": "cinny",
|
||||||
"version": "4.10.5",
|
"version": "4.11.1",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"emojibase-data": "15.3.2",
|
"emojibase-data": "15.3.2",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"focus-trap-react": "10.0.2",
|
"focus-trap-react": "10.0.2",
|
||||||
"folds": "2.6.1",
|
"folds": "2.6.2",
|
||||||
"html-dom-parser": "4.0.0",
|
"html-dom-parser": "4.0.0",
|
||||||
"html-react-parser": "4.2.0",
|
"html-react-parser": "4.2.0",
|
||||||
"i18next": "23.12.2",
|
"i18next": "23.12.2",
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
"linkify-react": "4.3.2",
|
"linkify-react": "4.3.2",
|
||||||
"linkifyjs": "4.3.2",
|
"linkifyjs": "4.3.2",
|
||||||
"matrix-js-sdk": "38.2.0",
|
"matrix-js-sdk": "38.2.0",
|
||||||
|
"matrix-widget-api": "1.13.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "4.2.67",
|
"pdfjs-dist": "4.2.67",
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
@@ -58,13 +59,14 @@
|
|||||||
"react-range": "1.8.14",
|
"react-range": "1.8.14",
|
||||||
"react-router-dom": "6.30.3",
|
"react-router-dom": "6.30.3",
|
||||||
"sanitize-html": "2.12.1",
|
"sanitize-html": "2.12.1",
|
||||||
"slate": "0.112.0",
|
"slate": "0.123.0",
|
||||||
"slate-dom": "0.112.2",
|
"slate-dom": "0.123.0",
|
||||||
"slate-history": "0.110.3",
|
"slate-history": "0.113.1",
|
||||||
"slate-react": "0.112.1",
|
"slate-react": "0.123.0",
|
||||||
"ua-parser-js": "1.0.35"
|
"ua-parser-js": "1.0.35"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@element-hq/element-call-embedded": "0.16.3",
|
||||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||||
"@rollup/plugin-inject": "5.0.3",
|
"@rollup/plugin-inject": "5.0.3",
|
||||||
"@rollup/plugin-wasm": "6.1.1",
|
"@rollup/plugin-wasm": "6.1.1",
|
||||||
@@ -1649,6 +1651,12 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@element-hq/element-call-embedded": {
|
||||||
|
"version": "0.16.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.16.3.tgz",
|
||||||
|
"integrity": "sha512-OViKJonDaDNVBUW9WdV9mk78/Ruh34C7XsEgt3O8D9z+64C39elbIgllHSoH5S12IRlv9RYrrV37FZLo6QWsDQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@emotion/hash": {
|
"node_modules/@emotion/hash": {
|
||||||
"version": "0.9.2",
|
"version": "0.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||||
@@ -7158,9 +7166,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/folds": {
|
"node_modules/folds": {
|
||||||
"version": "2.6.1",
|
"version": "2.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/folds/-/folds-2.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/folds/-/folds-2.6.2.tgz",
|
||||||
"integrity": "sha512-0L1ZSqwjFSg2fesa//C4DgP47Vp/KqDuzjAaOEYN21AvoptyVI+6OEXWrtIdE8DPQCZYr0bV+tqbrLyA6uAhaw==",
|
"integrity": "sha512-1HemxxSnBm8/U5kq1pDQrFkpltWgQN90DmWCZWkZb7D2pe8BhOJSwIRLjk9WxHcw6nn69oz2XNYIXtSw0LvX1w==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@vanilla-extract/css": "1.9.2",
|
"@vanilla-extract/css": "1.9.2",
|
||||||
@@ -8666,9 +8674,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/matrix-widget-api": {
|
"node_modules/matrix-widget-api": {
|
||||||
"version": "1.13.1",
|
"version": "1.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.0.tgz",
|
||||||
"integrity": "sha512-mkOHUVzaN018TCbObfGOSaMW2GoUxOfcxNNlTVx5/HeMk3OSQPQM0C9oEME5Liiv/dBUoSrEB64V8wF7e/gb1w==",
|
"integrity": "sha512-+LrvwkR1izL4h2euX8PDrvG/3PZZDEd6As+lmnR3jAVwbFJtU5iTnwmZGnCca9ddngCvXvAHkcpJBEPyPTZneQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/events": "^3.0.0",
|
"@types/events": "^3.0.0",
|
||||||
@@ -10283,20 +10291,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/slate": {
|
"node_modules/slate": {
|
||||||
"version": "0.112.0",
|
"version": "0.123.0",
|
||||||
"resolved": "https://registry.npmjs.org/slate/-/slate-0.112.0.tgz",
|
"resolved": "https://registry.npmjs.org/slate/-/slate-0.123.0.tgz",
|
||||||
"integrity": "sha512-PRnfFgDA3tSop4OH47zu4M1R4Uuhm/AmASu29Qp7sGghVFb713kPBKEnSf1op7Lx/nCHkRlCa3ThfHtCBy+5Yw==",
|
"integrity": "sha512-Oon3HR/QzJQBjuOUJT1jGGlp8Ff7t3Bkr/rJ2lDqxNT4H+cBnXpEVQ/si6hn1ZCHhD2xY/2N91PQoH/rD7kxTg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"dependencies": {
|
|
||||||
"immer": "^10.0.3",
|
|
||||||
"is-plain-object": "^5.0.0",
|
|
||||||
"tiny-warning": "^1.0.3"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/slate-dom": {
|
"node_modules/slate-dom": {
|
||||||
"version": "0.112.2",
|
"version": "0.123.0",
|
||||||
"resolved": "https://registry.npmjs.org/slate-dom/-/slate-dom-0.112.2.tgz",
|
"resolved": "https://registry.npmjs.org/slate-dom/-/slate-dom-0.123.0.tgz",
|
||||||
"integrity": "sha512-cozITMlpcBxrov854reM6+TooiHiqpfM/nZPrnjpN1wSiDsAQmYbWUyftC+jlwcpFj80vywfDHzlG6hXIc5h6A==",
|
"integrity": "sha512-OUinp4tvSrAlt64JL9y20Xin08jgnnj1gJmIuPdGvU5MELKXRNZh17a7EKKNOS6OZPAE8Dk9NI1MAIS/Qz0YBw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@juggle/resize-observer": "^3.4.0",
|
"@juggle/resize-observer": "^3.4.0",
|
||||||
@@ -10308,13 +10311,13 @@
|
|||||||
"tiny-invariant": "1.3.1"
|
"tiny-invariant": "1.3.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"slate": ">=0.99.0"
|
"slate": ">=0.121.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/slate-history": {
|
"node_modules/slate-history": {
|
||||||
"version": "0.110.3",
|
"version": "0.113.1",
|
||||||
"resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.110.3.tgz",
|
"resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.113.1.tgz",
|
||||||
"integrity": "sha512-sgdff4Usdflmw5ZUbhDkxFwCBQ2qlDKMMkF93w66KdV48vHOgN2BmLrf+2H8SdX8PYIpP/cTB0w8qWC2GwhDVA==",
|
"integrity": "sha512-J9NSJ+UG2GxoW0lw5mloaKcN0JI0x2IA5M5FxyGiInpn+QEutxT1WK7S/JneZCMFJBoHs1uu7S7e6pxQjubHmQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-plain-object": "^5.0.0"
|
"is-plain-object": "^5.0.0"
|
||||||
@@ -10324,15 +10327,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/slate-react": {
|
"node_modules/slate-react": {
|
||||||
"version": "0.112.1",
|
"version": "0.123.0",
|
||||||
"resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.112.1.tgz",
|
"resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.123.0.tgz",
|
||||||
"integrity": "sha512-V9b+waxPweXqAkSQmKQ1afG4Me6nVQACPpxQtHPIX02N7MXa5f5WilYv+bKt7vKKw+IZC2F0Gjzhv5BekVgP/A==",
|
"integrity": "sha512-nQwXL1FEacrY9ZFmatRhoBnsySNUX2x6qB77V3oNHd7wWxBJWuzz4GMrBXcVoRE8Gac7Angf8xaNGzb6zcPlHg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@juggle/resize-observer": "^3.4.0",
|
"@juggle/resize-observer": "^3.4.0",
|
||||||
"direction": "^1.0.4",
|
"direction": "^1.0.4",
|
||||||
"is-hotkey": "^0.2.0",
|
"is-hotkey": "^0.2.0",
|
||||||
"is-plain-object": "^5.0.0",
|
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"scroll-into-view-if-needed": "^3.1.0",
|
"scroll-into-view-if-needed": "^3.1.0",
|
||||||
"tiny-invariant": "1.3.1"
|
"tiny-invariant": "1.3.1"
|
||||||
@@ -10340,18 +10342,8 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=18.2.0",
|
"react": ">=18.2.0",
|
||||||
"react-dom": ">=18.2.0",
|
"react-dom": ">=18.2.0",
|
||||||
"slate": ">=0.99.0",
|
"slate": ">=0.121.0",
|
||||||
"slate-dom": ">=0.110.2"
|
"slate-dom": ">=0.119.1"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/slate/node_modules/immer": {
|
|
||||||
"version": "10.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
|
|
||||||
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/immer"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/smob": {
|
"node_modules/smob": {
|
||||||
@@ -10721,11 +10713,6 @@
|
|||||||
"integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==",
|
"integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tiny-warning": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
|
|
||||||
},
|
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.10",
|
"version": "0.2.10",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.10.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.10.tgz",
|
||||||
@@ -10909,6 +10896,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz",
|
||||||
"integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==",
|
"integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|||||||
15
package.json
15
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cinny",
|
"name": "cinny",
|
||||||
"version": "4.10.5",
|
"version": "4.11.1",
|
||||||
"description": "Yet another matrix client",
|
"description": "Yet another matrix client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
"lint": "yarn check:eslint && yarn check:prettier",
|
"lint": "yarn check:eslint && yarn check:prettier",
|
||||||
"check:eslint": "eslint src/*",
|
"check:eslint": "eslint src/*",
|
||||||
"check:prettier": "prettier --check .",
|
"check:prettier": "prettier --check .",
|
||||||
@@ -43,7 +44,7 @@
|
|||||||
"emojibase-data": "15.3.2",
|
"emojibase-data": "15.3.2",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"focus-trap-react": "10.0.2",
|
"focus-trap-react": "10.0.2",
|
||||||
"folds": "2.6.1",
|
"folds": "2.6.2",
|
||||||
"html-dom-parser": "4.0.0",
|
"html-dom-parser": "4.0.0",
|
||||||
"html-react-parser": "4.2.0",
|
"html-react-parser": "4.2.0",
|
||||||
"i18next": "23.12.2",
|
"i18next": "23.12.2",
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
"linkify-react": "4.3.2",
|
"linkify-react": "4.3.2",
|
||||||
"linkifyjs": "4.3.2",
|
"linkifyjs": "4.3.2",
|
||||||
"matrix-js-sdk": "38.2.0",
|
"matrix-js-sdk": "38.2.0",
|
||||||
|
"matrix-widget-api": "1.13.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "4.2.67",
|
"pdfjs-dist": "4.2.67",
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
@@ -69,13 +71,14 @@
|
|||||||
"react-range": "1.8.14",
|
"react-range": "1.8.14",
|
||||||
"react-router-dom": "6.30.3",
|
"react-router-dom": "6.30.3",
|
||||||
"sanitize-html": "2.12.1",
|
"sanitize-html": "2.12.1",
|
||||||
"slate": "0.112.0",
|
"slate": "0.123.0",
|
||||||
"slate-dom": "0.112.2",
|
"slate-dom": "0.123.0",
|
||||||
"slate-history": "0.110.3",
|
"slate-history": "0.113.1",
|
||||||
"slate-react": "0.112.1",
|
"slate-react": "0.123.0",
|
||||||
"ua-parser-js": "1.0.35"
|
"ua-parser-js": "1.0.35"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@element-hq/element-call-embedded": "0.16.3",
|
||||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||||
"@rollup/plugin-inject": "5.0.3",
|
"@rollup/plugin-inject": "5.0.3",
|
||||||
"@rollup/plugin-wasm": "6.1.1",
|
"@rollup/plugin-wasm": "6.1.1",
|
||||||
|
|||||||
66
src/app/components/CallEmbedProvider.tsx
Normal file
66
src/app/components/CallEmbedProvider.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import React, { ReactNode, useCallback, useRef } from 'react';
|
||||||
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
|
import { config } from 'folds';
|
||||||
|
import {
|
||||||
|
CallEmbedContextProvider,
|
||||||
|
CallEmbedRefContextProvider,
|
||||||
|
useCallHangupEvent,
|
||||||
|
useCallJoined,
|
||||||
|
useCallThemeSync,
|
||||||
|
useCallMemberSoundSync,
|
||||||
|
} 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';
|
||||||
|
|
||||||
|
function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||||
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
|
|
||||||
|
useCallMemberSoundSync(embed);
|
||||||
|
useCallThemeSync(embed);
|
||||||
|
useCallHangupEvent(
|
||||||
|
embed,
|
||||||
|
useCallback(() => {
|
||||||
|
setCallEmbed(undefined);
|
||||||
|
}, [setCallEmbed])
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallEmbedProviderProps = {
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||||
|
const callEmbed = useAtomValue(callEmbedAtom);
|
||||||
|
const callEmbedRef = useRef<HTMLDivElement>(null);
|
||||||
|
const joined = useCallJoined(callEmbed);
|
||||||
|
|
||||||
|
const selectedRoom = useSelectedRoom();
|
||||||
|
const chat = useAtomValue(callChatAtom);
|
||||||
|
const screenSize = useScreenSizeContext();
|
||||||
|
|
||||||
|
const chatOnlyView = chat && screenSize !== ScreenSize.Desktop;
|
||||||
|
|
||||||
|
const callVisible = callEmbed && selectedRoom === callEmbed.roomId && joined && !chatOnlyView;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CallEmbedContextProvider value={callEmbed}>
|
||||||
|
{callEmbed && <CallUtils embed={callEmbed} />}
|
||||||
|
<CallEmbedRefContextProvider value={callEmbedRef}>{children}</CallEmbedRefContextProvider>
|
||||||
|
<div
|
||||||
|
data-call-embed-container
|
||||||
|
style={{
|
||||||
|
visibility: callVisible ? undefined : 'hidden',
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '50%',
|
||||||
|
}}
|
||||||
|
ref={callEmbedRef}
|
||||||
|
/>
|
||||||
|
</CallEmbedContextProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,34 +16,24 @@ import {
|
|||||||
import { JoinRule } from 'matrix-js-sdk';
|
import { JoinRule } from 'matrix-js-sdk';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { stopPropagation } from '../utils/keyboard';
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
import { getRoomIconSrc } from '../utils/room';
|
||||||
|
|
||||||
export type ExtraJoinRules = 'knock_restricted';
|
export type ExtraJoinRules = 'knock_restricted';
|
||||||
export type ExtendedJoinRules = JoinRule | ExtraJoinRules;
|
export type ExtendedJoinRules = JoinRule | ExtraJoinRules;
|
||||||
|
|
||||||
type JoinRuleIcons = Record<ExtendedJoinRules, IconSrc>;
|
type JoinRuleIcons = Record<ExtendedJoinRules, IconSrc>;
|
||||||
export const useRoomJoinRuleIcon = (): JoinRuleIcons =>
|
|
||||||
|
export const useJoinRuleIcons = (roomType?: string): JoinRuleIcons =>
|
||||||
useMemo(
|
useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
[JoinRule.Invite]: Icons.HashLock,
|
[JoinRule.Invite]: getRoomIconSrc(Icons, roomType, JoinRule.Invite),
|
||||||
[JoinRule.Knock]: Icons.HashLock,
|
[JoinRule.Knock]: getRoomIconSrc(Icons, roomType, JoinRule.Knock),
|
||||||
knock_restricted: Icons.Hash,
|
knock_restricted: getRoomIconSrc(Icons, roomType, JoinRule.Restricted),
|
||||||
[JoinRule.Restricted]: Icons.Hash,
|
[JoinRule.Restricted]: getRoomIconSrc(Icons, roomType, JoinRule.Restricted),
|
||||||
[JoinRule.Public]: Icons.HashGlobe,
|
[JoinRule.Public]: getRoomIconSrc(Icons, roomType, JoinRule.Public),
|
||||||
[JoinRule.Private]: Icons.HashLock,
|
[JoinRule.Private]: getRoomIconSrc(Icons, roomType, JoinRule.Private),
|
||||||
}),
|
}),
|
||||||
[]
|
[roomType]
|
||||||
);
|
|
||||||
export const useSpaceJoinRuleIcon = (): JoinRuleIcons =>
|
|
||||||
useMemo(
|
|
||||||
() => ({
|
|
||||||
[JoinRule.Invite]: Icons.SpaceLock,
|
|
||||||
[JoinRule.Knock]: Icons.SpaceLock,
|
|
||||||
knock_restricted: Icons.Space,
|
|
||||||
[JoinRule.Restricted]: Icons.Space,
|
|
||||||
[JoinRule.Public]: Icons.SpaceGlobe,
|
|
||||||
[JoinRule.Private]: Icons.SpaceLock,
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
type JoinRuleLabels = Record<ExtendedJoinRules, string>;
|
type JoinRuleLabels = Record<ExtendedJoinRules, string>;
|
||||||
|
|||||||
@@ -2,43 +2,39 @@ import React from 'react';
|
|||||||
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
|
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
|
||||||
import { SequenceCard } from '../sequence-card';
|
import { SequenceCard } from '../sequence-card';
|
||||||
import { SettingTile } from '../setting-tile';
|
import { SettingTile } from '../setting-tile';
|
||||||
|
import { CreateRoomAccess } from './types';
|
||||||
|
|
||||||
export enum CreateRoomKind {
|
type CreateRoomAccessSelectorProps = {
|
||||||
Private = 'private',
|
value?: CreateRoomAccess;
|
||||||
Restricted = 'restricted',
|
onSelect: (value: CreateRoomAccess) => void;
|
||||||
Public = 'public',
|
|
||||||
}
|
|
||||||
type CreateRoomKindSelectorProps = {
|
|
||||||
value?: CreateRoomKind;
|
|
||||||
onSelect: (value: CreateRoomKind) => void;
|
|
||||||
canRestrict?: boolean;
|
canRestrict?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
getIcon: (kind: CreateRoomKind) => IconSrc;
|
getIcon: (access: CreateRoomAccess) => IconSrc;
|
||||||
};
|
};
|
||||||
export function CreateRoomKindSelector({
|
export function CreateRoomAccessSelector({
|
||||||
value,
|
value,
|
||||||
onSelect,
|
onSelect,
|
||||||
canRestrict,
|
canRestrict,
|
||||||
disabled,
|
disabled,
|
||||||
getIcon,
|
getIcon,
|
||||||
}: CreateRoomKindSelectorProps) {
|
}: CreateRoomAccessSelectorProps) {
|
||||||
return (
|
return (
|
||||||
<Box shrink="No" direction="Column" gap="100">
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
{canRestrict && (
|
{canRestrict && (
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
style={{ padding: config.space.S300 }}
|
style={{ padding: config.space.S300 }}
|
||||||
variant={value === CreateRoomKind.Restricted ? 'Primary' : 'SurfaceVariant'}
|
variant={value === CreateRoomAccess.Restricted ? 'Primary' : 'SurfaceVariant'}
|
||||||
direction="Column"
|
direction="Column"
|
||||||
gap="100"
|
gap="100"
|
||||||
as="button"
|
as="button"
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={value === CreateRoomKind.Restricted}
|
aria-pressed={value === CreateRoomAccess.Restricted}
|
||||||
onClick={() => onSelect(CreateRoomKind.Restricted)}
|
onClick={() => onSelect(CreateRoomAccess.Restricted)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
before={<Icon size="400" src={getIcon(CreateRoomKind.Restricted)} />}
|
before={<Icon size="400" src={getIcon(CreateRoomAccess.Restricted)} />}
|
||||||
after={value === CreateRoomKind.Restricted && <Icon src={Icons.Check} />}
|
after={value === CreateRoomAccess.Restricted && <Icon src={Icons.Check} />}
|
||||||
>
|
>
|
||||||
<Text size="H6">Restricted</Text>
|
<Text size="H6">Restricted</Text>
|
||||||
<Text size="T300" priority="300">
|
<Text size="T300" priority="300">
|
||||||
@@ -49,18 +45,18 @@ export function CreateRoomKindSelector({
|
|||||||
)}
|
)}
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
style={{ padding: config.space.S300 }}
|
style={{ padding: config.space.S300 }}
|
||||||
variant={value === CreateRoomKind.Private ? 'Primary' : 'SurfaceVariant'}
|
variant={value === CreateRoomAccess.Private ? 'Primary' : 'SurfaceVariant'}
|
||||||
direction="Column"
|
direction="Column"
|
||||||
gap="100"
|
gap="100"
|
||||||
as="button"
|
as="button"
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={value === CreateRoomKind.Private}
|
aria-pressed={value === CreateRoomAccess.Private}
|
||||||
onClick={() => onSelect(CreateRoomKind.Private)}
|
onClick={() => onSelect(CreateRoomAccess.Private)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
before={<Icon size="400" src={getIcon(CreateRoomKind.Private)} />}
|
before={<Icon size="400" src={getIcon(CreateRoomAccess.Private)} />}
|
||||||
after={value === CreateRoomKind.Private && <Icon src={Icons.Check} />}
|
after={value === CreateRoomAccess.Private && <Icon src={Icons.Check} />}
|
||||||
>
|
>
|
||||||
<Text size="H6">Private</Text>
|
<Text size="H6">Private</Text>
|
||||||
<Text size="T300" priority="300">
|
<Text size="T300" priority="300">
|
||||||
@@ -70,18 +66,18 @@ export function CreateRoomKindSelector({
|
|||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
style={{ padding: config.space.S300 }}
|
style={{ padding: config.space.S300 }}
|
||||||
variant={value === CreateRoomKind.Public ? 'Primary' : 'SurfaceVariant'}
|
variant={value === CreateRoomAccess.Public ? 'Primary' : 'SurfaceVariant'}
|
||||||
direction="Column"
|
direction="Column"
|
||||||
gap="100"
|
gap="100"
|
||||||
as="button"
|
as="button"
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={value === CreateRoomKind.Public}
|
aria-pressed={value === CreateRoomAccess.Public}
|
||||||
onClick={() => onSelect(CreateRoomKind.Public)}
|
onClick={() => onSelect(CreateRoomAccess.Public)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
before={<Icon size="400" src={getIcon(CreateRoomKind.Public)} />}
|
before={<Icon size="400" src={getIcon(CreateRoomAccess.Public)} />}
|
||||||
after={value === CreateRoomKind.Public && <Icon src={Icons.Check} />}
|
after={value === CreateRoomAccess.Public && <Icon src={Icons.Check} />}
|
||||||
>
|
>
|
||||||
<Text size="H6">Public</Text>
|
<Text size="H6">Public</Text>
|
||||||
<Text size="T300" priority="300">
|
<Text size="T300" priority="300">
|
||||||
75
src/app/components/create-room/CreateRoomTypeSelector.tsx
Normal file
75
src/app/components/create-room/CreateRoomTypeSelector.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
|
||||||
|
import { SequenceCard } from '../sequence-card';
|
||||||
|
import { SettingTile } from '../setting-tile';
|
||||||
|
import { CreateRoomType } from './types';
|
||||||
|
import { BetaNoticeBadge } from '../BetaNoticeBadge';
|
||||||
|
|
||||||
|
type CreateRoomTypeSelectorProps = {
|
||||||
|
value?: CreateRoomType;
|
||||||
|
onSelect: (value: CreateRoomType) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
getIcon: (type: CreateRoomType) => IconSrc;
|
||||||
|
};
|
||||||
|
export function CreateRoomTypeSelector({
|
||||||
|
value,
|
||||||
|
onSelect,
|
||||||
|
disabled,
|
||||||
|
getIcon,
|
||||||
|
}: CreateRoomTypeSelectorProps) {
|
||||||
|
return (
|
||||||
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
|
<SequenceCard
|
||||||
|
style={{ padding: config.space.S300 }}
|
||||||
|
variant={value === CreateRoomType.TextRoom ? 'Primary' : 'SurfaceVariant'}
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
aria-pressed={value === CreateRoomType.TextRoom}
|
||||||
|
onClick={() => onSelect(CreateRoomType.TextRoom)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
before={<Icon size="400" src={getIcon(CreateRoomType.TextRoom)} />}
|
||||||
|
after={value === CreateRoomType.TextRoom && <Icon src={Icons.Check} />}
|
||||||
|
>
|
||||||
|
<Box gap="200" alignItems="Baseline">
|
||||||
|
<Text size="H6" style={{ flexShrink: 0 }}>
|
||||||
|
Chat Room
|
||||||
|
</Text>
|
||||||
|
<Text size="T300" priority="300" truncate>
|
||||||
|
- Messages, photos, and videos.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
</SequenceCard>
|
||||||
|
<SequenceCard
|
||||||
|
style={{ padding: config.space.S300 }}
|
||||||
|
variant={value === CreateRoomType.VoiceRoom ? 'Primary' : 'SurfaceVariant'}
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
aria-pressed={value === CreateRoomType.VoiceRoom}
|
||||||
|
onClick={() => onSelect(CreateRoomType.VoiceRoom)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
before={<Icon size="400" src={getIcon(CreateRoomType.VoiceRoom)} />}
|
||||||
|
after={value === CreateRoomType.VoiceRoom && <Icon src={Icons.Check} />}
|
||||||
|
>
|
||||||
|
<Box gap="200" alignItems="Baseline">
|
||||||
|
<Text size="H6" style={{ flexShrink: 0 }}>
|
||||||
|
Voice Room
|
||||||
|
</Text>
|
||||||
|
<Text size="T300" priority="300" truncate>
|
||||||
|
- Live audio and video conversations.
|
||||||
|
</Text>
|
||||||
|
<BetaNoticeBadge />
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './CreateRoomKindSelector';
|
export * from './CreateRoomAccessSelector';
|
||||||
export * from './CreateRoomAliasInput';
|
export * from './CreateRoomAliasInput';
|
||||||
export * from './RoomVersionSelector';
|
export * from './RoomVersionSelector';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
export * from './AdditionalCreatorInput';
|
export * from './AdditionalCreatorInput';
|
||||||
|
export * from './types';
|
||||||
|
|||||||
10
src/app/components/create-room/types.ts
Normal file
10
src/app/components/create-room/types.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export enum CreateRoomType {
|
||||||
|
TextRoom = 'text',
|
||||||
|
VoiceRoom = 'voice',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CreateRoomAccess {
|
||||||
|
Private = 'private',
|
||||||
|
Restricted = 'restricted',
|
||||||
|
Public = 'public',
|
||||||
|
}
|
||||||
@@ -7,10 +7,10 @@ import {
|
|||||||
Room,
|
Room,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||||
import { CreateRoomKind } from './CreateRoomKindSelector';
|
|
||||||
import { RoomType, StateEvent } from '../../../types/matrix/room';
|
import { RoomType, StateEvent } from '../../../types/matrix/room';
|
||||||
import { getViaServers } from '../../plugins/via-servers';
|
import { getViaServers } from '../../plugins/via-servers';
|
||||||
import { getMxIdServer } from '../../utils/matrix';
|
import { getMxIdServer } from '../../utils/matrix';
|
||||||
|
import { CreateRoomAccess } from './types';
|
||||||
|
|
||||||
export const createRoomCreationContent = (
|
export const createRoomCreationContent = (
|
||||||
type: RoomType | undefined,
|
type: RoomType | undefined,
|
||||||
@@ -32,7 +32,7 @@ export const createRoomCreationContent = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createRoomJoinRulesState = (
|
export const createRoomJoinRulesState = (
|
||||||
kind: CreateRoomKind,
|
access: CreateRoomAccess,
|
||||||
parent: Room | undefined,
|
parent: Room | undefined,
|
||||||
knock: boolean
|
knock: boolean
|
||||||
) => {
|
) => {
|
||||||
@@ -40,13 +40,13 @@ export const createRoomJoinRulesState = (
|
|||||||
join_rule: knock ? JoinRule.Knock : JoinRule.Invite,
|
join_rule: knock ? JoinRule.Knock : JoinRule.Invite,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (kind === CreateRoomKind.Public) {
|
if (access === CreateRoomAccess.Public) {
|
||||||
content = {
|
content = {
|
||||||
join_rule: JoinRule.Public,
|
join_rule: JoinRule.Public,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === CreateRoomKind.Restricted && parent) {
|
if (access === CreateRoomAccess.Restricted && parent) {
|
||||||
content = {
|
content = {
|
||||||
join_rule: knock ? ('knock_restricted' as JoinRule) : JoinRule.Restricted,
|
join_rule: knock ? ('knock_restricted' as JoinRule) : JoinRule.Restricted,
|
||||||
allow: [
|
allow: [
|
||||||
@@ -86,11 +86,23 @@ export const createRoomEncryptionState = () => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const createRoomCallState = () => ({
|
||||||
|
type: 'org.matrix.msc3401.call',
|
||||||
|
state_key: '',
|
||||||
|
content: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createVoiceRoomPowerLevelsOverride = () => ({
|
||||||
|
events: {
|
||||||
|
[StateEvent.GroupCallMemberPrefix]: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export type CreateRoomData = {
|
export type CreateRoomData = {
|
||||||
version: string;
|
version: string;
|
||||||
type?: RoomType;
|
type?: RoomType;
|
||||||
parent?: Room;
|
parent?: Room;
|
||||||
kind: CreateRoomKind;
|
access: CreateRoomAccess;
|
||||||
name: string;
|
name: string;
|
||||||
topic?: string;
|
topic?: string;
|
||||||
aliasLocalPart?: string;
|
aliasLocalPart?: string;
|
||||||
@@ -110,7 +122,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
|||||||
initialState.push(createRoomParentState(data.parent));
|
initialState.push(createRoomParentState(data.parent));
|
||||||
}
|
}
|
||||||
|
|
||||||
initialState.push(createRoomJoinRulesState(data.kind, data.parent, data.knock));
|
if (data.type === RoomType.Call) {
|
||||||
|
initialState.push(createRoomCallState());
|
||||||
|
}
|
||||||
|
|
||||||
|
initialState.push(createRoomJoinRulesState(data.access, data.parent, data.knock));
|
||||||
|
|
||||||
const options: ICreateRoomOpts = {
|
const options: ICreateRoomOpts = {
|
||||||
room_version: data.version,
|
room_version: data.version,
|
||||||
@@ -122,6 +138,8 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
|||||||
data.allowFederation,
|
data.allowFederation,
|
||||||
data.additionalCreators
|
data.additionalCreators
|
||||||
),
|
),
|
||||||
|
power_level_content_override:
|
||||||
|
data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined,
|
||||||
initial_state: initialState,
|
initial_state: initialState,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -169,12 +169,13 @@ export function RoomMentionAutocomplete({
|
|||||||
<RoomIcon
|
<RoomIcon
|
||||||
size="50"
|
size="50"
|
||||||
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
|
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
|
||||||
|
roomType={room.getType()}
|
||||||
filled
|
filled
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RoomIcon size="100" joinRule={room.getJoinRule()} space={room.isSpaceRoom()} />
|
<RoomIcon size="100" joinRule={room.getJoinRule()} roomType={room.getType()} />
|
||||||
)}
|
)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -389,6 +389,8 @@ export function MLocation({ content }: MLocationProps) {
|
|||||||
const geoUri = content.geo_uri;
|
const geoUri = content.geo_uri;
|
||||||
if (typeof geoUri !== 'string') return <BrokenContent />;
|
if (typeof geoUri !== 'string') return <BrokenContent />;
|
||||||
const location = parseGeoUri(geoUri);
|
const location = parseGeoUri(geoUri);
|
||||||
|
if (!location) return <BrokenContent />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" alignItems="Start" gap="100">
|
<Box direction="Column" alignItems="Start" gap="100">
|
||||||
<Text size="T400">{geoUri}</Text>
|
<Text size="T400">{geoUri}</Text>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { JoinRule } from 'matrix-js-sdk';
|
|||||||
import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds';
|
import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds';
|
||||||
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react';
|
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react';
|
||||||
import * as css from './RoomAvatar.css';
|
import * as css from './RoomAvatar.css';
|
||||||
import { joinRuleToIconSrc } from '../../utils/room';
|
import { getRoomIconSrc } from '../../utils/room';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
|
|
||||||
type RoomAvatarProps = {
|
type RoomAvatarProps = {
|
||||||
@@ -44,13 +44,9 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
|
|||||||
export const RoomIcon = forwardRef<
|
export const RoomIcon = forwardRef<
|
||||||
SVGSVGElement,
|
SVGSVGElement,
|
||||||
Omit<ComponentProps<typeof Icon>, 'src'> & {
|
Omit<ComponentProps<typeof Icon>, 'src'> & {
|
||||||
joinRule: JoinRule;
|
joinRule?: JoinRule;
|
||||||
space?: boolean;
|
roomType?: string;
|
||||||
}
|
}
|
||||||
>(({ joinRule, space, ...props }, ref) => (
|
>(({ joinRule, roomType, ...props }, ref) => (
|
||||||
<Icon
|
<Icon src={getRoomIconSrc(Icons, roomType, joinRule)} {...props} ref={ref} />
|
||||||
src={joinRuleToIconSrc(Icons, joinRule, space || false) ?? Icons.Hash}
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const SequenceCard = as<
|
|||||||
firstChild,
|
firstChild,
|
||||||
lastChild,
|
lastChild,
|
||||||
outlined,
|
outlined,
|
||||||
|
mergeBorder,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
@@ -24,7 +25,7 @@ export const SequenceCard = as<
|
|||||||
<Box
|
<Box
|
||||||
as={AsSequenceCard}
|
as={AsSequenceCard}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
css.SequenceCard({ radii, outlined }),
|
css.SequenceCard({ radii, outlined, mergeBorder }),
|
||||||
ContainerColor({ variant }),
|
ContainerColor({ variant }),
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const SequenceCard = recipe({
|
|||||||
},
|
},
|
||||||
borderStyle: 'solid',
|
borderStyle: 'solid',
|
||||||
borderWidth: outlinedWidth,
|
borderWidth: outlinedWidth,
|
||||||
borderBottomWidth: 0,
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'&:first-child, :not(&) + &': {
|
'&:first-child, :not(&) + &': {
|
||||||
borderTopLeftRadius: [radii],
|
borderTopLeftRadius: [radii],
|
||||||
@@ -20,7 +20,6 @@ export const SequenceCard = recipe({
|
|||||||
'&:last-child, &:not(:has(+&))': {
|
'&:last-child, &:not(:has(+&))': {
|
||||||
borderBottomLeftRadius: [radii],
|
borderBottomLeftRadius: [radii],
|
||||||
borderBottomRightRadius: [radii],
|
borderBottomRightRadius: [radii],
|
||||||
borderBottomWidth: outlinedWidth,
|
|
||||||
},
|
},
|
||||||
[`&[data-first-child="true"]`]: {
|
[`&[data-first-child="true"]`]: {
|
||||||
borderTopLeftRadius: [radii],
|
borderTopLeftRadius: [radii],
|
||||||
@@ -74,6 +73,16 @@ export const SequenceCard = recipe({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mergeBorder: {
|
||||||
|
true: {
|
||||||
|
borderBottomWidth: 0,
|
||||||
|
selectors: {
|
||||||
|
'&:last-child, &:not(:has(+&))': {
|
||||||
|
borderBottomWidth: outlinedWidth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
radii: '400',
|
radii: '400',
|
||||||
|
|||||||
18
src/app/components/stacked-avatar/StackedAvatar.tsx
Normal file
18
src/app/components/stacked-avatar/StackedAvatar.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { as, Avatar } from 'folds';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
|
||||||
|
type StackedAvatarProps = {
|
||||||
|
radii?: '0' | '300' | '400' | '500' | 'Pill' | 'Inherit' | undefined;
|
||||||
|
};
|
||||||
|
export const StackedAvatar = as<'span', css.StackedAvatarVariants & StackedAvatarProps>(
|
||||||
|
({ size, variant, className, ...props }, ref) => (
|
||||||
|
<Avatar
|
||||||
|
size={size}
|
||||||
|
className={classNames(css.StackedAvatar({ size, variant }), className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
1
src/app/components/stacked-avatar/index.ts
Normal file
1
src/app/components/stacked-avatar/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './StackedAvatar';
|
||||||
59
src/app/components/stacked-avatar/styles.css.ts
Normal file
59
src/app/components/stacked-avatar/styles.css.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { ComplexStyleRule } from '@vanilla-extract/css';
|
||||||
|
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
||||||
|
import { color, config, ContainerColor, toRem } from 'folds';
|
||||||
|
|
||||||
|
const getVariant = (variant: ContainerColor): ComplexStyleRule => ({
|
||||||
|
outlineColor: color[variant].Container,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StackedAvatar = recipe({
|
||||||
|
base: {
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
outlineStyle: 'solid',
|
||||||
|
selectors: {
|
||||||
|
'&:first-child': {
|
||||||
|
marginLeft: 0,
|
||||||
|
},
|
||||||
|
'button&': {
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
'200': {
|
||||||
|
marginLeft: toRem(-6),
|
||||||
|
outlineWidth: config.borderWidth.B300,
|
||||||
|
},
|
||||||
|
'300': {
|
||||||
|
marginLeft: toRem(-9),
|
||||||
|
outlineWidth: config.borderWidth.B400,
|
||||||
|
},
|
||||||
|
'400': {
|
||||||
|
marginLeft: toRem(-10.5),
|
||||||
|
outlineWidth: config.borderWidth.B500,
|
||||||
|
},
|
||||||
|
'500': {
|
||||||
|
marginLeft: toRem(-13),
|
||||||
|
outlineWidth: config.borderWidth.B600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
Background: getVariant('Background'),
|
||||||
|
Surface: getVariant('Surface'),
|
||||||
|
SurfaceVariant: getVariant('SurfaceVariant'),
|
||||||
|
Primary: getVariant('Primary'),
|
||||||
|
Secondary: getVariant('Secondary'),
|
||||||
|
Success: getVariant('Success'),
|
||||||
|
Warning: getVariant('Warning'),
|
||||||
|
Critical: getVariant('Critical'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: '400',
|
||||||
|
variant: 'Surface',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StackedAvatarVariants = RecipeVariants<typeof StackedAvatar>;
|
||||||
@@ -323,7 +323,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RoomIcon size="100" joinRule={room.getJoinRule()} />
|
<RoomIcon size="100" joinRule={room.getJoinRule()} roomType={room.getType()} />
|
||||||
)}
|
)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ export type AutoDiscoveryInfo = Record<string, unknown> & {
|
|||||||
'm.identity_server'?: {
|
'm.identity_server'?: {
|
||||||
base_url: string;
|
base_url: string;
|
||||||
};
|
};
|
||||||
|
'org.matrix.msc2965.authentication'?: {
|
||||||
|
account?: string;
|
||||||
|
issuer?: string;
|
||||||
|
};
|
||||||
|
'org.matrix.msc4143.rtc_foci'?: [
|
||||||
|
{
|
||||||
|
livekit_service_url: string;
|
||||||
|
type: 'livekit';
|
||||||
|
}
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const autoDiscovery = async (
|
export const autoDiscovery = async (
|
||||||
|
|||||||
@@ -291,7 +291,11 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RoomIcon size="200" joinRule={room.getJoinRule()} />
|
<RoomIcon
|
||||||
|
size="200"
|
||||||
|
joinRule={room.getJoinRule()}
|
||||||
|
roomType={room.getType()}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
}
|
}
|
||||||
|
|||||||
226
src/app/features/call-status/CallControl.tsx
Normal file
226
src/app/features/call-status/CallControl.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { StatusDivider } from './components';
|
||||||
|
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import { callEmbedAtom } from '../../state/callEmbed';
|
||||||
|
|
||||||
|
type MicrophoneButtonProps = {
|
||||||
|
enabled: boolean;
|
||||||
|
onToggle: () => Promise<unknown>;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Top"
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text size="T200">{enabled ? 'Turn Off Microphone' : 'Turn On Microphone'}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(anchorRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={anchorRef}
|
||||||
|
variant={enabled ? 'Surface' : 'Warning'}
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
size="300"
|
||||||
|
onClick={() => onToggle()}
|
||||||
|
outlined
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SoundButtonProps = {
|
||||||
|
enabled: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Top"
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text size="T200">{enabled ? 'Turn Off Sound' : 'Turn On Sound'}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(anchorRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={anchorRef}
|
||||||
|
variant={enabled ? 'Surface' : 'Warning'}
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
size="300"
|
||||||
|
onClick={() => onToggle()}
|
||||||
|
outlined
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size="100"
|
||||||
|
src={enabled ? Icons.Headphone : Icons.HeadphoneMute}
|
||||||
|
filled={!enabled}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoButtonProps = {
|
||||||
|
enabled: boolean;
|
||||||
|
onToggle: () => Promise<unknown>;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Top"
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text size="T200">{enabled ? 'Stop Camera' : 'Start Camera'}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(anchorRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={anchorRef}
|
||||||
|
variant={enabled ? 'Success' : 'Surface'}
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
size="300"
|
||||||
|
onClick={() => onToggle()}
|
||||||
|
outlined
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size="100"
|
||||||
|
src={enabled ? Icons.VideoCamera : Icons.VideoCameraMute}
|
||||||
|
filled={enabled}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScreenShareButtonProps = {
|
||||||
|
enabled: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Top"
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text size="T200">{enabled ? 'Stop Screenshare' : 'Start Screenshare'}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(anchorRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={anchorRef}
|
||||||
|
variant={enabled ? 'Success' : 'Surface'}
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
size="300"
|
||||||
|
onClick={onToggle}
|
||||||
|
outlined
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.ScreenShare} filled={enabled} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CallControl({
|
||||||
|
callEmbed,
|
||||||
|
compact,
|
||||||
|
callJoined,
|
||||||
|
}: {
|
||||||
|
callEmbed: CallEmbed;
|
||||||
|
compact: boolean;
|
||||||
|
callJoined: boolean;
|
||||||
|
}) {
|
||||||
|
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
|
||||||
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
|
|
||||||
|
const [hangupState, hangup] = useAsyncCallback(
|
||||||
|
useCallback(() => callEmbed.hangup(), [callEmbed])
|
||||||
|
);
|
||||||
|
const exiting =
|
||||||
|
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
|
||||||
|
|
||||||
|
const handleHangup = () => {
|
||||||
|
if (!callJoined) {
|
||||||
|
setCallEmbed(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hangup();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box shrink="No" alignItems="Center" gap="300">
|
||||||
|
<Box alignItems="Inherit" gap="200">
|
||||||
|
<MicrophoneButton
|
||||||
|
enabled={microphone}
|
||||||
|
onToggle={() => callEmbed.control.toggleMicrophone()}
|
||||||
|
disabled={!callJoined}
|
||||||
|
/>
|
||||||
|
<SoundButton
|
||||||
|
enabled={sound}
|
||||||
|
onToggle={() => callEmbed.control.toggleSound()}
|
||||||
|
disabled={!callJoined}
|
||||||
|
/>
|
||||||
|
{!compact && <StatusDivider />}
|
||||||
|
<VideoButton
|
||||||
|
enabled={video}
|
||||||
|
onToggle={() => callEmbed.control.toggleVideo()}
|
||||||
|
disabled={!callJoined}
|
||||||
|
/>
|
||||||
|
{!compact && (
|
||||||
|
<ScreenShareButton
|
||||||
|
enabled={screenshare}
|
||||||
|
onToggle={() => callEmbed.control.toggleScreenshare()}
|
||||||
|
disabled={!callJoined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<StatusDivider />
|
||||||
|
<Chip
|
||||||
|
variant="Critical"
|
||||||
|
radii="Pill"
|
||||||
|
fill="Soft"
|
||||||
|
before={
|
||||||
|
exiting ? (
|
||||||
|
<Spinner variant="Critical" fill="Soft" size="50" />
|
||||||
|
) : (
|
||||||
|
<Icon size="50" src={Icons.PhoneDown} filled />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={exiting}
|
||||||
|
outlined
|
||||||
|
onClick={handleHangup}
|
||||||
|
>
|
||||||
|
{!compact && (
|
||||||
|
<Text as="span" size="L400">
|
||||||
|
End
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/app/features/call-status/CallRoomName.tsx
Normal file
55
src/app/features/call-status/CallRoomName.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { Chip, Text } from 'folds';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { useRoomName } from '../../hooks/useRoomMeta';
|
||||||
|
import { RoomIcon } from '../../components/room-avatar';
|
||||||
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
|
import { getAllParents, guessPerfectParent } from '../../utils/room';
|
||||||
|
import { useOrphanSpaces } from '../../state/hooks/roomList';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||||
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
|
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
|
||||||
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
|
|
||||||
|
type CallRoomNameProps = {
|
||||||
|
room: Room;
|
||||||
|
};
|
||||||
|
export function CallRoomName({ room }: CallRoomNameProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const name = useRoomName(room);
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
|
||||||
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const dm = mDirects.has(room.roomId);
|
||||||
|
|
||||||
|
const allRoomsSet = useAllJoinedRoomsSet();
|
||||||
|
const getRoom = useGetRoom(allRoomsSet);
|
||||||
|
|
||||||
|
const allParents = getAllParents(roomToParents, room.roomId);
|
||||||
|
const orphanParents = allParents && orphanSpaces.filter((o) => allParents.has(o));
|
||||||
|
const perfectOrphanParent = orphanParents && guessPerfectParent(mx, room.roomId, orphanParents);
|
||||||
|
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
variant="Background"
|
||||||
|
radii="Pill"
|
||||||
|
before={
|
||||||
|
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} filled />
|
||||||
|
}
|
||||||
|
onClick={() => navigateRoom(room.roomId)}
|
||||||
|
>
|
||||||
|
<Text size="L400" truncate>
|
||||||
|
{name}
|
||||||
|
{!dm && perfectOrphanParent && (
|
||||||
|
<Text as="span" size="T200" priority="300">
|
||||||
|
{' •'} <b>{getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/app/features/call-status/CallStatus.tsx
Normal file
81
src/app/features/call-status/CallStatus.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Spinner } from 'folds';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { LiveChip } from './LiveChip';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
import { CallRoomName } from './CallRoomName';
|
||||||
|
import { CallControl } from './CallControl';
|
||||||
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
|
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||||
|
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||||||
|
import { MemberGlance } from './MemberGlance';
|
||||||
|
import { StatusDivider } from './components';
|
||||||
|
import { CallEmbed } from '../../plugins/call/CallEmbed';
|
||||||
|
import { useCallJoined } from '../../hooks/useCallEmbed';
|
||||||
|
import { useCallSpeakers } from '../../hooks/useCallSpeakers';
|
||||||
|
import { MemberSpeaking } from './MemberSpeaking';
|
||||||
|
|
||||||
|
type CallStatusProps = {
|
||||||
|
callEmbed: CallEmbed;
|
||||||
|
};
|
||||||
|
export function CallStatus({ callEmbed }: CallStatusProps) {
|
||||||
|
const { room } = callEmbed;
|
||||||
|
|
||||||
|
const callSession = useCallSession(room);
|
||||||
|
const callMembers = useCallMembers(room, callSession);
|
||||||
|
const screenSize = useScreenSize();
|
||||||
|
const callJoined = useCallJoined(callEmbed);
|
||||||
|
const speakers = useCallSpeakers(callEmbed);
|
||||||
|
|
||||||
|
const compact = screenSize === ScreenSize.Mobile;
|
||||||
|
|
||||||
|
const memberVisible = callJoined && callMembers.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={classNames(css.CallStatus, ContainerColor({ variant: 'Background' }))}
|
||||||
|
shrink="No"
|
||||||
|
gap="400"
|
||||||
|
alignItems={compact ? undefined : 'Center'}
|
||||||
|
direction={compact ? 'Column' : 'Row'}
|
||||||
|
>
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
{memberVisible ? (
|
||||||
|
<Box shrink="No">
|
||||||
|
<LiveChip count={callMembers.length} room={room} members={callMembers} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Spinner variant="Secondary" size="200" />
|
||||||
|
)}
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="Inherit">
|
||||||
|
{!compact && (
|
||||||
|
<>
|
||||||
|
<CallRoomName room={room} />
|
||||||
|
{speakers.size > 0 && (
|
||||||
|
<>
|
||||||
|
<StatusDivider />
|
||||||
|
<span data-spacing-node />
|
||||||
|
<MemberSpeaking room={room} speakers={speakers} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{memberVisible && (
|
||||||
|
<Box shrink="No">
|
||||||
|
<MemberGlance room={room} members={callMembers} speakers={speakers} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{memberVisible && !compact && <StatusDivider />}
|
||||||
|
<Box shrink="No" alignItems="Center" gap="Inherit">
|
||||||
|
{compact && (
|
||||||
|
<Box grow="Yes">
|
||||||
|
<CallRoomName room={room} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<CallControl callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
src/app/features/call-status/LiveChip.tsx
Normal file
137
src/app/features/call-status/LiveChip.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import React, { MouseEventHandler, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
config,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
PopOut,
|
||||||
|
RectCords,
|
||||||
|
Scroll,
|
||||||
|
Text,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||||
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { UserAvatar } from '../../components/user-avatar';
|
||||||
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
|
import { getMouseEventCords } from '../../utils/dom';
|
||||||
|
|
||||||
|
type LiveChipProps = {
|
||||||
|
room: Room;
|
||||||
|
members: CallMembership[];
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
export function LiveChip({ count, room, members }: LiveChipProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const openUserProfile = useOpenUserRoomProfile();
|
||||||
|
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Top"
|
||||||
|
align="Start"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
style={{
|
||||||
|
maxHeight: '75vh',
|
||||||
|
maxWidth: toRem(300),
|
||||||
|
display: 'flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Scroll size="0" hideTrack visibility="Hover">
|
||||||
|
<Box direction="Column" style={{ padding: config.space.S100 }}>
|
||||||
|
{members.map((callMember) => {
|
||||||
|
const userId = callMember.sender;
|
||||||
|
if (!userId) return null;
|
||||||
|
const name =
|
||||||
|
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||||
|
const avatarMxc = getMemberAvatarMxc(room, userId);
|
||||||
|
const avatarUrl = avatarMxc
|
||||||
|
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={callMember.membershipID}
|
||||||
|
size="400"
|
||||||
|
variant="Surface"
|
||||||
|
radii="300"
|
||||||
|
style={{ paddingLeft: config.space.S200 }}
|
||||||
|
onClick={(evt) =>
|
||||||
|
openUserProfile(
|
||||||
|
room.roomId,
|
||||||
|
undefined,
|
||||||
|
userId,
|
||||||
|
getMouseEventCords(evt.nativeEvent),
|
||||||
|
'Right'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
before={
|
||||||
|
<Avatar size="200" radii="400">
|
||||||
|
<UserAvatar
|
||||||
|
userId={userId}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={name}
|
||||||
|
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
variant="Surface"
|
||||||
|
fill="Soft"
|
||||||
|
before={<Badge variant="Critical" fill="Solid" size="200" />}
|
||||||
|
after={<Icon size="50" src={cords ? Icons.ChevronBottom : Icons.ChevronTop} />}
|
||||||
|
radii="Pill"
|
||||||
|
onClick={handleOpenMenu}
|
||||||
|
>
|
||||||
|
<Text className={css.LiveChipText} as="span" size="L400" truncate>
|
||||||
|
{count} Live
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/app/features/call-status/MemberGlance.tsx
Normal file
75
src/app/features/call-status/MemberGlance.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Box, config, Icon, Icons, Text } from 'folds';
|
||||||
|
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||||
|
import React from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { UserAvatar } from '../../components/user-avatar';
|
||||||
|
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||||
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { StackedAvatar } from '../../components/stacked-avatar';
|
||||||
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
|
import { getMouseEventCords } from '../../utils/dom';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
|
||||||
|
type MemberGlanceProps = {
|
||||||
|
room: Room;
|
||||||
|
members: CallMembership[];
|
||||||
|
speakers: Set<string>;
|
||||||
|
max?: number;
|
||||||
|
};
|
||||||
|
export function MemberGlance({ room, members, speakers, max = 6 }: MemberGlanceProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const openUserProfile = useOpenUserRoomProfile();
|
||||||
|
|
||||||
|
const visibleMembers = members.slice(0, max);
|
||||||
|
const remainingCount = max && members.length > max ? members.length - max : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box alignItems="Center">
|
||||||
|
{visibleMembers.map((callMember) => {
|
||||||
|
const userId = callMember.sender;
|
||||||
|
if (!userId) return null;
|
||||||
|
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||||
|
const avatarMxc = getMemberAvatarMxc(room, userId);
|
||||||
|
const avatarUrl = avatarMxc
|
||||||
|
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StackedAvatar
|
||||||
|
key={callMember.membershipID}
|
||||||
|
className={speakers.has(callMember.sender) ? css.SpeakerAvatarOutline : undefined}
|
||||||
|
title={name}
|
||||||
|
as="button"
|
||||||
|
variant="Background"
|
||||||
|
size="200"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={(evt) =>
|
||||||
|
openUserProfile(
|
||||||
|
room.roomId,
|
||||||
|
undefined,
|
||||||
|
userId,
|
||||||
|
getMouseEventCords(evt.nativeEvent),
|
||||||
|
'Top'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
userId={userId}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={name}
|
||||||
|
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||||
|
/>
|
||||||
|
</StackedAvatar>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{remainingCount > 0 && (
|
||||||
|
<Text size="L400" style={{ paddingLeft: config.space.S100 }}>
|
||||||
|
+{remainingCount}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/app/features/call-status/MemberSpeaking.tsx
Normal file
78
src/app/features/call-status/MemberSpeaking.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Icon, Icons, Text } from 'folds';
|
||||||
|
import { getMemberDisplayName } from '../../utils/room';
|
||||||
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
|
|
||||||
|
type MemberSpeakingProps = {
|
||||||
|
room: Room;
|
||||||
|
speakers: Set<string>;
|
||||||
|
};
|
||||||
|
export function MemberSpeaking({ room, speakers }: MemberSpeakingProps) {
|
||||||
|
const speakingNames = Array.from(speakers).map(
|
||||||
|
(userId) => getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Box alignItems="Center" gap="100">
|
||||||
|
<Icon size="100" src={Icons.Mic} filled />
|
||||||
|
<Text size="T200" truncate>
|
||||||
|
{speakingNames.length === 1 && (
|
||||||
|
<>
|
||||||
|
<b>{speakingNames[0]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' is speaking...'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{speakingNames.length === 2 && (
|
||||||
|
<>
|
||||||
|
<b>{speakingNames[0]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' and '}
|
||||||
|
</Text>
|
||||||
|
<b>{speakingNames[1]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' are speaking...'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{speakingNames.length === 3 && (
|
||||||
|
<>
|
||||||
|
<b>{speakingNames[0]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{', '}
|
||||||
|
</Text>
|
||||||
|
<b>{speakingNames[1]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' and '}
|
||||||
|
</Text>
|
||||||
|
<b>{speakingNames[2]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' are speaking...'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{speakingNames.length > 3 && (
|
||||||
|
<>
|
||||||
|
<b>{speakingNames[0]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{', '}
|
||||||
|
</Text>
|
||||||
|
<b>{speakingNames[1]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{', '}
|
||||||
|
</Text>
|
||||||
|
<b>{speakingNames[2]}</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' and '}
|
||||||
|
</Text>
|
||||||
|
<b>{speakingNames.length - 3} others</b>
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{' are speaking...'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/app/features/call-status/components.tsx
Normal file
9
src/app/features/call-status/components.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Line } from 'folds';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
|
||||||
|
export function StatusDivider() {
|
||||||
|
return (
|
||||||
|
<Line variant="Background" size="300" direction="Vertical" className={css.ControlDivider} />
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/features/call-status/index.ts
Normal file
1
src/app/features/call-status/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './CallStatus';
|
||||||
21
src/app/features/call-status/styles.css.ts
Normal file
21
src/app/features/call-status/styles.css.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const LiveChipText = style({
|
||||||
|
color: color.Critical.Main,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CallStatus = style([
|
||||||
|
{
|
||||||
|
padding: `${toRem(6)} ${config.space.S200}`,
|
||||||
|
borderTop: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ControlDivider = style({
|
||||||
|
height: toRem(16),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SpeakerAvatarOutline = style({
|
||||||
|
boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Success.Main}`,
|
||||||
|
});
|
||||||
203
src/app/features/call/CallControls.tsx
Normal file
203
src/app/features/call/CallControls.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
config,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
PopOut,
|
||||||
|
RectCords,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
import {
|
||||||
|
ChatButton,
|
||||||
|
ControlDivider,
|
||||||
|
MicrophoneButton,
|
||||||
|
ScreenShareButton,
|
||||||
|
SoundButton,
|
||||||
|
VideoButton,
|
||||||
|
} from './Controls';
|
||||||
|
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||||
|
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
|
||||||
|
type CallControlsProps = {
|
||||||
|
callEmbed: CallEmbed;
|
||||||
|
};
|
||||||
|
export function CallControls({ callEmbed }: CallControlsProps) {
|
||||||
|
const controlRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [compact, setCompact] = useState(document.body.clientWidth < 500);
|
||||||
|
|
||||||
|
useResizeObserver(
|
||||||
|
useCallback(() => {
|
||||||
|
const element = controlRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
setCompact(element.clientWidth < 500);
|
||||||
|
}, []),
|
||||||
|
useCallback(() => controlRef.current, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const { microphone, video, sound, screenshare, spotlight } = useCallControlState(
|
||||||
|
callEmbed.control
|
||||||
|
);
|
||||||
|
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSpotlightClick = () => {
|
||||||
|
callEmbed.control.toggleSpotlight();
|
||||||
|
setCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReactionsClick = () => {
|
||||||
|
callEmbed.control.toggleReactions();
|
||||||
|
setCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSettingsClick = () => {
|
||||||
|
callEmbed.control.toggleSettings();
|
||||||
|
setCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [hangupState, hangup] = useAsyncCallback(
|
||||||
|
useCallback(() => callEmbed.hangup(), [callEmbed])
|
||||||
|
);
|
||||||
|
const exiting =
|
||||||
|
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={controlRef}
|
||||||
|
className={css.CallControlContainer}
|
||||||
|
justifyContent="Center"
|
||||||
|
alignItems="Center"
|
||||||
|
>
|
||||||
|
<SequenceCard
|
||||||
|
className={css.ControlCard}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
gap="400"
|
||||||
|
radii="500"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="SpaceBetween"
|
||||||
|
>
|
||||||
|
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||||||
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
|
<MicrophoneButton
|
||||||
|
enabled={microphone}
|
||||||
|
onToggle={() => callEmbed.control.toggleMicrophone()}
|
||||||
|
/>
|
||||||
|
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||||
|
</Box>
|
||||||
|
{!compact && <ControlDivider />}
|
||||||
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
|
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} />
|
||||||
|
<ScreenShareButton
|
||||||
|
enabled={screenshare}
|
||||||
|
onToggle={() => callEmbed.control.toggleScreenshare()}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{!compact && <ControlDivider />}
|
||||||
|
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||||||
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
|
<ChatButton />
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Top"
|
||||||
|
align="Center"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
radii="300"
|
||||||
|
onClick={handleSpotlightClick}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
{spotlight ? 'Grid View' : 'Spotlight View'}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
radii="300"
|
||||||
|
onClick={handleReactionsClick}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
Reactions
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
radii="300"
|
||||||
|
onClick={handleSettingsClick}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
Settings
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
variant="Surface"
|
||||||
|
fill="Soft"
|
||||||
|
radii="400"
|
||||||
|
size="400"
|
||||||
|
onClick={handleOpenMenu}
|
||||||
|
outlined
|
||||||
|
aria-pressed={!!cords}
|
||||||
|
>
|
||||||
|
<Icon size="400" src={Icons.VerticalDots} />
|
||||||
|
</IconButton>
|
||||||
|
</PopOut>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" direction="Column">
|
||||||
|
<Button
|
||||||
|
style={{ minWidth: toRem(88) }}
|
||||||
|
variant="Critical"
|
||||||
|
fill="Solid"
|
||||||
|
onClick={hangup}
|
||||||
|
before={
|
||||||
|
exiting ? (
|
||||||
|
<Spinner variant="Critical" fill="Solid" size="200" />
|
||||||
|
) : (
|
||||||
|
<Icon src={Icons.PhoneDown} size="200" filled />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={exiting}
|
||||||
|
>
|
||||||
|
<Text size="B400">End</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/app/features/call/CallMemberCard.tsx
Normal file
121
src/app/features/call/CallMemberCard.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { CallMembership, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Avatar, Box, Icon, Icons, Text } from 'folds';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
|
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||||
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
|
import { UserAvatar } from '../../components/user-avatar';
|
||||||
|
import { getMouseEventCords } from '../../utils/dom';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
|
||||||
|
interface MemberWithMembershipData {
|
||||||
|
membershipData?: SessionMembershipData & {
|
||||||
|
'm.call.intent': 'video' | 'audio';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallMemberCardProps = {
|
||||||
|
member: CallMembership;
|
||||||
|
};
|
||||||
|
export function CallMemberCard({ member }: CallMemberCardProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const room = useRoom();
|
||||||
|
|
||||||
|
const openUserProfile = useOpenUserRoomProfile();
|
||||||
|
|
||||||
|
const userId = member.sender;
|
||||||
|
if (!userId) return null;
|
||||||
|
|
||||||
|
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||||
|
const avatarMxc = getMemberAvatarMxc(room, userId);
|
||||||
|
const avatarUrl = avatarMxc
|
||||||
|
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const audioOnly =
|
||||||
|
(member as unknown as MemberWithMembershipData).membershipData?.['m.call.intent'] === 'audio';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SequenceCard
|
||||||
|
as="button"
|
||||||
|
key={member.membershipID}
|
||||||
|
className={css.CallMemberCard}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="500"
|
||||||
|
onClick={(evt: any) =>
|
||||||
|
openUserProfile(
|
||||||
|
room.roomId,
|
||||||
|
undefined,
|
||||||
|
userId,
|
||||||
|
getMouseEventCords(evt.nativeEvent),
|
||||||
|
'Right'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box grow="Yes" gap="300" alignItems="Center">
|
||||||
|
<Avatar size="200" radii="400">
|
||||||
|
<UserAvatar
|
||||||
|
userId={userId}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={name}
|
||||||
|
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="L400" truncate>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{audioOnly && <Icon src={Icons.VideoCameraMute} size="100" />}
|
||||||
|
</Box>
|
||||||
|
</SequenceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CallMemberRenderer({
|
||||||
|
members,
|
||||||
|
max = 4,
|
||||||
|
}: {
|
||||||
|
members: CallMembership[];
|
||||||
|
max?: number;
|
||||||
|
}) {
|
||||||
|
const [viewMore, setViewMore] = useState(false);
|
||||||
|
|
||||||
|
const truncatedMembers = viewMore ? members : members.slice(0, 4);
|
||||||
|
const remaining = members.length - truncatedMembers.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{truncatedMembers.map((member) => (
|
||||||
|
<CallMemberCard key={member.membershipID} member={member} />
|
||||||
|
))}
|
||||||
|
{members.length > max && (
|
||||||
|
<SequenceCard
|
||||||
|
as="button"
|
||||||
|
className={css.CallMemberCard}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="500"
|
||||||
|
onClick={() => setViewMore(!viewMore)}
|
||||||
|
>
|
||||||
|
<Box grow="Yes" gap="300" alignItems="Center">
|
||||||
|
{viewMore ? (
|
||||||
|
<Text size="L400" truncate>
|
||||||
|
Collapse
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text size="L400" truncate>
|
||||||
|
{remaining === 0 ? `+${remaining} Other` : `+${remaining} Others`}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Icon src={viewMore ? Icons.ChevronTop : Icons.ChevronBottom} size="100" />
|
||||||
|
</SequenceCard>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
src/app/features/call/CallView.tsx
Normal file
150
src/app/features/call/CallView.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import React, { RefObject, useRef } from 'react';
|
||||||
|
import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds';
|
||||||
|
import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed';
|
||||||
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
|
import { PrescreenControls } from './PrescreenControls';
|
||||||
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
|
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||||
|
import { CallMemberRenderer } from './CallMemberCard';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
import { CallControls } from './CallControls';
|
||||||
|
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||||
|
|
||||||
|
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.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JoinMessage({
|
||||||
|
hasParticipant,
|
||||||
|
livekitSupported,
|
||||||
|
}: {
|
||||||
|
hasParticipant?: boolean;
|
||||||
|
livekitSupported?: boolean;
|
||||||
|
}) {
|
||||||
|
if (hasParticipant) return null;
|
||||||
|
|
||||||
|
if (livekitSupported === false) {
|
||||||
|
return <LivekitServerMissingMessage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text style={{ margin: 'auto' }} size="L400" align="Center">
|
||||||
|
Voice chat’s empty — Be the first to hop in!
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoPermissionMessage() {
|
||||||
|
return (
|
||||||
|
<Text style={{ margin: 'auto' }} size="L400" align="Center">
|
||||||
|
You don't have permission to join!
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlreadyInCallMessage() {
|
||||||
|
return (
|
||||||
|
<Text style={{ margin: 'auto', color: color.Warning.Main }} size="L400" align="Center">
|
||||||
|
Already in another call — End the current call to join!
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CallPrescreen() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const livekitSupported = useLivekitSupport();
|
||||||
|
|
||||||
|
const powerLevels = usePowerLevelsContext();
|
||||||
|
const creators = useRoomCreators(room);
|
||||||
|
|
||||||
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
|
const hasPermission = permissions.event(StateEvent.GroupCallMemberPrefix, mx.getSafeUserId());
|
||||||
|
|
||||||
|
const callSession = useCallSession(room);
|
||||||
|
const callMembers = useCallMembers(room, callSession);
|
||||||
|
const hasParticipant = callMembers.length > 0;
|
||||||
|
|
||||||
|
const callEmbed = useCallEmbed();
|
||||||
|
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
|
||||||
|
|
||||||
|
const canJoin = hasPermission && (livekitSupported || hasParticipant);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Scroll variant="Surface" hideTrack>
|
||||||
|
<Box className={css.CallViewContent} alignItems="Center" justifyContent="Center">
|
||||||
|
<Box style={{ maxWidth: toRem(382), width: '100%' }} direction="Column" gap="100">
|
||||||
|
{hasParticipant && (
|
||||||
|
<Header size="300">
|
||||||
|
<Box grow="Yes" alignItems="Center">
|
||||||
|
<Text size="L400">Participant</Text>
|
||||||
|
</Box>
|
||||||
|
<Badge variant="Critical" fill="Solid" size="400">
|
||||||
|
<Text as="span" size="L400" truncate>
|
||||||
|
{callMembers.length} Live
|
||||||
|
</Text>
|
||||||
|
</Badge>
|
||||||
|
</Header>
|
||||||
|
)}
|
||||||
|
<CallMemberRenderer members={callMembers} />
|
||||||
|
<PrescreenControls canJoin={canJoin} />
|
||||||
|
<Box className={css.PrescreenMessage} alignItems="Center">
|
||||||
|
{!inOtherCall &&
|
||||||
|
(hasPermission ? (
|
||||||
|
<JoinMessage hasParticipant={hasParticipant} livekitSupported={livekitSupported} />
|
||||||
|
) : (
|
||||||
|
<NoPermissionMessage />
|
||||||
|
))}
|
||||||
|
{inOtherCall && <AlreadyInCallMessage />}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallJoinedProps = {
|
||||||
|
containerRef: RefObject<HTMLDivElement>;
|
||||||
|
joined: boolean;
|
||||||
|
};
|
||||||
|
function CallJoined({ joined, containerRef }: CallJoinedProps) {
|
||||||
|
const callEmbed = useCallEmbed();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Box grow="Yes" ref={containerRef} />
|
||||||
|
{callEmbed && joined && <CallControls callEmbed={callEmbed} />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CallView() {
|
||||||
|
const room = useRoom();
|
||||||
|
const callContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
useCallEmbedPlacementSync(callContainerRef);
|
||||||
|
|
||||||
|
const callEmbed = useCallEmbed();
|
||||||
|
const callJoined = useCallJoined(callEmbed);
|
||||||
|
|
||||||
|
const currentJoined = callEmbed?.roomId === room.roomId && callJoined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={ContainerColor({ variant: 'Surface' })}
|
||||||
|
style={{ minWidth: toRem(280) }}
|
||||||
|
grow="Yes"
|
||||||
|
>
|
||||||
|
{!currentJoined && <CallPrescreen />}
|
||||||
|
<CallJoined joined={currentJoined} containerRef={callContainerRef} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
src/app/features/call/Controls.tsx
Normal file
177
src/app/features/call/Controls.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Icon, IconButton, Icons, Line, Text, Tooltip, TooltipProvider } from 'folds';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
import { callChatAtom } from '../../state/callEmbed';
|
||||||
|
|
||||||
|
export function ControlDivider() {
|
||||||
|
return (
|
||||||
|
<Line variant="SurfaceVariant" size="300" direction="Vertical" className={css.ControlDivider} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type MicrophoneButtonProps = {
|
||||||
|
enabled: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
};
|
||||||
|
export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Top"
|
||||||
|
delay={500}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text size="T200">{enabled ? 'Turn Off Microphone' : 'Turn On Microphone'}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(anchorRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={anchorRef}
|
||||||
|
variant={enabled ? 'Surface' : 'Warning'}
|
||||||
|
fill="Soft"
|
||||||
|
radii="400"
|
||||||
|
size="400"
|
||||||
|
onClick={() => onToggle()}
|
||||||
|
outlined
|
||||||
|
>
|
||||||
|
<Icon size="400" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SoundButtonProps = {
|
||||||
|
enabled: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
};
|
||||||
|
export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Top"
|
||||||
|
delay={500}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text size="T200">{enabled ? 'Turn Off Sound' : 'Turn On Sound'}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(anchorRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={anchorRef}
|
||||||
|
variant={enabled ? 'Surface' : 'Warning'}
|
||||||
|
fill="Soft"
|
||||||
|
radii="400"
|
||||||
|
size="400"
|
||||||
|
onClick={() => onToggle()}
|
||||||
|
outlined
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size="400"
|
||||||
|
src={enabled ? Icons.Headphone : Icons.HeadphoneMute}
|
||||||
|
filled={!enabled}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoButtonProps = {
|
||||||
|
enabled: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
};
|
||||||
|
export function VideoButton({ enabled, onToggle }: VideoButtonProps) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Top"
|
||||||
|
delay={500}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text size="T200">{enabled ? 'Stop Camera' : 'Start Camera'}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(anchorRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={anchorRef}
|
||||||
|
variant={enabled ? 'Success' : 'Surface'}
|
||||||
|
fill="Soft"
|
||||||
|
radii="400"
|
||||||
|
size="400"
|
||||||
|
onClick={() => onToggle()}
|
||||||
|
outlined
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size="400"
|
||||||
|
src={enabled ? Icons.VideoCamera : Icons.VideoCameraMute}
|
||||||
|
filled={enabled}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScreenShareButtonProps = {
|
||||||
|
enabled: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
};
|
||||||
|
export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Top"
|
||||||
|
delay={500}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text size="T200">{enabled ? 'Stop Screenshare' : 'Start Screenshare'}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(anchorRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={anchorRef}
|
||||||
|
variant={enabled ? 'Success' : 'Surface'}
|
||||||
|
fill="Soft"
|
||||||
|
radii="400"
|
||||||
|
size="400"
|
||||||
|
onClick={() => onToggle()}
|
||||||
|
outlined
|
||||||
|
>
|
||||||
|
<Icon size="400" src={Icons.ScreenShare} filled={enabled} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatButton() {
|
||||||
|
const [chat, setChat] = useAtom(callChatAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Top"
|
||||||
|
delay={500}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text size="T200">{chat ? 'Close Chat' : 'Open Chat'}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(anchorRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={anchorRef}
|
||||||
|
variant={chat ? 'Success' : 'Surface'}
|
||||||
|
fill="Soft"
|
||||||
|
radii="400"
|
||||||
|
size="400"
|
||||||
|
onClick={() => setChat(!chat)}
|
||||||
|
outlined
|
||||||
|
>
|
||||||
|
<Icon size="400" src={Icons.Message} filled={chat} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/app/features/call/PrescreenControls.tsx
Normal file
67
src/app/features/call/PrescreenControls.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
|
||||||
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton } from './Controls';
|
||||||
|
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||||
|
import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed';
|
||||||
|
import { useCallPreferences } from '../../state/hooks/callPreferences';
|
||||||
|
|
||||||
|
type PrescreenControlsProps = {
|
||||||
|
canJoin?: boolean;
|
||||||
|
};
|
||||||
|
export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
||||||
|
const room = useRoom();
|
||||||
|
const callEmbed = useCallEmbed();
|
||||||
|
const callJoined = useCallJoined(callEmbed);
|
||||||
|
const direct = useIsDirectRoom();
|
||||||
|
|
||||||
|
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
|
||||||
|
|
||||||
|
const startCall = useCallStart(direct);
|
||||||
|
const joining = callEmbed?.roomId === room.roomId && !callJoined;
|
||||||
|
|
||||||
|
const disabled = inOtherCall || !canJoin;
|
||||||
|
|
||||||
|
const { microphone, video, sound, toggleMicrophone, toggleVideo, toggleSound } =
|
||||||
|
useCallPreferences();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SequenceCard
|
||||||
|
className={css.ControlCard}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
gap="400"
|
||||||
|
radii="500"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="SpaceBetween"
|
||||||
|
wrap="Wrap"
|
||||||
|
>
|
||||||
|
<Box shrink="No" alignItems="Inherit" justifyContent="SpaceBetween" gap="200">
|
||||||
|
<MicrophoneButton enabled={microphone} onToggle={toggleMicrophone} />
|
||||||
|
<SoundButton enabled={sound} onToggle={toggleSound} />
|
||||||
|
</Box>
|
||||||
|
<ControlDivider />
|
||||||
|
<Box shrink="No" alignItems="Inherit" justifyContent="SpaceBetween" gap="200">
|
||||||
|
<VideoButton enabled={video} onToggle={toggleVideo} />
|
||||||
|
<ChatButton />
|
||||||
|
</Box>
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Button
|
||||||
|
variant={disabled ? 'Secondary' : 'Success'}
|
||||||
|
fill={disabled ? 'Soft' : 'Solid'}
|
||||||
|
onClick={() => startCall(room, { microphone, video, sound })}
|
||||||
|
disabled={disabled || joining}
|
||||||
|
before={
|
||||||
|
joining ? (
|
||||||
|
<Spinner variant="Success" fill="Solid" size="200" />
|
||||||
|
) : (
|
||||||
|
<Icon src={Icons.Phone} size="200" filled />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="B400">Join</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</SequenceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/app/features/call/styles.css.ts
Normal file
28
src/app/features/call/styles.css.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const CallViewContent = style({
|
||||||
|
padding: config.space.S400,
|
||||||
|
paddingRight: 0,
|
||||||
|
minHeight: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ControlCard = style({
|
||||||
|
padding: config.space.S300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ControlDivider = style({
|
||||||
|
height: toRem(24),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CallMemberCard = style({
|
||||||
|
padding: config.space.S300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CallControlContainer = style({
|
||||||
|
padding: config.space.S400,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PrescreenMessage = style({
|
||||||
|
padding: config.space.S200,
|
||||||
|
});
|
||||||
@@ -6,9 +6,8 @@ import { useAtomValue } from 'jotai';
|
|||||||
import {
|
import {
|
||||||
ExtendedJoinRules,
|
ExtendedJoinRules,
|
||||||
JoinRulesSwitcher,
|
JoinRulesSwitcher,
|
||||||
useRoomJoinRuleIcon,
|
useJoinRuleIcons,
|
||||||
useRoomJoinRuleLabel,
|
useRoomJoinRuleLabel,
|
||||||
useSpaceJoinRuleIcon,
|
|
||||||
} from '../../../components/JoinRulesSwitcher';
|
} from '../../../components/JoinRulesSwitcher';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||||
@@ -75,8 +74,7 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) {
|
|||||||
return r;
|
return r;
|
||||||
}, [allowKnockRestricted, allowRestricted, allowKnock, space]);
|
}, [allowKnockRestricted, allowRestricted, allowKnock, space]);
|
||||||
|
|
||||||
const icons = useRoomJoinRuleIcon();
|
const icons = useJoinRuleIcons(room.getType());
|
||||||
const spaceIcons = useSpaceJoinRuleIcon();
|
|
||||||
const labels = useRoomJoinRuleLabel();
|
const labels = useRoomJoinRuleLabel();
|
||||||
|
|
||||||
const [submitState, submit] = useAsyncCallback(
|
const [submitState, submit] = useAsyncCallback(
|
||||||
@@ -137,7 +135,7 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) {
|
|||||||
}
|
}
|
||||||
after={
|
after={
|
||||||
<JoinRulesSwitcher
|
<JoinRulesSwitcher
|
||||||
icons={room.isSpaceRoom() ? spaceIcons : icons}
|
icons={icons}
|
||||||
labels={labels}
|
labels={labels}
|
||||||
rules={joinRules}
|
rules={joinRules}
|
||||||
value={rule}
|
value={rule}
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export function RoomProfileEdit({
|
|||||||
alt={name}
|
alt={name}
|
||||||
renderFallback={() => (
|
renderFallback={() => (
|
||||||
<RoomIcon
|
<RoomIcon
|
||||||
space={room.isSpaceRoom()}
|
roomType={room.getType()}
|
||||||
size="400"
|
size="400"
|
||||||
joinRule={joinRule?.join_rule ?? JoinRule.Invite}
|
joinRule={joinRule?.join_rule ?? JoinRule.Invite}
|
||||||
filled
|
filled
|
||||||
@@ -342,7 +342,7 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
|
|||||||
alt={name}
|
alt={name}
|
||||||
renderFallback={() => (
|
renderFallback={() => (
|
||||||
<RoomIcon
|
<RoomIcon
|
||||||
space={room.isSpaceRoom()}
|
roomType={room.getType()}
|
||||||
size="400"
|
size="400"
|
||||||
joinRule={joinRule?.join_rule ?? JoinRule.Invite}
|
joinRule={joinRule?.join_rule ?? JoinRule.Invite}
|
||||||
filled
|
filled
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
||||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
import { MatrixError, Room, JoinRule } from 'matrix-js-sdk';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -33,24 +33,43 @@ import {
|
|||||||
createRoom,
|
createRoom,
|
||||||
CreateRoomAliasInput,
|
CreateRoomAliasInput,
|
||||||
CreateRoomData,
|
CreateRoomData,
|
||||||
CreateRoomKind,
|
CreateRoomAccess,
|
||||||
CreateRoomKindSelector,
|
CreateRoomAccessSelector,
|
||||||
RoomVersionSelector,
|
RoomVersionSelector,
|
||||||
useAdditionalCreators,
|
useAdditionalCreators,
|
||||||
|
CreateRoomType,
|
||||||
} from '../../components/create-room';
|
} from '../../components/create-room';
|
||||||
|
import { RoomType } from '../../../types/matrix/room';
|
||||||
|
import { CreateRoomTypeSelector } from '../../components/create-room/CreateRoomTypeSelector';
|
||||||
|
import { getRoomIconSrc } from '../../utils/room';
|
||||||
|
|
||||||
const getCreateRoomKindToIcon = (kind: CreateRoomKind) => {
|
const getCreateRoomAccessToIcon = (access: CreateRoomAccess, type?: CreateRoomType) => {
|
||||||
if (kind === CreateRoomKind.Private) return Icons.HashLock;
|
const isVoiceRoom = type === CreateRoomType.VoiceRoom;
|
||||||
if (kind === CreateRoomKind.Restricted) return Icons.Hash;
|
|
||||||
return Icons.HashGlobe;
|
let joinRule: JoinRule = JoinRule.Public;
|
||||||
|
if (access === CreateRoomAccess.Restricted) joinRule = JoinRule.Restricted;
|
||||||
|
if (access === CreateRoomAccess.Private) joinRule = JoinRule.Knock;
|
||||||
|
|
||||||
|
return getRoomIconSrc(Icons, isVoiceRoom ? RoomType.Call : undefined, joinRule);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCreateRoomTypeToIcon = (type: CreateRoomType) => {
|
||||||
|
if (type === CreateRoomType.VoiceRoom) return Icons.VolumeHigh;
|
||||||
|
return Icons.Hash;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CreateRoomFormProps = {
|
type CreateRoomFormProps = {
|
||||||
defaultKind?: CreateRoomKind;
|
defaultAccess?: CreateRoomAccess;
|
||||||
|
defaultType?: CreateRoomType;
|
||||||
space?: Room;
|
space?: Room;
|
||||||
onCreate?: (roomId: string) => void;
|
onCreate?: (roomId: string) => void;
|
||||||
};
|
};
|
||||||
export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormProps) {
|
export function CreateRoomForm({
|
||||||
|
defaultAccess,
|
||||||
|
defaultType,
|
||||||
|
space,
|
||||||
|
onCreate,
|
||||||
|
}: CreateRoomFormProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
|
|
||||||
@@ -64,8 +83,9 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||||||
|
|
||||||
const allowRestricted = space && restrictedSupported(selectedRoomVersion);
|
const allowRestricted = space && restrictedSupported(selectedRoomVersion);
|
||||||
|
|
||||||
const [kind, setKind] = useState(
|
const [type, setType] = useState(defaultType ?? CreateRoomType.TextRoom);
|
||||||
defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private
|
const [access, setAccess] = useState(
|
||||||
|
defaultAccess ?? (allowRestricted ? CreateRoomAccess.Restricted : CreateRoomAccess.Private)
|
||||||
);
|
);
|
||||||
const allowAdditionalCreators = creatorsSupported(selectedRoomVersion);
|
const allowAdditionalCreators = creatorsSupported(selectedRoomVersion);
|
||||||
const { additionalCreators, addAdditionalCreator, removeAdditionalCreator } =
|
const { additionalCreators, addAdditionalCreator, removeAdditionalCreator } =
|
||||||
@@ -75,13 +95,13 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||||||
const [knock, setKnock] = useState(false);
|
const [knock, setKnock] = useState(false);
|
||||||
const [advance, setAdvance] = useState(false);
|
const [advance, setAdvance] = useState(false);
|
||||||
|
|
||||||
const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion);
|
const allowKnock = access === CreateRoomAccess.Private && knockSupported(selectedRoomVersion);
|
||||||
const allowKnockRestricted =
|
const allowKnockRestricted =
|
||||||
kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion);
|
access === CreateRoomAccess.Restricted && knockRestrictedSupported(selectedRoomVersion);
|
||||||
|
|
||||||
const handleRoomVersionChange = (version: string) => {
|
const handleRoomVersionChange = (version: string) => {
|
||||||
if (!restrictedSupported(version)) {
|
if (!restrictedSupported(version)) {
|
||||||
setKind(CreateRoomKind.Private);
|
setAccess(CreateRoomAccess.Private);
|
||||||
}
|
}
|
||||||
selectRoomVersion(version);
|
selectRoomVersion(version);
|
||||||
};
|
};
|
||||||
@@ -107,19 +127,23 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||||||
aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined;
|
aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined;
|
||||||
|
|
||||||
if (!roomName) return;
|
if (!roomName) return;
|
||||||
const publicRoom = kind === CreateRoomKind.Public;
|
const publicRoom = access === CreateRoomAccess.Public;
|
||||||
let roomKnock = false;
|
let roomKnock = false;
|
||||||
if (allowKnock && kind === CreateRoomKind.Private) {
|
if (allowKnock && access === CreateRoomAccess.Private) {
|
||||||
roomKnock = knock;
|
roomKnock = knock;
|
||||||
}
|
}
|
||||||
if (allowKnockRestricted && kind === CreateRoomKind.Restricted) {
|
if (allowKnockRestricted && access === CreateRoomAccess.Restricted) {
|
||||||
roomKnock = knock;
|
roomKnock = knock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let roomType: RoomType | undefined;
|
||||||
|
if (type === CreateRoomType.VoiceRoom) roomType = RoomType.Call;
|
||||||
|
|
||||||
create({
|
create({
|
||||||
version: selectedRoomVersion,
|
version: selectedRoomVersion,
|
||||||
|
type: roomType,
|
||||||
parent: space,
|
parent: space,
|
||||||
kind,
|
access,
|
||||||
name: roomName,
|
name: roomName,
|
||||||
topic: roomTopic || undefined,
|
topic: roomTopic || undefined,
|
||||||
aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
|
aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
|
||||||
@@ -136,21 +160,32 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
|
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
|
||||||
|
{!space && (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Type</Text>
|
||||||
|
<CreateRoomTypeSelector
|
||||||
|
value={type}
|
||||||
|
onSelect={setType}
|
||||||
|
disabled={disabled}
|
||||||
|
getIcon={getCreateRoomTypeToIcon}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Access</Text>
|
<Text size="L400">Access</Text>
|
||||||
<CreateRoomKindSelector
|
<CreateRoomAccessSelector
|
||||||
value={kind}
|
value={access}
|
||||||
onSelect={setKind}
|
onSelect={setAccess}
|
||||||
canRestrict={allowRestricted}
|
canRestrict={allowRestricted}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
getIcon={getCreateRoomKindToIcon}
|
getIcon={(roomAccess) => getCreateRoomAccessToIcon(roomAccess, type)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" direction="Column" gap="100">
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
<Text size="L400">Name</Text>
|
<Text size="L400">Name</Text>
|
||||||
<Input
|
<Input
|
||||||
required
|
required
|
||||||
before={<Icon size="100" src={getCreateRoomKindToIcon(kind)} />}
|
before={<Icon size="100" src={getCreateRoomAccessToIcon(access, type)} />}
|
||||||
name="nameInput"
|
name="nameInput"
|
||||||
autoFocus
|
autoFocus
|
||||||
size="500"
|
size="500"
|
||||||
@@ -171,7 +206,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{kind === CreateRoomKind.Public && <CreateRoomAliasInput disabled={disabled} />}
|
{access === CreateRoomAccess.Public && <CreateRoomAliasInput disabled={disabled} />}
|
||||||
|
|
||||||
<Box shrink="No" direction="Column" gap="100">
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
<Box gap="200" alignItems="End">
|
<Box gap="200" alignItems="End">
|
||||||
@@ -201,7 +236,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
)}
|
)}
|
||||||
{kind !== CreateRoomKind.Public && (
|
{access !== CreateRoomAccess.Public && (
|
||||||
<>
|
<>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
style={{ padding: config.space.S300 }}
|
style={{ padding: config.space.S300 }}
|
||||||
|
|||||||
@@ -23,12 +23,13 @@ import {
|
|||||||
} from '../../state/hooks/createRoomModal';
|
} from '../../state/hooks/createRoomModal';
|
||||||
import { CreateRoomModalState } from '../../state/createRoomModal';
|
import { CreateRoomModalState } from '../../state/createRoomModal';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { CreateRoomType } from '../../components/create-room/types';
|
||||||
|
|
||||||
type CreateRoomModalProps = {
|
type CreateRoomModalProps = {
|
||||||
state: CreateRoomModalState;
|
state: CreateRoomModalState;
|
||||||
};
|
};
|
||||||
function CreateRoomModal({ state }: CreateRoomModalProps) {
|
function CreateRoomModal({ state }: CreateRoomModalProps) {
|
||||||
const { spaceId } = state;
|
const { spaceId, type } = state;
|
||||||
const closeDialog = useCloseCreateRoomModal();
|
const closeDialog = useCloseCreateRoomModal();
|
||||||
|
|
||||||
const allJoinedRooms = useAllJoinedRoomsSet();
|
const allJoinedRooms = useAllJoinedRoomsSet();
|
||||||
@@ -57,7 +58,9 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4">New Room</Text>
|
<Text size="H4">
|
||||||
|
{type === CreateRoomType.VoiceRoom ? 'New Voice Room' : 'New Chat Room'}
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton size="300" radii="300" onClick={closeDialog}>
|
<IconButton size="300" radii="300" onClick={closeDialog}>
|
||||||
@@ -74,7 +77,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
|
|||||||
direction="Column"
|
direction="Column"
|
||||||
gap="500"
|
gap="500"
|
||||||
>
|
>
|
||||||
<CreateRoomForm space={space} onCreate={closeDialog} />
|
<CreateRoomForm space={space} onCreate={closeDialog} defaultType={type} />
|
||||||
</Box>
|
</Box>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -33,25 +33,25 @@ import {
|
|||||||
createRoom,
|
createRoom,
|
||||||
CreateRoomAliasInput,
|
CreateRoomAliasInput,
|
||||||
CreateRoomData,
|
CreateRoomData,
|
||||||
CreateRoomKind,
|
CreateRoomAccess,
|
||||||
CreateRoomKindSelector,
|
CreateRoomAccessSelector,
|
||||||
RoomVersionSelector,
|
RoomVersionSelector,
|
||||||
useAdditionalCreators,
|
useAdditionalCreators,
|
||||||
} from '../../components/create-room';
|
} from '../../components/create-room';
|
||||||
import { RoomType } from '../../../types/matrix/room';
|
import { RoomType } from '../../../types/matrix/room';
|
||||||
|
|
||||||
const getCreateSpaceKindToIcon = (kind: CreateRoomKind) => {
|
const getCreateSpaceAccessToIcon = (access: CreateRoomAccess) => {
|
||||||
if (kind === CreateRoomKind.Private) return Icons.SpaceLock;
|
if (access === CreateRoomAccess.Private) return Icons.SpaceLock;
|
||||||
if (kind === CreateRoomKind.Restricted) return Icons.Space;
|
if (access === CreateRoomAccess.Restricted) return Icons.Space;
|
||||||
return Icons.SpaceGlobe;
|
return Icons.SpaceGlobe;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CreateSpaceFormProps = {
|
type CreateSpaceFormProps = {
|
||||||
defaultKind?: CreateRoomKind;
|
defaultAccess?: CreateRoomAccess;
|
||||||
space?: Room;
|
space?: Room;
|
||||||
onCreate?: (roomId: string) => void;
|
onCreate?: (roomId: string) => void;
|
||||||
};
|
};
|
||||||
export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFormProps) {
|
export function CreateSpaceForm({ defaultAccess, space, onCreate }: CreateSpaceFormProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
|
|
||||||
@@ -65,8 +65,8 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
|
|||||||
|
|
||||||
const allowRestricted = space && restrictedSupported(selectedRoomVersion);
|
const allowRestricted = space && restrictedSupported(selectedRoomVersion);
|
||||||
|
|
||||||
const [kind, setKind] = useState(
|
const [access, setAccess] = useState(
|
||||||
defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private
|
defaultAccess ?? (allowRestricted ? CreateRoomAccess.Restricted : CreateRoomAccess.Private)
|
||||||
);
|
);
|
||||||
|
|
||||||
const allowAdditionalCreators = creatorsSupported(selectedRoomVersion);
|
const allowAdditionalCreators = creatorsSupported(selectedRoomVersion);
|
||||||
@@ -76,13 +76,13 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
|
|||||||
const [knock, setKnock] = useState(false);
|
const [knock, setKnock] = useState(false);
|
||||||
const [advance, setAdvance] = useState(false);
|
const [advance, setAdvance] = useState(false);
|
||||||
|
|
||||||
const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion);
|
const allowKnock = access === CreateRoomAccess.Private && knockSupported(selectedRoomVersion);
|
||||||
const allowKnockRestricted =
|
const allowKnockRestricted =
|
||||||
kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion);
|
access === CreateRoomAccess.Restricted && knockRestrictedSupported(selectedRoomVersion);
|
||||||
|
|
||||||
const handleRoomVersionChange = (version: string) => {
|
const handleRoomVersionChange = (version: string) => {
|
||||||
if (!restrictedSupported(version)) {
|
if (!restrictedSupported(version)) {
|
||||||
setKind(CreateRoomKind.Private);
|
setAccess(CreateRoomAccess.Private);
|
||||||
}
|
}
|
||||||
selectRoomVersion(version);
|
selectRoomVersion(version);
|
||||||
};
|
};
|
||||||
@@ -108,12 +108,12 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
|
|||||||
aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined;
|
aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined;
|
||||||
|
|
||||||
if (!roomName) return;
|
if (!roomName) return;
|
||||||
const publicRoom = kind === CreateRoomKind.Public;
|
const publicRoom = access === CreateRoomAccess.Public;
|
||||||
let roomKnock = false;
|
let roomKnock = false;
|
||||||
if (allowKnock && kind === CreateRoomKind.Private) {
|
if (allowKnock && access === CreateRoomAccess.Private) {
|
||||||
roomKnock = knock;
|
roomKnock = knock;
|
||||||
}
|
}
|
||||||
if (allowKnockRestricted && kind === CreateRoomKind.Restricted) {
|
if (allowKnockRestricted && access === CreateRoomAccess.Restricted) {
|
||||||
roomKnock = knock;
|
roomKnock = knock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
|
|||||||
version: selectedRoomVersion,
|
version: selectedRoomVersion,
|
||||||
type: RoomType.Space,
|
type: RoomType.Space,
|
||||||
parent: space,
|
parent: space,
|
||||||
kind,
|
access,
|
||||||
name: roomName,
|
name: roomName,
|
||||||
topic: roomTopic || undefined,
|
topic: roomTopic || undefined,
|
||||||
aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
|
aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
|
||||||
@@ -139,19 +139,19 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
|
|||||||
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
|
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Access</Text>
|
<Text size="L400">Access</Text>
|
||||||
<CreateRoomKindSelector
|
<CreateRoomAccessSelector
|
||||||
value={kind}
|
value={access}
|
||||||
onSelect={setKind}
|
onSelect={setAccess}
|
||||||
canRestrict={allowRestricted}
|
canRestrict={allowRestricted}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
getIcon={getCreateSpaceKindToIcon}
|
getIcon={getCreateSpaceAccessToIcon}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" direction="Column" gap="100">
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
<Text size="L400">Name</Text>
|
<Text size="L400">Name</Text>
|
||||||
<Input
|
<Input
|
||||||
required
|
required
|
||||||
before={<Icon size="100" src={getCreateSpaceKindToIcon(kind)} />}
|
before={<Icon size="100" src={getCreateSpaceAccessToIcon(access)} />}
|
||||||
name="nameInput"
|
name="nameInput"
|
||||||
autoFocus
|
autoFocus
|
||||||
size="500"
|
size="500"
|
||||||
@@ -172,7 +172,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{kind === CreateRoomKind.Public && <CreateRoomAliasInput disabled={disabled} />}
|
{access === CreateRoomAccess.Public && <CreateRoomAliasInput disabled={disabled} />}
|
||||||
|
|
||||||
<Box shrink="No" direction="Column" gap="100">
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
<Box gap="200" alignItems="End">
|
<Box gap="200" alignItems="End">
|
||||||
@@ -202,7 +202,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
|
|||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
)}
|
)}
|
||||||
{kind !== CreateRoomKind.Public && advance && (allowKnock || allowKnockRestricted) && (
|
{access !== CreateRoomAccess.Public && advance && (allowKnock || allowKnockRestricted) && (
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
style={{ padding: config.space.S300 }}
|
style={{ padding: config.space.S300 }}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
|||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<BackRouteHandler>
|
<BackRouteHandler>
|
||||||
{(onBack) => (
|
{(onBack) => (
|
||||||
<IconButton onClick={onBack}>
|
<IconButton fill="None" onClick={onBack}>
|
||||||
<Icon src={Icons.ArrowLeft} />
|
<Icon src={Icons.ArrowLeft} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
@@ -218,7 +218,11 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
|
<IconButton
|
||||||
|
fill="None"
|
||||||
|
ref={triggerRef}
|
||||||
|
onClick={() => setPeopleDrawer((drawer) => !drawer)}
|
||||||
|
>
|
||||||
<Icon size="400" src={Icons.User} />
|
<Icon size="400" src={Icons.User} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
@@ -235,7 +239,12 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}>
|
<IconButton
|
||||||
|
fill="None"
|
||||||
|
onClick={handleOpenMenu}
|
||||||
|
ref={triggerRef}
|
||||||
|
aria-pressed={!!menuAnchor}
|
||||||
|
>
|
||||||
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
|
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProf
|
|||||||
|
|
||||||
type RoomProfileProps = {
|
type RoomProfileProps = {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
roomType?: string;
|
||||||
name: string;
|
name: string;
|
||||||
topic?: string;
|
topic?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
@@ -185,6 +186,7 @@ type RoomProfileProps = {
|
|||||||
};
|
};
|
||||||
function RoomProfile({
|
function RoomProfile({
|
||||||
roomId,
|
roomId,
|
||||||
|
roomType,
|
||||||
name,
|
name,
|
||||||
topic,
|
topic,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
@@ -200,9 +202,7 @@ function RoomProfile({
|
|||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
alt={name}
|
alt={name}
|
||||||
renderFallback={() => (
|
renderFallback={() => <RoomIcon size="300" joinRule={joinRule} roomType={roomType} />}
|
||||||
<RoomIcon size="300" joinRule={joinRule ?? JoinRule.Restricted} filled />
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
@@ -338,6 +338,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
|
|||||||
{(localSummary) => (
|
{(localSummary) => (
|
||||||
<RoomProfile
|
<RoomProfile
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
|
roomType={localSummary.roomType}
|
||||||
name={localSummary.name}
|
name={localSummary.name}
|
||||||
topic={localSummary.topic}
|
topic={localSummary.topic}
|
||||||
avatarUrl={
|
avatarUrl={
|
||||||
@@ -396,6 +397,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
|
|||||||
{summary && (
|
{summary && (
|
||||||
<RoomProfile
|
<RoomProfile
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
|
roomType={summary.room_type}
|
||||||
name={summary.name || summary.canonical_alias || roomId}
|
name={summary.name || summary.canonical_alias || roomId}
|
||||||
topic={summary.topic}
|
topic={summary.topic}
|
||||||
avatarUrl={
|
avatarUrl={
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||||||
import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
|
import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
|
||||||
import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
|
import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
|
||||||
import { AddExistingModal } from '../add-existing';
|
import { AddExistingModal } from '../add-existing';
|
||||||
|
import { CreateRoomType } from '../../components/create-room/types';
|
||||||
|
import { BetaNoticeBadge } from '../../components/BetaNoticeBadge';
|
||||||
|
|
||||||
function SpaceProfileLoading() {
|
function SpaceProfileLoading() {
|
||||||
return (
|
return (
|
||||||
@@ -249,8 +251,8 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
|
|||||||
setCords(evt.currentTarget.getBoundingClientRect());
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateRoom = () => {
|
const handleCreateRoom = (type?: CreateRoomType) => {
|
||||||
openCreateRoomModal(item.roomId);
|
openCreateRoomModal(item.roomId, type);
|
||||||
setCords(undefined);
|
setCords(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -281,9 +283,19 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
|
|||||||
radii="300"
|
radii="300"
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
fill="None"
|
fill="None"
|
||||||
onClick={handleCreateRoom}
|
onClick={() => handleCreateRoom(CreateRoomType.TextRoom)}
|
||||||
>
|
>
|
||||||
<Text size="T300">New Room</Text>
|
<Text size="T300">Chat Room</Text>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Primary"
|
||||||
|
fill="None"
|
||||||
|
onClick={() => handleCreateRoom(CreateRoomType.VoiceRoom)}
|
||||||
|
after={<BetaNoticeBadge />}
|
||||||
|
>
|
||||||
|
<Text size="T300">Voice Room</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
|
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
|
||||||
<Text size="T300">Existing Room</Text>
|
<Text size="T300">Existing Room</Text>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { SearchOrderBy } from 'matrix-js-sdk';
|
|||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { joinRuleToIconSrc } from '../../utils/room';
|
import { getRoomIconSrc } from '../../utils/room';
|
||||||
import { factoryRoomIdByAtoZ } from '../../utils/sort';
|
import { factoryRoomIdByAtoZ } from '../../utils/sort';
|
||||||
import {
|
import {
|
||||||
SearchItemStrGetter,
|
SearchItemStrGetter,
|
||||||
@@ -274,9 +274,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
|
|||||||
before={
|
before={
|
||||||
<Icon
|
<Icon
|
||||||
size="50"
|
size="50"
|
||||||
src={
|
src={getRoomIconSrc(Icons, room.getType(), room.getJoinRule())}
|
||||||
joinRuleToIconSrc(Icons, room.getJoinRule(), false) ?? Icons.Hash
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -392,10 +390,7 @@ export function SearchFilters({
|
|||||||
onClick={() => onSelectedRoomsChange(selectedRooms.filter((rId) => rId !== roomId))}
|
onClick={() => onSelectedRoomsChange(selectedRooms.filter((rId) => rId !== roomId))}
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
before={
|
before={
|
||||||
<Icon
|
<Icon size="50" src={getRoomIconSrc(Icons, room.getType(), room.getJoinRule())} />
|
||||||
size="50"
|
|
||||||
src={joinRuleToIconSrc(Icons, room.getJoinRule(), false) ?? Icons.Hash}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
after={<Icon size="50" src={Icons.Cross} />}
|
after={<Icon size="50" src={Icons.Cross} />}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -203,7 +203,12 @@ export function SearchResultGroup({
|
|||||||
src={getRoomAvatarUrl(mx, room, 96, useAuthentication)}
|
src={getRoomAvatarUrl(mx, room, 96, useAuthentication)}
|
||||||
alt={room.name}
|
alt={room.name}
|
||||||
renderFallback={() => (
|
renderFallback={() => (
|
||||||
<RoomIcon size="50" joinRule={room.getJoinRule() ?? JoinRule.Restricted} filled />
|
<RoomIcon
|
||||||
|
size="50"
|
||||||
|
roomType={room.getType()}
|
||||||
|
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
|
||||||
|
filled
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { useFocusWithin, useHover } from 'react-aria';
|
import { useFocusWithin, useHover } from 'react-aria';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
|
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
|
||||||
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
||||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||||
@@ -51,6 +52,13 @@ import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationS
|
|||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||||
|
import { useRoomName } from '../../hooks/useRoomMeta';
|
||||||
|
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||||
|
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
||||||
|
import { callChatAtom } from '../../state/callEmbed';
|
||||||
|
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
|
||||||
|
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
|
||||||
|
import { livekitSupport } from '../../hooks/useLivekitSupport';
|
||||||
|
|
||||||
type RoomNavItemMenuProps = {
|
type RoomNavItemMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -209,6 +217,24 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function CallChatToggle() {
|
||||||
|
const [chat, setChat] = useAtom(callChatAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setChat(!chat)}
|
||||||
|
aria-pressed={chat}
|
||||||
|
aria-label="Toggle Chat"
|
||||||
|
variant="Background"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Message} filled={chat} />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type RoomNavItemProps = {
|
type RoomNavItemProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
@@ -236,6 +262,8 @@ export function RoomNavItem({
|
|||||||
(receipt) => receipt.userId !== mx.getUserId()
|
(receipt) => receipt.userId !== mx.getUserId()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const roomName = useRoomName(room);
|
||||||
|
|
||||||
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
setMenuAnchor({
|
setMenuAnchor({
|
||||||
@@ -251,6 +279,29 @@ export function RoomNavItem({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const optionsVisible = hover || !!menuAnchor;
|
const optionsVisible = hover || !!menuAnchor;
|
||||||
|
const callSession = useCallSession(room);
|
||||||
|
const callMembers = useCallMembers(room, callSession);
|
||||||
|
const startCall = useCallStart(direct);
|
||||||
|
const callEmbed = useCallEmbed();
|
||||||
|
const callPref = useAtomValue(useCallPreferencesAtom());
|
||||||
|
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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not join if already in call
|
||||||
|
if (callEmbed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Start call in second click
|
||||||
|
if (selected) {
|
||||||
|
evt.preventDefault();
|
||||||
|
startCall(room, callPref);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavItem
|
<NavItem
|
||||||
@@ -263,7 +314,7 @@ export function RoomNavItem({
|
|||||||
{...hoverProps}
|
{...hoverProps}
|
||||||
{...focusWithinProps}
|
{...focusWithinProps}
|
||||||
>
|
>
|
||||||
<NavLink to={linkPath}>
|
<NavLink to={linkPath} onClick={room.isCallRoom() ? handleStartCall : undefined}>
|
||||||
<NavItemContent>
|
<NavItemContent>
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
<Avatar size="200" radii="400">
|
<Avatar size="200" radii="400">
|
||||||
@@ -275,25 +326,28 @@ export function RoomNavItem({
|
|||||||
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||||
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
|
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||||
}
|
}
|
||||||
alt={room.name}
|
alt={roomName}
|
||||||
renderFallback={() => (
|
renderFallback={() => (
|
||||||
<Text as="span" size="H6">
|
<Text as="span" size="H6">
|
||||||
{nameInitials(room.name)}
|
{nameInitials(roomName)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RoomIcon
|
<RoomIcon
|
||||||
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
|
style={{
|
||||||
|
opacity: unread ? config.opacity.P500 : config.opacity.P300,
|
||||||
|
}}
|
||||||
filled={selected}
|
filled={selected}
|
||||||
size="100"
|
size="100"
|
||||||
joinRule={room.getJoinRule()}
|
joinRule={room.getJoinRule()}
|
||||||
|
roomType={room.getType()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Box as="span" grow="Yes">
|
<Box as="span" grow="Yes">
|
||||||
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
||||||
{room.name}
|
{roomName}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
|
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
|
||||||
@@ -307,14 +361,30 @@ export function RoomNavItem({
|
|||||||
</UnreadBadgeCenter>
|
</UnreadBadgeCenter>
|
||||||
)}
|
)}
|
||||||
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
|
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
|
||||||
<Icon size="50" src={getRoomNotificationModeIcon(notificationMode)} />
|
<Icon
|
||||||
|
size="50"
|
||||||
|
src={getRoomNotificationModeIcon(notificationMode)}
|
||||||
|
aria-label={notificationMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{room.isCallRoom() && callMembers.length > 0 && (
|
||||||
|
<Badge variant="Critical" fill="Solid" size="400">
|
||||||
|
<Text as="span" size="L400" truncate>
|
||||||
|
{callMembers.length} Live
|
||||||
|
</Text>
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</NavItemContent>
|
</NavItemContent>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
{optionsVisible && (
|
{optionsVisible && (
|
||||||
<NavItemOptions>
|
<NavItemOptions>
|
||||||
|
{selected && (callEmbed?.roomId === room.roomId || room.isCallRoom()) && (
|
||||||
|
<CallChatToggle />
|
||||||
|
)}
|
||||||
<PopOut
|
<PopOut
|
||||||
|
id={`menu-${room.roomId}`}
|
||||||
|
aria-expanded={!!menuAnchor}
|
||||||
anchor={menuAnchor}
|
anchor={menuAnchor}
|
||||||
offset={menuAnchor?.width === 0 ? 0 : undefined}
|
offset={menuAnchor?.width === 0 ? 0 : undefined}
|
||||||
alignOffset={menuAnchor?.width === 0 ? 0 : -5}
|
alignOffset={menuAnchor?.width === 0 ? 0 : -5}
|
||||||
@@ -343,6 +413,8 @@ export function RoomNavItem({
|
|||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleOpenMenu}
|
onClick={handleOpenMenu}
|
||||||
aria-pressed={!!menuAnchor}
|
aria-pressed={!!menuAnchor}
|
||||||
|
aria-controls={`menu-${room.roomId}`}
|
||||||
|
aria-label="More Options"
|
||||||
variant="Background"
|
variant="Background"
|
||||||
fill="None"
|
fill="None"
|
||||||
size="300"
|
size="300"
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
|
|||||||
renderFallback={() => (
|
renderFallback={() => (
|
||||||
<RoomIcon
|
<RoomIcon
|
||||||
size="50"
|
size="50"
|
||||||
|
roomType={room.getType()}
|
||||||
joinRule={joinRuleContent?.join_rule ?? JoinRule.Invite}
|
joinRule={joinRuleContent?.join_rule ?? JoinRule.Invite}
|
||||||
filled
|
filled
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
|
|||||||
|
|
||||||
const canEditPowers = permissions.stateEvent(StateEvent.PowerLevelTags, mx.getSafeUserId());
|
const canEditPowers = permissions.stateEvent(StateEvent.PowerLevelTags, mx.getSafeUserId());
|
||||||
const canEditPermissions = permissions.stateEvent(StateEvent.RoomPowerLevels, mx.getSafeUserId());
|
const canEditPermissions = permissions.stateEvent(StateEvent.RoomPowerLevels, mx.getSafeUserId());
|
||||||
const permissionGroups = usePermissionGroups();
|
const permissionGroups = usePermissionGroups(room.isCallRoom());
|
||||||
|
|
||||||
const [powerEditor, setPowerEditor] = useState(false);
|
const [powerEditor, setPowerEditor] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
|||||||
import { MessageEvent, StateEvent } from '../../../../types/matrix/room';
|
import { MessageEvent, StateEvent } from '../../../../types/matrix/room';
|
||||||
import { PermissionGroup } from '../../common-settings/permissions';
|
import { PermissionGroup } from '../../common-settings/permissions';
|
||||||
|
|
||||||
export const usePermissionGroups = (): PermissionGroup[] => {
|
export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => {
|
||||||
const groups: PermissionGroup[] = useMemo(() => {
|
const groups: PermissionGroup[] = useMemo(() => {
|
||||||
const messagesGroup: PermissionGroup = {
|
const messagesGroup: PermissionGroup = {
|
||||||
name: 'Messages',
|
name: 'Messages',
|
||||||
@@ -46,6 +46,19 @@ export const usePermissionGroups = (): PermissionGroup[] => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const callSettingsGroup: PermissionGroup = {
|
||||||
|
name: 'Calls',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
location: {
|
||||||
|
state: true,
|
||||||
|
key: StateEvent.GroupCallMemberPrefix,
|
||||||
|
},
|
||||||
|
name: 'Join Call',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
const moderationGroup: PermissionGroup = {
|
const moderationGroup: PermissionGroup = {
|
||||||
name: 'Moderation',
|
name: 'Moderation',
|
||||||
items: [
|
items: [
|
||||||
@@ -203,12 +216,13 @@ export const usePermissionGroups = (): PermissionGroup[] => {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
messagesGroup,
|
messagesGroup,
|
||||||
|
...(isCallRoom ? [callSettingsGroup] : []),
|
||||||
moderationGroup,
|
moderationGroup,
|
||||||
roomOverviewGroup,
|
roomOverviewGroup,
|
||||||
roomSettingsGroup,
|
roomSettingsGroup,
|
||||||
otherSettingsGroup,
|
otherSettingsGroup,
|
||||||
];
|
];
|
||||||
}, []);
|
}, [isCallRoom]);
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
};
|
};
|
||||||
|
|||||||
57
src/app/features/room/CallChatView.tsx
Normal file
57
src/app/features/room/CallChatView.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { Box, Text, TooltipProvider, Tooltip, Icon, Icons, IconButton, toRem } from 'folds';
|
||||||
|
import { Page, PageHeader } from '../../components/page';
|
||||||
|
import { callChatAtom } from '../../state/callEmbed';
|
||||||
|
import { RoomView } from './RoomView';
|
||||||
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
|
|
||||||
|
export function CallChatView() {
|
||||||
|
const { eventId } = useParams();
|
||||||
|
const setChat = useSetAtom(callChatAtom);
|
||||||
|
const screenSize = useScreenSizeContext();
|
||||||
|
|
||||||
|
const handleClose = () => setChat(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page
|
||||||
|
style={{
|
||||||
|
width: screenSize === ScreenSize.Desktop ? toRem(456) : '100%',
|
||||||
|
flexShrink: 0,
|
||||||
|
flexGrow: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PageHeader>
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H5" truncate>
|
||||||
|
Chat
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" alignItems="Center">
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>Close</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton ref={triggerRef} variant="Surface" onClick={handleClose}>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<RoomView eventId={eventId} />
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import React, { useCallback } from 'react';
|
|||||||
import { Box, Line } from 'folds';
|
import { Box, Line } from 'folds';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
import { RoomView } from './RoomView';
|
import { RoomView } from './RoomView';
|
||||||
import { MembersDrawer } from './MembersDrawer';
|
import { MembersDrawer } from './MembersDrawer';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
@@ -13,6 +14,10 @@ import { useKeyDown } from '../../hooks/useKeyDown';
|
|||||||
import { markAsRead } from '../../utils/notifications';
|
import { markAsRead } from '../../utils/notifications';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||||
|
import { CallView } from '../call/CallView';
|
||||||
|
import { RoomViewHeader } from './RoomViewHeader';
|
||||||
|
import { callChatAtom } from '../../state/callEmbed';
|
||||||
|
import { CallChatView } from './CallChatView';
|
||||||
|
|
||||||
export function Room() {
|
export function Room() {
|
||||||
const { eventId } = useParams();
|
const { eventId } = useParams();
|
||||||
@@ -24,6 +29,7 @@ export function Room() {
|
|||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
const members = useRoomMembers(mx, room.roomId);
|
const members = useRoomMembers(mx, room.roomId);
|
||||||
|
const chat = useAtomValue(callChatAtom);
|
||||||
|
|
||||||
useKeyDown(
|
useKeyDown(
|
||||||
window,
|
window,
|
||||||
@@ -37,11 +43,37 @@ export function Room() {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const callView = room.isCallRoom();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PowerLevelsContextProvider value={powerLevels}>
|
<PowerLevelsContextProvider value={powerLevels}>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<RoomView room={room} eventId={eventId} />
|
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
|
||||||
{screenSize === ScreenSize.Desktop && isDrawer && (
|
<Box grow="Yes" direction="Column">
|
||||||
|
<RoomViewHeader callView />
|
||||||
|
<Box grow="Yes">
|
||||||
|
<CallView />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{!callView && (
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<RoomViewHeader />
|
||||||
|
<Box grow="Yes">
|
||||||
|
<RoomView eventId={eventId} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{callView && chat && (
|
||||||
|
<>
|
||||||
|
{screenSize === ScreenSize.Desktop && (
|
||||||
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
|
)}
|
||||||
|
<CallChatView />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!callView && screenSize === ScreenSize.Desktop && isDrawer && (
|
||||||
<>
|
<>
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
<MembersDrawer key={room.roomId} room={room} members={members} />
|
<MembersDrawer key={room.roomId} room={room} members={members} />
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const isComposing = useComposingCheck();
|
const isComposing = useComposingCheck();
|
||||||
|
|
||||||
useElementSizeObserver(
|
useElementSizeObserver(
|
||||||
useCallback(() => document.body, []),
|
useCallback(() => fileDropContainerRef.current, [fileDropContainerRef]),
|
||||||
useCallback((width) => setHideStickerBtn(width < 500), [])
|
useCallback((width) => setHideStickerBtn(width < 500), [])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { HTMLReactParserOptions } from 'html-react-parser';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
import { Editor } from 'slate';
|
import { Editor } from 'slate';
|
||||||
|
import { SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||||
import to from 'await-to-js';
|
import to from 'await-to-js';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import {
|
import {
|
||||||
@@ -1469,6 +1470,57 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
</Event>
|
</Event>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
[StateEvent.GroupCallMemberPrefix]: (mEventId, mEvent, item) => {
|
||||||
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||||
|
const senderId = mEvent.getSender() ?? '';
|
||||||
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
||||||
|
const content = mEvent.getContent<SessionMembershipData>();
|
||||||
|
const prevContent = mEvent.getPrevContent();
|
||||||
|
|
||||||
|
const callJoined = content.application;
|
||||||
|
if (callJoined && 'application' in prevContent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeJSX = (
|
||||||
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Event
|
||||||
|
key={mEvent.getId()}
|
||||||
|
data-message-item={item}
|
||||||
|
data-message-id={mEventId}
|
||||||
|
room={room}
|
||||||
|
mEvent={mEvent}
|
||||||
|
highlight={highlighted}
|
||||||
|
messageSpacing={messageSpacing}
|
||||||
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
|
>
|
||||||
|
<EventContent
|
||||||
|
messageLayout={messageLayout}
|
||||||
|
time={timeJSX}
|
||||||
|
iconSrc={callJoined ? Icons.Phone : Icons.PhoneDown}
|
||||||
|
content={
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
<b>{senderName}</b>
|
||||||
|
{callJoined ? ' joined the call' : ' ended the call'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Event>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
(mEventId, mEvent, item) => {
|
(mEventId, mEvent, item) => {
|
||||||
if (!showHiddenEvents) return null;
|
if (!showHiddenEvents) return null;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useRef } from 'react';
|
import React, { useCallback, useRef } from 'react';
|
||||||
import { Box, Text, config } from 'folds';
|
import { Box, Text, config } from 'folds';
|
||||||
import { EventType, Room } from 'matrix-js-sdk';
|
import { EventType } from 'matrix-js-sdk';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
@@ -15,13 +15,13 @@ import { RoomTombstone } from './RoomTombstone';
|
|||||||
import { RoomInput } from './RoomInput';
|
import { RoomInput } from './RoomInput';
|
||||||
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
|
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
|
||||||
import { Page } from '../../components/page';
|
import { Page } from '../../components/page';
|
||||||
import { RoomViewHeader } from './RoomViewHeader';
|
|
||||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
import { editableActiveElement } from '../../utils/dom';
|
import { editableActiveElement } from '../../utils/dom';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
|
|
||||||
const FN_KEYS_REGEX = /^F\d+$/;
|
const FN_KEYS_REGEX = /^F\d+$/;
|
||||||
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||||
@@ -30,10 +30,8 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// do not focus on F keys
|
|
||||||
if (FN_KEYS_REGEX.test(code)) return false;
|
if (FN_KEYS_REGEX.test(code)) return false;
|
||||||
|
|
||||||
// do not focus on numlock/scroll lock
|
|
||||||
if (
|
if (
|
||||||
code.startsWith('OS') ||
|
code.startsWith('OS') ||
|
||||||
code.startsWith('Meta') ||
|
code.startsWith('Meta') ||
|
||||||
@@ -56,12 +54,13 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
export function RoomView({ eventId }: { eventId?: string }) {
|
||||||
const roomInputRef = useRef<HTMLDivElement>(null);
|
const roomInputRef = useRef<HTMLDivElement>(null);
|
||||||
const roomViewRef = useRef<HTMLDivElement>(null);
|
const roomViewRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
|
|
||||||
|
const room = useRoom();
|
||||||
const { roomId } = room;
|
const { roomId } = room;
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
|
|
||||||
@@ -93,7 +92,6 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page ref={roomViewRef}>
|
<Page ref={roomViewRef}>
|
||||||
<RoomViewHeader />
|
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<RoomTimeline
|
<RoomTimeline
|
||||||
key={roomId}
|
key={roomId}
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { JoinRule, Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { useAtomValue } from 'jotai';
|
|
||||||
|
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
import { PageHeader } from '../../components/page';
|
import { PageHeader } from '../../components/page';
|
||||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||||
@@ -33,7 +31,7 @@ import { UseStateProvider } from '../../components/UseStateProvider';
|
|||||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
@@ -48,7 +46,6 @@ import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
|||||||
import { copyToClipboard } from '../../utils/dom';
|
import { copyToClipboard } from '../../utils/dom';
|
||||||
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
||||||
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
||||||
@@ -69,6 +66,8 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
|||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||||
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
|
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||||
|
|
||||||
type RoomMenuProps = {
|
type RoomMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -254,7 +253,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export function RoomViewHeader() {
|
export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
@@ -263,12 +262,12 @@ export function RoomViewHeader() {
|
|||||||
const space = useSpaceOptionally();
|
const space = useSpaceOptionally();
|
||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const direct = useIsDirectRoom();
|
||||||
|
|
||||||
const pinnedEvents = useRoomPinnedEvents(room);
|
const pinnedEvents = useRoomPinnedEvents(room);
|
||||||
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
||||||
const ecryptedRoom = !!encryptionEvent;
|
const encryptedRoom = !!encryptionEvent;
|
||||||
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
|
const avatarMxc = useRoomAvatar(room, direct);
|
||||||
const name = useRoomName(room);
|
const name = useRoomName(room);
|
||||||
const topic = useRoomTopic(room);
|
const topic = useRoomTopic(room);
|
||||||
const avatarUrl = avatarMxc
|
const avatarUrl = avatarMxc
|
||||||
@@ -295,14 +294,27 @@ export function RoomViewHeader() {
|
|||||||
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openSettings = useOpenRoomSettings();
|
||||||
|
const parentSpace = useSpaceOptionally();
|
||||||
|
const handleMemberToggle = () => {
|
||||||
|
if (callView) {
|
||||||
|
openSettings(room.roomId, parentSpace?.roomId, RoomSettingsPage.MembersPage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPeopleDrawer(!peopleDrawer);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageHeader balance={screenSize === ScreenSize.Mobile}>
|
<PageHeader
|
||||||
|
className={ContainerColor({ variant: 'Surface' })}
|
||||||
|
balance={screenSize === ScreenSize.Mobile}
|
||||||
|
>
|
||||||
<Box grow="Yes" gap="300">
|
<Box grow="Yes" gap="300">
|
||||||
{screenSize === ScreenSize.Mobile && (
|
{screenSize === ScreenSize.Mobile && (
|
||||||
<BackRouteHandler>
|
<BackRouteHandler>
|
||||||
{(onBack) => (
|
{(onBack) => (
|
||||||
<Box shrink="No" alignItems="Center">
|
<Box shrink="No" alignItems="Center">
|
||||||
<IconButton onClick={onBack}>
|
<IconButton fill="None" onClick={onBack}>
|
||||||
<Icon src={Icons.ArrowLeft} />
|
<Icon src={Icons.ArrowLeft} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -317,11 +329,7 @@ export function RoomViewHeader() {
|
|||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
alt={name}
|
alt={name}
|
||||||
renderFallback={() => (
|
renderFallback={() => (
|
||||||
<RoomIcon
|
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} />
|
||||||
size="200"
|
|
||||||
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
|
|
||||||
filled
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@@ -369,8 +377,9 @@ export function RoomViewHeader() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
{!ecryptedRoom && (
|
{!encryptedRoom && (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
offset={4}
|
offset={4}
|
||||||
@@ -381,7 +390,7 @@ export function RoomViewHeader() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton ref={triggerRef} onClick={handleSearchClick}>
|
<IconButton fill="None" ref={triggerRef} onClick={handleSearchClick}>
|
||||||
<Icon size="400" src={Icons.Search} />
|
<Icon size="400" src={Icons.Search} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
@@ -398,6 +407,7 @@ export function RoomViewHeader() {
|
|||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
|
fill="None"
|
||||||
style={{ position: 'relative' }}
|
style={{ position: 'relative' }}
|
||||||
onClick={handleOpenPinMenu}
|
onClick={handleOpenPinMenu}
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
@@ -443,23 +453,29 @@ export function RoomViewHeader() {
|
|||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{screenSize === ScreenSize.Desktop && (
|
{screenSize === ScreenSize.Desktop && (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
offset={4}
|
offset={4}
|
||||||
tooltip={
|
tooltip={
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Text>{peopleDrawer ? 'Hide Members' : 'Show Members'}</Text>
|
{callView ? (
|
||||||
|
<Text>Members</Text>
|
||||||
|
) : (
|
||||||
|
<Text>{peopleDrawer ? 'Hide Members' : 'Show Members'}</Text>
|
||||||
|
)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
|
<IconButton fill="None" ref={triggerRef} onClick={handleMemberToggle}>
|
||||||
<Icon size="400" src={Icons.User} />
|
<Icon size="400" src={Icons.User} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
align="End"
|
align="End"
|
||||||
@@ -471,7 +487,12 @@ export function RoomViewHeader() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}>
|
<IconButton
|
||||||
|
fill="None"
|
||||||
|
onClick={handleOpenMenu}
|
||||||
|
ref={triggerRef}
|
||||||
|
aria-pressed={!!menuAnchor}
|
||||||
|
>
|
||||||
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
|
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -327,11 +327,9 @@ export const MessageCopyLinkItem = as<
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
|
||||||
const eventId = mEvent.getId();
|
const eventId = mEvent.getId();
|
||||||
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
|
||||||
if (!eventId) return;
|
if (!eventId) return;
|
||||||
copyToClipboard(getMatrixToRoomEvent(roomIdOrAlias, eventId, viaServers));
|
copyToClipboard(getMatrixToRoomEvent(room.roomId, eventId, getViaServers(room)));
|
||||||
onClose?.();
|
onClose?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -373,7 +373,7 @@ export function Search({ requestClose }: SearchProps) {
|
|||||||
<RoomIcon
|
<RoomIcon
|
||||||
size="100"
|
size="100"
|
||||||
joinRule={room.getJoinRule()}
|
joinRule={room.getJoinRule()}
|
||||||
space={room.isSpaceRoom()}
|
roomType={room.getType()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function About({ requestClose }: AboutProps) {
|
|||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Box gap="100" alignItems="End">
|
<Box gap="100" alignItems="End">
|
||||||
<Text size="H3">Cinny</Text>
|
<Text size="H3">Cinny</Text>
|
||||||
<Text size="T200">v4.10.5</Text>
|
<Text size="T200">v4.11.1</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Text>Yet another matrix client.</Text>
|
<Text>Yet another matrix client.</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
|
|||||||
alt={roomName}
|
alt={roomName}
|
||||||
renderFallback={() => (
|
renderFallback={() => (
|
||||||
<RoomIcon
|
<RoomIcon
|
||||||
space
|
roomType={room.getType()}
|
||||||
size="50"
|
size="50"
|
||||||
joinRule={joinRuleContent?.join_rule ?? JoinRule.Invite}
|
joinRule={joinRuleContent?.join_rule ?? JoinRule.Invite}
|
||||||
filled
|
filled
|
||||||
|
|||||||
64
src/app/hooks/useCall.ts
Normal file
64
src/app/hooks/useCall.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import {
|
||||||
|
MatrixRTCSession,
|
||||||
|
MatrixRTCSessionEvent,
|
||||||
|
} from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
|
||||||
|
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { MatrixRTCSessionManagerEvents } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSessionManager';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
|
||||||
|
export const useCallSession = (room: Room): MatrixRTCSession => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
const [session, setSession] = useState(mx.matrixRTC.getRoomSession(room));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const start = (roomId: string) => {
|
||||||
|
if (roomId !== room.roomId) return;
|
||||||
|
setSession(mx.matrixRTC.getRoomSession(room));
|
||||||
|
};
|
||||||
|
const end = (roomId: string) => {
|
||||||
|
if (roomId !== room.roomId) return;
|
||||||
|
setSession(mx.matrixRTC.getRoomSession(room));
|
||||||
|
};
|
||||||
|
mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, start);
|
||||||
|
mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, end);
|
||||||
|
return () => {
|
||||||
|
mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, start);
|
||||||
|
mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, end);
|
||||||
|
};
|
||||||
|
}, [mx, room]);
|
||||||
|
|
||||||
|
return session;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCallMembers = (room: Room, session: MatrixRTCSession): CallMembership[] => {
|
||||||
|
const [memberships, setMemberships] = useState<CallMembership[]>(
|
||||||
|
MatrixRTCSession.sessionMembershipsForRoom(room, session.sessionDescription)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateMemberships = () => {
|
||||||
|
setMemberships(MatrixRTCSession.sessionMembershipsForRoom(room, session.sessionDescription));
|
||||||
|
};
|
||||||
|
|
||||||
|
updateMemberships();
|
||||||
|
|
||||||
|
session.on(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships);
|
||||||
|
return () => {
|
||||||
|
session.removeListener(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships);
|
||||||
|
};
|
||||||
|
}, [session, room]);
|
||||||
|
|
||||||
|
return memberships;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCallMembersChange = (session: MatrixRTCSession, callback: () => void): void => {
|
||||||
|
useEffect(() => {
|
||||||
|
session.on(MatrixRTCSessionEvent.MembershipsChanged, callback);
|
||||||
|
return () => {
|
||||||
|
session.removeListener(MatrixRTCSessionEvent.MembershipsChanged, callback);
|
||||||
|
};
|
||||||
|
}, [session, callback]);
|
||||||
|
};
|
||||||
142
src/app/hooks/useCallEmbed.ts
Normal file
142
src/app/hooks/useCallEmbed.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { createContext, RefObject, useCallback, useContext, useEffect, useState } from 'react';
|
||||||
|
import { MatrixRTCSession } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
|
||||||
|
import { MatrixClient, Room } from 'matrix-js-sdk';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import {
|
||||||
|
CallEmbed,
|
||||||
|
ElementCallThemeKind,
|
||||||
|
ElementWidgetActions,
|
||||||
|
useClientWidgetApiEvent,
|
||||||
|
} from '../plugins/call';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { ThemeKind, useTheme } from './useTheme';
|
||||||
|
import { callEmbedAtom } from '../state/callEmbed';
|
||||||
|
import { useResizeObserver } from './useResizeObserver';
|
||||||
|
import { CallControlState } from '../plugins/call/CallControlState';
|
||||||
|
import { useCallMembersChange, useCallSession } from './useCall';
|
||||||
|
import { CallPreferences } from '../state/callPreferences';
|
||||||
|
|
||||||
|
const CallEmbedContext = createContext<CallEmbed | undefined>(undefined);
|
||||||
|
|
||||||
|
export const CallEmbedContextProvider = CallEmbedContext.Provider;
|
||||||
|
|
||||||
|
export const useCallEmbed = (): CallEmbed | undefined => {
|
||||||
|
const callEmbed = useContext(CallEmbedContext);
|
||||||
|
|
||||||
|
return callEmbed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CallEmbedRefContext = createContext<RefObject<HTMLDivElement> | undefined>(undefined);
|
||||||
|
export const CallEmbedRefContextProvider = CallEmbedRefContext.Provider;
|
||||||
|
export const useCallEmbedRef = (): RefObject<HTMLDivElement> => {
|
||||||
|
const ref = useContext(CallEmbedRefContext);
|
||||||
|
if (!ref) {
|
||||||
|
throw new Error('CallEmbedRef is not provided!');
|
||||||
|
}
|
||||||
|
return ref;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createCallEmbed = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
room: Room,
|
||||||
|
dm: boolean,
|
||||||
|
themeKind: ElementCallThemeKind,
|
||||||
|
container: HTMLElement,
|
||||||
|
pref?: CallPreferences
|
||||||
|
): CallEmbed => {
|
||||||
|
const rtcSession = mx.matrixRTC.getRoomSession(room);
|
||||||
|
const ongoing =
|
||||||
|
MatrixRTCSession.sessionMembershipsForRoom(room, rtcSession.sessionDescription).length > 0;
|
||||||
|
|
||||||
|
const intent = CallEmbed.getIntent(dm, ongoing);
|
||||||
|
const widget = CallEmbed.getWidget(mx, room, intent, themeKind);
|
||||||
|
const controlState = pref && new CallControlState(pref.microphone, pref.video, pref.sound);
|
||||||
|
|
||||||
|
const embed = new CallEmbed(mx, room, widget, container, controlState);
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCallStart = (dm = false) => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const theme = useTheme();
|
||||||
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
|
const callEmbedRef = useCallEmbedRef();
|
||||||
|
|
||||||
|
const startCall = useCallback(
|
||||||
|
(room: Room, pref?: CallPreferences) => {
|
||||||
|
const container = callEmbedRef.current;
|
||||||
|
if (!container) {
|
||||||
|
throw new Error('Failed to start call, No embed container element found!');
|
||||||
|
}
|
||||||
|
const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref);
|
||||||
|
|
||||||
|
setCallEmbed(callEmbed);
|
||||||
|
},
|
||||||
|
[mx, dm, theme, setCallEmbed, callEmbedRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
return startCall;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCallJoined = (embed?: CallEmbed): boolean => {
|
||||||
|
const [joined, setJoined] = useState(embed?.joined ?? false);
|
||||||
|
|
||||||
|
useClientWidgetApiEvent(
|
||||||
|
embed?.call,
|
||||||
|
ElementWidgetActions.JoinCall,
|
||||||
|
useCallback(() => {
|
||||||
|
setJoined(true);
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!embed) {
|
||||||
|
setJoined(false);
|
||||||
|
}
|
||||||
|
}, [embed]);
|
||||||
|
|
||||||
|
return joined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCallHangupEvent = (embed: CallEmbed, callback: () => void) => {
|
||||||
|
useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCallMemberSoundSync = (embed: CallEmbed) => {
|
||||||
|
const callSession = useCallSession(embed.room);
|
||||||
|
useCallMembersChange(
|
||||||
|
callSession,
|
||||||
|
useCallback(() => embed.control.applySound(), [embed])
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCallThemeSync = (embed: CallEmbed) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const name: ElementCallThemeKind = theme.kind === ThemeKind.Dark ? 'dark' : 'light';
|
||||||
|
|
||||||
|
embed.setTheme(name);
|
||||||
|
}, [theme.kind, embed]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCallEmbedPlacementSync = (containerViewRef: RefObject<HTMLDivElement>): void => {
|
||||||
|
const callEmbedRef = useCallEmbedRef();
|
||||||
|
|
||||||
|
const syncCallEmbedPlacement = useCallback(() => {
|
||||||
|
const embedEl = callEmbedRef.current;
|
||||||
|
const container = containerViewRef.current;
|
||||||
|
if (!embedEl || !container) return;
|
||||||
|
|
||||||
|
embedEl.style.top = `${container.offsetTop}px`;
|
||||||
|
embedEl.style.left = `${container.offsetLeft}px`;
|
||||||
|
embedEl.style.width = `${container.clientWidth}px`;
|
||||||
|
embedEl.style.height = `${container.clientHeight}px`;
|
||||||
|
}, [callEmbedRef, containerViewRef]);
|
||||||
|
|
||||||
|
useResizeObserver(
|
||||||
|
syncCallEmbedPlacement,
|
||||||
|
useCallback(() => containerViewRef.current, [containerViewRef])
|
||||||
|
);
|
||||||
|
};
|
||||||
60
src/app/hooks/useCallSpeakers.ts
Normal file
60
src/app/hooks/useCallSpeakers.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { CallEmbed } from '../plugins/call';
|
||||||
|
import { useMutationObserver } from './useMutationObserver';
|
||||||
|
import { isUserId } from '../utils/matrix';
|
||||||
|
import { useCallMembers, useCallSession } from './useCall';
|
||||||
|
import { useCallJoined } from './useCallEmbed';
|
||||||
|
|
||||||
|
export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
||||||
|
const [speakers, setSpeakers] = useState(new Set<string>());
|
||||||
|
const callSession = useCallSession(callEmbed.room);
|
||||||
|
const callMembers = useCallMembers(callEmbed.room, callSession);
|
||||||
|
const joined = useCallJoined(callEmbed);
|
||||||
|
|
||||||
|
const videoContainers = useMemo(() => {
|
||||||
|
if (callMembers && joined) return callEmbed.document?.querySelectorAll('[data-video-fit]');
|
||||||
|
return undefined;
|
||||||
|
}, [callEmbed, callMembers, joined]);
|
||||||
|
|
||||||
|
const mutationObserver = useMutationObserver(
|
||||||
|
useCallback(
|
||||||
|
(mutations) => {
|
||||||
|
const s = new Set<string>();
|
||||||
|
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
if (mutation.type !== 'attributes') return;
|
||||||
|
const el = mutation.target as HTMLElement;
|
||||||
|
|
||||||
|
const style = callEmbed.iframe.contentWindow?.getComputedStyle(el, '::before');
|
||||||
|
if (!style) return;
|
||||||
|
const tileBackgroundImage = style.getPropertyValue('background-image');
|
||||||
|
const speaking = tileBackgroundImage !== 'none';
|
||||||
|
if (!speaking) return;
|
||||||
|
|
||||||
|
const speakerId = el.querySelector('[aria-label]')?.getAttribute('aria-label');
|
||||||
|
if (speakerId && isUserId(speakerId)) {
|
||||||
|
s.add(speakerId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSpeakers(s);
|
||||||
|
},
|
||||||
|
[callEmbed]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
videoContainers?.forEach((element) => {
|
||||||
|
mutationObserver.observe(element, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class', 'style'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mutationObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [videoContainers, mutationObserver]);
|
||||||
|
|
||||||
|
return speakers;
|
||||||
|
};
|
||||||
16
src/app/hooks/useLivekitSupport.ts
Normal file
16
src/app/hooks/useLivekitSupport.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { AutoDiscoveryInfo } from '../cs-api';
|
||||||
|
import { useAutoDiscoveryInfo } from './useAutoDiscoveryInfo';
|
||||||
|
|
||||||
|
export const livekitSupport = (autoDiscoveryInfo: AutoDiscoveryInfo): boolean => {
|
||||||
|
const rtcFoci = autoDiscoveryInfo['org.matrix.msc4143.rtc_foci'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
Array.isArray(rtcFoci) && rtcFoci.some((info) => typeof info.livekit_service_url === 'string')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLivekitSupport = (): boolean => {
|
||||||
|
const autoDiscoveryInfo = useAutoDiscoveryInfo();
|
||||||
|
|
||||||
|
return livekitSupport(autoDiscoveryInfo);
|
||||||
|
};
|
||||||
37
src/app/hooks/useMutationObserver.ts
Normal file
37
src/app/hooks/useMutationObserver.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
export type OnMutationCallback = (mutations: MutationRecord[]) => void;
|
||||||
|
|
||||||
|
export const getMutationRecord = (
|
||||||
|
target: Node,
|
||||||
|
mutations: MutationRecord[]
|
||||||
|
): MutationRecord | undefined => mutations.find((mutation) => mutation.target === target);
|
||||||
|
|
||||||
|
export const useMutationObserver = (
|
||||||
|
onMutationCallback: OnMutationCallback,
|
||||||
|
observeElement?: Node | null | (() => Node | null),
|
||||||
|
options?: MutationObserverInit
|
||||||
|
): MutationObserver => {
|
||||||
|
const mutationObserver = useMemo(
|
||||||
|
() => new MutationObserver(onMutationCallback),
|
||||||
|
[onMutationCallback]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => () => mutationObserver?.disconnect(), [mutationObserver]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = typeof observeElement === 'function' ? observeElement() : observeElement;
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
mutationObserver.observe(element, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (element) {
|
||||||
|
mutationObserver.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [mutationObserver, observeElement, options]);
|
||||||
|
|
||||||
|
return mutationObserver;
|
||||||
|
};
|
||||||
@@ -20,6 +20,8 @@ export const useRoomName = (room: Room): string => {
|
|||||||
const [name, setName] = useState(room.name);
|
const [name, setName] = useState(room.name);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setName(room.name);
|
||||||
|
|
||||||
const handleRoomNameChange: RoomEventHandlerMap[RoomEvent.Name] = () => {
|
const handleRoomNameChange: RoomEventHandlerMap[RoomEvent.Name] = () => {
|
||||||
setName(room.name);
|
setName(room.name);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
import { Room } from 'matrix-js-sdk';
|
|
||||||
import { StateEvent } from '../../types/matrix/room';
|
|
||||||
import { useForceUpdate } from './useForceUpdate';
|
|
||||||
import { useStateEventCallback } from './useStateEventCallback';
|
|
||||||
import { getStateEvents } from '../utils/room';
|
|
||||||
|
|
||||||
export const useStateEvents = (room: Room, eventType: StateEvent) => {
|
|
||||||
const [updateCount, forceUpdate] = useForceUpdate();
|
|
||||||
|
|
||||||
useStateEventCallback(
|
|
||||||
room.client,
|
|
||||||
useCallback(
|
|
||||||
(event) => {
|
|
||||||
if (event.getRoomId() === room.roomId && event.getType() === eventType) {
|
|
||||||
forceUpdate();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[room, eventType, forceUpdate]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => getStateEvents(room, eventType),
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
[room, eventType, updateCount]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
18
src/app/pages/CallStatusRenderer.tsx
Normal file
18
src/app/pages/CallStatusRenderer.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useCallEmbed } from '../hooks/useCallEmbed';
|
||||||
|
import { CallStatus } from '../features/call-status';
|
||||||
|
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||||
|
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||||
|
|
||||||
|
export function CallStatusRenderer() {
|
||||||
|
const callEmbed = useCallEmbed();
|
||||||
|
const selectedRoom = useSelectedRoom();
|
||||||
|
|
||||||
|
const screenSize = useScreenSizeContext();
|
||||||
|
|
||||||
|
if (!callEmbed) return null;
|
||||||
|
|
||||||
|
if (screenSize === ScreenSize.Mobile && callEmbed.roomId === selectedRoom) return null;
|
||||||
|
|
||||||
|
return <CallStatus callEmbed={callEmbed} />;
|
||||||
|
}
|
||||||
@@ -68,6 +68,8 @@ import { Create } from './client/create';
|
|||||||
import { CreateSpaceModalRenderer } from '../features/create-space';
|
import { CreateSpaceModalRenderer } from '../features/create-space';
|
||||||
import { SearchModalRenderer } from '../features/search';
|
import { SearchModalRenderer } from '../features/search';
|
||||||
import { getFallbackSession } from '../state/sessions';
|
import { getFallbackSession } from '../state/sessions';
|
||||||
|
import { CallStatusRenderer } from './CallStatusRenderer';
|
||||||
|
import { CallEmbedProvider } from '../components/CallEmbedProvider';
|
||||||
|
|
||||||
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
|
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
|
||||||
const { hashRouter } = clientConfig;
|
const { hashRouter } = clientConfig;
|
||||||
@@ -124,15 +126,18 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||||||
<ClientRoomsNotificationPreferences>
|
<ClientRoomsNotificationPreferences>
|
||||||
<ClientBindAtoms>
|
<ClientBindAtoms>
|
||||||
<ClientNonUIFeatures>
|
<ClientNonUIFeatures>
|
||||||
<ClientLayout
|
<CallEmbedProvider>
|
||||||
nav={
|
<ClientLayout
|
||||||
<MobileFriendlyClientNav>
|
nav={
|
||||||
<SidebarNav />
|
<MobileFriendlyClientNav>
|
||||||
</MobileFriendlyClientNav>
|
<SidebarNav />
|
||||||
}
|
</MobileFriendlyClientNav>
|
||||||
>
|
}
|
||||||
<Outlet />
|
>
|
||||||
</ClientLayout>
|
<Outlet />
|
||||||
|
</ClientLayout>
|
||||||
|
<CallStatusRenderer />
|
||||||
|
</CallEmbedProvider>
|
||||||
<SearchModalRenderer />
|
<SearchModalRenderer />
|
||||||
<UserRoomProfileRenderer />
|
<UserRoomProfileRenderer />
|
||||||
<CreateRoomModalRenderer />
|
<CreateRoomModalRenderer />
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function AuthFooter() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
v4.10.5
|
v4.11.1
|
||||||
</Text>
|
</Text>
|
||||||
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
|
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
|
||||||
Twitter
|
Twitter
|
||||||
|
|||||||
32
src/app/pages/client/AutoDiscovery.tsx
Normal file
32
src/app/pages/client/AutoDiscovery.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React, { ReactNode, useCallback, useMemo } from 'react';
|
||||||
|
import { AutoDiscoveryInfoProvider } from '../../hooks/useAutoDiscoveryInfo';
|
||||||
|
import { AsyncStatus, useAsyncCallbackValue } from '../../hooks/useAsyncCallback';
|
||||||
|
import { autoDiscovery, AutoDiscoveryInfo } from '../../cs-api';
|
||||||
|
import { getMxIdServer } from '../../utils/matrix';
|
||||||
|
|
||||||
|
type AutoDiscoveryProps = {
|
||||||
|
userId: string;
|
||||||
|
baseUrl: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
export function AutoDiscovery({ userId, baseUrl, children }: AutoDiscoveryProps) {
|
||||||
|
const [state] = useAsyncCallbackValue(
|
||||||
|
useCallback(async () => {
|
||||||
|
const server = getMxIdServer(userId);
|
||||||
|
return autoDiscovery(fetch, server ?? userId);
|
||||||
|
}, [userId])
|
||||||
|
);
|
||||||
|
|
||||||
|
const [, info] = state.status === AsyncStatus.Success ? state.data : [];
|
||||||
|
|
||||||
|
const fallback: AutoDiscoveryInfo = useMemo(
|
||||||
|
() => ({
|
||||||
|
'm.homeserver': {
|
||||||
|
base_url: baseUrl,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[baseUrl]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AutoDiscoveryInfoProvider value={info ?? fallback}>{children}</AutoDiscoveryInfoProvider>;
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import { makeNavToActivePathAtom } from '../../state/navToActivePath';
|
|||||||
import { NavToActivePathProvider } from '../../state/hooks/navToActivePath';
|
import { NavToActivePathProvider } from '../../state/hooks/navToActivePath';
|
||||||
import { makeOpenedSidebarFolderAtom } from '../../state/openedSidebarFolder';
|
import { makeOpenedSidebarFolderAtom } from '../../state/openedSidebarFolder';
|
||||||
import { OpenedSidebarFolderProvider } from '../../state/hooks/openedSidebarFolder';
|
import { OpenedSidebarFolderProvider } from '../../state/hooks/openedSidebarFolder';
|
||||||
|
import { makeCallPreferencesAtom } from '../../state/callPreferences';
|
||||||
|
import { CallPreferencesProvider } from '../../state/hooks/callPreferences';
|
||||||
|
|
||||||
type ClientInitStorageAtomProps = {
|
type ClientInitStorageAtomProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -24,12 +26,16 @@ export function ClientInitStorageAtom({ children }: ClientInitStorageAtomProps)
|
|||||||
|
|
||||||
const openedSidebarFolderAtom = useMemo(() => makeOpenedSidebarFolderAtom(userId), [userId]);
|
const openedSidebarFolderAtom = useMemo(() => makeOpenedSidebarFolderAtom(userId), [userId]);
|
||||||
|
|
||||||
|
const callPreferencesAtom = useMemo(() => makeCallPreferencesAtom(userId), [userId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClosedNavCategoriesProvider value={closedNavCategoriesAtom}>
|
<ClosedNavCategoriesProvider value={closedNavCategoriesAtom}>
|
||||||
<ClosedLobbyCategoriesProvider value={closedLobbyCategoriesAtom}>
|
<ClosedLobbyCategoriesProvider value={closedLobbyCategoriesAtom}>
|
||||||
<NavToActivePathProvider value={navToActivePathAtom}>
|
<NavToActivePathProvider value={navToActivePathAtom}>
|
||||||
<OpenedSidebarFolderProvider value={openedSidebarFolderAtom}>
|
<OpenedSidebarFolderProvider value={openedSidebarFolderAtom}>
|
||||||
{children}
|
<CallPreferencesProvider value={callPreferencesAtom}>
|
||||||
|
{children}
|
||||||
|
</CallPreferencesProvider>
|
||||||
</OpenedSidebarFolderProvider>
|
</OpenedSidebarFolderProvider>
|
||||||
</NavToActivePathProvider>
|
</NavToActivePathProvider>
|
||||||
</ClosedLobbyCategoriesProvider>
|
</ClosedLobbyCategoriesProvider>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { stopPropagation } from '../../utils/keyboard';
|
|||||||
import { SyncStatus } from './SyncStatus';
|
import { SyncStatus } from './SyncStatus';
|
||||||
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
||||||
import { getFallbackSession } from '../../state/sessions';
|
import { getFallbackSession } from '../../state/sessions';
|
||||||
|
import { AutoDiscovery } from './AutoDiscovery';
|
||||||
|
|
||||||
function ClientRootLoading() {
|
function ClientRootLoading() {
|
||||||
return (
|
return (
|
||||||
@@ -143,7 +144,7 @@ type ClientRootProps = {
|
|||||||
};
|
};
|
||||||
export function ClientRoot({ children }: ClientRootProps) {
|
export function ClientRoot({ children }: ClientRootProps) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const { baseUrl } = getFallbackSession() ?? {};
|
const { baseUrl, userId } = getFallbackSession() ?? {};
|
||||||
|
|
||||||
const [loadState, loadMatrix] = useAsyncCallback<MatrixClient, Error, []>(
|
const [loadState, loadMatrix] = useAsyncCallback<MatrixClient, Error, []>(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
@@ -183,47 +184,55 @@ export function ClientRoot({ children }: ClientRootProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SpecVersions baseUrl={baseUrl!}>
|
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
|
||||||
{mx && <SyncStatus mx={mx} />}
|
<SpecVersions baseUrl={baseUrl!}>
|
||||||
{loading && <ClientRootOptions mx={mx} />}
|
{mx && <SyncStatus mx={mx} />}
|
||||||
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
|
{loading && <ClientRootOptions mx={mx} />}
|
||||||
<SplashScreen>
|
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
|
||||||
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="400">
|
<SplashScreen>
|
||||||
<Dialog>
|
<Box
|
||||||
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
direction="Column"
|
||||||
{loadState.status === AsyncStatus.Error && (
|
grow="Yes"
|
||||||
<Text>{`Failed to load. ${loadState.error.message}`}</Text>
|
alignItems="Center"
|
||||||
)}
|
justifyContent="Center"
|
||||||
{startState.status === AsyncStatus.Error && (
|
gap="400"
|
||||||
<Text>{`Failed to start. ${startState.error.message}`}</Text>
|
>
|
||||||
)}
|
<Dialog>
|
||||||
<Button variant="Critical" onClick={mx ? () => startMatrix(mx) : loadMatrix}>
|
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
||||||
<Text as="span" size="B400">
|
{loadState.status === AsyncStatus.Error && (
|
||||||
Retry
|
<Text>{`Failed to load. ${loadState.error.message}`}</Text>
|
||||||
</Text>
|
)}
|
||||||
</Button>
|
{startState.status === AsyncStatus.Error && (
|
||||||
</Box>
|
<Text>{`Failed to start. ${startState.error.message}`}</Text>
|
||||||
</Dialog>
|
)}
|
||||||
</Box>
|
<Button variant="Critical" onClick={mx ? () => startMatrix(mx) : loadMatrix}>
|
||||||
</SplashScreen>
|
<Text as="span" size="B400">
|
||||||
)}
|
Retry
|
||||||
{loading || !mx ? (
|
</Text>
|
||||||
<ClientRootLoading />
|
</Button>
|
||||||
) : (
|
</Box>
|
||||||
<MatrixClientProvider value={mx}>
|
</Dialog>
|
||||||
<ServerConfigsLoader>
|
</Box>
|
||||||
{(serverConfigs) => (
|
</SplashScreen>
|
||||||
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
|
)}
|
||||||
<MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}>
|
{loading || !mx ? (
|
||||||
<AuthMetadataProvider value={serverConfigs.authMetadata}>
|
<ClientRootLoading />
|
||||||
{children}
|
) : (
|
||||||
</AuthMetadataProvider>
|
<MatrixClientProvider value={mx}>
|
||||||
</MediaConfigProvider>
|
<ServerConfigsLoader>
|
||||||
</CapabilitiesProvider>
|
{(serverConfigs) => (
|
||||||
)}
|
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
|
||||||
</ServerConfigsLoader>
|
<MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}>
|
||||||
</MatrixClientProvider>
|
<AuthMetadataProvider value={serverConfigs.authMetadata}>
|
||||||
)}
|
{children}
|
||||||
</SpecVersions>
|
</AuthMetadataProvider>
|
||||||
|
</MediaConfigProvider>
|
||||||
|
</CapabilitiesProvider>
|
||||||
|
)}
|
||||||
|
</ServerConfigsLoader>
|
||||||
|
</MatrixClientProvider>
|
||||||
|
)}
|
||||||
|
</SpecVersions>
|
||||||
|
</AutoDiscovery>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function WelcomePage() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
v4.10.5
|
v4.11.1
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -417,7 +417,12 @@ function RoomNotificationsGroupComp({
|
|||||||
src={getRoomAvatarUrl(mx, room, 96, useAuthentication)}
|
src={getRoomAvatarUrl(mx, room, 96, useAuthentication)}
|
||||||
alt={room.name}
|
alt={room.name}
|
||||||
renderFallback={() => (
|
renderFallback={() => (
|
||||||
<RoomIcon size="50" joinRule={room.getJoinRule() ?? JoinRule.Restricted} filled />
|
<RoomIcon
|
||||||
|
size="50"
|
||||||
|
roomType={room.getType()}
|
||||||
|
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
|
||||||
|
filled
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ import { ContainerColor } from '../../../styles/ContainerColor.css';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { BreakWord } from '../../../styles/Text.css';
|
import { BreakWord } from '../../../styles/Text.css';
|
||||||
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
|
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
|
||||||
|
import { useCallEmbed } from '../../../hooks/useCallEmbed';
|
||||||
|
|
||||||
type SpaceMenuProps = {
|
type SpaceMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -387,15 +388,15 @@ export function Space() {
|
|||||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||||
|
|
||||||
const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone);
|
const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone);
|
||||||
|
|
||||||
const selectedRoomId = useSelectedRoom();
|
const selectedRoomId = useSelectedRoom();
|
||||||
const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias);
|
const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias);
|
||||||
const searchSelected = useSpaceSearchSelected(spaceIdOrAlias);
|
const searchSelected = useSpaceSearchSelected(spaceIdOrAlias);
|
||||||
|
const callEmbed = useCallEmbed();
|
||||||
|
|
||||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
||||||
|
|
||||||
const getRoom = useCallback(
|
const getRoom = useCallback(
|
||||||
(rId: string) => {
|
(rId: string): Room | undefined => {
|
||||||
if (allJoinedRooms.has(rId)) {
|
if (allJoinedRooms.has(rId)) {
|
||||||
return mx.getRoom(rId) ?? undefined;
|
return mx.getRoom(rId) ?? undefined;
|
||||||
}
|
}
|
||||||
@@ -412,11 +413,11 @@ export function Space() {
|
|||||||
if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) {
|
if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const showRoom = roomToUnread.has(roomId) || roomId === selectedRoomId;
|
const showRoomAnyway =
|
||||||
if (showRoom) return false;
|
roomToUnread.has(roomId) || roomId === selectedRoomId || callEmbed?.roomId === roomId;
|
||||||
return true;
|
return !showRoomAnyway;
|
||||||
},
|
},
|
||||||
[space.roomId, closedCategories, roomToUnread, selectedRoomId]
|
[space.roomId, closedCategories, roomToUnread, selectedRoomId, callEmbed]
|
||||||
),
|
),
|
||||||
useCallback(
|
useCallback(
|
||||||
(sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)),
|
(sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)),
|
||||||
|
|||||||
239
src/app/plugins/call/CallControl.ts
Normal file
239
src/app/plugins/call/CallControl.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { ClientWidgetApi } from 'matrix-widget-api';
|
||||||
|
import EventEmitter from 'events';
|
||||||
|
import { CallControlState } from './CallControlState';
|
||||||
|
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
|
||||||
|
|
||||||
|
export enum CallControlEvent {
|
||||||
|
StateUpdate = 'state_update',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CallControl extends EventEmitter implements CallControlState {
|
||||||
|
private state: CallControlState;
|
||||||
|
|
||||||
|
private call: ClientWidgetApi;
|
||||||
|
|
||||||
|
private iframe: HTMLIFrameElement;
|
||||||
|
|
||||||
|
private controlMutationObserver: MutationObserver;
|
||||||
|
|
||||||
|
private get document(): Document | undefined {
|
||||||
|
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get screenshareButton(): HTMLElement | undefined {
|
||||||
|
const screenshareBtn = this.document?.querySelector(
|
||||||
|
'[data-testid="incall_screenshare"]'
|
||||||
|
) as HTMLElement | null;
|
||||||
|
|
||||||
|
return screenshareBtn ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get settingsButton(): HTMLElement | undefined {
|
||||||
|
const leaveBtn = this.document?.querySelector('[data-testid="incall_leave"]');
|
||||||
|
|
||||||
|
const settingsButton = leaveBtn?.previousElementSibling as HTMLElement | null;
|
||||||
|
|
||||||
|
return settingsButton ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get reactionsButton(): HTMLElement | undefined {
|
||||||
|
const reactionsButton = this.settingsButton?.previousElementSibling as HTMLElement | null;
|
||||||
|
|
||||||
|
return reactionsButton ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get spotlightButton(): HTMLInputElement | undefined {
|
||||||
|
const spotlightButton = this.document?.querySelector(
|
||||||
|
'input[value="spotlight"]'
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
|
||||||
|
return spotlightButton ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get gridButton(): HTMLInputElement | undefined {
|
||||||
|
const gridButton = this.document?.querySelector(
|
||||||
|
'input[value="grid"]'
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
|
||||||
|
return gridButton ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.state = state;
|
||||||
|
this.call = call;
|
||||||
|
this.iframe = iframe;
|
||||||
|
|
||||||
|
this.controlMutationObserver = new MutationObserver(this.onControlMutation.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
public getState(): CallControlState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get microphone(): boolean {
|
||||||
|
return this.state.microphone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get video(): boolean {
|
||||||
|
return this.state.video;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get sound(): boolean {
|
||||||
|
return this.state.sound;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get screenshare(): boolean {
|
||||||
|
return this.state.screenshare;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get spotlight(): boolean {
|
||||||
|
return this.state.spotlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async applyState() {
|
||||||
|
await this.setMediaState({
|
||||||
|
audio_enabled: this.microphone,
|
||||||
|
video_enabled: this.video,
|
||||||
|
});
|
||||||
|
this.setSound(this.sound);
|
||||||
|
this.emitStateUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public startObserving() {
|
||||||
|
this.controlMutationObserver.disconnect();
|
||||||
|
|
||||||
|
const screenshareBtn = this.screenshareButton;
|
||||||
|
if (screenshareBtn) {
|
||||||
|
this.controlMutationObserver.observe(screenshareBtn, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['data-kind'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const spotlightBtn = this.spotlightButton;
|
||||||
|
if (spotlightBtn) {
|
||||||
|
this.controlMutationObserver.observe(spotlightBtn, {
|
||||||
|
attributes: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onControlMutation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public applySound() {
|
||||||
|
this.setSound(this.sound);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setMediaState(state: ElementMediaStatePayload) {
|
||||||
|
return this.call.transport.send(ElementWidgetActions.DeviceMute, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setSound(sound: boolean): void {
|
||||||
|
const callDocument = this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
|
||||||
|
if (callDocument) {
|
||||||
|
callDocument.querySelectorAll('audio').forEach((el) => {
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
el.muted = !sound;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onMediaState(evt: CustomEvent<ElementMediaStateDetail>) {
|
||||||
|
const { data } = evt.detail;
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const state = new CallControlState(
|
||||||
|
data.audio_enabled ?? this.microphone,
|
||||||
|
data.video_enabled ?? this.video,
|
||||||
|
this.sound,
|
||||||
|
this.screenshare,
|
||||||
|
this.spotlight
|
||||||
|
);
|
||||||
|
|
||||||
|
this.state = state;
|
||||||
|
this.emitStateUpdate();
|
||||||
|
|
||||||
|
if (this.microphone && !this.sound) {
|
||||||
|
this.toggleSound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onControlMutation() {
|
||||||
|
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
|
||||||
|
const spotlight: boolean = this.spotlightButton?.checked ?? false;
|
||||||
|
|
||||||
|
this.state = new CallControlState(
|
||||||
|
this.microphone,
|
||||||
|
this.video,
|
||||||
|
this.sound,
|
||||||
|
screenshare,
|
||||||
|
spotlight
|
||||||
|
);
|
||||||
|
this.emitStateUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleMicrophone() {
|
||||||
|
const payload: ElementMediaStatePayload = {
|
||||||
|
audio_enabled: !this.microphone,
|
||||||
|
video_enabled: this.video,
|
||||||
|
};
|
||||||
|
return this.setMediaState(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleVideo() {
|
||||||
|
const payload: ElementMediaStatePayload = {
|
||||||
|
audio_enabled: this.microphone,
|
||||||
|
video_enabled: !this.video,
|
||||||
|
};
|
||||||
|
return this.setMediaState(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleSound() {
|
||||||
|
const sound = !this.sound;
|
||||||
|
|
||||||
|
this.setSound(sound);
|
||||||
|
|
||||||
|
const state = new CallControlState(
|
||||||
|
this.microphone,
|
||||||
|
this.video,
|
||||||
|
sound,
|
||||||
|
this.screenshare,
|
||||||
|
this.spotlight
|
||||||
|
);
|
||||||
|
this.state = state;
|
||||||
|
this.emitStateUpdate();
|
||||||
|
|
||||||
|
if (!this.sound && this.microphone) {
|
||||||
|
this.toggleMicrophone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleScreenshare() {
|
||||||
|
this.screenshareButton?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleSpotlight() {
|
||||||
|
if (this.spotlight) {
|
||||||
|
this.gridButton?.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.spotlightButton?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleReactions() {
|
||||||
|
this.reactionsButton?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleSettings() {
|
||||||
|
this.settingsButton?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose() {
|
||||||
|
this.controlMutationObserver.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitStateUpdate() {
|
||||||
|
this.emit(CallControlEvent.StateUpdate);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/app/plugins/call/CallControlState.ts
Normal file
25
src/app/plugins/call/CallControlState.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export class CallControlState {
|
||||||
|
public readonly microphone: boolean;
|
||||||
|
|
||||||
|
public readonly video: boolean;
|
||||||
|
|
||||||
|
public readonly sound: boolean;
|
||||||
|
|
||||||
|
public readonly screenshare: boolean;
|
||||||
|
|
||||||
|
public readonly spotlight: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
microphone: boolean,
|
||||||
|
video: boolean,
|
||||||
|
sound: boolean,
|
||||||
|
screenshare = false,
|
||||||
|
spotlight = false
|
||||||
|
) {
|
||||||
|
this.microphone = microphone;
|
||||||
|
this.video = video;
|
||||||
|
this.sound = sound;
|
||||||
|
this.screenshare = screenshare;
|
||||||
|
this.spotlight = spotlight;
|
||||||
|
}
|
||||||
|
}
|
||||||
398
src/app/plugins/call/CallEmbed.ts
Normal file
398
src/app/plugins/call/CallEmbed.ts
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
import {
|
||||||
|
ClientEvent,
|
||||||
|
KnownMembership,
|
||||||
|
MatrixClient,
|
||||||
|
MatrixEvent,
|
||||||
|
MatrixEventEvent,
|
||||||
|
Room,
|
||||||
|
RoomStateEvent,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import {
|
||||||
|
ClientWidgetApi,
|
||||||
|
IRoomEvent,
|
||||||
|
IWidget,
|
||||||
|
Widget,
|
||||||
|
WidgetApiToWidgetAction,
|
||||||
|
WidgetDriver,
|
||||||
|
} from 'matrix-widget-api';
|
||||||
|
import { CallWidgetDriver } from './CallWidgetDriver';
|
||||||
|
import { trimTrailingSlash } from '../../utils/common';
|
||||||
|
import {
|
||||||
|
ElementCallIntent,
|
||||||
|
ElementCallThemeKind,
|
||||||
|
ElementMediaStateDetail,
|
||||||
|
ElementWidgetActions,
|
||||||
|
} from './types';
|
||||||
|
import { CallControl } from './CallControl';
|
||||||
|
import { CallControlState } from './CallControlState';
|
||||||
|
|
||||||
|
export class CallEmbed {
|
||||||
|
private mx: MatrixClient;
|
||||||
|
|
||||||
|
public readonly call: ClientWidgetApi;
|
||||||
|
|
||||||
|
public readonly iframe: HTMLIFrameElement;
|
||||||
|
|
||||||
|
public readonly room: Room;
|
||||||
|
|
||||||
|
public joined = false;
|
||||||
|
|
||||||
|
public readonly control: CallControl;
|
||||||
|
|
||||||
|
private readonly container: HTMLElement;
|
||||||
|
|
||||||
|
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
|
||||||
|
|
||||||
|
private eventsToFeed = new WeakSet<MatrixEvent>();
|
||||||
|
|
||||||
|
private readonly disposables: Array<() => void> = [];
|
||||||
|
|
||||||
|
static getIntent(dm: boolean, ongoing: boolean): ElementCallIntent {
|
||||||
|
if (ongoing) {
|
||||||
|
return dm ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExisting;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dm ? ElementCallIntent.StartCallDM : ElementCallIntent.StartCall;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getWidget(
|
||||||
|
mx: MatrixClient,
|
||||||
|
room: Room,
|
||||||
|
intent: ElementCallIntent,
|
||||||
|
themeKind: ElementCallThemeKind
|
||||||
|
): Widget {
|
||||||
|
const userId = mx.getSafeUserId();
|
||||||
|
const deviceId = mx.getDeviceId() ?? '';
|
||||||
|
const clientOrigin = window.location.origin;
|
||||||
|
const widgetId = 'call-embed';
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
widgetId,
|
||||||
|
parentUrl: clientOrigin,
|
||||||
|
baseUrl: mx.baseUrl,
|
||||||
|
roomId: room.roomId,
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
intent,
|
||||||
|
|
||||||
|
skipLobby: 'true',
|
||||||
|
confineToRoom: 'true',
|
||||||
|
appPrompt: 'false',
|
||||||
|
perParticipantE2EE: room.hasEncryptionStateEvent().toString(),
|
||||||
|
lang: 'en-EN',
|
||||||
|
theme: themeKind,
|
||||||
|
});
|
||||||
|
|
||||||
|
const widgetUrl = new URL(
|
||||||
|
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`,
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
widgetUrl.search = params.toString();
|
||||||
|
|
||||||
|
const options: IWidget = {
|
||||||
|
id: widgetId,
|
||||||
|
creatorUserId: userId,
|
||||||
|
name: 'Call',
|
||||||
|
type: 'm.call',
|
||||||
|
url: widgetUrl.href,
|
||||||
|
waitForIframeLoad: false,
|
||||||
|
data: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const widget: Widget = new Widget(options);
|
||||||
|
|
||||||
|
return widget;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getIframe(url: string): HTMLIFrameElement {
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
|
||||||
|
iframe.title = 'Call Embed';
|
||||||
|
iframe.sandbox =
|
||||||
|
'allow-forms allow-scripts allow-same-origin allow-popups allow-modals allow-downloads';
|
||||||
|
iframe.allow = 'microphone; camera; display-capture; autoplay; clipboard-write;';
|
||||||
|
iframe.src = url;
|
||||||
|
|
||||||
|
iframe.style.width = '100%';
|
||||||
|
iframe.style.height = '100%';
|
||||||
|
iframe.style.border = 'none';
|
||||||
|
|
||||||
|
return iframe;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
mx: MatrixClient,
|
||||||
|
room: Room,
|
||||||
|
widget: Widget,
|
||||||
|
container: HTMLElement,
|
||||||
|
initialControlState?: CallControlState
|
||||||
|
) {
|
||||||
|
const iframe = CallEmbed.getIframe(
|
||||||
|
widget.getCompleteUrl({ currentUserId: mx.getSafeUserId() })
|
||||||
|
);
|
||||||
|
container.append(iframe);
|
||||||
|
|
||||||
|
const callWidgetDriver: WidgetDriver = new CallWidgetDriver(mx, room.roomId);
|
||||||
|
const call: ClientWidgetApi = new ClientWidgetApi(widget, iframe, callWidgetDriver);
|
||||||
|
|
||||||
|
this.mx = mx;
|
||||||
|
this.call = call;
|
||||||
|
this.room = room;
|
||||||
|
this.iframe = iframe;
|
||||||
|
this.container = container;
|
||||||
|
|
||||||
|
const controlState = initialControlState ?? new CallControlState(true, false, true);
|
||||||
|
this.control = new CallControl(controlState, call, iframe);
|
||||||
|
|
||||||
|
let initialMediaEvent = true;
|
||||||
|
this.disposables.push(
|
||||||
|
this.listenAction<ElementMediaStateDetail>(ElementWidgetActions.DeviceMute, (evt) => {
|
||||||
|
if (initialMediaEvent) {
|
||||||
|
initialMediaEvent = false;
|
||||||
|
this.control.applyState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.control.onMediaState(evt);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
get roomId(): string {
|
||||||
|
return this.room.roomId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get document(): Document | undefined {
|
||||||
|
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setTheme(theme: ElementCallThemeKind) {
|
||||||
|
return this.call.transport.send(WidgetApiToWidgetAction.ThemeChange, {
|
||||||
|
name: theme,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public hangup() {
|
||||||
|
return this.call.transport.send(ElementWidgetActions.HangupCall, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onPreparing(callback: () => void) {
|
||||||
|
return this.listenEvent('preparing', callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onPreparingError(callback: (error: any) => void) {
|
||||||
|
return this.listenEvent('error:preparing', callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onReady(callback: () => void) {
|
||||||
|
return this.listenEvent('ready', callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onCapabilitiesNotified(callback: () => void) {
|
||||||
|
return this.listenEvent('capabilitiesNotified', callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private start() {
|
||||||
|
// Room widgets get locked to the room they were added in
|
||||||
|
this.call.setViewedRoomId(this.roomId);
|
||||||
|
this.disposables.push(
|
||||||
|
this.listenAction(ElementWidgetActions.JoinCall, this.onCallJoined.bind(this))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Populate the map of "read up to" events for this widget with the current event in every room.
|
||||||
|
// This is a bit inefficient, but should be okay. We do this for all rooms in case the widget
|
||||||
|
// requests timeline capabilities in other rooms down the road. It's just easier to manage here.
|
||||||
|
this.mx.getRooms().forEach((room) => {
|
||||||
|
// Timelines are most recent last
|
||||||
|
const events = room.getLiveTimeline()?.getEvents() || [];
|
||||||
|
const roomEvent = events[events.length - 1];
|
||||||
|
if (!roomEvent) return; // force later code to think the room is fresh
|
||||||
|
this.readUpToMap[room.roomId] = roomEvent.getId()!;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach listeners for feeding events - the underlying widget classes handle permissions for us
|
||||||
|
this.mx.on(ClientEvent.Event, this.onEvent.bind(this));
|
||||||
|
this.mx.on(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this));
|
||||||
|
this.mx.on(RoomStateEvent.Events, this.onStateUpdate.bind(this));
|
||||||
|
this.mx.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the widget messaging for if it is started. Skips stopping if it is an active
|
||||||
|
* widget.
|
||||||
|
* @param opts
|
||||||
|
*/
|
||||||
|
public dispose(): void {
|
||||||
|
this.disposables.forEach((disposable) => {
|
||||||
|
disposable();
|
||||||
|
});
|
||||||
|
this.call.stop();
|
||||||
|
this.container.removeChild(this.iframe);
|
||||||
|
this.control.dispose();
|
||||||
|
|
||||||
|
this.mx.off(ClientEvent.Event, this.onEvent.bind(this));
|
||||||
|
this.mx.off(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this));
|
||||||
|
this.mx.off(RoomStateEvent.Events, this.onStateUpdate.bind(this));
|
||||||
|
this.mx.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent.bind(this));
|
||||||
|
|
||||||
|
// Clear internal state
|
||||||
|
this.readUpToMap = {};
|
||||||
|
this.eventsToFeed = new WeakSet<MatrixEvent>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onCallJoined(): void {
|
||||||
|
this.joined = true;
|
||||||
|
this.applyStyles();
|
||||||
|
this.control.startObserving();
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyStyles(): void {
|
||||||
|
const doc = this.document;
|
||||||
|
if (!doc) return;
|
||||||
|
|
||||||
|
doc.body.style.setProperty('background', 'none', 'important');
|
||||||
|
const controls = doc.body.querySelector('[data-testid="incall_leave"]')?.parentElement
|
||||||
|
?.parentElement;
|
||||||
|
if (controls) {
|
||||||
|
controls.style.setProperty('position', 'absolute');
|
||||||
|
controls.style.setProperty('visibility', 'hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onEvent(ev: MatrixEvent): void {
|
||||||
|
this.mx.decryptEventIfNeeded(ev);
|
||||||
|
this.feedEvent(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onEventDecrypted(ev: MatrixEvent): void {
|
||||||
|
this.feedEvent(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onStateUpdate(ev: MatrixEvent): void {
|
||||||
|
if (this.call === null) return;
|
||||||
|
const raw = ev.getEffectiveEvent();
|
||||||
|
this.call.feedStateUpdate(raw as IRoomEvent).catch((e) => {
|
||||||
|
console.error('Error sending state update to widget: ', e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onToDeviceEvent(ev: MatrixEvent): Promise<void> {
|
||||||
|
await this.mx.decryptEventIfNeeded(ev);
|
||||||
|
if (ev.isDecryptionFailure()) return;
|
||||||
|
await this.call?.feedToDevice(ev.getEffectiveEvent() as IRoomEvent, ev.isEncrypted());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether the event has a relation to an unknown parent.
|
||||||
|
*/
|
||||||
|
private relatesToUnknown(ev: MatrixEvent): boolean {
|
||||||
|
// Replies to unknown events don't count
|
||||||
|
if (!ev.relationEventId || ev.replyEventId) return false;
|
||||||
|
const room = this.mx.getRoom(ev.getRoomId());
|
||||||
|
return room === null || !room.findEventById(ev.relationEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advances the "read up to" marker for a room to a certain event. No-ops if
|
||||||
|
* the event is before the marker.
|
||||||
|
* @returns Whether the "read up to" marker was advanced.
|
||||||
|
*/
|
||||||
|
private advanceReadUpToMarker(ev: MatrixEvent): boolean {
|
||||||
|
const evId = ev.getId();
|
||||||
|
if (evId === undefined) return false;
|
||||||
|
const roomId = ev.getRoomId();
|
||||||
|
if (roomId === undefined) return false;
|
||||||
|
const room = this.mx.getRoom(roomId);
|
||||||
|
if (room === null) return false;
|
||||||
|
|
||||||
|
const upToEventId = this.readUpToMap[ev.getRoomId()!];
|
||||||
|
if (!upToEventId) {
|
||||||
|
// There's no marker yet; start it at this event
|
||||||
|
this.readUpToMap[roomId] = evId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small optimization for exact match (skip the search)
|
||||||
|
if (upToEventId === evId) return false;
|
||||||
|
|
||||||
|
// Timelines are most recent last, so reverse the order and limit ourselves to 100 events
|
||||||
|
// to avoid overusing the CPU.
|
||||||
|
const timeline = room.getLiveTimeline();
|
||||||
|
const events = [...timeline.getEvents()].reverse().slice(0, 100);
|
||||||
|
function isRelevantTimelineEvent(timelineEvent: MatrixEvent): boolean {
|
||||||
|
return timelineEvent.getId() === upToEventId || timelineEvent.getId() === ev.getId();
|
||||||
|
}
|
||||||
|
const possibleMarkerEv = events.find(isRelevantTimelineEvent);
|
||||||
|
if (possibleMarkerEv?.getId() === upToEventId) {
|
||||||
|
// The event must be somewhere before the "read up to" marker
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (possibleMarkerEv?.getId() === ev.getId()) {
|
||||||
|
// The event is after the marker; advance it
|
||||||
|
this.readUpToMap[roomId] = evId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can't say for sure whether the widget has seen the event; let's
|
||||||
|
// just assume that it has
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether the event comes from a room that we've been invited to
|
||||||
|
* (in which case we likely don't have the full timeline).
|
||||||
|
*/
|
||||||
|
private isFromInvite(ev: MatrixEvent): boolean {
|
||||||
|
const room = this.mx.getRoom(ev.getRoomId());
|
||||||
|
return room?.getMyMembership() === KnownMembership.Invite;
|
||||||
|
}
|
||||||
|
|
||||||
|
private feedEvent(ev: MatrixEvent): void {
|
||||||
|
if (this.call === null) return;
|
||||||
|
if (
|
||||||
|
// If we had decided earlier to feed this event to the widget, but
|
||||||
|
// it just wasn't ready, give it another try
|
||||||
|
this.eventsToFeed.delete(ev) ||
|
||||||
|
// Skip marker timeline check for events with relations to unknown parent because these
|
||||||
|
// events are not added to the timeline here and will be ignored otherwise:
|
||||||
|
// https://github.com/matrix-org/matrix-js-sdk/blob/d3dfcd924201d71b434af3d77343b5229b6ed75e/src/models/room.ts#L2207-L2213
|
||||||
|
this.relatesToUnknown(ev) ||
|
||||||
|
// Skip marker timeline check for rooms where membership is
|
||||||
|
// 'invite', otherwise the membership event from the invitation room
|
||||||
|
// will advance the marker and new state events will not be
|
||||||
|
// forwarded to the widget.
|
||||||
|
this.isFromInvite(ev) ||
|
||||||
|
// Check whether this event would be before or after our "read up to" marker. If it's
|
||||||
|
// before, or we can't decide, then we assume the widget will have already seen the event.
|
||||||
|
// If the event is after, or we don't have a marker for the room,
|
||||||
|
// then the marker will advance and we'll send it through.
|
||||||
|
// This approach of "read up to" prevents widgets receiving decryption spam from startup or
|
||||||
|
// receiving ancient events from backfill and such.
|
||||||
|
this.advanceReadUpToMarker(ev)
|
||||||
|
) {
|
||||||
|
// If the event is still being decrypted, remember that we want to
|
||||||
|
// feed it to the widget (even if not strictly in the order given by
|
||||||
|
// the timeline) and get back to it later
|
||||||
|
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) {
|
||||||
|
this.eventsToFeed.add(ev);
|
||||||
|
} else {
|
||||||
|
const raw = ev.getEffectiveEvent();
|
||||||
|
this.call.feedEvent(raw as IRoomEvent).catch((e) => {
|
||||||
|
console.error('Error sending event to widget: ', e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public listenAction<T>(type: string, callback: (event: CustomEvent<T>) => void) {
|
||||||
|
return this.listenEvent(`action:${type}`, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
public listenEvent<T>(type: string, callback: (event: T) => void) {
|
||||||
|
this.call.on(type, callback);
|
||||||
|
return () => {
|
||||||
|
this.call.off(type, callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
340
src/app/plugins/call/CallWidgetDriver.ts
Normal file
340
src/app/plugins/call/CallWidgetDriver.ts
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import {
|
||||||
|
type Capability,
|
||||||
|
type ISendDelayedEventDetails,
|
||||||
|
type ISendEventDetails,
|
||||||
|
type IReadEventRelationsResult,
|
||||||
|
type IRoomEvent,
|
||||||
|
WidgetDriver,
|
||||||
|
type IWidgetApiErrorResponseDataDetails,
|
||||||
|
type ISearchUserDirectoryResult,
|
||||||
|
type IGetMediaConfigResult,
|
||||||
|
type UpdateDelayedEventAction,
|
||||||
|
OpenIDRequestState,
|
||||||
|
SimpleObservable,
|
||||||
|
IOpenIDUpdate,
|
||||||
|
} from 'matrix-widget-api';
|
||||||
|
import {
|
||||||
|
EventType,
|
||||||
|
type IContent,
|
||||||
|
MatrixError,
|
||||||
|
type MatrixEvent,
|
||||||
|
Direction,
|
||||||
|
type SendDelayedEventResponse,
|
||||||
|
type StateEvents,
|
||||||
|
type TimelineEvents,
|
||||||
|
MatrixClient,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import { getCallCapabilities } from './utils';
|
||||||
|
import { downloadMedia, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
|
|
||||||
|
export class CallWidgetDriver extends WidgetDriver {
|
||||||
|
private allowedCapabilities: Set<Capability>;
|
||||||
|
|
||||||
|
private readonly mx: MatrixClient;
|
||||||
|
|
||||||
|
public constructor(mx: MatrixClient, private inRoomId: string) {
|
||||||
|
super();
|
||||||
|
this.mx = mx;
|
||||||
|
|
||||||
|
const deviceId = mx.getDeviceId();
|
||||||
|
if (!deviceId) throw new Error('Failed to initialize CallWidgetDriver! Device ID not found.');
|
||||||
|
|
||||||
|
this.allowedCapabilities = getCallCapabilities(inRoomId, mx.getSafeUserId(), deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
|
||||||
|
const allow = Array.from(requested).filter((cap) => this.allowedCapabilities.has(cap));
|
||||||
|
return new Set(allow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendEvent(
|
||||||
|
eventType: string,
|
||||||
|
content: IContent,
|
||||||
|
stateKey: string | null = null,
|
||||||
|
targetRoomId: string | null = null
|
||||||
|
): Promise<ISendEventDetails> {
|
||||||
|
const client = this.mx;
|
||||||
|
const roomId = targetRoomId || this.inRoomId;
|
||||||
|
|
||||||
|
if (!client || !roomId) throw new Error('Not in a room or not attached to a client');
|
||||||
|
|
||||||
|
let r: { event_id: string } | null;
|
||||||
|
if (typeof stateKey === 'string') {
|
||||||
|
r = await client.sendStateEvent(
|
||||||
|
roomId,
|
||||||
|
eventType as keyof StateEvents,
|
||||||
|
content as StateEvents[keyof StateEvents],
|
||||||
|
stateKey
|
||||||
|
);
|
||||||
|
} else if (eventType === EventType.RoomRedaction) {
|
||||||
|
// special case: extract the `redacts` property and call redact
|
||||||
|
r = await client.redactEvent(roomId, content.redacts);
|
||||||
|
} else {
|
||||||
|
r = await client.sendEvent(
|
||||||
|
roomId,
|
||||||
|
eventType as keyof TimelineEvents,
|
||||||
|
content as TimelineEvents[keyof TimelineEvents]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { roomId, eventId: r.event_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendDelayedEvent(
|
||||||
|
delay: number | null,
|
||||||
|
parentDelayId: string | null,
|
||||||
|
eventType: string,
|
||||||
|
content: IContent,
|
||||||
|
stateKey: string | null = null,
|
||||||
|
targetRoomId: string | null = null
|
||||||
|
): Promise<ISendDelayedEventDetails> {
|
||||||
|
const client = this.mx;
|
||||||
|
const roomId = targetRoomId || this.inRoomId;
|
||||||
|
|
||||||
|
if (!client || !roomId) throw new Error('Not in a room or not attached to a client');
|
||||||
|
|
||||||
|
let delayOpts;
|
||||||
|
if (delay !== null) {
|
||||||
|
delayOpts = {
|
||||||
|
delay,
|
||||||
|
...(parentDelayId !== null && { parent_delay_id: parentDelayId }),
|
||||||
|
};
|
||||||
|
} else if (parentDelayId !== null) {
|
||||||
|
delayOpts = {
|
||||||
|
parent_delay_id: parentDelayId,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error('Must provide at least one of delay or parentDelayId');
|
||||||
|
}
|
||||||
|
|
||||||
|
let r: SendDelayedEventResponse | null;
|
||||||
|
if (stateKey !== null) {
|
||||||
|
// state event
|
||||||
|
r = await client._unstable_sendDelayedStateEvent(
|
||||||
|
roomId,
|
||||||
|
delayOpts,
|
||||||
|
eventType as keyof StateEvents,
|
||||||
|
content as StateEvents[keyof StateEvents],
|
||||||
|
stateKey
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// message event
|
||||||
|
r = await client._unstable_sendDelayedEvent(
|
||||||
|
roomId,
|
||||||
|
delayOpts,
|
||||||
|
null,
|
||||||
|
eventType as keyof TimelineEvents,
|
||||||
|
content as TimelineEvents[keyof TimelineEvents]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
roomId,
|
||||||
|
delayId: r.delay_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateDelayedEvent(
|
||||||
|
delayId: string,
|
||||||
|
action: UpdateDelayedEventAction
|
||||||
|
): Promise<void> {
|
||||||
|
const client = this.mx;
|
||||||
|
|
||||||
|
if (!client) throw new Error('Not in a room or not attached to a client');
|
||||||
|
|
||||||
|
await client._unstable_updateDelayedEvent(delayId, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendToDevice(
|
||||||
|
eventType: string,
|
||||||
|
encrypted: boolean,
|
||||||
|
contentMap: { [userId: string]: { [deviceId: string]: object } }
|
||||||
|
): Promise<void> {
|
||||||
|
const client = this.mx;
|
||||||
|
|
||||||
|
if (encrypted) {
|
||||||
|
const crypto = client.getCrypto();
|
||||||
|
if (!crypto) throw new Error('E2EE not enabled');
|
||||||
|
|
||||||
|
// attempt to re-batch these up into a single request
|
||||||
|
const invertedContentMap: { [content: string]: { userId: string; deviceId: string }[] } = {};
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
for (const userId of Object.keys(contentMap)) {
|
||||||
|
const userContentMap = contentMap[userId];
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
for (const deviceId of Object.keys(userContentMap)) {
|
||||||
|
const content = userContentMap[deviceId];
|
||||||
|
const stringifiedContent = JSON.stringify(content);
|
||||||
|
invertedContentMap[stringifiedContent] = invertedContentMap[stringifiedContent] || [];
|
||||||
|
invertedContentMap[stringifiedContent].push({ userId, deviceId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Object.entries(invertedContentMap).map(async ([stringifiedContent, recipients]) => {
|
||||||
|
const batch = await crypto.encryptToDeviceMessages(
|
||||||
|
eventType,
|
||||||
|
recipients,
|
||||||
|
JSON.parse(stringifiedContent)
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.queueToDevice(batch);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await client.queueToDevice({
|
||||||
|
eventType,
|
||||||
|
batch: Object.entries(contentMap).flatMap(([userId, userContentMap]) =>
|
||||||
|
Object.entries(userContentMap).map(([deviceId, content]) => ({
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
payload: content,
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async readRoomTimeline(
|
||||||
|
roomId: string,
|
||||||
|
eventType: string,
|
||||||
|
msgtype: string | undefined,
|
||||||
|
stateKey: string | undefined,
|
||||||
|
limit: number,
|
||||||
|
since: string | undefined
|
||||||
|
): Promise<IRoomEvent[]> {
|
||||||
|
const safeLimit =
|
||||||
|
limit > 0 ? Math.min(limit, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
|
||||||
|
|
||||||
|
const room = this.mx.getRoom(roomId);
|
||||||
|
if (room === null) return [];
|
||||||
|
const results: MatrixEvent[] = [];
|
||||||
|
const events = room.getLiveTimeline().getEvents();
|
||||||
|
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const ev = events[i];
|
||||||
|
if (results.length >= safeLimit) break;
|
||||||
|
if (since !== undefined && ev.getId() === since) break;
|
||||||
|
|
||||||
|
if (
|
||||||
|
ev.getType() === eventType &&
|
||||||
|
!ev.isState() &&
|
||||||
|
(eventType !== EventType.RoomMessage || !msgtype || msgtype === ev.getContent().msgtype) &&
|
||||||
|
(ev.getStateKey() === undefined || stateKey === undefined || ev.getStateKey() === stateKey)
|
||||||
|
) {
|
||||||
|
results.push(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.map((e) => e.getEffectiveEvent() as IRoomEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>): Promise<void> {
|
||||||
|
return observer.update({
|
||||||
|
state: OpenIDRequestState.Allowed,
|
||||||
|
token: await this.mx.getOpenIdToken(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async readRoomState(
|
||||||
|
roomId: string,
|
||||||
|
eventType: string,
|
||||||
|
stateKey: string | undefined
|
||||||
|
): Promise<IRoomEvent[]> {
|
||||||
|
const room = this.mx.getRoom(roomId);
|
||||||
|
if (room === null) return [];
|
||||||
|
const state = room.getLiveTimeline().getState(Direction.Forward);
|
||||||
|
if (state === undefined) return [];
|
||||||
|
|
||||||
|
if (stateKey === undefined)
|
||||||
|
return state.getStateEvents(eventType).map((e) => e.getEffectiveEvent() as IRoomEvent);
|
||||||
|
const event = state.getStateEvents(eventType, stateKey);
|
||||||
|
return event === null ? [] : [event.getEffectiveEvent() as IRoomEvent];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async readEventRelations(
|
||||||
|
eventId: string,
|
||||||
|
roomId?: string,
|
||||||
|
relationType?: string,
|
||||||
|
eventType?: string,
|
||||||
|
from?: string,
|
||||||
|
to?: string,
|
||||||
|
limit?: number,
|
||||||
|
direction?: 'f' | 'b'
|
||||||
|
): Promise<IReadEventRelationsResult> {
|
||||||
|
const client = this.mx;
|
||||||
|
const dir = direction as Direction;
|
||||||
|
const targetRoomId = roomId ?? this.inRoomId ?? undefined;
|
||||||
|
|
||||||
|
if (typeof targetRoomId !== 'string') {
|
||||||
|
throw new Error('Error while reading the current room');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { events, nextBatch, prevBatch } = await client.relations(
|
||||||
|
targetRoomId,
|
||||||
|
eventId,
|
||||||
|
relationType ?? null,
|
||||||
|
eventType ?? null,
|
||||||
|
{ from, to, limit, dir }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
chunk: events.map((e) => e.getEffectiveEvent() as IRoomEvent),
|
||||||
|
nextBatch: nextBatch ?? undefined,
|
||||||
|
prevBatch: prevBatch ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async searchUserDirectory(
|
||||||
|
searchTerm: string,
|
||||||
|
limit?: number
|
||||||
|
): Promise<ISearchUserDirectoryResult> {
|
||||||
|
const client = this.mx;
|
||||||
|
|
||||||
|
const { limited, results } = await client.searchUserDirectory({ term: searchTerm, limit });
|
||||||
|
|
||||||
|
return {
|
||||||
|
limited,
|
||||||
|
results: results.map((r) => ({
|
||||||
|
userId: r.user_id,
|
||||||
|
displayName: r.display_name,
|
||||||
|
avatarUrl: r.avatar_url,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMediaConfig(): Promise<IGetMediaConfigResult> {
|
||||||
|
const client = this.mx;
|
||||||
|
|
||||||
|
return client.getMediaConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async uploadFile(file: XMLHttpRequestBodyInit): Promise<{ contentUri: string }> {
|
||||||
|
const client = this.mx;
|
||||||
|
|
||||||
|
const uploadResult = await client.uploadContent(file);
|
||||||
|
|
||||||
|
return { contentUri: uploadResult.content_uri };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async downloadFile(contentUri: string): Promise<{ file: XMLHttpRequestBodyInit }> {
|
||||||
|
const httpUrl = mxcUrlToHttp(this.mx, contentUri, true);
|
||||||
|
if (!httpUrl) {
|
||||||
|
throw new Error('Call widget failed to download file! No http url!');
|
||||||
|
}
|
||||||
|
const blob = await downloadMedia(httpUrl);
|
||||||
|
return { file: blob };
|
||||||
|
}
|
||||||
|
|
||||||
|
public getKnownRooms(): string[] {
|
||||||
|
return this.mx.getVisibleRooms().map((r) => r.roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
public processError(error: unknown): IWidgetApiErrorResponseDataDetails | undefined {
|
||||||
|
return error instanceof MatrixError
|
||||||
|
? { matrix_api_error: error.asWidgetApiErrorData() }
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/app/plugins/call/hooks.ts
Normal file
49
src/app/plugins/call/hooks.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
ClientWidgetApi,
|
||||||
|
IWidgetApiAcknowledgeResponseData,
|
||||||
|
IWidgetApiRequestData,
|
||||||
|
} from 'matrix-widget-api';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { CallControl, CallControlEvent } from './CallControl';
|
||||||
|
import { CallControlState } from './CallControlState';
|
||||||
|
|
||||||
|
export const useClientWidgetApiEvent = <T>(
|
||||||
|
api: ClientWidgetApi | undefined,
|
||||||
|
type: string,
|
||||||
|
callback: (event: CustomEvent<T>) => void
|
||||||
|
) => {
|
||||||
|
useEffect(() => {
|
||||||
|
api?.on(`action:${type}`, callback);
|
||||||
|
return () => {
|
||||||
|
api?.off(`action:${type}`, callback);
|
||||||
|
};
|
||||||
|
}, [api, type, callback]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSendClientWidgetApiAction = (api: ClientWidgetApi) => {
|
||||||
|
const sendWidgetAction = useCallback(
|
||||||
|
async <T extends IWidgetApiRequestData = IWidgetApiRequestData>(
|
||||||
|
action: string,
|
||||||
|
data: T
|
||||||
|
): Promise<IWidgetApiAcknowledgeResponseData> => api.transport.send(action, data),
|
||||||
|
[api]
|
||||||
|
);
|
||||||
|
|
||||||
|
return sendWidgetAction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCallControlState = (control: CallControl): CallControlState => {
|
||||||
|
const [state, setState] = useState(control.getState());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUpdate = () => {
|
||||||
|
setState(control.getState());
|
||||||
|
};
|
||||||
|
control.on(CallControlEvent.StateUpdate, handleUpdate);
|
||||||
|
return () => {
|
||||||
|
control.off(CallControlEvent.StateUpdate, handleUpdate);
|
||||||
|
};
|
||||||
|
}, [control]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
3
src/app/plugins/call/index.ts
Normal file
3
src/app/plugins/call/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './CallEmbed';
|
||||||
|
export * from './hooks';
|
||||||
|
export * from './types';
|
||||||
25
src/app/plugins/call/types.ts
Normal file
25
src/app/plugins/call/types.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export enum ElementCallIntent {
|
||||||
|
StartCall = 'start_call',
|
||||||
|
JoinExisting = 'join_existing',
|
||||||
|
StartCallDM = 'start_call_dm',
|
||||||
|
JoinExistingDM = 'join_existing_dm',
|
||||||
|
StartCallDMVoice = 'start_call_dm_voice',
|
||||||
|
JoinExistingDMVoice = 'join_existing_dm_voice',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ElementCallThemeKind = 'light' | 'dark';
|
||||||
|
|
||||||
|
export type ElementMediaStatePayload = {
|
||||||
|
audio_enabled?: boolean;
|
||||||
|
video_enabled?: boolean;
|
||||||
|
};
|
||||||
|
export type ElementMediaStateDetail = {
|
||||||
|
data?: ElementMediaStatePayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum ElementWidgetActions {
|
||||||
|
JoinCall = 'io.element.join',
|
||||||
|
HangupCall = 'im.vector.hangup',
|
||||||
|
Close = 'io.element.close',
|
||||||
|
DeviceMute = 'io.element.device_mute',
|
||||||
|
}
|
||||||
118
src/app/plugins/call/utils.ts
Normal file
118
src/app/plugins/call/utils.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import {
|
||||||
|
type Capability,
|
||||||
|
EventDirection,
|
||||||
|
MatrixCapabilities,
|
||||||
|
WidgetEventCapability,
|
||||||
|
} from 'matrix-widget-api';
|
||||||
|
import { EventType } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
export function getCallCapabilities(
|
||||||
|
roomId: string,
|
||||||
|
userId: string,
|
||||||
|
deviceId: string
|
||||||
|
): Set<Capability> {
|
||||||
|
const capabilities: Set<Capability> = new Set();
|
||||||
|
|
||||||
|
capabilities.add(MatrixCapabilities.Screenshots);
|
||||||
|
capabilities.add(MatrixCapabilities.AlwaysOnScreen);
|
||||||
|
capabilities.add(MatrixCapabilities.MSC3846TurnServers);
|
||||||
|
capabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent);
|
||||||
|
capabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent);
|
||||||
|
capabilities.add(`org.matrix.msc2762.timeline:${roomId}`);
|
||||||
|
capabilities.add(`org.matrix.msc2762.state:${roomId}`);
|
||||||
|
|
||||||
|
capabilities.add(
|
||||||
|
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomMember).raw
|
||||||
|
);
|
||||||
|
capabilities.add(
|
||||||
|
WidgetEventCapability.forStateEvent(EventDirection.Receive, 'org.matrix.msc3401.call').raw
|
||||||
|
);
|
||||||
|
capabilities.add(
|
||||||
|
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomEncryption).raw
|
||||||
|
);
|
||||||
|
capabilities.add(
|
||||||
|
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomName).raw
|
||||||
|
);
|
||||||
|
|
||||||
|
capabilities.add(
|
||||||
|
WidgetEventCapability.forStateEvent(
|
||||||
|
EventDirection.Send,
|
||||||
|
'org.matrix.msc3401.call.member',
|
||||||
|
userId
|
||||||
|
).raw
|
||||||
|
);
|
||||||
|
capabilities.add(
|
||||||
|
WidgetEventCapability.forStateEvent(
|
||||||
|
EventDirection.Send,
|
||||||
|
'org.matrix.msc3401.call.member',
|
||||||
|
`_${userId}_${deviceId}_m.call`
|
||||||
|
).raw
|
||||||
|
);
|
||||||
|
capabilities.add(
|
||||||
|
WidgetEventCapability.forStateEvent(
|
||||||
|
EventDirection.Send,
|
||||||
|
'org.matrix.msc3401.call.member',
|
||||||
|
`${userId}_${deviceId}_m.call`
|
||||||
|
).raw
|
||||||
|
);
|
||||||
|
capabilities.add(
|
||||||
|
WidgetEventCapability.forStateEvent(
|
||||||
|
EventDirection.Send,
|
||||||
|
'org.matrix.msc3401.call.member',
|
||||||
|
`_${userId}_${deviceId}`
|
||||||
|
).raw
|
||||||
|
);
|
||||||
|
capabilities.add(
|
||||||
|
WidgetEventCapability.forStateEvent(
|
||||||
|
EventDirection.Send,
|
||||||
|
'org.matrix.msc3401.call.member',
|
||||||
|
`${userId}_${deviceId}`
|
||||||
|
).raw
|
||||||
|
);
|
||||||
|
|
||||||
|
capabilities.add(
|
||||||
|
WidgetEventCapability.forStateEvent(EventDirection.Receive, 'org.matrix.msc3401.call.member')
|
||||||
|
.raw
|
||||||
|
);
|
||||||
|
capabilities.add(
|
||||||
|
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.msc4310.rtc.decline',
|
||||||
|
].forEach((type) => {
|
||||||
|
capabilities.add(WidgetEventCapability.forRoomEvent(EventDirection.Send, type).raw);
|
||||||
|
capabilities.add(WidgetEventCapability.forRoomEvent(EventDirection.Receive, type).raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
[
|
||||||
|
EventType.CallInvite,
|
||||||
|
EventType.CallCandidates,
|
||||||
|
EventType.CallAnswer,
|
||||||
|
EventType.CallHangup,
|
||||||
|
EventType.CallReject,
|
||||||
|
EventType.CallSelectAnswer,
|
||||||
|
EventType.CallNegotiate,
|
||||||
|
EventType.CallSDPStreamMetadataChanged,
|
||||||
|
EventType.CallSDPStreamMetadataChangedPrefix,
|
||||||
|
EventType.CallReplaces,
|
||||||
|
EventType.CallEncryptionKeysPrefix,
|
||||||
|
].forEach((type) => {
|
||||||
|
capabilities.add(WidgetEventCapability.forToDeviceEvent(EventDirection.Send, type).raw);
|
||||||
|
capabilities.add(WidgetEventCapability.forToDeviceEvent(EventDirection.Receive, type).raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
return capabilities;
|
||||||
|
}
|
||||||
@@ -16,11 +16,11 @@ export const getMatrixToRoom = (roomIdOrAlias: string, viaServers?: string[]): s
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getMatrixToRoomEvent = (
|
export const getMatrixToRoomEvent = (
|
||||||
roomIdOrAlias: string,
|
roomId: string,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
viaServers?: string[]
|
viaServers?: string[]
|
||||||
): string => {
|
): string => {
|
||||||
let fragment = `${roomIdOrAlias}/${eventId}`;
|
let fragment = `${roomId}/${eventId}`;
|
||||||
|
|
||||||
if (Array.isArray(viaServers) && viaServers.length > 0) {
|
if (Array.isArray(viaServers) && viaServers.length > 0) {
|
||||||
fragment = withViaServers(fragment, viaServers);
|
fragment = withViaServers(fragment, viaServers);
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ export const getRecentEmojis = (mx: MatrixClient, limit?: number): IEmoji[] => {
|
|||||||
|
|
||||||
export function addRecentEmoji(mx: MatrixClient, unicode: string) {
|
export function addRecentEmoji(mx: MatrixClient, unicode: string) {
|
||||||
const recentEmojiEvent = getAccountData(mx, AccountDataEvent.ElementRecentEmoji);
|
const recentEmojiEvent = getAccountData(mx, AccountDataEvent.ElementRecentEmoji);
|
||||||
const recentEmoji = recentEmojiEvent?.getContent<IRecentEmojiContent>().recent_emoji ?? [];
|
const recentEmojiContent = recentEmojiEvent?.getContent<IRecentEmojiContent>();
|
||||||
|
const recentEmoji =
|
||||||
|
recentEmojiContent && Array.isArray(recentEmojiContent.recent_emoji)
|
||||||
|
? structuredClone(recentEmojiContent.recent_emoji)
|
||||||
|
: [];
|
||||||
|
|
||||||
const emojiIndex = recentEmoji.findIndex(([u]) => u === unicode);
|
const emojiIndex = recentEmoji.findIndex(([u]) => u === unicode);
|
||||||
let entry: [EmojiUnicode, EmojiUsageCount];
|
let entry: [EmojiUnicode, EmojiUsageCount];
|
||||||
|
|||||||
20
src/app/state/callEmbed.ts
Normal file
20
src/app/state/callEmbed.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
import { CallEmbed } from '../plugins/call';
|
||||||
|
|
||||||
|
const baseCallEmbedAtom = atom<CallEmbed | undefined>(undefined);
|
||||||
|
|
||||||
|
export const callEmbedAtom = atom<CallEmbed | undefined, [CallEmbed | undefined], void>(
|
||||||
|
(get) => get(baseCallEmbedAtom),
|
||||||
|
(get, set, callEmbed) => {
|
||||||
|
const prevCallEmbed = get(baseCallEmbedAtom);
|
||||||
|
if (callEmbed === prevCallEmbed) return;
|
||||||
|
|
||||||
|
if (prevCallEmbed) {
|
||||||
|
prevCallEmbed.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
set(baseCallEmbedAtom, callEmbed);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const callChatAtom = atom<boolean>(false);
|
||||||
39
src/app/state/callPreferences.ts
Normal file
39
src/app/state/callPreferences.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { WritableAtom } from 'jotai';
|
||||||
|
import {
|
||||||
|
atomWithLocalStorage,
|
||||||
|
getLocalStorageItem,
|
||||||
|
setLocalStorageItem,
|
||||||
|
} from './utils/atomWithLocalStorage';
|
||||||
|
|
||||||
|
export type CallPreferences = {
|
||||||
|
microphone: boolean;
|
||||||
|
video: boolean;
|
||||||
|
sound: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CALL_PREFERENCES = 'callPreferences';
|
||||||
|
|
||||||
|
const DEFAULT_PREFERENCES: CallPreferences = {
|
||||||
|
microphone: true,
|
||||||
|
video: false,
|
||||||
|
sound: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CallPreferencesAtom = WritableAtom<CallPreferences, [CallPreferences], undefined>;
|
||||||
|
|
||||||
|
export const makeCallPreferencesAtom = (userId: string): CallPreferencesAtom => {
|
||||||
|
const storeKey = `${CALL_PREFERENCES}${userId}`;
|
||||||
|
|
||||||
|
const callPreferencesAtom = atomWithLocalStorage<CallPreferences>(
|
||||||
|
storeKey,
|
||||||
|
(key) => {
|
||||||
|
const v = getLocalStorageItem<CallPreferences>(key, DEFAULT_PREFERENCES);
|
||||||
|
return v;
|
||||||
|
},
|
||||||
|
(key, value) => {
|
||||||
|
setLocalStorageItem(key, value);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return callPreferencesAtom;
|
||||||
|
};
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { atom } from 'jotai';
|
import { atom } from 'jotai';
|
||||||
|
import { CreateRoomType } from '../components/create-room/types';
|
||||||
|
|
||||||
export type CreateRoomModalState = {
|
export type CreateRoomModalState = {
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
|
type?: CreateRoomType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createRoomModalAtom = atom<CreateRoomModalState | undefined>(undefined);
|
export const createRoomModalAtom = atom<CreateRoomModalState | undefined>(undefined);
|
||||||
|
|||||||
61
src/app/state/hooks/callPreferences.ts
Normal file
61
src/app/state/hooks/callPreferences.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { createContext, useCallback, useContext } from 'react';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { CallPreferences, CallPreferencesAtom } from '../callPreferences';
|
||||||
|
|
||||||
|
const CallPreferencesAtomContext = createContext<CallPreferencesAtom | null>(null);
|
||||||
|
export const CallPreferencesProvider = CallPreferencesAtomContext.Provider;
|
||||||
|
|
||||||
|
export const useCallPreferencesAtom = (): CallPreferencesAtom => {
|
||||||
|
const atom = useContext(CallPreferencesAtomContext);
|
||||||
|
if (!atom) {
|
||||||
|
throw new Error('CallPreferencesAtom not provided!');
|
||||||
|
}
|
||||||
|
|
||||||
|
return atom;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCallPreferences = (): CallPreferences & {
|
||||||
|
toggleMicrophone: () => void;
|
||||||
|
toggleVideo: () => void;
|
||||||
|
toggleSound: () => void;
|
||||||
|
} => {
|
||||||
|
const callPrefAtom = useCallPreferencesAtom();
|
||||||
|
const [pref, setPref] = useAtom(callPrefAtom);
|
||||||
|
|
||||||
|
const toggleMicrophone = useCallback(() => {
|
||||||
|
const microphone = !pref.microphone;
|
||||||
|
|
||||||
|
setPref({
|
||||||
|
microphone,
|
||||||
|
video: pref.video,
|
||||||
|
sound: !pref.sound && microphone ? true : pref.sound,
|
||||||
|
});
|
||||||
|
}, [setPref, pref]);
|
||||||
|
|
||||||
|
const toggleVideo = useCallback(() => {
|
||||||
|
const video = !pref.video;
|
||||||
|
|
||||||
|
setPref({
|
||||||
|
microphone: pref.microphone,
|
||||||
|
video,
|
||||||
|
sound: pref.sound,
|
||||||
|
});
|
||||||
|
}, [setPref, pref]);
|
||||||
|
|
||||||
|
const toggleSound = useCallback(() => {
|
||||||
|
const sound = !pref.sound;
|
||||||
|
|
||||||
|
setPref({
|
||||||
|
microphone: !sound ? false : pref.microphone,
|
||||||
|
video: pref.video,
|
||||||
|
sound,
|
||||||
|
});
|
||||||
|
}, [setPref, pref]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...pref,
|
||||||
|
toggleMicrophone,
|
||||||
|
toggleVideo,
|
||||||
|
toggleSound,
|
||||||
|
};
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user