forked from github/cinny
Compare commits
78 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 | ||
|
|
6347640a35 | ||
|
|
f2d8ad0b6b | ||
|
|
739786d9ab | ||
|
|
f642809939 | ||
|
|
02106a99b9 | ||
|
|
df3a3ba789 | ||
|
|
cd80d4c9e8 | ||
|
|
dab44edef2 | ||
|
|
ed0ad61bc4 | ||
|
|
b2cb717178 | ||
|
|
7a9f6d2223 | ||
|
|
a9022184fc | ||
|
|
826b3c2997 | ||
|
|
2e6c5f7c04 | ||
|
|
2d6730de56 | ||
|
|
b6cc0e3077 | ||
|
|
91c8731940 | ||
|
|
1f03891b25 | ||
|
|
9ff15b8b03 | ||
|
|
170f5cd473 | ||
|
|
29ec172c8b | ||
|
|
0f220f50d6 | ||
|
|
d866c1b903 | ||
|
|
fbde1a2030 | ||
|
|
4ba7b9162d | ||
|
|
9d49418a1f | ||
|
|
3522751a15 | ||
|
|
074c555294 | ||
|
|
206a927f30 | ||
|
|
fd37dfe3f9 | ||
|
|
1ce6ca2b07 | ||
|
|
83e5125b37 | ||
|
|
ca82aa283a | ||
|
|
8ce33ee6ff | ||
|
|
073a9f5786 | ||
|
|
655c1c9aff | ||
|
|
17d4bceb42 | ||
|
|
0f61f2f328 | ||
|
|
c88cb4bca9 | ||
|
|
46c02b89de | ||
|
|
e13d97aa98 | ||
|
|
958ae8945d | ||
|
|
f55a3764d5 | ||
|
|
3bdcf37bf0 | ||
|
|
9d7808ec46 | ||
|
|
20d30903fd | ||
|
|
b78f6f23b5 | ||
|
|
867a47218a | ||
|
|
afc251aa7c | ||
|
|
31efbf73b7 | ||
|
|
31c6d13fdf | ||
|
|
b3497d9ed6 |
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@v4.2.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v4.4.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version: 20.12.2
|
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@v4.6.2
|
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@v4.6.2
|
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
|
||||||
|
|||||||
15
.github/workflows/deploy-pull-request.yml
vendored
15
.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. ⚠️
|
||||||
50
.github/workflows/docker-pr.yml
vendored
50
.github/workflows/docker-pr.yml
vendored
@@ -5,15 +5,59 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'Dockerfile'
|
- 'Dockerfile'
|
||||||
- '.github/workflows/docker-pr.yml'
|
- '.github/workflows/docker-pr.yml'
|
||||||
|
- '.github/workflows/prod-deploy.yml'
|
||||||
|
|
||||||
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@v4.2.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Build Docker image
|
|
||||||
uses: docker/build-push-action@v6.18.0
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
|
- name: Login to Docker Hub #Do not update this action from a outside PR
|
||||||
|
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@v4.2.0
|
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@v4.2.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v4.4.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version: 20.12.2
|
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@v4.2.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v4.4.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version: 20.12.2
|
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@72f2c25fcb47643c292f7107632f7a47c1df5cd8
|
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@v4.2.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3.6.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.10.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.5.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.5.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.8.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.18.0
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|||||||
1
.node-version
Normal file
1
.node-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
24.13.1
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
## Builder
|
## Builder
|
||||||
FROM node:20.12.2-alpine3.18 as builder
|
FROM node:24.13.1-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ RUN npm run build
|
|||||||
|
|
||||||
|
|
||||||
## App
|
## App
|
||||||
FROM nginx:1.29.0-alpine
|
FROM nginx:1.29.5-alpine
|
||||||
|
|
||||||
COPY --from=builder /src/dist /app
|
COPY --from=builder /src/dist /app
|
||||||
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf
|
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ mxFo+ioe/ABCufSmyqFye0psX3Sp
|
|||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> We recommend using a version manager as versions change very quickly. You will likely need to switch between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Iron LTS (v20).
|
> We recommend using a version manager as versions change very quickly. You will likely need to switch between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Krypton LTS (v24.13.1).
|
||||||
|
|
||||||
Execute the following commands to start a development server:
|
Execute the following commands to start a development server:
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
15
config.json
15
config.json
@@ -1,13 +1,6 @@
|
|||||||
{
|
{
|
||||||
"defaultHomeserver": 2,
|
"defaultHomeserver": 1,
|
||||||
"homeserverList": [
|
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
|
||||||
"converser.eu",
|
|
||||||
"envs.net",
|
|
||||||
"matrix.org",
|
|
||||||
"monero.social",
|
|
||||||
"mozilla.org",
|
|
||||||
"xmr.se"
|
|
||||||
],
|
|
||||||
"allowCustomHomeservers": true,
|
"allowCustomHomeservers": true,
|
||||||
|
|
||||||
"featuredCommunities": {
|
"featuredCommunities": {
|
||||||
@@ -15,7 +8,7 @@
|
|||||||
"spaces": [
|
"spaces": [
|
||||||
"#cinny-space:matrix.org",
|
"#cinny-space:matrix.org",
|
||||||
"#community:matrix.org",
|
"#community:matrix.org",
|
||||||
"#space:envs.net",
|
"#space:unredacted.org",
|
||||||
"#science-space:matrix.org",
|
"#science-space:matrix.org",
|
||||||
"#libregaming-games:tchncs.de",
|
"#libregaming-games:tchncs.de",
|
||||||
"#mathematics-on:matrix.org"
|
"#mathematics-on:matrix.org"
|
||||||
@@ -28,7 +21,7 @@
|
|||||||
"#PrivSec.dev:arcticfoxes.net",
|
"#PrivSec.dev:arcticfoxes.net",
|
||||||
"#disroot:aria-net.org"
|
"#disroot:aria-net.org"
|
||||||
],
|
],
|
||||||
"servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"]
|
"servers": ["matrix.org", "mozilla.org", "unredacted.org"]
|
||||||
},
|
},
|
||||||
|
|
||||||
"hashRouter": {
|
"hashRouter": {
|
||||||
|
|||||||
@@ -90,6 +90,7 @@
|
|||||||
window.global ||= window;
|
window.global ||= window;
|
||||||
</script>
|
</script>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
<div id="portalContainer"></div>
|
||||||
<script type="module" src="./src/index.tsx"></script>
|
<script type="module" src="./src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
160
package-lock.json
generated
160
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "cinny",
|
"name": "cinny",
|
||||||
"version": "4.10.0",
|
"version": "4.11.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cinny",
|
"name": "cinny",
|
||||||
"version": "4.10.0",
|
"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.2.0",
|
"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",
|
||||||
@@ -41,9 +41,10 @@
|
|||||||
"immer": "9.0.16",
|
"immer": "9.0.16",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
"jotai": "2.6.0",
|
"jotai": "2.6.0",
|
||||||
"linkify-react": "4.1.3",
|
"linkify-react": "4.3.2",
|
||||||
"linkifyjs": "4.1.3",
|
"linkifyjs": "4.3.2",
|
||||||
"matrix-js-sdk": "37.5.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",
|
||||||
@@ -56,15 +57,16 @@
|
|||||||
"react-google-recaptcha": "2.1.0",
|
"react-google-recaptcha": "2.1.0",
|
||||||
"react-i18next": "15.0.0",
|
"react-i18next": "15.0.0",
|
||||||
"react-range": "1.8.14",
|
"react-range": "1.8.14",
|
||||||
"react-router-dom": "6.20.0",
|
"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",
|
||||||
@@ -2256,20 +2264,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
"version": "14.1.0",
|
"version": "15.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-14.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-15.3.0.tgz",
|
||||||
"integrity": "sha512-vcSxHJIr6lP0Fgo8jl0sTHg+OZxZn+skGjiyB62erfgw/R2QqJl0ZVSY8SRcbk9LtHo/ZGld1tnaOyjL2e3cLQ==",
|
"integrity": "sha512-QyxHvncvkl7nf+tnn92PjQ54gMNV8hMSpiukiDgNrqF6IYwgySTlcSdkPYdw8QjZJ0NR6fnVrNzMec0OohM3wA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@matrix-org/olm": {
|
|
||||||
"version": "3.2.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@matrix-org/olm/-/olm-3.2.15.tgz",
|
|
||||||
"integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -3705,9 +3707,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.13.0",
|
"version": "1.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||||
"integrity": "sha512-5dMOnVnefRsl4uRnAdoWjtVTdh8e6aZqgM4puy9nmEADH72ck+uXwzpJLEKE9Q6F8ZljNewLgmTfkxUrBdv4WA==",
|
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -7163,9 +7166,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/folds": {
|
"node_modules/folds": {
|
||||||
"version": "2.2.0",
|
"version": "2.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/folds/-/folds-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/folds/-/folds-2.6.2.tgz",
|
||||||
"integrity": "sha512-uOfck5eWEIK11rhOAEdSoPIvMXwv+D1Go03pxSAKezWVb+uRoBdmE6LEqiLOF+ac4DGmZRMPvpdDsXCg7EVNIg==",
|
"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",
|
||||||
@@ -8497,18 +8500,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/linkify-react": {
|
"node_modules/linkify-react": {
|
||||||
"version": "4.1.3",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/linkify-react/-/linkify-react-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/linkify-react/-/linkify-react-4.3.2.tgz",
|
||||||
"integrity": "sha512-rhI3zM/fxn5BfRPHfi4r9N7zgac4vOIxub1wHIWXLA5ENTMs+BGaIaFO1D1PhmxgwhIKmJz3H7uCP0Dg5JwSlA==",
|
"integrity": "sha512-mi744h1hf+WDsr+paJgSBBgYNLMWNSHyM9V9LVUo03RidNGdw1VpI7Twnt+K3pEh3nIzB4xiiAgZxpd61ItKpQ==",
|
||||||
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"linkifyjs": "^4.0.0",
|
"linkifyjs": "^4.0.0",
|
||||||
"react": ">= 15.0.0"
|
"react": ">= 15.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/linkifyjs": {
|
"node_modules/linkifyjs": {
|
||||||
"version": "4.1.3",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
|
||||||
"integrity": "sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg=="
|
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
@@ -8631,14 +8636,13 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/matrix-js-sdk": {
|
"node_modules/matrix-js-sdk": {
|
||||||
"version": "37.5.0",
|
"version": "38.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-37.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-38.2.0.tgz",
|
||||||
"integrity": "sha512-5tyuAi5hnKud1UkVq8Z2/3c22hWGELBZzErJPZkE6Hju2uGUfGtrIx6uj6puv0ZjvsUU3X6Qgm8vdReKO1PGig==",
|
"integrity": "sha512-R3jzK8rDGi/3OXOax8jFFyxblCG9KTT5yuXAbvnZCGcpTm8lZ4mHQAn5UydVD8qiyUMNMpaaMd6/k7N+5I/yaQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
"@matrix-org/matrix-sdk-crypto-wasm": "^14.0.1",
|
"@matrix-org/matrix-sdk-crypto-wasm": "^15.1.0",
|
||||||
"@matrix-org/olm": "3.2.15",
|
|
||||||
"another-json": "^0.2.0",
|
"another-json": "^0.2.0",
|
||||||
"bs58": "^6.0.0",
|
"bs58": "^6.0.0",
|
||||||
"content-type": "^1.0.4",
|
"content-type": "^1.0.4",
|
||||||
@@ -8653,7 +8657,7 @@
|
|||||||
"uuid": "11"
|
"uuid": "11"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=22.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/matrix-js-sdk/node_modules/uuid": {
|
"node_modules/matrix-js-sdk/node_modules/uuid": {
|
||||||
@@ -8670,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",
|
||||||
@@ -9612,11 +9616,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "6.20.0",
|
"version": "6.30.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||||
"integrity": "sha512-pVvzsSsgUxxtuNfTHC4IxjATs10UaAtvLGVSA1tbUE4GDaOSU1Esu2xF5nWLz7KPiMuW8BJWuPFdlGYJ7/rW0w==",
|
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/router": "1.13.0"
|
"@remix-run/router": "1.23.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
@@ -9626,12 +9631,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "6.20.0",
|
"version": "6.30.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
||||||
"integrity": "sha512-CbcKjEyiSVpA6UtCHOIYLUYn/UJfwzp55va4yEfpk7JBN3GPqWfHrdLkAvNCcpXr8QoihcDMuk0dzWZxtlB/mQ==",
|
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/router": "1.13.0",
|
"@remix-run/router": "1.23.2",
|
||||||
"react-router": "6.20.0"
|
"react-router": "6.30.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
@@ -10285,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",
|
||||||
@@ -10310,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"
|
||||||
@@ -10326,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"
|
||||||
@@ -10342,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": {
|
||||||
@@ -10723,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",
|
||||||
@@ -10911,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"
|
||||||
|
|||||||
23
package.json
23
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cinny",
|
"name": "cinny",
|
||||||
"version": "4.10.0",
|
"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.2.0",
|
"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",
|
||||||
@@ -52,9 +53,10 @@
|
|||||||
"immer": "9.0.16",
|
"immer": "9.0.16",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
"jotai": "2.6.0",
|
"jotai": "2.6.0",
|
||||||
"linkify-react": "4.1.3",
|
"linkify-react": "4.3.2",
|
||||||
"linkifyjs": "4.1.3",
|
"linkifyjs": "4.3.2",
|
||||||
"matrix-js-sdk": "37.5.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",
|
||||||
@@ -67,15 +69,16 @@
|
|||||||
"react-google-recaptcha": "2.1.0",
|
"react-google-recaptcha": "2.1.0",
|
||||||
"react-i18next": "15.0.0",
|
"react-i18next": "15.0.0",
|
||||||
"react-range": "1.8.14",
|
"react-range": "1.8.14",
|
||||||
"react-router-dom": "6.20.0",
|
"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",
|
||||||
|
|||||||
@@ -51,8 +51,12 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) {
|
|||||||
},
|
},
|
||||||
location.pathname
|
location.pathname
|
||||||
);
|
);
|
||||||
if (spaceMatch?.params.spaceIdOrAlias) {
|
const encodedSpaceIdOrAlias = spaceMatch?.params.spaceIdOrAlias;
|
||||||
navigate(getSpacePath(spaceMatch.params.spaceIdOrAlias));
|
const decodedSpaceIdOrAlias =
|
||||||
|
encodedSpaceIdOrAlias && decodeURIComponent(encodedSpaceIdOrAlias);
|
||||||
|
|
||||||
|
if (decodedSpaceIdOrAlias) {
|
||||||
|
navigate(getSpacePath(decodedSpaceIdOrAlias));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|||||||
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>;
|
||||||
|
|||||||
@@ -209,13 +209,11 @@ export function RenderMessageContent({
|
|||||||
<MVideo
|
<MVideo
|
||||||
content={getContent()}
|
content={getContent()}
|
||||||
renderAsFile={renderFile}
|
renderAsFile={renderFile}
|
||||||
renderVideoContent={({ body, info, mimeType, url, encInfo }) => (
|
renderVideoContent={({ body, info, ...props }) => (
|
||||||
<VideoContent
|
<VideoContent
|
||||||
body={body}
|
body={body}
|
||||||
info={info}
|
info={info}
|
||||||
mimeType={mimeType}
|
{...props}
|
||||||
url={url}
|
|
||||||
encInfo={encInfo}
|
|
||||||
renderThumbnail={
|
renderThumbnail={
|
||||||
mediaAutoLoad
|
mediaAutoLoad
|
||||||
? () => (
|
? () => (
|
||||||
|
|||||||
@@ -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: [
|
||||||
@@ -74,6 +74,10 @@ export const createRoomParentState = (parent: Room) => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createSpacePowerLevelsOverride = () => ({
|
||||||
|
events_default: 50,
|
||||||
|
});
|
||||||
|
|
||||||
export const createRoomEncryptionState = () => ({
|
export const createRoomEncryptionState = () => ({
|
||||||
type: 'm.room.encryption',
|
type: 'm.room.encryption',
|
||||||
state_key: '',
|
state_key: '',
|
||||||
@@ -82,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;
|
||||||
@@ -106,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,
|
||||||
@@ -118,9 +138,15 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (data.type === RoomType.Space) {
|
||||||
|
options.power_level_content_override = createSpacePowerLevelsOverride();
|
||||||
|
}
|
||||||
|
|
||||||
const result = await mx.createRoom(options);
|
const result = await mx.createRoom(options);
|
||||||
|
|
||||||
if (data.parent) {
|
if (data.parent) {
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export function EmoticonAutocomplete({
|
|||||||
{autoCompleteEmoticon.map((emoticon) => {
|
{autoCompleteEmoticon.map((emoticon) => {
|
||||||
const isCustomEmoji = 'url' in emoticon;
|
const isCustomEmoji = 'url' in emoticon;
|
||||||
const key = isCustomEmoji ? emoticon.url : emoticon.unicode;
|
const key = isCustomEmoji ? emoticon.url : emoticon.unicode;
|
||||||
|
const customEmojiUrl = mxcUrlToHttp(mx, key, useAuthentication);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={emoticon.shortcode + key}
|
key={emoticon.shortcode + key}
|
||||||
@@ -98,11 +100,11 @@ export function EmoticonAutocomplete({
|
|||||||
}
|
}
|
||||||
onClick={() => handleAutocomplete(key, emoticon.shortcode)}
|
onClick={() => handleAutocomplete(key, emoticon.shortcode)}
|
||||||
before={
|
before={
|
||||||
isCustomEmoji ? (
|
isCustomEmoji && customEmojiUrl ? (
|
||||||
<Box
|
<Box
|
||||||
shrink="No"
|
shrink="No"
|
||||||
as="img"
|
as="img"
|
||||||
src={mxcUrlToHttp(mx, key, useAuthentication) || key}
|
src={customEmojiUrl}
|
||||||
alt={emoticon.shortcode}
|
alt={emoticon.shortcode}
|
||||||
style={{ width: toRem(24), height: toRem(24), objectFit: 'contain' }}
|
style={{ width: toRem(24), height: toRem(24), objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,9 +212,10 @@ export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): M
|
|||||||
if (node.type === BlockType.CodeBlock) return;
|
if (node.type === BlockType.CodeBlock) return;
|
||||||
|
|
||||||
if (node.type === BlockType.Mention) {
|
if (node.type === BlockType.Mention) {
|
||||||
if (node.id === getCanonicalAliasOrRoomId(mx, roomId)) {
|
if (node.name === '@room') {
|
||||||
mentionData.room = true;
|
mentionData.room = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isUserId(node.id) && node.id !== mx.getUserId()) {
|
if (isUserId(node.id) && node.id !== mx.getUserId()) {
|
||||||
mentionData.users.add(node.id);
|
mentionData.users.add(node.id);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
34
src/app/components/emoji-board/components/Group.tsx
Normal file
34
src/app/components/emoji-board/components/Group.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { as, Box, Text } from 'folds';
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
|
||||||
|
export const getDOMGroupId = (id: string): string => `EmojiBoardGroup-${id}`;
|
||||||
|
|
||||||
|
export const EmojiGroup = as<
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
>(({ className, id, label, children, ...props }, ref) => (
|
||||||
|
<Box
|
||||||
|
id={getDOMGroupId(id)}
|
||||||
|
data-group-id={id}
|
||||||
|
className={classNames(css.EmojiGroup, className)}
|
||||||
|
direction="Column"
|
||||||
|
gap="200"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Text id={`EmojiGroup-${id}-label`} as="label" className={css.EmojiGroupLabel} size="O400">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<div aria-labelledby={`EmojiGroup-${id}-label`} className={css.EmojiGroupContent}>
|
||||||
|
<Box wrap="Wrap" justifyContent="Center">
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
));
|
||||||
105
src/app/components/emoji-board/components/Item.tsx
Normal file
105
src/app/components/emoji-board/components/Item.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box } from 'folds';
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { EmojiItemInfo, EmojiType } from '../types';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
import { PackImageReader } from '../../../plugins/custom-emoji';
|
||||||
|
import { IEmoji } from '../../../plugins/emoji';
|
||||||
|
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
|
|
||||||
|
export const getEmojiItemInfo = (element: Element): EmojiItemInfo | undefined => {
|
||||||
|
const label = element.getAttribute('title');
|
||||||
|
const type = element.getAttribute('data-emoji-type') as EmojiType | undefined;
|
||||||
|
const data = element.getAttribute('data-emoji-data');
|
||||||
|
const shortcode = element.getAttribute('data-emoji-shortcode');
|
||||||
|
|
||||||
|
if (type && data && shortcode && label)
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
shortcode,
|
||||||
|
label,
|
||||||
|
};
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmojiItemProps = {
|
||||||
|
emoji: IEmoji;
|
||||||
|
};
|
||||||
|
export function EmojiItem({ emoji }: EmojiItemProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
className={css.EmojiItem}
|
||||||
|
title={emoji.label}
|
||||||
|
aria-label={`${emoji.label} emoji`}
|
||||||
|
data-emoji-type={EmojiType.Emoji}
|
||||||
|
data-emoji-data={emoji.unicode}
|
||||||
|
data-emoji-shortcode={emoji.shortcode}
|
||||||
|
>
|
||||||
|
{emoji.unicode}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomEmojiItemProps = {
|
||||||
|
mx: MatrixClient;
|
||||||
|
useAuthentication?: boolean;
|
||||||
|
image: PackImageReader;
|
||||||
|
};
|
||||||
|
export function CustomEmojiItem({ mx, useAuthentication, image }: CustomEmojiItemProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
className={css.EmojiItem}
|
||||||
|
title={image.body || image.shortcode}
|
||||||
|
aria-label={`${image.body || image.shortcode} emoji`}
|
||||||
|
data-emoji-type={EmojiType.CustomEmoji}
|
||||||
|
data-emoji-data={image.url}
|
||||||
|
data-emoji-shortcode={image.shortcode}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
loading="lazy"
|
||||||
|
className={css.CustomEmojiImg}
|
||||||
|
alt={image.body || image.shortcode}
|
||||||
|
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type StickerItemProps = {
|
||||||
|
mx: MatrixClient;
|
||||||
|
useAuthentication?: boolean;
|
||||||
|
image: PackImageReader;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StickerItem({ mx, useAuthentication, image }: StickerItemProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
className={css.StickerItem}
|
||||||
|
title={image.body || image.shortcode}
|
||||||
|
aria-label={`${image.body || image.shortcode} emoji`}
|
||||||
|
data-emoji-type={EmojiType.Sticker}
|
||||||
|
data-emoji-data={image.url}
|
||||||
|
data-emoji-shortcode={image.shortcode}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
loading="lazy"
|
||||||
|
className={css.StickerImg}
|
||||||
|
alt={image.body || image.shortcode}
|
||||||
|
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/app/components/emoji-board/components/Layout.tsx
Normal file
30
src/app/components/emoji-board/components/Layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { as, Box, Line } from 'folds';
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
|
||||||
|
export const EmojiBoardLayout = as<
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
header: ReactNode;
|
||||||
|
sidebar?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
>(({ className, header, sidebar, children, ...props }, ref) => (
|
||||||
|
<Box
|
||||||
|
display="InlineFlex"
|
||||||
|
className={classNames(css.Base, className)}
|
||||||
|
direction="Row"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Box direction="Column" grow="Yes">
|
||||||
|
<Box className={css.Header} direction="Column" shrink="No">
|
||||||
|
{header}
|
||||||
|
</Box>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
<Line size="300" direction="Vertical" />
|
||||||
|
{sidebar}
|
||||||
|
</Box>
|
||||||
|
));
|
||||||
22
src/app/components/emoji-board/components/NoStickerPacks.tsx
Normal file
22
src/app/components/emoji-board/components/NoStickerPacks.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, toRem, config, Icons, Icon, Text } from 'folds';
|
||||||
|
|
||||||
|
export function NoStickerPacks() {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{ padding: `${toRem(60)} ${config.space.S500}` }}
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
direction="Column"
|
||||||
|
gap="300"
|
||||||
|
>
|
||||||
|
<Icon size="600" src={Icons.Sticker} />
|
||||||
|
<Box direction="Inherit">
|
||||||
|
<Text align="Center">No Sticker Packs!</Text>
|
||||||
|
<Text priority="300" align="Center" size="T200">
|
||||||
|
Add stickers from user, room or space settings.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/app/components/emoji-board/components/Preview.tsx
Normal file
53
src/app/components/emoji-board/components/Preview.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Box, Text } from 'folds';
|
||||||
|
import React from 'react';
|
||||||
|
import { Atom, atom, useAtomValue } from 'jotai';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
|
|
||||||
|
export type PreviewData = {
|
||||||
|
key: string;
|
||||||
|
shortcode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPreviewDataAtom = (initial?: PreviewData) =>
|
||||||
|
atom<PreviewData | undefined>(initial);
|
||||||
|
|
||||||
|
type PreviewProps = {
|
||||||
|
previewAtom: Atom<PreviewData | undefined>;
|
||||||
|
};
|
||||||
|
export function Preview({ previewAtom }: PreviewProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
|
const { key, shortcode } = useAtomValue(previewAtom) ?? {};
|
||||||
|
|
||||||
|
if (!shortcode) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box shrink="No" className={css.Preview} gap="300" alignItems="Center">
|
||||||
|
{key && (
|
||||||
|
<Box
|
||||||
|
display="InlineFlex"
|
||||||
|
className={css.PreviewEmoji}
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
>
|
||||||
|
{key.startsWith('mxc://') ? (
|
||||||
|
<img
|
||||||
|
className={css.PreviewImg}
|
||||||
|
src={mxcUrlToHttp(mx, key, useAuthentication) ?? key}
|
||||||
|
alt={shortcode}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
key
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Text size="H5" truncate>
|
||||||
|
:{shortcode}:
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/app/components/emoji-board/components/SearchInput.tsx
Normal file
51
src/app/components/emoji-board/components/SearchInput.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React, { ChangeEventHandler, useRef } from 'react';
|
||||||
|
import { Input, Chip, Icon, Icons, Text } from 'folds';
|
||||||
|
import { mobileOrTablet } from '../../../utils/user-agent';
|
||||||
|
|
||||||
|
type SearchInputProps = {
|
||||||
|
query?: string;
|
||||||
|
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||||
|
allowTextCustomEmoji?: boolean;
|
||||||
|
onTextCustomEmojiSelect?: (text: string) => void;
|
||||||
|
};
|
||||||
|
export function SearchInput({
|
||||||
|
query,
|
||||||
|
onChange,
|
||||||
|
allowTextCustomEmoji,
|
||||||
|
onTextCustomEmojiSelect,
|
||||||
|
}: SearchInputProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleReact = () => {
|
||||||
|
const textEmoji = inputRef.current?.value.trim();
|
||||||
|
if (!textEmoji) return;
|
||||||
|
onTextCustomEmojiSelect?.(textEmoji);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="400"
|
||||||
|
placeholder={allowTextCustomEmoji ? 'Search or Text Reaction ' : 'Search'}
|
||||||
|
maxLength={50}
|
||||||
|
after={
|
||||||
|
allowTextCustomEmoji && query ? (
|
||||||
|
<Chip
|
||||||
|
variant="Primary"
|
||||||
|
radii="Pill"
|
||||||
|
after={<Icon src={Icons.ArrowRight} size="50" />}
|
||||||
|
outlined
|
||||||
|
onClick={handleReact}
|
||||||
|
>
|
||||||
|
<Text size="L400">React</Text>
|
||||||
|
</Chip>
|
||||||
|
) : (
|
||||||
|
<Icon src={Icons.Search} size="50" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onChange={onChange}
|
||||||
|
autoFocus={!mobileOrTablet()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
src/app/components/emoji-board/components/Sidebar.tsx
Normal file
130
src/app/components/emoji-board/components/Sidebar.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Scroll,
|
||||||
|
Line,
|
||||||
|
as,
|
||||||
|
TooltipProvider,
|
||||||
|
Tooltip,
|
||||||
|
Text,
|
||||||
|
IconButton,
|
||||||
|
Icon,
|
||||||
|
IconSrc,
|
||||||
|
Icons,
|
||||||
|
} from 'folds';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
|
||||||
|
export function Sidebar({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Box className={css.Sidebar} shrink="No">
|
||||||
|
<Scroll size="0">
|
||||||
|
<Box className={css.SidebarContent} direction="Column" alignItems="Center" gap="100">
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarStack = as<'div'>(({ className, children, ...props }, ref) => (
|
||||||
|
<Box
|
||||||
|
className={classNames(css.SidebarStack, className)}
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="100"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
));
|
||||||
|
export function SidebarDivider() {
|
||||||
|
return <Line className={css.SidebarDivider} size="300" variant="Surface" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarBtn<T extends string>({
|
||||||
|
active,
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
label: string;
|
||||||
|
id: T;
|
||||||
|
onClick: (id: T) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider
|
||||||
|
delay={500}
|
||||||
|
position="Left"
|
||||||
|
tooltip={
|
||||||
|
<Tooltip id={`SidebarStackItem-${id}-label`}>
|
||||||
|
<Text size="T300">{label}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(ref) => (
|
||||||
|
<IconButton
|
||||||
|
aria-pressed={active}
|
||||||
|
aria-labelledby={`SidebarStackItem-${id}-label`}
|
||||||
|
ref={ref}
|
||||||
|
onClick={() => onClick(id)}
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
variant="Surface"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupIconProps<T extends string> = {
|
||||||
|
active: boolean;
|
||||||
|
id: T;
|
||||||
|
label: string;
|
||||||
|
icon: IconSrc;
|
||||||
|
onClick: (id: T) => void;
|
||||||
|
};
|
||||||
|
export function GroupIcon<T extends string>({
|
||||||
|
active,
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
onClick,
|
||||||
|
}: GroupIconProps<T>) {
|
||||||
|
return (
|
||||||
|
<SidebarBtn active={active} id={id} label={label} onClick={onClick}>
|
||||||
|
<Icon src={icon} filled={active} />
|
||||||
|
</SidebarBtn>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageGroupIconProps<T extends string> = {
|
||||||
|
active: boolean;
|
||||||
|
id: T;
|
||||||
|
label: string;
|
||||||
|
url?: string;
|
||||||
|
onClick: (id: T) => void;
|
||||||
|
};
|
||||||
|
export function ImageGroupIcon<T extends string>({
|
||||||
|
active,
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
url,
|
||||||
|
onClick,
|
||||||
|
}: ImageGroupIconProps<T>) {
|
||||||
|
return (
|
||||||
|
<SidebarBtn active={active} id={id} label={label} onClick={onClick}>
|
||||||
|
{url ? (
|
||||||
|
<img className={css.SidebarBtnImg} src={url} alt={label} />
|
||||||
|
) : (
|
||||||
|
<Icon src={Icons.Photo} filled={active} />
|
||||||
|
)}
|
||||||
|
</SidebarBtn>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/app/components/emoji-board/components/Tabs.tsx
Normal file
44
src/app/components/emoji-board/components/Tabs.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import React, { CSSProperties } from 'react';
|
||||||
|
import { Badge, Box, Text } from 'folds';
|
||||||
|
import { EmojiBoardTab } from '../types';
|
||||||
|
|
||||||
|
const styles: CSSProperties = {
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmojiBoardTabs({
|
||||||
|
tab,
|
||||||
|
onTabChange,
|
||||||
|
}: {
|
||||||
|
tab: EmojiBoardTab;
|
||||||
|
onTabChange: (tab: EmojiBoardTab) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box gap="100">
|
||||||
|
<Badge
|
||||||
|
style={styles}
|
||||||
|
as="button"
|
||||||
|
variant="Secondary"
|
||||||
|
fill={tab === EmojiBoardTab.Sticker ? 'Solid' : 'None'}
|
||||||
|
size="500"
|
||||||
|
onClick={() => onTabChange(EmojiBoardTab.Sticker)}
|
||||||
|
>
|
||||||
|
<Text as="span" size="L400">
|
||||||
|
Sticker
|
||||||
|
</Text>
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
style={styles}
|
||||||
|
as="button"
|
||||||
|
variant="Secondary"
|
||||||
|
fill={tab === EmojiBoardTab.Emoji ? 'Solid' : 'None'}
|
||||||
|
size="500"
|
||||||
|
onClick={() => onTabChange(EmojiBoardTab.Emoji)}
|
||||||
|
>
|
||||||
|
<Text as="span" size="L400">
|
||||||
|
Emoji
|
||||||
|
</Text>
|
||||||
|
</Badge>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/app/components/emoji-board/components/index.tsx
Normal file
8
src/app/components/emoji-board/components/index.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export * from './SearchInput';
|
||||||
|
export * from './Tabs';
|
||||||
|
export * from './Sidebar';
|
||||||
|
export * from './NoStickerPacks';
|
||||||
|
export * from './Preview';
|
||||||
|
export * from './Item';
|
||||||
|
export * from './Group';
|
||||||
|
export * from './Layout';
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
|
import { toRem, color, config, DefaultReset, FocusOutline } from 'folds';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout
|
||||||
|
*/
|
||||||
|
|
||||||
export const Base = style({
|
export const Base = style({
|
||||||
maxWidth: toRem(432),
|
maxWidth: toRem(432),
|
||||||
@@ -13,6 +17,15 @@ export const Base = style({
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const Header = style({
|
||||||
|
padding: config.space.S300,
|
||||||
|
paddingBottom: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar
|
||||||
|
*/
|
||||||
|
|
||||||
export const Sidebar = style({
|
export const Sidebar = style({
|
||||||
width: toRem(54),
|
width: toRem(54),
|
||||||
backgroundColor: color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
@@ -29,26 +42,21 @@ export const SidebarStack = style({
|
|||||||
backgroundColor: color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const NativeEmojiSidebarStack = style({
|
|
||||||
position: 'sticky',
|
|
||||||
bottom: '-67%',
|
|
||||||
zIndex: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SidebarDivider = style({
|
export const SidebarDivider = style({
|
||||||
width: toRem(18),
|
width: toRem(18),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Header = style({
|
export const SidebarBtnImg = style({
|
||||||
padding: config.space.S300,
|
width: toRem(24),
|
||||||
paddingBottom: 0,
|
height: toRem(24),
|
||||||
|
objectFit: 'contain',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const EmojiBoardTab = style({
|
/**
|
||||||
cursor: 'pointer',
|
* Preview
|
||||||
});
|
*/
|
||||||
|
|
||||||
export const Footer = style({
|
export const Preview = style({
|
||||||
padding: config.space.S200,
|
padding: config.space.S200,
|
||||||
margin: config.space.S300,
|
margin: config.space.S300,
|
||||||
marginTop: 0,
|
marginTop: 0,
|
||||||
@@ -59,7 +67,30 @@ export const Footer = style({
|
|||||||
color: color.SurfaceVariant.OnContainer,
|
color: color.SurfaceVariant.OnContainer,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const PreviewEmoji = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
width: toRem(32),
|
||||||
|
height: toRem(32),
|
||||||
|
fontSize: toRem(32),
|
||||||
|
lineHeight: toRem(32),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
export const PreviewImg = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
width: toRem(32),
|
||||||
|
height: toRem(32),
|
||||||
|
objectFit: 'contain',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group
|
||||||
|
*/
|
||||||
|
|
||||||
export const EmojiGroup = style({
|
export const EmojiGroup = style({
|
||||||
|
position: 'relative',
|
||||||
padding: `${config.space.S300} 0`,
|
padding: `${config.space.S300} 0`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,15 +113,9 @@ export const EmojiGroupContent = style([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const EmojiPreview = style([
|
/**
|
||||||
DefaultReset,
|
* Item
|
||||||
{
|
*/
|
||||||
width: toRem(32),
|
|
||||||
height: toRem(32),
|
|
||||||
fontSize: toRem(32),
|
|
||||||
lineHeight: toRem(32),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const EmojiItem = style([
|
export const EmojiItem = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from './EmojiBoard';
|
export * from './EmojiBoard';
|
||||||
|
export * from './types';
|
||||||
|
|||||||
17
src/app/components/emoji-board/types.ts
Normal file
17
src/app/components/emoji-board/types.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export enum EmojiBoardTab {
|
||||||
|
Emoji = 'Emoji',
|
||||||
|
Sticker = 'Sticker',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum EmojiType {
|
||||||
|
Emoji = 'emoji',
|
||||||
|
CustomEmoji = 'customEmoji',
|
||||||
|
Sticker = 'sticker',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmojiItemInfo = {
|
||||||
|
type: EmojiType;
|
||||||
|
data: string;
|
||||||
|
shortcode: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
@@ -27,7 +27,8 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
|
|||||||
|
|
||||||
const [downloadState, download] = useAsyncCallback(
|
const [downloadState, download] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
||||||
|
if (!mediaUrl) throw new Error('Invalid media URL');
|
||||||
const fileContent = encInfo
|
const fileContent = encInfo
|
||||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||||
: await downloadMedia(mediaUrl);
|
: await downloadMedia(mediaUrl);
|
||||||
|
|||||||
@@ -224,6 +224,8 @@ type RenderVideoContentProps = {
|
|||||||
mimeType: string;
|
mimeType: string;
|
||||||
url: string;
|
url: string;
|
||||||
encInfo?: IEncryptedFile;
|
encInfo?: IEncryptedFile;
|
||||||
|
markedAsSpoiler?: boolean;
|
||||||
|
spoilerReason?: string;
|
||||||
};
|
};
|
||||||
type MVideoProps = {
|
type MVideoProps = {
|
||||||
content: IVideoContent;
|
content: IVideoContent;
|
||||||
@@ -274,6 +276,8 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
|||||||
mimeType: safeMimeType,
|
mimeType: safeMimeType,
|
||||||
url: mxcUrl,
|
url: mxcUrl,
|
||||||
encInfo: content.file,
|
encInfo: content.file,
|
||||||
|
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
|
||||||
|
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
|
||||||
})}
|
})}
|
||||||
</AttachmentBox>
|
</AttachmentBox>
|
||||||
</Attachment>
|
</Attachment>
|
||||||
@@ -385,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>
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export function AudioContent({
|
|||||||
|
|
||||||
const [srcState, loadSrc] = useAsyncCallback(
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
||||||
|
if (!mediaUrl) throw new Error('Invalid media URL');
|
||||||
const fileContent = encInfo
|
const fileContent = encInfo
|
||||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||||
: await downloadMedia(mediaUrl);
|
: await downloadMedia(mediaUrl);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Box, Icon, IconSrc } from 'folds';
|
import { Box, Icon, IconSrc } from 'folds';
|
||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import { CompactLayout, ModernLayout } from '..';
|
import { BubbleLayout, CompactLayout, ModernLayout } from '..';
|
||||||
import { MessageLayout } from '../../../state/settings';
|
import { MessageLayout } from '../../../state/settings';
|
||||||
|
|
||||||
export type EventContentProps = {
|
export type EventContentProps = {
|
||||||
@@ -30,9 +30,15 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
return messageLayout === MessageLayout.Compact ? (
|
if (messageLayout === MessageLayout.Compact) {
|
||||||
<CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>
|
return <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>;
|
||||||
) : (
|
}
|
||||||
<ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>
|
if (messageLayout === MessageLayout.Bubble) {
|
||||||
);
|
return (
|
||||||
|
<BubbleLayout hideBubble before={beforeJSX}>
|
||||||
|
{msgContentJSX}
|
||||||
|
</BubbleLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
|
|||||||
|
|
||||||
const [textState, loadText] = useAsyncCallback(
|
const [textState, loadText] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
||||||
|
if (!mediaUrl) throw new Error('Invalid media URL');
|
||||||
const fileContent = encInfo
|
const fileContent = encInfo
|
||||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||||
: await downloadMedia(mediaUrl);
|
: await downloadMedia(mediaUrl);
|
||||||
@@ -176,7 +177,8 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
|
|||||||
|
|
||||||
const [pdfState, loadPdf] = useAsyncCallback(
|
const [pdfState, loadPdf] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
||||||
|
if (!mediaUrl) throw new Error('Invalid media URL');
|
||||||
const fileContent = encInfo
|
const fileContent = encInfo
|
||||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||||
: await downloadMedia(mediaUrl);
|
: await downloadMedia(mediaUrl);
|
||||||
@@ -253,7 +255,8 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
|
|||||||
|
|
||||||
const [downloadState, download] = useAsyncCallback(
|
const [downloadState, download] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
||||||
|
if (!mediaUrl) throw new Error('Invalid media URL');
|
||||||
const fileContent = encInfo
|
const fileContent = encInfo
|
||||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||||
: await downloadMedia(mediaUrl);
|
: await downloadMedia(mediaUrl);
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
|
|
||||||
const [srcState, loadSrc] = useAsyncCallback(
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
||||||
|
if (!mediaUrl) throw new Error('Invalid media URL');
|
||||||
if (encInfo) {
|
if (encInfo) {
|
||||||
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||||
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo)
|
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo)
|
||||||
@@ -214,7 +215,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
)}
|
)}
|
||||||
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
|
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
|
||||||
!load &&
|
!load &&
|
||||||
!markedAsSpoiler && (
|
!blurred && (
|
||||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||||
<Spinner variant="Secondary" />
|
<Spinner variant="Secondary" />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
|
|||||||
throw new Error('Failed to load thumbnail');
|
throw new Error('Failed to load thumbnail');
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication) ?? thumbMxcUrl;
|
const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication);
|
||||||
|
if (!mediaUrl) throw new Error('Invalid media URL');
|
||||||
if (encInfo) {
|
if (encInfo) {
|
||||||
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||||
decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo)
|
decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Chip,
|
||||||
Icon,
|
Icon,
|
||||||
Icons,
|
Icons,
|
||||||
Spinner,
|
Spinner,
|
||||||
@@ -47,6 +48,8 @@ type VideoContentProps = {
|
|||||||
info: IVideoInfo & IThumbnailContent;
|
info: IVideoInfo & IThumbnailContent;
|
||||||
encInfo?: EncryptedAttachmentInfo;
|
encInfo?: EncryptedAttachmentInfo;
|
||||||
autoPlay?: boolean;
|
autoPlay?: boolean;
|
||||||
|
markedAsSpoiler?: boolean;
|
||||||
|
spoilerReason?: string;
|
||||||
renderThumbnail?: () => ReactNode;
|
renderThumbnail?: () => ReactNode;
|
||||||
renderVideo: (props: RenderVideoProps) => ReactNode;
|
renderVideo: (props: RenderVideoProps) => ReactNode;
|
||||||
};
|
};
|
||||||
@@ -60,6 +63,8 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
info,
|
info,
|
||||||
encInfo,
|
encInfo,
|
||||||
autoPlay,
|
autoPlay,
|
||||||
|
markedAsSpoiler,
|
||||||
|
spoilerReason,
|
||||||
renderThumbnail,
|
renderThumbnail,
|
||||||
renderVideo,
|
renderVideo,
|
||||||
...props
|
...props
|
||||||
@@ -72,10 +77,12 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
|
|
||||||
const [load, setLoad] = useState(false);
|
const [load, setLoad] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||||
|
|
||||||
const [srcState, loadSrc] = useAsyncCallback(
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
||||||
|
if (!mediaUrl) throw new Error('Invalid media URL');
|
||||||
const fileContent = encInfo
|
const fileContent = encInfo
|
||||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||||
decryptFile(encBuf, mimeType, encInfo)
|
decryptFile(encBuf, mimeType, encInfo)
|
||||||
@@ -114,11 +121,15 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{renderThumbnail && !load && (
|
{renderThumbnail && !load && (
|
||||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
<Box
|
||||||
|
className={classNames(css.AbsoluteContainer, blurred && css.Blur)}
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
>
|
||||||
{renderThumbnail()}
|
{renderThumbnail()}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{!autoPlay && srcState.status === AsyncStatus.Idle && (
|
{!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (
|
||||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||||
<Button
|
<Button
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
@@ -133,7 +144,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Box className={css.AbsoluteContainer}>
|
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||||
{renderVideo({
|
{renderVideo({
|
||||||
title: body,
|
title: body,
|
||||||
src: srcState.data,
|
src: srcState.data,
|
||||||
@@ -144,8 +155,39 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
{blurred && !error && srcState.status !== AsyncStatus.Error && (
|
||||||
|
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||||
|
<TooltipProvider
|
||||||
|
tooltip={
|
||||||
|
typeof spoilerReason === 'string' && (
|
||||||
|
<Tooltip variant="Secondary">
|
||||||
|
<Text>{spoilerReason}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
position="Top"
|
||||||
|
align="Center"
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<Chip
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
size="500"
|
||||||
|
outlined
|
||||||
|
onClick={() => {
|
||||||
|
setBlurred(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Spoiler</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
|
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
|
||||||
!load && (
|
!load &&
|
||||||
|
!blurred && (
|
||||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||||
<Spinner variant="Secondary" />
|
<Spinner variant="Secondary" />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,18 +1,63 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import { Box, as } from 'folds';
|
import classNames from 'classnames';
|
||||||
|
import { Box, ContainerColor, as, color } from 'folds';
|
||||||
import * as css from './layout.css';
|
import * as css from './layout.css';
|
||||||
|
|
||||||
|
type BubbleArrowProps = {
|
||||||
|
variant: ContainerColor;
|
||||||
|
};
|
||||||
|
function BubbleLeftArrow({ variant }: BubbleArrowProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={css.BubbleLeftArrow}
|
||||||
|
width="9"
|
||||||
|
height="8"
|
||||||
|
viewBox="0 0 9 8"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M9.00004 8V0H4.82847C3.04666 0 2.15433 2.15428 3.41426 3.41421L8.00004 8H9.00004Z"
|
||||||
|
fill={color[variant].Container}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type BubbleLayoutProps = {
|
type BubbleLayoutProps = {
|
||||||
|
hideBubble?: boolean;
|
||||||
before?: ReactNode;
|
before?: ReactNode;
|
||||||
|
header?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BubbleLayout = as<'div', BubbleLayoutProps>(({ before, children, ...props }, ref) => (
|
export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
||||||
<Box gap="300" {...props} ref={ref}>
|
({ hideBubble, before, header, children, ...props }, ref) => (
|
||||||
<Box className={css.BubbleBefore} shrink="No">
|
<Box gap="300" {...props} ref={ref}>
|
||||||
{before}
|
<Box className={css.BubbleBefore} shrink="No">
|
||||||
|
{before}
|
||||||
|
</Box>
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
{header}
|
||||||
|
{hideBubble ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
<Box
|
||||||
|
className={
|
||||||
|
hideBubble
|
||||||
|
? undefined
|
||||||
|
: classNames(css.BubbleContent, before ? css.BubbleContentArrowLeft : undefined)
|
||||||
|
}
|
||||||
|
direction="Column"
|
||||||
|
>
|
||||||
|
{before ? <BubbleLeftArrow variant="SurfaceVariant" /> : null}
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box className={css.BubbleContent} direction="Column">
|
)
|
||||||
{children}
|
);
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
));
|
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export const CompactHeader = style([
|
|||||||
export const AvatarBase = style({
|
export const AvatarBase = style({
|
||||||
paddingTop: toRem(4),
|
paddingTop: toRem(4),
|
||||||
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
|
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
|
||||||
|
display: 'flex',
|
||||||
alignSelf: 'start',
|
alignSelf: 'start',
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
@@ -133,14 +134,31 @@ export const ModernBefore = style({
|
|||||||
minWidth: toRem(36),
|
minWidth: toRem(36),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const BubbleBefore = style([ModernBefore]);
|
export const BubbleBefore = style({
|
||||||
|
minWidth: toRem(36),
|
||||||
|
});
|
||||||
|
|
||||||
export const BubbleContent = style({
|
export const BubbleContent = style({
|
||||||
maxWidth: toRem(800),
|
maxWidth: toRem(800),
|
||||||
padding: config.space.S200,
|
padding: config.space.S200,
|
||||||
backgroundColor: color.SurfaceVariant.Container,
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
color: color.SurfaceVariant.OnContainer,
|
color: color.SurfaceVariant.OnContainer,
|
||||||
borderRadius: config.radii.R400,
|
borderRadius: config.radii.R500,
|
||||||
|
position: 'relative',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BubbleContentArrowLeft = style({
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BubbleLeftArrow = style({
|
||||||
|
width: toRem(9),
|
||||||
|
height: toRem(8),
|
||||||
|
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: toRem(-8),
|
||||||
|
zIndex: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Username = style({
|
export const Username = style({
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -26,7 +26,12 @@ export function SSOStage({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMessage = (evt: MessageEvent) => {
|
const handleMessage = (evt: MessageEvent) => {
|
||||||
if (ssoWindow && evt.data === 'authDone' && evt.source === ssoWindow) {
|
if (
|
||||||
|
evt.origin === new URL(ssoRedirectURL).origin &&
|
||||||
|
ssoWindow &&
|
||||||
|
evt.data === 'authDone' &&
|
||||||
|
evt.source === ssoWindow
|
||||||
|
) {
|
||||||
ssoWindow.close();
|
ssoWindow.close();
|
||||||
setSSOWindow(undefined);
|
setSSOWindow(undefined);
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
@@ -37,7 +42,7 @@ export function SSOStage({
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('message', handleMessage);
|
window.removeEventListener('message', handleMessage);
|
||||||
};
|
};
|
||||||
}, [ssoWindow, handleSubmit]);
|
}, [ssoWindow, handleSubmit, ssoRedirectURL]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { ReactNode, useEffect } from 'react';
|
||||||
import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
|
import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
|
||||||
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
|
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
|
||||||
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
||||||
@@ -13,8 +13,54 @@ import {
|
|||||||
import { useObjectURL } from '../../hooks/useObjectURL';
|
import { useObjectURL } from '../../hooks/useObjectURL';
|
||||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||||
|
|
||||||
type ImagePreviewProps = { fileItem: TUploadItem; onSpoiler: (marked: boolean) => void };
|
type PreviewImageProps = {
|
||||||
function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
|
fileItem: TUploadItem;
|
||||||
|
};
|
||||||
|
function PreviewImage({ fileItem }: PreviewImageProps) {
|
||||||
|
const { originalFile, metadata } = fileItem;
|
||||||
|
const fileUrl = useObjectURL(originalFile);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
style={{
|
||||||
|
objectFit: 'contain',
|
||||||
|
width: '100%',
|
||||||
|
height: toRem(152),
|
||||||
|
filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
|
||||||
|
}}
|
||||||
|
alt={originalFile.name}
|
||||||
|
src={fileUrl}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreviewVideoProps = {
|
||||||
|
fileItem: TUploadItem;
|
||||||
|
};
|
||||||
|
function PreviewVideo({ fileItem }: PreviewVideoProps) {
|
||||||
|
const { originalFile, metadata } = fileItem;
|
||||||
|
const fileUrl = useObjectURL(originalFile);
|
||||||
|
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||||
|
<video
|
||||||
|
style={{
|
||||||
|
objectFit: 'contain',
|
||||||
|
width: '100%',
|
||||||
|
height: toRem(152),
|
||||||
|
filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
|
||||||
|
}}
|
||||||
|
src={fileUrl}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaPreviewProps = {
|
||||||
|
fileItem: TUploadItem;
|
||||||
|
onSpoiler: (marked: boolean) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
function MediaPreview({ fileItem, onSpoiler, children }: MediaPreviewProps) {
|
||||||
const { originalFile, metadata } = fileItem;
|
const { originalFile, metadata } = fileItem;
|
||||||
const fileUrl = useObjectURL(originalFile);
|
const fileUrl = useObjectURL(originalFile);
|
||||||
|
|
||||||
@@ -27,16 +73,7 @@ function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
{children}
|
||||||
style={{
|
|
||||||
objectFit: 'contain',
|
|
||||||
width: '100%',
|
|
||||||
height: toRem(152),
|
|
||||||
filter: fileItem.metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
|
|
||||||
}}
|
|
||||||
src={fileUrl}
|
|
||||||
alt={originalFile.name}
|
|
||||||
/>
|
|
||||||
<Box
|
<Box
|
||||||
justifyContent="End"
|
justifyContent="End"
|
||||||
style={{
|
style={{
|
||||||
@@ -136,7 +173,14 @@ export function UploadCardRenderer({
|
|||||||
bottom={
|
bottom={
|
||||||
<>
|
<>
|
||||||
{fileItem.originalFile.type.startsWith('image') && (
|
{fileItem.originalFile.type.startsWith('image') && (
|
||||||
<ImagePreview fileItem={fileItem} onSpoiler={handleSpoiler} />
|
<MediaPreview fileItem={fileItem} onSpoiler={handleSpoiler}>
|
||||||
|
<PreviewImage fileItem={fileItem} />
|
||||||
|
</MediaPreview>
|
||||||
|
)}
|
||||||
|
{fileItem.originalFile.type.startsWith('video') && (
|
||||||
|
<MediaPreview fileItem={fileItem} onSpoiler={handleSpoiler}>
|
||||||
|
<PreviewVideo fileItem={fileItem} />
|
||||||
|
</MediaPreview>
|
||||||
)}
|
)}
|
||||||
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
|
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
|
||||||
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
|
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
|
||||||
|
|||||||
@@ -30,7 +30,15 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||||||
if (previewStatus.status === AsyncStatus.Error) return null;
|
if (previewStatus.status === AsyncStatus.Error) return null;
|
||||||
|
|
||||||
const renderContent = (prev: IPreviewUrlResponse) => {
|
const renderContent = (prev: IPreviewUrlResponse) => {
|
||||||
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication, 256, 256, 'scale', false);
|
const imgUrl = mxcUrlToHttp(
|
||||||
|
mx,
|
||||||
|
prev['og:image'] || '',
|
||||||
|
useAuthentication,
|
||||||
|
256,
|
||||||
|
256,
|
||||||
|
'scale',
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -42,7 +50,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||||||
as="a"
|
as="a"
|
||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="no-referrer"
|
rel="noreferrer"
|
||||||
size="T200"
|
size="T200"
|
||||||
priority="300"
|
priority="300"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -329,7 +329,7 @@ function LocalAddressesList({
|
|||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selected}
|
checked={selected}
|
||||||
onChange={() => toggleSelect(alias)}
|
onClick={() => toggleSelect(alias)}
|
||||||
size="50"
|
size="50"
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
|
|||||||
import { useRoom } from '../../../hooks/useRoom';
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
import { useRoomMembers } from '../../../hooks/useRoomMembers';
|
import { useRoomMembers } from '../../../hooks/useRoomMembers';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
import { useGetMemberPowerLevel, usePowerLevels } from '../../../hooks/usePowerLevels';
|
||||||
import { VirtualTile } from '../../../components/virtualizer';
|
import { VirtualTile } from '../../../components/virtualizer';
|
||||||
import { MemberTile } from '../../../components/member-tile';
|
import { MemberTile } from '../../../components/member-tile';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
@@ -87,12 +87,13 @@ export function Members({ requestClose }: MembersProps) {
|
|||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
const creators = useRoomCreators(room);
|
const creators = useRoomCreators(room);
|
||||||
const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
|
const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
|
||||||
|
const getPowerLevel = useGetMemberPowerLevel(powerLevels);
|
||||||
|
|
||||||
const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
|
const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
|
||||||
const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
|
const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
|
||||||
const membershipFilter = useMembershipFilter(membershipFilterIndex, useMembershipFilterMenu());
|
const membershipFilter = useMembershipFilter(membershipFilterIndex, useMembershipFilterMenu());
|
||||||
const memberSort = useMemberSort(sortFilterIndex, useMemberSortMenu());
|
const memberSort = useMemberSort(sortFilterIndex, useMemberSortMenu());
|
||||||
const memberPowerSort = useMemberPowerSort(creators);
|
const memberPowerSort = useMemberPowerSort(creators, getPowerLevel);
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -183,7 +218,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||||||
onClick={() => setAdvance(!advance)}
|
onClick={() => setAdvance(!advance)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Text size="T200">Advance Options</Text>
|
<Text size="T200">Advanced Options</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -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">
|
||||||
@@ -184,7 +184,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
|
|||||||
onClick={() => setAdvance(!advance)}
|
onClick={() => setAdvance(!advance)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Text size="T200">Advance Options</Text>
|
<Text size="T200">Advanced Options</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function General({ requestClose }: GeneralProps) {
|
|||||||
<RoomLocalAddresses permissions={permissions} />
|
<RoomLocalAddresses permissions={permissions} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Advance Options</Text>
|
<Text size="L400">Advanced Options</Text>
|
||||||
<RoomUpgrade permissions={permissions} requestClose={requestClose} />
|
<RoomUpgrade permissions={permissions} requestClose={requestClose} />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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: [
|
||||||
@@ -177,6 +190,13 @@ export const usePermissionGroups = (): PermissionGroup[] => {
|
|||||||
const otherSettingsGroup: PermissionGroup = {
|
const otherSettingsGroup: PermissionGroup = {
|
||||||
name: 'Other',
|
name: 'Other',
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
location: {
|
||||||
|
state: true,
|
||||||
|
key: StateEvent.PoniesRoomEmotes,
|
||||||
|
},
|
||||||
|
name: 'Manage Emojis & Stickers',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
location: {
|
location: {
|
||||||
state: true,
|
state: true,
|
||||||
@@ -196,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
|
|||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useMembershipFilter, useMembershipFilterMenu } from '../../hooks/useMemberFilter';
|
import { useMembershipFilter, useMembershipFilterMenu } from '../../hooks/useMemberFilter';
|
||||||
import { useMemberPowerSort, useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort';
|
import { useMemberPowerSort, useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { useGetMemberPowerLevel, usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
import { MembershipFilterMenu } from '../../components/MembershipFilterMenu';
|
import { MembershipFilterMenu } from '../../components/MembershipFilterMenu';
|
||||||
import { MemberSortMenu } from '../../components/MemberSortMenu';
|
import { MemberSortMenu } from '../../components/MemberSortMenu';
|
||||||
import { useOpenUserRoomProfile, useUserRoomProfileState } from '../../state/hooks/userRoomProfile';
|
import { useOpenUserRoomProfile, useUserRoomProfileState } from '../../state/hooks/userRoomProfile';
|
||||||
@@ -185,6 +185,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
|||||||
const powerLevels = usePowerLevelsContext();
|
const powerLevels = usePowerLevelsContext();
|
||||||
const creators = useRoomCreators(room);
|
const creators = useRoomCreators(room);
|
||||||
const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
|
const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
|
||||||
|
const getPowerLevel = useGetMemberPowerLevel(powerLevels);
|
||||||
|
|
||||||
const fetchingMembers = members.length < room.getJoinedMemberCount();
|
const fetchingMembers = members.length < room.getJoinedMemberCount();
|
||||||
const openUserRoomProfile = useOpenUserRoomProfile();
|
const openUserRoomProfile = useOpenUserRoomProfile();
|
||||||
@@ -198,7 +199,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
|||||||
|
|
||||||
const membershipFilter = useMembershipFilter(membershipFilterIndex, membershipFilterMenu);
|
const membershipFilter = useMembershipFilter(membershipFilterIndex, membershipFilterMenu);
|
||||||
const memberSort = useMemberSort(sortFilterIndex, sortFilterMenu);
|
const memberSort = useMemberSort(sortFilterIndex, sortFilterMenu);
|
||||||
const memberPowerSort = useMemberPowerSort(creators);
|
const memberPowerSort = useMemberPowerSort(creators, getPowerLevel);
|
||||||
|
|
||||||
const typingMembers = useRoomTypingMember(room.roomId);
|
const typingMembers = useRoomTypingMember(room.roomId);
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ import { useRoomCreators } from '../../hooks/useRoomCreators';
|
|||||||
import { useTheme } from '../../hooks/useTheme';
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||||
|
import { useComposingCheck } from '../../hooks/useComposingCheck';
|
||||||
|
|
||||||
interface RoomInputProps {
|
interface RoomInputProps {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
@@ -217,8 +218,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
|
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
|
||||||
const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500);
|
const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500);
|
||||||
|
|
||||||
|
const isComposing = useComposingCheck();
|
||||||
|
|
||||||
useElementSizeObserver(
|
useElementSizeObserver(
|
||||||
useCallback(() => document.body, []),
|
useCallback(() => fileDropContainerRef.current, [fileDropContainerRef]),
|
||||||
useCallback((width) => setHideStickerBtn(width < 500), [])
|
useCallback((width) => setHideStickerBtn(width < 500), [])
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -380,7 +383,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
(evt) => {
|
(evt) => {
|
||||||
if (
|
if (
|
||||||
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
|
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
|
||||||
!evt.nativeEvent.isComposing
|
!isComposing(evt)
|
||||||
) {
|
) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
submit();
|
submit();
|
||||||
@@ -394,7 +397,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[submit, setReplyDraft, enterForNewline, autocompleteQuery]
|
[submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyUp: KeyboardEventHandler = useCallback(
|
const handleKeyUp: KeyboardEventHandler = useCallback(
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -471,6 +472,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const permissions = useRoomPermissions(creators, powerLevels);
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
|
|
||||||
const canRedact = permissions.action('redact', mx.getSafeUserId());
|
const canRedact = permissions.action('redact', mx.getSafeUserId());
|
||||||
|
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
|
||||||
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
|
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
|
||||||
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
|
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
|
||||||
const [editId, setEditId] = useState<string>();
|
const [editId, setEditId] = useState<string>();
|
||||||
@@ -1047,7 +1049,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
edit={editId === mEventId}
|
edit={editId === mEventId}
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||||
canSendReaction={canSendReaction}
|
canSendReaction={canSendReaction}
|
||||||
canPinEvent={canPinEvent}
|
canPinEvent={canPinEvent}
|
||||||
imagePackRooms={imagePackRooms}
|
imagePackRooms={imagePackRooms}
|
||||||
@@ -1129,7 +1131,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
edit={editId === mEventId}
|
edit={editId === mEventId}
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||||
canSendReaction={canSendReaction}
|
canSendReaction={canSendReaction}
|
||||||
canPinEvent={canPinEvent}
|
canPinEvent={canPinEvent}
|
||||||
imagePackRooms={imagePackRooms}
|
imagePackRooms={imagePackRooms}
|
||||||
@@ -1247,7 +1249,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||||
canSendReaction={canSendReaction}
|
canSendReaction={canSendReaction}
|
||||||
canPinEvent={canPinEvent}
|
canPinEvent={canPinEvent}
|
||||||
imagePackRooms={imagePackRooms}
|
imagePackRooms={imagePackRooms}
|
||||||
@@ -1468,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;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user