Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9253fc33fd | |||
| bd9dbb5e83 | |||
| 70eb0edc47 | |||
| 75a05cf83d | |||
| 44b48b05b4 | |||
| 9f6220b1bb | |||
| 20abfc0342 | |||
| 94722c8a97 | |||
| 69091bc055 | |||
| c37220eb21 | |||
| f3c2babd4b | |||
| 7b5fbb7e3b | |||
| 109eac91f9 | |||
| a23851d4a6 | |||
| 2dfdda5d8c | |||
| cfe52d623a | |||
| 01781554a2 | |||
| c6b1a9d75f | |||
| 9ebce5b00c | |||
| 185eb160e7 | |||
| 2e12c742fb | |||
| f2bcd65a9b | |||
| 77f0c0d4ca | |||
| 13df48c658 | |||
| 9b68b4ae53 | |||
| f914b59c07 | |||
| 1d086dda77 | |||
| 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 |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": [
|
||||
"matrix.lotusguild.org"
|
||||
],
|
||||
"allowCustomHomeservers": false,
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [],
|
||||
"rooms": [],
|
||||
"servers": []
|
||||
},
|
||||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": [
|
||||
"matrix.lotusguild.org"
|
||||
],
|
||||
"allowCustomHomeservers": false,
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [],
|
||||
"rooms": [],
|
||||
"servers": []
|
||||
},
|
||||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended", ":dependencyDashboardApproval"],
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
":dependencyDashboardApproval",
|
||||
":semanticCommits"
|
||||
],
|
||||
"labels": ["Dependencies"],
|
||||
"packageRules": [
|
||||
{
|
||||
|
||||
@@ -12,12 +12,12 @@ jobs:
|
||||
PR_NUMBER: ${{github.event.number}}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4.4.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 20.12.2
|
||||
cache: 'npm'
|
||||
node-version-file: ".node-version"
|
||||
package-manager-cache: false
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build app
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run build
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: preview
|
||||
path: dist
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
- name: Save pr number
|
||||
run: echo ${PR_NUMBER} > ./pr.txt
|
||||
- name: Upload pr number
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: pr
|
||||
path: ./pr.txt
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
- 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'
|
||||
# Beta Release
|
||||
uses: cla-assistant/github-action@v2.6.1
|
||||
uses: cla-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# the below token should have repo scope and must be manually added by you in the repository's secret
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
name: Deploy PR to Netlify
|
||||
run-name: "Deploy PR to Netlify (${{ github.event.workflow_run.head_branch }})"
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
@@ -15,7 +16,7 @@ jobs:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Download pr number
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
with:
|
||||
workflow: ${{ github.event.workflow.id }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
@@ -24,7 +25,7 @@ jobs:
|
||||
id: pr
|
||||
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
|
||||
- name: Download artifact
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
with:
|
||||
workflow: ${{ github.event.workflow.id }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
@@ -32,7 +33,7 @@ jobs:
|
||||
path: dist
|
||||
- name: Deploy to Netlify
|
||||
id: netlify
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}"
|
||||
@@ -45,12 +46,12 @@ jobs:
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
|
||||
timeout-minutes: 1
|
||||
- name: Comment preview on PR
|
||||
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6
|
||||
uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b #v3.0.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
pr_number: ${{ steps.pr.outputs.id }}
|
||||
comment_tag: ${{ steps.pr.outputs.id }}
|
||||
pr-number: ${{ steps.pr.outputs.id }}
|
||||
comment-tag: ${{ steps.pr.outputs.id }}
|
||||
message: |
|
||||
Preview: ${{ steps.netlify.outputs.deploy-url }}
|
||||
⚠️ Exercise caution. Use test accounts. ⚠️
|
||||
⚠️ Exercise caution. Use test accounts. ⚠️
|
||||
|
||||
@@ -5,15 +5,59 @@ on:
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
- '.github/workflows/docker-pr.yml'
|
||||
- '.github/workflows/prod-deploy.yml'
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.0
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6.18.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Login to Docker Hub #Do not update this action from a outside PR
|
||||
if: github.event.pull_request.head.repo.fork == false
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Login to the Github Container registry #Do not update this action from a outside PR
|
||||
if: github.event.pull_request.head.repo.fork == false
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker, GHCR
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
ajbura/cinny
|
||||
ghcr.io/${{ github.repository }}
|
||||
|
||||
- name: Build Docker image (no push)
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Show Docker images
|
||||
run: docker images
|
||||
|
||||
@@ -14,9 +14,9 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.2.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: NPM Lockfile Changes
|
||||
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891
|
||||
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891 # v1.0.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Optional inputs, can be deleted safely if you are happy with default values.
|
||||
|
||||
@@ -11,12 +11,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4.4.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 20.12.2
|
||||
cache: 'npm'
|
||||
node-version-file: ".node-version"
|
||||
package-manager-cache: false
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build app
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run build
|
||||
- name: Deploy to Netlify
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: 'Dev deploy ${{ github.sha }}'
|
||||
|
||||
@@ -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 }}
|
||||
@@ -10,12 +10,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4.4.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 20.12.2
|
||||
cache: 'npm'
|
||||
node-version-file: ".node-version"
|
||||
package-manager-cache: false
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build app
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run build
|
||||
- name: Deploy to Netlify
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: 'Prod deploy ${{ github.ref_name }}'
|
||||
@@ -52,45 +52,45 @@ jobs:
|
||||
gpg --export | xxd -p
|
||||
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||
- name: Upload tagged release
|
||||
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8
|
||||
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
|
||||
with:
|
||||
files: |
|
||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
|
||||
|
||||
publish-image:
|
||||
name: Push Docker image to Docker Hub, ghcr
|
||||
name: Push Docker image to Docker Hub, GHCR
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- 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
|
||||
uses: docker/setup-buildx-action@v3.10.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3.5.0
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
- name: Login to Docker Hub #Do not update this action from a outside PR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to the Container registry
|
||||
uses: docker/login-action@v3.5.0
|
||||
- name: Login to the Github Container registry #Do not update this action from a outside PR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
- name: Extract metadata (tags, labels) for Docker, GHCR
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5.8.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_USERNAME }}/cinny
|
||||
ghcr.io/${{ github.repository }}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6.18.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
24.13.1
|
||||
@@ -1,5 +1,5 @@
|
||||
## Builder
|
||||
FROM node:20.12.2-alpine3.18 as builder
|
||||
FROM node:24.13.1-alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
@@ -11,7 +11,7 @@ RUN npm run build
|
||||
|
||||
|
||||
## App
|
||||
FROM nginx:1.29.0-alpine
|
||||
FROM nginx:1.29.5-alpine
|
||||
|
||||
COPY --from=builder /src/dist /app
|
||||
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
@@ -1,111 +1,62 @@
|
||||
# Cinny
|
||||
<p>
|
||||
<a href="https://github.com/ajbura/cinny/releases">
|
||||
<img alt="GitHub release downloads" src="https://img.shields.io/github/downloads/ajbura/cinny/total?logo=github&style=social"></a>
|
||||
<a href="https://hub.docker.com/r/ajbura/cinny">
|
||||
<img alt="DockerHub downloads" src="https://img.shields.io/docker/pulls/ajbura/cinny?logo=docker&style=social"></a>
|
||||
<a href="https://fosstodon.org/@cinnyapp">
|
||||
<img alt="Follow on Mastodon" src="https://img.shields.io/mastodon/follow/106845779685925461?domain=https%3A%2F%2Ffosstodon.org&logo=mastodon&style=social"></a>
|
||||
<a href="https://twitter.com/intent/follow?screen_name=cinnyapp">
|
||||
<img alt="Follow on Twitter" src="https://img.shields.io/twitter/follow/cinnyapp?logo=twitter&style=social"></a>
|
||||
<a href="https://cinny.in/#sponsor">
|
||||
<img alt="Sponsor Cinny" src="https://img.shields.io/opencollective/all/cinny?logo=opencollective&style=social"></a>
|
||||
</p>
|
||||
# Lotus Chat
|
||||
|
||||
A Matrix client focusing primarily on simple, elegant and secure interface. The main goal is to have an instant messaging application that is easy on people and has a modern touch.
|
||||
- [Roadmap](https://github.com/orgs/cinnyapp/projects/1)
|
||||
- [Contributing](./CONTRIBUTING.md)
|
||||
A Matrix client for [Lotus Guild](https://lotusguild.org) — forked from [Cinny](https://github.com/cinnyapp/cinny) v4.11.1.
|
||||
|
||||
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380">
|
||||
Deployed at [chat.lotusguild.org](https://chat.lotusguild.org).
|
||||
|
||||
## Getting started
|
||||
The web app is available at [app.cinny.in](https://app.cinny.in/) and gets updated on each new release. The `dev` branch is continuously deployed at [dev.cinny.in](https://dev.cinny.in) but keep in mind that it could have things broken.
|
||||
---
|
||||
|
||||
You can also download our desktop app from the [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop).
|
||||
## Changes from upstream Cinny
|
||||
|
||||
## Self-hosting
|
||||
To host Cinny on your own, simply download the tarball from [GitHub releases](https://github.com/cinnyapp/cinny/releases/latest), and serve the files from `dist/` using your preferred webserver. Alternatively, you can just pull the docker image from [DockerHub](https://hub.docker.com/r/ajbura/cinny) or [GitHub Container Registry](https://github.com/cinnyapp/cinny/pkgs/container/cinny).
|
||||
### Branding & Identity
|
||||
|
||||
* The default homeservers and explore pages are defined in [`config.json`](config.json).
|
||||
- Package renamed to `lotus-chat`, description updated to "Lotus Chat — Matrix client for Lotus Guild"
|
||||
- App title changed from "Cinny" to "Lotus Chat" throughout
|
||||
- Favicon, PWA icons, and all icon sizes (57×57 → 180×180 Apple touch icons) replaced with Lotus.png variants
|
||||
- Logo in About dialog and Auth page replaced with official Lotus.png
|
||||
- Auth footer rewritten: shows dynamic version from `package.json`, links to lotusguild.org, chat.lotusguild.org, and matrix.lotusguild.org
|
||||
- Welcome page tagline changed from "Yet another matrix client" to "A Matrix client for Lotus Guild"
|
||||
- Encryption key export filename changed from `cinny-keys.txt` to `lotus-keys.txt`
|
||||
- `manifest.json` updated with Lotus name, description, and branding colors
|
||||
|
||||
* You need to set up redirects to serve the assests. Example configurations; [netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf), [caddy](contrib/caddy/caddyfile).
|
||||
* If you have trouble configuring redirects you can [enable hash routing](config.json#L35) — the url in the browser will have a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/` instead of `app.cinny.in/home/`) but you won't have to configure your webserver.
|
||||
### LotusGuild Terminal Design System (TDS) v1.2
|
||||
|
||||
* To deploy on subdirectory, you need to rebuild the app youself after updating the `base` path in [`build.config.ts`](build.config.ts).
|
||||
* For example, if you want to deploy on `https://cinny.in/app`, then set `base: '/app'`.
|
||||
A full custom theme engine layered on top of Cinny's vanilla-extract theming:
|
||||
|
||||
<details><summary><b>PGP Public Key to verify tarball</b></summary>
|
||||
**Dark mode** (`LotusTerminalTheme`):
|
||||
- CRT terminal aesthetic: scanline overlay, vignette, phosphor glow
|
||||
- Palette: bg `#030508`, orange `#FF6B00`, cyan `#00D4FF`, green `#00FF88`, text `#c4d9ee`
|
||||
- Monospace font stack, terminal-style scrollbars
|
||||
- Custom hex-grid and circuit-board CSS background patterns
|
||||
- Matrix-style boot messages on the welcome page
|
||||
- CSS variables: `--lt-*` family covering colors, glow effects, borders, animations
|
||||
|
||||
```
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
**Light mode** (`LotusTerminalLightTheme`):
|
||||
- Full light palette: bg `#edf0f5`, orange `#c44e00`, cyan `#0062b8`, green `#006d35`, text `#111827`
|
||||
- No CRT effects (scanlines, vignette disabled)
|
||||
- Light-mode scrollbars, adjusted code block colors, semantic color overrides
|
||||
- Scoped to `html[data-theme="light"] body.lotusTerminalBodyClass`
|
||||
- `ThemeManager.tsx` sets `data-theme` attribute based on active theme kind
|
||||
|
||||
mQGNBGJw/g0BDAC8qQeLqDMzYzfPyOmRlHVEoguVTo+eo1aVdQH2X7OELdjjBlyj
|
||||
6d6c1adv/uF2g83NNMoQY7GEeHjRnXE4m8kYSaarb840pxrYUagDc0dAbJOGaCBY
|
||||
FKTo7U1Kvg0vdiaRuus0pvc1NVdXSxRNQbFXBSwduD+zn66TI3HfcEHNN62FG1cE
|
||||
K1jWDwLAU0P3kKmj8+CAc3h9ZklPu0k/+t5bf/LJkvdBJAUzGZpehbPL5f3u3BZ0
|
||||
leZLIrR8uV7PiV5jKFahxlKR5KQHld8qQm+qVhYbUzpuMBGmh419I6UvTzxuRcvU
|
||||
Frn9ttCEzV55Y+so4X2e4ZnB+5gOnNw+ecifGVdj/+UyWnqvqqDvLrEjjK890nLb
|
||||
Pil4siecNMEpiwAN6WSmKpWaCwQAHEGDVeZCc/kT0iYfj5FBcsTVqWiO6eaxkUlm
|
||||
jnulqWqRrlB8CJQQvih/g//uSEBdzIibo+ro+3Jpe120U/XVUH62i9HoRQEm6ADG
|
||||
4zS5hIq4xyA8fL8AEQEAAbQdQ2lubnlBcHAgPGNpbm55YXBwQGdtYWlsLmNvbT6J
|
||||
AdQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQSRri2MHidaaZv+
|
||||
vvuUMwx6UK/M8wUCZqEDwAUJFvwIswAKCRCUMwx6UK/M877qC/4lxXOQIoWnLLkK
|
||||
YiRCTkGsH6NdxgeYr6wpXT4xuQ45ZxCytwHpOGQmO/5up5961TxWW8D1frRIJHjj
|
||||
AZGoRCL3EKEuY8nt3D99fpf3DvZrs1uoVAhiyn737hRlZAg+QsJheeGCmdSJ0hX5
|
||||
Yud8SE+9zxLS1+CEjMrsUd/RGre/phme+wNXfaHfREAC9ewolgVChPIbMxG2f+vs
|
||||
K8Xv52BFng7ta9fgsl1XuOjpuaSbQv6g+4ONk/lxKF0SmnhEGM3dmIYPONxW47Yf
|
||||
atnIjRra/YhPTNwrNBGMmG4IFKaOsMbjW/eakjWTWOVKKJNBMoDdRcYYWIMCpLy8
|
||||
AQUrMtQEsHSnqCwrw818S5A6rrhcfVGk36RGm0nOy6LS5g5jmqaYsvbCcBGY9B2c
|
||||
SUAVNm17oo7TtEajk8hcSXoZod1t++pyjcVKEmSn3nFK7v5m3V+cPhNTxZMK459P
|
||||
3x1Ucqj/kTqrxKw6s2Uknuk0ajmw0ljV+BQwgL6maguo9BKgCNW5AY0EYnD+DQEM
|
||||
ANOu/d6ZMF8bW+Df9RDCUQKytbaZfa+ZbIHBus7whCD/SQMOhPKntv3HX7SmMCs+
|
||||
5i27kJMu4YN623JCS7hdCoXVO1R5kXCEcneW/rPBMDutaM472YvIWMIqK9Wwl5+0
|
||||
Piu2N+uTkKhe9uS2u7eN+Khef3d7xfjGRxoppM+xI9dZO+jhYiy8LuC0oBohTjJq
|
||||
QPqfGDpowBwRkkOsGz/XVcesJ1Pzg4bKivTS9kZjZSyT9RRSY8As0sVUN57AwYul
|
||||
s1+eh00n/tVpi2Jj9pCm7S0csSXvXj8v2OTdK1jt4YjpzR0/rwh4+/xlOjDjZEqH
|
||||
vMPhpzpbgnwkxZ3X8BFne9dJ3maC5zQ3LAeCP5m1W0hXzagYhfyjo74slJgD1O8c
|
||||
LDf2Oxc5MyM8Y/UK497zfqSPfgT3NhQmhHzk83DjXw3I6Z3A3U+Jp61w0eBRI1nx
|
||||
H1UIG+gldcAKUTcfwL0lghoT3nmi9JAbvek0Smhz00Bbo8/dx8vwQRxDUxlt7Exx
|
||||
NwARAQABiQG8BBgBCAAmAhsMFiEEka4tjB4nWmmb/r77lDMMelCvzPMFAmahA9IF
|
||||
CRb8CMUACgkQlDMMelCvzPPQgQv/d5/z+fxgKqgfhQX+V49X4WgTVxZ/CzztDoJ1
|
||||
XAq1dzTNEy8AFguXIo6eVXPSpMxec7ZreN3+UPQBnCf3eR5YxWNYOYKmk0G4E8D2
|
||||
KGUJept7TSA42/8N2ov6tToXFg4CgzKZj0fYLwgutly7K8eiWmSU6ptaO8aEQBHB
|
||||
gTGIOO3h6vJMGVycmoeRnHjv4wV84YWSVFSoJ7cY0he4Z9UznJBbE/KHZjrkXsPo
|
||||
N+Gg5lDuOP5xjKzM5SogV9lhxBAhMWAg3URUF15yruZBiA8uV1FOK8sal/9C1G7V
|
||||
M6ygA6uOZqXlZtcdA94RoSsW2pZ9eLVPsxz2B3Zko7tu11MpNP/wYmfGTI3KxZBj
|
||||
n/eodvwjJSgHpGOFSmbNzvPJo3to5nNlp7wH1KxIMc6Uuu9hgfDfwkFZgV2bnFIa
|
||||
Q6gyF548Ub48z7Dz83+WwLgbX19ve4oZx+dqSdczP6ILHRQomtrzrkkP2LU52oI5
|
||||
mxFo+ioe/ABCufSmyqFye0psX3Sp
|
||||
=WtqZ
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
```
|
||||
</details>
|
||||
**Chat Backgrounds** (18+ custom patterns, all TDS-aware):
|
||||
- Blueprint grid, carbon fiber, starfield, topographic contours, herringbone, crosshatch
|
||||
- Chevron, polka dots, triangles, plaid
|
||||
- All patterns use CSS custom properties — adapt to both TDS dark and light themes
|
||||
- Settings toggle for showing per-message sender profiles
|
||||
|
||||
## Local development
|
||||
> [!TIP]
|
||||
> We recommend using a version manager as versions change very quickly. You will likely need to switch between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Iron LTS (v20).
|
||||
### Voice / Video Call Improvements
|
||||
|
||||
Execute the following commands to start a development server:
|
||||
```sh
|
||||
npm ci # Installs all dependencies
|
||||
npm start # Serve a development version
|
||||
```
|
||||
|
||||
To build the app:
|
||||
```sh
|
||||
npm run build # Compiles the app into the dist/ directory
|
||||
```
|
||||
|
||||
### Running with Docker
|
||||
This repository includes a Dockerfile, which builds the application from source and serves it with Nginx on port 80. To
|
||||
use this locally, you can build the container like so:
|
||||
```
|
||||
docker build -t cinny:latest .
|
||||
```
|
||||
|
||||
You can then run the container you've built with a command similar to this:
|
||||
```
|
||||
docker run -p 8080:80 cinny:latest
|
||||
```
|
||||
|
||||
This will forward your `localhost` port 8080 to the container's port 80. You can visit the app in your browser by navigating to `http://localhost:8080`.
|
||||
- **Element Call 0.19.3**: Upgraded from 0.16.3. Dist copied to `public/element-call/` by vite at build time.
|
||||
- **Camera default OFF**: Camera no longer persists across sessions via localStorage. Always starts disabled. Optional `cameraOnJoin` setting for explicit opt-in.
|
||||
- **Deafen button**: Tooltip corrected to "Deafen" / "Undeafen" (was "Turn Off Sound" / "Turn On Sound")
|
||||
- **Screenshare confirmation**: A confirm dialog appears before screenshare is broadcast to call participants
|
||||
- **Auto-revert spotlight on screenshare**: When someone starts screensharing, EC normally forces all participants into spotlight view. Patched in `CallControl.ts` `onControlMutation()` — detects the screenshare button going `primary` and clicks `gridButton` after 600ms to revert to grid layout. Participants choose to watch screenshare manually.
|
||||
- **Push to Talk (PTT)**:
|
||||
- Configurable keybind (default: Space) via Settings > General > Calls
|
||||
- Mic activates on keydown, deactivates on keyup; mic muted on tab blur/focus to prevent stuck-on mic
|
||||
- Visual indicator: plain folds `Chip` by default; when LotusGuild TDS is active: orange `PTT — Hold SPACE` / green `● LIVE` in JetBrains Mono
|
||||
- Listens on both main window and EC iframe `contentWindow` for reliable key capture
|
||||
- Implemented via `CallControl.setMicrophone()` public method on the widget bridge
|
||||
- **Noise suppression toggle**: Settings > General > Calls — passes `noiseSuppression` URL parameter to the embedded Element Call widget
|
||||
- **DM calls**: Phone button in DM room header starts a 1-on-1 call via `useCallStart(true)`. `Room.tsx` switches to CallView layout when the DM has an active call embed. Standard voice rooms were already supported; this extends call support to all direct messages.
|
||||
- **Picture-in-picture (PiP)**: When navigating away from a call room while in an active call, the call embed shrinks to a 280x158px floating window in the bottom-right corner. The PiP window is **draggable** — drag it anywhere on screen to move it out of the way. Clicking (without dragging) navigates back to the call room. Drag vs click distinguished by a 5px movement threshold; touch drag supported. Imperative style overrides on `callEmbedRef.current` via `useEffect` — a wrapper div cannot be used because `useCallEmbedPlacementSync` writes `top/left/width/height` directly onto that element.
|
||||
|
||||
@@ -1,38 +1,17 @@
|
||||
{
|
||||
"defaultHomeserver": 2,
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": [
|
||||
"converser.eu",
|
||||
"envs.net",
|
||||
"matrix.org",
|
||||
"monero.social",
|
||||
"mozilla.org",
|
||||
"xmr.se"
|
||||
"matrix.lotusguild.org"
|
||||
],
|
||||
"allowCustomHomeservers": true,
|
||||
|
||||
"allowCustomHomeservers": false,
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [
|
||||
"#cinny-space:matrix.org",
|
||||
"#community:matrix.org",
|
||||
"#space:envs.net",
|
||||
"#science-space:matrix.org",
|
||||
"#libregaming-games:tchncs.de",
|
||||
"#mathematics-on:matrix.org"
|
||||
],
|
||||
"rooms": [
|
||||
"#cinny:matrix.org",
|
||||
"#freesoftware:matrix.org",
|
||||
"#pcapdroid:matrix.org",
|
||||
"#gentoo:matrix.org",
|
||||
"#PrivSec.dev:arcticfoxes.net",
|
||||
"#disroot:aria-net.org"
|
||||
],
|
||||
"servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"]
|
||||
"spaces": [],
|
||||
"rooms": [],
|
||||
"servers": []
|
||||
},
|
||||
|
||||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,33 +4,36 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||
<title>Cinny</title>
|
||||
<meta name="name" content="Cinny" />
|
||||
<meta name="author" content="Ajay Bura" />
|
||||
<title>Lotus Chat</title>
|
||||
<meta name="name" content="Lotus Chat" />
|
||||
<meta name="author" content="Lotus Guild" />
|
||||
<meta
|
||||
name="description"
|
||||
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
|
||||
content="Lotus Chat — the Lotus Guild Matrix client. Secure, fast, and built for our community."
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="cinny, cinnyapp, cinnychat, matrix, matrix client, matrix.org, element"
|
||||
content="lotus chat, lotus guild, matrix, matrix client"
|
||||
/>
|
||||
|
||||
<meta property="og:title" content="Cinny" />
|
||||
<meta property="og:url" content="https://cinny.in" />
|
||||
<meta property="og:image" content="https://cinny.in/assets/favicon-48x48.png" />
|
||||
<meta property="og:title" content="Lotus Chat" />
|
||||
<meta property="og:url" content="https://chat.lotusguild.org" />
|
||||
<meta property="og:image" content="https://chat.lotusguild.org/public/res/android/android-chrome-192x192.png" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
|
||||
content="Lotus Chat — the Lotus Guild Matrix client. Secure, fast, and built for our community."
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap" rel="stylesheet">
|
||||
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="application-name" content="Cinny" />
|
||||
<meta name="apple-mobile-web-app-title" content="Cinny" />
|
||||
<meta name="application-name" content="Lotus Chat" />
|
||||
<meta name="apple-mobile-web-app-title" content="Lotus Chat" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
@@ -90,6 +93,7 @@
|
||||
window.global ||= window;
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
<div id="portalContainer"></div>
|
||||
<script type="module" src="./src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cinny",
|
||||
"version": "4.10.0",
|
||||
"version": "4.11.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cinny",
|
||||
"version": "4.10.0",
|
||||
"version": "4.11.1",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
||||
@@ -32,7 +32,7 @@
|
||||
"emojibase-data": "15.3.2",
|
||||
"file-saver": "2.0.5",
|
||||
"focus-trap-react": "10.0.2",
|
||||
"folds": "2.2.0",
|
||||
"folds": "2.6.2",
|
||||
"html-dom-parser": "4.0.0",
|
||||
"html-react-parser": "4.2.0",
|
||||
"i18next": "23.12.2",
|
||||
@@ -41,9 +41,10 @@
|
||||
"immer": "9.0.16",
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.6.0",
|
||||
"linkify-react": "4.1.3",
|
||||
"linkifyjs": "4.1.3",
|
||||
"matrix-js-sdk": "37.5.0",
|
||||
"linkify-react": "4.3.2",
|
||||
"linkifyjs": "4.3.2",
|
||||
"matrix-js-sdk": "38.2.0",
|
||||
"matrix-widget-api": "1.13.0",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "4.2.67",
|
||||
"prismjs": "1.30.0",
|
||||
@@ -56,15 +57,16 @@
|
||||
"react-google-recaptcha": "2.1.0",
|
||||
"react-i18next": "15.0.0",
|
||||
"react-range": "1.8.14",
|
||||
"react-router-dom": "6.20.0",
|
||||
"react-router-dom": "6.30.3",
|
||||
"sanitize-html": "2.12.1",
|
||||
"slate": "0.112.0",
|
||||
"slate-dom": "0.112.2",
|
||||
"slate-history": "0.110.3",
|
||||
"slate-react": "0.112.1",
|
||||
"slate": "0.123.0",
|
||||
"slate-dom": "0.123.0",
|
||||
"slate-history": "0.113.1",
|
||||
"slate-react": "0.123.0",
|
||||
"ua-parser-js": "1.0.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-call-embedded": "0.16.3",
|
||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||
"@rollup/plugin-inject": "5.0.3",
|
||||
"@rollup/plugin-wasm": "6.1.1",
|
||||
@@ -1649,6 +1651,12 @@
|
||||
"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": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||
@@ -2256,20 +2264,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-14.1.0.tgz",
|
||||
"integrity": "sha512-vcSxHJIr6lP0Fgo8jl0sTHg+OZxZn+skGjiyB62erfgw/R2QqJl0ZVSY8SRcbk9LtHo/ZGld1tnaOyjL2e3cLQ==",
|
||||
"version": "15.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-15.3.0.tgz",
|
||||
"integrity": "sha512-QyxHvncvkl7nf+tnn92PjQ54gMNV8hMSpiukiDgNrqF6IYwgySTlcSdkPYdw8QjZJ0NR6fnVrNzMec0OohM3wA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"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": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -3705,9 +3707,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.13.0.tgz",
|
||||
"integrity": "sha512-5dMOnVnefRsl4uRnAdoWjtVTdh8e6aZqgM4puy9nmEADH72ck+uXwzpJLEKE9Q6F8ZljNewLgmTfkxUrBdv4WA==",
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
@@ -7163,9 +7166,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/folds": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/folds/-/folds-2.2.0.tgz",
|
||||
"integrity": "sha512-uOfck5eWEIK11rhOAEdSoPIvMXwv+D1Go03pxSAKezWVb+uRoBdmE6LEqiLOF+ac4DGmZRMPvpdDsXCg7EVNIg==",
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/folds/-/folds-2.6.2.tgz",
|
||||
"integrity": "sha512-1HemxxSnBm8/U5kq1pDQrFkpltWgQN90DmWCZWkZb7D2pe8BhOJSwIRLjk9WxHcw6nn69oz2XNYIXtSw0LvX1w==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@vanilla-extract/css": "1.9.2",
|
||||
@@ -8497,18 +8500,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/linkify-react": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/linkify-react/-/linkify-react-4.1.3.tgz",
|
||||
"integrity": "sha512-rhI3zM/fxn5BfRPHfi4r9N7zgac4vOIxub1wHIWXLA5ENTMs+BGaIaFO1D1PhmxgwhIKmJz3H7uCP0Dg5JwSlA==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/linkify-react/-/linkify-react-4.3.2.tgz",
|
||||
"integrity": "sha512-mi744h1hf+WDsr+paJgSBBgYNLMWNSHyM9V9LVUo03RidNGdw1VpI7Twnt+K3pEh3nIzB4xiiAgZxpd61ItKpQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"linkifyjs": "^4.0.0",
|
||||
"react": ">= 15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz",
|
||||
"integrity": "sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg=="
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
|
||||
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
@@ -8631,14 +8636,13 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/matrix-js-sdk": {
|
||||
"version": "37.5.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-37.5.0.tgz",
|
||||
"integrity": "sha512-5tyuAi5hnKud1UkVq8Z2/3c22hWGELBZzErJPZkE6Hju2uGUfGtrIx6uj6puv0ZjvsUU3X6Qgm8vdReKO1PGig==",
|
||||
"version": "38.2.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-38.2.0.tgz",
|
||||
"integrity": "sha512-R3jzK8rDGi/3OXOax8jFFyxblCG9KTT5yuXAbvnZCGcpTm8lZ4mHQAn5UydVD8qiyUMNMpaaMd6/k7N+5I/yaQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^14.0.1",
|
||||
"@matrix-org/olm": "3.2.15",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^15.1.0",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^6.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
@@ -8653,7 +8657,7 @@
|
||||
"uuid": "11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/matrix-js-sdk/node_modules/uuid": {
|
||||
@@ -8670,9 +8674,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/matrix-widget-api": {
|
||||
"version": "1.13.1",
|
||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.1.tgz",
|
||||
"integrity": "sha512-mkOHUVzaN018TCbObfGOSaMW2GoUxOfcxNNlTVx5/HeMk3OSQPQM0C9oEME5Liiv/dBUoSrEB64V8wF7e/gb1w==",
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.0.tgz",
|
||||
"integrity": "sha512-+LrvwkR1izL4h2euX8PDrvG/3PZZDEd6As+lmnR3jAVwbFJtU5iTnwmZGnCca9ddngCvXvAHkcpJBEPyPTZneQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/events": "^3.0.0",
|
||||
@@ -9612,11 +9616,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.20.0.tgz",
|
||||
"integrity": "sha512-pVvzsSsgUxxtuNfTHC4IxjATs10UaAtvLGVSA1tbUE4GDaOSU1Esu2xF5nWLz7KPiMuW8BJWuPFdlGYJ7/rW0w==",
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.13.0"
|
||||
"@remix-run/router": "1.23.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -9626,12 +9631,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.20.0.tgz",
|
||||
"integrity": "sha512-CbcKjEyiSVpA6UtCHOIYLUYn/UJfwzp55va4yEfpk7JBN3GPqWfHrdLkAvNCcpXr8QoihcDMuk0dzWZxtlB/mQ==",
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
||||
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.13.0",
|
||||
"react-router": "6.20.0"
|
||||
"@remix-run/router": "1.23.2",
|
||||
"react-router": "6.30.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -10285,20 +10291,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/slate": {
|
||||
"version": "0.112.0",
|
||||
"resolved": "https://registry.npmjs.org/slate/-/slate-0.112.0.tgz",
|
||||
"integrity": "sha512-PRnfFgDA3tSop4OH47zu4M1R4Uuhm/AmASu29Qp7sGghVFb713kPBKEnSf1op7Lx/nCHkRlCa3ThfHtCBy+5Yw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immer": "^10.0.3",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"tiny-warning": "^1.0.3"
|
||||
}
|
||||
"version": "0.123.0",
|
||||
"resolved": "https://registry.npmjs.org/slate/-/slate-0.123.0.tgz",
|
||||
"integrity": "sha512-Oon3HR/QzJQBjuOUJT1jGGlp8Ff7t3Bkr/rJ2lDqxNT4H+cBnXpEVQ/si6hn1ZCHhD2xY/2N91PQoH/rD7kxTg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/slate-dom": {
|
||||
"version": "0.112.2",
|
||||
"resolved": "https://registry.npmjs.org/slate-dom/-/slate-dom-0.112.2.tgz",
|
||||
"integrity": "sha512-cozITMlpcBxrov854reM6+TooiHiqpfM/nZPrnjpN1wSiDsAQmYbWUyftC+jlwcpFj80vywfDHzlG6hXIc5h6A==",
|
||||
"version": "0.123.0",
|
||||
"resolved": "https://registry.npmjs.org/slate-dom/-/slate-dom-0.123.0.tgz",
|
||||
"integrity": "sha512-OUinp4tvSrAlt64JL9y20Xin08jgnnj1gJmIuPdGvU5MELKXRNZh17a7EKKNOS6OZPAE8Dk9NI1MAIS/Qz0YBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
@@ -10310,13 +10311,13 @@
|
||||
"tiny-invariant": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"slate": ">=0.99.0"
|
||||
"slate": ">=0.121.0"
|
||||
}
|
||||
},
|
||||
"node_modules/slate-history": {
|
||||
"version": "0.110.3",
|
||||
"resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.110.3.tgz",
|
||||
"integrity": "sha512-sgdff4Usdflmw5ZUbhDkxFwCBQ2qlDKMMkF93w66KdV48vHOgN2BmLrf+2H8SdX8PYIpP/cTB0w8qWC2GwhDVA==",
|
||||
"version": "0.113.1",
|
||||
"resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.113.1.tgz",
|
||||
"integrity": "sha512-J9NSJ+UG2GxoW0lw5mloaKcN0JI0x2IA5M5FxyGiInpn+QEutxT1WK7S/JneZCMFJBoHs1uu7S7e6pxQjubHmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-plain-object": "^5.0.0"
|
||||
@@ -10326,15 +10327,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/slate-react": {
|
||||
"version": "0.112.1",
|
||||
"resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.112.1.tgz",
|
||||
"integrity": "sha512-V9b+waxPweXqAkSQmKQ1afG4Me6nVQACPpxQtHPIX02N7MXa5f5WilYv+bKt7vKKw+IZC2F0Gjzhv5BekVgP/A==",
|
||||
"version": "0.123.0",
|
||||
"resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.123.0.tgz",
|
||||
"integrity": "sha512-nQwXL1FEacrY9ZFmatRhoBnsySNUX2x6qB77V3oNHd7wWxBJWuzz4GMrBXcVoRE8Gac7Angf8xaNGzb6zcPlHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"direction": "^1.0.4",
|
||||
"is-hotkey": "^0.2.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"tiny-invariant": "1.3.1"
|
||||
@@ -10342,18 +10342,8 @@
|
||||
"peerDependencies": {
|
||||
"react": ">=18.2.0",
|
||||
"react-dom": ">=18.2.0",
|
||||
"slate": ">=0.99.0",
|
||||
"slate-dom": ">=0.110.2"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
"slate": ">=0.121.0",
|
||||
"slate-dom": ">=0.119.1"
|
||||
}
|
||||
},
|
||||
"node_modules/smob": {
|
||||
@@ -10723,11 +10713,6 @@
|
||||
"integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==",
|
||||
"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": {
|
||||
"version": "0.2.10",
|
||||
"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",
|
||||
"integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cinny",
|
||||
"version": "4.10.0",
|
||||
"description": "Yet another matrix client",
|
||||
"name": "lotus-chat",
|
||||
"version": "4.11.1",
|
||||
"description": "Lotus Chat \u2014 Matrix client for Lotus Guild",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -10,6 +10,7 @@
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "yarn check:eslint && yarn check:prettier",
|
||||
"check:eslint": "eslint src/*",
|
||||
"check:prettier": "prettier --check .",
|
||||
@@ -43,7 +44,7 @@
|
||||
"emojibase-data": "15.3.2",
|
||||
"file-saver": "2.0.5",
|
||||
"focus-trap-react": "10.0.2",
|
||||
"folds": "2.2.0",
|
||||
"folds": "2.6.2",
|
||||
"html-dom-parser": "4.0.0",
|
||||
"html-react-parser": "4.2.0",
|
||||
"i18next": "23.12.2",
|
||||
@@ -52,9 +53,10 @@
|
||||
"immer": "9.0.16",
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.6.0",
|
||||
"linkify-react": "4.1.3",
|
||||
"linkifyjs": "4.1.3",
|
||||
"matrix-js-sdk": "37.5.0",
|
||||
"linkify-react": "4.3.2",
|
||||
"linkifyjs": "4.3.2",
|
||||
"matrix-js-sdk": "38.2.0",
|
||||
"matrix-widget-api": "1.13.0",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "4.2.67",
|
||||
"prismjs": "1.30.0",
|
||||
@@ -67,15 +69,16 @@
|
||||
"react-google-recaptcha": "2.1.0",
|
||||
"react-i18next": "15.0.0",
|
||||
"react-range": "1.8.14",
|
||||
"react-router-dom": "6.20.0",
|
||||
"react-router-dom": "6.30.3",
|
||||
"sanitize-html": "2.12.1",
|
||||
"slate": "0.112.0",
|
||||
"slate-dom": "0.112.2",
|
||||
"slate-history": "0.110.3",
|
||||
"slate-react": "0.112.1",
|
||||
"slate": "0.123.0",
|
||||
"slate-dom": "0.123.0",
|
||||
"slate-history": "0.113.1",
|
||||
"slate-react": "0.123.0",
|
||||
"ua-parser-js": "1.0.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-call-embedded": "0.16.3",
|
||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||
"@rollup/plugin-inject": "5.0.3",
|
||||
"@rollup/plugin-wasm": "6.1.1",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": [
|
||||
"matrix.lotusguild.org"
|
||||
],
|
||||
"allowCustomHomeservers": false,
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [],
|
||||
"rooms": [],
|
||||
"servers": []
|
||||
},
|
||||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 631 B |
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "Cinny",
|
||||
"short_name": "Cinny",
|
||||
"description": "Yet another matrix client",
|
||||
"name": "Lotus Chat",
|
||||
"short_name": "Lotus Chat",
|
||||
"description": "Lotus Chat \u2014 the Lotus Guild Matrix client",
|
||||
"dir": "auto",
|
||||
"lang": "en-US",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"start_url": "./",
|
||||
"background_color": "#fff",
|
||||
"theme_color": "#fff",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#980000",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./public/android/android-chrome-36x36.png",
|
||||
@@ -55,5 +55,24 @@
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
"social",
|
||||
"communication",
|
||||
"productivity"
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "New Message",
|
||||
"short_name": "DM",
|
||||
"description": "Open a new direct message",
|
||||
"url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "res/android/android-chrome-96x96.png",
|
||||
"sizes": "96x96"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
@@ -1,13 +1,14 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2707_1961)">
|
||||
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
|
||||
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
|
||||
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#45B83B"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2707_1961">
|
||||
<rect width="18" height="18" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="#980000" fill-opacity="0.88">
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(0,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(45,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(90,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(135,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(180,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(225,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(270,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(315,9,9)"/>
|
||||
</g>
|
||||
<circle cx="9" cy="9" r="2.2" fill="#cc2000"/>
|
||||
<circle cx="14.5" cy="14.5" r="3" fill="#45B83B"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 841 B |
@@ -1,13 +1,14 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2707_2015)">
|
||||
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
|
||||
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
|
||||
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#989898"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2707_2015">
|
||||
<rect width="18" height="18" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="#980000" fill-opacity="0.88">
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(0,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(45,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(90,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(135,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(180,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(225,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(270,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(315,9,9)"/>
|
||||
</g>
|
||||
<circle cx="9" cy="9" r="2.2" fill="#cc2000"/>
|
||||
<circle cx="14.5" cy="14.5" r="3" fill="#989898"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 841 B |
@@ -1,19 +1,13 @@
|
||||
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In -->
|
||||
<svg version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
|
||||
x="0px" y="0px" width="18px" height="18px" viewBox="0 0 18 18" enable-background="new 0 0 18 18" xml:space="preserve">
|
||||
<defs>
|
||||
</defs>
|
||||
<g>
|
||||
<g>
|
||||
<circle fill="#FFFFFF" cx="9" cy="9" r="8.5"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M9,0C4,0,0,4,0,9c0,5,4,9,9,9c5,0,9-4,9-9C18,4,14,0,9,0z M1.2,10.8l3.5-2.3c0-0.1,0-0.2,0-0.3c0-1.8,1.3-3.2,3.1-3.4
|
||||
c0.1,0,0.2,0,0.4,0c1.2,0,2.3,0.6,2.9,1.6c0.3-0.1,0.6-0.1,0.9-0.1c0.4,0,0.8,0,1.2,0.1c0.7,0.2,1.4,0.5,2,0.9
|
||||
C14.6,7.1,14,7,13.3,7c-1.2,0-2.2,0.4-2.9,1.4c-0.7,0.9-1.1,2-1.1,3.2c0,1.5-0.4,2.9-1.3,4.2c-0.3,0.4-0.5,0.7-0.8,1
|
||||
C4.2,16.1,1.9,13.8,1.2,10.8z"/>
|
||||
<circle cx="9.5" cy="6.4" r="0.5"/>
|
||||
</g>
|
||||
</g>
|
||||
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="#980000" fill-opacity="0.88">
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(0,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(45,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(90,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(135,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(180,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(225,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(270,9,9)"/>
|
||||
<path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(315,9,9)"/>
|
||||
</g>
|
||||
<circle cx="9" cy="9" r="2.2" fill="#cc2000"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 871 B After Width: | Height: | Size: 788 B |
@@ -51,8 +51,12 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) {
|
||||
},
|
||||
location.pathname
|
||||
);
|
||||
if (spaceMatch?.params.spaceIdOrAlias) {
|
||||
navigate(getSpacePath(spaceMatch.params.spaceIdOrAlias));
|
||||
const encodedSpaceIdOrAlias = spaceMatch?.params.spaceIdOrAlias;
|
||||
const decodedSpaceIdOrAlias =
|
||||
encodedSpaceIdOrAlias && decodeURIComponent(encodedSpaceIdOrAlias);
|
||||
|
||||
if (decodedSpaceIdOrAlias) {
|
||||
navigate(getSpacePath(decodedSpaceIdOrAlias));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import React, { ReactNode, useCallback, useEffect, 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';
|
||||
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
||||
|
||||
const PIP_MIN_W = 200;
|
||||
const PIP_MIN_H = 112; // keeps roughly 16:9 at minimum
|
||||
|
||||
function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
|
||||
useCallMemberSoundSync(embed);
|
||||
useCallThemeSync(embed);
|
||||
useCallHangupEvent(
|
||||
embed,
|
||||
useCallback(() => {
|
||||
setCallEmbed(undefined);
|
||||
}, [setCallEmbed])
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type Corner = 'se' | 'sw' | 'ne' | 'nw';
|
||||
|
||||
/** Normalise the element to top/left positioning so resize math is uniform. */
|
||||
function normaliseToTopLeft(el: HTMLDivElement) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
el.style.top = `${rect.top}px`;
|
||||
el.style.left = `${rect.left}px`;
|
||||
el.style.right = 'auto';
|
||||
el.style.bottom = 'auto';
|
||||
el.style.width = `${rect.width}px`;
|
||||
el.style.height = `${rect.height}px`;
|
||||
}
|
||||
|
||||
type CallEmbedProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
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 { navigateRoom } = useRoomNavigate();
|
||||
|
||||
const chatOnlyView = chat && screenSize !== ScreenSize.Desktop;
|
||||
const inCallRoom = callEmbed && selectedRoom === callEmbed.roomId;
|
||||
const callActive = callEmbed && joined;
|
||||
const callVisible = inCallRoom && callActive && !chatOnlyView;
|
||||
const pipMode = callActive && !inCallRoom;
|
||||
|
||||
const pipDragRef = useRef<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
origLeft: number;
|
||||
origTop: number;
|
||||
dragged: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// useCallEmbedPlacementSync writes top/left/width/height directly on this element.
|
||||
// Override those in PiP mode; clear them when returning so it can take back control.
|
||||
useEffect(() => {
|
||||
const el = callEmbedRef.current;
|
||||
if (!el) return;
|
||||
if (pipMode) {
|
||||
el.style.top = 'auto';
|
||||
el.style.left = 'auto';
|
||||
el.style.bottom = '72px';
|
||||
el.style.right = '16px';
|
||||
el.style.width = '280px';
|
||||
el.style.height = '158px';
|
||||
el.style.borderRadius = '12px';
|
||||
el.style.overflow = 'hidden';
|
||||
el.style.zIndex = '99';
|
||||
el.style.boxShadow = '0 8px 32px rgba(0,0,0,0.55)';
|
||||
el.style.border = '1px solid rgba(255,255,255,0.1)';
|
||||
el.style.visibility = 'visible';
|
||||
} else {
|
||||
el.style.top = '';
|
||||
el.style.left = '';
|
||||
el.style.bottom = '';
|
||||
el.style.right = '';
|
||||
el.style.width = '';
|
||||
el.style.height = '';
|
||||
el.style.borderRadius = '';
|
||||
el.style.overflow = '';
|
||||
el.style.zIndex = '';
|
||||
el.style.boxShadow = '';
|
||||
el.style.border = '';
|
||||
el.style.visibility = callVisible ? '' : 'hidden';
|
||||
}
|
||||
}, [pipMode, callVisible]);
|
||||
|
||||
// ── Drag to move ────────────────────────────────────────────────────────────
|
||||
const handlePipMouseDown = (e: React.MouseEvent) => {
|
||||
const el = callEmbedRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
pipDragRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origLeft: rect.left,
|
||||
origTop: rect.top,
|
||||
dragged: false,
|
||||
};
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
if (!pipDragRef.current || !el) return;
|
||||
const dx = ev.clientX - pipDragRef.current.startX;
|
||||
const dy = ev.clientY - pipDragRef.current.startY;
|
||||
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) {
|
||||
pipDragRef.current.dragged = true;
|
||||
document.body.style.cursor = 'grabbing';
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
if (pipDragRef.current.dragged) {
|
||||
const newLeft = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx));
|
||||
const newTop = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy));
|
||||
el.style.left = `${newLeft}px`;
|
||||
el.style.top = `${newTop}px`;
|
||||
el.style.right = 'auto';
|
||||
el.style.bottom = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
setTimeout(() => {
|
||||
if (pipDragRef.current) pipDragRef.current.dragged = false;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
const handlePipTouchStart = (e: React.TouchEvent) => {
|
||||
const el = callEmbedRef.current;
|
||||
if (!el || e.touches.length !== 1) return;
|
||||
const touch = e.touches[0];
|
||||
const rect = el.getBoundingClientRect();
|
||||
pipDragRef.current = {
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
origLeft: rect.left,
|
||||
origTop: rect.top,
|
||||
dragged: false,
|
||||
};
|
||||
|
||||
const onTouchMove = (ev: TouchEvent) => {
|
||||
if (!pipDragRef.current || !el || ev.touches.length !== 1) return;
|
||||
ev.preventDefault();
|
||||
const t = ev.touches[0];
|
||||
const dx = t.clientX - pipDragRef.current.startX;
|
||||
const dy = t.clientY - pipDragRef.current.startY;
|
||||
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) {
|
||||
pipDragRef.current.dragged = true;
|
||||
}
|
||||
if (pipDragRef.current.dragged) {
|
||||
const newLeft = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx));
|
||||
const newTop = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy));
|
||||
el.style.left = `${newLeft}px`;
|
||||
el.style.top = `${newTop}px`;
|
||||
el.style.right = 'auto';
|
||||
el.style.bottom = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
document.removeEventListener('touchend', onTouchEnd);
|
||||
setTimeout(() => {
|
||||
if (pipDragRef.current) pipDragRef.current.dragged = false;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
|
||||
const handlePipClick = (roomId: string) => {
|
||||
if (pipDragRef.current?.dragged) return;
|
||||
navigateRoom(roomId);
|
||||
};
|
||||
|
||||
// ── Resize from corner handles ───────────────────────────────────────────────
|
||||
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const el = callEmbedRef.current;
|
||||
if (!el) return;
|
||||
|
||||
normaliseToTopLeft(el);
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startW = el.offsetWidth;
|
||||
const startH = el.offsetHeight;
|
||||
const startL = parseFloat(el.style.left);
|
||||
const startT = parseFloat(el.style.top);
|
||||
|
||||
document.body.style.cursor = `${corner}-resize`;
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
const dx = ev.clientX - startX;
|
||||
const dy = ev.clientY - startY;
|
||||
|
||||
let w = startW;
|
||||
let h = startH;
|
||||
let l = startL;
|
||||
let t = startT;
|
||||
|
||||
if (corner === 'se') { w = startW + dx; h = startH + dy; }
|
||||
if (corner === 'sw') { w = startW - dx; h = startH + dy; l = startL + startW - Math.max(PIP_MIN_W, w); }
|
||||
if (corner === 'ne') { w = startW + dx; h = startH - dy; t = startT + startH - Math.max(PIP_MIN_H, h); }
|
||||
if (corner === 'nw') { w = startW - dx; h = startH - dy; l = startL + startW - Math.max(PIP_MIN_W, w); t = startT + startH - Math.max(PIP_MIN_H, h); }
|
||||
|
||||
w = Math.max(PIP_MIN_W, Math.min(w, window.innerWidth));
|
||||
h = Math.max(PIP_MIN_H, Math.min(h, window.innerHeight));
|
||||
l = Math.max(0, Math.min(l, window.innerWidth - PIP_MIN_W));
|
||||
t = Math.max(0, Math.min(t, window.innerHeight - PIP_MIN_H));
|
||||
|
||||
el.style.width = `${w}px`;
|
||||
el.style.height = `${h}px`;
|
||||
el.style.left = `${l}px`;
|
||||
el.style.top = `${t}px`;
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
// Corner handle style helper
|
||||
const cornerHandle = (corner: Corner): React.CSSProperties => {
|
||||
const s = corner.includes('s');
|
||||
const e = corner.includes('e');
|
||||
return {
|
||||
position: 'absolute',
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
[s ? 'bottom' : 'top']: 0,
|
||||
[e ? 'right' : 'left']: 0,
|
||||
cursor: `${corner}-resize`,
|
||||
zIndex: 2,
|
||||
};
|
||||
};
|
||||
|
||||
// Grip dot pattern rendered inside each handle
|
||||
const gripDots = (corner: Corner) => {
|
||||
const s = corner.includes('s');
|
||||
const e = corner.includes('e');
|
||||
// 3 dots arranged in an L toward the active corner
|
||||
const dots: React.CSSProperties[] = [];
|
||||
const r = 2; // dot radius px
|
||||
const gap = 5;
|
||||
const base = 3;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
for (let j = 0; j < 3; j++) {
|
||||
if (i + j < 2) continue; // only the 3 corner-most dots
|
||||
dots.push({
|
||||
position: 'absolute',
|
||||
width: r * 2,
|
||||
height: r * 2,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.45)',
|
||||
[s ? 'bottom' : 'top']: base + i * gap,
|
||||
[e ? 'right' : 'left']: base + j * gap,
|
||||
});
|
||||
}
|
||||
}
|
||||
return dots;
|
||||
};
|
||||
|
||||
return (
|
||||
<CallEmbedContextProvider value={callEmbed}>
|
||||
{callEmbed && <CallUtils embed={callEmbed} />}
|
||||
<CallEmbedRefContextProvider value={callEmbedRef}>{children}</CallEmbedRefContextProvider>
|
||||
<div
|
||||
data-call-embed-container
|
||||
ref={callEmbedRef}
|
||||
style={{
|
||||
visibility: callVisible ? undefined : 'hidden',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '50%',
|
||||
}}
|
||||
>
|
||||
{pipMode && callEmbed && (
|
||||
<>
|
||||
{/* Drag-to-move overlay (zIndex 1, sits over the iframe) */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Return to call"
|
||||
onMouseDown={handlePipMouseDown}
|
||||
onTouchStart={handlePipTouchStart}
|
||||
onClick={() => handlePipClick(callEmbed.roomId)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handlePipClick(callEmbed.roomId)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
background: 'transparent',
|
||||
cursor: 'grab',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-end',
|
||||
padding: '6px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
borderRadius: '6px',
|
||||
padding: '3px 8px',
|
||||
color: '#fff',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
↗ Return to call
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Corner resize handles (zIndex 2, above the drag overlay) */}
|
||||
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => (
|
||||
<div
|
||||
key={corner}
|
||||
style={cornerHandle(corner)}
|
||||
onMouseDown={(e) => handleResizeMouseDown(e, corner)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{gripDots(corner).map((style, i) => (
|
||||
<div key={i} style={style} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CallEmbedContextProvider>
|
||||
);
|
||||
}
|
||||
@@ -16,34 +16,24 @@ import {
|
||||
import { JoinRule } from 'matrix-js-sdk';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { stopPropagation } from '../utils/keyboard';
|
||||
import { getRoomIconSrc } from '../utils/room';
|
||||
|
||||
export type ExtraJoinRules = 'knock_restricted';
|
||||
export type ExtendedJoinRules = JoinRule | ExtraJoinRules;
|
||||
|
||||
type JoinRuleIcons = Record<ExtendedJoinRules, IconSrc>;
|
||||
export const useRoomJoinRuleIcon = (): JoinRuleIcons =>
|
||||
|
||||
export const useJoinRuleIcons = (roomType?: string): JoinRuleIcons =>
|
||||
useMemo(
|
||||
() => ({
|
||||
[JoinRule.Invite]: Icons.HashLock,
|
||||
[JoinRule.Knock]: Icons.HashLock,
|
||||
knock_restricted: Icons.Hash,
|
||||
[JoinRule.Restricted]: Icons.Hash,
|
||||
[JoinRule.Public]: Icons.HashGlobe,
|
||||
[JoinRule.Private]: Icons.HashLock,
|
||||
[JoinRule.Invite]: getRoomIconSrc(Icons, roomType, JoinRule.Invite),
|
||||
[JoinRule.Knock]: getRoomIconSrc(Icons, roomType, JoinRule.Knock),
|
||||
knock_restricted: getRoomIconSrc(Icons, roomType, JoinRule.Restricted),
|
||||
[JoinRule.Restricted]: getRoomIconSrc(Icons, roomType, JoinRule.Restricted),
|
||||
[JoinRule.Public]: getRoomIconSrc(Icons, roomType, JoinRule.Public),
|
||||
[JoinRule.Private]: getRoomIconSrc(Icons, roomType, JoinRule.Private),
|
||||
}),
|
||||
[]
|
||||
);
|
||||
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,
|
||||
}),
|
||||
[]
|
||||
[roomType]
|
||||
);
|
||||
|
||||
type JoinRuleLabels = Record<ExtendedJoinRules, string>;
|
||||
|
||||
@@ -209,13 +209,11 @@ export function RenderMessageContent({
|
||||
<MVideo
|
||||
content={getContent()}
|
||||
renderAsFile={renderFile}
|
||||
renderVideoContent={({ body, info, mimeType, url, encInfo }) => (
|
||||
renderVideoContent={({ body, info, ...props }) => (
|
||||
<VideoContent
|
||||
body={body}
|
||||
info={info}
|
||||
mimeType={mimeType}
|
||||
url={url}
|
||||
encInfo={encInfo}
|
||||
{...props}
|
||||
renderThumbnail={
|
||||
mediaAutoLoad
|
||||
? () => (
|
||||
|
||||
@@ -2,43 +2,39 @@ import React from 'react';
|
||||
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
|
||||
import { SequenceCard } from '../sequence-card';
|
||||
import { SettingTile } from '../setting-tile';
|
||||
import { CreateRoomAccess } from './types';
|
||||
|
||||
export enum CreateRoomKind {
|
||||
Private = 'private',
|
||||
Restricted = 'restricted',
|
||||
Public = 'public',
|
||||
}
|
||||
type CreateRoomKindSelectorProps = {
|
||||
value?: CreateRoomKind;
|
||||
onSelect: (value: CreateRoomKind) => void;
|
||||
type CreateRoomAccessSelectorProps = {
|
||||
value?: CreateRoomAccess;
|
||||
onSelect: (value: CreateRoomAccess) => void;
|
||||
canRestrict?: boolean;
|
||||
disabled?: boolean;
|
||||
getIcon: (kind: CreateRoomKind) => IconSrc;
|
||||
getIcon: (access: CreateRoomAccess) => IconSrc;
|
||||
};
|
||||
export function CreateRoomKindSelector({
|
||||
export function CreateRoomAccessSelector({
|
||||
value,
|
||||
onSelect,
|
||||
canRestrict,
|
||||
disabled,
|
||||
getIcon,
|
||||
}: CreateRoomKindSelectorProps) {
|
||||
}: CreateRoomAccessSelectorProps) {
|
||||
return (
|
||||
<Box shrink="No" direction="Column" gap="100">
|
||||
{canRestrict && (
|
||||
<SequenceCard
|
||||
style={{ padding: config.space.S300 }}
|
||||
variant={value === CreateRoomKind.Restricted ? 'Primary' : 'SurfaceVariant'}
|
||||
variant={value === CreateRoomAccess.Restricted ? 'Primary' : 'SurfaceVariant'}
|
||||
direction="Column"
|
||||
gap="100"
|
||||
as="button"
|
||||
type="button"
|
||||
aria-pressed={value === CreateRoomKind.Restricted}
|
||||
onClick={() => onSelect(CreateRoomKind.Restricted)}
|
||||
aria-pressed={value === CreateRoomAccess.Restricted}
|
||||
onClick={() => onSelect(CreateRoomAccess.Restricted)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SettingTile
|
||||
before={<Icon size="400" src={getIcon(CreateRoomKind.Restricted)} />}
|
||||
after={value === CreateRoomKind.Restricted && <Icon src={Icons.Check} />}
|
||||
before={<Icon size="400" src={getIcon(CreateRoomAccess.Restricted)} />}
|
||||
after={value === CreateRoomAccess.Restricted && <Icon src={Icons.Check} />}
|
||||
>
|
||||
<Text size="H6">Restricted</Text>
|
||||
<Text size="T300" priority="300">
|
||||
@@ -49,18 +45,18 @@ export function CreateRoomKindSelector({
|
||||
)}
|
||||
<SequenceCard
|
||||
style={{ padding: config.space.S300 }}
|
||||
variant={value === CreateRoomKind.Private ? 'Primary' : 'SurfaceVariant'}
|
||||
variant={value === CreateRoomAccess.Private ? 'Primary' : 'SurfaceVariant'}
|
||||
direction="Column"
|
||||
gap="100"
|
||||
as="button"
|
||||
type="button"
|
||||
aria-pressed={value === CreateRoomKind.Private}
|
||||
onClick={() => onSelect(CreateRoomKind.Private)}
|
||||
aria-pressed={value === CreateRoomAccess.Private}
|
||||
onClick={() => onSelect(CreateRoomAccess.Private)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SettingTile
|
||||
before={<Icon size="400" src={getIcon(CreateRoomKind.Private)} />}
|
||||
after={value === CreateRoomKind.Private && <Icon src={Icons.Check} />}
|
||||
before={<Icon size="400" src={getIcon(CreateRoomAccess.Private)} />}
|
||||
after={value === CreateRoomAccess.Private && <Icon src={Icons.Check} />}
|
||||
>
|
||||
<Text size="H6">Private</Text>
|
||||
<Text size="T300" priority="300">
|
||||
@@ -70,18 +66,18 @@ export function CreateRoomKindSelector({
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
style={{ padding: config.space.S300 }}
|
||||
variant={value === CreateRoomKind.Public ? 'Primary' : 'SurfaceVariant'}
|
||||
variant={value === CreateRoomAccess.Public ? 'Primary' : 'SurfaceVariant'}
|
||||
direction="Column"
|
||||
gap="100"
|
||||
as="button"
|
||||
type="button"
|
||||
aria-pressed={value === CreateRoomKind.Public}
|
||||
onClick={() => onSelect(CreateRoomKind.Public)}
|
||||
aria-pressed={value === CreateRoomAccess.Public}
|
||||
onClick={() => onSelect(CreateRoomAccess.Public)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SettingTile
|
||||
before={<Icon size="400" src={getIcon(CreateRoomKind.Public)} />}
|
||||
after={value === CreateRoomKind.Public && <Icon src={Icons.Check} />}
|
||||
before={<Icon size="400" src={getIcon(CreateRoomAccess.Public)} />}
|
||||
after={value === CreateRoomAccess.Public && <Icon src={Icons.Check} />}
|
||||
>
|
||||
<Text size="H6">Public</Text>
|
||||
<Text size="T300" priority="300">
|
||||
@@ -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 './RoomVersionSelector';
|
||||
export * from './utils';
|
||||
export * from './AdditionalCreatorInput';
|
||||
export * from './types';
|
||||
|
||||
@@ -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,
|
||||
} from 'matrix-js-sdk';
|
||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import { CreateRoomKind } from './CreateRoomKindSelector';
|
||||
import { RoomType, StateEvent } from '../../../types/matrix/room';
|
||||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { getMxIdServer } from '../../utils/matrix';
|
||||
import { CreateRoomAccess } from './types';
|
||||
|
||||
export const createRoomCreationContent = (
|
||||
type: RoomType | undefined,
|
||||
@@ -32,7 +32,7 @@ export const createRoomCreationContent = (
|
||||
};
|
||||
|
||||
export const createRoomJoinRulesState = (
|
||||
kind: CreateRoomKind,
|
||||
access: CreateRoomAccess,
|
||||
parent: Room | undefined,
|
||||
knock: boolean
|
||||
) => {
|
||||
@@ -40,13 +40,13 @@ export const createRoomJoinRulesState = (
|
||||
join_rule: knock ? JoinRule.Knock : JoinRule.Invite,
|
||||
};
|
||||
|
||||
if (kind === CreateRoomKind.Public) {
|
||||
if (access === CreateRoomAccess.Public) {
|
||||
content = {
|
||||
join_rule: JoinRule.Public,
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === CreateRoomKind.Restricted && parent) {
|
||||
if (access === CreateRoomAccess.Restricted && parent) {
|
||||
content = {
|
||||
join_rule: knock ? ('knock_restricted' as JoinRule) : JoinRule.Restricted,
|
||||
allow: [
|
||||
@@ -74,6 +74,10 @@ export const createRoomParentState = (parent: Room) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const createSpacePowerLevelsOverride = () => ({
|
||||
events_default: 50,
|
||||
});
|
||||
|
||||
export const createRoomEncryptionState = () => ({
|
||||
type: 'm.room.encryption',
|
||||
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 = {
|
||||
version: string;
|
||||
type?: RoomType;
|
||||
parent?: Room;
|
||||
kind: CreateRoomKind;
|
||||
access: CreateRoomAccess;
|
||||
name: string;
|
||||
topic?: string;
|
||||
aliasLocalPart?: string;
|
||||
@@ -106,7 +122,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
||||
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 = {
|
||||
room_version: data.version,
|
||||
@@ -118,9 +138,15 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
||||
data.allowFederation,
|
||||
data.additionalCreators
|
||||
),
|
||||
power_level_content_override:
|
||||
data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined,
|
||||
initial_state: initialState,
|
||||
};
|
||||
|
||||
if (data.type === RoomType.Space) {
|
||||
options.power_level_content_override = createSpacePowerLevelsOverride();
|
||||
}
|
||||
|
||||
const result = await mx.createRoom(options);
|
||||
|
||||
if (data.parent) {
|
||||
|
||||
@@ -88,6 +88,8 @@ export function EmoticonAutocomplete({
|
||||
{autoCompleteEmoticon.map((emoticon) => {
|
||||
const isCustomEmoji = 'url' in emoticon;
|
||||
const key = isCustomEmoji ? emoticon.url : emoticon.unicode;
|
||||
const customEmojiUrl = mxcUrlToHttp(mx, key, useAuthentication);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={emoticon.shortcode + key}
|
||||
@@ -98,11 +100,11 @@ export function EmoticonAutocomplete({
|
||||
}
|
||||
onClick={() => handleAutocomplete(key, emoticon.shortcode)}
|
||||
before={
|
||||
isCustomEmoji ? (
|
||||
isCustomEmoji && customEmojiUrl ? (
|
||||
<Box
|
||||
shrink="No"
|
||||
as="img"
|
||||
src={mxcUrlToHttp(mx, key, useAuthentication) || key}
|
||||
src={customEmojiUrl}
|
||||
alt={emoticon.shortcode}
|
||||
style={{ width: toRem(24), height: toRem(24), objectFit: 'contain' }}
|
||||
/>
|
||||
|
||||
@@ -169,12 +169,13 @@ export function RoomMentionAutocomplete({
|
||||
<RoomIcon
|
||||
size="50"
|
||||
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
|
||||
roomType={room.getType()}
|
||||
filled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<RoomIcon size="100" joinRule={room.getJoinRule()} space={room.isSpaceRoom()} />
|
||||
<RoomIcon size="100" joinRule={room.getJoinRule()} roomType={room.getType()} />
|
||||
)}
|
||||
</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.Mention) {
|
||||
if (node.id === getCanonicalAliasOrRoomId(mx, roomId)) {
|
||||
if (node.name === '@room') {
|
||||
mentionData.room = true;
|
||||
}
|
||||
|
||||
if (isUserId(node.id) && node.id !== mx.getUserId()) {
|
||||
mentionData.users.add(node.id);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
|
||||
import { toRem, color, config, DefaultReset, FocusOutline } from 'folds';
|
||||
|
||||
/**
|
||||
* Layout
|
||||
*/
|
||||
|
||||
export const Base = style({
|
||||
maxWidth: toRem(432),
|
||||
@@ -13,6 +17,15 @@ export const Base = style({
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const Header = style({
|
||||
padding: config.space.S300,
|
||||
paddingBottom: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Sidebar
|
||||
*/
|
||||
|
||||
export const Sidebar = style({
|
||||
width: toRem(54),
|
||||
backgroundColor: color.Surface.Container,
|
||||
@@ -29,26 +42,21 @@ export const SidebarStack = style({
|
||||
backgroundColor: color.Surface.Container,
|
||||
});
|
||||
|
||||
export const NativeEmojiSidebarStack = style({
|
||||
position: 'sticky',
|
||||
bottom: '-67%',
|
||||
zIndex: 1,
|
||||
});
|
||||
|
||||
export const SidebarDivider = style({
|
||||
width: toRem(18),
|
||||
});
|
||||
|
||||
export const Header = style({
|
||||
padding: config.space.S300,
|
||||
paddingBottom: 0,
|
||||
export const SidebarBtnImg = style({
|
||||
width: toRem(24),
|
||||
height: toRem(24),
|
||||
objectFit: 'contain',
|
||||
});
|
||||
|
||||
export const EmojiBoardTab = style({
|
||||
cursor: 'pointer',
|
||||
});
|
||||
/**
|
||||
* Preview
|
||||
*/
|
||||
|
||||
export const Footer = style({
|
||||
export const Preview = style({
|
||||
padding: config.space.S200,
|
||||
margin: config.space.S300,
|
||||
marginTop: 0,
|
||||
@@ -59,7 +67,30 @@ export const Footer = style({
|
||||
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({
|
||||
position: 'relative',
|
||||
padding: `${config.space.S300} 0`,
|
||||
});
|
||||
|
||||
@@ -82,15 +113,9 @@ export const EmojiGroupContent = style([
|
||||
},
|
||||
]);
|
||||
|
||||
export const EmojiPreview = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: toRem(32),
|
||||
height: toRem(32),
|
||||
fontSize: toRem(32),
|
||||
lineHeight: toRem(32),
|
||||
},
|
||||
]);
|
||||
/**
|
||||
* Item
|
||||
*/
|
||||
|
||||
export const EmojiItem = style([
|
||||
DefaultReset,
|
||||
@@ -1 +1,2 @@
|
||||
export * from './EmojiBoard';
|
||||
export * from './types';
|
||||
|
||||
@@ -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(
|
||||
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
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
: await downloadMedia(mediaUrl);
|
||||
|
||||
@@ -224,6 +224,8 @@ type RenderVideoContentProps = {
|
||||
mimeType: string;
|
||||
url: string;
|
||||
encInfo?: IEncryptedFile;
|
||||
markedAsSpoiler?: boolean;
|
||||
spoilerReason?: string;
|
||||
};
|
||||
type MVideoProps = {
|
||||
content: IVideoContent;
|
||||
@@ -274,6 +276,8 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
||||
mimeType: safeMimeType,
|
||||
url: mxcUrl,
|
||||
encInfo: content.file,
|
||||
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
|
||||
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
|
||||
})}
|
||||
</AttachmentBox>
|
||||
</Attachment>
|
||||
@@ -385,6 +389,8 @@ export function MLocation({ content }: MLocationProps) {
|
||||
const geoUri = content.geo_uri;
|
||||
if (typeof geoUri !== 'string') return <BrokenContent />;
|
||||
const location = parseGeoUri(geoUri);
|
||||
if (!location) return <BrokenContent />;
|
||||
|
||||
return (
|
||||
<Box direction="Column" alignItems="Start" gap="100">
|
||||
<Text size="T400">{geoUri}</Text>
|
||||
|
||||
@@ -54,7 +54,8 @@ export function AudioContent({
|
||||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
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
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
: await downloadMedia(mediaUrl);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, Icon, IconSrc } from 'folds';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { CompactLayout, ModernLayout } from '..';
|
||||
import { BubbleLayout, CompactLayout, ModernLayout } from '..';
|
||||
import { MessageLayout } from '../../../state/settings';
|
||||
|
||||
export type EventContentProps = {
|
||||
@@ -30,9 +30,15 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon
|
||||
</Box>
|
||||
);
|
||||
|
||||
return messageLayout === MessageLayout.Compact ? (
|
||||
<CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>
|
||||
) : (
|
||||
<ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>
|
||||
);
|
||||
if (messageLayout === MessageLayout.Compact) {
|
||||
return <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>;
|
||||
}
|
||||
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(
|
||||
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
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
: await downloadMedia(mediaUrl);
|
||||
@@ -176,7 +177,8 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
|
||||
|
||||
const [pdfState, loadPdf] = useAsyncCallback(
|
||||
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
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
: await downloadMedia(mediaUrl);
|
||||
@@ -253,7 +255,8 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
|
||||
|
||||
const [downloadState, download] = useAsyncCallback(
|
||||
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
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
: await downloadMedia(mediaUrl);
|
||||
|
||||
@@ -87,7 +87,8 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
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) {
|
||||
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo)
|
||||
@@ -214,7 +215,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
)}
|
||||
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
|
||||
!load &&
|
||||
!markedAsSpoiler && (
|
||||
!blurred && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
<Spinner variant="Secondary" />
|
||||
</Box>
|
||||
|
||||
@@ -23,7 +23,8 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
|
||||
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) {
|
||||
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo)
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Icon,
|
||||
Icons,
|
||||
Spinner,
|
||||
@@ -47,6 +48,8 @@ type VideoContentProps = {
|
||||
info: IVideoInfo & IThumbnailContent;
|
||||
encInfo?: EncryptedAttachmentInfo;
|
||||
autoPlay?: boolean;
|
||||
markedAsSpoiler?: boolean;
|
||||
spoilerReason?: string;
|
||||
renderThumbnail?: () => ReactNode;
|
||||
renderVideo: (props: RenderVideoProps) => ReactNode;
|
||||
};
|
||||
@@ -60,6 +63,8 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||
info,
|
||||
encInfo,
|
||||
autoPlay,
|
||||
markedAsSpoiler,
|
||||
spoilerReason,
|
||||
renderThumbnail,
|
||||
renderVideo,
|
||||
...props
|
||||
@@ -72,10 +77,12 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||
|
||||
const [load, setLoad] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
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
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, mimeType, encInfo)
|
||||
@@ -114,11 +121,15 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||
/>
|
||||
)}
|
||||
{renderThumbnail && !load && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
<Box
|
||||
className={classNames(css.AbsoluteContainer, blurred && css.Blur)}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
{renderThumbnail()}
|
||||
</Box>
|
||||
)}
|
||||
{!autoPlay && srcState.status === AsyncStatus.Idle && (
|
||||
{!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
<Button
|
||||
variant="Secondary"
|
||||
@@ -133,7 +144,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||
</Box>
|
||||
)}
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Box className={css.AbsoluteContainer}>
|
||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||
{renderVideo({
|
||||
title: body,
|
||||
src: srcState.data,
|
||||
@@ -144,8 +155,39 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||
})}
|
||||
</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) &&
|
||||
!load && (
|
||||
!load &&
|
||||
!blurred && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
<Spinner variant="Secondary" />
|
||||
</Box>
|
||||
|
||||
@@ -1,18 +1,63 @@
|
||||
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';
|
||||
|
||||
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 = {
|
||||
hideBubble?: boolean;
|
||||
before?: ReactNode;
|
||||
header?: ReactNode;
|
||||
};
|
||||
|
||||
export const BubbleLayout = as<'div', BubbleLayoutProps>(({ before, children, ...props }, ref) => (
|
||||
<Box gap="300" {...props} ref={ref}>
|
||||
<Box className={css.BubbleBefore} shrink="No">
|
||||
{before}
|
||||
export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
||||
({ hideBubble, before, header, children, ...props }, ref) => (
|
||||
<Box gap="300" {...props} ref={ref}>
|
||||
<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 className={css.BubbleContent} direction="Column">
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
));
|
||||
)
|
||||
);
|
||||
|
||||
@@ -120,6 +120,7 @@ export const CompactHeader = style([
|
||||
export const AvatarBase = style({
|
||||
paddingTop: toRem(4),
|
||||
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
|
||||
display: 'flex',
|
||||
alignSelf: 'start',
|
||||
|
||||
selectors: {
|
||||
@@ -133,14 +134,31 @@ export const ModernBefore = style({
|
||||
minWidth: toRem(36),
|
||||
});
|
||||
|
||||
export const BubbleBefore = style([ModernBefore]);
|
||||
export const BubbleBefore = style({
|
||||
minWidth: toRem(36),
|
||||
});
|
||||
|
||||
export const BubbleContent = style({
|
||||
maxWidth: toRem(800),
|
||||
padding: config.space.S200,
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
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({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { JoinRule } from 'matrix-js-sdk';
|
||||
import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds';
|
||||
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react';
|
||||
import * as css from './RoomAvatar.css';
|
||||
import { joinRuleToIconSrc } from '../../utils/room';
|
||||
import { getRoomIconSrc } from '../../utils/room';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
|
||||
type RoomAvatarProps = {
|
||||
@@ -44,13 +44,9 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
|
||||
export const RoomIcon = forwardRef<
|
||||
SVGSVGElement,
|
||||
Omit<ComponentProps<typeof Icon>, 'src'> & {
|
||||
joinRule: JoinRule;
|
||||
space?: boolean;
|
||||
joinRule?: JoinRule;
|
||||
roomType?: string;
|
||||
}
|
||||
>(({ joinRule, space, ...props }, ref) => (
|
||||
<Icon
|
||||
src={joinRuleToIconSrc(Icons, joinRule, space || false) ?? Icons.Hash}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
>(({ joinRule, roomType, ...props }, ref) => (
|
||||
<Icon src={getRoomIconSrc(Icons, roomType, joinRule)} {...props} ref={ref} />
|
||||
));
|
||||
|
||||
@@ -17,6 +17,7 @@ export const SequenceCard = as<
|
||||
firstChild,
|
||||
lastChild,
|
||||
outlined,
|
||||
mergeBorder,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
@@ -24,7 +25,7 @@ export const SequenceCard = as<
|
||||
<Box
|
||||
as={AsSequenceCard}
|
||||
className={classNames(
|
||||
css.SequenceCard({ radii, outlined }),
|
||||
css.SequenceCard({ radii, outlined, mergeBorder }),
|
||||
ContainerColor({ variant }),
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ export const SequenceCard = recipe({
|
||||
},
|
||||
borderStyle: 'solid',
|
||||
borderWidth: outlinedWidth,
|
||||
borderBottomWidth: 0,
|
||||
|
||||
selectors: {
|
||||
'&:first-child, :not(&) + &': {
|
||||
borderTopLeftRadius: [radii],
|
||||
@@ -20,7 +20,6 @@ export const SequenceCard = recipe({
|
||||
'&:last-child, &:not(:has(+&))': {
|
||||
borderBottomLeftRadius: [radii],
|
||||
borderBottomRightRadius: [radii],
|
||||
borderBottomWidth: outlinedWidth,
|
||||
},
|
||||
[`&[data-first-child="true"]`]: {
|
||||
borderTopLeftRadius: [radii],
|
||||
@@ -74,6 +73,16 @@ export const SequenceCard = recipe({
|
||||
},
|
||||
},
|
||||
},
|
||||
mergeBorder: {
|
||||
true: {
|
||||
borderBottomWidth: 0,
|
||||
selectors: {
|
||||
'&:last-child, &:not(:has(+&))': {
|
||||
borderBottomWidth: outlinedWidth,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
radii: '400',
|
||||
|
||||
@@ -21,7 +21,7 @@ export function SplashScreen({ children }: SplashScreenProps) {
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Text size="H2" align="Center">
|
||||
Cinny
|
||||
Lotus Chat
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from './StackedAvatar';
|
||||
@@ -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(() => {
|
||||
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();
|
||||
setSSOWindow(undefined);
|
||||
handleSubmit();
|
||||
@@ -37,7 +42,7 @@ export function SSOStage({
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [ssoWindow, handleSubmit]);
|
||||
}, [ssoWindow, handleSubmit, ssoRedirectURL]);
|
||||
|
||||
return (
|
||||
<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 { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
|
||||
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
||||
@@ -13,8 +13,54 @@ import {
|
||||
import { useObjectURL } from '../../hooks/useObjectURL';
|
||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||
|
||||
type ImagePreviewProps = { fileItem: TUploadItem; onSpoiler: (marked: boolean) => void };
|
||||
function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
|
||||
type PreviewImageProps = {
|
||||
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 fileUrl = useObjectURL(originalFile);
|
||||
|
||||
@@ -27,16 +73,7 @@ function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
width: '100%',
|
||||
height: toRem(152),
|
||||
filter: fileItem.metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
|
||||
}}
|
||||
src={fileUrl}
|
||||
alt={originalFile.name}
|
||||
/>
|
||||
{children}
|
||||
<Box
|
||||
justifyContent="End"
|
||||
style={{
|
||||
@@ -136,7 +173,14 @@ export function UploadCardRenderer({
|
||||
bottom={
|
||||
<>
|
||||
{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 && (
|
||||
<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;
|
||||
|
||||
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 (
|
||||
<>
|
||||
@@ -42,7 +50,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="no-referrer"
|
||||
rel="noreferrer"
|
||||
size="T200"
|
||||
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>
|
||||
}
|
||||
|
||||
@@ -20,6 +20,16 @@ export type AutoDiscoveryInfo = Record<string, unknown> & {
|
||||
'm.identity_server'?: {
|
||||
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 (
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||