Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76ac4e2987 | ||
|
|
f82cfead46 | ||
|
|
4d1ae4eafd | ||
|
|
a41dee4a55 | ||
|
|
13961d501f | ||
|
|
e6f14e79da | ||
|
|
1ff09d0fc1 | ||
|
|
f2d25c8d6c | ||
|
|
fe4fb4b4f7 | ||
|
|
faa952295f | ||
|
|
e9798a22c3 | ||
|
|
34dd64103c | ||
|
|
6a27720709 | ||
|
|
ccf10fc20c | ||
|
|
31942b1114 | ||
|
|
d8d4714370 | ||
|
|
9183fd66b2 | ||
|
|
67b05eeb09 | ||
|
|
7d4b0dd133 | ||
|
|
9073dee986 | ||
|
|
3cdb5c2fe6 | ||
|
|
acc7d4ff56 | ||
|
|
50cc78788f | ||
|
|
c462a3b8d5 | ||
|
|
c30c142653 | ||
|
|
fbd7e0a14b | ||
|
|
6b81401e2d | ||
|
|
c757b8967f | ||
|
|
d0a7ef31bc | ||
|
|
3fd8a18157 | ||
|
|
54ba1096d7 | ||
|
|
87fc490c3b | ||
|
|
ebe5beba1d | ||
|
|
77ab37f637 | ||
|
|
461e730c34 | ||
|
|
05e83eabef | ||
|
|
ba72925d53 | ||
|
|
87ce209050 | ||
|
|
3ed8260877 | ||
|
|
44347db6e4 | ||
|
|
91632aa193 | ||
|
|
e6f4eeca8e | ||
|
|
a23279e633 | ||
|
|
83057ebbd4 | ||
|
|
c51ba9670e | ||
|
|
59a007419f | ||
|
|
206ed33516 | ||
|
|
0d27bde33e | ||
|
|
df391968d8 | ||
|
|
5964eee833 | ||
|
|
387ce9c462 | ||
|
|
6ddcf2cb02 | ||
|
|
87e97eab88 | ||
|
|
13f1d53191 | ||
|
|
225894d327 | ||
|
|
c14333c540 | ||
|
|
405d1f6789 | ||
|
|
ff6d0b8f9b | ||
|
|
d141c02074 | ||
|
|
12ae94cd60 | ||
|
|
82805dcfdd | ||
|
|
5c39a36c12 | ||
|
|
4aed4d7472 | ||
|
|
649f70332b | ||
|
|
08e975cd8e | ||
|
|
7d54eef95b | ||
|
|
1361c1d5de | ||
|
|
ea48092270 | ||
|
|
324ed776c9 | ||
|
|
7af89da092 | ||
|
|
002223e149 | ||
|
|
90aecb8d7a | ||
|
|
3e39dd25af | ||
|
|
71bfc96b5c | ||
|
|
074a5e855d | ||
|
|
c16e060f73 | ||
|
|
f688e2d1ae | ||
|
|
286983c833 | ||
|
|
00f3df8719 | ||
|
|
d8009978e5 | ||
|
|
9bb30fbd92 | ||
|
|
82688c2e13 | ||
|
|
a02d7162d9 | ||
|
|
e39cc32df9 | ||
|
|
6017c0a2fc | ||
|
|
78fa6e3925 | ||
|
|
5d00383d71 | ||
|
|
19cf700d61 | ||
|
|
0c5ff65639 | ||
|
|
1206ffced2 | ||
|
|
5fbd0c13db | ||
|
|
36a8ce5561 | ||
|
|
dbadbe34b3 | ||
|
|
2b8b0dcffd | ||
|
|
b7e5e0db3e | ||
|
|
5c94471956 | ||
|
|
ccfe30cd68 | ||
|
|
2c7038cd1f | ||
|
|
d70ca86d7c | ||
|
|
8d95758ed7 | ||
|
|
dd4c1a94e6 | ||
|
|
7c6ab366af | ||
|
|
f121cc0a24 | ||
|
|
7456c152b7 | ||
|
|
b63868bbb5 | ||
|
|
59e8d66255 | ||
|
|
1b200eb676 | ||
|
|
3ada21a1df | ||
|
|
9fe67da98b | ||
|
|
d8d4bce287 | ||
|
|
b3979b31c7 | ||
|
|
2e0c7c4406 | ||
|
|
f73dc05e25 |
13
.github/renovate.json
vendored
13
.github/renovate.json
vendored
@@ -1,15 +1,14 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": [
|
"extends": ["config:recommended", ":dependencyDashboardApproval"],
|
||||||
"config:base",
|
"labels": ["Dependencies"],
|
||||||
":dependencyDashboardApproval"
|
|
||||||
],
|
|
||||||
"labels": [ "Dependencies" ],
|
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"matchUpdateTypes": [ "lockFileMaintenance" ]
|
"matchUpdateTypes": ["lockFileMaintenance"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"lockFileMaintenance": { "enabled": true },
|
"lockFileMaintenance": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
"dependencyDashboard": true
|
"dependencyDashboard": true
|
||||||
}
|
}
|
||||||
6
.github/workflows/build-pull-request.yml
vendored
6
.github/workflows/build-pull-request.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.2.0
|
uses: actions/checkout@v4.2.0
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v4.2.0
|
uses: actions/setup-node@v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 20.12.2
|
node-version: 20.12.2
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||||
run: npm run build
|
run: npm run build
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4.6.0
|
uses: actions/upload-artifact@v4.6.2
|
||||||
with:
|
with:
|
||||||
name: preview
|
name: preview
|
||||||
path: dist
|
path: dist
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
- name: Save pr number
|
- name: Save pr number
|
||||||
run: echo ${PR_NUMBER} > ./pr.txt
|
run: echo ${PR_NUMBER} > ./pr.txt
|
||||||
- name: Upload pr number
|
- name: Upload pr number
|
||||||
uses: actions/upload-artifact@v4.6.0
|
uses: actions/upload-artifact@v4.6.2
|
||||||
with:
|
with:
|
||||||
name: pr
|
name: pr
|
||||||
path: ./pr.txt
|
path: ./pr.txt
|
||||||
|
|||||||
4
.github/workflows/deploy-pull-request.yml
vendored
4
.github/workflows/deploy-pull-request.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download pr number
|
- name: Download pr number
|
||||||
uses: dawidd6/action-download-artifact@20319c5641d495c8a52e688b7dc5fada6c3a9fbc
|
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5
|
||||||
with:
|
with:
|
||||||
workflow: ${{ github.event.workflow.id }}
|
workflow: ${{ github.event.workflow.id }}
|
||||||
run_id: ${{ github.event.workflow_run.id }}
|
run_id: ${{ github.event.workflow_run.id }}
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
id: pr
|
id: pr
|
||||||
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
|
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
|
||||||
- name: Download artifact
|
- name: Download artifact
|
||||||
uses: dawidd6/action-download-artifact@20319c5641d495c8a52e688b7dc5fada6c3a9fbc
|
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5
|
||||||
with:
|
with:
|
||||||
workflow: ${{ github.event.workflow.id }}
|
workflow: ${{ github.event.workflow.id }}
|
||||||
run_id: ${{ github.event.workflow_run.id }}
|
run_id: ${{ github.event.workflow_run.id }}
|
||||||
|
|||||||
2
.github/workflows/docker-pr.yml
vendored
2
.github/workflows/docker-pr.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.2.0
|
uses: actions/checkout@v4.2.0
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
uses: docker/build-push-action@v6.13.0
|
uses: docker/build-push-action@v6.18.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
|
|||||||
2
.github/workflows/netlify-dev.yml
vendored
2
.github/workflows/netlify-dev.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.2.0
|
uses: actions/checkout@v4.2.0
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v4.2.0
|
uses: actions/setup-node@v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 20.12.2
|
node-version: 20.12.2
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
16
.github/workflows/prod-deploy.yml
vendored
16
.github/workflows/prod-deploy.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.2.0
|
uses: actions/checkout@v4.2.0
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v4.2.0
|
uses: actions/setup-node@v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 20.12.2
|
node-version: 20.12.2
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
gpg --export | xxd -p
|
gpg --export | xxd -p
|
||||||
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||||
- name: Upload tagged release
|
- name: Upload tagged release
|
||||||
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda
|
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||||
@@ -68,29 +68,29 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.2.0
|
uses: actions/checkout@v4.2.0
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3.4.0
|
uses: docker/setup-qemu-action@v3.6.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3.9.0
|
uses: docker/setup-buildx-action@v3.10.0
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3.3.0
|
uses: docker/login-action@v3.5.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Login to the Container registry
|
- name: Login to the Container registry
|
||||||
uses: docker/login-action@v3.3.0
|
uses: docker/login-action@v3.5.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5.6.1
|
uses: docker/metadata-action@v5.8.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ secrets.DOCKER_USERNAME }}/cinny
|
${{ secrets.DOCKER_USERNAME }}/cinny
|
||||||
ghcr.io/${{ github.repository }}
|
ghcr.io/${{ github.repository }}
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6.13.0
|
uses: docker/build-push-action@v6.18.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ RUN npm run build
|
|||||||
|
|
||||||
|
|
||||||
## App
|
## App
|
||||||
FROM nginx:1.27.4-alpine
|
FROM nginx:1.29.0-alpine
|
||||||
|
|
||||||
COPY --from=builder /src/dist /app
|
COPY --from=builder /src/dist /app
|
||||||
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf
|
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|||||||
33
README.md
33
README.md
@@ -19,27 +19,22 @@ A Matrix client focusing primarily on simple, elegant and secure interface. The
|
|||||||
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380">
|
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380">
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
* Web app is available at https://app.cinny.in and gets updated on each new release. The `dev` branch is continuously deployed at https://dev.cinny.in but keep in mind that it could have things broken.
|
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 [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop).
|
You can also download our desktop app from the [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop).
|
||||||
|
|
||||||
* To host Cinny on your own, download tarball of the app from [GitHub release](https://github.com/cinnyapp/cinny/releases/latest).
|
## Self-hosting
|
||||||
You can serve the application with a webserver of your choice by simply copying `dist/` directory to the webroot.
|
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).
|
||||||
To set default Homeserver on login, register and Explore Community page, place a customized [`config.json`](config.json) in webroot of your choice.
|
|
||||||
You will also need to setup redirects to serve the assests. An example setting of redirects for netlify is done in [`netlify.toml`](netlify.toml). You can also set `hashRouter.enabled = true` in [`config.json`](config.json) if you have trouble setting redirects.
|
|
||||||
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 change `base: '/app'`.
|
|
||||||
|
|
||||||
* Alternatively you can just pull the [DockerHub image](https://hub.docker.com/r/ajbura/cinny) by:
|
* The default homeservers and explore pages are defined in [`config.json`](config.json).
|
||||||
```
|
|
||||||
docker pull ajbura/cinny
|
|
||||||
```
|
|
||||||
or [ghcr image](https://github.com/cinnyapp/cinny/pkgs/container/cinny) by:
|
|
||||||
```
|
|
||||||
docker pull ghcr.io/cinnyapp/cinny:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
<details>
|
* 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).
|
||||||
<summary>PGP Public Key to verify tarball</summary>
|
* 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.
|
||||||
|
|
||||||
|
* 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'`.
|
||||||
|
|
||||||
|
<details><summary><b>PGP Public Key to verify tarball</b></summary>
|
||||||
|
|
||||||
```
|
```
|
||||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
@@ -87,8 +82,8 @@ mxFo+ioe/ABCufSmyqFye0psX3Sp
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
> We recommend using a version manager as versions change very quickly. You will likely need to switch
|
> [!TIP]
|
||||||
between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Iron LTS (v20).
|
> We recommend using a version manager as versions change very quickly. You will likely need to switch between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Iron LTS (v20).
|
||||||
|
|
||||||
Execute the following commands to start a development server:
|
Execute the following commands to start a development server:
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
6
contrib/caddy/caddyfile
Normal file
6
contrib/caddy/caddyfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
|
||||||
|
cinny.domain.tld {
|
||||||
|
root * /path/to/cinny/dist
|
||||||
|
try_files {path} / index.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
@@ -1,35 +1,34 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name cinny.domain.tld;
|
server_name cinny.domain.tld;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /.well-known/acme-challenge/ {
|
location /.well-known/acme-challenge/ {
|
||||||
alias /var/lib/letsencrypt/.well-known/acme-challenge/;
|
alias /var/lib/letsencrypt/.well-known/acme-challenge/;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
listen [::]:443 ssl;
|
listen [::]:443 ssl;
|
||||||
server_name cinny.domain.tld;
|
server_name cinny.domain.tld;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
root /opt/cinny/dist/;
|
root /opt/cinny/dist/;
|
||||||
|
|
||||||
rewrite ^/config.json$ /config.json break;
|
rewrite ^/config.json$ /config.json break;
|
||||||
rewrite ^/manifest.json$ /manifest.json break;
|
rewrite ^/manifest.json$ /manifest.json break;
|
||||||
|
|
||||||
rewrite ^.*/olm.wasm$ /olm.wasm break;
|
rewrite ^/sw.js$ /sw.js break;
|
||||||
rewrite ^/sw.js$ /sw.js break;
|
rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
|
||||||
rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
|
|
||||||
|
|
||||||
rewrite ^/public/(.*)$ /public/$1 break;
|
rewrite ^/public/(.*)$ /public/$1 break;
|
||||||
rewrite ^/assets/(.*)$ /assets/$1 break;
|
rewrite ^/assets/(.*)$ /assets/$1 break;
|
||||||
|
|
||||||
rewrite ^(.+)$ /index.html break;
|
rewrite ^(.+)$ /index.html break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ server {
|
|||||||
rewrite ^/config.json$ /config.json break;
|
rewrite ^/config.json$ /config.json break;
|
||||||
rewrite ^/manifest.json$ /manifest.json break;
|
rewrite ^/manifest.json$ /manifest.json break;
|
||||||
|
|
||||||
rewrite ^.*/olm.wasm$ /olm.wasm break;
|
|
||||||
rewrite ^/sw.js$ /sw.js break;
|
rewrite ^/sw.js$ /sw.js break;
|
||||||
rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
|
rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,6 @@
|
|||||||
to = "/sw.js"
|
to = "/sw.js"
|
||||||
status = 200
|
status = 200
|
||||||
|
|
||||||
[[redirects]]
|
|
||||||
from = "*/olm.wasm"
|
|
||||||
to = "/olm.wasm"
|
|
||||||
status = 200
|
|
||||||
force = true
|
|
||||||
|
|
||||||
[[redirects]]
|
[[redirects]]
|
||||||
from = "/pdf.worker.min.js"
|
from = "/pdf.worker.min.js"
|
||||||
|
|||||||
683
package-lock.json
generated
683
package-lock.json
generated
@@ -1,19 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "cinny",
|
"name": "cinny",
|
||||||
"version": "4.3.2",
|
"version": "4.9.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cinny",
|
"name": "cinny",
|
||||||
"version": "4.3.2",
|
"version": "4.9.0",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||||
"@fontsource/inter": "4.5.14",
|
"@fontsource/inter": "4.5.14",
|
||||||
"@matrix-org/olm": "3.2.15",
|
|
||||||
"@tanstack/react-query": "5.24.1",
|
"@tanstack/react-query": "5.24.1",
|
||||||
"@tanstack/react-query-devtools": "5.24.1",
|
"@tanstack/react-query-devtools": "5.24.1",
|
||||||
"@tanstack/react-virtual": "3.2.0",
|
"@tanstack/react-virtual": "3.2.0",
|
||||||
@@ -22,8 +21,10 @@
|
|||||||
"@vanilla-extract/recipes": "0.3.0",
|
"@vanilla-extract/recipes": "0.3.0",
|
||||||
"@vanilla-extract/vite-plugin": "3.7.1",
|
"@vanilla-extract/vite-plugin": "3.7.1",
|
||||||
"await-to-js": "3.0.0",
|
"await-to-js": "3.0.0",
|
||||||
|
"badwords-list": "2.0.1-4",
|
||||||
"blurhash": "2.0.4",
|
"blurhash": "2.0.4",
|
||||||
"browser-encrypt-attachment": "0.3.0",
|
"browser-encrypt-attachment": "0.3.0",
|
||||||
|
"chroma-js": "3.1.2",
|
||||||
"classnames": "2.3.2",
|
"classnames": "2.3.2",
|
||||||
"dateformat": "5.0.3",
|
"dateformat": "5.0.3",
|
||||||
"dayjs": "1.11.10",
|
"dayjs": "1.11.10",
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"flux": "4.0.3",
|
"flux": "4.0.3",
|
||||||
"focus-trap-react": "10.0.2",
|
"focus-trap-react": "10.0.2",
|
||||||
"folds": "2.1.0",
|
"folds": "2.2.0",
|
||||||
"formik": "2.4.6",
|
"formik": "2.4.6",
|
||||||
"html-dom-parser": "4.0.0",
|
"html-dom-parser": "4.0.0",
|
||||||
"html-react-parser": "4.2.0",
|
"html-react-parser": "4.2.0",
|
||||||
@@ -45,15 +46,16 @@
|
|||||||
"jotai": "2.6.0",
|
"jotai": "2.6.0",
|
||||||
"linkify-react": "4.1.3",
|
"linkify-react": "4.1.3",
|
||||||
"linkifyjs": "4.1.3",
|
"linkifyjs": "4.1.3",
|
||||||
"matrix-js-sdk": "35.0.0",
|
"matrix-js-sdk": "37.5.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "4.2.67",
|
"pdfjs-dist": "4.2.67",
|
||||||
"prismjs": "1.29.0",
|
"prismjs": "1.30.0",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-aria": "3.29.1",
|
"react-aria": "3.29.1",
|
||||||
"react-autosize-textarea": "7.1.0",
|
"react-autosize-textarea": "7.1.0",
|
||||||
"react-blurhash": "0.2.0",
|
"react-blurhash": "0.2.0",
|
||||||
|
"react-colorful": "5.6.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-error-boundary": "4.0.13",
|
"react-error-boundary": "4.0.13",
|
||||||
"react-google-recaptcha": "2.1.0",
|
"react-google-recaptcha": "2.1.0",
|
||||||
@@ -62,9 +64,10 @@
|
|||||||
"react-range": "1.8.14",
|
"react-range": "1.8.14",
|
||||||
"react-router-dom": "6.20.0",
|
"react-router-dom": "6.20.0",
|
||||||
"sanitize-html": "2.12.1",
|
"sanitize-html": "2.12.1",
|
||||||
"slate": "0.94.1",
|
"slate": "0.112.0",
|
||||||
"slate-history": "0.93.0",
|
"slate-dom": "0.112.2",
|
||||||
"slate-react": "0.98.4",
|
"slate-history": "0.110.3",
|
||||||
|
"slate-react": "0.112.1",
|
||||||
"tippy.js": "6.3.7",
|
"tippy.js": "6.3.7",
|
||||||
"ua-parser-js": "1.0.35"
|
"ua-parser-js": "1.0.35"
|
||||||
},
|
},
|
||||||
@@ -72,7 +75,9 @@
|
|||||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||||
"@rollup/plugin-inject": "5.0.3",
|
"@rollup/plugin-inject": "5.0.3",
|
||||||
"@rollup/plugin-wasm": "6.1.1",
|
"@rollup/plugin-wasm": "6.1.1",
|
||||||
|
"@types/chroma-js": "3.1.1",
|
||||||
"@types/file-saver": "2.0.5",
|
"@types/file-saver": "2.0.5",
|
||||||
|
"@types/is-hotkey": "0.1.10",
|
||||||
"@types/node": "18.11.18",
|
"@types/node": "18.11.18",
|
||||||
"@types/prismjs": "1.26.0",
|
"@types/prismjs": "1.26.0",
|
||||||
"@types/react": "18.2.39",
|
"@types/react": "18.2.39",
|
||||||
@@ -94,7 +99,7 @@
|
|||||||
"prettier": "2.8.1",
|
"prettier": "2.8.1",
|
||||||
"sass": "1.56.2",
|
"sass": "1.56.2",
|
||||||
"typescript": "4.9.4",
|
"typescript": "4.9.4",
|
||||||
"vite": "5.0.13",
|
"vite": "5.4.19",
|
||||||
"vite-plugin-pwa": "0.20.5",
|
"vite-plugin-pwa": "0.20.5",
|
||||||
"vite-plugin-static-copy": "1.0.4",
|
"vite-plugin-static-copy": "1.0.4",
|
||||||
"vite-plugin-top-level-await": "1.4.4"
|
"vite-plugin-top-level-await": "1.4.4"
|
||||||
@@ -2259,17 +2264,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
"version": "11.1.0",
|
"version": "14.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-11.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-14.1.0.tgz",
|
||||||
"integrity": "sha512-JPuO9RCVDklDjbFzMvZfQb7PuiFkLY72bniRSu81lRzkkrcbZtmKqBFMm9H4f2FSz+tHVkDnmsvn12I4sdJJ5A==",
|
"integrity": "sha512-vcSxHJIr6lP0Fgo8jl0sTHg+OZxZn+skGjiyB62erfgw/R2QqJl0ZVSY8SRcbk9LtHo/ZGld1tnaOyjL2e3cLQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@matrix-org/olm": {
|
"node_modules/@matrix-org/olm": {
|
||||||
"version": "3.2.15",
|
"version": "3.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@matrix-org/olm/-/olm-3.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@matrix-org/olm/-/olm-3.2.15.tgz",
|
||||||
"integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q=="
|
"integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
@@ -4570,6 +4577,13 @@
|
|||||||
"@babel/types": "^7.20.7"
|
"@babel/types": "^7.20.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chroma-js": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-SFCr4edNkZ1bGaLzGz7rgR1bRzVX4MmMxwsIa3/Bh6ose8v+hRpneoizHv0KChdjxaXyjRtaMq7sCuZSzPomQA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||||
@@ -4578,7 +4592,8 @@
|
|||||||
"node_modules/@types/events": {
|
"node_modules/@types/events": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
|
||||||
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g=="
|
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/file-saver": {
|
"node_modules/@types/file-saver": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
@@ -4598,7 +4613,9 @@
|
|||||||
"node_modules/@types/is-hotkey": {
|
"node_modules/@types/is-hotkey": {
|
||||||
"version": "0.1.10",
|
"version": "0.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/is-hotkey/-/is-hotkey-0.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/is-hotkey/-/is-hotkey-0.1.10.tgz",
|
||||||
"integrity": "sha512-RvC8KMw5BCac1NvRRyaHgMMEtBaZ6wh0pyPTBu7izn4Sj/AX9Y4aXU5c7rX8PnM/knsuUpC1IeoBkANtxBypsQ=="
|
"integrity": "sha512-RvC8KMw5BCac1NvRRyaHgMMEtBaZ6wh0pyPTBu7izn4Sj/AX9Y4aXU5c7rX8PnM/knsuUpC1IeoBkANtxBypsQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
@@ -4612,11 +4629,6 @@
|
|||||||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/lodash": {
|
|
||||||
"version": "4.17.14",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.14.tgz",
|
|
||||||
"integrity": "sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "18.11.18",
|
"version": "18.11.18",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz",
|
||||||
@@ -4670,7 +4682,8 @@
|
|||||||
"node_modules/@types/retry": {
|
"node_modules/@types/retry": {
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
|
||||||
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="
|
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/sanitize-html": {
|
"node_modules/@types/sanitize-html": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
@@ -5080,7 +5093,8 @@
|
|||||||
"node_modules/another-json": {
|
"node_modules/another-json": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz",
|
||||||
"integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg=="
|
"integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
@@ -5423,6 +5437,12 @@
|
|||||||
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/badwords-list": {
|
||||||
|
"version": "2.0.1-4",
|
||||||
|
"resolved": "https://registry.npmjs.org/badwords-list/-/badwords-list-2.0.1-4.tgz",
|
||||||
|
"integrity": "sha512-FxfZUp7B9yCnesNtFQS9v6PvZdxTYa14Q60JR6vhjdQdWI4naTjJIyx22JzoER8ooeT8SAAKoHLjKfCV7XgYUQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -5430,9 +5450,10 @@
|
|||||||
"devOptional": true
|
"devOptional": true
|
||||||
},
|
},
|
||||||
"node_modules/base-x": {
|
"node_modules/base-x": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
|
||||||
"integrity": "sha512-sMW3VGSX1QWVFA6l8U62MLKz29rRfpTlYdCqLdpLo1/Yd4zZwSbnUaDfciIAowAqvq7YFnWq9hrhdg1KYgc1lQ=="
|
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
@@ -5538,6 +5559,7 @@
|
|||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
|
||||||
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
|
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base-x": "^5.0.0"
|
"base-x": "^5.0.0"
|
||||||
}
|
}
|
||||||
@@ -5730,6 +5752,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chroma-js": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg==",
|
||||||
|
"license": "(BSD-3-Clause AND Apache-2.0)"
|
||||||
|
},
|
||||||
"node_modules/classnames": {
|
"node_modules/classnames": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
|
||||||
@@ -5797,9 +5825,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/compute-scroll-into-view": {
|
"node_modules/compute-scroll-into-view": {
|
||||||
"version": "1.0.20",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
|
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
|
||||||
"integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="
|
"integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/computed-style": {
|
"node_modules/computed-style": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
@@ -5833,6 +5862,7 @@
|
|||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -6984,6 +7014,7 @@
|
|||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.8.x"
|
"node": ">=0.8.x"
|
||||||
}
|
}
|
||||||
@@ -7234,15 +7265,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/folds": {
|
"node_modules/folds": {
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/folds/-/folds-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/folds/-/folds-2.2.0.tgz",
|
||||||
"integrity": "sha512-KwAG8bH3jsyZ9FKPMg+6ABV2YOcpp4nL0cCelsalnaPeRThkc5fgG1Xj5mhmdffYKjEXpEbERi5qmGbepgJryg==",
|
"integrity": "sha512-uOfck5eWEIK11rhOAEdSoPIvMXwv+D1Go03pxSAKezWVb+uRoBdmE6LEqiLOF+ac4DGmZRMPvpdDsXCg7EVNIg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@vanilla-extract/css": "^1.9.2",
|
"@vanilla-extract/css": "1.9.2",
|
||||||
"@vanilla-extract/recipes": "^0.3.0",
|
"@vanilla-extract/recipes": "0.3.0",
|
||||||
"classnames": "^2.3.2",
|
"classnames": "2.3.2",
|
||||||
"react": "^17.0.0",
|
"react": "17.0.0",
|
||||||
"react-dom": "^17.0.0"
|
"react-dom": "17.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
@@ -8145,7 +8177,8 @@
|
|||||||
"node_modules/is-hotkey": {
|
"node_modules/is-hotkey": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
|
||||||
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw=="
|
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/is-map": {
|
"node_modules/is-map": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
@@ -8541,6 +8574,7 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
||||||
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
|
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -8673,6 +8707,7 @@
|
|||||||
"version": "1.9.2",
|
"version": "1.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||||
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6.0"
|
"node": ">= 0.6.0"
|
||||||
},
|
},
|
||||||
@@ -8748,21 +8783,23 @@
|
|||||||
"node_modules/matrix-events-sdk": {
|
"node_modules/matrix-events-sdk": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz",
|
||||||
"integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA=="
|
"integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/matrix-js-sdk": {
|
"node_modules/matrix-js-sdk": {
|
||||||
"version": "35.0.0",
|
"version": "37.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-35.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-37.5.0.tgz",
|
||||||
"integrity": "sha512-X8hIsd/8x1SC9vRr8DiNKQxmdrfRujtvEWPz8mY4FxVDJG8HEGDHvqUmaSy2jrtnOUn4oHzGQVLFO3DnhsSf8w==",
|
"integrity": "sha512-5tyuAi5hnKud1UkVq8Z2/3c22hWGELBZzErJPZkE6Hju2uGUfGtrIx6uj6puv0ZjvsUU3X6Qgm8vdReKO1PGig==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
"@matrix-org/matrix-sdk-crypto-wasm": "^11.0.0",
|
"@matrix-org/matrix-sdk-crypto-wasm": "^14.0.1",
|
||||||
"@matrix-org/olm": "3.2.15",
|
"@matrix-org/olm": "3.2.15",
|
||||||
"another-json": "^0.2.0",
|
"another-json": "^0.2.0",
|
||||||
"bs58": "^6.0.0",
|
"bs58": "^6.0.0",
|
||||||
"content-type": "^1.0.4",
|
"content-type": "^1.0.4",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"loglevel": "^1.7.1",
|
"loglevel": "^1.9.2",
|
||||||
"matrix-events-sdk": "0.0.1",
|
"matrix-events-sdk": "0.0.1",
|
||||||
"matrix-widget-api": "^1.10.0",
|
"matrix-widget-api": "^1.10.0",
|
||||||
"oidc-client-ts": "^3.0.1",
|
"oidc-client-ts": "^3.0.1",
|
||||||
@@ -8776,21 +8813,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/matrix-js-sdk/node_modules/uuid": {
|
"node_modules/matrix-js-sdk/node_modules/uuid": {
|
||||||
"version": "11.0.5",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||||
"integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==",
|
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist/esm/bin/uuid"
|
"uuid": "dist/esm/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/matrix-widget-api": {
|
"node_modules/matrix-widget-api": {
|
||||||
"version": "1.12.0",
|
"version": "1.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.1.tgz",
|
||||||
"integrity": "sha512-6JRd9fJGGvuBRhcTg9wX+Skn/Q1wox3jdp5yYQKJ6pPw4urW9bkTR90APBKVDB1vorJKT44jml+lCzkDMRBjww==",
|
"integrity": "sha512-mkOHUVzaN018TCbObfGOSaMW2GoUxOfcxNNlTVx5/HeMk3OSQPQM0C9oEME5Liiv/dBUoSrEB64V8wF7e/gb1w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/events": "^3.0.0",
|
"@types/events": "^3.0.0",
|
||||||
"events": "^3.2.0"
|
"events": "^3.2.0"
|
||||||
@@ -9182,9 +9221,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/oidc-client-ts": {
|
"node_modules/oidc-client-ts": {
|
||||||
"version": "3.1.0",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.2.1.tgz",
|
||||||
"integrity": "sha512-IDopEXjiwjkmJLYZo6BTlvwOtnlSniWZkKZoXforC/oLZHC9wkIxd25Kwtmo5yKFMMVcsp3JY6bhcNJqdYk8+g==",
|
"integrity": "sha512-hS5AJ5s/x4bXhHvNJT1v+GGvzHUwdRWqNQQbSrp10L1IRmzfRGKQ3VWN3dstJb+oF3WtAyKezwD2+dTEIyBiAA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jwt-decode": "^4.0.0"
|
"jwt-decode": "^4.0.0"
|
||||||
},
|
},
|
||||||
@@ -9272,6 +9312,7 @@
|
|||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
|
||||||
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
|
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/retry": "0.12.0",
|
"@types/retry": "0.12.0",
|
||||||
"retry": "^0.13.1"
|
"retry": "^0.13.1"
|
||||||
@@ -9492,9 +9533,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prismjs": {
|
"node_modules/prismjs": {
|
||||||
"version": "1.29.0",
|
"version": "1.30.0",
|
||||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||||
"integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==",
|
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
@@ -9653,6 +9695,16 @@
|
|||||||
"react": ">=15"
|
"react": ">=15"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-colorful": {
|
||||||
|
"version": "5.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
|
||||||
|
"integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.2.0",
|
"version": "18.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
||||||
@@ -10024,6 +10076,7 @@
|
|||||||
"version": "0.13.1",
|
"version": "0.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||||
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
|
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
@@ -10225,17 +10278,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/scroll-into-view-if-needed": {
|
"node_modules/scroll-into-view-if-needed": {
|
||||||
"version": "2.2.31",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
|
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
|
||||||
"integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
|
"integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"compute-scroll-into-view": "^1.0.20"
|
"compute-scroll-into-view": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sdp-transform": {
|
"node_modules/sdp-transform": {
|
||||||
"version": "2.15.0",
|
"version": "2.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
|
||||||
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
|
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
|
||||||
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"sdp-verify": "checker.js"
|
"sdp-verify": "checker.js"
|
||||||
}
|
}
|
||||||
@@ -10458,19 +10513,39 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/slate": {
|
"node_modules/slate": {
|
||||||
"version": "0.94.1",
|
"version": "0.112.0",
|
||||||
"resolved": "https://registry.npmjs.org/slate/-/slate-0.94.1.tgz",
|
"resolved": "https://registry.npmjs.org/slate/-/slate-0.112.0.tgz",
|
||||||
"integrity": "sha512-GH/yizXr1ceBoZ9P9uebIaHe3dC/g6Plpf9nlUwnvoyf6V1UOYrRwkabtOCd3ZfIGxomY4P7lfgLr7FPH8/BKA==",
|
"integrity": "sha512-PRnfFgDA3tSop4OH47zu4M1R4Uuhm/AmASu29Qp7sGghVFb713kPBKEnSf1op7Lx/nCHkRlCa3ThfHtCBy+5Yw==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"immer": "^9.0.6",
|
"immer": "^10.0.3",
|
||||||
"is-plain-object": "^5.0.0",
|
"is-plain-object": "^5.0.0",
|
||||||
"tiny-warning": "^1.0.3"
|
"tiny-warning": "^1.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"slate": ">=0.99.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/slate-history": {
|
"node_modules/slate-history": {
|
||||||
"version": "0.93.0",
|
"version": "0.110.3",
|
||||||
"resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.93.0.tgz",
|
"resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.110.3.tgz",
|
||||||
"integrity": "sha512-Gr1GMGPipRuxIz41jD2/rbvzPj8eyar56TVMyJBvBeIpQSSjNISssvGNDYfJlSWM8eaRqf6DAcxMKzsLCYeX6g==",
|
"integrity": "sha512-sgdff4Usdflmw5ZUbhDkxFwCBQ2qlDKMMkF93w66KdV48vHOgN2BmLrf+2H8SdX8PYIpP/cTB0w8qWC2GwhDVA==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-plain-object": "^5.0.0"
|
"is-plain-object": "^5.0.0"
|
||||||
},
|
},
|
||||||
@@ -10479,30 +10554,35 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/slate-react": {
|
"node_modules/slate-react": {
|
||||||
"version": "0.98.4",
|
"version": "0.112.1",
|
||||||
"resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.98.4.tgz",
|
"resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.112.1.tgz",
|
||||||
"integrity": "sha512-8Of3v9hFuX8rIRc86LuuBhU9t8ps+9ARKL4yyhCrKQYZ93Ep/LFA3GvPGvtf3zYuVadZ8tkhRH8tbHOGNAndLw==",
|
"integrity": "sha512-V9b+waxPweXqAkSQmKQ1afG4Me6nVQACPpxQtHPIX02N7MXa5f5WilYv+bKt7vKKw+IZC2F0Gjzhv5BekVgP/A==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@juggle/resize-observer": "^3.4.0",
|
"@juggle/resize-observer": "^3.4.0",
|
||||||
"@types/is-hotkey": "^0.1.1",
|
"direction": "^1.0.4",
|
||||||
"@types/lodash": "^4.14.149",
|
"is-hotkey": "^0.2.0",
|
||||||
"direction": "^1.0.3",
|
|
||||||
"is-hotkey": "^0.1.6",
|
|
||||||
"is-plain-object": "^5.0.0",
|
"is-plain-object": "^5.0.0",
|
||||||
"lodash": "^4.17.4",
|
"lodash": "^4.17.21",
|
||||||
"scroll-into-view-if-needed": "^2.2.20",
|
"scroll-into-view-if-needed": "^3.1.0",
|
||||||
"tiny-invariant": "1.0.6"
|
"tiny-invariant": "1.3.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=16.8.0",
|
"react": ">=18.2.0",
|
||||||
"react-dom": ">=16.8.0",
|
"react-dom": ">=18.2.0",
|
||||||
"slate": ">=0.65.3"
|
"slate": ">=0.99.0",
|
||||||
|
"slate-dom": ">=0.110.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/slate-react/node_modules/is-hotkey": {
|
"node_modules/slate/node_modules/immer": {
|
||||||
"version": "0.1.8",
|
"version": "10.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
|
||||||
"integrity": "sha512-qs3NZ1INIS+H+yeo7cD9pDfwYV/jqRh1JG9S9zYrNudkoUQg7OL7ziXqRKu+InFjUIDoP2o6HIkLYMh1pcWgyQ=="
|
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/smob": {
|
"node_modules/smob": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
@@ -10866,9 +10946,10 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/tiny-invariant": {
|
"node_modules/tiny-invariant": {
|
||||||
"version": "1.0.6",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz",
|
||||||
"integrity": "sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA=="
|
"integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tiny-warning": {
|
"node_modules/tiny-warning": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
@@ -11118,7 +11199,8 @@
|
|||||||
"node_modules/unhomoglyph": {
|
"node_modules/unhomoglyph": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz",
|
||||||
"integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg=="
|
"integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
@@ -11249,13 +11331,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.0.13",
|
"version": "5.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
|
||||||
"integrity": "sha512-/9ovhv2M2dGTuA+dY93B9trfyWMDRQw2jdVBhHNP6wr0oF34wG2i/N55801iZIpgUpnHDm4F/FabGQLyc+eOgg==",
|
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.19.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.43",
|
||||||
"rollup": "^4.2.0"
|
"rollup": "^4.20.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vite": "bin/vite.js"
|
"vite": "bin/vite.js"
|
||||||
@@ -11274,6 +11357,7 @@
|
|||||||
"less": "*",
|
"less": "*",
|
||||||
"lightningcss": "^1.21.0",
|
"lightningcss": "^1.21.0",
|
||||||
"sass": "*",
|
"sass": "*",
|
||||||
|
"sass-embedded": "*",
|
||||||
"stylus": "*",
|
"stylus": "*",
|
||||||
"sugarss": "*",
|
"sugarss": "*",
|
||||||
"terser": "^5.4.0"
|
"terser": "^5.4.0"
|
||||||
@@ -11291,6 +11375,9 @@
|
|||||||
"sass": {
|
"sass": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"sass-embedded": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"stylus": {
|
"stylus": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
@@ -11390,6 +11477,412 @@
|
|||||||
"vite": ">=2.8"
|
"vite": ">=2.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/esbuild": {
|
||||||
|
"version": "0.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
|
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.21.5",
|
||||||
|
"@esbuild/android-arm": "0.21.5",
|
||||||
|
"@esbuild/android-arm64": "0.21.5",
|
||||||
|
"@esbuild/android-x64": "0.21.5",
|
||||||
|
"@esbuild/darwin-arm64": "0.21.5",
|
||||||
|
"@esbuild/darwin-x64": "0.21.5",
|
||||||
|
"@esbuild/freebsd-arm64": "0.21.5",
|
||||||
|
"@esbuild/freebsd-x64": "0.21.5",
|
||||||
|
"@esbuild/linux-arm": "0.21.5",
|
||||||
|
"@esbuild/linux-arm64": "0.21.5",
|
||||||
|
"@esbuild/linux-ia32": "0.21.5",
|
||||||
|
"@esbuild/linux-loong64": "0.21.5",
|
||||||
|
"@esbuild/linux-mips64el": "0.21.5",
|
||||||
|
"@esbuild/linux-ppc64": "0.21.5",
|
||||||
|
"@esbuild/linux-riscv64": "0.21.5",
|
||||||
|
"@esbuild/linux-s390x": "0.21.5",
|
||||||
|
"@esbuild/linux-x64": "0.21.5",
|
||||||
|
"@esbuild/netbsd-x64": "0.21.5",
|
||||||
|
"@esbuild/openbsd-x64": "0.21.5",
|
||||||
|
"@esbuild/sunos-x64": "0.21.5",
|
||||||
|
"@esbuild/win32-arm64": "0.21.5",
|
||||||
|
"@esbuild/win32-ia32": "0.21.5",
|
||||||
|
"@esbuild/win32-x64": "0.21.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/void-elements": {
|
"node_modules/void-elements": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||||
|
|||||||
23
package.json
23
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cinny",
|
"name": "cinny",
|
||||||
"version": "4.3.2",
|
"version": "4.9.0",
|
||||||
"description": "Yet another matrix client",
|
"description": "Yet another matrix client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -24,7 +24,6 @@
|
|||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||||
"@fontsource/inter": "4.5.14",
|
"@fontsource/inter": "4.5.14",
|
||||||
"@matrix-org/olm": "3.2.15",
|
|
||||||
"@tanstack/react-query": "5.24.1",
|
"@tanstack/react-query": "5.24.1",
|
||||||
"@tanstack/react-query-devtools": "5.24.1",
|
"@tanstack/react-query-devtools": "5.24.1",
|
||||||
"@tanstack/react-virtual": "3.2.0",
|
"@tanstack/react-virtual": "3.2.0",
|
||||||
@@ -33,8 +32,10 @@
|
|||||||
"@vanilla-extract/recipes": "0.3.0",
|
"@vanilla-extract/recipes": "0.3.0",
|
||||||
"@vanilla-extract/vite-plugin": "3.7.1",
|
"@vanilla-extract/vite-plugin": "3.7.1",
|
||||||
"await-to-js": "3.0.0",
|
"await-to-js": "3.0.0",
|
||||||
|
"badwords-list": "2.0.1-4",
|
||||||
"blurhash": "2.0.4",
|
"blurhash": "2.0.4",
|
||||||
"browser-encrypt-attachment": "0.3.0",
|
"browser-encrypt-attachment": "0.3.0",
|
||||||
|
"chroma-js": "3.1.2",
|
||||||
"classnames": "2.3.2",
|
"classnames": "2.3.2",
|
||||||
"dateformat": "5.0.3",
|
"dateformat": "5.0.3",
|
||||||
"dayjs": "1.11.10",
|
"dayjs": "1.11.10",
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"flux": "4.0.3",
|
"flux": "4.0.3",
|
||||||
"focus-trap-react": "10.0.2",
|
"focus-trap-react": "10.0.2",
|
||||||
"folds": "2.1.0",
|
"folds": "2.2.0",
|
||||||
"formik": "2.4.6",
|
"formik": "2.4.6",
|
||||||
"html-dom-parser": "4.0.0",
|
"html-dom-parser": "4.0.0",
|
||||||
"html-react-parser": "4.2.0",
|
"html-react-parser": "4.2.0",
|
||||||
@@ -56,15 +57,16 @@
|
|||||||
"jotai": "2.6.0",
|
"jotai": "2.6.0",
|
||||||
"linkify-react": "4.1.3",
|
"linkify-react": "4.1.3",
|
||||||
"linkifyjs": "4.1.3",
|
"linkifyjs": "4.1.3",
|
||||||
"matrix-js-sdk": "35.0.0",
|
"matrix-js-sdk": "37.5.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "4.2.67",
|
"pdfjs-dist": "4.2.67",
|
||||||
"prismjs": "1.29.0",
|
"prismjs": "1.30.0",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-aria": "3.29.1",
|
"react-aria": "3.29.1",
|
||||||
"react-autosize-textarea": "7.1.0",
|
"react-autosize-textarea": "7.1.0",
|
||||||
"react-blurhash": "0.2.0",
|
"react-blurhash": "0.2.0",
|
||||||
|
"react-colorful": "5.6.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-error-boundary": "4.0.13",
|
"react-error-boundary": "4.0.13",
|
||||||
"react-google-recaptcha": "2.1.0",
|
"react-google-recaptcha": "2.1.0",
|
||||||
@@ -73,9 +75,10 @@
|
|||||||
"react-range": "1.8.14",
|
"react-range": "1.8.14",
|
||||||
"react-router-dom": "6.20.0",
|
"react-router-dom": "6.20.0",
|
||||||
"sanitize-html": "2.12.1",
|
"sanitize-html": "2.12.1",
|
||||||
"slate": "0.94.1",
|
"slate": "0.112.0",
|
||||||
"slate-history": "0.93.0",
|
"slate-dom": "0.112.2",
|
||||||
"slate-react": "0.98.4",
|
"slate-history": "0.110.3",
|
||||||
|
"slate-react": "0.112.1",
|
||||||
"tippy.js": "6.3.7",
|
"tippy.js": "6.3.7",
|
||||||
"ua-parser-js": "1.0.35"
|
"ua-parser-js": "1.0.35"
|
||||||
},
|
},
|
||||||
@@ -83,7 +86,9 @@
|
|||||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||||
"@rollup/plugin-inject": "5.0.3",
|
"@rollup/plugin-inject": "5.0.3",
|
||||||
"@rollup/plugin-wasm": "6.1.1",
|
"@rollup/plugin-wasm": "6.1.1",
|
||||||
|
"@types/chroma-js": "3.1.1",
|
||||||
"@types/file-saver": "2.0.5",
|
"@types/file-saver": "2.0.5",
|
||||||
|
"@types/is-hotkey": "0.1.10",
|
||||||
"@types/node": "18.11.18",
|
"@types/node": "18.11.18",
|
||||||
"@types/prismjs": "1.26.0",
|
"@types/prismjs": "1.26.0",
|
||||||
"@types/react": "18.2.39",
|
"@types/react": "18.2.39",
|
||||||
@@ -105,7 +110,7 @@
|
|||||||
"prettier": "2.8.1",
|
"prettier": "2.8.1",
|
||||||
"sass": "1.56.2",
|
"sass": "1.56.2",
|
||||||
"typescript": "4.9.4",
|
"typescript": "4.9.4",
|
||||||
"vite": "5.0.13",
|
"vite": "5.4.19",
|
||||||
"vite-plugin-pwa": "0.20.5",
|
"vite-plugin-pwa": "0.20.5",
|
||||||
"vite-plugin-static-copy": "1.0.4",
|
"vite-plugin-static-copy": "1.0.4",
|
||||||
"vite-plugin-top-level-await": "1.4.4"
|
"vite-plugin-top-level-await": "1.4.4"
|
||||||
|
|||||||
@@ -4,10 +4,25 @@ import PropTypes from 'prop-types';
|
|||||||
import dateFormat from 'dateformat';
|
import dateFormat from 'dateformat';
|
||||||
import { isInSameDay } from '../../../util/common';
|
import { isInSameDay } from '../../../util/common';
|
||||||
|
|
||||||
function Time({ timestamp, fullTime }) {
|
/**
|
||||||
|
* Renders a formatted timestamp.
|
||||||
|
*
|
||||||
|
* Displays the time in hour:minute format if the message is from today or yesterday, unless `fullTime` is true.
|
||||||
|
* For older messages, it shows the date and time.
|
||||||
|
*
|
||||||
|
* @param {number} timestamp - The timestamp to display.
|
||||||
|
* @param {boolean} [fullTime=false] - If true, always show the full date and time.
|
||||||
|
* @param {boolean} hour24Clock - Whether to use 24-hour time format.
|
||||||
|
* @param {string} dateFormatString - Format string for the date part.
|
||||||
|
* @returns {JSX.Element} A <time> element with the formatted date/time.
|
||||||
|
*/
|
||||||
|
function Time({ timestamp, fullTime, hour24Clock, dateFormatString }) {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
|
|
||||||
const formattedFullTime = dateFormat(date, 'dd mmmm yyyy, hh:MM TT');
|
const formattedFullTime = dateFormat(
|
||||||
|
date,
|
||||||
|
hour24Clock ? 'dd mmmm yyyy, HH:MM' : 'dd mmmm yyyy, hh:MM TT'
|
||||||
|
);
|
||||||
let formattedDate = formattedFullTime;
|
let formattedDate = formattedFullTime;
|
||||||
|
|
||||||
if (!fullTime) {
|
if (!fullTime) {
|
||||||
@@ -16,17 +31,19 @@ function Time({ timestamp, fullTime }) {
|
|||||||
compareDate.setDate(compareDate.getDate() - 1);
|
compareDate.setDate(compareDate.getDate() - 1);
|
||||||
const isYesterday = isInSameDay(date, compareDate);
|
const isYesterday = isInSameDay(date, compareDate);
|
||||||
|
|
||||||
formattedDate = dateFormat(date, isToday || isYesterday ? 'hh:MM TT' : 'dd/mm/yyyy');
|
const timeFormat = hour24Clock ? 'HH:MM' : 'hh:MM TT';
|
||||||
|
|
||||||
|
formattedDate = dateFormat(
|
||||||
|
date,
|
||||||
|
isToday || isYesterday ? timeFormat : dateFormatString.toLowerCase()
|
||||||
|
);
|
||||||
if (isYesterday) {
|
if (isYesterday) {
|
||||||
formattedDate = `Yesterday, ${formattedDate}`;
|
formattedDate = `Yesterday, ${formattedDate}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<time
|
<time dateTime={date.toISOString()} title={formattedFullTime}>
|
||||||
dateTime={date.toISOString()}
|
|
||||||
title={formattedFullTime}
|
|
||||||
>
|
|
||||||
{formattedDate}
|
{formattedDate}
|
||||||
</time>
|
</time>
|
||||||
);
|
);
|
||||||
@@ -39,6 +56,8 @@ Time.defaultProps = {
|
|||||||
Time.propTypes = {
|
Time.propTypes = {
|
||||||
timestamp: PropTypes.number.isRequired,
|
timestamp: PropTypes.number.isRequired,
|
||||||
fullTime: PropTypes.bool,
|
fullTime: PropTypes.bool,
|
||||||
|
hour24Clock: PropTypes.bool.isRequired,
|
||||||
|
dateFormatString: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Time;
|
export default Time;
|
||||||
|
|||||||
322
src/app/components/AccountDataEditor.tsx
Normal file
322
src/app/components/AccountDataEditor.tsx
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
TextArea as TextAreaComponent,
|
||||||
|
color,
|
||||||
|
Spinner,
|
||||||
|
Chip,
|
||||||
|
Scroll,
|
||||||
|
config,
|
||||||
|
} from 'folds';
|
||||||
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { Cursor } from '../plugins/text-area';
|
||||||
|
import { syntaxErrorPosition } from '../utils/dom';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||||
|
import { Page, PageHeader } from './page';
|
||||||
|
import { useAlive } from '../hooks/useAlive';
|
||||||
|
import { SequenceCard } from './sequence-card';
|
||||||
|
import { TextViewerContent } from './text-viewer';
|
||||||
|
import { useTextAreaCodeEditor } from '../hooks/useTextAreaCodeEditor';
|
||||||
|
|
||||||
|
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||||
|
|
||||||
|
export type AccountDataSubmitCallback = (type: string, content: object) => Promise<void>;
|
||||||
|
|
||||||
|
type AccountDataInfo = {
|
||||||
|
type: string;
|
||||||
|
content: object;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountDataEditProps = {
|
||||||
|
type: string;
|
||||||
|
defaultContent: string;
|
||||||
|
submitChange: AccountDataSubmitCallback;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSave: (info: AccountDataInfo) => void;
|
||||||
|
};
|
||||||
|
function AccountDataEdit({
|
||||||
|
type,
|
||||||
|
defaultContent,
|
||||||
|
submitChange,
|
||||||
|
onCancel,
|
||||||
|
onSave,
|
||||||
|
}: AccountDataEditProps) {
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
|
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [jsonError, setJSONError] = useState<SyntaxError>();
|
||||||
|
|
||||||
|
const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor(
|
||||||
|
textAreaRef,
|
||||||
|
EDITOR_INTENT_SPACE_COUNT
|
||||||
|
);
|
||||||
|
|
||||||
|
const [submitState, submit] = useAsyncCallback<void, MatrixError, [string, object]>(submitChange);
|
||||||
|
const submitting = submitState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
|
||||||
|
const target = evt.target as HTMLFormElement | undefined;
|
||||||
|
const typeInput = target?.typeInput as HTMLInputElement | undefined;
|
||||||
|
const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
|
||||||
|
if (!typeInput || !contentTextArea) return;
|
||||||
|
|
||||||
|
const typeStr = typeInput.value.trim();
|
||||||
|
const contentStr = contentTextArea.value.trim();
|
||||||
|
|
||||||
|
let parsedContent: object;
|
||||||
|
try {
|
||||||
|
parsedContent = JSON.parse(contentStr);
|
||||||
|
} catch (e) {
|
||||||
|
setJSONError(e as SyntaxError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setJSONError(undefined);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!typeStr ||
|
||||||
|
parsedContent === null ||
|
||||||
|
defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(typeStr, parsedContent).then(() => {
|
||||||
|
if (alive()) {
|
||||||
|
onSave({
|
||||||
|
type: typeStr,
|
||||||
|
content: parsedContent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (jsonError) {
|
||||||
|
const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
|
||||||
|
const cursor = new Cursor(errorPosition, errorPosition, 'none');
|
||||||
|
operations.select(cursor);
|
||||||
|
getTarget()?.focus();
|
||||||
|
}
|
||||||
|
}, [jsonError, operations, getTarget]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
grow="Yes"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S400,
|
||||||
|
}}
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
aria-disabled={submitting}
|
||||||
|
>
|
||||||
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
|
<Text size="L400">Account Data</Text>
|
||||||
|
<Box gap="300">
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Input
|
||||||
|
variant={type.length > 0 || submitting ? 'SurfaceVariant' : 'Background'}
|
||||||
|
name="typeInput"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
readOnly={type.length > 0 || submitting}
|
||||||
|
defaultValue={type}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="Success"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
|
||||||
|
>
|
||||||
|
<Text size="B400">Save</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
<Text size="B400">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{submitState.status === AsyncStatus.Error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
<b>{submitState.error.message}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
|
<Box shrink="No">
|
||||||
|
<Text size="L400">JSON Content</Text>
|
||||||
|
</Box>
|
||||||
|
<TextAreaComponent
|
||||||
|
ref={textAreaRef}
|
||||||
|
name="contentTextArea"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
defaultValue={defaultContent}
|
||||||
|
resize="None"
|
||||||
|
spellCheck="false"
|
||||||
|
required
|
||||||
|
readOnly={submitting}
|
||||||
|
/>
|
||||||
|
{jsonError && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
<b>
|
||||||
|
{jsonError.name}: {jsonError.message}
|
||||||
|
</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountDataViewProps = {
|
||||||
|
type: string;
|
||||||
|
defaultContent: string;
|
||||||
|
onEdit: () => void;
|
||||||
|
};
|
||||||
|
function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S400,
|
||||||
|
}}
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<Box shrink="No" gap="300" alignItems="End">
|
||||||
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
|
<Text size="L400">Account Data</Text>
|
||||||
|
<Input
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
readOnly
|
||||||
|
defaultValue={type}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
|
||||||
|
<Text size="B400">Edit</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
|
<Text size="L400">JSON Content</Text>
|
||||||
|
<SequenceCard variant="SurfaceVariant">
|
||||||
|
<Scroll visibility="Always" size="300" hideTrack>
|
||||||
|
<TextViewerContent
|
||||||
|
size="T300"
|
||||||
|
style={{
|
||||||
|
padding: `${config.space.S300} ${config.space.S100} ${config.space.S300} ${config.space.S300}`,
|
||||||
|
}}
|
||||||
|
text={defaultContent}
|
||||||
|
langName="JSON"
|
||||||
|
/>
|
||||||
|
</Scroll>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AccountDataEditorProps = {
|
||||||
|
type?: string;
|
||||||
|
content?: object;
|
||||||
|
submitChange: AccountDataSubmitCallback;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AccountDataEditor({
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
submitChange,
|
||||||
|
requestClose,
|
||||||
|
}: AccountDataEditorProps) {
|
||||||
|
const [data, setData] = useState<AccountDataInfo>({
|
||||||
|
type: type ?? '',
|
||||||
|
content: content ?? {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [edit, setEdit] = useState(!type);
|
||||||
|
|
||||||
|
const closeEdit = useCallback(() => {
|
||||||
|
if (!type) {
|
||||||
|
requestClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEdit(false);
|
||||||
|
}, [type, requestClose]);
|
||||||
|
|
||||||
|
const handleSave = useCallback((info: AccountDataInfo) => {
|
||||||
|
setData(info);
|
||||||
|
setEdit(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const contentJSONStr = useMemo(
|
||||||
|
() => JSON.stringify(data.content, null, EDITOR_INTENT_SPACE_COUNT),
|
||||||
|
[data.content]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageHeader outlined={false} balance>
|
||||||
|
<Box alignItems="Center" grow="Yes" gap="200">
|
||||||
|
<Box alignItems="Inherit" grow="Yes" gap="200">
|
||||||
|
<Chip
|
||||||
|
size="500"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={requestClose}
|
||||||
|
before={<Icon size="100" src={Icons.ArrowLeft} />}
|
||||||
|
>
|
||||||
|
<Text size="T300">Developer Tools</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
{edit ? (
|
||||||
|
<AccountDataEdit
|
||||||
|
type={data.type}
|
||||||
|
defaultContent={contentJSONStr}
|
||||||
|
submitChange={submitChange}
|
||||||
|
onCancel={closeEdit}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AccountDataView
|
||||||
|
type={data.type}
|
||||||
|
defaultContent={contentJSONStr}
|
||||||
|
onEdit={() => setEdit(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/app/components/BetaNoticeBadge.tsx
Normal file
25
src/app/components/BetaNoticeBadge.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { TooltipProvider, Tooltip, Box, Text, Badge, toRem } from 'folds';
|
||||||
|
|
||||||
|
export function BetaNoticeBadge() {
|
||||||
|
return (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Right"
|
||||||
|
align="Center"
|
||||||
|
tooltip={
|
||||||
|
<Tooltip style={{ maxWidth: toRem(200) }}>
|
||||||
|
<Box direction="Column">
|
||||||
|
<Text size="L400">Notice</Text>
|
||||||
|
<Text size="T200">This feature is under testing and may change over time.</Text>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<Badge size="500" tabIndex={0} ref={triggerRef} variant="Primary" fill="Solid">
|
||||||
|
<Text size="L400">Beta</Text>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { ReactNode, useCallback, useEffect } from 'react';
|
|
||||||
import { Capabilities } from 'matrix-js-sdk';
|
|
||||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
|
||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
|
||||||
import { MediaConfig } from '../hooks/useMediaConfig';
|
|
||||||
import { promiseFulfilledResult } from '../utils/common';
|
|
||||||
|
|
||||||
type CapabilitiesAndMediaConfigLoaderProps = {
|
|
||||||
children: (capabilities?: Capabilities, mediaConfig?: MediaConfig) => ReactNode;
|
|
||||||
};
|
|
||||||
export function CapabilitiesAndMediaConfigLoader({
|
|
||||||
children,
|
|
||||||
}: CapabilitiesAndMediaConfigLoaderProps) {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
|
|
||||||
const [state, load] = useAsyncCallback<
|
|
||||||
[Capabilities | undefined, MediaConfig | undefined],
|
|
||||||
unknown,
|
|
||||||
[]
|
|
||||||
>(
|
|
||||||
useCallback(async () => {
|
|
||||||
const result = await Promise.allSettled([mx.getCapabilities(), mx.getMediaConfig()]);
|
|
||||||
const capabilities = promiseFulfilledResult(result[0]);
|
|
||||||
const mediaConfig = promiseFulfilledResult(result[1]);
|
|
||||||
return [capabilities, mediaConfig];
|
|
||||||
}, [mx])
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const [capabilities, mediaConfig] =
|
|
||||||
state.status === AsyncStatus.Success ? state.data : [undefined, undefined];
|
|
||||||
return children(capabilities, mediaConfig);
|
|
||||||
}
|
|
||||||
59
src/app/components/HexColorPickerPopOut.tsx
Normal file
59
src/app/components/HexColorPickerPopOut.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { Box, Button, config, Menu, PopOut, RectCords, Text } from 'folds';
|
||||||
|
import React, { MouseEventHandler, ReactNode, useState } from 'react';
|
||||||
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
|
||||||
|
type HexColorPickerPopOutProps = {
|
||||||
|
children: (onOpen: MouseEventHandler<HTMLElement>, opened: boolean) => ReactNode;
|
||||||
|
picker: ReactNode;
|
||||||
|
onRemove?: () => void;
|
||||||
|
};
|
||||||
|
export function HexColorPickerPopOut({ picker, onRemove, children }: HexColorPickerPopOutProps) {
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const handleOpen: MouseEventHandler<HTMLElement> = (evt) => {
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Bottom"
|
||||||
|
align="Center"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
onDeactivate: () => setCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
style={{
|
||||||
|
padding: config.space.S100,
|
||||||
|
borderRadius: config.radii.R500,
|
||||||
|
overflow: 'initial',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
{picker}
|
||||||
|
{onRemove && (
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="400"
|
||||||
|
onClick={() => onRemove()}
|
||||||
|
>
|
||||||
|
<Text size="B300">Remove</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children(handleOpen, !!cords)}
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
src/app/components/JoinRulesSwitcher.tsx
Normal file
155
src/app/components/JoinRulesSwitcher.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
config,
|
||||||
|
Box,
|
||||||
|
MenuItem,
|
||||||
|
Text,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
IconSrc,
|
||||||
|
RectCords,
|
||||||
|
PopOut,
|
||||||
|
Menu,
|
||||||
|
Button,
|
||||||
|
Spinner,
|
||||||
|
} from 'folds';
|
||||||
|
import { JoinRule } from 'matrix-js-sdk';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
|
||||||
|
export type ExtraJoinRules = 'knock_restricted';
|
||||||
|
export type ExtendedJoinRules = JoinRule | ExtraJoinRules;
|
||||||
|
|
||||||
|
type JoinRuleIcons = Record<ExtendedJoinRules, IconSrc>;
|
||||||
|
export const useRoomJoinRuleIcon = (): 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,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
export const useSpaceJoinRuleIcon = (): JoinRuleIcons =>
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
[JoinRule.Invite]: Icons.SpaceLock,
|
||||||
|
[JoinRule.Knock]: Icons.SpaceLock,
|
||||||
|
knock_restricted: Icons.Space,
|
||||||
|
[JoinRule.Restricted]: Icons.Space,
|
||||||
|
[JoinRule.Public]: Icons.SpaceGlobe,
|
||||||
|
[JoinRule.Private]: Icons.SpaceLock,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
type JoinRuleLabels = Record<ExtendedJoinRules, string>;
|
||||||
|
export const useRoomJoinRuleLabel = (): JoinRuleLabels =>
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
[JoinRule.Invite]: 'Invite Only',
|
||||||
|
[JoinRule.Knock]: 'Knock & Invite',
|
||||||
|
knock_restricted: 'Space Members or Knock',
|
||||||
|
[JoinRule.Restricted]: 'Space Members',
|
||||||
|
[JoinRule.Public]: 'Public',
|
||||||
|
[JoinRule.Private]: 'Invite Only',
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
type JoinRulesSwitcherProps<T extends ExtendedJoinRules[]> = {
|
||||||
|
icons: JoinRuleIcons;
|
||||||
|
labels: JoinRuleLabels;
|
||||||
|
rules: T;
|
||||||
|
value: T[number];
|
||||||
|
onChange: (value: T[number]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
changing?: boolean;
|
||||||
|
};
|
||||||
|
export function JoinRulesSwitcher<T extends ExtendedJoinRules[]>({
|
||||||
|
icons,
|
||||||
|
labels,
|
||||||
|
rules,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
changing,
|
||||||
|
}: JoinRulesSwitcherProps<T>) {
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(selectedRule: ExtendedJoinRules) => {
|
||||||
|
setCords(undefined);
|
||||||
|
onChange(selectedRule);
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{rules.map((rule) => (
|
||||||
|
<MenuItem
|
||||||
|
key={rule}
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={value === rule}
|
||||||
|
onClick={() => handleChange(rule)}
|
||||||
|
before={<Icon size="100" src={icons[rule]} />}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="T300">{labels[rule]}</Text>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
before={<Icon size="100" src={icons[value] ?? icons[JoinRule.Restricted]} />}
|
||||||
|
after={
|
||||||
|
changing ? (
|
||||||
|
<Spinner size="100" variant="Secondary" fill="Soft" />
|
||||||
|
) : (
|
||||||
|
<Icon size="100" src={Icons.ChevronBottom} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={handleOpenMenu}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Text size="B300">{labels[value] ?? 'Unsupported'}</Text>
|
||||||
|
</Button>
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/app/components/MemberSortMenu.tsx
Normal file
45
src/app/components/MemberSortMenu.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import React from 'react';
|
||||||
|
import { config, Menu, MenuItem, Text } from 'folds';
|
||||||
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
import { useMemberSortMenu } from '../hooks/useMemberSort';
|
||||||
|
|
||||||
|
type MemberSortMenuProps = {
|
||||||
|
requestClose: () => void;
|
||||||
|
selected: number;
|
||||||
|
onSelect: (index: number) => void;
|
||||||
|
};
|
||||||
|
export function MemberSortMenu({ selected, onSelect, requestClose }: MemberSortMenuProps) {
|
||||||
|
const memberSortMenu = useMemberSortMenu();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: requestClose,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu style={{ padding: config.space.S100 }}>
|
||||||
|
{memberSortMenu.map((menuItem, index) => (
|
||||||
|
<MenuItem
|
||||||
|
key={menuItem.name}
|
||||||
|
variant="Surface"
|
||||||
|
aria-pressed={selected === index}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(index);
|
||||||
|
requestClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="T300">{menuItem.name}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/app/components/MembershipFilterMenu.tsx
Normal file
49
src/app/components/MembershipFilterMenu.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import React from 'react';
|
||||||
|
import { config, Menu, MenuItem, Text } from 'folds';
|
||||||
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
import { useMembershipFilterMenu } from '../hooks/useMemberFilter';
|
||||||
|
|
||||||
|
type MembershipFilterMenuProps = {
|
||||||
|
requestClose: () => void;
|
||||||
|
selected: number;
|
||||||
|
onSelect: (index: number) => void;
|
||||||
|
};
|
||||||
|
export function MembershipFilterMenu({
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
requestClose,
|
||||||
|
}: MembershipFilterMenuProps) {
|
||||||
|
const membershipFilterMenu = useMembershipFilterMenu();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: requestClose,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu style={{ padding: config.space.S100 }}>
|
||||||
|
{membershipFilterMenu.map((menuItem, index) => (
|
||||||
|
<MenuItem
|
||||||
|
key={menuItem.name}
|
||||||
|
variant="Surface"
|
||||||
|
aria-pressed={selected === index}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(index);
|
||||||
|
requestClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="T300">{menuItem.name}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { MsgType } from 'matrix-js-sdk';
|
import { MsgType } from 'matrix-js-sdk';
|
||||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||||
import { Opts } from 'linkifyjs';
|
import { Opts } from 'linkifyjs';
|
||||||
|
import { config } from 'folds';
|
||||||
import {
|
import {
|
||||||
AudioContent,
|
AudioContent,
|
||||||
DownloadFile,
|
DownloadFile,
|
||||||
@@ -29,7 +30,7 @@ import { ImageViewer } from './image-viewer';
|
|||||||
import { PdfViewer } from './Pdf-viewer';
|
import { PdfViewer } from './Pdf-viewer';
|
||||||
import { TextViewer } from './text-viewer';
|
import { TextViewer } from './text-viewer';
|
||||||
import { testMatrixTo } from '../plugins/matrix-to';
|
import { testMatrixTo } from '../plugins/matrix-to';
|
||||||
import {IImageContent} from "../../types/matrix/common";
|
import { IImageContent } from '../../types/matrix/common';
|
||||||
|
|
||||||
type RenderMessageContentProps = {
|
type RenderMessageContentProps = {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -70,9 +71,10 @@ export function RenderMessageContent({
|
|||||||
};
|
};
|
||||||
const renderCaption = () => {
|
const renderCaption = () => {
|
||||||
const content: IImageContent = getContent();
|
const content: IImageContent = getContent();
|
||||||
if(content.filename && content.filename !== content.body) {
|
if (content.filename && content.filename !== content.body) {
|
||||||
return (
|
return (
|
||||||
<MText
|
<MText
|
||||||
|
style={{ marginTop: config.space.S200 }}
|
||||||
edited={edited}
|
edited={edited}
|
||||||
content={content}
|
content={content}
|
||||||
renderBody={(props) => (
|
renderBody={(props) => (
|
||||||
@@ -85,41 +87,40 @@ export function RenderMessageContent({
|
|||||||
)}
|
)}
|
||||||
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
|
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
};
|
||||||
|
|
||||||
const renderFile = () => (
|
const renderFile = () => (
|
||||||
<>
|
<>
|
||||||
<MFile
|
<MFile
|
||||||
content={getContent()}
|
content={getContent()}
|
||||||
renderFileContent={({ body, mimeType, info, encInfo, url }) => (
|
renderFileContent={({ body, mimeType, info, encInfo, url }) => (
|
||||||
<FileContent
|
<FileContent
|
||||||
body={body}
|
body={body}
|
||||||
mimeType={mimeType}
|
mimeType={mimeType}
|
||||||
renderAsPdfFile={() => (
|
renderAsPdfFile={() => (
|
||||||
<ReadPdfFile
|
<ReadPdfFile
|
||||||
body={body}
|
body={body}
|
||||||
mimeType={mimeType}
|
mimeType={mimeType}
|
||||||
url={url}
|
url={url}
|
||||||
encInfo={encInfo}
|
encInfo={encInfo}
|
||||||
renderViewer={(p) => <PdfViewer {...p} />}
|
renderViewer={(p) => <PdfViewer {...p} />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderAsTextFile={() => (
|
renderAsTextFile={() => (
|
||||||
<ReadTextFile
|
<ReadTextFile
|
||||||
body={body}
|
body={body}
|
||||||
mimeType={mimeType}
|
mimeType={mimeType}
|
||||||
url={url}
|
url={url}
|
||||||
encInfo={encInfo}
|
encInfo={encInfo}
|
||||||
renderViewer={(p) => <TextViewer {...p} />}
|
renderViewer={(p) => <TextViewer {...p} />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DownloadFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} info={info} />
|
<DownloadFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} info={info} />
|
||||||
</FileContent>
|
</FileContent>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
outlined={outlineAttachment}
|
outlined={outlineAttachment}
|
||||||
/>
|
/>
|
||||||
@@ -188,12 +189,12 @@ export function RenderMessageContent({
|
|||||||
<MImage
|
<MImage
|
||||||
content={getContent()}
|
content={getContent()}
|
||||||
renderImageContent={(props) => (
|
renderImageContent={(props) => (
|
||||||
<ImageContent
|
<ImageContent
|
||||||
{...props}
|
{...props}
|
||||||
autoPlay={mediaAutoLoad}
|
autoPlay={mediaAutoLoad}
|
||||||
renderImage={(p) => <Image {...p} loading="lazy" />}
|
renderImage={(p) => <Image {...p} loading="lazy" />}
|
||||||
renderViewer={(p) => <ImageViewer {...p} />}
|
renderViewer={(p) => <ImageViewer {...p} />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
outlined={outlineAttachment}
|
outlined={outlineAttachment}
|
||||||
/>
|
/>
|
||||||
@@ -218,13 +219,13 @@ export function RenderMessageContent({
|
|||||||
renderThumbnail={
|
renderThumbnail={
|
||||||
mediaAutoLoad
|
mediaAutoLoad
|
||||||
? () => (
|
? () => (
|
||||||
<ThumbnailContent
|
<ThumbnailContent
|
||||||
info={info}
|
info={info}
|
||||||
renderImage={(src) => (
|
renderImage={(src) => (
|
||||||
<Image alt={body} title={body} src={src} loading="lazy" />
|
<Image alt={body} title={body} src={src} loading="lazy" />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
renderVideo={(p) => <Video {...p} />}
|
renderVideo={(p) => <Video {...p} />}
|
||||||
@@ -234,7 +235,6 @@ export function RenderMessageContent({
|
|||||||
/>
|
/>
|
||||||
{renderCaption()}
|
{renderCaption()}
|
||||||
</>
|
</>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +251,6 @@ export function RenderMessageContent({
|
|||||||
/>
|
/>
|
||||||
{renderCaption()}
|
{renderCaption()}
|
||||||
</>
|
</>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
120
src/app/components/RoomNotificationSwitcher.tsx
Normal file
120
src/app/components/RoomNotificationSwitcher.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { Box, config, Icon, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
|
||||||
|
import React, { MouseEventHandler, ReactNode, useMemo, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
import {
|
||||||
|
getRoomNotificationModeIcon,
|
||||||
|
RoomNotificationMode,
|
||||||
|
useSetRoomNotificationPreference,
|
||||||
|
} from '../hooks/useRoomsNotificationPreferences';
|
||||||
|
import { AsyncStatus } from '../hooks/useAsyncCallback';
|
||||||
|
|
||||||
|
const useRoomNotificationModes = (): RoomNotificationMode[] =>
|
||||||
|
useMemo(
|
||||||
|
() => [
|
||||||
|
RoomNotificationMode.Unset,
|
||||||
|
RoomNotificationMode.AllMessages,
|
||||||
|
RoomNotificationMode.SpecialMessages,
|
||||||
|
RoomNotificationMode.Mute,
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const useRoomNotificationModeStr = (): Record<RoomNotificationMode, string> =>
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
[RoomNotificationMode.Unset]: 'Default',
|
||||||
|
[RoomNotificationMode.AllMessages]: 'All Messages',
|
||||||
|
[RoomNotificationMode.SpecialMessages]: 'Mention & Keywords',
|
||||||
|
[RoomNotificationMode.Mute]: 'Mute',
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
type NotificationModeSwitcherProps = {
|
||||||
|
roomId: string;
|
||||||
|
value?: RoomNotificationMode;
|
||||||
|
children: (
|
||||||
|
handleOpen: MouseEventHandler<HTMLButtonElement>,
|
||||||
|
opened: boolean,
|
||||||
|
changing: boolean
|
||||||
|
) => ReactNode;
|
||||||
|
};
|
||||||
|
export function RoomNotificationModeSwitcher({
|
||||||
|
roomId,
|
||||||
|
value = RoomNotificationMode.Unset,
|
||||||
|
children,
|
||||||
|
}: NotificationModeSwitcherProps) {
|
||||||
|
const modes = useRoomNotificationModes();
|
||||||
|
const modeToStr = useRoomNotificationModeStr();
|
||||||
|
|
||||||
|
const { modeState, setMode } = useSetRoomNotificationPreference(roomId);
|
||||||
|
const changing = modeState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setMenuCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (mode: RoomNotificationMode) => {
|
||||||
|
if (changing) return;
|
||||||
|
setMode(mode, value);
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={menuCords}
|
||||||
|
offset={5}
|
||||||
|
position="Right"
|
||||||
|
align="Start"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: handleClose,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{modes.map((mode) => (
|
||||||
|
<MenuItem
|
||||||
|
key={mode}
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
aria-pressed={mode === value}
|
||||||
|
radii="300"
|
||||||
|
disabled={changing}
|
||||||
|
onClick={() => handleSelect(mode)}
|
||||||
|
before={
|
||||||
|
<Icon
|
||||||
|
size="100"
|
||||||
|
src={getRoomNotificationModeIcon(mode)}
|
||||||
|
filled={mode === value}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T300">
|
||||||
|
{mode === value ? <b>{modeToStr[mode]}</b> : modeToStr[mode]}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children(handleOpenMenu, !!menuCords, changing)}
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { FormEventHandler, useCallback } from 'react';
|
import React, { FormEventHandler, useCallback } from 'react';
|
||||||
import { Box, Text, Button, Spinner, color } from 'folds';
|
import { Box, Text, Button, Spinner, color } from 'folds';
|
||||||
import { decodeRecoveryKey } from 'matrix-js-sdk/lib/crypto-api';
|
import { decodeRecoveryKey, deriveRecoveryKeyFromPassphrase } from 'matrix-js-sdk/lib/crypto-api';
|
||||||
import { deriveKey } from 'matrix-js-sdk/lib/crypto/key_passphrase';
|
|
||||||
import { PasswordInput } from './password-input';
|
import { PasswordInput } from './password-input';
|
||||||
import {
|
import {
|
||||||
SecretStorageKeyContent,
|
SecretStorageKeyContent,
|
||||||
@@ -29,11 +28,16 @@ export function SecretStorageRecoveryPassphrase({
|
|||||||
const [driveKeyState, submitPassphrase] = useAsyncCallback<
|
const [driveKeyState, submitPassphrase] = useAsyncCallback<
|
||||||
Uint8Array,
|
Uint8Array,
|
||||||
Error,
|
Error,
|
||||||
Parameters<typeof deriveKey>
|
Parameters<typeof deriveRecoveryKeyFromPassphrase>
|
||||||
>(
|
>(
|
||||||
useCallback(
|
useCallback(
|
||||||
async (passphrase, salt, iterations, bits) => {
|
async (passphrase, salt, iterations, bits) => {
|
||||||
const decodedRecoveryKey = await deriveKey(passphrase, salt, iterations, bits);
|
const decodedRecoveryKey = await deriveRecoveryKeyFromPassphrase(
|
||||||
|
passphrase,
|
||||||
|
salt,
|
||||||
|
iterations,
|
||||||
|
bits
|
||||||
|
);
|
||||||
|
|
||||||
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
|
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
|
||||||
|
|
||||||
|
|||||||
52
src/app/components/ServerConfigsLoader.tsx
Normal file
52
src/app/components/ServerConfigsLoader.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { ReactNode, useCallback, useMemo } from 'react';
|
||||||
|
import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk';
|
||||||
|
import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
|
||||||
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
|
import { MediaConfig } from '../hooks/useMediaConfig';
|
||||||
|
import { promiseFulfilledResult } from '../utils/common';
|
||||||
|
|
||||||
|
export type ServerConfigs = {
|
||||||
|
capabilities?: Capabilities;
|
||||||
|
mediaConfig?: MediaConfig;
|
||||||
|
authMetadata?: ValidatedAuthMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ServerConfigsLoaderProps = {
|
||||||
|
children: (configs: ServerConfigs) => ReactNode;
|
||||||
|
};
|
||||||
|
export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const fallbackConfigs = useMemo(() => ({}), []);
|
||||||
|
|
||||||
|
const [configsState] = useAsyncCallbackValue<ServerConfigs, unknown>(
|
||||||
|
useCallback(async () => {
|
||||||
|
const result = await Promise.allSettled([
|
||||||
|
mx.getCapabilities(),
|
||||||
|
mx.getMediaConfig(),
|
||||||
|
mx.getAuthMetadata(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const capabilities = promiseFulfilledResult(result[0]);
|
||||||
|
const mediaConfig = promiseFulfilledResult(result[1]);
|
||||||
|
const authMetadata = promiseFulfilledResult(result[2]);
|
||||||
|
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
validatedAuthMetadata = validateAuthMetadata(authMetadata);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
capabilities,
|
||||||
|
mediaConfig,
|
||||||
|
authMetadata: validatedAuthMetadata,
|
||||||
|
};
|
||||||
|
}, [mx])
|
||||||
|
);
|
||||||
|
|
||||||
|
const configs: ServerConfigs =
|
||||||
|
configsState.status === AsyncStatus.Success ? configsState.data : fallbackConfigs;
|
||||||
|
|
||||||
|
return children(configs);
|
||||||
|
}
|
||||||
55
src/app/components/UserRoomProfileRenderer.tsx
Normal file
55
src/app/components/UserRoomProfileRenderer.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Menu, PopOut, toRem } from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile';
|
||||||
|
import { UserRoomProfile } from './user-profile';
|
||||||
|
import { UserRoomProfileState } from '../state/userRoomProfile';
|
||||||
|
import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
|
||||||
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
import { SpaceProvider } from '../hooks/useSpace';
|
||||||
|
import { RoomProvider } from '../hooks/useRoom';
|
||||||
|
|
||||||
|
function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) {
|
||||||
|
const { roomId, spaceId, userId, cords, position } = state;
|
||||||
|
const allJoinedRooms = useAllJoinedRoomsSet();
|
||||||
|
const getRoom = useGetRoom(allJoinedRooms);
|
||||||
|
const room = getRoom(roomId);
|
||||||
|
const space = spaceId ? getRoom(spaceId) : undefined;
|
||||||
|
|
||||||
|
const close = useCloseUserRoomProfile();
|
||||||
|
|
||||||
|
if (!room) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position={position ?? 'Top'}
|
||||||
|
align="Start"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: close,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu style={{ width: toRem(340) }}>
|
||||||
|
<SpaceProvider value={space ?? null}>
|
||||||
|
<RoomProvider value={room}>
|
||||||
|
<UserRoomProfile userId={userId} />
|
||||||
|
</RoomProvider>
|
||||||
|
</SpaceProvider>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserRoomProfileRenderer() {
|
||||||
|
const state = useUserRoomProfileState();
|
||||||
|
|
||||||
|
if (!state) return null;
|
||||||
|
return <UserRoomProfileContextMenu state={state} />;
|
||||||
|
}
|
||||||
306
src/app/components/create-room/AdditionalCreatorInput.tsx
Normal file
306
src/app/components/create-room/AdditionalCreatorInput.tsx
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
config,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
Line,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
PopOut,
|
||||||
|
RectCords,
|
||||||
|
Scroll,
|
||||||
|
Text,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import React, {
|
||||||
|
ChangeEventHandler,
|
||||||
|
KeyboardEventHandler,
|
||||||
|
MouseEventHandler,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../utils/matrix';
|
||||||
|
import { useDirectUsers } from '../../hooks/useDirectUsers';
|
||||||
|
import { SettingTile } from '../setting-tile';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
|
||||||
|
import { findAndReplace } from '../../utils/findAndReplace';
|
||||||
|
import { highlightText } from '../../styles/CustomHtml.css';
|
||||||
|
import { makeHighlightRegex } from '../../plugins/react-custom-html-parser';
|
||||||
|
|
||||||
|
export const useAdditionalCreators = (defaultCreators?: string[]) => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [additionalCreators, setAdditionalCreators] = useState<string[]>(
|
||||||
|
() => defaultCreators?.filter((id) => id !== mx.getSafeUserId()) ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
const addAdditionalCreator = (userId: string) => {
|
||||||
|
if (userId === mx.getSafeUserId()) return;
|
||||||
|
|
||||||
|
setAdditionalCreators((creators) => {
|
||||||
|
const creatorsSet = new Set(creators);
|
||||||
|
creatorsSet.add(userId);
|
||||||
|
return Array.from(creatorsSet);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAdditionalCreator = (userId: string) => {
|
||||||
|
setAdditionalCreators((creators) => {
|
||||||
|
const creatorsSet = new Set(creators);
|
||||||
|
creatorsSet.delete(userId);
|
||||||
|
return Array.from(creatorsSet);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
additionalCreators,
|
||||||
|
addAdditionalCreator,
|
||||||
|
removeAdditionalCreator,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||||
|
limit: 1000,
|
||||||
|
matchOptions: {
|
||||||
|
contain: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const getUserIdString = (userId: string) => getMxIdLocalPart(userId) ?? userId;
|
||||||
|
|
||||||
|
type AdditionalCreatorInputProps = {
|
||||||
|
additionalCreators: string[];
|
||||||
|
onSelect: (userId: string) => void;
|
||||||
|
onRemove: (userId: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
export function AdditionalCreatorInput({
|
||||||
|
additionalCreators,
|
||||||
|
onSelect,
|
||||||
|
onRemove,
|
||||||
|
disabled,
|
||||||
|
}: AdditionalCreatorInputProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
const directUsers = useDirectUsers();
|
||||||
|
|
||||||
|
const [validUserId, setValidUserId] = useState<string>();
|
||||||
|
const filteredUsers = useMemo(
|
||||||
|
() => directUsers.filter((userId) => !additionalCreators.includes(userId)),
|
||||||
|
[directUsers, additionalCreators]
|
||||||
|
);
|
||||||
|
const [result, search, resetSearch] = useAsyncSearch(
|
||||||
|
filteredUsers,
|
||||||
|
getUserIdString,
|
||||||
|
SEARCH_OPTIONS
|
||||||
|
);
|
||||||
|
const queryHighlighRegex = result?.query ? makeHighlightRegex([result.query]) : undefined;
|
||||||
|
|
||||||
|
const suggestionUsers = result
|
||||||
|
? result.items
|
||||||
|
: filteredUsers.sort((a, b) => (a.toLocaleLowerCase() >= b.toLocaleLowerCase() ? 1 : -1));
|
||||||
|
|
||||||
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
const handleCloseMenu = () => {
|
||||||
|
setMenuCords(undefined);
|
||||||
|
setValidUserId(undefined);
|
||||||
|
resetSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreatorChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
const creatorInput = evt.currentTarget;
|
||||||
|
const creator = creatorInput.value.trim();
|
||||||
|
if (isUserId(creator)) {
|
||||||
|
setValidUserId(creator);
|
||||||
|
} else {
|
||||||
|
setValidUserId(undefined);
|
||||||
|
const term =
|
||||||
|
getMxIdLocalPart(creator) ?? (creator.startsWith('@') ? creator.slice(1) : creator);
|
||||||
|
if (term) {
|
||||||
|
search(term);
|
||||||
|
} else {
|
||||||
|
resetSearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectUserId = (userId?: string) => {
|
||||||
|
if (userId && isUserId(userId)) {
|
||||||
|
onSelect(userId);
|
||||||
|
handleCloseMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreatorKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
if (isKeyHotkey('enter', evt)) {
|
||||||
|
evt.preventDefault();
|
||||||
|
const creator = evt.currentTarget.value.trim();
|
||||||
|
handleSelectUserId(isUserId(creator) ? creator : suggestionUsers[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnterClick = () => {
|
||||||
|
handleSelectUserId(validUserId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingTile
|
||||||
|
title="Founders"
|
||||||
|
description="Special privileged users can be assigned during creation. These users have elevated control and can only be modified during a upgrade."
|
||||||
|
>
|
||||||
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
|
<Box gap="200" wrap="Wrap">
|
||||||
|
<Chip type="button" variant="Primary" radii="Pill" outlined>
|
||||||
|
<Text size="B300">{mx.getSafeUserId()}</Text>
|
||||||
|
</Chip>
|
||||||
|
{additionalCreators.map((creator) => (
|
||||||
|
<Chip
|
||||||
|
type="button"
|
||||||
|
key={creator}
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
after={<Icon size="50" src={Icons.Cross} />}
|
||||||
|
onClick={() => onRemove(creator)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Text size="B300">{creator}</Text>
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
<PopOut
|
||||||
|
anchor={menuCords}
|
||||||
|
position="Bottom"
|
||||||
|
align="Center"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
onDeactivate: handleCloseMenu,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
style={{
|
||||||
|
width: '100vw',
|
||||||
|
maxWidth: toRem(300),
|
||||||
|
height: toRem(250),
|
||||||
|
display: 'flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Box shrink="No" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
|
<Input
|
||||||
|
size="400"
|
||||||
|
variant="Background"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
placeholder="@john:server"
|
||||||
|
onChange={handleCreatorChange}
|
||||||
|
onKeyDown={handleCreatorKeyDown}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="Success"
|
||||||
|
radii="300"
|
||||||
|
onClick={handleEnterClick}
|
||||||
|
disabled={!validUserId}
|
||||||
|
>
|
||||||
|
<Text size="B400">Enter</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Line size="300" />
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
{!validUserId && suggestionUsers.length > 0 ? (
|
||||||
|
<Scroll size="300" hideTrack>
|
||||||
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
style={{ padding: config.space.S200, paddingRight: 0 }}
|
||||||
|
>
|
||||||
|
{suggestionUsers.map((userId) => (
|
||||||
|
<MenuItem
|
||||||
|
key={userId}
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => handleSelectUserId(userId)}
|
||||||
|
after={
|
||||||
|
<Text size="T200" truncate>
|
||||||
|
{getMxIdServer(userId)}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="T200" truncate>
|
||||||
|
<b>
|
||||||
|
{queryHighlighRegex
|
||||||
|
? findAndReplace(
|
||||||
|
getMxIdLocalPart(userId) ?? userId,
|
||||||
|
queryHighlighRegex,
|
||||||
|
(match, pushIndex) => (
|
||||||
|
<span
|
||||||
|
key={`highlight-${pushIndex}`}
|
||||||
|
className={highlightText}
|
||||||
|
>
|
||||||
|
{match[0]}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
(txt) => txt
|
||||||
|
)
|
||||||
|
: getMxIdLocalPart(userId)}
|
||||||
|
</b>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
>
|
||||||
|
<Text size="H6" align="Center">
|
||||||
|
No Suggestions
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" align="Center">
|
||||||
|
Please provide the user ID and hit Enter.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
type="button"
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={handleOpenMenu}
|
||||||
|
aria-pressed={!!menuCords}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Plus} />
|
||||||
|
</Chip>
|
||||||
|
</PopOut>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/app/components/create-room/CreateRoomAliasInput.tsx
Normal file
118
src/app/components/create-room/CreateRoomAliasInput.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import React, {
|
||||||
|
FormEventHandler,
|
||||||
|
KeyboardEventHandler,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { Box, color, Icon, Icons, Input, Spinner, Text, toRem } from 'folds';
|
||||||
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
import { getMxIdServer } from '../../utils/matrix';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { replaceSpaceWithDash } from '../../utils/common';
|
||||||
|
import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback';
|
||||||
|
import { useDebounce } from '../../hooks/useDebounce';
|
||||||
|
|
||||||
|
export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const aliasInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [aliasAvail, setAliasAvail] = useState<AsyncState<boolean, Error>>({
|
||||||
|
status: AsyncStatus.Idle,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (aliasAvail.status === AsyncStatus.Success && aliasInputRef.current?.value === '') {
|
||||||
|
setAliasAvail({ status: AsyncStatus.Idle });
|
||||||
|
}
|
||||||
|
}, [aliasAvail]);
|
||||||
|
|
||||||
|
const checkAliasAvail = useAsync(
|
||||||
|
useCallback(
|
||||||
|
async (aliasLocalPart: string) => {
|
||||||
|
const roomAlias = `#${aliasLocalPart}:${getMxIdServer(mx.getSafeUserId())}`;
|
||||||
|
try {
|
||||||
|
const result = await mx.getRoomIdForAlias(roomAlias);
|
||||||
|
return typeof result.room_id !== 'string';
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof MatrixError && e.httpStatus === 404) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mx]
|
||||||
|
),
|
||||||
|
setAliasAvail
|
||||||
|
);
|
||||||
|
const aliasAvailable: boolean | undefined =
|
||||||
|
aliasAvail.status === AsyncStatus.Success ? aliasAvail.data : undefined;
|
||||||
|
|
||||||
|
const debounceCheckAliasAvail = useDebounce(checkAliasAvail, { wait: 500 });
|
||||||
|
|
||||||
|
const handleAliasChange: FormEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
const aliasInput = evt.currentTarget;
|
||||||
|
const aliasLocalPart = replaceSpaceWithDash(aliasInput.value);
|
||||||
|
if (aliasLocalPart) {
|
||||||
|
aliasInput.value = aliasLocalPart;
|
||||||
|
debounceCheckAliasAvail(aliasLocalPart);
|
||||||
|
} else {
|
||||||
|
setAliasAvail({ status: AsyncStatus.Idle });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAliasKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
if (isKeyHotkey('enter', evt)) {
|
||||||
|
evt.preventDefault();
|
||||||
|
|
||||||
|
const aliasInput = evt.currentTarget;
|
||||||
|
const aliasLocalPart = replaceSpaceWithDash(aliasInput.value);
|
||||||
|
if (aliasLocalPart) {
|
||||||
|
checkAliasAvail(aliasLocalPart);
|
||||||
|
} else {
|
||||||
|
setAliasAvail({ status: AsyncStatus.Idle });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
|
<Text size="L400">Address (Optional)</Text>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Pick an unique address to make it discoverable.
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
ref={aliasInputRef}
|
||||||
|
onChange={handleAliasChange}
|
||||||
|
before={
|
||||||
|
aliasAvail.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner size="100" variant="Secondary" />
|
||||||
|
) : (
|
||||||
|
<Icon size="100" src={Icons.Hash} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
after={
|
||||||
|
<Text style={{ maxWidth: toRem(150) }} truncate>
|
||||||
|
:{getMxIdServer(mx.getSafeUserId())}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
onKeyDown={handleAliasKeyDown}
|
||||||
|
name="aliasInput"
|
||||||
|
size="500"
|
||||||
|
variant={aliasAvailable === true ? 'Success' : 'SurfaceVariant'}
|
||||||
|
radii="400"
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
{aliasAvailable === false && (
|
||||||
|
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="100">
|
||||||
|
<Icon src={Icons.Warning} filled size="50" />
|
||||||
|
<Text size="T200">
|
||||||
|
<b>This address is already taken. Please select a different one.</b>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/app/components/create-room/CreateRoomKindSelector.tsx
Normal file
94
src/app/components/create-room/CreateRoomKindSelector.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
|
||||||
|
import { SequenceCard } from '../sequence-card';
|
||||||
|
import { SettingTile } from '../setting-tile';
|
||||||
|
|
||||||
|
export enum CreateRoomKind {
|
||||||
|
Private = 'private',
|
||||||
|
Restricted = 'restricted',
|
||||||
|
Public = 'public',
|
||||||
|
}
|
||||||
|
type CreateRoomKindSelectorProps = {
|
||||||
|
value?: CreateRoomKind;
|
||||||
|
onSelect: (value: CreateRoomKind) => void;
|
||||||
|
canRestrict?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
getIcon: (kind: CreateRoomKind) => IconSrc;
|
||||||
|
};
|
||||||
|
export function CreateRoomKindSelector({
|
||||||
|
value,
|
||||||
|
onSelect,
|
||||||
|
canRestrict,
|
||||||
|
disabled,
|
||||||
|
getIcon,
|
||||||
|
}: CreateRoomKindSelectorProps) {
|
||||||
|
return (
|
||||||
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
|
{canRestrict && (
|
||||||
|
<SequenceCard
|
||||||
|
style={{ padding: config.space.S300 }}
|
||||||
|
variant={value === CreateRoomKind.Restricted ? 'Primary' : 'SurfaceVariant'}
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
aria-pressed={value === CreateRoomKind.Restricted}
|
||||||
|
onClick={() => onSelect(CreateRoomKind.Restricted)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
before={<Icon size="400" src={getIcon(CreateRoomKind.Restricted)} />}
|
||||||
|
after={value === CreateRoomKind.Restricted && <Icon src={Icons.Check} />}
|
||||||
|
>
|
||||||
|
<Text size="H6">Restricted</Text>
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
Only member of parent space can join.
|
||||||
|
</Text>
|
||||||
|
</SettingTile>
|
||||||
|
</SequenceCard>
|
||||||
|
)}
|
||||||
|
<SequenceCard
|
||||||
|
style={{ padding: config.space.S300 }}
|
||||||
|
variant={value === CreateRoomKind.Private ? 'Primary' : 'SurfaceVariant'}
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
aria-pressed={value === CreateRoomKind.Private}
|
||||||
|
onClick={() => onSelect(CreateRoomKind.Private)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
before={<Icon size="400" src={getIcon(CreateRoomKind.Private)} />}
|
||||||
|
after={value === CreateRoomKind.Private && <Icon src={Icons.Check} />}
|
||||||
|
>
|
||||||
|
<Text size="H6">Private</Text>
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
Only people with invite can join.
|
||||||
|
</Text>
|
||||||
|
</SettingTile>
|
||||||
|
</SequenceCard>
|
||||||
|
<SequenceCard
|
||||||
|
style={{ padding: config.space.S300 }}
|
||||||
|
variant={value === CreateRoomKind.Public ? 'Primary' : 'SurfaceVariant'}
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
aria-pressed={value === CreateRoomKind.Public}
|
||||||
|
onClick={() => onSelect(CreateRoomKind.Public)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
before={<Icon size="400" src={getIcon(CreateRoomKind.Public)} />}
|
||||||
|
after={value === CreateRoomKind.Public && <Icon src={Icons.Check} />}
|
||||||
|
>
|
||||||
|
<Text size="H6">Public</Text>
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
Anyone with the address can join.
|
||||||
|
</Text>
|
||||||
|
</SettingTile>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/app/components/create-room/RoomVersionSelector.tsx
Normal file
117
src/app/components/create-room/RoomVersionSelector.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import React, { MouseEventHandler, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
config,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
Menu,
|
||||||
|
PopOut,
|
||||||
|
RectCords,
|
||||||
|
Text,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { SettingTile } from '../setting-tile';
|
||||||
|
import { SequenceCard } from '../sequence-card';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
export function RoomVersionSelector({
|
||||||
|
versions,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
versions: string[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (version: string) => {
|
||||||
|
setMenuCords(undefined);
|
||||||
|
onChange(version);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SequenceCard
|
||||||
|
style={{ padding: config.space.S300 }}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="500"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Version"
|
||||||
|
after={
|
||||||
|
<PopOut
|
||||||
|
anchor={menuCords}
|
||||||
|
offset={5}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setMenuCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="200"
|
||||||
|
style={{ padding: config.space.S200, maxWidth: toRem(300) }}
|
||||||
|
>
|
||||||
|
<Text size="L400">Versions</Text>
|
||||||
|
<Box wrap="Wrap" gap="100">
|
||||||
|
{versions.map((version) => (
|
||||||
|
<Chip
|
||||||
|
key={version}
|
||||||
|
variant={value === version ? 'Primary' : 'SurfaceVariant'}
|
||||||
|
aria-pressed={value === version}
|
||||||
|
outlined={value === version}
|
||||||
|
radii="300"
|
||||||
|
onClick={() => handleSelect(version)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Text truncate size="T300">
|
||||||
|
{version}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleMenu}
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={!!menuCords}
|
||||||
|
before={<Icon size="50" src={menuCords ? Icons.ChevronTop : Icons.ChevronBottom} />}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Text size="B300">{value}</Text>
|
||||||
|
</Button>
|
||||||
|
</PopOut>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/components/create-room/index.ts
Normal file
5
src/app/components/create-room/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './CreateRoomKindSelector';
|
||||||
|
export * from './CreateRoomAliasInput';
|
||||||
|
export * from './RoomVersionSelector';
|
||||||
|
export * from './utils';
|
||||||
|
export * from './AdditionalCreatorInput';
|
||||||
140
src/app/components/create-room/utils.ts
Normal file
140
src/app/components/create-room/utils.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import {
|
||||||
|
ICreateRoomOpts,
|
||||||
|
ICreateRoomStateEvent,
|
||||||
|
JoinRule,
|
||||||
|
MatrixClient,
|
||||||
|
RestrictedAllowType,
|
||||||
|
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';
|
||||||
|
|
||||||
|
export const createRoomCreationContent = (
|
||||||
|
type: RoomType | undefined,
|
||||||
|
allowFederation: boolean,
|
||||||
|
additionalCreators: string[] | undefined
|
||||||
|
): object => {
|
||||||
|
const content: Record<string, any> = {};
|
||||||
|
if (typeof type === 'string') {
|
||||||
|
content.type = type;
|
||||||
|
}
|
||||||
|
if (allowFederation === false) {
|
||||||
|
content['m.federate'] = false;
|
||||||
|
}
|
||||||
|
if (Array.isArray(additionalCreators)) {
|
||||||
|
content.additional_creators = additionalCreators;
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRoomJoinRulesState = (
|
||||||
|
kind: CreateRoomKind,
|
||||||
|
parent: Room | undefined,
|
||||||
|
knock: boolean
|
||||||
|
) => {
|
||||||
|
let content: RoomJoinRulesEventContent = {
|
||||||
|
join_rule: knock ? JoinRule.Knock : JoinRule.Invite,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (kind === CreateRoomKind.Public) {
|
||||||
|
content = {
|
||||||
|
join_rule: JoinRule.Public,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === CreateRoomKind.Restricted && parent) {
|
||||||
|
content = {
|
||||||
|
join_rule: knock ? ('knock_restricted' as JoinRule) : JoinRule.Restricted,
|
||||||
|
allow: [
|
||||||
|
{
|
||||||
|
type: RestrictedAllowType.RoomMembership,
|
||||||
|
room_id: parent.roomId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: StateEvent.RoomJoinRules,
|
||||||
|
state_key: '',
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRoomParentState = (parent: Room) => ({
|
||||||
|
type: StateEvent.SpaceParent,
|
||||||
|
state_key: parent.roomId,
|
||||||
|
content: {
|
||||||
|
canonical: true,
|
||||||
|
via: getViaServers(parent),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createRoomEncryptionState = () => ({
|
||||||
|
type: 'm.room.encryption',
|
||||||
|
state_key: '',
|
||||||
|
content: {
|
||||||
|
algorithm: 'm.megolm.v1.aes-sha2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateRoomData = {
|
||||||
|
version: string;
|
||||||
|
type?: RoomType;
|
||||||
|
parent?: Room;
|
||||||
|
kind: CreateRoomKind;
|
||||||
|
name: string;
|
||||||
|
topic?: string;
|
||||||
|
aliasLocalPart?: string;
|
||||||
|
encryption?: boolean;
|
||||||
|
knock: boolean;
|
||||||
|
allowFederation: boolean;
|
||||||
|
additionalCreators?: string[];
|
||||||
|
};
|
||||||
|
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
|
||||||
|
const initialState: ICreateRoomStateEvent[] = [];
|
||||||
|
|
||||||
|
if (data.encryption) {
|
||||||
|
initialState.push(createRoomEncryptionState());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.parent) {
|
||||||
|
initialState.push(createRoomParentState(data.parent));
|
||||||
|
}
|
||||||
|
|
||||||
|
initialState.push(createRoomJoinRulesState(data.kind, data.parent, data.knock));
|
||||||
|
|
||||||
|
const options: ICreateRoomOpts = {
|
||||||
|
room_version: data.version,
|
||||||
|
name: data.name,
|
||||||
|
topic: data.topic,
|
||||||
|
room_alias_name: data.aliasLocalPart,
|
||||||
|
creation_content: createRoomCreationContent(
|
||||||
|
data.type,
|
||||||
|
data.allowFederation,
|
||||||
|
data.additionalCreators
|
||||||
|
),
|
||||||
|
initial_state: initialState,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await mx.createRoom(options);
|
||||||
|
|
||||||
|
if (data.parent) {
|
||||||
|
await mx.sendStateEvent(
|
||||||
|
data.parent.roomId,
|
||||||
|
StateEvent.SpaceChild as any,
|
||||||
|
{
|
||||||
|
auto_join: false,
|
||||||
|
suggested: false,
|
||||||
|
via: [getMxIdServer(mx.getUserId() ?? '') ?? ''],
|
||||||
|
},
|
||||||
|
result.room_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.room_id;
|
||||||
|
};
|
||||||
8
src/app/components/cutout-card/CutoutCard.css.ts
Normal file
8
src/app/components/cutout-card/CutoutCard.css.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { config } from 'folds';
|
||||||
|
|
||||||
|
export const CutoutCard = style({
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
borderWidth: config.borderWidth.B300,
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
15
src/app/components/cutout-card/CutoutCard.tsx
Normal file
15
src/app/components/cutout-card/CutoutCard.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { as, ContainerColor as TContainerColor } from 'folds';
|
||||||
|
import React from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
|
import * as css from './CutoutCard.css';
|
||||||
|
|
||||||
|
export const CutoutCard = as<'div', { variant?: TContainerColor }>(
|
||||||
|
({ as: AsCutoutCard = 'div', className, variant = 'Surface', ...props }, ref) => (
|
||||||
|
<AsCutoutCard
|
||||||
|
className={classNames(ContainerColor({ variant }), css.CutoutCard, className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
1
src/app/components/cutout-card/index.ts
Normal file
1
src/app/components/cutout-card/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './CutoutCard';
|
||||||
@@ -41,21 +41,21 @@ export const EditorTextarea = style([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const EditorPlaceholder = style([
|
export const EditorPlaceholderContainer = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
position: 'absolute',
|
|
||||||
zIndex: 1,
|
|
||||||
width: '100%',
|
|
||||||
opacity: config.opacity.Placeholder,
|
opacity: config.opacity.Placeholder,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
selectors: {
|
export const EditorPlaceholderTextVisual = style([
|
||||||
'&:not(:first-child)': {
|
DefaultReset,
|
||||||
display: 'none',
|
{
|
||||||
},
|
display: 'block',
|
||||||
},
|
paddingTop: toRem(13),
|
||||||
|
paddingLeft: toRem(1),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -106,22 +106,17 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
[editor, onKeyDown]
|
[editor, onKeyDown]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderPlaceholder = useCallback(({ attributes, children }: RenderPlaceholderProps) => {
|
const renderPlaceholder = useCallback(
|
||||||
// drop style attribute as we use our custom placeholder css.
|
({ attributes, children }: RenderPlaceholderProps) => (
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
<span {...attributes} className={css.EditorPlaceholderContainer}>
|
||||||
const { style, ...props } = attributes;
|
{/* Inner component to style the actual text position and appearance */}
|
||||||
return (
|
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
|
||||||
<Text
|
{children}
|
||||||
as="span"
|
</Text>
|
||||||
{...props}
|
</span>
|
||||||
className={css.EditorPlaceholder}
|
),
|
||||||
contentEditable={false}
|
[]
|
||||||
truncate
|
);
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css.Editor} ref={ref}>
|
<div className={css.Editor} ref={ref}>
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export function RenderElement({ attributes, element, children }: RenderElementPr
|
|||||||
<Text as="pre" className={css.CodeBlock} {...attributes}>
|
<Text as="pre" className={css.CodeBlock} {...attributes}>
|
||||||
<Scroll
|
<Scroll
|
||||||
direction="Horizontal"
|
direction="Horizontal"
|
||||||
variant="Secondary"
|
variant="SurfaceVariant"
|
||||||
size="300"
|
size="300"
|
||||||
visibility="Hover"
|
visibility="Hover"
|
||||||
hideTrack
|
hideTrack
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ export function Toolbar() {
|
|||||||
<Box className={css.MarkdownBtnBox} shrink="No" grow="Yes" justifyContent="End">
|
<Box className={css.MarkdownBtnBox} shrink="No" grow="Yes" justifyContent="End">
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
align="End"
|
align="End"
|
||||||
tooltip={<BtnTooltip text="Toggle Markdown" />}
|
tooltip={<BtnTooltip text={isMarkdown ? 'Disable Markdown' : 'Enable Markdown'} />}
|
||||||
delay={500}
|
delay={500}
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Header, Menu, Scroll, config } from 'folds';
|
|||||||
|
|
||||||
import * as css from './AutocompleteMenu.css';
|
import * as css from './AutocompleteMenu.css';
|
||||||
import { preventScrollWithArrowKey, stopPropagation } from '../../../utils/keyboard';
|
import { preventScrollWithArrowKey, stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
|
|
||||||
type AutocompleteMenuProps = {
|
type AutocompleteMenuProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
@@ -12,13 +13,22 @@ type AutocompleteMenuProps = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
export function AutocompleteMenu({ headerContent, requestClose, children }: AutocompleteMenuProps) {
|
export function AutocompleteMenu({ headerContent, requestClose, children }: AutocompleteMenuProps) {
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
|
const handleDeactivate = () => {
|
||||||
|
if (alive()) {
|
||||||
|
// The component is unmounted so we will not call for `requestClose`
|
||||||
|
requestClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css.AutocompleteMenuBase}>
|
<div className={css.AutocompleteMenuBase}>
|
||||||
<div className={css.AutocompleteMenuContainer}>
|
<div className={css.AutocompleteMenuContainer}>
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
onDeactivate: () => requestClose(),
|
onPostDeactivate: handleDeactivate,
|
||||||
returnFocusOnDeactivate: false,
|
returnFocusOnDeactivate: false,
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
allowOutsideClick: true,
|
allowOutsideClick: true,
|
||||||
|
|||||||
@@ -6,11 +6,7 @@ import { Room } from 'matrix-js-sdk';
|
|||||||
import { AutocompleteQuery } from './autocompleteQuery';
|
import { AutocompleteQuery } from './autocompleteQuery';
|
||||||
import { AutocompleteMenu } from './AutocompleteMenu';
|
import { AutocompleteMenu } from './AutocompleteMenu';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import {
|
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
|
||||||
SearchItemStrGetter,
|
|
||||||
UseAsyncSearchOptions,
|
|
||||||
useAsyncSearch,
|
|
||||||
} from '../../../hooks/useAsyncSearch';
|
|
||||||
import { onTabPress } from '../../../utils/keyboard';
|
import { onTabPress } from '../../../utils/keyboard';
|
||||||
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
|
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
|
||||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||||
@@ -20,6 +16,7 @@ import { useKeyDown } from '../../../hooks/useKeyDown';
|
|||||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { ImageUsage, PackImageReader } from '../../../plugins/custom-emoji';
|
import { ImageUsage, PackImageReader } from '../../../plugins/custom-emoji';
|
||||||
|
import { getEmoticonSearchStr } from '../../../plugins/utils';
|
||||||
|
|
||||||
type EmoticonCompleteHandler = (key: string, shortcode: string) => void;
|
type EmoticonCompleteHandler = (key: string, shortcode: string) => void;
|
||||||
|
|
||||||
@@ -33,16 +30,11 @@ type EmoticonAutocompleteProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||||
limit: 20,
|
|
||||||
matchOptions: {
|
matchOptions: {
|
||||||
contain: true,
|
contain: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEmoticonStr: SearchItemStrGetter<EmoticonSearchItem> = (emoticon) => [
|
|
||||||
`:${emoticon.shortcode}:`,
|
|
||||||
];
|
|
||||||
|
|
||||||
export function EmoticonAutocomplete({
|
export function EmoticonAutocomplete({
|
||||||
imagePackRooms,
|
imagePackRooms,
|
||||||
editor,
|
editor,
|
||||||
@@ -63,10 +55,12 @@ export function EmoticonAutocomplete({
|
|||||||
);
|
);
|
||||||
}, [imagePacks]);
|
}, [imagePacks]);
|
||||||
|
|
||||||
const [result, search, resetSearch] = useAsyncSearch(searchList, getEmoticonStr, SEARCH_OPTIONS);
|
const [result, search, resetSearch] = useAsyncSearch(
|
||||||
const autoCompleteEmoticon = (result ? result.items : recentEmoji).sort((a, b) =>
|
searchList,
|
||||||
a.shortcode.localeCompare(b.shortcode)
|
getEmoticonSearchStr,
|
||||||
|
SEARCH_OPTIONS
|
||||||
);
|
);
|
||||||
|
const autoCompleteEmoticon = result ? result.items.slice(0, 20) : recentEmoji;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (query.text) search(query.text);
|
if (query.text) search(query.text);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { getDirectRoomAvatarUrl } from '../../../utils/room';
|
|||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { AutocompleteQuery } from './autocompleteQuery';
|
import { AutocompleteQuery } from './autocompleteQuery';
|
||||||
import { AutocompleteMenu } from './AutocompleteMenu';
|
import { AutocompleteMenu } from './AutocompleteMenu';
|
||||||
import { getMxIdServer, validMxId } from '../../../utils/matrix';
|
import { getMxIdServer, isRoomAlias } from '../../../utils/matrix';
|
||||||
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
|
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
|
||||||
import { onTabPress } from '../../../utils/keyboard';
|
import { onTabPress } from '../../../utils/keyboard';
|
||||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||||
@@ -22,7 +22,7 @@ import { getViaServers } from '../../../plugins/via-servers';
|
|||||||
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
|
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
|
||||||
|
|
||||||
const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
|
const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
|
||||||
validMxId(`#${text}`)
|
isRoomAlias(`#${text}`)
|
||||||
? `#${text}`
|
? `#${text}`
|
||||||
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
||||||
|
|
||||||
@@ -65,7 +65,6 @@ type RoomMentionAutocompleteProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||||
limit: 20,
|
|
||||||
matchOptions: {
|
matchOptions: {
|
||||||
contain: true,
|
contain: true,
|
||||||
},
|
},
|
||||||
@@ -97,7 +96,7 @@ export function RoomMentionAutocomplete({
|
|||||||
SEARCH_OPTIONS
|
SEARCH_OPTIONS
|
||||||
);
|
);
|
||||||
|
|
||||||
const autoCompleteRoomIds = result ? result.items : allRooms.slice(0, 20);
|
const autoCompleteRoomIds = result ? result.items.slice(0, 20) : allRooms.slice(0, 20);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (query.text) search(query.text);
|
if (query.text) search(query.text);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { onTabPress } from '../../../utils/keyboard';
|
import { onTabPress } from '../../../utils/keyboard';
|
||||||
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
|
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
|
||||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||||
import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix';
|
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
|
||||||
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
|
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
|
||||||
import { UserAvatar } from '../../user-avatar';
|
import { UserAvatar } from '../../user-avatar';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
@@ -24,7 +24,7 @@ import { Membership } from '../../../../types/matrix/room';
|
|||||||
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
|
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
|
||||||
|
|
||||||
const userIdFromQueryText = (mx: MatrixClient, text: string) =>
|
const userIdFromQueryText = (mx: MatrixClient, text: string) =>
|
||||||
validMxId(`@${text}`)
|
isUserId(`@${text}`)
|
||||||
? `@${text}`
|
? `@${text}`
|
||||||
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ const withAllowedMembership = (member: RoomMember): boolean =>
|
|||||||
member.membership === Membership.Knock;
|
member.membership === Membership.Knock;
|
||||||
|
|
||||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||||
limit: 20,
|
limit: 1000,
|
||||||
matchOptions: {
|
matchOptions: {
|
||||||
contain: true,
|
contain: true,
|
||||||
},
|
},
|
||||||
@@ -97,7 +97,7 @@ export function UserMentionAutocomplete({
|
|||||||
const members = useRoomMembers(mx, roomId);
|
const members = useRoomMembers(mx, roomId);
|
||||||
|
|
||||||
const [result, search, resetSearch] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS);
|
const [result, search, resetSearch] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS);
|
||||||
const autoCompleteMembers = (result ? result.items : members.slice(0, 20)).filter(
|
const autoCompleteMembers = (result ? result.items.slice(0, 20) : members.slice(0, 20)).filter(
|
||||||
withAllowedMembership
|
withAllowedMembership
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -26,48 +26,75 @@ import {
|
|||||||
testMatrixTo,
|
testMatrixTo,
|
||||||
} from '../../plugins/matrix-to';
|
} from '../../plugins/matrix-to';
|
||||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
import { tryDecodeURIComponent } from '../../utils/dom';
|
||||||
|
import {
|
||||||
|
escapeMarkdownInlineSequences,
|
||||||
|
escapeMarkdownBlockSequences,
|
||||||
|
} from '../../plugins/markdown';
|
||||||
|
|
||||||
const markNodeToType: Record<string, MarkType> = {
|
type ProcessTextCallback = (text: string) => string;
|
||||||
b: MarkType.Bold,
|
|
||||||
strong: MarkType.Bold,
|
|
||||||
i: MarkType.Italic,
|
|
||||||
em: MarkType.Italic,
|
|
||||||
u: MarkType.Underline,
|
|
||||||
s: MarkType.StrikeThrough,
|
|
||||||
del: MarkType.StrikeThrough,
|
|
||||||
code: MarkType.Code,
|
|
||||||
span: MarkType.Spoiler,
|
|
||||||
};
|
|
||||||
|
|
||||||
const elementToTextMark = (node: Element): MarkType | undefined => {
|
const getText = (node: ChildNode): string => {
|
||||||
const markType = markNodeToType[node.name];
|
|
||||||
if (!markType) return undefined;
|
|
||||||
|
|
||||||
if (markType === MarkType.Spoiler && node.attribs['data-mx-spoiler'] === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
markType === MarkType.Code &&
|
|
||||||
node.parent &&
|
|
||||||
'name' in node.parent &&
|
|
||||||
node.parent.name === 'pre'
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return markType;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseNodeText = (node: ChildNode): string => {
|
|
||||||
if (isText(node)) {
|
if (isText(node)) {
|
||||||
return node.data;
|
return node.data;
|
||||||
}
|
}
|
||||||
if (isTag(node)) {
|
if (isTag(node)) {
|
||||||
return node.children.map((child) => parseNodeText(child)).join('');
|
return node.children.map((child) => getText(child)).join('');
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const elementToInlineNode = (node: Element): MentionElement | EmoticonElement | undefined => {
|
const getInlineNodeMarkType = (node: Element): MarkType | undefined => {
|
||||||
|
if (node.name === 'b' || node.name === 'strong') {
|
||||||
|
return MarkType.Bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.name === 'i' || node.name === 'em') {
|
||||||
|
return MarkType.Italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.name === 'u') {
|
||||||
|
return MarkType.Underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.name === 's' || node.name === 'del') {
|
||||||
|
return MarkType.StrikeThrough;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.name === 'code') {
|
||||||
|
if (node.parent && 'name' in node.parent && node.parent.name === 'pre') {
|
||||||
|
return undefined; // Don't apply `Code` mark inside a <pre> tag
|
||||||
|
}
|
||||||
|
return MarkType.Code;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.name === 'span' && node.attribs['data-mx-spoiler'] !== undefined) {
|
||||||
|
return MarkType.Spoiler;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInlineMarkElement = (
|
||||||
|
markType: MarkType,
|
||||||
|
node: Element,
|
||||||
|
getChild: (child: ChildNode) => InlineElement[]
|
||||||
|
): InlineElement[] => {
|
||||||
|
const children = node.children.flatMap(getChild);
|
||||||
|
const mdSequence = node.attribs['data-md'];
|
||||||
|
if (mdSequence !== undefined) {
|
||||||
|
children.unshift({ text: mdSequence });
|
||||||
|
children.push({ text: mdSequence });
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
children.forEach((child) => {
|
||||||
|
if (Text.isText(child)) {
|
||||||
|
child[markType] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInlineNonMarkElement = (node: Element): MentionElement | EmoticonElement | undefined => {
|
||||||
if (node.name === 'img' && node.attribs['data-mx-emoticon'] !== undefined) {
|
if (node.name === 'img' && node.attribs['data-mx-emoticon'] !== undefined) {
|
||||||
const { src, alt } = node.attribs;
|
const { src, alt } = node.attribs;
|
||||||
if (!src) return undefined;
|
if (!src) return undefined;
|
||||||
@@ -79,13 +106,13 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
|
|||||||
if (testMatrixTo(href)) {
|
if (testMatrixTo(href)) {
|
||||||
const userMention = parseMatrixToUser(href);
|
const userMention = parseMatrixToUser(href);
|
||||||
if (userMention) {
|
if (userMention) {
|
||||||
return createMentionElement(userMention, parseNodeText(node) || userMention, false);
|
return createMentionElement(userMention, getText(node) || userMention, false);
|
||||||
}
|
}
|
||||||
const roomMention = parseMatrixToRoom(href);
|
const roomMention = parseMatrixToRoom(href);
|
||||||
if (roomMention) {
|
if (roomMention) {
|
||||||
return createMentionElement(
|
return createMentionElement(
|
||||||
roomMention.roomIdOrAlias,
|
roomMention.roomIdOrAlias,
|
||||||
parseNodeText(node) || roomMention.roomIdOrAlias,
|
getText(node) || roomMention.roomIdOrAlias,
|
||||||
false,
|
false,
|
||||||
undefined,
|
undefined,
|
||||||
roomMention.viaServers
|
roomMention.viaServers
|
||||||
@@ -95,7 +122,7 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
|
|||||||
if (eventMention) {
|
if (eventMention) {
|
||||||
return createMentionElement(
|
return createMentionElement(
|
||||||
eventMention.roomIdOrAlias,
|
eventMention.roomIdOrAlias,
|
||||||
parseNodeText(node) || eventMention.roomIdOrAlias,
|
getText(node) || eventMention.roomIdOrAlias,
|
||||||
false,
|
false,
|
||||||
eventMention.eventId,
|
eventMention.eventId,
|
||||||
eventMention.viaServers
|
eventMention.viaServers
|
||||||
@@ -106,44 +133,40 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseInlineNodes = (node: ChildNode): InlineElement[] => {
|
const getInlineElement = (node: ChildNode, processText: ProcessTextCallback): InlineElement[] => {
|
||||||
if (isText(node)) {
|
if (isText(node)) {
|
||||||
return [{ text: node.data }];
|
return [{ text: processText(node.data) }];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTag(node)) {
|
if (isTag(node)) {
|
||||||
const markType = elementToTextMark(node);
|
const markType = getInlineNodeMarkType(node);
|
||||||
if (markType) {
|
if (markType) {
|
||||||
const children = node.children.flatMap(parseInlineNodes);
|
return getInlineMarkElement(markType, node, (child) => {
|
||||||
if (node.attribs['data-md'] !== undefined) {
|
if (markType === MarkType.Code) return [{ text: getText(child) }];
|
||||||
children.unshift({ text: node.attribs['data-md'] });
|
return getInlineElement(child, processText);
|
||||||
children.push({ text: node.attribs['data-md'] });
|
});
|
||||||
} else {
|
|
||||||
children.forEach((child) => {
|
|
||||||
if (Text.isText(child)) {
|
|
||||||
child[markType] = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return children;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const inlineNode = elementToInlineNode(node);
|
const inlineNode = getInlineNonMarkElement(node);
|
||||||
if (inlineNode) return [inlineNode];
|
if (inlineNode) return [inlineNode];
|
||||||
|
|
||||||
if (node.name === 'a') {
|
if (node.name === 'a') {
|
||||||
const children = node.childNodes.flatMap(parseInlineNodes);
|
const children = node.childNodes.flatMap((child) => getInlineElement(child, processText));
|
||||||
children.unshift({ text: '[' });
|
children.unshift({ text: '[' });
|
||||||
children.push({ text: `](${node.attribs.href})` });
|
children.push({ text: `](${node.attribs.href})` });
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
return node.childNodes.flatMap(parseInlineNodes);
|
return node.childNodes.flatMap((child) => getInlineElement(child, processText));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElement[] => {
|
const parseBlockquoteNode = (
|
||||||
|
node: Element,
|
||||||
|
processText: ProcessTextCallback
|
||||||
|
): BlockQuoteElement[] | ParagraphElement[] => {
|
||||||
const quoteLines: Array<InlineElement[]> = [];
|
const quoteLines: Array<InlineElement[]> = [];
|
||||||
let lineHolder: InlineElement[] = [];
|
let lineHolder: InlineElement[] = [];
|
||||||
|
|
||||||
@@ -156,7 +179,7 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
|
|||||||
|
|
||||||
node.children.forEach((child) => {
|
node.children.forEach((child) => {
|
||||||
if (isText(child)) {
|
if (isText(child)) {
|
||||||
lineHolder.push({ text: child.data });
|
lineHolder.push({ text: processText(child.data) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isTag(child)) {
|
if (isTag(child)) {
|
||||||
@@ -168,19 +191,20 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
|
|||||||
|
|
||||||
if (child.name === 'p') {
|
if (child.name === 'p') {
|
||||||
appendLine();
|
appendLine();
|
||||||
quoteLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
|
quoteLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
|
lineHolder.push(...getInlineElement(child, processText));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
appendLine();
|
appendLine();
|
||||||
|
|
||||||
if (node.attribs['data-md'] !== undefined) {
|
const mdSequence = node.attribs['data-md'];
|
||||||
|
if (mdSequence !== undefined) {
|
||||||
return quoteLines.map((lineChildren) => ({
|
return quoteLines.map((lineChildren) => ({
|
||||||
type: BlockType.Paragraph,
|
type: BlockType.Paragraph,
|
||||||
children: [{ text: `${node.attribs['data-md']} ` }, ...lineChildren],
|
children: [{ text: `${mdSequence} ` }, ...lineChildren],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,22 +219,19 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElement[] => {
|
const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElement[] => {
|
||||||
const codeLines = parseNodeText(node).trim().split('\n');
|
const codeLines = getText(node).trim().split('\n');
|
||||||
|
|
||||||
if (node.attribs['data-md'] !== undefined) {
|
const mdSequence = node.attribs['data-md'];
|
||||||
const pLines = codeLines.map<ParagraphElement>((lineText) => ({
|
if (mdSequence !== undefined) {
|
||||||
|
const pLines = codeLines.map<ParagraphElement>((text) => ({
|
||||||
type: BlockType.Paragraph,
|
type: BlockType.Paragraph,
|
||||||
children: [
|
children: [{ text }],
|
||||||
{
|
|
||||||
text: lineText,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
}));
|
||||||
const childCode = node.children[0];
|
const childCode = node.children[0];
|
||||||
const className =
|
const className =
|
||||||
isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs.class ?? '' : '';
|
isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs.class ?? '' : '';
|
||||||
const prefix = { text: `${node.attribs['data-md']}${className.replace('language-', '')}` };
|
const prefix = { text: `${mdSequence}${className.replace('language-', '')}` };
|
||||||
const suffix = { text: node.attribs['data-md'] };
|
const suffix = { text: mdSequence };
|
||||||
return [
|
return [
|
||||||
{ type: BlockType.Paragraph, children: [prefix] },
|
{ type: BlockType.Paragraph, children: [prefix] },
|
||||||
...pLines,
|
...pLines,
|
||||||
@@ -221,19 +242,16 @@ const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElemen
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
type: BlockType.CodeBlock,
|
type: BlockType.CodeBlock,
|
||||||
children: codeLines.map<CodeLineElement>((lineTxt) => ({
|
children: codeLines.map<CodeLineElement>((text) => ({
|
||||||
type: BlockType.CodeLine,
|
type: BlockType.CodeLine,
|
||||||
children: [
|
children: [{ text }],
|
||||||
{
|
|
||||||
text: lineTxt,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
const parseListNode = (
|
const parseListNode = (
|
||||||
node: Element
|
node: Element,
|
||||||
|
processText: ProcessTextCallback
|
||||||
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
|
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
|
||||||
const listLines: Array<InlineElement[]> = [];
|
const listLines: Array<InlineElement[]> = [];
|
||||||
let lineHolder: InlineElement[] = [];
|
let lineHolder: InlineElement[] = [];
|
||||||
@@ -247,7 +265,7 @@ const parseListNode = (
|
|||||||
|
|
||||||
node.children.forEach((child) => {
|
node.children.forEach((child) => {
|
||||||
if (isText(child)) {
|
if (isText(child)) {
|
||||||
lineHolder.push({ text: child.data });
|
lineHolder.push({ text: processText(child.data) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isTag(child)) {
|
if (isTag(child)) {
|
||||||
@@ -259,17 +277,18 @@ const parseListNode = (
|
|||||||
|
|
||||||
if (child.name === 'li') {
|
if (child.name === 'li') {
|
||||||
appendLine();
|
appendLine();
|
||||||
listLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
|
listLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
|
lineHolder.push(...getInlineElement(child, processText));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
appendLine();
|
appendLine();
|
||||||
|
|
||||||
if (node.attribs['data-md'] !== undefined) {
|
const mdSequence = node.attribs['data-md'];
|
||||||
const prefix = node.attribs['data-md'] || '-';
|
if (mdSequence !== undefined) {
|
||||||
|
const prefix = mdSequence || '-';
|
||||||
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
|
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
|
||||||
return listLines.map((lineChildren) => ({
|
return listLines.map((lineChildren) => ({
|
||||||
type: BlockType.Paragraph,
|
type: BlockType.Paragraph,
|
||||||
@@ -302,17 +321,21 @@ const parseListNode = (
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
const parseHeadingNode = (node: Element): HeadingElement | ParagraphElement => {
|
const parseHeadingNode = (
|
||||||
const children = node.children.flatMap((child) => parseInlineNodes(child));
|
node: Element,
|
||||||
|
processText: ProcessTextCallback
|
||||||
|
): HeadingElement | ParagraphElement => {
|
||||||
|
const children = node.children.flatMap((child) => getInlineElement(child, processText));
|
||||||
|
|
||||||
const headingMatch = node.name.match(/^h([123456])$/);
|
const headingMatch = node.name.match(/^h([123456])$/);
|
||||||
const [, g1AsLevel] = headingMatch ?? ['h3', '3'];
|
const [, g1AsLevel] = headingMatch ?? ['h3', '3'];
|
||||||
const level = parseInt(g1AsLevel, 10);
|
const level = parseInt(g1AsLevel, 10);
|
||||||
|
|
||||||
if (node.attribs['data-md'] !== undefined) {
|
const mdSequence = node.attribs['data-md'];
|
||||||
|
if (mdSequence !== undefined) {
|
||||||
return {
|
return {
|
||||||
type: BlockType.Paragraph,
|
type: BlockType.Paragraph,
|
||||||
children: [{ text: `${node.attribs['data-md']} ` }, ...children],
|
children: [{ text: `${mdSequence} ` }, ...children],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +346,11 @@ const parseHeadingNode = (node: Element): HeadingElement | ParagraphElement => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
|
export const domToEditorInput = (
|
||||||
|
domNodes: ChildNode[],
|
||||||
|
processText: ProcessTextCallback,
|
||||||
|
processLineStartText: ProcessTextCallback
|
||||||
|
): Descendant[] => {
|
||||||
const children: Descendant[] = [];
|
const children: Descendant[] = [];
|
||||||
|
|
||||||
let lineHolder: InlineElement[] = [];
|
let lineHolder: InlineElement[] = [];
|
||||||
@@ -340,7 +367,14 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
|
|||||||
|
|
||||||
domNodes.forEach((node) => {
|
domNodes.forEach((node) => {
|
||||||
if (isText(node)) {
|
if (isText(node)) {
|
||||||
lineHolder.push({ text: node.data });
|
if (lineHolder.length === 0) {
|
||||||
|
// we are inserting first part of line
|
||||||
|
// it may contain block markdown starting data
|
||||||
|
// that we may need to escape.
|
||||||
|
lineHolder.push({ text: processLineStartText(node.data) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lineHolder.push({ text: processText(node.data) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isTag(node)) {
|
if (isTag(node)) {
|
||||||
@@ -354,14 +388,14 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
|
|||||||
appendLine();
|
appendLine();
|
||||||
children.push({
|
children.push({
|
||||||
type: BlockType.Paragraph,
|
type: BlockType.Paragraph,
|
||||||
children: node.children.flatMap((child) => parseInlineNodes(child)),
|
children: node.children.flatMap((child) => getInlineElement(child, processText)),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.name === 'blockquote') {
|
if (node.name === 'blockquote') {
|
||||||
appendLine();
|
appendLine();
|
||||||
children.push(...parseBlockquoteNode(node));
|
children.push(...parseBlockquoteNode(node, processText));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (node.name === 'pre') {
|
if (node.name === 'pre') {
|
||||||
@@ -371,17 +405,17 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
|
|||||||
}
|
}
|
||||||
if (node.name === 'ol' || node.name === 'ul') {
|
if (node.name === 'ol' || node.name === 'ul') {
|
||||||
appendLine();
|
appendLine();
|
||||||
children.push(...parseListNode(node));
|
children.push(...parseListNode(node, processText));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.name.match(/^h[123456]$/)) {
|
if (node.name.match(/^h[123456]$/)) {
|
||||||
appendLine();
|
appendLine();
|
||||||
children.push(parseHeadingNode(node));
|
children.push(parseHeadingNode(node, processText));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
parseInlineNodes(node).forEach((inlineNode) => lineHolder.push(inlineNode));
|
lineHolder.push(...getInlineElement(node, processText));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
appendLine();
|
appendLine();
|
||||||
@@ -389,21 +423,31 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
|
|||||||
return children;
|
return children;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const htmlToEditorInput = (unsafeHtml: string): Descendant[] => {
|
export const htmlToEditorInput = (unsafeHtml: string, markdown?: boolean): Descendant[] => {
|
||||||
const sanitizedHtml = sanitizeCustomHtml(unsafeHtml);
|
const sanitizedHtml = sanitizeCustomHtml(unsafeHtml);
|
||||||
|
|
||||||
|
const processText = (partText: string) => {
|
||||||
|
if (!markdown) return partText;
|
||||||
|
return escapeMarkdownInlineSequences(partText);
|
||||||
|
};
|
||||||
|
|
||||||
const domNodes = parse(sanitizedHtml);
|
const domNodes = parse(sanitizedHtml);
|
||||||
const editorNodes = domToEditorInput(domNodes);
|
const editorNodes = domToEditorInput(domNodes, processText, (lineStartText: string) => {
|
||||||
|
if (!markdown) return lineStartText;
|
||||||
|
return escapeMarkdownBlockSequences(lineStartText, processText);
|
||||||
|
});
|
||||||
return editorNodes;
|
return editorNodes;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const plainToEditorInput = (text: string): Descendant[] => {
|
export const plainToEditorInput = (text: string, markdown?: boolean): Descendant[] => {
|
||||||
const editorNodes: Descendant[] = text.split('\n').map((lineText) => {
|
const editorNodes: Descendant[] = text.split('\n').map((lineText) => {
|
||||||
const paragraphNode: ParagraphElement = {
|
const paragraphNode: ParagraphElement = {
|
||||||
type: BlockType.Paragraph,
|
type: BlockType.Paragraph,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
text: lineText,
|
text: markdown
|
||||||
|
? escapeMarkdownBlockSequences(lineText, escapeMarkdownInlineSequences)
|
||||||
|
: lineText,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { Descendant, Text } from 'slate';
|
import { Descendant, Editor, Text } from 'slate';
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
import { sanitizeText } from '../../utils/sanitize';
|
import { sanitizeText } from '../../utils/sanitize';
|
||||||
import { BlockType } from './types';
|
import { BlockType } from './types';
|
||||||
import { CustomElement } from './slate';
|
import { CustomElement } from './slate';
|
||||||
import { parseBlockMD, parseInlineMD } from '../../plugins/markdown';
|
import {
|
||||||
|
parseBlockMD,
|
||||||
|
parseInlineMD,
|
||||||
|
unescapeMarkdownBlockSequences,
|
||||||
|
unescapeMarkdownInlineSequences,
|
||||||
|
} from '../../plugins/markdown';
|
||||||
import { findAndReplace } from '../../utils/findAndReplace';
|
import { findAndReplace } from '../../utils/findAndReplace';
|
||||||
|
import { sanitizeForRegex } from '../../utils/regex';
|
||||||
|
import { getCanonicalAliasOrRoomId, isUserId } from '../../utils/matrix';
|
||||||
|
|
||||||
export type OutputOptions = {
|
export type OutputOptions = {
|
||||||
allowTextFormatting?: boolean;
|
allowTextFormatting?: boolean;
|
||||||
@@ -18,7 +25,7 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
|
|||||||
if (node.bold) string = `<strong>${string}</strong>`;
|
if (node.bold) string = `<strong>${string}</strong>`;
|
||||||
if (node.italic) string = `<i>${string}</i>`;
|
if (node.italic) string = `<i>${string}</i>`;
|
||||||
if (node.underline) string = `<u>${string}</u>`;
|
if (node.underline) string = `<u>${string}</u>`;
|
||||||
if (node.strikeThrough) string = `<del>${string}</del>`;
|
if (node.strikeThrough) string = `<s>${string}</s>`;
|
||||||
if (node.code) string = `<code>${string}</code>`;
|
if (node.code) string = `<code>${string}</code>`;
|
||||||
if (node.spoiler) string = `<span data-mx-spoiler>${string}</span>`;
|
if (node.spoiler) string = `<span data-mx-spoiler>${string}</span>`;
|
||||||
}
|
}
|
||||||
@@ -101,7 +108,8 @@ export const toMatrixCustomHTML = (
|
|||||||
allowBlockMarkdown: false,
|
allowBlockMarkdown: false,
|
||||||
})
|
})
|
||||||
.replace(/<br\/>$/, '\n')
|
.replace(/<br\/>$/, '\n')
|
||||||
.replace(/^>/, '>');
|
.replace(/^(\\*)>/, '$1>');
|
||||||
|
|
||||||
markdownLines += line;
|
markdownLines += line;
|
||||||
if (index === targetNodes.length - 1) {
|
if (index === targetNodes.length - 1) {
|
||||||
return parseBlockMD(markdownLines, ignoreHTMLParseInlineMD);
|
return parseBlockMD(markdownLines, ignoreHTMLParseInlineMD);
|
||||||
@@ -156,11 +164,14 @@ const elementToPlainText = (node: CustomElement, children: string): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toPlainText = (node: Descendant | Descendant[]): string => {
|
export const toPlainText = (node: Descendant | Descendant[], isMarkdown: boolean): string => {
|
||||||
if (Array.isArray(node)) return node.map((n) => toPlainText(n)).join('');
|
if (Array.isArray(node)) return node.map((n) => toPlainText(n, isMarkdown)).join('');
|
||||||
if (Text.isText(node)) return node.text;
|
if (Text.isText(node))
|
||||||
|
return isMarkdown
|
||||||
|
? unescapeMarkdownBlockSequences(node.text, unescapeMarkdownInlineSequences)
|
||||||
|
: node.text;
|
||||||
|
|
||||||
const children = node.children.map((n) => toPlainText(n)).join('');
|
const children = node.children.map((n) => toPlainText(n, isMarkdown)).join('');
|
||||||
return elementToPlainText(node, children);
|
return elementToPlainText(node, children);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -179,9 +190,42 @@ export const customHtmlEqualsPlainText = (customHtml: string, plain: string): bo
|
|||||||
export const trimCustomHtml = (customHtml: string) => customHtml.replace(/<br\/>$/g, '').trim();
|
export const trimCustomHtml = (customHtml: string) => customHtml.replace(/<br\/>$/g, '').trim();
|
||||||
|
|
||||||
export const trimCommand = (cmdName: string, str: string) => {
|
export const trimCommand = (cmdName: string, str: string) => {
|
||||||
const cmdRegX = new RegExp(`^(\\s+)?(\\/${cmdName})([^\\S\n]+)?`);
|
const cmdRegX = new RegExp(`^(\\s+)?(\\/${sanitizeForRegex(cmdName)})([^\\S\n]+)?`);
|
||||||
|
|
||||||
const match = str.match(cmdRegX);
|
const match = str.match(cmdRegX);
|
||||||
if (!match) return str;
|
if (!match) return str;
|
||||||
return str.slice(match[0].length);
|
return str.slice(match[0].length);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MentionsData = {
|
||||||
|
room: boolean;
|
||||||
|
users: Set<string>;
|
||||||
|
};
|
||||||
|
export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): MentionsData => {
|
||||||
|
const mentionData: MentionsData = {
|
||||||
|
room: false,
|
||||||
|
users: new Set(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseMentions = (node: Descendant): void => {
|
||||||
|
if (Text.isText(node)) return;
|
||||||
|
if (node.type === BlockType.CodeBlock) return;
|
||||||
|
|
||||||
|
if (node.type === BlockType.Mention) {
|
||||||
|
if (node.id === getCanonicalAliasOrRoomId(mx, roomId)) {
|
||||||
|
mentionData.room = true;
|
||||||
|
}
|
||||||
|
if (isUserId(node.id) && node.id !== mx.getUserId()) {
|
||||||
|
mentionData.users.add(node.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.children.forEach(parseMentions);
|
||||||
|
};
|
||||||
|
|
||||||
|
editor.children.forEach(parseMentions);
|
||||||
|
|
||||||
|
return mentionData;
|
||||||
|
};
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { addRecentEmoji } from '../../plugins/recent-emoji';
|
|||||||
import { mobileOrTablet } from '../../utils/user-agent';
|
import { mobileOrTablet } from '../../utils/user-agent';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
|
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
|
||||||
|
import { getEmoticonSearchStr } from '../../plugins/utils';
|
||||||
|
|
||||||
const RECENT_GROUP_ID = 'recent_group';
|
const RECENT_GROUP_ID = 'recent_group';
|
||||||
const SEARCH_GROUP_ID = 'search_group';
|
const SEARCH_GROUP_ID = 'search_group';
|
||||||
@@ -471,36 +472,34 @@ export function SearchEmojiGroup({
|
|||||||
return (
|
return (
|
||||||
<EmojiGroup key={id} id={id} label={label}>
|
<EmojiGroup key={id} id={id} label={label}>
|
||||||
{tab === EmojiBoardTab.Emoji
|
{tab === EmojiBoardTab.Emoji
|
||||||
? searchResult
|
? searchResult.map((emoji) =>
|
||||||
.sort((a, b) => a.shortcode.localeCompare(b.shortcode))
|
'unicode' in emoji ? (
|
||||||
.map((emoji) =>
|
<EmojiItem
|
||||||
'unicode' in emoji ? (
|
key={emoji.unicode}
|
||||||
<EmojiItem
|
label={emoji.label}
|
||||||
key={emoji.unicode}
|
type={EmojiType.Emoji}
|
||||||
label={emoji.label}
|
data={emoji.unicode}
|
||||||
type={EmojiType.Emoji}
|
shortcode={emoji.shortcode}
|
||||||
data={emoji.unicode}
|
>
|
||||||
shortcode={emoji.shortcode}
|
{emoji.unicode}
|
||||||
>
|
</EmojiItem>
|
||||||
{emoji.unicode}
|
) : (
|
||||||
</EmojiItem>
|
<EmojiItem
|
||||||
) : (
|
key={emoji.shortcode}
|
||||||
<EmojiItem
|
label={emoji.body || emoji.shortcode}
|
||||||
key={emoji.shortcode}
|
type={EmojiType.CustomEmoji}
|
||||||
label={emoji.body || emoji.shortcode}
|
data={emoji.url}
|
||||||
type={EmojiType.CustomEmoji}
|
shortcode={emoji.shortcode}
|
||||||
data={emoji.url}
|
>
|
||||||
shortcode={emoji.shortcode}
|
<img
|
||||||
>
|
loading="lazy"
|
||||||
<img
|
className={css.CustomEmojiImg}
|
||||||
loading="lazy"
|
alt={emoji.body || emoji.shortcode}
|
||||||
className={css.CustomEmojiImg}
|
src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url}
|
||||||
alt={emoji.body || emoji.shortcode}
|
/>
|
||||||
src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url}
|
</EmojiItem>
|
||||||
/>
|
|
||||||
</EmojiItem>
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
: searchResult.map((emoji) =>
|
: searchResult.map((emoji) =>
|
||||||
'unicode' in emoji ? null : (
|
'unicode' in emoji ? null : (
|
||||||
<StickerItem
|
<StickerItem
|
||||||
@@ -638,15 +637,8 @@ export const NativeEmojiGroups = memo(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSearchListItemStr = (item: PackImageReader | IEmoji) => {
|
|
||||||
const shortcode = `:${item.shortcode}:`;
|
|
||||||
if ('body' in item) {
|
|
||||||
return [shortcode, item.body ?? ''];
|
|
||||||
}
|
|
||||||
return shortcode;
|
|
||||||
};
|
|
||||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||||
limit: 26,
|
limit: 1000,
|
||||||
matchOptions: {
|
matchOptions: {
|
||||||
contain: true,
|
contain: true,
|
||||||
},
|
},
|
||||||
@@ -662,6 +654,7 @@ export function EmojiBoard({
|
|||||||
onCustomEmojiSelect,
|
onCustomEmojiSelect,
|
||||||
onStickerSelect,
|
onStickerSelect,
|
||||||
allowTextCustomEmoji,
|
allowTextCustomEmoji,
|
||||||
|
addToRecentEmoji = true,
|
||||||
}: {
|
}: {
|
||||||
tab?: EmojiBoardTab;
|
tab?: EmojiBoardTab;
|
||||||
onTabChange?: (tab: EmojiBoardTab) => void;
|
onTabChange?: (tab: EmojiBoardTab) => void;
|
||||||
@@ -672,6 +665,7 @@ export function EmojiBoard({
|
|||||||
onCustomEmojiSelect?: (mxc: string, shortcode: string) => void;
|
onCustomEmojiSelect?: (mxc: string, shortcode: string) => void;
|
||||||
onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
|
onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
|
||||||
allowTextCustomEmoji?: boolean;
|
allowTextCustomEmoji?: boolean;
|
||||||
|
addToRecentEmoji?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const emojiTab = tab === EmojiBoardTab.Emoji;
|
const emojiTab = tab === EmojiBoardTab.Emoji;
|
||||||
const stickerTab = tab === EmojiBoardTab.Sticker;
|
const stickerTab = tab === EmojiBoardTab.Sticker;
|
||||||
@@ -698,10 +692,12 @@ export function EmojiBoard({
|
|||||||
|
|
||||||
const [result, search, resetSearch] = useAsyncSearch(
|
const [result, search, resetSearch] = useAsyncSearch(
|
||||||
searchList,
|
searchList,
|
||||||
getSearchListItemStr,
|
getEmoticonSearchStr,
|
||||||
SEARCH_OPTIONS
|
SEARCH_OPTIONS
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const searchedItems = result?.items.slice(0, 100);
|
||||||
|
|
||||||
const handleOnChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
|
const handleOnChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
|
||||||
useCallback(
|
useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
@@ -741,7 +737,9 @@ export function EmojiBoard({
|
|||||||
if (emojiInfo.type === EmojiType.Emoji) {
|
if (emojiInfo.type === EmojiType.Emoji) {
|
||||||
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||||
if (!evt.altKey && !evt.shiftKey) {
|
if (!evt.altKey && !evt.shiftKey) {
|
||||||
addRecentEmoji(mx, emojiInfo.data);
|
if (addToRecentEmoji) {
|
||||||
|
addRecentEmoji(mx, emojiInfo.data);
|
||||||
|
}
|
||||||
requestClose();
|
requestClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -922,13 +920,13 @@ export function EmojiBoard({
|
|||||||
direction="Column"
|
direction="Column"
|
||||||
gap="200"
|
gap="200"
|
||||||
>
|
>
|
||||||
{result && (
|
{searchedItems && (
|
||||||
<SearchEmojiGroup
|
<SearchEmojiGroup
|
||||||
mx={mx}
|
mx={mx}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
id={SEARCH_GROUP_ID}
|
id={SEARCH_GROUP_ID}
|
||||||
label={result.items.length ? 'Search Results' : 'No Results found'}
|
label={searchedItems.length ? 'Search Results' : 'No Results found'}
|
||||||
emojis={result.items}
|
emojis={searchedItems}
|
||||||
useAuthentication={useAuthentication}
|
useAuthentication={useAuthentication}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ import { getMemberDisplayName } from '../../utils/room';
|
|||||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
import * as css from './EventReaders.css';
|
import * as css from './EventReaders.css';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { openProfileViewer } from '../../../client/action/navigation';
|
|
||||||
import { UserAvatar } from '../user-avatar';
|
import { UserAvatar } from '../user-avatar';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
|
|
||||||
export type EventReadersProps = {
|
export type EventReadersProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -33,6 +34,8 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const latestEventReaders = useRoomEventReaders(room, eventId);
|
const latestEventReaders = useRoomEventReaders(room, eventId);
|
||||||
|
const openProfile = useOpenUserRoomProfile();
|
||||||
|
const space = useSpaceOptionally();
|
||||||
|
|
||||||
const getName = (userId: string) =>
|
const getName = (userId: string) =>
|
||||||
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||||
@@ -57,19 +60,32 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||||||
<Box className={css.Content} direction="Column">
|
<Box className={css.Content} direction="Column">
|
||||||
{latestEventReaders.map((readerId) => {
|
{latestEventReaders.map((readerId) => {
|
||||||
const name = getName(readerId);
|
const name = getName(readerId);
|
||||||
const avatarMxcUrl = room
|
const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl();
|
||||||
.getMember(readerId)
|
const avatarUrl = avatarMxcUrl
|
||||||
?.getMxcAvatarUrl();
|
? mx.mxcUrlToHttp(
|
||||||
const avatarUrl = avatarMxcUrl ? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication) : undefined;
|
avatarMxcUrl,
|
||||||
|
100,
|
||||||
|
100,
|
||||||
|
'crop',
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
useAuthentication
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={readerId}
|
key={readerId}
|
||||||
style={{ padding: `0 ${config.space.S200}` }}
|
style={{ padding: `0 ${config.space.S200}` }}
|
||||||
radii="400"
|
radii="400"
|
||||||
onClick={() => {
|
onClick={(event) => {
|
||||||
requestClose();
|
openProfile(
|
||||||
openProfileViewer(readerId, room.roomId);
|
room.roomId,
|
||||||
|
space?.roomId,
|
||||||
|
readerId,
|
||||||
|
event.currentTarget.getBoundingClientRect(),
|
||||||
|
'Bottom'
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
before={
|
before={
|
||||||
<Avatar size="200">
|
<Avatar size="200">
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
|
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { ImagePackContent } from './ImagePackContent';
|
import { ImagePackContent } from './ImagePackContent';
|
||||||
import { ImagePack, PackContent } from '../../plugins/custom-emoji';
|
import { ImagePack, PackContent } from '../../plugins/custom-emoji';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { useRoomImagePack } from '../../hooks/useImagePacks';
|
import { useRoomImagePack } from '../../hooks/useImagePacks';
|
||||||
import { randomStr } from '../../utils/common';
|
import { randomStr } from '../../utils/common';
|
||||||
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
|
|
||||||
type RoomImagePackProps = {
|
type RoomImagePackProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -17,9 +19,10 @@ export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const userId = mx.getUserId()!;
|
const userId = mx.getUserId()!;
|
||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
|
const creators = useRoomCreators(room);
|
||||||
|
|
||||||
const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
const canEditImagePack = canSendStateEvent(StateEvent.PoniesRoomEmotes, getPowerLevel(userId));
|
const canEditImagePack = permissions.stateEvent(StateEvent.PoniesRoomEmotes, userId);
|
||||||
|
|
||||||
const fallbackPack = useMemo(() => {
|
const fallbackPack = useMemo(() => {
|
||||||
const fakePackId = randomStr(4);
|
const fakePackId = randomStr(4);
|
||||||
|
|||||||
53
src/app/components/member-tile/MemberTile.tsx
Normal file
53
src/app/components/member-tile/MemberTile.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { as, Avatar, Box, Icon, Icons, Text } from 'folds';
|
||||||
|
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
|
||||||
|
import { getMemberDisplayName } from '../../utils/room';
|
||||||
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
|
import { UserAvatar } from '../user-avatar';
|
||||||
|
import * as css from './style.css';
|
||||||
|
|
||||||
|
const getName = (room: Room, member: RoomMember) =>
|
||||||
|
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
|
||||||
|
|
||||||
|
type MemberTileProps = {
|
||||||
|
mx: MatrixClient;
|
||||||
|
room: Room;
|
||||||
|
member: RoomMember;
|
||||||
|
useAuthentication: boolean;
|
||||||
|
after?: ReactNode;
|
||||||
|
};
|
||||||
|
export const MemberTile = as<'button', MemberTileProps>(
|
||||||
|
({ as: AsMemberTile = 'button', mx, room, member, useAuthentication, after, ...props }, ref) => {
|
||||||
|
const name = getName(room, member);
|
||||||
|
const username = getMxIdLocalPart(member.userId);
|
||||||
|
|
||||||
|
const avatarMxcUrl = member.getMxcAvatarUrl();
|
||||||
|
const avatarUrl = avatarMxcUrl
|
||||||
|
? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AsMemberTile className={css.MemberTile} {...props} ref={ref}>
|
||||||
|
<Avatar size="300" radii="400">
|
||||||
|
<UserAvatar
|
||||||
|
userId={member.userId}
|
||||||
|
src={avatarUrl ?? undefined}
|
||||||
|
alt={name}
|
||||||
|
renderFallback={() => <Icon size="300" src={Icons.User} filled />}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
<Box grow="Yes" as="span" direction="Column">
|
||||||
|
<Text as="span" size="T300" truncate>
|
||||||
|
<b>{name}</b>
|
||||||
|
</Text>
|
||||||
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="100">
|
||||||
|
<Text as="span" size="T200" priority="300" truncate>
|
||||||
|
{username}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{after}
|
||||||
|
</AsMemberTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
1
src/app/components/member-tile/index.ts
Normal file
1
src/app/components/member-tile/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './MemberTile';
|
||||||
32
src/app/components/member-tile/style.css.ts
Normal file
32
src/app/components/member-tile/style.css.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, DefaultReset, Disabled, FocusOutline } from 'folds';
|
||||||
|
|
||||||
|
export const MemberTile = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S200,
|
||||||
|
|
||||||
|
padding: config.space.S100,
|
||||||
|
borderRadius: config.radii.R500,
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'button&': {
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
'&[aria-pressed=true]': {
|
||||||
|
backgroundColor: color.Surface.ContainerActive,
|
||||||
|
},
|
||||||
|
'button&:hover, &:focus-visible': {
|
||||||
|
backgroundColor: color.Surface.ContainerHover,
|
||||||
|
},
|
||||||
|
'button&:active': {
|
||||||
|
backgroundColor: color.Surface.ContainerActive,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
FocusOutline,
|
||||||
|
Disabled,
|
||||||
|
]);
|
||||||
@@ -1,22 +1,81 @@
|
|||||||
import { Badge, Box, Text, as, toRem } from 'folds';
|
import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
|
||||||
import React from 'react';
|
import React, { ReactNode, useCallback } from 'react';
|
||||||
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
|
import FileSaver from 'file-saver';
|
||||||
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import {
|
||||||
|
decryptFile,
|
||||||
|
downloadEncryptedMedia,
|
||||||
|
downloadMedia,
|
||||||
|
mxcUrlToHttp,
|
||||||
|
} from '../../utils/matrix';
|
||||||
|
|
||||||
const badgeStyles = { maxWidth: toRem(100) };
|
const badgeStyles = { maxWidth: toRem(100) };
|
||||||
|
|
||||||
|
type FileDownloadButtonProps = {
|
||||||
|
filename: string;
|
||||||
|
url: string;
|
||||||
|
mimeType: string;
|
||||||
|
encInfo?: EncryptedAttachmentInfo;
|
||||||
|
};
|
||||||
|
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
|
const [downloadState, download] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||||
|
const fileContent = encInfo
|
||||||
|
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||||
|
: await downloadMedia(mediaUrl);
|
||||||
|
|
||||||
|
const fileURL = URL.createObjectURL(fileContent);
|
||||||
|
FileSaver.saveAs(fileURL, filename);
|
||||||
|
return fileURL;
|
||||||
|
}, [mx, url, useAuthentication, mimeType, encInfo, filename])
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloading = downloadState.status === AsyncStatus.Loading;
|
||||||
|
const hasError = downloadState.status === AsyncStatus.Error;
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
disabled={downloading}
|
||||||
|
onClick={download}
|
||||||
|
variant={hasError ? 'Critical' : 'SurfaceVariant'}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
{downloading ? (
|
||||||
|
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
|
||||||
|
) : (
|
||||||
|
<Icon size="100" src={Icons.Download} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type FileHeaderProps = {
|
export type FileHeaderProps = {
|
||||||
body: string;
|
body: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
|
after?: ReactNode;
|
||||||
};
|
};
|
||||||
export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, ...props }, ref) => (
|
export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, after, ...props }, ref) => (
|
||||||
<Box alignItems="Center" gap="200" grow="Yes" {...props} ref={ref}>
|
<Box alignItems="Center" gap="200" grow="Yes" {...props} ref={ref}>
|
||||||
<Badge style={badgeStyles} variant="Secondary" radii="Pill">
|
<Box shrink="No">
|
||||||
<Text size="O400" truncate>
|
<Badge style={badgeStyles} variant="Secondary" radii="Pill">
|
||||||
{mimeTypeToExt(mimeType)}
|
<Text size="O400" truncate>
|
||||||
|
{mimeTypeToExt(mimeType)}
|
||||||
|
</Text>
|
||||||
|
</Badge>
|
||||||
|
</Box>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{body}
|
||||||
</Text>
|
</Text>
|
||||||
</Badge>
|
</Box>
|
||||||
<Text size="T300" truncate>
|
{after}
|
||||||
{body}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { CSSProperties, ReactNode } from 'react';
|
||||||
import { Box, Chip, Icon, Icons, Text, toRem } from 'folds';
|
import { Box, Chip, Icon, Icons, Text, toRem } from 'folds';
|
||||||
import { IContent } from 'matrix-js-sdk';
|
import { IContent } from 'matrix-js-sdk';
|
||||||
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
|
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
|
||||||
@@ -22,11 +22,13 @@ import {
|
|||||||
IThumbnailContent,
|
IThumbnailContent,
|
||||||
IVideoContent,
|
IVideoContent,
|
||||||
IVideoInfo,
|
IVideoInfo,
|
||||||
|
MATRIX_SPOILER_PROPERTY_NAME,
|
||||||
|
MATRIX_SPOILER_REASON_PROPERTY_NAME,
|
||||||
} from '../../../types/matrix/common';
|
} from '../../../types/matrix/common';
|
||||||
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
|
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
|
||||||
import { parseGeoUri, scaleYDimension } from '../../utils/common';
|
import { parseGeoUri, scaleYDimension } from '../../utils/common';
|
||||||
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
||||||
import { FileHeader } from './FileHeader';
|
import { FileHeader, FileDownloadButton } from './FileHeader';
|
||||||
|
|
||||||
export function MBadEncrypted() {
|
export function MBadEncrypted() {
|
||||||
return (
|
return (
|
||||||
@@ -72,8 +74,9 @@ type MTextProps = {
|
|||||||
content: Record<string, unknown>;
|
content: Record<string, unknown>;
|
||||||
renderBody: (props: RenderBodyProps) => ReactNode;
|
renderBody: (props: RenderBodyProps) => ReactNode;
|
||||||
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
||||||
|
style?: CSSProperties;
|
||||||
};
|
};
|
||||||
export function MText({ edited, content, renderBody, renderUrlsPreview }: MTextProps) {
|
export function MText({ edited, content, renderBody, renderUrlsPreview, style }: MTextProps) {
|
||||||
const { body, formatted_body: customBody } = content;
|
const { body, formatted_body: customBody } = content;
|
||||||
|
|
||||||
if (typeof body !== 'string') return <BrokenContent />;
|
if (typeof body !== 'string') return <BrokenContent />;
|
||||||
@@ -86,6 +89,7 @@ export function MText({ edited, content, renderBody, renderUrlsPreview }: MTextP
|
|||||||
<MessageTextBody
|
<MessageTextBody
|
||||||
preWrap={typeof customBody !== 'string'}
|
preWrap={typeof customBody !== 'string'}
|
||||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
||||||
|
style={style}
|
||||||
>
|
>
|
||||||
{renderBody({
|
{renderBody({
|
||||||
body: trimmedBody,
|
body: trimmedBody,
|
||||||
@@ -177,6 +181,8 @@ type RenderImageContentProps = {
|
|||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
url: string;
|
url: string;
|
||||||
encInfo?: IEncryptedFile;
|
encInfo?: IEncryptedFile;
|
||||||
|
markedAsSpoiler?: boolean;
|
||||||
|
spoilerReason?: string;
|
||||||
};
|
};
|
||||||
type MImageProps = {
|
type MImageProps = {
|
||||||
content: IImageContent;
|
content: IImageContent;
|
||||||
@@ -204,6 +210,8 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
|||||||
mimeType: imgInfo?.mimetype,
|
mimeType: imgInfo?.mimetype,
|
||||||
url: mxcUrl,
|
url: mxcUrl,
|
||||||
encInfo: content.file,
|
encInfo: content.file,
|
||||||
|
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
|
||||||
|
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
|
||||||
})}
|
})}
|
||||||
</AttachmentBox>
|
</AttachmentBox>
|
||||||
</Attachment>
|
</Attachment>
|
||||||
@@ -237,8 +245,24 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
|||||||
|
|
||||||
const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
|
const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
|
||||||
|
|
||||||
|
const filename = content.filename ?? content.body ?? 'Video';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Attachment outlined={outlined}>
|
<Attachment outlined={outlined}>
|
||||||
|
<AttachmentHeader>
|
||||||
|
<FileHeader
|
||||||
|
body={filename}
|
||||||
|
mimeType={safeMimeType}
|
||||||
|
after={
|
||||||
|
<FileDownloadButton
|
||||||
|
filename={filename}
|
||||||
|
url={mxcUrl}
|
||||||
|
mimeType={safeMimeType}
|
||||||
|
encInfo={content.file}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</AttachmentHeader>
|
||||||
<AttachmentBox
|
<AttachmentBox
|
||||||
style={{
|
style={{
|
||||||
height: toRem(height < 48 ? 48 : height),
|
height: toRem(height < 48 ? 48 : height),
|
||||||
@@ -280,10 +304,22 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }:
|
|||||||
return <BrokenContent />;
|
return <BrokenContent />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filename = content.filename ?? content.body ?? 'Audio';
|
||||||
return (
|
return (
|
||||||
<Attachment outlined={outlined}>
|
<Attachment outlined={outlined}>
|
||||||
<AttachmentHeader>
|
<AttachmentHeader>
|
||||||
<FileHeader body={content.filename ?? content.body ?? 'Audio'} mimeType={safeMimeType} />
|
<FileHeader
|
||||||
|
body={filename}
|
||||||
|
mimeType={safeMimeType}
|
||||||
|
after={
|
||||||
|
<FileDownloadButton
|
||||||
|
filename={filename}
|
||||||
|
url={mxcUrl}
|
||||||
|
mimeType={safeMimeType}
|
||||||
|
encInfo={content.file}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</AttachmentHeader>
|
</AttachmentHeader>
|
||||||
<AttachmentBox>
|
<AttachmentBox>
|
||||||
<AttachmentContent>
|
<AttachmentContent>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export const ReplyBend = style({
|
|||||||
|
|
||||||
export const ThreadIndicator = style({
|
export const ThreadIndicator = style({
|
||||||
opacity: config.opacity.P300,
|
opacity: config.opacity.P300,
|
||||||
gap: toRem(2),
|
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'button&': {
|
'button&': {
|
||||||
@@ -19,11 +18,6 @@ export const ThreadIndicator = style({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ThreadIndicatorIcon = style({
|
|
||||||
width: toRem(14),
|
|
||||||
height: toRem(14),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Reply = style({
|
export const Reply = style({
|
||||||
marginBottom: toRem(1),
|
marginBottom: toRem(1),
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
|
|||||||
import { EventTimelineSet, Room } from 'matrix-js-sdk';
|
import { EventTimelineSet, Room } from 'matrix-js-sdk';
|
||||||
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
|
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
|
||||||
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
|
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
|
||||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
import { LinePlaceholder } from './placeholder';
|
import { LinePlaceholder } from './placeholder';
|
||||||
@@ -11,6 +10,8 @@ import * as css from './Reply.css';
|
|||||||
import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
|
import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
|
||||||
import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
|
import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
|
||||||
import { useRoomEvent } from '../../hooks/useRoomEvent';
|
import { useRoomEvent } from '../../hooks/useRoomEvent';
|
||||||
|
import colorMXID from '../../../util/colorMXID';
|
||||||
|
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||||
|
|
||||||
type ReplyLayoutProps = {
|
type ReplyLayoutProps = {
|
||||||
userColor?: string;
|
userColor?: string;
|
||||||
@@ -37,9 +38,16 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
||||||
<Box className={css.ThreadIndicator} alignItems="Center" {...props} ref={ref}>
|
<Box
|
||||||
<Icon className={css.ThreadIndicatorIcon} src={Icons.Message} />
|
shrink="No"
|
||||||
<Text size="T200">Threaded reply</Text>
|
className={css.ThreadIndicator}
|
||||||
|
alignItems="Center"
|
||||||
|
gap="100"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Thread} />
|
||||||
|
<Text size="L400">Thread</Text>
|
||||||
</Box>
|
</Box>
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -49,10 +57,26 @@ type ReplyProps = {
|
|||||||
replyEventId: string;
|
replyEventId: string;
|
||||||
threadRootId?: string | undefined;
|
threadRootId?: string | undefined;
|
||||||
onClick?: MouseEventHandler | undefined;
|
onClick?: MouseEventHandler | undefined;
|
||||||
|
getMemberPowerTag?: GetMemberPowerTag;
|
||||||
|
accessibleTagColors?: Map<string, string>;
|
||||||
|
legacyUsernameColor?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Reply = as<'div', ReplyProps>(
|
export const Reply = as<'div', ReplyProps>(
|
||||||
({ room, timelineSet, replyEventId, threadRootId, onClick, ...props }, ref) => {
|
(
|
||||||
|
{
|
||||||
|
room,
|
||||||
|
timelineSet,
|
||||||
|
replyEventId,
|
||||||
|
threadRootId,
|
||||||
|
onClick,
|
||||||
|
getMemberPowerTag,
|
||||||
|
accessibleTagColors,
|
||||||
|
legacyUsernameColor,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
|
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
|
||||||
const getFromLocalTimeline = useCallback(
|
const getFromLocalTimeline = useCallback(
|
||||||
() => timelineSet?.findEventById(replyEventId),
|
() => timelineSet?.findEventById(replyEventId),
|
||||||
@@ -62,6 +86,10 @@ export const Reply = as<'div', ReplyProps>(
|
|||||||
|
|
||||||
const { body } = replyEvent?.getContent() ?? {};
|
const { body } = replyEvent?.getContent() ?? {};
|
||||||
const sender = replyEvent?.getSender();
|
const sender = replyEvent?.getSender();
|
||||||
|
const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
|
||||||
|
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;
|
||||||
|
|
||||||
|
const usernameColor = legacyUsernameColor ? colorMXID(sender ?? replyEventId) : tagColor;
|
||||||
|
|
||||||
const fallbackBody = replyEvent?.isRedacted() ? (
|
const fallbackBody = replyEvent?.isRedacted() ? (
|
||||||
<MessageDeletedContent />
|
<MessageDeletedContent />
|
||||||
@@ -73,13 +101,13 @@ export const Reply = as<'div', ReplyProps>(
|
|||||||
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
|
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" alignItems="Start" {...props} ref={ref}>
|
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
|
||||||
{threadRootId && (
|
{threadRootId && (
|
||||||
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
|
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
|
||||||
)}
|
)}
|
||||||
<ReplyLayout
|
<ReplyLayout
|
||||||
as="button"
|
as="button"
|
||||||
userColor={sender ? colorMXID(sender) : undefined}
|
userColor={usernameColor}
|
||||||
username={
|
username={
|
||||||
sender && (
|
sender && (
|
||||||
<Text size="T300" truncate>
|
<Text size="T300" truncate>
|
||||||
|
|||||||
@@ -5,19 +5,35 @@ import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/ti
|
|||||||
export type TimeProps = {
|
export type TimeProps = {
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
ts: number;
|
ts: number;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a formatted timestamp, supporting compact and full display modes.
|
||||||
|
*
|
||||||
|
* Displays the time in hour:minute format if the message is from today, yesterday, or if `compact` is true.
|
||||||
|
* For older messages, it shows the date and time.
|
||||||
|
*
|
||||||
|
* @param {number} ts - The timestamp to display.
|
||||||
|
* @param {boolean} [compact=false] - If true, always show only the time.
|
||||||
|
* @param {boolean} hour24Clock - Whether to use 24-hour time format.
|
||||||
|
* @param {string} dateFormatString - Format string for the date part.
|
||||||
|
* @returns {React.ReactElement} A <Text as="time"> element with the formatted date/time.
|
||||||
|
*/
|
||||||
export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
|
export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
|
||||||
({ compact, ts, ...props }, ref) => {
|
({ compact, hour24Clock, dateFormatString, ts, ...props }, ref) => {
|
||||||
|
const formattedTime = timeHourMinute(ts, hour24Clock);
|
||||||
|
|
||||||
let time = '';
|
let time = '';
|
||||||
if (compact) {
|
if (compact) {
|
||||||
time = timeHourMinute(ts);
|
time = formattedTime;
|
||||||
} else if (today(ts)) {
|
} else if (today(ts)) {
|
||||||
time = timeHourMinute(ts);
|
time = formattedTime;
|
||||||
} else if (yesterday(ts)) {
|
} else if (yesterday(ts)) {
|
||||||
time = `Yesterday ${timeHourMinute(ts)}`;
|
time = `Yesterday ${formattedTime}`;
|
||||||
} else {
|
} else {
|
||||||
time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`;
|
time = `${timeDayMonYear(ts, dateFormatString)} ${formattedTime}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Chip,
|
||||||
Icon,
|
Icon,
|
||||||
Icons,
|
Icons,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -29,6 +30,7 @@ import { stopPropagation } from '../../../utils/keyboard';
|
|||||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { ModalWide } from '../../../styles/Modal.css';
|
import { ModalWide } from '../../../styles/Modal.css';
|
||||||
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
|
|
||||||
type RenderViewerProps = {
|
type RenderViewerProps = {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -51,6 +53,8 @@ export type ImageContentProps = {
|
|||||||
info?: IImageInfo;
|
info?: IImageInfo;
|
||||||
encInfo?: EncryptedAttachmentInfo;
|
encInfo?: EncryptedAttachmentInfo;
|
||||||
autoPlay?: boolean;
|
autoPlay?: boolean;
|
||||||
|
markedAsSpoiler?: boolean;
|
||||||
|
spoilerReason?: string;
|
||||||
renderViewer: (props: RenderViewerProps) => ReactNode;
|
renderViewer: (props: RenderViewerProps) => ReactNode;
|
||||||
renderImage: (props: RenderImageProps) => ReactNode;
|
renderImage: (props: RenderImageProps) => ReactNode;
|
||||||
};
|
};
|
||||||
@@ -64,6 +68,8 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
info,
|
info,
|
||||||
encInfo,
|
encInfo,
|
||||||
autoPlay,
|
autoPlay,
|
||||||
|
markedAsSpoiler,
|
||||||
|
spoilerReason,
|
||||||
renderViewer,
|
renderViewer,
|
||||||
renderImage,
|
renderImage,
|
||||||
...props
|
...props
|
||||||
@@ -72,11 +78,12 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
) => {
|
) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const blurHash = info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
|
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
||||||
|
|
||||||
const [load, setLoad] = useState(false);
|
const [load, setLoad] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [viewer, setViewer] = useState(false);
|
const [viewer, setViewer] = useState(false);
|
||||||
|
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||||
|
|
||||||
const [srcState, loadSrc] = useAsyncCallback(
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
@@ -145,7 +152,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
punch={1}
|
punch={1}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!autoPlay && srcState.status === AsyncStatus.Idle && (
|
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
|
||||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||||
<Button
|
<Button
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
@@ -160,7 +167,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Box className={css.AbsoluteContainer}>
|
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||||
{renderImage({
|
{renderImage({
|
||||||
alt: body,
|
alt: body,
|
||||||
title: body,
|
title: body,
|
||||||
@@ -172,8 +179,42 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
{blurred && !error && srcState.status !== AsyncStatus.Error && (
|
||||||
|
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||||
|
<TooltipProvider
|
||||||
|
tooltip={
|
||||||
|
typeof spoilerReason === 'string' && (
|
||||||
|
<Tooltip variant="Secondary">
|
||||||
|
<Text>{spoilerReason}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
position="Top"
|
||||||
|
align="Center"
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<Chip
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
size="500"
|
||||||
|
outlined
|
||||||
|
onClick={() => {
|
||||||
|
setBlurred(false);
|
||||||
|
if (srcState.status === AsyncStatus.Idle) {
|
||||||
|
loadSrc();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Spoiler</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
|
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
|
||||||
!load && (
|
!load &&
|
||||||
|
!markedAsSpoiler && (
|
||||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||||
<Spinner variant="Secondary" />
|
<Spinner variant="Secondary" />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
mxcUrlToHttp,
|
mxcUrlToHttp,
|
||||||
} from '../../../utils/matrix';
|
} from '../../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
|
|
||||||
type RenderVideoProps = {
|
type RenderVideoProps = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -68,7 +69,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
) => {
|
) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const blurHash = info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
|
const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
||||||
|
|
||||||
const [load, setLoad] = useState(false);
|
const [load, setLoad] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const AbsoluteContainer = style([
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
zIndex: 1,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
},
|
},
|
||||||
@@ -30,3 +31,10 @@ export const AbsoluteFooter = style([
|
|||||||
right: config.space.S100,
|
right: config.space.S100,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const Blur = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
filter: 'blur(44px)',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export const Username = as<'span'>(({ as: AsUsername = 'span', className, ...pro
|
|||||||
<AsUsername className={classNames(css.Username, className)} {...props} ref={ref} />
|
<AsUsername className={classNames(css.Username, className)} {...props} ref={ref} />
|
||||||
));
|
));
|
||||||
|
|
||||||
|
export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...props }, ref) => (
|
||||||
|
<AsUsernameBold className={classNames(css.UsernameBold, className)} {...props} ref={ref} />
|
||||||
|
));
|
||||||
|
|
||||||
export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?: boolean }>(
|
export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?: boolean }>(
|
||||||
({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => (
|
({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => (
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export const AvatarBase = style({
|
|||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
transform: `translateY(${toRem(-4)})`,
|
transform: `translateY(${toRem(-2)})`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -157,6 +157,10 @@ export const Username = style({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const UsernameBold = style({
|
||||||
|
fontWeight: 550,
|
||||||
|
});
|
||||||
|
|
||||||
export const MessageTextBody = recipe({
|
export const MessageTextBody = recipe({
|
||||||
base: {
|
base: {
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
|
|||||||
@@ -105,6 +105,20 @@ export const PageContent = as<'div'>(({ className, ...props }, ref) => (
|
|||||||
<div className={classNames(css.PageContent, className)} {...props} ref={ref} />
|
<div className={classNames(css.PageContent, className)} {...props} ref={ref} />
|
||||||
));
|
));
|
||||||
|
|
||||||
|
export function PageHeroEmpty({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={classNames(ContainerColor({ variant: 'SurfaceVariant' }), css.PageHeroEmpty)}
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
gap="200"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const PageHeroSection = as<'div', ComponentProps<typeof Box>>(
|
export const PageHeroSection = as<'div', ComponentProps<typeof Box>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -92,6 +92,15 @@ export const PageContent = style([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const PageHeroEmpty = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
padding: config.space.S400,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
minHeight: toRem(450),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
export const PageHeroSection = style([
|
export const PageHeroSection = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
|
|||||||
21
src/app/components/power/PowerColorBadge.tsx
Normal file
21
src/app/components/power/PowerColorBadge.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { as } from 'folds';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import * as css from './style.css';
|
||||||
|
|
||||||
|
type PowerColorBadgeProps = {
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
export const PowerColorBadge = as<'span', PowerColorBadgeProps>(
|
||||||
|
({ as: AsPowerColorBadge = 'span', color, className, style, ...props }, ref) => (
|
||||||
|
<AsPowerColorBadge
|
||||||
|
className={classNames(css.PowerColorBadge, { [css.PowerColorBadgeNone]: !color }, className)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
15
src/app/components/power/PowerIcon.tsx
Normal file
15
src/app/components/power/PowerIcon.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import * as css from './style.css';
|
||||||
|
import { JUMBO_EMOJI_REG } from '../../utils/regex';
|
||||||
|
|
||||||
|
type PowerIconProps = css.PowerIconVariants & {
|
||||||
|
iconSrc: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
export function PowerIcon({ size, iconSrc, name }: PowerIconProps) {
|
||||||
|
return JUMBO_EMOJI_REG.test(iconSrc) ? (
|
||||||
|
<span className={css.PowerIcon({ size })}>{iconSrc}</span>
|
||||||
|
) : (
|
||||||
|
<img className={css.PowerIcon({ size })} src={iconSrc} alt={name} />
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/app/components/power/PowerSelector.tsx
Normal file
94
src/app/components/power/PowerSelector.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React, { forwardRef, MouseEventHandler, ReactNode, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { Box, config, Menu, MenuItem, PopOut, Scroll, Text, toRem, RectCords } from 'folds';
|
||||||
|
import { getPowers, PowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||||
|
import { PowerColorBadge } from './PowerColorBadge';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
type PowerSelectorProps = {
|
||||||
|
powerLevelTags: PowerLevelTags;
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
};
|
||||||
|
export const PowerSelector = forwardRef<HTMLDivElement, PowerSelectorProps>(
|
||||||
|
({ powerLevelTags, value, onChange }, ref) => (
|
||||||
|
<Menu
|
||||||
|
ref={ref}
|
||||||
|
style={{
|
||||||
|
maxHeight: '75vh',
|
||||||
|
maxWidth: toRem(300),
|
||||||
|
display: 'flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Scroll size="0" hideTrack visibility="Hover">
|
||||||
|
<div style={{ padding: config.space.S100 }}>
|
||||||
|
{getPowers(powerLevelTags).map((power) => {
|
||||||
|
const selected = value === power;
|
||||||
|
const tag = powerLevelTags[power];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={power}
|
||||||
|
aria-pressed={selected}
|
||||||
|
radii="300"
|
||||||
|
onClick={selected ? undefined : () => onChange(power)}
|
||||||
|
before={<PowerColorBadge color={tag.color} />}
|
||||||
|
after={<Text size="L400">{power}</Text>}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
||||||
|
{tag.name}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
type PowerSwitcherProps = PowerSelectorProps & {
|
||||||
|
children: (handleOpen: MouseEventHandler<HTMLButtonElement>, opened: boolean) => ReactNode;
|
||||||
|
};
|
||||||
|
export function PowerSwitcher({ powerLevelTags, value, onChange, children }: PowerSwitcherProps) {
|
||||||
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={menuCords}
|
||||||
|
offset={5}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setMenuCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PowerSelector
|
||||||
|
powerLevelTags={powerLevelTags}
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => {
|
||||||
|
onChange(v);
|
||||||
|
setMenuCords(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children(handleOpen, !!menuCords)}
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/app/components/power/index.ts
Normal file
3
src/app/components/power/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './PowerColorBadge';
|
||||||
|
export * from './PowerIcon';
|
||||||
|
export * from './PowerSelector';
|
||||||
90
src/app/components/power/style.css.ts
Normal file
90
src/app/components/power/style.css.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { createVar, style } from '@vanilla-extract/css';
|
||||||
|
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
||||||
|
import { color, config, DefaultReset, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const PowerColorBadge = style({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
width: toRem(16),
|
||||||
|
height: toRem(16),
|
||||||
|
borderRadius: config.radii.Pill,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Secondary.ContainerLine}`,
|
||||||
|
position: 'relative',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PowerColorBadgeNone = style({
|
||||||
|
selectors: {
|
||||||
|
'&::before': {
|
||||||
|
content: '',
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '100%',
|
||||||
|
height: config.borderWidth.B300,
|
||||||
|
backgroundColor: color.Critical.Main,
|
||||||
|
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `rotateZ(-45deg)`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const PowerIconSize = createVar();
|
||||||
|
export const PowerIcon = recipe({
|
||||||
|
base: [
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'inline-flex',
|
||||||
|
height: PowerIconSize,
|
||||||
|
minWidth: PowerIconSize,
|
||||||
|
fontSize: PowerIconSize,
|
||||||
|
lineHeight: PowerIconSize,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
cursor: 'default',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
'50': {
|
||||||
|
vars: {
|
||||||
|
[PowerIconSize]: config.size.X50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'100': {
|
||||||
|
vars: {
|
||||||
|
[PowerIconSize]: config.size.X100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'200': {
|
||||||
|
vars: {
|
||||||
|
[PowerIconSize]: config.size.X200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'300': {
|
||||||
|
vars: {
|
||||||
|
[PowerIconSize]: config.size.X300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'400': {
|
||||||
|
vars: {
|
||||||
|
[PowerIconSize]: config.size.X400,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'500': {
|
||||||
|
vars: {
|
||||||
|
[PowerIconSize]: config.size.X500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'600': {
|
||||||
|
vars: {
|
||||||
|
[PowerIconSize]: config.size.X600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: '400',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PowerIconVariants = RecipeVariants<typeof PowerIcon>;
|
||||||
80
src/app/components/presence/Presence.tsx
Normal file
80
src/app/components/presence/Presence.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
as,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
color,
|
||||||
|
ContainerColor,
|
||||||
|
MainColor,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
TooltipProvider,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import React, { ReactNode, useId } from 'react';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
import { Presence, usePresenceLabel } from '../../hooks/useUserPresence';
|
||||||
|
|
||||||
|
const PresenceToColor: Record<Presence, MainColor> = {
|
||||||
|
[Presence.Online]: 'Success',
|
||||||
|
[Presence.Unavailable]: 'Warning',
|
||||||
|
[Presence.Offline]: 'Secondary',
|
||||||
|
};
|
||||||
|
|
||||||
|
type PresenceBadgeProps = {
|
||||||
|
presence: Presence;
|
||||||
|
status?: string;
|
||||||
|
size?: '200' | '300' | '400' | '500';
|
||||||
|
};
|
||||||
|
export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
||||||
|
const label = usePresenceLabel();
|
||||||
|
const badgeLabelId = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Right"
|
||||||
|
align="Center"
|
||||||
|
offset={4}
|
||||||
|
delay={200}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip id={badgeLabelId}>
|
||||||
|
<Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
|
||||||
|
<Text size="L400">{label[presence]}</Text>
|
||||||
|
{status && <Text size="T200">•</Text>}
|
||||||
|
{status && <Text size="T200">{status}</Text>}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<Badge
|
||||||
|
aria-labelledby={badgeLabelId}
|
||||||
|
ref={triggerRef}
|
||||||
|
size={size}
|
||||||
|
variant={PresenceToColor[presence]}
|
||||||
|
fill={presence === Presence.Offline ? 'Soft' : 'Solid'}
|
||||||
|
radii="Pill"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AvatarPresenceProps = {
|
||||||
|
badge: ReactNode;
|
||||||
|
variant?: ContainerColor;
|
||||||
|
};
|
||||||
|
export const AvatarPresence = as<'div', AvatarPresenceProps>(
|
||||||
|
({ as: AsAvatarPresence, badge, variant = 'Surface', children, ...props }, ref) => (
|
||||||
|
<Box as={AsAvatarPresence} className={css.AvatarPresence} {...props} ref={ref}>
|
||||||
|
{badge && (
|
||||||
|
<div
|
||||||
|
className={css.AvatarPresenceBadge}
|
||||||
|
style={{ backgroundColor: color[variant].Container }}
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
);
|
||||||
1
src/app/components/presence/index.ts
Normal file
1
src/app/components/presence/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './Presence';
|
||||||
22
src/app/components/presence/styles.css.ts
Normal file
22
src/app/components/presence/styles.css.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { config } from 'folds';
|
||||||
|
|
||||||
|
export const AvatarPresence = style({
|
||||||
|
display: 'flex',
|
||||||
|
position: 'relative',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AvatarPresenceBadge = style({
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
transform: 'translate(25%, 25%)',
|
||||||
|
zIndex: 1,
|
||||||
|
|
||||||
|
display: 'flex',
|
||||||
|
padding: config.borderWidth.B600,
|
||||||
|
backgroundColor: 'inherit',
|
||||||
|
borderRadius: config.radii.Pill,
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
@@ -15,6 +15,8 @@ import { nameInitials } from '../../utils/common';
|
|||||||
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
|
||||||
export type RoomIntroProps = {
|
export type RoomIntroProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -43,6 +45,8 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
|||||||
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
|
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
|
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
|
||||||
<Box>
|
<Box>
|
||||||
@@ -67,7 +71,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
|||||||
<Text size="T200" priority="300">
|
<Text size="T200" priority="300">
|
||||||
{'Created by '}
|
{'Created by '}
|
||||||
<b>@{creatorName}</b>
|
<b>@{creatorName}</b>
|
||||||
{` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts)}`}
|
{` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -83,7 +87,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
|||||||
{typeof prevRoomId === 'string' &&
|
{typeof prevRoomId === 'string' &&
|
||||||
(mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? (
|
(mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigateRoom(prevRoomId)}
|
onClick={() => navigateRoom(prevRoomId, createContent?.predecessor?.event_id)}
|
||||||
variant="Success"
|
variant="Success"
|
||||||
size="300"
|
size="300"
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
|
|||||||
@@ -7,12 +7,31 @@ import * as css from './style.css';
|
|||||||
export const SequenceCard = as<
|
export const SequenceCard = as<
|
||||||
'div',
|
'div',
|
||||||
ComponentProps<typeof Box> & ContainerColorVariants & css.SequenceCardVariants
|
ComponentProps<typeof Box> & ContainerColorVariants & css.SequenceCardVariants
|
||||||
>(({ className, variant, firstChild, lastChild, outlined, ...props }, ref) => (
|
>(
|
||||||
<Box
|
(
|
||||||
className={classNames(css.SequenceCard({ outlined }), ContainerColor({ variant }), className)}
|
{
|
||||||
data-first-child={firstChild}
|
as: AsSequenceCard = 'div',
|
||||||
data-last-child={lastChild}
|
className,
|
||||||
{...props}
|
variant,
|
||||||
ref={ref}
|
radii,
|
||||||
/>
|
firstChild,
|
||||||
));
|
lastChild,
|
||||||
|
outlined,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<Box
|
||||||
|
as={AsSequenceCard}
|
||||||
|
className={classNames(
|
||||||
|
css.SequenceCard({ radii, outlined }),
|
||||||
|
ContainerColor({ variant }),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-first-child={firstChild}
|
||||||
|
data-last-child={lastChild}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
|
|||||||
import { config } from 'folds';
|
import { config } from 'folds';
|
||||||
|
|
||||||
const outlinedWidth = createVar('0');
|
const outlinedWidth = createVar('0');
|
||||||
|
const radii = createVar(config.radii.R400);
|
||||||
export const SequenceCard = recipe({
|
export const SequenceCard = recipe({
|
||||||
base: {
|
base: {
|
||||||
vars: {
|
vars: {
|
||||||
@@ -13,33 +14,59 @@ export const SequenceCard = recipe({
|
|||||||
borderBottomWidth: 0,
|
borderBottomWidth: 0,
|
||||||
selectors: {
|
selectors: {
|
||||||
'&:first-child, :not(&) + &': {
|
'&:first-child, :not(&) + &': {
|
||||||
borderTopLeftRadius: config.radii.R400,
|
borderTopLeftRadius: [radii],
|
||||||
borderTopRightRadius: config.radii.R400,
|
borderTopRightRadius: [radii],
|
||||||
},
|
},
|
||||||
'&:last-child, &:not(:has(+&))': {
|
'&:last-child, &:not(:has(+&))': {
|
||||||
borderBottomLeftRadius: config.radii.R400,
|
borderBottomLeftRadius: [radii],
|
||||||
borderBottomRightRadius: config.radii.R400,
|
borderBottomRightRadius: [radii],
|
||||||
borderBottomWidth: outlinedWidth,
|
borderBottomWidth: outlinedWidth,
|
||||||
},
|
},
|
||||||
[`&[data-first-child="true"]`]: {
|
[`&[data-first-child="true"]`]: {
|
||||||
borderTopLeftRadius: config.radii.R400,
|
borderTopLeftRadius: [radii],
|
||||||
borderTopRightRadius: config.radii.R400,
|
borderTopRightRadius: [radii],
|
||||||
},
|
},
|
||||||
[`&[data-first-child="false"]`]: {
|
[`&[data-first-child="false"]`]: {
|
||||||
borderTopLeftRadius: 0,
|
borderTopLeftRadius: 0,
|
||||||
borderTopRightRadius: 0,
|
borderTopRightRadius: 0,
|
||||||
},
|
},
|
||||||
[`&[data-last-child="true"]`]: {
|
[`&[data-last-child="true"]`]: {
|
||||||
borderBottomLeftRadius: config.radii.R400,
|
borderBottomLeftRadius: [radii],
|
||||||
borderBottomRightRadius: config.radii.R400,
|
borderBottomRightRadius: [radii],
|
||||||
},
|
},
|
||||||
[`&[data-last-child="false"]`]: {
|
[`&[data-last-child="false"]`]: {
|
||||||
borderBottomLeftRadius: 0,
|
borderBottomLeftRadius: 0,
|
||||||
borderBottomRightRadius: 0,
|
borderBottomRightRadius: 0,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'button&': {
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
|
radii: {
|
||||||
|
'0': {
|
||||||
|
vars: {
|
||||||
|
[radii]: config.radii.R0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'300': {
|
||||||
|
vars: {
|
||||||
|
[radii]: config.radii.R300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'400': {
|
||||||
|
vars: {
|
||||||
|
[radii]: config.radii.R400,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'500': {
|
||||||
|
vars: {
|
||||||
|
[radii]: config.radii.R500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
outlined: {
|
outlined: {
|
||||||
true: {
|
true: {
|
||||||
vars: {
|
vars: {
|
||||||
@@ -48,5 +75,8 @@ export const SequenceCard = recipe({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
radii: '400',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
export type SequenceCardVariants = RecipeVariants<typeof SequenceCard>;
|
export type SequenceCardVariants = RecipeVariants<typeof SequenceCard>;
|
||||||
|
|||||||
16
src/app/components/server-badge/ServerBadge.tsx
Normal file
16
src/app/components/server-badge/ServerBadge.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { as, Badge, Text } from 'folds';
|
||||||
|
|
||||||
|
export const ServerBadge = as<
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
server: string;
|
||||||
|
fill?: 'Solid' | 'None';
|
||||||
|
}
|
||||||
|
>(({ as: AsServerBadge = 'div', fill, server, ...props }, ref) => (
|
||||||
|
<Badge as={AsServerBadge} variant="Secondary" fill={fill} radii="300" {...props} ref={ref}>
|
||||||
|
<Text as="span" size="L400" truncate>
|
||||||
|
{server}
|
||||||
|
</Text>
|
||||||
|
</Badge>
|
||||||
|
));
|
||||||
1
src/app/components/server-badge/index.ts
Normal file
1
src/app/components/server-badge/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './ServerBadge';
|
||||||
@@ -31,8 +31,11 @@ export const TextViewerContent = style([
|
|||||||
export const TextViewerPre = style([
|
export const TextViewerPre = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
padding: config.space.S600,
|
|
||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const TextViewerPrePadding = style({
|
||||||
|
padding: config.space.S600,
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
||||||
import React, { Suspense, lazy } from 'react';
|
import React, { ComponentProps, HTMLAttributes, Suspense, forwardRef, lazy } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'folds';
|
import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'folds';
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
@@ -8,6 +8,29 @@ import { copyToClipboard } from '../../utils/dom';
|
|||||||
|
|
||||||
const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism'));
|
const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism'));
|
||||||
|
|
||||||
|
type TextViewerContentProps = {
|
||||||
|
text: string;
|
||||||
|
langName: string;
|
||||||
|
size?: ComponentProps<typeof Text>['size'];
|
||||||
|
} & HTMLAttributes<HTMLPreElement>;
|
||||||
|
export const TextViewerContent = forwardRef<HTMLPreElement, TextViewerContentProps>(
|
||||||
|
({ text, langName, size, className, ...props }, ref) => (
|
||||||
|
<Text
|
||||||
|
as="pre"
|
||||||
|
size={size}
|
||||||
|
className={classNames(css.TextViewerPre, `language-${langName}`, className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<ErrorBoundary fallback={<code>{text}</code>}>
|
||||||
|
<Suspense fallback={<code>{text}</code>}>
|
||||||
|
<ReactPrism key={text}>{(codeRef) => <code ref={codeRef}>{text}</code>}</ReactPrism>
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
export type TextViewerProps = {
|
export type TextViewerProps = {
|
||||||
name: string;
|
name: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -43,6 +66,7 @@ export const TextViewer = as<'div', TextViewerProps>(
|
|||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
grow="Yes"
|
grow="Yes"
|
||||||
className={css.TextViewerContent}
|
className={css.TextViewerContent}
|
||||||
@@ -50,13 +74,11 @@ export const TextViewer = as<'div', TextViewerProps>(
|
|||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
>
|
>
|
||||||
<Scroll hideTrack variant="Background" visibility="Hover">
|
<Scroll hideTrack variant="Background" visibility="Hover">
|
||||||
<Text as="pre" className={classNames(css.TextViewerPre, `language-${langName}`)}>
|
<TextViewerContent
|
||||||
<ErrorBoundary fallback={<code>{text}</code>}>
|
className={css.TextViewerPrePadding}
|
||||||
<Suspense fallback={<code>{text}</code>}>
|
text={text}
|
||||||
<ReactPrism>{(codeRef) => <code ref={codeRef}>{text}</code>}</ReactPrism>
|
langName={langName}
|
||||||
</Suspense>
|
/>
|
||||||
</ErrorBoundary>
|
|
||||||
</Text>
|
|
||||||
</Scroll>
|
</Scroll>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
129
src/app/components/time-date/DatePicker.tsx
Normal file
129
src/app/components/time-date/DatePicker.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import React, { forwardRef } from 'react';
|
||||||
|
import { Menu, Box, Text, Chip } from 'folds';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
import { PickerColumn } from './PickerColumn';
|
||||||
|
import { dateFor, daysInMonth, daysToMs } from '../../utils/time';
|
||||||
|
|
||||||
|
type DatePickerProps = {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
};
|
||||||
|
export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
||||||
|
({ min, max, value, onChange }, ref) => {
|
||||||
|
const selectedYear = dayjs(value).year();
|
||||||
|
const selectedMonth = dayjs(value).month() + 1;
|
||||||
|
const selectedDay = dayjs(value).date();
|
||||||
|
|
||||||
|
const handleSubmit = (newValue: number) => {
|
||||||
|
onChange(Math.min(Math.max(min, newValue), max));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDay = (day: number) => {
|
||||||
|
const seconds = daysToMs(day);
|
||||||
|
const lastSeconds = daysToMs(selectedDay);
|
||||||
|
const newValue = value + (seconds - lastSeconds);
|
||||||
|
handleSubmit(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMonthAndYear = (month: number, year: number) => {
|
||||||
|
const mDays = daysInMonth(month, year);
|
||||||
|
const currentDate = dateFor(selectedYear, selectedMonth, selectedDay);
|
||||||
|
const time = value - currentDate;
|
||||||
|
|
||||||
|
const newDate = dateFor(year, month, mDays < selectedDay ? mDays : selectedDay);
|
||||||
|
|
||||||
|
const newValue = newDate + time;
|
||||||
|
handleSubmit(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMonth = (month: number) => {
|
||||||
|
handleMonthAndYear(month, selectedYear);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleYear = (year: number) => {
|
||||||
|
handleMonthAndYear(selectedMonth, year);
|
||||||
|
};
|
||||||
|
|
||||||
|
const minYear = dayjs(min).year();
|
||||||
|
const maxYear = dayjs(max).year();
|
||||||
|
const yearsRange = maxYear - minYear + 1;
|
||||||
|
|
||||||
|
const minMonth = dayjs(min).month() + 1;
|
||||||
|
const maxMonth = dayjs(max).month() + 1;
|
||||||
|
|
||||||
|
const minDay = dayjs(min).date();
|
||||||
|
const maxDay = dayjs(max).date();
|
||||||
|
return (
|
||||||
|
<Menu className={css.PickerMenu} ref={ref}>
|
||||||
|
<Box direction="Row" gap="200" className={css.PickerContainer}>
|
||||||
|
<PickerColumn title="Day">
|
||||||
|
{Array.from(Array(daysInMonth(selectedMonth, selectedYear)).keys())
|
||||||
|
.map((i) => i + 1)
|
||||||
|
.map((day) => (
|
||||||
|
<Chip
|
||||||
|
key={day}
|
||||||
|
size="500"
|
||||||
|
variant={selectedDay === day ? 'Primary' : 'SurfaceVariant'}
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
aria-selected={selectedDay === day}
|
||||||
|
onClick={() => handleDay(day)}
|
||||||
|
disabled={
|
||||||
|
(selectedYear === minYear && selectedMonth === minMonth && day < minDay) ||
|
||||||
|
(selectedYear === maxYear && selectedMonth === maxMonth && day > maxDay)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T300">{day}</Text>
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</PickerColumn>
|
||||||
|
<PickerColumn title="Month">
|
||||||
|
{Array.from(Array(12).keys())
|
||||||
|
.map((i) => i + 1)
|
||||||
|
.map((month) => (
|
||||||
|
<Chip
|
||||||
|
key={month}
|
||||||
|
size="500"
|
||||||
|
variant={selectedMonth === month ? 'Primary' : 'SurfaceVariant'}
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
aria-selected={selectedMonth === month}
|
||||||
|
onClick={() => handleMonth(month)}
|
||||||
|
disabled={
|
||||||
|
(selectedYear === minYear && month < minMonth) ||
|
||||||
|
(selectedYear === maxYear && month > maxMonth)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T300">
|
||||||
|
{dayjs()
|
||||||
|
.month(month - 1)
|
||||||
|
.format('MMM')}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</PickerColumn>
|
||||||
|
<PickerColumn title="Year">
|
||||||
|
{Array.from(Array(yearsRange).keys())
|
||||||
|
.map((i) => minYear + i)
|
||||||
|
.map((year) => (
|
||||||
|
<Chip
|
||||||
|
key={year}
|
||||||
|
size="500"
|
||||||
|
variant={selectedYear === year ? 'Primary' : 'SurfaceVariant'}
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
aria-selected={selectedYear === year}
|
||||||
|
onClick={() => handleYear(year)}
|
||||||
|
>
|
||||||
|
<Text size="T300">{year}</Text>
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</PickerColumn>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
23
src/app/components/time-date/PickerColumn.tsx
Normal file
23
src/app/components/time-date/PickerColumn.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { Box, Text, Scroll } from 'folds';
|
||||||
|
import { CutoutCard } from '../cutout-card';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
|
||||||
|
export function PickerColumn({ title, children }: { title: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text className={css.PickerColumnLabel} size="L400">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<CutoutCard variant="Background">
|
||||||
|
<Scroll variant="Background" size="300" hideTrack>
|
||||||
|
<Box className={css.PickerColumnContent} direction="Column" gap="100">
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</CutoutCard>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
src/app/components/time-date/TimePicker.tsx
Normal file
153
src/app/components/time-date/TimePicker.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import React, { forwardRef } from 'react';
|
||||||
|
import { Menu, Box, Text, Chip } from 'folds';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
import { PickerColumn } from './PickerColumn';
|
||||||
|
import { hour12to24, hour24to12, hoursToMs, inSameDay, minutesToMs } from '../../utils/time';
|
||||||
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
|
||||||
|
type TimePickerProps = {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
};
|
||||||
|
export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
||||||
|
({ min, max, value, onChange }, ref) => {
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
|
||||||
|
const hour24 = dayjs(value).hour();
|
||||||
|
|
||||||
|
const selectedHour = hour24Clock ? hour24 : hour24to12(hour24);
|
||||||
|
const selectedMinute = dayjs(value).minute();
|
||||||
|
const selectedPM = hour24 >= 12;
|
||||||
|
|
||||||
|
const handleSubmit = (newValue: number) => {
|
||||||
|
onChange(Math.min(Math.max(min, newValue), max));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHour = (hour: number) => {
|
||||||
|
const seconds = hoursToMs(hour24Clock ? hour : hour12to24(hour, selectedPM));
|
||||||
|
const lastSeconds = hoursToMs(hour24);
|
||||||
|
const newValue = value + (seconds - lastSeconds);
|
||||||
|
handleSubmit(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMinute = (minute: number) => {
|
||||||
|
const seconds = minutesToMs(minute);
|
||||||
|
const lastSeconds = minutesToMs(selectedMinute);
|
||||||
|
const newValue = value + (seconds - lastSeconds);
|
||||||
|
handleSubmit(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePeriod = (pm: boolean) => {
|
||||||
|
const seconds = hoursToMs(hour12to24(selectedHour, pm));
|
||||||
|
const lastSeconds = hoursToMs(hour24);
|
||||||
|
const newValue = value + (seconds - lastSeconds);
|
||||||
|
handleSubmit(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const minHour24 = dayjs(min).hour();
|
||||||
|
const maxHour24 = dayjs(max).hour();
|
||||||
|
|
||||||
|
const minMinute = dayjs(min).minute();
|
||||||
|
const maxMinute = dayjs(max).minute();
|
||||||
|
const minPM = minHour24 >= 12;
|
||||||
|
const maxPM = maxHour24 >= 12;
|
||||||
|
|
||||||
|
const minDay = inSameDay(min, value);
|
||||||
|
const maxDay = inSameDay(max, value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu className={css.PickerMenu} ref={ref}>
|
||||||
|
<Box direction="Row" gap="200" className={css.PickerContainer}>
|
||||||
|
<PickerColumn title="Hour">
|
||||||
|
{hour24Clock
|
||||||
|
? Array.from(Array(24).keys()).map((hour) => (
|
||||||
|
<Chip
|
||||||
|
key={hour}
|
||||||
|
size="500"
|
||||||
|
variant={hour === selectedHour ? 'Primary' : 'Background'}
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
aria-selected={hour === selectedHour}
|
||||||
|
onClick={() => handleHour(hour)}
|
||||||
|
disabled={(minDay && hour < minHour24) || (maxDay && hour > maxHour24)}
|
||||||
|
>
|
||||||
|
<Text size="T300">{hour < 10 ? `0${hour}` : hour}</Text>
|
||||||
|
</Chip>
|
||||||
|
))
|
||||||
|
: Array.from(Array(12).keys())
|
||||||
|
.map((i) => {
|
||||||
|
if (i === 0) return 12;
|
||||||
|
return i;
|
||||||
|
})
|
||||||
|
.map((hour) => (
|
||||||
|
<Chip
|
||||||
|
key={hour}
|
||||||
|
size="500"
|
||||||
|
variant={hour === selectedHour ? 'Primary' : 'Background'}
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
aria-selected={hour === selectedHour}
|
||||||
|
onClick={() => handleHour(hour)}
|
||||||
|
disabled={
|
||||||
|
(minDay && hour12to24(hour, selectedPM) < minHour24) ||
|
||||||
|
(maxDay && hour12to24(hour, selectedPM) > maxHour24)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T300">{hour < 10 ? `0${hour}` : hour}</Text>
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</PickerColumn>
|
||||||
|
<PickerColumn title="Minutes">
|
||||||
|
{Array.from(Array(60).keys()).map((minute) => (
|
||||||
|
<Chip
|
||||||
|
key={minute}
|
||||||
|
size="500"
|
||||||
|
variant={minute === selectedMinute ? 'Primary' : 'Background'}
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
aria-selected={minute === selectedMinute}
|
||||||
|
onClick={() => handleMinute(minute)}
|
||||||
|
disabled={
|
||||||
|
(minDay && hour24 === minHour24 && minute < minMinute) ||
|
||||||
|
(maxDay && hour24 === maxHour24 && minute > maxMinute)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T300">{minute < 10 ? `0${minute}` : minute}</Text>
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</PickerColumn>
|
||||||
|
{!hour24Clock && (
|
||||||
|
<PickerColumn title="Period">
|
||||||
|
<Chip
|
||||||
|
size="500"
|
||||||
|
variant={!selectedPM ? 'Primary' : 'SurfaceVariant'}
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
aria-selected={!selectedPM}
|
||||||
|
onClick={() => handlePeriod(false)}
|
||||||
|
disabled={minDay && minPM}
|
||||||
|
>
|
||||||
|
<Text size="T300">AM</Text>
|
||||||
|
</Chip>
|
||||||
|
<Chip
|
||||||
|
size="500"
|
||||||
|
variant={selectedPM ? 'Primary' : 'SurfaceVariant'}
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
aria-selected={selectedPM}
|
||||||
|
onClick={() => handlePeriod(true)}
|
||||||
|
disabled={maxDay && !maxPM}
|
||||||
|
>
|
||||||
|
<Text size="T300">PM</Text>
|
||||||
|
</Chip>
|
||||||
|
</PickerColumn>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
2
src/app/components/time-date/index.ts
Normal file
2
src/app/components/time-date/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './TimePicker';
|
||||||
|
export * from './DatePicker';
|
||||||
16
src/app/components/time-date/styles.css.ts
Normal file
16
src/app/components/time-date/styles.css.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const PickerMenu = style({
|
||||||
|
padding: config.space.S200,
|
||||||
|
});
|
||||||
|
export const PickerContainer = style({
|
||||||
|
maxHeight: toRem(250),
|
||||||
|
});
|
||||||
|
export const PickerColumnLabel = style({
|
||||||
|
padding: config.space.S200,
|
||||||
|
});
|
||||||
|
export const PickerColumnContent = style({
|
||||||
|
padding: config.space.S200,
|
||||||
|
paddingRight: 0,
|
||||||
|
});
|
||||||
@@ -4,7 +4,8 @@ import { UploadCard, UploadCardError, CompactUploadCardProgress } from './Upload
|
|||||||
import { TUploadAtom, UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
import { TUploadAtom, UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { TUploadContent } from '../../utils/matrix';
|
import { TUploadContent } from '../../utils/matrix';
|
||||||
import { getFileTypeIcon } from '../../utils/common';
|
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
|
||||||
|
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||||
|
|
||||||
type CompactUploadCardRendererProps = {
|
type CompactUploadCardRendererProps = {
|
||||||
isEncrypted?: boolean;
|
isEncrypted?: boolean;
|
||||||
@@ -19,10 +20,16 @@ export function CompactUploadCardRenderer({
|
|||||||
onComplete,
|
onComplete,
|
||||||
}: CompactUploadCardRendererProps) {
|
}: CompactUploadCardRendererProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const mediaConfig = useMediaConfig();
|
||||||
|
const allowSize = mediaConfig['m.upload.size'] || Infinity;
|
||||||
|
|
||||||
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
|
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
|
||||||
const { file } = upload;
|
const { file } = upload;
|
||||||
|
const fileSizeExceeded = file.size >= allowSize;
|
||||||
|
|
||||||
if (upload.status === UploadStatus.Idle) startUpload();
|
if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
|
||||||
|
startUpload();
|
||||||
|
}
|
||||||
|
|
||||||
const removeUpload = () => {
|
const removeUpload = () => {
|
||||||
cancelUpload();
|
cancelUpload();
|
||||||
@@ -76,7 +83,7 @@ export function CompactUploadCardRenderer({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{upload.status === UploadStatus.Idle && (
|
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
|
||||||
<CompactUploadCardProgress sentBytes={0} totalBytes={file.size} />
|
<CompactUploadCardProgress sentBytes={0} totalBytes={file.size} />
|
||||||
)}
|
)}
|
||||||
{upload.status === UploadStatus.Loading && (
|
{upload.status === UploadStatus.Loading && (
|
||||||
@@ -87,6 +94,15 @@ export function CompactUploadCardRenderer({
|
|||||||
<Text size="T200">{upload.error.message}</Text>
|
<Text size="T200">{upload.error.message}</Text>
|
||||||
</UploadCardError>
|
</UploadCardError>
|
||||||
)}
|
)}
|
||||||
|
{upload.status === UploadStatus.Idle && fileSizeExceeded && (
|
||||||
|
<UploadCardError>
|
||||||
|
<Text size="T200">
|
||||||
|
The file size exceeds the limit. Maximum allowed size is{' '}
|
||||||
|
<b>{bytesToSize(allowSize)}</b>, but the uploaded file is{' '}
|
||||||
|
<b>{bytesToSize(file.size)}</b>.
|
||||||
|
</Text>
|
||||||
|
</UploadCardError>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</UploadCard>
|
</UploadCard>
|
||||||
|
|||||||
@@ -1,28 +1,97 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Chip, Icon, IconButton, Icons, Text, color } from 'folds';
|
import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
|
||||||
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
|
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
|
||||||
import { TUploadAtom, UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { TUploadContent } from '../../utils/matrix';
|
import { TUploadContent } from '../../utils/matrix';
|
||||||
import { getFileTypeIcon } from '../../utils/common';
|
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
|
||||||
|
import {
|
||||||
|
roomUploadAtomFamily,
|
||||||
|
TUploadItem,
|
||||||
|
TUploadMetadata,
|
||||||
|
} from '../../state/room/roomInputDrafts';
|
||||||
|
import { useObjectURL } from '../../hooks/useObjectURL';
|
||||||
|
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||||
|
|
||||||
|
type ImagePreviewProps = { fileItem: TUploadItem; onSpoiler: (marked: boolean) => void };
|
||||||
|
function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
|
||||||
|
const { originalFile, metadata } = fileItem;
|
||||||
|
const fileUrl = useObjectURL(originalFile);
|
||||||
|
|
||||||
|
return fileUrl ? (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'black',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
style={{
|
||||||
|
objectFit: 'contain',
|
||||||
|
width: '100%',
|
||||||
|
height: toRem(152),
|
||||||
|
filter: fileItem.metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
|
||||||
|
}}
|
||||||
|
src={fileUrl}
|
||||||
|
alt={originalFile.name}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
justifyContent="End"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: config.space.S100,
|
||||||
|
left: config.space.S100,
|
||||||
|
right: config.space.S100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
variant={metadata.markedAsSpoiler ? 'Warning' : 'Secondary'}
|
||||||
|
fill="Soft"
|
||||||
|
radii="Pill"
|
||||||
|
aria-pressed={metadata.markedAsSpoiler}
|
||||||
|
before={<Icon src={Icons.EyeBlind} size="50" />}
|
||||||
|
onClick={() => onSpoiler(!metadata.markedAsSpoiler)}
|
||||||
|
>
|
||||||
|
<Text size="B300">Spoiler</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
type UploadCardRendererProps = {
|
type UploadCardRendererProps = {
|
||||||
isEncrypted?: boolean;
|
isEncrypted?: boolean;
|
||||||
uploadAtom: TUploadAtom;
|
fileItem: TUploadItem;
|
||||||
|
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
|
||||||
onRemove: (file: TUploadContent) => void;
|
onRemove: (file: TUploadContent) => void;
|
||||||
onComplete?: (upload: UploadSuccess) => void;
|
onComplete?: (upload: UploadSuccess) => void;
|
||||||
};
|
};
|
||||||
export function UploadCardRenderer({
|
export function UploadCardRenderer({
|
||||||
isEncrypted,
|
isEncrypted,
|
||||||
uploadAtom,
|
fileItem,
|
||||||
|
setMetadata,
|
||||||
onRemove,
|
onRemove,
|
||||||
onComplete,
|
onComplete,
|
||||||
}: UploadCardRendererProps) {
|
}: UploadCardRendererProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const mediaConfig = useMediaConfig();
|
||||||
|
const allowSize = mediaConfig['m.upload.size'] || Infinity;
|
||||||
|
|
||||||
|
const uploadAtom = roomUploadAtomFamily(fileItem.file);
|
||||||
|
const { metadata } = fileItem;
|
||||||
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
|
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
|
||||||
const { file } = upload;
|
const { file } = upload;
|
||||||
|
const fileSizeExceeded = file.size >= allowSize;
|
||||||
|
|
||||||
if (upload.status === UploadStatus.Idle) startUpload();
|
if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
|
||||||
|
startUpload();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSpoiler = (marked: boolean) => {
|
||||||
|
setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
|
||||||
|
};
|
||||||
|
|
||||||
const removeUpload = () => {
|
const removeUpload = () => {
|
||||||
cancelUpload();
|
cancelUpload();
|
||||||
@@ -66,7 +135,10 @@ export function UploadCardRenderer({
|
|||||||
}
|
}
|
||||||
bottom={
|
bottom={
|
||||||
<>
|
<>
|
||||||
{upload.status === UploadStatus.Idle && (
|
{fileItem.originalFile.type.startsWith('image') && (
|
||||||
|
<ImagePreview fileItem={fileItem} onSpoiler={handleSpoiler} />
|
||||||
|
)}
|
||||||
|
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
|
||||||
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
|
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
|
||||||
)}
|
)}
|
||||||
{upload.status === UploadStatus.Loading && (
|
{upload.status === UploadStatus.Loading && (
|
||||||
@@ -77,6 +149,15 @@ export function UploadCardRenderer({
|
|||||||
<Text size="T200">{upload.error.message}</Text>
|
<Text size="T200">{upload.error.message}</Text>
|
||||||
</UploadCardError>
|
</UploadCardError>
|
||||||
)}
|
)}
|
||||||
|
{upload.status === UploadStatus.Idle && fileSizeExceeded && (
|
||||||
|
<UploadCardError>
|
||||||
|
<Text size="T200">
|
||||||
|
The file size exceeds the limit. Maximum allowed size is{' '}
|
||||||
|
<b>{bytesToSize(allowSize)}</b>, but the uploaded file is{' '}
|
||||||
|
<b>{bytesToSize(file.size)}</b>.
|
||||||
|
</Text>
|
||||||
|
</UploadCardError>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
101
src/app/components/user-profile/CreatorChip.tsx
Normal file
101
src/app/components/user-profile/CreatorChip.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { Chip, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
|
||||||
|
import React, { MouseEventHandler, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||||
|
import { PowerColorBadge, PowerIcon } from '../power';
|
||||||
|
import { getPowerTagIconSrc } from '../../hooks/useMemberPowerTag';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
|
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
||||||
|
import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
|
||||||
|
import { SpaceSettingsPage } from '../../state/spaceSettings';
|
||||||
|
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||||
|
|
||||||
|
export function CreatorChip() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const room = useRoom();
|
||||||
|
const space = useSpaceOptionally();
|
||||||
|
const openRoomSettings = useOpenRoomSettings();
|
||||||
|
const openSpaceSettings = useOpenSpaceSettings();
|
||||||
|
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
const tag = useRoomCreatorsTag();
|
||||||
|
const tagIconSrc = tag.icon && getPowerTagIconSrc(mx, useAuthentication, tag.icon);
|
||||||
|
|
||||||
|
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => setCords(undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Bottom"
|
||||||
|
align="Start"
|
||||||
|
offset={4}
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: close,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<div style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
if (room.isSpaceRoom()) {
|
||||||
|
openSpaceSettings(
|
||||||
|
room.roomId,
|
||||||
|
space?.roomId,
|
||||||
|
SpaceSettingsPage.PermissionsPage
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
openRoomSettings(room.roomId, space?.roomId, RoomSettingsPage.PermissionsPage);
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Manage Powers</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
variant="Success"
|
||||||
|
outlined
|
||||||
|
radii="Pill"
|
||||||
|
before={
|
||||||
|
cords ? (
|
||||||
|
<Icon size="50" src={Icons.ChevronBottom} />
|
||||||
|
) : (
|
||||||
|
<PowerColorBadge color={tag.color} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
after={tagIconSrc ? <PowerIcon size="50" iconSrc={tagIconSrc} /> : undefined}
|
||||||
|
onClick={open}
|
||||||
|
aria-pressed={!!cords}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
{tag.name}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
357
src/app/components/user-profile/PowerChip.tsx
Normal file
357
src/app/components/user-profile/PowerChip.tsx
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
config,
|
||||||
|
Dialog,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Line,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
PopOut,
|
||||||
|
RectCords,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import React, { MouseEventHandler, useCallback, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { PowerColorBadge, PowerIcon } from '../power';
|
||||||
|
import { useGetMemberPowerLevel, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||||
|
import { getPowers, usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
|
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
||||||
|
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||||
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
|
import { CutoutCard } from '../cutout-card';
|
||||||
|
import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
|
||||||
|
import { SpaceSettingsPage } from '../../state/spaceSettings';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import { BreakWord } from '../../styles/Text.css';
|
||||||
|
import { getPowerTagIconSrc, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||||
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
|
import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
|
||||||
|
|
||||||
|
type SelfDemoteAlertProps = {
|
||||||
|
power: number;
|
||||||
|
onCancel: () => void;
|
||||||
|
onChange: (power: number) => void;
|
||||||
|
};
|
||||||
|
function SelfDemoteAlert({ power, onCancel, onChange }: SelfDemoteAlertProps) {
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: onCancel,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog variant="Surface">
|
||||||
|
<Header
|
||||||
|
style={{ padding: `0 ${config.space.S200} 0 ${config.space.S400}` }}
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H4">Self Demotion</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" onClick={onCancel} radii="300">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box style={{ padding: config.space.S400, paddingTop: 0 }} direction="Column" gap="500">
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text priority="400">
|
||||||
|
You are about to demote yourself! You will not be able to regain this power
|
||||||
|
yourself. Are you sure?
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Button type="submit" variant="Warning" onClick={() => onChange(power)}>
|
||||||
|
<Text size="B400">Demote</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SharedPowerAlertProps = {
|
||||||
|
power: number;
|
||||||
|
onCancel: () => void;
|
||||||
|
onChange: (power: number) => void;
|
||||||
|
};
|
||||||
|
function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps) {
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: onCancel,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog variant="Surface">
|
||||||
|
<Header
|
||||||
|
style={{ padding: `0 ${config.space.S200} 0 ${config.space.S400}` }}
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H4">Shared Power</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" onClick={onCancel} radii="300">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box style={{ padding: config.space.S400, paddingTop: 0 }} direction="Column" gap="500">
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text priority="400">
|
||||||
|
You are promoting the user to have the same power as yourself! You will not be
|
||||||
|
able to change their power afterward. Are you sure?
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Button type="submit" variant="Warning" onClick={() => onChange(power)}>
|
||||||
|
<Text size="B400">Promote</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PowerChip({ userId }: { userId: string }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const space = useSpaceOptionally();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const openRoomSettings = useOpenRoomSettings();
|
||||||
|
const openSpaceSettings = useOpenSpaceSettings();
|
||||||
|
|
||||||
|
const powerLevels = usePowerLevels(room);
|
||||||
|
const creators = useRoomCreators(room);
|
||||||
|
|
||||||
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
|
const getMemberPowerLevel = useGetMemberPowerLevel(powerLevels);
|
||||||
|
const { hasMorePower } = useMemberPowerCompare(creators, powerLevels);
|
||||||
|
|
||||||
|
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
||||||
|
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
|
||||||
|
|
||||||
|
const myUserId = mx.getSafeUserId();
|
||||||
|
const canChangePowers =
|
||||||
|
permissions.stateEvent(StateEvent.RoomPowerLevels, myUserId) &&
|
||||||
|
(myUserId === userId ? true : hasMorePower(myUserId, userId));
|
||||||
|
|
||||||
|
const tag = getMemberPowerTag(userId);
|
||||||
|
const tagIconSrc = tag.icon && getPowerTagIconSrc(mx, useAuthentication, tag.icon);
|
||||||
|
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => setCords(undefined);
|
||||||
|
|
||||||
|
const [powerState, changePower] = useAsyncCallback<undefined, Error, [number]>(
|
||||||
|
useCallback(
|
||||||
|
async (power: number) => {
|
||||||
|
await mx.setPowerLevel(room.roomId, userId, power);
|
||||||
|
},
|
||||||
|
[mx, userId, room]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const changing = powerState.status === AsyncStatus.Loading;
|
||||||
|
const error = powerState.status === AsyncStatus.Error;
|
||||||
|
const [selfDemote, setSelfDemote] = useState<number>();
|
||||||
|
const [sharedPower, setSharedPower] = useState<number>();
|
||||||
|
|
||||||
|
const handlePowerSelect = (power: number): void => {
|
||||||
|
close();
|
||||||
|
if (!canChangePowers) return;
|
||||||
|
if (power === getMemberPowerLevel(userId)) return;
|
||||||
|
|
||||||
|
if (userId === mx.getSafeUserId()) {
|
||||||
|
setSelfDemote(power);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!creators.has(myUserId) && power === getMemberPowerLevel(myUserId)) {
|
||||||
|
setSharedPower(power);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
changePower(power);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelfDemote = (power: number) => {
|
||||||
|
setSelfDemote(undefined);
|
||||||
|
changePower(power);
|
||||||
|
};
|
||||||
|
const handleSharedPower = (power: number) => {
|
||||||
|
setSharedPower(undefined);
|
||||||
|
changePower(power);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Bottom"
|
||||||
|
align="Start"
|
||||||
|
offset={4}
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: close,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
style={{ padding: config.space.S100, maxWidth: toRem(200) }}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
|
||||||
|
<Text size="L400">Error: {powerState.error.name}</Text>
|
||||||
|
<Text className={BreakWord} size="T200">
|
||||||
|
{powerState.error.message}
|
||||||
|
</Text>
|
||||||
|
</CutoutCard>
|
||||||
|
)}
|
||||||
|
{getPowers(powerLevelTags).map((power) => {
|
||||||
|
const powerTag = powerLevelTags[power];
|
||||||
|
const powerTagIconSrc =
|
||||||
|
powerTag.icon && getPowerTagIconSrc(mx, useAuthentication, powerTag.icon);
|
||||||
|
|
||||||
|
const selected = getMemberPowerLevel(userId) === power;
|
||||||
|
const canAssignPower = creators.has(myUserId)
|
||||||
|
? true
|
||||||
|
: power <= getMemberPowerLevel(myUserId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={power}
|
||||||
|
variant={selected ? 'Primary' : 'Surface'}
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
aria-disabled={changing || !canChangePowers || !canAssignPower}
|
||||||
|
aria-pressed={selected}
|
||||||
|
before={<PowerColorBadge color={powerTag.color} />}
|
||||||
|
after={
|
||||||
|
powerTagIconSrc ? (
|
||||||
|
<PowerIcon size="50" iconSrc={powerTagIconSrc} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
onClick={
|
||||||
|
canChangePowers && canAssignPower
|
||||||
|
? () => handlePowerSelect(power)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="B300">{powerTag.name}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
<Line size="300" />
|
||||||
|
<div style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
if (room.isSpaceRoom()) {
|
||||||
|
openSpaceSettings(
|
||||||
|
room.roomId,
|
||||||
|
space?.roomId,
|
||||||
|
SpaceSettingsPage.PermissionsPage
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
openRoomSettings(
|
||||||
|
room.roomId,
|
||||||
|
space?.roomId,
|
||||||
|
RoomSettingsPage.PermissionsPage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Manage Powers</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
variant={error ? 'Critical' : 'SurfaceVariant'}
|
||||||
|
radii="Pill"
|
||||||
|
before={
|
||||||
|
cords ? (
|
||||||
|
<Icon size="50" src={Icons.ChevronBottom} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!changing && <PowerColorBadge color={tag.color} />}
|
||||||
|
{changing && <Spinner size="50" variant="Secondary" fill="Soft" />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
after={tagIconSrc ? <PowerIcon size="50" iconSrc={tagIconSrc} /> : undefined}
|
||||||
|
onClick={open}
|
||||||
|
aria-pressed={!!cords}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
{tag.name}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
</PopOut>
|
||||||
|
{typeof selfDemote === 'number' ? (
|
||||||
|
<SelfDemoteAlert
|
||||||
|
power={selfDemote}
|
||||||
|
onCancel={() => setSelfDemote(undefined)}
|
||||||
|
onChange={handleSelfDemote}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{typeof sharedPower === 'number' ? (
|
||||||
|
<SharedPowerAlert
|
||||||
|
power={sharedPower}
|
||||||
|
onCancel={() => setSharedPower(undefined)}
|
||||||
|
onChange={handleSharedPower}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
514
src/app/components/user-profile/UserChips.tsx
Normal file
514
src/app/components/user-profile/UserChips.tsx
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import {
|
||||||
|
PopOut,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
config,
|
||||||
|
Text,
|
||||||
|
Line,
|
||||||
|
Chip,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
RectCords,
|
||||||
|
Spinner,
|
||||||
|
toRem,
|
||||||
|
Box,
|
||||||
|
Scroll,
|
||||||
|
Avatar,
|
||||||
|
} from 'folds';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { getMxIdServer } from '../../utils/matrix';
|
||||||
|
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { copyToClipboard } from '../../utils/dom';
|
||||||
|
import { getExploreServerPath } from '../../pages/pathUtils';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import { factoryRoomIdByAtoZ } from '../../utils/sort';
|
||||||
|
import { useMutualRooms, useMutualRoomsSupport } from '../../hooks/useMutualRooms';
|
||||||
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
|
import { useDirectRooms } from '../../pages/client/direct/useDirectRooms';
|
||||||
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
|
||||||
|
import { RoomAvatar, RoomIcon } from '../room-avatar';
|
||||||
|
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
|
||||||
|
import { nameInitials } from '../../utils/common';
|
||||||
|
import { getMatrixToUser } from '../../plugins/matrix-to';
|
||||||
|
import { useTimeoutToggle } from '../../hooks/useTimeoutToggle';
|
||||||
|
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||||
|
import { CutoutCard } from '../cutout-card';
|
||||||
|
import { SettingTile } from '../setting-tile';
|
||||||
|
|
||||||
|
export function ServerChip({ server }: { server: string }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const myServer = getMxIdServer(mx.getSafeUserId());
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const closeProfile = useCloseUserRoomProfile();
|
||||||
|
const [copied, setCopied] = useTimeoutToggle();
|
||||||
|
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => setCords(undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Bottom"
|
||||||
|
align="Start"
|
||||||
|
offset={4}
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: close,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<div style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
copyToClipboard(server);
|
||||||
|
setCopied();
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Copy Server</Text>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
navigate(getExploreServerPath(server));
|
||||||
|
closeProfile();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Explore Community</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
<Line size="300" />
|
||||||
|
<div style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
variant={myServer === server ? 'Surface' : 'Critical'}
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
window.open(`https://${server}`, '_blank');
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Open in Browser</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
variant={myServer === server ? 'SurfaceVariant' : 'Warning'}
|
||||||
|
radii="Pill"
|
||||||
|
before={
|
||||||
|
cords ? (
|
||||||
|
<Icon size="50" src={Icons.ChevronBottom} />
|
||||||
|
) : (
|
||||||
|
<Icon size="50" src={copied ? Icons.Check : Icons.Server} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={open}
|
||||||
|
aria-pressed={!!cords}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
{server}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareChip({ userId }: { userId: string }) {
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const [copied, setCopied] = useTimeoutToggle();
|
||||||
|
|
||||||
|
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => setCords(undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Bottom"
|
||||||
|
align="Start"
|
||||||
|
offset={4}
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: close,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<div style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
copyToClipboard(userId);
|
||||||
|
setCopied();
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Copy User ID</Text>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
copyToClipboard(getMatrixToUser(userId));
|
||||||
|
setCopied();
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Copy User Link</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
variant={copied ? 'Success' : 'SurfaceVariant'}
|
||||||
|
radii="Pill"
|
||||||
|
before={
|
||||||
|
cords ? (
|
||||||
|
<Icon size="50" src={Icons.ChevronBottom} />
|
||||||
|
) : (
|
||||||
|
<Icon size="50" src={copied ? Icons.Check : Icons.Link} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={open}
|
||||||
|
aria-pressed={!!cords}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
Share
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type MutualRoomsData = {
|
||||||
|
rooms: Room[];
|
||||||
|
spaces: Room[];
|
||||||
|
directs: Room[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MutualRoomsChip({ userId }: { userId: string }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const mutualRoomSupported = useMutualRoomsSupport();
|
||||||
|
const mutualRoomsState = useMutualRooms(userId);
|
||||||
|
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||||
|
const closeUserRoomProfile = useCloseUserRoomProfile();
|
||||||
|
const directs = useDirectRooms();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
|
const allJoinedRooms = useAllJoinedRoomsSet();
|
||||||
|
const getRoom = useGetRoom(allJoinedRooms);
|
||||||
|
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => setCords(undefined);
|
||||||
|
|
||||||
|
const mutual: MutualRoomsData = useMemo(() => {
|
||||||
|
const data: MutualRoomsData = {
|
||||||
|
rooms: [],
|
||||||
|
spaces: [],
|
||||||
|
directs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mutualRoomsState.status === AsyncStatus.Success) {
|
||||||
|
const mutualRooms = mutualRoomsState.data
|
||||||
|
.sort(factoryRoomIdByAtoZ(mx))
|
||||||
|
.map(getRoom)
|
||||||
|
.filter((room) => !!room);
|
||||||
|
mutualRooms.forEach((room) => {
|
||||||
|
if (room.isSpaceRoom()) {
|
||||||
|
data.spaces.push(room);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (directs.includes(room.roomId)) {
|
||||||
|
data.directs.push(room);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data.rooms.push(room);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}, [mutualRoomsState, getRoom, directs, mx]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
userId === mx.getSafeUserId() ||
|
||||||
|
!mutualRoomSupported ||
|
||||||
|
mutualRoomsState.status === AsyncStatus.Error
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderItem = (room: Room) => {
|
||||||
|
const { roomId } = room;
|
||||||
|
const dm = directs.includes(roomId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={roomId}
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
style={{ paddingLeft: config.space.S100 }}
|
||||||
|
onClick={() => {
|
||||||
|
if (room.isSpaceRoom()) {
|
||||||
|
navigateSpace(roomId);
|
||||||
|
} else {
|
||||||
|
navigateRoom(roomId);
|
||||||
|
}
|
||||||
|
closeUserRoomProfile();
|
||||||
|
}}
|
||||||
|
before={
|
||||||
|
<Avatar size="200" radii={dm ? '400' : '300'}>
|
||||||
|
{dm || room.isSpaceRoom() ? (
|
||||||
|
<RoomAvatar
|
||||||
|
roomId={room.roomId}
|
||||||
|
src={
|
||||||
|
dm
|
||||||
|
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||||
|
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||||
|
}
|
||||||
|
alt={room.name}
|
||||||
|
renderFallback={() => (
|
||||||
|
<Text as="span" size="H6">
|
||||||
|
{nameInitials(room.name)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RoomIcon size="100" joinRule={room.getJoinRule()} />
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
{room.name}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Bottom"
|
||||||
|
align="Start"
|
||||||
|
offset={4}
|
||||||
|
content={
|
||||||
|
mutualRoomsState.status === AsyncStatus.Success ? (
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: close,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
maxWidth: toRem(200),
|
||||||
|
maxHeight: '80vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Scroll size="300" hideTrack>
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
style={{ padding: config.space.S200, paddingRight: 0 }}
|
||||||
|
>
|
||||||
|
{mutual.spaces.length > 0 && (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text style={{ paddingLeft: config.space.S100 }} size="L400">
|
||||||
|
Spaces
|
||||||
|
</Text>
|
||||||
|
{mutual.spaces.map(renderItem)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{mutual.rooms.length > 0 && (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text style={{ paddingLeft: config.space.S100 }} size="L400">
|
||||||
|
Rooms
|
||||||
|
</Text>
|
||||||
|
{mutual.rooms.map(renderItem)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{mutual.directs.length > 0 && (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text style={{ paddingLeft: config.space.S100 }} size="L400">
|
||||||
|
Direct Messages
|
||||||
|
</Text>
|
||||||
|
{mutual.directs.map(renderItem)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="Pill"
|
||||||
|
before={mutualRoomsState.status === AsyncStatus.Loading && <Spinner size="50" />}
|
||||||
|
disabled={
|
||||||
|
mutualRoomsState.status !== AsyncStatus.Success || mutualRoomsState.data.length === 0
|
||||||
|
}
|
||||||
|
onClick={open}
|
||||||
|
aria-pressed={!!cords}
|
||||||
|
>
|
||||||
|
<Text size="B300">
|
||||||
|
{mutualRoomsState.status === AsyncStatus.Success &&
|
||||||
|
`${mutualRoomsState.data.length} Mutual Rooms`}
|
||||||
|
{mutualRoomsState.status === AsyncStatus.Loading && 'Mutual Rooms'}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IgnoredUserAlert() {
|
||||||
|
return (
|
||||||
|
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
|
||||||
|
<SettingTile>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Box gap="200" justifyContent="SpaceBetween">
|
||||||
|
<Text size="L400">Blocked User</Text>
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column">
|
||||||
|
<Text size="T200">You do not receive any messages or invites from this user.</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
</CutoutCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OptionsChip({ userId }: { userId: string }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => setCords(undefined);
|
||||||
|
|
||||||
|
const ignoredUsers = useIgnoredUsers();
|
||||||
|
const ignored = ignoredUsers.includes(userId);
|
||||||
|
|
||||||
|
const [ignoreState, toggleIgnore] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const users = ignoredUsers.filter((u) => u !== userId);
|
||||||
|
if (!ignored) users.push(userId);
|
||||||
|
await mx.setIgnoredUsers(users);
|
||||||
|
}, [mx, ignoredUsers, userId, ignored])
|
||||||
|
);
|
||||||
|
const ignoring = ignoreState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Bottom"
|
||||||
|
align="Start"
|
||||||
|
offset={4}
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: close,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<div style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
variant="Critical"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
toggleIgnore();
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
before={
|
||||||
|
ignoring ? (
|
||||||
|
<Spinner variant="Critical" size="50" />
|
||||||
|
) : (
|
||||||
|
<Icon size="50" src={Icons.Prohibited} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={ignoring}
|
||||||
|
>
|
||||||
|
<Text size="B300">{ignored ? 'Unblock User' : 'Block User'}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip variant="SurfaceVariant" radii="Pill" onClick={open} aria-pressed={!!cords}>
|
||||||
|
{ignoring ? (
|
||||||
|
<Spinner variant="Secondary" size="50" />
|
||||||
|
) : (
|
||||||
|
<Icon size="50" src={Icons.HorizontalDots} />
|
||||||
|
)}
|
||||||
|
</Chip>
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/app/components/user-profile/UserHero.tsx
Normal file
75
src/app/components/user-profile/UserHero.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Avatar, Box, Icon, Icons, Text } from 'folds';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
import { UserAvatar } from '../user-avatar';
|
||||||
|
import colorMXID from '../../../util/colorMXID';
|
||||||
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
|
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
|
||||||
|
import { UserPresence } from '../../hooks/useUserPresence';
|
||||||
|
import { AvatarPresence, PresenceBadge } from '../presence';
|
||||||
|
|
||||||
|
type UserHeroProps = {
|
||||||
|
userId: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
presence?: UserPresence;
|
||||||
|
};
|
||||||
|
export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
|
||||||
|
return (
|
||||||
|
<Box direction="Column" className={css.UserHero}>
|
||||||
|
<div
|
||||||
|
className={css.UserHeroCoverContainer}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colorMXID(userId),
|
||||||
|
filter: avatarUrl ? undefined : 'brightness(50%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{avatarUrl && <img className={css.UserHeroCover} src={avatarUrl} alt={userId} />}
|
||||||
|
</div>
|
||||||
|
<div className={css.UserHeroAvatarContainer}>
|
||||||
|
<AvatarPresence
|
||||||
|
className={css.UserAvatarContainer}
|
||||||
|
badge={
|
||||||
|
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Avatar className={css.UserHeroAvatar} size="500">
|
||||||
|
<UserAvatar
|
||||||
|
userId={userId}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={userId}
|
||||||
|
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
</AvatarPresence>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserHeroNameProps = {
|
||||||
|
displayName?: string;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
|
||||||
|
const username = getMxIdLocalPart(userId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box grow="Yes" direction="Column" gap="0">
|
||||||
|
<Box alignItems="Baseline" gap="200" wrap="Wrap">
|
||||||
|
<Text
|
||||||
|
size="H4"
|
||||||
|
className={classNames(BreakWord, LineClamp3)}
|
||||||
|
title={displayName ?? username}
|
||||||
|
>
|
||||||
|
{displayName ?? username ?? userId}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box alignItems="Center" gap="100" wrap="Wrap">
|
||||||
|
<Text size="T200" className={classNames(BreakWord, LineClamp3)} title={username}>
|
||||||
|
@{username}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
349
src/app/components/user-profile/UserModeration.tsx
Normal file
349
src/app/components/user-profile/UserModeration.tsx
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import { Box, Button, color, config, Icon, Icons, Spinner, Text, Input } from 'folds';
|
||||||
|
import React, { useCallback, useRef } from 'react';
|
||||||
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
|
import { CutoutCard } from '../cutout-card';
|
||||||
|
import { SettingTile } from '../setting-tile';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { BreakWord } from '../../styles/Text.css';
|
||||||
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
import { timeDayMonYear, timeHourMinute } from '../../utils/time';
|
||||||
|
|
||||||
|
type UserKickAlertProps = {
|
||||||
|
reason?: string;
|
||||||
|
kickedBy?: string;
|
||||||
|
ts?: number;
|
||||||
|
};
|
||||||
|
export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
|
const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
|
||||||
|
const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
|
||||||
|
<SettingTile>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Box gap="200" justifyContent="SpaceBetween">
|
||||||
|
<Text size="L400">Kicked User</Text>
|
||||||
|
{time && date && (
|
||||||
|
<Text size="T200">
|
||||||
|
{date} {time}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column">
|
||||||
|
{kickedBy && (
|
||||||
|
<Text size="T200">
|
||||||
|
Kicked by: <b>{kickedBy}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text size="T200">
|
||||||
|
{reason ? (
|
||||||
|
<>
|
||||||
|
Reason: <b>{reason}</b>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<i>No Reason Provided.</i>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
</CutoutCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserBanAlertProps = {
|
||||||
|
userId: string;
|
||||||
|
reason?: string;
|
||||||
|
canUnban?: boolean;
|
||||||
|
bannedBy?: string;
|
||||||
|
ts?: number;
|
||||||
|
};
|
||||||
|
export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBanAlertProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
|
const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
|
||||||
|
const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
|
||||||
|
|
||||||
|
const [unbanState, unban] = useAsyncCallback<undefined, Error, []>(
|
||||||
|
useCallback(async () => {
|
||||||
|
await mx.unban(room.roomId, userId);
|
||||||
|
}, [mx, room, userId])
|
||||||
|
);
|
||||||
|
const banning = unbanState.status === AsyncStatus.Loading;
|
||||||
|
const error = unbanState.status === AsyncStatus.Error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
|
||||||
|
<SettingTile>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Box gap="200" justifyContent="SpaceBetween">
|
||||||
|
<Text size="L400">Banned User</Text>
|
||||||
|
{time && date && (
|
||||||
|
<Text size="T200">
|
||||||
|
{date} {time}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column">
|
||||||
|
{bannedBy && (
|
||||||
|
<Text size="T200">
|
||||||
|
Banned by: <b>{bannedBy}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text size="T200">
|
||||||
|
{reason ? (
|
||||||
|
<>
|
||||||
|
Reason: <b>{reason}</b>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<i>No Reason Provided.</i>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{error && (
|
||||||
|
<Text className={BreakWord} size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
<b>{unbanState.error.message}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{canUnban && (
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
radii="300"
|
||||||
|
onClick={unban}
|
||||||
|
before={banning && <Spinner size="100" variant="Critical" fill="Solid" />}
|
||||||
|
disabled={banning}
|
||||||
|
>
|
||||||
|
<Text size="B300">Unban</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
</CutoutCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserInviteAlertProps = {
|
||||||
|
userId: string;
|
||||||
|
reason?: string;
|
||||||
|
canKick?: boolean;
|
||||||
|
invitedBy?: string;
|
||||||
|
ts?: number;
|
||||||
|
};
|
||||||
|
export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: UserInviteAlertProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
|
const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
|
||||||
|
const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
|
||||||
|
|
||||||
|
const [kickState, kick] = useAsyncCallback<undefined, Error, []>(
|
||||||
|
useCallback(async () => {
|
||||||
|
await mx.kick(room.roomId, userId);
|
||||||
|
}, [mx, room, userId])
|
||||||
|
);
|
||||||
|
const kicking = kickState.status === AsyncStatus.Loading;
|
||||||
|
const error = kickState.status === AsyncStatus.Error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CutoutCard style={{ padding: config.space.S200 }} variant="Success">
|
||||||
|
<SettingTile>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Box gap="200" justifyContent="SpaceBetween">
|
||||||
|
<Text size="L400">Invited User</Text>
|
||||||
|
{time && date && (
|
||||||
|
<Text size="T200">
|
||||||
|
{date} {time}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column">
|
||||||
|
{invitedBy && (
|
||||||
|
<Text size="T200">
|
||||||
|
Invited by: <b>{invitedBy}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text size="T200">
|
||||||
|
{reason ? (
|
||||||
|
<>
|
||||||
|
Reason: <b>{reason}</b>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<i>No Reason Provided.</i>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{error && (
|
||||||
|
<Text className={BreakWord} size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
<b>{kickState.error.message}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{canKick && (
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Success"
|
||||||
|
fill="Soft"
|
||||||
|
outlined
|
||||||
|
radii="300"
|
||||||
|
onClick={kick}
|
||||||
|
before={kicking && <Spinner size="100" variant="Success" fill="Soft" />}
|
||||||
|
disabled={kicking}
|
||||||
|
>
|
||||||
|
<Text size="B300">Cancel Invite</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
</CutoutCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserModerationProps = {
|
||||||
|
userId: string;
|
||||||
|
canKick: boolean;
|
||||||
|
canBan: boolean;
|
||||||
|
canInvite: boolean;
|
||||||
|
};
|
||||||
|
export function UserModeration({ userId, canKick, canBan, canInvite }: UserModerationProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const reasonInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const getReason = useCallback((): string | undefined => {
|
||||||
|
const reason = reasonInputRef.current?.value.trim() || undefined;
|
||||||
|
if (reasonInputRef.current) {
|
||||||
|
reasonInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
return reason;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [kickState, kick] = useAsyncCallback<undefined, Error, []>(
|
||||||
|
useCallback(async () => {
|
||||||
|
await mx.kick(room.roomId, userId, getReason());
|
||||||
|
}, [mx, room, userId, getReason])
|
||||||
|
);
|
||||||
|
|
||||||
|
const [banState, ban] = useAsyncCallback<undefined, Error, []>(
|
||||||
|
useCallback(async () => {
|
||||||
|
await mx.ban(room.roomId, userId, getReason());
|
||||||
|
}, [mx, room, userId, getReason])
|
||||||
|
);
|
||||||
|
|
||||||
|
const [inviteState, invite] = useAsyncCallback<undefined, Error, []>(
|
||||||
|
useCallback(async () => {
|
||||||
|
await mx.invite(room.roomId, userId, getReason());
|
||||||
|
}, [mx, room, userId, getReason])
|
||||||
|
);
|
||||||
|
|
||||||
|
const disabled =
|
||||||
|
kickState.status === AsyncStatus.Loading ||
|
||||||
|
banState.status === AsyncStatus.Loading ||
|
||||||
|
inviteState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
if (!canBan && !canKick && !canInvite) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="400">
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
|
<Text size="L400">Moderation</Text>
|
||||||
|
<Input
|
||||||
|
ref={reasonInputRef}
|
||||||
|
placeholder="Reason"
|
||||||
|
size="300"
|
||||||
|
variant="Background"
|
||||||
|
radii="300"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
{kickState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
|
||||||
|
<b>{kickState.error.message}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{banState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
|
||||||
|
<b>{banState.error.message}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{inviteState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
|
||||||
|
<b>{inviteState.error.message}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" gap="200">
|
||||||
|
{canInvite && (
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
before={
|
||||||
|
inviteState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner size="50" variant="Secondary" fill="Soft" />
|
||||||
|
) : (
|
||||||
|
<Icon size="50" src={Icons.ArrowRight} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={invite}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Text size="B300">Invite</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canKick && (
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
before={
|
||||||
|
kickState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner size="50" variant="Critical" fill="Soft" />
|
||||||
|
) : (
|
||||||
|
<Icon size="50" src={Icons.ArrowLeft} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={kick}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Text size="B300">Kick</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canBan && (
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
fill="Solid"
|
||||||
|
radii="300"
|
||||||
|
before={
|
||||||
|
banState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner size="50" variant="Critical" fill="Solid" />
|
||||||
|
) : (
|
||||||
|
<Icon size="50" src={Icons.Prohibited} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={ban}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Text size="B300">Ban</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
src/app/components/user-profile/UserRoomProfile.tsx
Normal file
169
src/app/components/user-profile/UserRoomProfile.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { Box, Button, color, config, Icon, Icons, Spinner, Text } from 'folds';
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { UserHero, UserHeroName } from './UserHero';
|
||||||
|
import { getDMRoomFor, getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
|
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||||
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
|
import { useUserPresence } from '../../hooks/useUserPresence';
|
||||||
|
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import { createDM } from '../../../client/action/room';
|
||||||
|
import { hasDevices } from '../../../util/matrixUtil';
|
||||||
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
|
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
|
import { PowerChip } from './PowerChip';
|
||||||
|
import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration';
|
||||||
|
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||||
|
import { useMembership } from '../../hooks/useMembership';
|
||||||
|
import { Membership } from '../../../types/matrix/room';
|
||||||
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
|
import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
|
||||||
|
import { CreatorChip } from './CreatorChip';
|
||||||
|
|
||||||
|
type UserRoomProfileProps = {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
const alive = useAlive();
|
||||||
|
const closeUserRoomProfile = useCloseUserRoomProfile();
|
||||||
|
const ignoredUsers = useIgnoredUsers();
|
||||||
|
const ignored = ignoredUsers.includes(userId);
|
||||||
|
|
||||||
|
const room = useRoom();
|
||||||
|
const powerLevels = usePowerLevels(room);
|
||||||
|
const creators = useRoomCreators(room);
|
||||||
|
|
||||||
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
|
const { hasMorePower } = useMemberPowerCompare(creators, powerLevels);
|
||||||
|
|
||||||
|
const myUserId = mx.getSafeUserId();
|
||||||
|
const creator = creators.has(userId);
|
||||||
|
|
||||||
|
const canKickUser = permissions.action('kick', myUserId) && hasMorePower(myUserId, userId);
|
||||||
|
const canBanUser = permissions.action('ban', myUserId) && hasMorePower(myUserId, userId);
|
||||||
|
const canUnban = permissions.action('ban', myUserId);
|
||||||
|
const canInvite = permissions.action('invite', myUserId);
|
||||||
|
|
||||||
|
const member = room.getMember(userId);
|
||||||
|
const membership = useMembership(room, userId);
|
||||||
|
|
||||||
|
const server = getMxIdServer(userId);
|
||||||
|
const displayName = getMemberDisplayName(room, userId);
|
||||||
|
const avatarMxc = getMemberAvatarMxc(room, userId);
|
||||||
|
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
|
||||||
|
|
||||||
|
const presence = useUserPresence(userId);
|
||||||
|
|
||||||
|
const [directMessageState, directMessage] = useAsyncCallback<string, Error, []>(
|
||||||
|
useCallback(async () => {
|
||||||
|
const result = await createDM(mx, userId, await hasDevices(mx, userId));
|
||||||
|
return result.room_id as string;
|
||||||
|
}, [userId, mx])
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMessage = () => {
|
||||||
|
const dmRoomId = getDMRoomFor(mx, userId)?.roomId;
|
||||||
|
if (dmRoomId) {
|
||||||
|
navigateRoom(dmRoomId);
|
||||||
|
closeUserRoomProfile();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
directMessage().then((rId) => {
|
||||||
|
if (alive()) {
|
||||||
|
navigateRoom(rId);
|
||||||
|
closeUserRoomProfile();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column">
|
||||||
|
<UserHero
|
||||||
|
userId={userId}
|
||||||
|
avatarUrl={avatarUrl}
|
||||||
|
presence={presence && presence.lastActiveTs !== 0 ? presence : undefined}
|
||||||
|
/>
|
||||||
|
<Box direction="Column" gap="500" style={{ padding: config.space.S400 }}>
|
||||||
|
<Box direction="Column" gap="400">
|
||||||
|
<Box gap="400" alignItems="Start">
|
||||||
|
<UserHeroName displayName={displayName} userId={userId} />
|
||||||
|
<Box shrink="No">
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Primary"
|
||||||
|
fill="Solid"
|
||||||
|
radii="300"
|
||||||
|
disabled={directMessageState.status === AsyncStatus.Loading}
|
||||||
|
before={
|
||||||
|
directMessageState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner size="50" variant="Primary" fill="Solid" />
|
||||||
|
) : (
|
||||||
|
<Icon size="50" src={Icons.Message} filled />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={handleMessage}
|
||||||
|
>
|
||||||
|
<Text size="B300">Message</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{directMessageState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }}>
|
||||||
|
<b>{directMessageState.error.message}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Box alignItems="Center" gap="200" wrap="Wrap">
|
||||||
|
{server && <ServerChip server={server} />}
|
||||||
|
<ShareChip userId={userId} />
|
||||||
|
{creator ? <CreatorChip /> : <PowerChip userId={userId} />}
|
||||||
|
<MutualRoomsChip userId={userId} />
|
||||||
|
<OptionsChip userId={userId} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{ignored && <IgnoredUserAlert />}
|
||||||
|
{member && membership === Membership.Ban && (
|
||||||
|
<UserBanAlert
|
||||||
|
userId={userId}
|
||||||
|
reason={member.events.member?.getContent().reason}
|
||||||
|
canUnban={canUnban}
|
||||||
|
bannedBy={member.events.member?.getSender()}
|
||||||
|
ts={member.events.member?.getTs()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{member &&
|
||||||
|
membership === Membership.Leave &&
|
||||||
|
member.events.member &&
|
||||||
|
member.events.member.getSender() !== userId && (
|
||||||
|
<UserKickAlert
|
||||||
|
reason={member.events.member?.getContent().reason}
|
||||||
|
kickedBy={member.events.member?.getSender()}
|
||||||
|
ts={member.events.member?.getTs()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{member && membership === Membership.Invite && (
|
||||||
|
<UserInviteAlert
|
||||||
|
userId={userId}
|
||||||
|
reason={member.events.member?.getContent().reason}
|
||||||
|
canKick={canKickUser}
|
||||||
|
invitedBy={member.events.member?.getSender()}
|
||||||
|
ts={member.events.member?.getTs()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<UserModeration
|
||||||
|
userId={userId}
|
||||||
|
canInvite={canInvite && membership === Membership.Leave}
|
||||||
|
canKick={canKickUser && membership === Membership.Join}
|
||||||
|
canBan={canBanUser && membership !== Membership.Ban}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/components/user-profile/index.ts
Normal file
1
src/app/components/user-profile/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './UserRoomProfile';
|
||||||
42
src/app/components/user-profile/styles.css.ts
Normal file
42
src/app/components/user-profile/styles.css.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const UserHeader = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
padding: config.space.S200,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UserHero = style({
|
||||||
|
position: 'relative',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UserHeroCoverContainer = style({
|
||||||
|
height: toRem(96),
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
export const UserHeroCover = style({
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
filter: 'blur(16px)',
|
||||||
|
transform: 'scale(2)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UserHeroAvatarContainer = style({
|
||||||
|
position: 'relative',
|
||||||
|
height: toRem(29),
|
||||||
|
});
|
||||||
|
export const UserAvatarContainer = style({
|
||||||
|
position: 'absolute',
|
||||||
|
left: config.space.S400,
|
||||||
|
top: 0,
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
});
|
||||||
|
export const UserHeroAvatar = style({
|
||||||
|
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
||||||
|
});
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
IconButton,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
Scroll,
|
||||||
|
Switch,
|
||||||
|
Button,
|
||||||
|
MenuItem,
|
||||||
|
config,
|
||||||
|
color,
|
||||||
|
} from 'folds';
|
||||||
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
import { copyToClipboard } from '../../../utils/dom';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { useRoomState } from '../../../hooks/useRoomState';
|
||||||
|
import { StateEventEditor, StateEventInfo } from './StateEventEditor';
|
||||||
|
import { SendRoomEvent } from './SendRoomEvent';
|
||||||
|
import { useRoomAccountData } from '../../../hooks/useRoomAccountData';
|
||||||
|
import { CutoutCard } from '../../../components/cutout-card';
|
||||||
|
import {
|
||||||
|
AccountDataEditor,
|
||||||
|
AccountDataSubmitCallback,
|
||||||
|
} from '../../../components/AccountDataEditor';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
|
||||||
|
type DeveloperToolsProps = {
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||||
|
const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
|
||||||
|
const roomState = useRoomState(room);
|
||||||
|
const accountData = useRoomAccountData(room);
|
||||||
|
|
||||||
|
const [expandState, setExpandState] = useState(false);
|
||||||
|
const [expandStateType, setExpandStateType] = useState<string>();
|
||||||
|
const [openStateEvent, setOpenStateEvent] = useState<StateEventInfo>();
|
||||||
|
const [composeEvent, setComposeEvent] = useState<{ type?: string; stateKey?: string }>();
|
||||||
|
|
||||||
|
const [expandAccountData, setExpandAccountData] = useState(false);
|
||||||
|
const [accountDataType, setAccountDataType] = useState<string | null>();
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setOpenStateEvent(undefined);
|
||||||
|
setComposeEvent(undefined);
|
||||||
|
setAccountDataType(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitAccountData: AccountDataSubmitCallback = useCallback(
|
||||||
|
async (type, content) => {
|
||||||
|
await mx.setRoomAccountData(room.roomId, type, content);
|
||||||
|
},
|
||||||
|
[mx, room.roomId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (accountDataType !== undefined) {
|
||||||
|
return (
|
||||||
|
<AccountDataEditor
|
||||||
|
type={accountDataType ?? undefined}
|
||||||
|
content={accountDataType ? accountData.get(accountDataType) : undefined}
|
||||||
|
submitChange={submitAccountData}
|
||||||
|
requestClose={handleClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (composeEvent) {
|
||||||
|
return <SendRoomEvent {...composeEvent} requestClose={handleClose} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openStateEvent) {
|
||||||
|
return <StateEventEditor {...openStateEvent} requestClose={handleClose} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageHeader outlined={false}>
|
||||||
|
<Box grow="Yes" gap="200">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Text size="H3" truncate>
|
||||||
|
Developer Tools
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Scroll hideTrack visibility="Hover">
|
||||||
|
<PageContent>
|
||||||
|
<Box direction="Column" gap="700">
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Options</Text>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Enable Developer Tools"
|
||||||
|
after={
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={developerTools}
|
||||||
|
onChange={setDeveloperTools}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
{developerTools && (
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Room ID"
|
||||||
|
description={`Copy room ID to clipboard. ("${room.roomId}")`}
|
||||||
|
after={
|
||||||
|
<Button
|
||||||
|
onClick={() => copyToClipboard(room.roomId ?? '<NO_ROOM_ID_FOUND>')}
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
>
|
||||||
|
<Text size="B300">Copy</Text>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{developerTools && (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Data</Text>
|
||||||
|
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="New Message Event"
|
||||||
|
description="Create and send a new message event within the room."
|
||||||
|
after={
|
||||||
|
<Button
|
||||||
|
onClick={() => setComposeEvent({})}
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
>
|
||||||
|
<Text size="B300">Compose</Text>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Room State"
|
||||||
|
description="State events of the room."
|
||||||
|
after={
|
||||||
|
<Button
|
||||||
|
onClick={() => setExpandState(!expandState)}
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
before={
|
||||||
|
<Icon
|
||||||
|
src={expandState ? Icons.ChevronTop : Icons.ChevronBottom}
|
||||||
|
size="100"
|
||||||
|
filled
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="B300">{expandState ? 'Collapse' : 'Expand'}</Text>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{expandState && (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Box justifyContent="SpaceBetween">
|
||||||
|
<Text size="L400">Events</Text>
|
||||||
|
<Text size="L400">Total: {roomState.size}</Text>
|
||||||
|
</Box>
|
||||||
|
<CutoutCard>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => setComposeEvent({ stateKey: '' })}
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="0"
|
||||||
|
before={<Icon size="50" src={Icons.Plus} />}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="T200" truncate>
|
||||||
|
Add New
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
{Array.from(roomState.keys())
|
||||||
|
.sort()
|
||||||
|
.map((eventType) => {
|
||||||
|
const expanded = eventType === expandStateType;
|
||||||
|
const stateKeyToEvents = roomState.get(eventType);
|
||||||
|
if (!stateKeyToEvents) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box id={eventType} key={eventType} direction="Column" gap="100">
|
||||||
|
<MenuItem
|
||||||
|
onClick={() =>
|
||||||
|
setExpandStateType(expanded ? undefined : eventType)
|
||||||
|
}
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="0"
|
||||||
|
before={
|
||||||
|
<Icon
|
||||||
|
size="50"
|
||||||
|
src={expanded ? Icons.ChevronBottom : Icons.ChevronRight}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
after={<Text size="L400">{stateKeyToEvents.size}</Text>}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="T200" truncate>
|
||||||
|
{eventType}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
{expanded && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginLeft: config.space.S400,
|
||||||
|
borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() =>
|
||||||
|
setComposeEvent({ type: eventType, stateKey: '' })
|
||||||
|
}
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="0"
|
||||||
|
before={<Icon size="50" src={Icons.Plus} />}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="T200" truncate>
|
||||||
|
Add New
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
{Array.from(stateKeyToEvents.keys())
|
||||||
|
.sort()
|
||||||
|
.map((stateKey) => (
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setOpenStateEvent({
|
||||||
|
type: eventType,
|
||||||
|
stateKey,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
key={stateKey}
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="0"
|
||||||
|
after={<Icon size="50" src={Icons.ChevronRight} />}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="T200" truncate>
|
||||||
|
{stateKey ? `"${stateKey}"` : 'Default'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CutoutCard>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</SequenceCard>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Account Data"
|
||||||
|
description="Private personalization data stored within room."
|
||||||
|
after={
|
||||||
|
<Button
|
||||||
|
onClick={() => setExpandAccountData(!expandAccountData)}
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
before={
|
||||||
|
<Icon
|
||||||
|
src={expandAccountData ? Icons.ChevronTop : Icons.ChevronBottom}
|
||||||
|
size="100"
|
||||||
|
filled
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="B300">{expandAccountData ? 'Collapse' : 'Expand'}</Text>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{expandAccountData && (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Box justifyContent="SpaceBetween">
|
||||||
|
<Text size="L400">Events</Text>
|
||||||
|
<Text size="L400">Total: {accountData.size}</Text>
|
||||||
|
</Box>
|
||||||
|
<CutoutCard>
|
||||||
|
<MenuItem
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="0"
|
||||||
|
before={<Icon size="50" src={Icons.Plus} />}
|
||||||
|
onClick={() => setAccountDataType(null)}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="T200" truncate>
|
||||||
|
Add New
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
{Array.from(accountData.keys())
|
||||||
|
.sort()
|
||||||
|
.map((type) => (
|
||||||
|
<MenuItem
|
||||||
|
key={type}
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="0"
|
||||||
|
after={<Icon size="50" src={Icons.ChevronRight} />}
|
||||||
|
onClick={() => setAccountDataType(type)}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="T200" truncate>
|
||||||
|
{type}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</CutoutCard>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</PageContent>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
|
||||||
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
IconButton,
|
||||||
|
Text,
|
||||||
|
config,
|
||||||
|
Button,
|
||||||
|
Spinner,
|
||||||
|
color,
|
||||||
|
TextArea as TextAreaComponent,
|
||||||
|
Input,
|
||||||
|
} from 'folds';
|
||||||
|
import { Page, PageHeader } from '../../../components/page';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
|
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { syntaxErrorPosition } from '../../../utils/dom';
|
||||||
|
import { Cursor } from '../../../plugins/text-area';
|
||||||
|
|
||||||
|
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||||
|
|
||||||
|
export type SendRoomEventProps = {
|
||||||
|
type?: string;
|
||||||
|
stateKey?: string;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const alive = useAlive();
|
||||||
|
const composeStateEvent = typeof stateKey === 'string';
|
||||||
|
|
||||||
|
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [jsonError, setJSONError] = useState<SyntaxError>();
|
||||||
|
const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor(
|
||||||
|
textAreaRef,
|
||||||
|
EDITOR_INTENT_SPACE_COUNT
|
||||||
|
);
|
||||||
|
|
||||||
|
const [submitState, submit] = useAsyncCallback<
|
||||||
|
object,
|
||||||
|
MatrixError,
|
||||||
|
[string, string | undefined, object]
|
||||||
|
>(
|
||||||
|
useCallback(
|
||||||
|
(evtType, evtStateKey, evtContent) => {
|
||||||
|
if (typeof evtStateKey === 'string') {
|
||||||
|
return mx.sendStateEvent(room.roomId, evtType as any, evtContent, evtStateKey);
|
||||||
|
}
|
||||||
|
return mx.sendEvent(room.roomId, evtType as any, evtContent);
|
||||||
|
},
|
||||||
|
[mx, room]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const submitting = submitState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
|
||||||
|
const target = evt.target as HTMLFormElement | undefined;
|
||||||
|
const typeInput = target?.typeInput as HTMLInputElement | undefined;
|
||||||
|
const stateKeyInput = target?.stateKeyInput as HTMLInputElement | undefined;
|
||||||
|
const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
|
||||||
|
if (!typeInput || !contentTextArea) return;
|
||||||
|
|
||||||
|
const evtType = typeInput.value;
|
||||||
|
const evtStateKey = stateKeyInput?.value;
|
||||||
|
const contentStr = contentTextArea.value.trim();
|
||||||
|
|
||||||
|
let parsedContent: object;
|
||||||
|
try {
|
||||||
|
parsedContent = JSON.parse(contentStr);
|
||||||
|
} catch (e) {
|
||||||
|
setJSONError(e as SyntaxError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setJSONError(undefined);
|
||||||
|
|
||||||
|
if (parsedContent === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(evtType, evtStateKey, parsedContent).then(() => {
|
||||||
|
if (alive()) {
|
||||||
|
requestClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (jsonError) {
|
||||||
|
const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
|
||||||
|
const cursor = new Cursor(errorPosition, errorPosition, 'none');
|
||||||
|
operations.select(cursor);
|
||||||
|
getTarget()?.focus();
|
||||||
|
}
|
||||||
|
}, [jsonError, operations, getTarget]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageHeader outlined={false} balance>
|
||||||
|
<Box alignItems="Center" grow="Yes" gap="200">
|
||||||
|
<Box alignItems="Inherit" grow="Yes" gap="200">
|
||||||
|
<Chip
|
||||||
|
size="500"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={requestClose}
|
||||||
|
before={<Icon size="100" src={Icons.ArrowLeft} />}
|
||||||
|
>
|
||||||
|
<Text size="T300">Developer Tools</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
grow="Yes"
|
||||||
|
style={{ padding: config.space.S400 }}
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
aria-disabled={submitting}
|
||||||
|
>
|
||||||
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
|
<Text size="L400">{composeStateEvent ? 'State Event Type' : 'Message Event Type'}</Text>
|
||||||
|
<Box gap="300">
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Input
|
||||||
|
variant="Background"
|
||||||
|
name="typeInput"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
readOnly={submitting}
|
||||||
|
defaultValue={type}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="Success"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
|
||||||
|
>
|
||||||
|
<Text size="B400">Send</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{submitState.status === AsyncStatus.Error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
<b>{submitState.error.message}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{composeStateEvent && (
|
||||||
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
|
<Text size="L400">State Key (Optional)</Text>
|
||||||
|
<Input
|
||||||
|
variant="Background"
|
||||||
|
name="stateKeyInput"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
readOnly={submitting}
|
||||||
|
defaultValue={stateKey}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
|
<Box shrink="No">
|
||||||
|
<Text size="L400">JSON Content</Text>
|
||||||
|
</Box>
|
||||||
|
<TextAreaComponent
|
||||||
|
ref={textAreaRef}
|
||||||
|
name="contentTextArea"
|
||||||
|
style={{ fontFamily: 'monospace' }}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
resize="None"
|
||||||
|
spellCheck="false"
|
||||||
|
required
|
||||||
|
readOnly={submitting}
|
||||||
|
/>
|
||||||
|
{jsonError && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
<b>
|
||||||
|
{jsonError.name}: {jsonError.message}
|
||||||
|
</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
IconButton,
|
||||||
|
Chip,
|
||||||
|
Scroll,
|
||||||
|
config,
|
||||||
|
TextArea as TextAreaComponent,
|
||||||
|
color,
|
||||||
|
Spinner,
|
||||||
|
Button,
|
||||||
|
} from 'folds';
|
||||||
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { Page, PageHeader } from '../../../components/page';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { TextViewerContent } from '../../../components/text-viewer';
|
||||||
|
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
|
import { Cursor } from '../../../plugins/text-area';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { syntaxErrorPosition } from '../../../utils/dom';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
|
import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
||||||
|
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
|
||||||
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
|
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||||
|
|
||||||
|
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||||
|
|
||||||
|
type StateEventEditProps = {
|
||||||
|
type: string;
|
||||||
|
stateKey: string;
|
||||||
|
content: object;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEditProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
|
const defaultContentStr = useMemo(
|
||||||
|
() => JSON.stringify(content, undefined, EDITOR_INTENT_SPACE_COUNT),
|
||||||
|
[content]
|
||||||
|
);
|
||||||
|
|
||||||
|
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [jsonError, setJSONError] = useState<SyntaxError>();
|
||||||
|
const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor(
|
||||||
|
textAreaRef,
|
||||||
|
EDITOR_INTENT_SPACE_COUNT
|
||||||
|
);
|
||||||
|
|
||||||
|
const [submitState, submit] = useAsyncCallback<object, MatrixError, [object]>(
|
||||||
|
useCallback(
|
||||||
|
(c) => mx.sendStateEvent(room.roomId, type as any, c, stateKey),
|
||||||
|
[mx, room, type, stateKey]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const submitting = submitState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
|
||||||
|
const target = evt.target as HTMLFormElement | undefined;
|
||||||
|
const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
|
||||||
|
if (!contentTextArea) return;
|
||||||
|
|
||||||
|
const contentStr = contentTextArea.value.trim();
|
||||||
|
|
||||||
|
let parsedContent: object;
|
||||||
|
try {
|
||||||
|
parsedContent = JSON.parse(contentStr);
|
||||||
|
} catch (e) {
|
||||||
|
setJSONError(e as SyntaxError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setJSONError(undefined);
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsedContent === null ||
|
||||||
|
defaultContentStr === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(parsedContent).then(() => {
|
||||||
|
if (alive()) {
|
||||||
|
requestClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (jsonError) {
|
||||||
|
const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
|
||||||
|
const cursor = new Cursor(errorPosition, errorPosition, 'none');
|
||||||
|
operations.select(cursor);
|
||||||
|
getTarget()?.focus();
|
||||||
|
}
|
||||||
|
}, [jsonError, operations, getTarget]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
grow="Yes"
|
||||||
|
style={{ padding: config.space.S400 }}
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
aria-disabled={submitting}
|
||||||
|
>
|
||||||
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
|
<Text size="L400">State Event</Text>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title={type}
|
||||||
|
description={stateKey}
|
||||||
|
after={
|
||||||
|
<Box gap="200">
|
||||||
|
<Button
|
||||||
|
variant="Success"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
|
||||||
|
>
|
||||||
|
<Text size="B300">Save</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={requestClose}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
<Text size="B300">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
|
||||||
|
{submitState.status === AsyncStatus.Error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
<b>{submitState.error.message}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
|
<Box shrink="No">
|
||||||
|
<Text size="L400">JSON Content</Text>
|
||||||
|
</Box>
|
||||||
|
<TextAreaComponent
|
||||||
|
ref={textAreaRef}
|
||||||
|
name="contentTextArea"
|
||||||
|
style={{ fontFamily: 'monospace' }}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
defaultValue={defaultContentStr}
|
||||||
|
resize="None"
|
||||||
|
spellCheck="false"
|
||||||
|
required
|
||||||
|
readOnly={submitting}
|
||||||
|
/>
|
||||||
|
{jsonError && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
<b>
|
||||||
|
{jsonError.name}: {jsonError.message}
|
||||||
|
</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type StateEventViewProps = {
|
||||||
|
content: object;
|
||||||
|
eventJSONStr: string;
|
||||||
|
onEditContent?: (content: object) => void;
|
||||||
|
};
|
||||||
|
function StateEventView({ content, eventJSONStr, onEditContent }: StateEventViewProps) {
|
||||||
|
return (
|
||||||
|
<Box direction="Column" style={{ padding: config.space.S400 }} gap="400">
|
||||||
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
|
<Box gap="200" alignItems="End">
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="L400">State Event</Text>
|
||||||
|
</Box>
|
||||||
|
{onEditContent && (
|
||||||
|
<Box shrink="No" gap="200">
|
||||||
|
<Chip
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
onClick={() => onEditContent(content)}
|
||||||
|
>
|
||||||
|
<Text size="B300">Edit</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<SequenceCard variant="SurfaceVariant">
|
||||||
|
<Scroll visibility="Always" size="300" hideTrack>
|
||||||
|
<TextViewerContent
|
||||||
|
size="T300"
|
||||||
|
style={{
|
||||||
|
padding: `${config.space.S300} ${config.space.S100} ${config.space.S300} ${config.space.S300}`,
|
||||||
|
}}
|
||||||
|
text={eventJSONStr}
|
||||||
|
langName="JSON"
|
||||||
|
/>
|
||||||
|
</Scroll>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StateEventInfo = {
|
||||||
|
type: string;
|
||||||
|
stateKey: string;
|
||||||
|
};
|
||||||
|
export type StateEventEditorProps = StateEventInfo & {
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StateEventEditor({ type, stateKey, requestClose }: StateEventEditorProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const stateEvent = useStateEvent(room, type as unknown as StateEvent, stateKey);
|
||||||
|
const [editContent, setEditContent] = useState<object>();
|
||||||
|
const powerLevels = usePowerLevels(room);
|
||||||
|
const creators = useRoomCreators(room);
|
||||||
|
|
||||||
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
|
const canEdit = permissions.stateEvent(type, mx.getSafeUserId());
|
||||||
|
|
||||||
|
const eventJSONStr = useMemo(() => {
|
||||||
|
if (!stateEvent) return '';
|
||||||
|
return JSON.stringify(stateEvent.event, null, EDITOR_INTENT_SPACE_COUNT);
|
||||||
|
}, [stateEvent]);
|
||||||
|
|
||||||
|
const handleCloseEdit = useCallback(() => {
|
||||||
|
setEditContent(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageHeader outlined={false} balance>
|
||||||
|
<Box alignItems="Center" grow="Yes" gap="200">
|
||||||
|
<Box alignItems="Inherit" grow="Yes" gap="200">
|
||||||
|
<Chip
|
||||||
|
size="500"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={requestClose}
|
||||||
|
before={<Icon size="100" src={Icons.ArrowLeft} />}
|
||||||
|
>
|
||||||
|
<Text size="T300">Developer Tools</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
{editContent ? (
|
||||||
|
<StateEventEdit
|
||||||
|
type={type}
|
||||||
|
stateKey={stateKey}
|
||||||
|
content={editContent}
|
||||||
|
requestClose={handleCloseEdit}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StateEventView
|
||||||
|
content={stateEvent?.getContent() ?? {}}
|
||||||
|
onEditContent={canEdit ? setEditContent : undefined}
|
||||||
|
eventJSONStr={eventJSONStr}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './DevelopTools';
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
||||||
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
|
import { ImagePack } from '../../../plugins/custom-emoji';
|
||||||
|
import { ImagePackView } from '../../../components/image-pack-view';
|
||||||
|
import { RoomPacks } from './RoomPacks';
|
||||||
|
|
||||||
|
type EmojisStickersProps = {
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
export function EmojisStickers({ requestClose }: EmojisStickersProps) {
|
||||||
|
const [imagePack, setImagePack] = useState<ImagePack>();
|
||||||
|
|
||||||
|
const handleImagePackViewClose = () => {
|
||||||
|
setImagePack(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (imagePack) {
|
||||||
|
return <ImagePackView address={imagePack.address} requestClose={handleImagePackViewClose} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageHeader outlined={false}>
|
||||||
|
<Box grow="Yes" gap="200">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Text size="H3" truncate>
|
||||||
|
Emojis & Stickers
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Scroll hideTrack visibility="Hover">
|
||||||
|
<PageContent>
|
||||||
|
<Box direction="Column" gap="700">
|
||||||
|
<RoomPacks onViewPack={setImagePack} />
|
||||||
|
</Box>
|
||||||
|
</PageContent>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
353
src/app/features/common-settings/emojis-stickers/RoomPacks.tsx
Normal file
353
src/app/features/common-settings/emojis-stickers/RoomPacks.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
toRem,
|
||||||
|
config,
|
||||||
|
Input,
|
||||||
|
Spinner,
|
||||||
|
color,
|
||||||
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
} from 'folds';
|
||||||
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import {
|
||||||
|
ImagePack,
|
||||||
|
ImageUsage,
|
||||||
|
PackAddress,
|
||||||
|
packAddressEqual,
|
||||||
|
PackContent,
|
||||||
|
} from '../../../plugins/custom-emoji';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { useRoomImagePacks } from '../../../hooks/useImagePacks';
|
||||||
|
import { LineClamp2 } from '../../../styles/Text.css';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
||||||
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import { suffixRename } from '../../../utils/common';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
|
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||||
|
|
||||||
|
type CreatePackTileProps = {
|
||||||
|
packs: ImagePack[];
|
||||||
|
roomId: string;
|
||||||
|
};
|
||||||
|
function CreatePackTile({ packs, roomId }: CreatePackTileProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
|
const [addState, addPack] = useAsyncCallback<void, MatrixError, [string, string]>(
|
||||||
|
useCallback(
|
||||||
|
async (stateKey, name) => {
|
||||||
|
const content: PackContent = {
|
||||||
|
pack: {
|
||||||
|
display_name: name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await mx.sendStateEvent(roomId, StateEvent.PoniesRoomEmotes as any, content, stateKey);
|
||||||
|
},
|
||||||
|
[mx, roomId]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const creating = addState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (creating) return;
|
||||||
|
|
||||||
|
const target = evt.target as HTMLFormElement | undefined;
|
||||||
|
const nameInput = target?.nameInput as HTMLInputElement | undefined;
|
||||||
|
if (!nameInput) return;
|
||||||
|
const name = nameInput?.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
let packKey = name.replace(/\s/g, '-');
|
||||||
|
|
||||||
|
const hasPack = (k: string): boolean => !!packs.find((pack) => pack.address?.stateKey === k);
|
||||||
|
if (hasPack(packKey)) {
|
||||||
|
packKey = suffixRename(packKey, hasPack);
|
||||||
|
}
|
||||||
|
|
||||||
|
addPack(packKey, name).then(() => {
|
||||||
|
if (alive()) {
|
||||||
|
nameInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="New Pack"
|
||||||
|
description="Add your own emoji and sticker pack to use in room."
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{ marginTop: config.space.S200 }}
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
gap="200"
|
||||||
|
alignItems="End"
|
||||||
|
>
|
||||||
|
<Box direction="Column" gap="100" grow="Yes">
|
||||||
|
<Text size="L400">Name</Text>
|
||||||
|
<Input
|
||||||
|
name="nameInput"
|
||||||
|
required
|
||||||
|
size="400"
|
||||||
|
variant="Secondary"
|
||||||
|
radii="300"
|
||||||
|
readOnly={creating}
|
||||||
|
/>
|
||||||
|
{addState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||||
|
{addState.error.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="Success"
|
||||||
|
radii="300"
|
||||||
|
type="submit"
|
||||||
|
disabled={creating}
|
||||||
|
before={creating && <Spinner size="200" variant="Success" fill="Solid" />}
|
||||||
|
>
|
||||||
|
<Text size="B400">Create</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
</SequenceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoomPacksProps = {
|
||||||
|
onViewPack: (imagePack: ImagePack) => void;
|
||||||
|
};
|
||||||
|
export function RoomPacks({ onViewPack }: RoomPacksProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const room = useRoom();
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
|
const powerLevels = usePowerLevels(room);
|
||||||
|
const creators = useRoomCreators(room);
|
||||||
|
|
||||||
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
|
const canEdit = permissions.stateEvent(StateEvent.PoniesRoomEmotes, mx.getSafeUserId());
|
||||||
|
|
||||||
|
const unfilteredPacks = useRoomImagePacks(room);
|
||||||
|
const packs = useMemo(() => unfilteredPacks.filter((pack) => !pack.deleted), [unfilteredPacks]);
|
||||||
|
|
||||||
|
const [removedPacks, setRemovedPacks] = useState<PackAddress[]>([]);
|
||||||
|
const hasChanges = removedPacks.length > 0;
|
||||||
|
|
||||||
|
const [applyState, applyChanges] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
for (let i = 0; i < removedPacks.length; i += 1) {
|
||||||
|
const addr = removedPacks[i];
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await mx.sendStateEvent(room.roomId, StateEvent.PoniesRoomEmotes as any, {}, addr.stateKey);
|
||||||
|
}
|
||||||
|
}, [mx, room, removedPacks])
|
||||||
|
);
|
||||||
|
const applyingChanges = applyState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const handleRemove = (address: PackAddress) => {
|
||||||
|
setRemovedPacks((addresses) => [...addresses, address]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUndoRemove = (address: PackAddress) => {
|
||||||
|
setRemovedPacks((addresses) => addresses.filter((addr) => !packAddressEqual(addr, address)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelChanges = () => setRemovedPacks([]);
|
||||||
|
|
||||||
|
const handleApplyChanges = () => {
|
||||||
|
applyChanges().then(() => {
|
||||||
|
if (alive()) {
|
||||||
|
setRemovedPacks([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPack = (pack: ImagePack) => {
|
||||||
|
const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon);
|
||||||
|
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
|
||||||
|
const { address } = pack;
|
||||||
|
if (!address) return null;
|
||||||
|
const removed = !!removedPacks.find((addr) => packAddressEqual(addr, address));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SequenceCard
|
||||||
|
key={pack.id}
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant={removed ? 'Critical' : 'SurfaceVariant'}
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title={
|
||||||
|
<span style={{ textDecoration: removed ? 'line-through' : undefined }}>
|
||||||
|
{pack.meta.name ?? 'Unknown'}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={<span className={LineClamp2}>{pack.meta.attribution}</span>}
|
||||||
|
before={
|
||||||
|
<Box alignItems="Center" gap="300">
|
||||||
|
{canEdit &&
|
||||||
|
(removed ? (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="Pill"
|
||||||
|
variant="Critical"
|
||||||
|
onClick={() => handleUndoRemove(address)}
|
||||||
|
disabled={applyingChanges}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Plus} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="Pill"
|
||||||
|
variant="Secondary"
|
||||||
|
onClick={() => handleRemove(address)}
|
||||||
|
disabled={applyingChanges}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
))}
|
||||||
|
<Avatar size="300" radii="300">
|
||||||
|
{avatarUrl ? (
|
||||||
|
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
|
||||||
|
) : (
|
||||||
|
<AvatarFallback>
|
||||||
|
<Icon size="400" src={Icons.Sticker} filled />
|
||||||
|
</AvatarFallback>
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
after={
|
||||||
|
!removed && (
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
onClick={() => onViewPack(pack)}
|
||||||
|
>
|
||||||
|
<Text size="B300">View</Text>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Packs</Text>
|
||||||
|
{canEdit && <CreatePackTile roomId={room.roomId} packs={packs} />}
|
||||||
|
{packs.map(renderPack)}
|
||||||
|
{packs.length === 0 && (
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
justifyContent="Center"
|
||||||
|
direction="Column"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
padding: `${config.space.S700} ${config.space.S400}`,
|
||||||
|
maxWidth: toRem(300),
|
||||||
|
margin: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="H5" align="Center">
|
||||||
|
No Packs
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" align="Center">
|
||||||
|
There are no emoji or sticker packs to display at the moment.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</SequenceCard>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{hasChanges && (
|
||||||
|
<Menu
|
||||||
|
style={{
|
||||||
|
position: 'sticky',
|
||||||
|
padding: config.space.S200,
|
||||||
|
paddingLeft: config.space.S400,
|
||||||
|
bottom: config.space.S400,
|
||||||
|
left: config.space.S400,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
variant="Critical"
|
||||||
|
>
|
||||||
|
<Box alignItems="Center" gap="400">
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
{applyState.status === AsyncStatus.Error ? (
|
||||||
|
<Text size="T200">
|
||||||
|
<b>Failed to remove packs! Please try again.</b>
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text size="T200">
|
||||||
|
<b>Delete selected packs. ({removedPacks.length} selected)</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" gap="200">
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
disabled={applyingChanges}
|
||||||
|
onClick={handleCancelChanges}
|
||||||
|
>
|
||||||
|
<Text size="B300">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
radii="300"
|
||||||
|
disabled={applyingChanges}
|
||||||
|
before={applyingChanges && <Spinner variant="Critical" fill="Solid" size="100" />}
|
||||||
|
onClick={handleApplyChanges}
|
||||||
|
>
|
||||||
|
<Text size="B300">Delete</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user