Compare commits

..

15 commits

477 changed files with 17283 additions and 27345 deletions

View file

@ -61,12 +61,4 @@ module.exports = {
"@typescript-eslint/no-unused-vars": "error", "@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-shadow": "error" "@typescript-eslint/no-shadow": "error"
}, },
overrides: [
{
files: ['*.ts'],
rules: {
'no-undef': 'off',
},
},
],
}; };

View file

@ -12,9 +12,9 @@ jobs:
PR_NUMBER: ${{github.event.number}} PR_NUMBER: ${{github.event.number}}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.0 uses: actions/checkout@v4.1.5
- name: Setup node - name: Setup node
uses: actions/setup-node@v4.0.4 uses: actions/setup-node@v4.0.2
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.3.6 uses: actions/upload-artifact@v4.3.3
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.3.6 uses: actions/upload-artifact@v4.3.3
with: with:
name: pr name: pr
path: ./pr.txt path: ./pr.txt

View file

@ -12,7 +12,7 @@ jobs:
- name: 'CLA Assistant' - name: 'CLA Assistant'
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# Beta Release # Beta Release
uses: cla-assistant/github-action@v2.6.1 uses: cla-assistant/github-action@v2.4.0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret # the below token should have repo scope and must be manually added by you in the repository's secret

View file

@ -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@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11 uses: dawidd6/action-download-artifact@09f2f74827fd3a8607589e5ad7f9398816f540fe
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@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11 uses: dawidd6/action-download-artifact@09f2f74827fd3a8607589e5ad7f9398816f540fe
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 }}

View file

@ -11,9 +11,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.0 uses: actions/checkout@v4.1.5
- name: Build Docker image - name: Build Docker image
uses: docker/build-push-action@v6.9.0 uses: docker/build-push-action@v5.3.0
with: with:
context: . context: .
push: false push: false

View file

@ -14,7 +14,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4.2.0 uses: actions/checkout@v4.1.5
- name: NPM Lockfile Changes - name: NPM Lockfile Changes
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891 uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891
with: with:

View file

@ -11,9 +11,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.0 uses: actions/checkout@v4.1.5
- name: Setup node - name: Setup node
uses: actions/setup-node@v4.0.4 uses: actions/setup-node@v4.0.2
with: with:
node-version: 20.12.2 node-version: 20.12.2
cache: 'npm' cache: 'npm'

View file

@ -10,9 +10,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.0 uses: actions/checkout@v4.1.5
- name: Setup node - name: Setup node
uses: actions/setup-node@v4.0.4 uses: actions/setup-node@v4.0.2
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@c062e08bd532815e2082a85e87e3ef29c3e6d191 uses: softprops/action-gh-release@69320dbe05506a9a39fc8ae11030b214ec2d1f87
with: with:
files: | files: |
cinny-${{ steps.vars.outputs.tag }}.tar.gz cinny-${{ steps.vars.outputs.tag }}.tar.gz
@ -66,18 +66,18 @@ jobs:
packages: write packages: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.0 uses: actions/checkout@v4.1.5
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3.2.0 uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.6.1 uses: docker/setup-buildx-action@v3.3.0
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3.3.0 uses: docker/login-action@v3.1.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.1.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@ -90,7 +90,7 @@ jobs:
${{ 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.9.0 uses: docker/build-push-action@v5.3.0
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

1
.gitignore vendored
View file

@ -4,4 +4,3 @@ node_modules
devAssets devAssets
.DS_Store .DS_Store
.idea

3
.npmrc
View file

@ -1,2 +1,3 @@
legacy-peer-deps=true legacy-peer-deps=true
save-exact=true save-exact=true
@matrix-org:registry=https://gitlab.matrix.org/api/v4/projects/27/packages/npm/

View file

@ -1,128 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
cinnyapp@gmail.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View file

@ -11,10 +11,9 @@ RUN npm run build
## App ## App
FROM nginx:1.27.0-alpine FROM nginx:1.26.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
RUN rm -rf /usr/share/nginx/html \ RUN rm -rf /usr/share/nginx/html \
&& ln -s /app /usr/share/nginx/html && ln -s /app /usr/share/nginx/html

View file

@ -19,24 +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. 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.
* 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 [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). To host Cinny on your own, download tarball of the app from [GitHub release](https://github.com/cinnyapp/cinny/releases/latest).
You can serve the application with a webserver of your choice by simply copying `dist/` directory to the webroot. You can serve the application with a webserver of your choice by simply copying `dist/` directory to the webroot.
To set default Homeserver on login, register and Explore Community page, place a customized [`config.json`](config.json) in webroot of your choice. To set default Homeserver on login and register 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: Alternatively you can just pull the [DockerHub image](https://hub.docker.com/r/ajbura/cinny) by:
``` ```
docker pull ajbura/cinny docker pull ajbura/cinny
``` ```
or [ghcr image](https://github.com/cinnyapp/cinny/pkgs/container/cinny) by: or [ghcr image](https://github.com/cinnyapp/cinny/pkgs/container/cinny) by:
``` ```
docker pull ghcr.io/cinnyapp/cinny:latest docker pull ghcr.io/cinnyapp/cinny:latest
``` ```
<details> <details>
<summary>PGP Public Key to verify tarball</summary> <summary>PGP Public Key to verify tarball</summary>
@ -53,16 +51,16 @@ Frn9ttCEzV55Y+so4X2e4ZnB+5gOnNw+ecifGVdj/+UyWnqvqqDvLrEjjK890nLb
Pil4siecNMEpiwAN6WSmKpWaCwQAHEGDVeZCc/kT0iYfj5FBcsTVqWiO6eaxkUlm Pil4siecNMEpiwAN6WSmKpWaCwQAHEGDVeZCc/kT0iYfj5FBcsTVqWiO6eaxkUlm
jnulqWqRrlB8CJQQvih/g//uSEBdzIibo+ro+3Jpe120U/XVUH62i9HoRQEm6ADG jnulqWqRrlB8CJQQvih/g//uSEBdzIibo+ro+3Jpe120U/XVUH62i9HoRQEm6ADG
4zS5hIq4xyA8fL8AEQEAAbQdQ2lubnlBcHAgPGNpbm55YXBwQGdtYWlsLmNvbT6J 4zS5hIq4xyA8fL8AEQEAAbQdQ2lubnlBcHAgPGNpbm55YXBwQGdtYWlsLmNvbT6J
AdQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQSRri2MHidaaZv+ AdQEEwEIAD4WIQSRri2MHidaaZv+vvuUMwx6UK/M8wUCYnD+DQIbAwUJA8JnAAUL
vvuUMwx6UK/M8wUCZqEDwAUJFvwIswAKCRCUMwx6UK/M877qC/4lxXOQIoWnLLkK CQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRCUMwx6UK/M88ApC/9HAdbum1lYBC0s
YiRCTkGsH6NdxgeYr6wpXT4xuQ45ZxCytwHpOGQmO/5up5961TxWW8D1frRIJHjj 1k7GwP2A7B4sQtBWjy771BzybWlHeaeG+BGJwg4YiuowXZMm5dubFJFoI/CfeY07
AZGoRCL3EKEuY8nt3D99fpf3DvZrs1uoVAhiyn737hRlZAg+QsJheeGCmdSJ0hX5 B5aK40/bmT6Xcfkp0VA74c1wUpubBUEJN7tH5HG/OGd9BKeq9E/HHtVaJLVT1k3w
Yud8SE+9zxLS1+CEjMrsUd/RGre/phme+wNXfaHfREAC9ewolgVChPIbMxG2f+vs Rhv9VuHO6nR30EEp7IDthftotl5S4lio3+W0pKk4TAKV8vjaCNp3y/lAHzoP1BU9
K8Xv52BFng7ta9fgsl1XuOjpuaSbQv6g+4ONk/lxKF0SmnhEGM3dmIYPONxW47Yf bUSao+7GXVeArKBjuqxN+t1uuiaxPH4L0oe2pMVjTig04zGJM5fTVoly859MEcC/
atnIjRra/YhPTNwrNBGMmG4IFKaOsMbjW/eakjWTWOVKKJNBMoDdRcYYWIMCpLy8 R7Taq9RWGfXFmgCXy8Dviz3eOD90vqpCzhX4+ypK0cp2X0UwhMH4dpKUzExmdbhl
AQUrMtQEsHSnqCwrw818S5A6rrhcfVGk36RGm0nOy6LS5g5jmqaYsvbCcBGY9B2c eBO5GcHB4VxvloRBNf9/Lr7YOTgWejMUw+MlhZE2RE8unfW1LnM/cjL4dhXzO/XB
SUAVNm17oo7TtEajk8hcSXoZod1t++pyjcVKEmSn3nFK7v5m3V+cPhNTxZMK459P FUHHNq8d6d4e02rfWqw7mZo2/NVJgFRcvzw2rgx7w7CKtCNwF4lNjUetB2waZzDb
3x1Ucqj/kTqrxKw6s2Uknuk0ajmw0ljV+BQwgL6maguo9BKgCNW5AY0EYnD+DQEM fAE0kwhK4Iuwvy12JOBzL0Yy9MxANtwUryr/LQz9AmdT4Rwnp0S5AY0EYnD+DQEM
ANOu/d6ZMF8bW+Df9RDCUQKytbaZfa+ZbIHBus7whCD/SQMOhPKntv3HX7SmMCs+ ANOu/d6ZMF8bW+Df9RDCUQKytbaZfa+ZbIHBus7whCD/SQMOhPKntv3HX7SmMCs+
5i27kJMu4YN623JCS7hdCoXVO1R5kXCEcneW/rPBMDutaM472YvIWMIqK9Wwl5+0 5i27kJMu4YN623JCS7hdCoXVO1R5kXCEcneW/rPBMDutaM472YvIWMIqK9Wwl5+0
Piu2N+uTkKhe9uS2u7eN+Khef3d7xfjGRxoppM+xI9dZO+jhYiy8LuC0oBohTjJq Piu2N+uTkKhe9uS2u7eN+Khef3d7xfjGRxoppM+xI9dZO+jhYiy8LuC0oBohTjJq
@ -71,17 +69,17 @@ s1+eh00n/tVpi2Jj9pCm7S0csSXvXj8v2OTdK1jt4YjpzR0/rwh4+/xlOjDjZEqH
vMPhpzpbgnwkxZ3X8BFne9dJ3maC5zQ3LAeCP5m1W0hXzagYhfyjo74slJgD1O8c vMPhpzpbgnwkxZ3X8BFne9dJ3maC5zQ3LAeCP5m1W0hXzagYhfyjo74slJgD1O8c
LDf2Oxc5MyM8Y/UK497zfqSPfgT3NhQmhHzk83DjXw3I6Z3A3U+Jp61w0eBRI1nx LDf2Oxc5MyM8Y/UK497zfqSPfgT3NhQmhHzk83DjXw3I6Z3A3U+Jp61w0eBRI1nx
H1UIG+gldcAKUTcfwL0lghoT3nmi9JAbvek0Smhz00Bbo8/dx8vwQRxDUxlt7Exx H1UIG+gldcAKUTcfwL0lghoT3nmi9JAbvek0Smhz00Bbo8/dx8vwQRxDUxlt7Exx
NwARAQABiQG8BBgBCAAmAhsMFiEEka4tjB4nWmmb/r77lDMMelCvzPMFAmahA9IF NwARAQABiQG8BBgBCAAmFiEEka4tjB4nWmmb/r77lDMMelCvzPMFAmJw/g0CGwwF
CRb8CMUACgkQlDMMelCvzPPQgQv/d5/z+fxgKqgfhQX+V49X4WgTVxZ/CzztDoJ1 CQPCZwAACgkQlDMMelCvzPPT7Qv8CjXUEhphZFLwpBfaNOzRNfIXJST9aDit8zHW
XAq1dzTNEy8AFguXIo6eVXPSpMxec7ZreN3+UPQBnCf3eR5YxWNYOYKmk0G4E8D2 IMmfSpORVfpU71IyIB3o/DtTUPwCeb8nvNJs7aj1QT1ZUSsqFa3yY2S16V/g8+WN
KGUJept7TSA42/8N2ov6tToXFg4CgzKZj0fYLwgutly7K8eiWmSU6ptaO8aEQBHB sHca6oDSc1J+A0eEpEL1HbG1b5OPBC0AeGvvMOoqrbqThBZVKg1Jc/0SD3cvKElv
gTGIOO3h6vJMGVycmoeRnHjv4wV84YWSVFSoJ7cY0he4Z9UznJBbE/KHZjrkXsPo aHeCZCNNmfcZ2Ib4HYhhc8//ZtC9TeI+5J/YesctY1M12EoWMxMrc27Y3P5Pa0BI
N+Gg5lDuOP5xjKzM5SogV9lhxBAhMWAg3URUF15yruZBiA8uV1FOK8sal/9C1G7V Uc3qxWggPq1vOFYsEshL0w99HyJvREJmQA7Fa0crV+rICxyrBxJeNnEvjH/0KCBU
M6ygA6uOZqXlZtcdA94RoSsW2pZ9eLVPsxz2B3Zko7tu11MpNP/wYmfGTI3KxZBj LCkEonLY1QwrxyeeV3VpxGE3zHHE3azOdAjTIoAdzX5f/qhbgYlM68GL2f8xdDkp
n/eodvwjJSgHpGOFSmbNzvPJo3to5nNlp7wH1KxIMc6Uuu9hgfDfwkFZgV2bnFIa O0igSGHWhO4F8BfmE7IOTx1Bi7daczp8nCFxh73cKpKB0RUsd9xxrqYpovjmEAlo
Q6gyF548Ub48z7Dz83+WwLgbX19ve4oZx+dqSdczP6ILHRQomtrzrkkP2LU52oI5 w7aHpdzt64NQcsrbK10OSVDF3gFa9Vz20/NQvdUrp8jGmAb/8+nYqI94Jsc28H36
mxFo+ioe/ABCufSmyqFye0psX3Sp UeGsouhyuITLwEhScounZDqop+Dx
=WtqZ =Zg+6
-----END PGP PUBLIC KEY BLOCK----- -----END PGP PUBLIC KEY BLOCK-----
``` ```
</details> </details>

View file

@ -10,27 +10,6 @@
], ],
"allowCustomHomeservers": true, "allowCustomHomeservers": true,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [
"#cinny-space:matrix.org",
"#community:matrix.org",
"#space:envs.net",
"#science-space:matrix.org",
"#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org"
],
"rooms": [
"#cinny:matrix.org",
"#freesoftware:matrix.org",
"#pcapdroid:matrix.org",
"#gentoo:matrix.org",
"#PrivSec.dev:arcticfoxes.net",
"#disroot:aria-net.org"
],
"servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"]
},
"hashRouter": { "hashRouter": {
"enabled": false, "enabled": false,
"basename": "/" "basename": "/"

View file

@ -19,17 +19,9 @@ server {
location / { location / {
root /opt/cinny/dist/; root /opt/cinny/dist/;
index index.html;
rewrite ^/config.json$ /config.json break; }
rewrite ^/manifest.json$ /manifest.json break; location ~* ^\/(login|register) {
try_files $uri $uri/ /index.html;
rewrite ^.*/olm.wasm$ /olm.wasm break;
rewrite ^/sw.js$ /sw.js break;
rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
rewrite ^/public/(.*)$ /public/$1 break;
rewrite ^/assets/(.*)$ /assets/$1 break;
rewrite ^(.+)$ /index.html break;
} }
} }

View file

@ -1,20 +0,0 @@
server {
listen 80;
listen [::]:80;
location / {
root /usr/share/nginx/html;
rewrite ^/config.json$ /config.json break;
rewrite ^/manifest.json$ /manifest.json break;
rewrite ^.*/olm.wasm$ /olm.wasm break;
rewrite ^/sw.js$ /sw.js break;
rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
rewrite ^/public/(.*)$ /public/$1 break;
rewrite ^/assets/(.*)$ /assets/$1 break;
rewrite ^(.+)$ /index.html break;
}
}

View file

@ -90,6 +90,12 @@
window.global ||= window; window.global ||= window;
</script> </script>
<div id="root"></div> <div id="root"></div>
<audio id="notificationSound">
<source src="./public/sound/notification.ogg" type="audio/ogg" />
</audio>
<audio id="inviteSound">
<source src="./public/sound/invite.ogg" type="audio/ogg" />
</audio>
<script type="module" src="./src/index.tsx"></script> <script type="module" src="./src/index.tsx"></script>
</body> </body>
</html> </html>

View file

@ -7,17 +7,11 @@
from = "/manifest.json" from = "/manifest.json"
to = "/manifest.json" to = "/manifest.json"
status = 200 status = 200
[[redirects]] [[redirects]]
from = "/sw.js" from = "/olm.wasm"
to = "/sw.js"
status = 200
[[redirects]]
from = "*/olm.wasm"
to = "/olm.wasm" to = "/olm.wasm"
status = 200 status = 200
force = true
[[redirects]] [[redirects]]
from = "/pdf.worker.min.js" from = "/pdf.worker.min.js"
@ -37,5 +31,4 @@
[[redirects]] [[redirects]]
from = "/*" from = "/*"
to = "/index.html" to = "/index.html"
status = 200 status = 200
force = true

6616
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "cinny", "name": "cinny",
"version": "4.2.3", "version": "3.2.0",
"description": "Yet another matrix client", "description": "Yet another matrix client",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@ -20,14 +20,10 @@
"author": "Ajay Bura", "author": "Ajay Bura",
"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-auto-scroll": "1.3.0",
"@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", "@khanacademy/simple-markdown": "0.8.6",
"@tanstack/react-query": "5.24.1", "@matrix-org/olm": "3.2.14",
"@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.0.0-beta.54",
"@tanstack/react-virtual": "3.2.0",
"@tippyjs/react": "4.2.6", "@tippyjs/react": "4.2.6",
"@vanilla-extract/css": "1.9.3", "@vanilla-extract/css": "1.9.3",
"@vanilla-extract/recipes": "0.3.0", "@vanilla-extract/recipes": "0.3.0",
@ -44,19 +40,18 @@
"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.0.0", "folds": "1.5.1",
"formik": "2.4.6", "formik": "2.2.9",
"html-dom-parser": "4.0.0", "html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0", "html-react-parser": "4.2.0",
"i18next": "23.12.2",
"i18next-browser-languagedetector": "8.0.0",
"i18next-http-backend": "2.5.2",
"immer": "9.0.16", "immer": "9.0.16",
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"jotai": "2.6.0", "jotai": "2.6.0",
"linkify-react": "4.1.3", "katex": "0.16.10",
"linkifyjs": "4.1.3", "linkify-html": "4.0.2",
"matrix-js-sdk": "34.11.1", "linkify-react": "4.1.1",
"linkifyjs": "4.0.2",
"matrix-js-sdk": "29.1.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.29.0",
@ -65,10 +60,11 @@
"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-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-error-boundary": "4.0.13", "react-error-boundary": "4.0.10",
"react-google-recaptcha": "2.1.0", "react-google-recaptcha": "2.1.0",
"react-i18next": "15.0.0",
"react-modal": "3.16.1", "react-modal": "3.16.1",
"react-range": "1.8.14", "react-range": "1.8.14",
"react-router-dom": "6.20.0", "react-router-dom": "6.20.0",
@ -106,7 +102,6 @@
"sass": "1.56.2", "sass": "1.56.2",
"typescript": "4.9.4", "typescript": "4.9.4",
"vite": "5.0.13", "vite": "5.0.13",
"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.1" "vite-plugin-top-level-await": "1.4.1"
} }

View file

@ -1,7 +0,0 @@
{
"Organisms": {
"RoomCommon": {
"changed_room_name": " hat den Raum Name geändert"
}
}
}

View file

@ -1,7 +0,0 @@
{
"Organisms": {
"RoomCommon": {
"changed_room_name": " changed room name"
}
}
}

View file

@ -2,13 +2,17 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './Avatar.scss'; import './Avatar.scss';
import { twemojify } from '../../../util/twemojify';
import Text from '../text/Text'; import Text from '../text/Text';
import RawIcon from '../system-icons/RawIcon'; import RawIcon from '../system-icons/RawIcon';
import ImageBrokenSVG from '../../../../public/res/svg/image-broken.svg'; import ImageBrokenSVG from '../../../../public/res/svg/image-broken.svg';
import { avatarInitials } from '../../../util/common'; import { avatarInitials } from '../../../util/common';
const Avatar = React.forwardRef(({ text, bgColor, iconSrc, iconColor, imageSrc, size }, ref) => { const Avatar = React.forwardRef(({
text, bgColor, iconSrc, iconColor, imageSrc, size,
}, ref) => {
let textSize = 's1'; let textSize = 's1';
if (size === 'large') textSize = 'h1'; if (size === 'large') textSize = 'h1';
if (size === 'small') textSize = 'b1'; if (size === 'small') textSize = 'b1';
@ -16,34 +20,34 @@ const Avatar = React.forwardRef(({ text, bgColor, iconSrc, iconColor, imageSrc,
return ( return (
<div ref={ref} className={`avatar-container avatar-container__${size} noselect`}> <div ref={ref} className={`avatar-container avatar-container__${size} noselect`}>
{imageSrc !== null ? ( {
<img imageSrc !== null
draggable="false" ? (
src={imageSrc} <img
onLoad={(e) => { draggable="false"
e.target.style.backgroundColor = 'transparent'; src={imageSrc}
}} onLoad={(e) => { e.target.style.backgroundColor = 'transparent'; }}
onError={(e) => { onError={(e) => { e.target.src = ImageBrokenSVG; }}
e.target.src = ImageBrokenSVG; alt=""
}} />
alt="" )
/> : (
) : ( <span
<span style={{ backgroundColor: iconSrc === null ? bgColor : 'transparent' }}
style={{ backgroundColor: iconSrc === null ? bgColor : 'transparent' }} className={`avatar__border${iconSrc !== null ? '--active' : ''}`}
className={`avatar__border${iconSrc !== null ? '--active' : ''}`} >
> {
{iconSrc !== null ? ( iconSrc !== null
<RawIcon size={size} src={iconSrc} color={iconColor} /> ? <RawIcon size={size} src={iconSrc} color={iconColor} />
) : ( : text !== null && (
text !== null && ( <Text variant={textSize} primary>
<Text variant={textSize} primary> {twemojify(avatarInitials(text))}
{avatarInitials(text)} </Text>
</Text> )
) }
)} </span>
</span> )
)} }
</div> </div>
); );
}); });

View file

@ -22,7 +22,8 @@
height: 16px; height: 16px;
background-color: var(--tc-surface-low); background-color: var(--tc-surface-low);
border-radius: calc(var(--bo-radius) / 2); border-radius: calc(var(--bo-radius) / 2);
transition: transform 200ms ease-in-out, opacity 200ms ease-in-out; transition: transform 200ms ease-in-out,
opacity 200ms ease-in-out;
opacity: 0.6; opacity: 0.6;
} }
@ -35,8 +36,8 @@
@include dir.prop(transform, var(--ltr), var(--rtl)); @include dir.prop(transform, var(--ltr), var(--rtl));
transform: translateX(calc(125%)); transform: translateX(calc(125%));
background-color: var(--bg-surface); background-color: white;
opacity: 1; opacity: 1;
} }
} }
} }

View file

@ -1,28 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import './Divider.scss';
import Text from '../text/Text';
function Divider({ text, variant, align }) {
const dividerClass = ` divider--${variant} divider--${align}`;
return (
<div className={`divider${dividerClass}`}>
{text !== null && <Text className="divider__text" variant="b3" weight="bold">{text}</Text>}
</div>
);
}
Divider.defaultProps = {
text: null,
variant: 'surface',
align: 'center',
};
Divider.propTypes = {
text: PropTypes.string,
variant: PropTypes.oneOf(['surface', 'primary', 'positive', 'caution', 'danger']),
align: PropTypes.oneOf(['left', 'center', 'right']),
};
export default Divider;

View file

@ -0,0 +1,3 @@
.katex-display {
margin: 0 !important;
}

View file

@ -1,86 +0,0 @@
import { ReactNode, useCallback } from 'react';
import { matchPath, useLocation, useNavigate } from 'react-router-dom';
import {
getDirectPath,
getExplorePath,
getHomePath,
getInboxPath,
getSpacePath,
} from '../pages/pathUtils';
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from '../pages/paths';
type BackRouteHandlerProps = {
children: (onBack: () => void) => ReactNode;
};
export function BackRouteHandler({ children }: BackRouteHandlerProps) {
const navigate = useNavigate();
const location = useLocation();
const goBack = useCallback(() => {
if (
matchPath(
{
path: HOME_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getHomePath());
return;
}
if (
matchPath(
{
path: DIRECT_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getDirectPath());
return;
}
const spaceMatch = matchPath(
{
path: SPACE_PATH,
caseSensitive: true,
end: false,
},
location.pathname
);
if (spaceMatch?.params.spaceIdOrAlias) {
navigate(getSpacePath(spaceMatch.params.spaceIdOrAlias));
return;
}
if (
matchPath(
{
path: EXPLORE_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getExplorePath());
return;
}
if (
matchPath(
{
path: INBOX_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getInboxPath());
}
}, [navigate, location]);
return children(goBack);
}

View file

@ -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(true), 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);
}

View file

@ -1,19 +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';
type CapabilitiesLoaderProps = {
children: (capabilities: Capabilities | undefined) => ReactNode;
};
export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) {
const mx = useMatrixClient();
const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(true), [mx]));
useEffect(() => {
load();
}, [load]);
return children(state.status === AsyncStatus.Success ? state.data : undefined);
}

View file

@ -1,19 +0,0 @@
import { ReactNode, useCallback, useEffect } from 'react';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { MediaConfig } from '../hooks/useMediaConfig';
type MediaConfigLoaderProps = {
children: (mediaConfig: MediaConfig | undefined) => ReactNode;
};
export function MediaConfigLoader({ children }: MediaConfigLoaderProps) {
const mx = useMatrixClient();
const [state, load] = useAsyncCallback(useCallback(() => mx.getMediaConfig(), [mx]));
useEffect(() => {
load();
}, [load]);
return children(state.status === AsyncStatus.Success ? state.data : undefined);
}

View file

@ -1,6 +1,6 @@
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React, { FormEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react'; import React, { FormEventHandler, useEffect, useRef, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { import {
Box, Box,
@ -13,7 +13,6 @@ import {
Input, Input,
Menu, Menu,
PopOut, PopOut,
RectCords,
Scroll, Scroll,
Spinner, Spinner,
Text, Text,
@ -26,7 +25,6 @@ import * as css from './PdfViewer.css';
import { AsyncStatus } from '../../hooks/useAsyncCallback'; import { AsyncStatus } from '../../hooks/useAsyncCallback';
import { useZoom } from '../../hooks/useZoom'; import { useZoom } from '../../hooks/useZoom';
import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist'; import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist';
import { stopPropagation } from '../../utils/keyboard';
export type PdfViewerProps = { export type PdfViewerProps = {
name: string; name: string;
@ -50,7 +48,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
const isError = const isError =
pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error; pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error;
const [pageNo, setPageNo] = useState(1); const [pageNo, setPageNo] = useState(1);
const [jumpAnchor, setJumpAnchor] = useState<RectCords>(); const [openJump, setOpenJump] = useState(false);
useEffect(() => { useEffect(() => {
loadPdfJS(); loadPdfJS();
@ -88,7 +86,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
if (!jumpInput) return; if (!jumpInput) return;
const jumpTo = parseInt(jumpInput.value, 10); const jumpTo = parseInt(jumpInput.value, 10);
setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo))); setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo)));
setJumpAnchor(undefined); setOpenJump(false);
}; };
const handlePrevPage = () => { const handlePrevPage = () => {
@ -100,10 +98,6 @@ export const PdfViewer = as<'div', PdfViewerProps>(
setPageNo((n) => Math.min(n + 1, docState.data.numPages)); setPageNo((n) => Math.min(n + 1, docState.data.numPages));
}; };
const handleOpenJump: MouseEventHandler<HTMLButtonElement> = (evt) => {
setJumpAnchor(evt.currentTarget.getBoundingClientRect());
};
return ( return (
<Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}> <Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
<Header className={css.PdfViewerHeader} size="400"> <Header className={css.PdfViewerHeader} size="400">
@ -193,16 +187,15 @@ export const PdfViewer = as<'div', PdfViewerProps>(
</Chip> </Chip>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200"> <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
<PopOut <PopOut
anchor={jumpAnchor} open={openJump}
align="Center" align="Center"
position="Top" position="Top"
content={ content={
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
onDeactivate: () => setJumpAnchor(undefined), onDeactivate: () => setOpenJump(false),
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}} }}
> >
<Menu variant="Surface"> <Menu variant="Surface">
@ -234,14 +227,17 @@ export const PdfViewer = as<'div', PdfViewerProps>(
</FocusTrap> </FocusTrap>
} }
> >
<Chip {(anchorRef) => (
onClick={handleOpenJump} <Chip
variant="SurfaceVariant" onClick={() => setOpenJump(!openJump)}
radii="300" ref={anchorRef}
aria-pressed={jumpAnchor !== undefined} variant="SurfaceVariant"
> radii="300"
<Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text> aria-pressed={openJump}
</Chip> >
<Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
</Chip>
)}
</PopOut> </PopOut>
</Box> </Box>
<Chip <Chip

View file

@ -1,234 +0,0 @@
import React from 'react';
import { MsgType } from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import {
AudioContent,
DownloadFile,
FileContent,
ImageContent,
MAudio,
MBadEncrypted,
MEmote,
MFile,
MImage,
MLocation,
MNotice,
MText,
MVideo,
ReadPdfFile,
ReadTextFile,
RenderBody,
ThumbnailContent,
UnsupportedContent,
VideoContent,
} from './message';
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
import { Image, MediaControl, Video } from './media';
import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer';
import { testMatrixTo } from '../plugins/matrix-to';
type RenderMessageContentProps = {
displayName: string;
msgType: string;
ts: number;
edited?: boolean;
getContent: <T>() => T;
mediaAutoLoad?: boolean;
urlPreview?: boolean;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
outlineAttachment?: boolean;
};
export function RenderMessageContent({
displayName,
msgType,
ts,
edited,
getContent,
mediaAutoLoad,
urlPreview,
highlightRegex,
htmlReactParserOptions,
linkifyOpts,
outlineAttachment,
}: RenderMessageContentProps) {
const renderUrlsPreview = (urls: string[]) => {
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
if (filteredUrls.length === 0) return undefined;
return (
<UrlPreviewHolder>
{filteredUrls.map((url) => (
<UrlPreviewCard key={url} url={url} ts={ts} />
))}
</UrlPreviewHolder>
);
};
const renderFile = () => (
<MFile
content={getContent()}
renderFileContent={({ body, mimeType, info, encInfo, url }) => (
<FileContent
body={body}
mimeType={mimeType}
renderAsPdfFile={() => (
<ReadPdfFile
body={body}
mimeType={mimeType}
url={url}
encInfo={encInfo}
renderViewer={(p) => <PdfViewer {...p} />}
/>
)}
renderAsTextFile={() => (
<ReadTextFile
body={body}
mimeType={mimeType}
url={url}
encInfo={encInfo}
renderViewer={(p) => <TextViewer {...p} />}
/>
)}
>
<DownloadFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} info={info} />
</FileContent>
)}
outlined={outlineAttachment}
/>
);
if (msgType === MsgType.Text) {
return (
<MText
edited={edited}
content={getContent()}
renderBody={(props) => (
<RenderBody
{...props}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/>
);
}
if (msgType === MsgType.Emote) {
return (
<MEmote
displayName={displayName}
edited={edited}
content={getContent()}
renderBody={(props) => (
<RenderBody
{...props}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/>
);
}
if (msgType === MsgType.Notice) {
return (
<MNotice
edited={edited}
content={getContent()}
renderBody={(props) => (
<RenderBody
{...props}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/>
);
}
if (msgType === MsgType.Image) {
return (
<MImage
content={getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
outlined={outlineAttachment}
/>
);
}
if (msgType === MsgType.Video) {
return (
<MVideo
content={getContent()}
renderAsFile={renderFile}
renderVideoContent={({ body, info, mimeType, url, encInfo }) => (
<VideoContent
body={body}
info={info}
mimeType={mimeType}
url={url}
encInfo={encInfo}
renderThumbnail={
mediaAutoLoad
? () => (
<ThumbnailContent
info={info}
renderImage={(src) => (
<Image alt={body} title={body} src={src} loading="lazy" />
)}
/>
)
: undefined
}
renderVideo={(p) => <Video {...p} />}
/>
)}
outlined={outlineAttachment}
/>
);
}
if (msgType === MsgType.Audio) {
return (
<MAudio
content={getContent()}
renderAsFile={renderFile}
renderAudioContent={(props) => (
<AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
)}
outlined={outlineAttachment}
/>
);
}
if (msgType === MsgType.File) {
return renderFile();
}
if (msgType === MsgType.Location) {
return <MLocation content={getContent()} />;
}
if (msgType === 'm.bad.encrypted') {
return <MBadEncrypted />;
}
return <UnsupportedContent />;
}

View file

@ -1,90 +0,0 @@
import { ReactNode, useCallback, useState } from 'react';
import { MatrixClient, Room } from 'matrix-js-sdk';
import { useQuery } from '@tanstack/react-query';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { LocalRoomSummary, useLocalRoomSummary } from '../hooks/useLocalRoomSummary';
import { AsyncState, AsyncStatus } from '../hooks/useAsyncCallback';
export type IRoomSummary = Awaited<ReturnType<MatrixClient['getRoomSummary']>>;
type RoomSummaryLoaderProps = {
roomIdOrAlias: string;
children: (roomSummary?: IRoomSummary) => ReactNode;
};
export function RoomSummaryLoader({ roomIdOrAlias, children }: RoomSummaryLoaderProps) {
const mx = useMatrixClient();
const fetchSummary = useCallback(() => mx.getRoomSummary(roomIdOrAlias), [mx, roomIdOrAlias]);
const { data } = useQuery({
queryKey: [roomIdOrAlias, `summary`],
queryFn: fetchSummary,
});
return children(data);
}
export function LocalRoomSummaryLoader({
room,
children,
}: {
room: Room;
children: (roomSummary: LocalRoomSummary) => ReactNode;
}) {
const summary = useLocalRoomSummary(room);
return children(summary);
}
export function HierarchyRoomSummaryLoader({
roomId,
children,
}: {
roomId: string;
children: (state: AsyncState<IHierarchyRoom, Error>) => ReactNode;
}) {
const mx = useMatrixClient();
const fetchSummary = useCallback(() => mx.getRoomHierarchy(roomId, 1, 1), [mx, roomId]);
const [errorMemo, setError] = useState<Error>();
const { data, error } = useQuery({
queryKey: [roomId, `hierarchy`],
queryFn: fetchSummary,
retryOnMount: false,
refetchOnWindowFocus: false,
retry: (failureCount, err) => {
setError(err);
if (failureCount > 3) return false;
return true;
},
});
let state: AsyncState<IHierarchyRoom, Error> = {
status: AsyncStatus.Loading,
};
if (error) {
state = {
status: AsyncStatus.Error,
error,
};
}
if (errorMemo) {
state = {
status: AsyncStatus.Error,
error: errorMemo,
};
}
const summary = data?.rooms[0] ?? undefined;
if (summary) {
state = {
status: AsyncStatus.Success,
data: summary,
};
}
return children(state);
}

View file

@ -1,24 +0,0 @@
import { ReactElement } from 'react';
import { Unread } from '../../types/matrix/room';
import { useRoomUnread, useRoomsUnread } from '../state/hooks/unread';
import { roomToUnreadAtom } from '../state/room/roomToUnread';
type RoomUnreadProviderProps = {
roomId: string;
children: (unread?: Unread) => ReactElement;
};
export function RoomUnreadProvider({ roomId, children }: RoomUnreadProviderProps) {
const unread = useRoomUnread(roomId, roomToUnreadAtom);
return children(unread);
}
type RoomsUnreadProviderProps = {
rooms: string[];
children: (unread?: Unread) => ReactElement;
};
export function RoomsUnreadProvider({ rooms, children }: RoomsUnreadProviderProps) {
const unread = useRoomsUnread(rooms, roomToUnreadAtom);
return children(unread);
}

View file

@ -1,28 +0,0 @@
import { ReactNode } from 'react';
import { RoomToParents } from '../../types/matrix/room';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { allRoomsAtom } from '../state/room-list/roomList';
import { useChildDirectScopeFactory, useSpaceChildren } from '../state/hooks/roomList';
type SpaceChildDirectsProviderProps = {
spaceId: string;
mDirects: Set<string>;
roomToParents: RoomToParents;
children: (rooms: string[]) => ReactNode;
};
export function SpaceChildDirectsProvider({
spaceId,
roomToParents,
mDirects,
children,
}: SpaceChildDirectsProviderProps) {
const mx = useMatrixClient();
const childDirects = useSpaceChildren(
allRoomsAtom,
spaceId,
useChildDirectScopeFactory(mx, mDirects, roomToParents)
);
return children(childDirects);
}

View file

@ -1,28 +0,0 @@
import { ReactNode } from 'react';
import { RoomToParents } from '../../types/matrix/room';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { allRoomsAtom } from '../state/room-list/roomList';
import { useChildRoomScopeFactory, useSpaceChildren } from '../state/hooks/roomList';
type SpaceChildRoomsProviderProps = {
spaceId: string;
mDirects: Set<string>;
roomToParents: RoomToParents;
children: (rooms: string[]) => ReactNode;
};
export function SpaceChildRoomsProvider({
spaceId,
roomToParents,
mDirects,
children,
}: SpaceChildRoomsProviderProps) {
const mx = useMatrixClient();
const childRooms = useSpaceChildren(
allRoomsAtom,
spaceId,
useChildRoomScopeFactory(mx, mDirects, roomToParents)
);
return children(childRooms);
}

View file

@ -1,25 +1,20 @@
import { ReactNode, useCallback, useEffect, useState } from 'react'; import { ReactNode, useCallback, useEffect } from 'react';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { SpecVersions, specVersions } from '../cs-api'; import { SpecVersions, specVersions } from '../cs-api';
import { useAutoDiscoveryInfo } from '../hooks/useAutoDiscoveryInfo';
type SpecVersionsLoaderProps = { type SpecVersionsLoaderProps = {
baseUrl: string;
fallback?: () => ReactNode; fallback?: () => ReactNode;
error?: (err: unknown, retry: () => void, ignore: () => void) => ReactNode; error?: (err: unknown) => ReactNode;
children: (versions: SpecVersions) => ReactNode; children: (versions: SpecVersions) => ReactNode;
}; };
export function SpecVersionsLoader({ export function SpecVersionsLoader({ fallback, error, children }: SpecVersionsLoaderProps) {
baseUrl, const autoDiscoveryInfo = useAutoDiscoveryInfo();
fallback, const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url;
error,
children,
}: SpecVersionsLoaderProps) {
const [state, load] = useAsyncCallback( const [state, load] = useAsyncCallback(
useCallback(() => specVersions(fetch, baseUrl), [baseUrl]) useCallback(() => specVersions(fetch, baseUrl), [baseUrl])
); );
const [ignoreError, setIgnoreError] = useState(false);
const ignoreCallback = useCallback(() => setIgnoreError(true), []);
useEffect(() => { useEffect(() => {
load(); load();
@ -29,15 +24,9 @@ export function SpecVersionsLoader({
return fallback?.(); return fallback?.();
} }
if (!ignoreError && state.status === AsyncStatus.Error) { if (state.status === AsyncStatus.Error) {
return error?.(state.error, load, ignoreCallback); return error?.(state.error);
} }
return children( return children(state.data);
state.status === AsyncStatus.Success
? state.data
: {
versions: [],
}
);
} }

View file

@ -13,7 +13,6 @@ import {
IconButton, IconButton,
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard';
export type UIAFlowOverlayProps = { export type UIAFlowOverlayProps = {
currentStep: number; currentStep: number;
@ -29,7 +28,7 @@ export function UIAFlowOverlay({
}: UIAFlowOverlayProps) { }: UIAFlowOverlayProps) {
return ( return (
<Overlay open backdrop={<OverlayBackdrop />}> <Overlay open backdrop={<OverlayBackdrop />}>
<FocusTrap focusTrapOptions={{ initialFocus: false, escapeDeactivates: stopPropagation }}> <FocusTrap focusTrapOptions={{ initialFocus: false }}>
<Box style={{ height: '100%' }} direction="Column" grow="Yes" gap="400"> <Box style={{ height: '100%' }} direction="Column" grow="Yes" gap="400">
<Box grow="Yes" direction="Column" alignItems="Center" justifyContent="Center"> <Box grow="Yes" direction="Column" alignItems="Center" justifyContent="Center">
{children} {children}

View file

@ -1,84 +0,0 @@
import React, { useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
config,
Icon,
IconButton,
Icons,
Line,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
} from 'folds';
import { CustomEditor, useEditor } from './Editor';
import { Toolbar } from './Toolbar';
import { stopPropagation } from '../../utils/keyboard';
export function EditorPreview() {
const [open, setOpen] = useState(false);
const editor = useEditor();
const [toolbar, setToolbar] = useState(false);
return (
<>
<IconButton variant="SurfaceVariant" onClick={() => setOpen(!open)}>
<Icon src={Icons.BlockQuote} />
</IconButton>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setOpen(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500">
<div style={{ padding: config.space.S400 }}>
<CustomEditor
editor={editor}
placeholder="Send a message..."
before={
<IconButton variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.PlusCircle} />
</IconButton>
}
after={
<>
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
onClick={() => setToolbar(!toolbar)}
aria-pressed={toolbar}
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
<IconButton variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.Smile} />
</IconButton>
<IconButton variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.Send} />
</IconButton>
</>
}
bottom={
toolbar && (
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
</div>
)
}
/>
</div>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
</>
);
}

View file

@ -14,8 +14,6 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getEmojiUrl, isUsingTwemoji } from '../../plugins/emoji'; import { getEmojiUrl, isUsingTwemoji } from '../../plugins/emoji';
import { getBeginCommand } from './utils'; import { getBeginCommand } from './utils';
import { BlockType } from './types'; import { BlockType } from './types';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
// Put this at the start and end of an inline component to work around this Chromium bug: // Put this at the start and end of an inline component to work around this Chromium bug:
// https://bugs.chromium.org/p/chromium/issues/detail?id=1249405 // https://bugs.chromium.org/p/chromium/issues/detail?id=1249405
@ -79,7 +77,6 @@ function RenderEmoticonElement({
children, children,
}: { element: EmoticonElement } & RenderElementProps) { }: { element: EmoticonElement } & RenderElementProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const selected = useSelected(); const selected = useSelected();
const focused = useFocused(); const focused = useFocused();
@ -94,7 +91,7 @@ function RenderEmoticonElement({
{element.key.startsWith('mxc://') ? ( {element.key.startsWith('mxc://') ? (
<img <img
className={css.EmoticonImg} className={css.EmoticonImg}
src={mxcUrlToHttp(mx, element.key, useAuthentication) ?? element.key} src={mx.mxcUrlToHttp(element.key) ?? element.key}
alt={element.shortcode} alt={element.shortcode}
/> />
) : ( ) : (

View file

@ -10,14 +10,13 @@ import {
Line, Line,
Menu, Menu,
PopOut, PopOut,
RectCords,
Scroll, Scroll,
Text, Text,
Tooltip, Tooltip,
TooltipProvider, TooltipProvider,
toRem, toRem,
} from 'folds'; } from 'folds';
import React, { MouseEventHandler, ReactNode, useState } from 'react'; import React, { ReactNode, useState } from 'react';
import { ReactEditor, useSlate } from 'slate-react'; import { ReactEditor, useSlate } from 'slate-react';
import { import {
headingLevel, headingLevel,
@ -35,7 +34,6 @@ import { isMacOS } from '../../utils/user-agent';
import { KeySymbol } from '../../utils/key-symbol'; import { KeySymbol } from '../../utils/key-symbol';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { stopPropagation } from '../../utils/keyboard';
function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) { function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) {
return ( return (
@ -121,38 +119,30 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
export function HeadingBlockButton() { export function HeadingBlockButton() {
const editor = useSlate(); const editor = useSlate();
const level = headingLevel(editor); const level = headingLevel(editor);
const [anchor, setAnchor] = useState<RectCords>(); const [open, setOpen] = useState(false);
const isActive = isBlockActive(editor, BlockType.Heading); const isActive = isBlockActive(editor, BlockType.Heading);
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl'; const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
const handleMenuSelect = (selectedLevel: HeadingLevel) => { const handleMenuSelect = (selectedLevel: HeadingLevel) => {
setAnchor(undefined); setOpen(false);
toggleBlock(editor, BlockType.Heading, { level: selectedLevel }); toggleBlock(editor, BlockType.Heading, { level: selectedLevel });
ReactEditor.focus(editor); ReactEditor.focus(editor);
}; };
const handleMenuOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
if (isActive) {
toggleBlock(editor, BlockType.Heading);
return;
}
setAnchor(evt.currentTarget.getBoundingClientRect());
};
return ( return (
<PopOut <PopOut
anchor={anchor} open={open}
offset={5} offset={5}
position="Top" position="Top"
content={ content={
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
onDeactivate: () => setAnchor(undefined), onDeactivate: () => setOpen(false),
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight', evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}} }}
> >
<Menu style={{ padding: config.space.S100 }}> <Menu style={{ padding: config.space.S100 }}>
@ -207,17 +197,20 @@ export function HeadingBlockButton() {
</FocusTrap> </FocusTrap>
} }
> >
<IconButton {(ref) => (
style={{ width: 'unset' }} <IconButton
variant="SurfaceVariant" style={{ width: 'unset' }}
onClick={handleMenuOpen} ref={ref}
aria-pressed={isActive} variant="SurfaceVariant"
size="400" onClick={() => (isActive ? toggleBlock(editor, BlockType.Heading) : setOpen(!open))}
radii="300" aria-pressed={isActive}
> size="400"
<Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} /> radii="300"
<Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} /> >
</IconButton> <Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
<Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
</IconButton>
)}
</PopOut> </PopOut>
); );
} }

View file

@ -4,7 +4,7 @@ import { isKeyHotkey } from 'is-hotkey';
import { Header, Menu, Scroll, config } from 'folds'; 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 } from '../../../utils/keyboard';
type AutocompleteMenuProps = { type AutocompleteMenuProps = {
requestClose: () => void; requestClose: () => void;
@ -24,7 +24,6 @@ export function AutocompleteMenu({ headerContent, requestClose, children }: Auto
allowOutsideClick: true, allowOutsideClick: true,
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt), isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
escapeDeactivates: stopPropagation,
}} }}
> >
<Menu className={css.AutocompleteMenu}> <Menu className={css.AutocompleteMenu}>

View file

@ -18,8 +18,6 @@ import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
import { IEmoji, emojis } from '../../../plugins/emoji'; import { IEmoji, emojis } from '../../../plugins/emoji';
import { ExtendedPackImage, PackUsage } from '../../../plugins/custom-emoji'; import { ExtendedPackImage, PackUsage } from '../../../plugins/custom-emoji';
import { useKeyDown } from '../../../hooks/useKeyDown'; import { useKeyDown } from '../../../hooks/useKeyDown';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
type EmoticonCompleteHandler = (key: string, shortcode: string) => void; type EmoticonCompleteHandler = (key: string, shortcode: string) => void;
@ -50,7 +48,6 @@ export function EmoticonAutocomplete({
requestClose, requestClose,
}: EmoticonAutocompleteProps) { }: EmoticonAutocompleteProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const imagePacks = useRelevantImagePacks(mx, PackUsage.Emoticon, imagePackRooms); const imagePacks = useRelevantImagePacks(mx, PackUsage.Emoticon, imagePackRooms);
const recentEmoji = useRecentEmoji(mx, 20); const recentEmoji = useRecentEmoji(mx, 20);
@ -106,7 +103,7 @@ export function EmoticonAutocomplete({
<Box <Box
shrink="No" shrink="No"
as="img" as="img"
src={mxcUrlToHttp(mx, key, useAuthentication) || key} src={mx.mxcUrlToHttp(key) || key}
alt={emoticon.shortcode} alt={emoticon.shortcode}
style={{ width: toRem(24), height: toRem(24), objectFit: 'contain' }} style={{ width: toRem(24), height: toRem(24), objectFit: 'contain' }}
/> />

View file

@ -1,11 +1,12 @@
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect } from 'react'; import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
import { Editor } from 'slate'; import { Editor } from 'slate';
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds'; import { Avatar, AvatarFallback, AvatarImage, Icon, Icons, MenuItem, Text, color } from 'folds';
import { JoinRule, MatrixClient } from 'matrix-js-sdk'; import { MatrixClient } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { createMentionElement, moveCursor, replaceWithElement } from '../utils'; import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
import { getDirectRoomAvatarUrl } from '../../../utils/room'; import { getRoomAvatarUrl, joinRuleToIconSrc } from '../../../utils/room';
import { roomIdByActivity } from '../../../../util/sort';
import initMatrix from '../../../../client/initMatrix';
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';
@ -13,11 +14,6 @@ import { getMxIdServer, validMxId } 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';
import { mDirectAtom } from '../../../state/mDirectList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { factoryRoomIdByActivity } from '../../../utils/sort';
import { RoomAvatar, RoomIcon } from '../../room-avatar';
import { getViaServers } from '../../../plugins/via-servers';
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void; type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
@ -78,12 +74,15 @@ export function RoomMentionAutocomplete({
requestClose, requestClose,
}: RoomMentionAutocompleteProps) { }: RoomMentionAutocompleteProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom); const dms: Set<string> = initMatrix.roomList?.directs ?? new Set();
const allRooms = useAtomValue(allRoomsAtom).sort(factoryRoomIdByActivity(mx)); const allRoomId: string[] = useMemo(() => {
const { spaces = [], rooms = [], directs = [] } = initMatrix.roomList ?? {};
return [...spaces, ...rooms, ...directs].sort(roomIdByActivity);
}, []);
const [result, search, resetSearch] = useAsyncSearch( const [result, search, resetSearch] = useAsyncSearch(
allRooms, allRoomId,
useCallback( useCallback(
(rId) => { (rId) => {
const r = mx.getRoom(rId); const r = mx.getRoom(rId);
@ -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 : allRoomId.slice(0, 20);
useEffect(() => { useEffect(() => {
if (query.text) search(query.text); if (query.text) search(query.text);
@ -105,14 +104,10 @@ export function RoomMentionAutocomplete({
}, [query.text, search, resetSearch]); }, [query.text, search, resetSearch]);
const handleAutocomplete: MentionAutoCompleteHandler = (roomAliasOrId, name) => { const handleAutocomplete: MentionAutoCompleteHandler = (roomAliasOrId, name) => {
const mentionRoom = mx.getRoom(roomAliasOrId);
const viaServers = mentionRoom ? getViaServers(mentionRoom) : undefined;
const mentionEl = createMentionElement( const mentionEl = createMentionElement(
roomAliasOrId, roomAliasOrId,
name.startsWith('#') ? name : `#${name}`, name.startsWith('#') ? name : `#${name}`,
roomId === roomAliasOrId || mx.getRoom(roomId)?.getCanonicalAlias() === roomAliasOrId, roomId === roomAliasOrId || mx.getRoom(roomId)?.getCanonicalAlias() === roomAliasOrId
undefined,
viaServers
); );
replaceWithElement(editor, query.range, mentionEl); replaceWithElement(editor, query.range, mentionEl);
moveCursor(editor, true); moveCursor(editor, true);
@ -141,7 +136,9 @@ export function RoomMentionAutocomplete({
autoCompleteRoomIds.map((rId) => { autoCompleteRoomIds.map((rId) => {
const room = mx.getRoom(rId); const room = mx.getRoom(rId);
if (!room) return null; if (!room) return null;
const dm = mDirects.has(room.roomId); const dm = dms.has(room.roomId);
const avatarUrl = getRoomAvatarUrl(mx, room);
const iconSrc = !dm && joinRuleToIconSrc(Icons, room.getJoinRule(), room.isSpaceRoom());
const handleSelect = () => handleAutocomplete(room.getCanonicalAlias() ?? rId, room.name); const handleSelect = () => handleAutocomplete(room.getCanonicalAlias() ?? rId, room.name);
@ -161,21 +158,17 @@ export function RoomMentionAutocomplete({
} }
before={ before={
<Avatar size="200"> <Avatar size="200">
{dm ? ( {iconSrc && <Icon src={iconSrc} size="100" />}
<RoomAvatar {avatarUrl && !iconSrc && <AvatarImage src={avatarUrl} alt={room.name} />}
roomId={room.roomId} {!avatarUrl && !iconSrc && (
src={getDirectRoomAvatarUrl(mx, room)} <AvatarFallback
alt={room.name} style={{
renderFallback={() => ( backgroundColor: color.Secondary.Container,
<RoomIcon color: color.Secondary.OnContainer,
size="50" }}
joinRule={room.getJoinRule() ?? JoinRule.Restricted} >
filled <Text size="H6">{room.name[0]}</Text>
/> </AvatarFallback>
)}
/>
) : (
<RoomIcon size="100" joinRule={room.getJoinRule()} space={room.isSpaceRoom()} />
)} )}
</Avatar> </Avatar>
} }

View file

@ -1,6 +1,6 @@
import React, { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react'; import React, { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react';
import { Editor } from 'slate'; import { Editor } from 'slate';
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds'; import { Avatar, AvatarFallback, AvatarImage, MenuItem, Text, color } from 'folds';
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk'; import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { AutocompleteQuery } from './autocompleteQuery'; import { AutocompleteQuery } from './autocompleteQuery';
@ -17,8 +17,6 @@ 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, validMxId } from '../../../utils/matrix';
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room'; import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
import { UserAvatar } from '../../user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
type MentionAutoCompleteHandler = (userId: string, name: string) => void; type MentionAutoCompleteHandler = (userId: string, name: string) => void;
@ -28,10 +26,12 @@ const userIdFromQueryText = (mx: MatrixClient, text: string) =>
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`; : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
function UnknownMentionItem({ function UnknownMentionItem({
query,
userId, userId,
name, name,
handleAutocomplete, handleAutocomplete,
}: { }: {
query: AutocompleteQuery<string>;
userId: string; userId: string;
name: string; name: string;
handleAutocomplete: MentionAutoCompleteHandler; handleAutocomplete: MentionAutoCompleteHandler;
@ -46,10 +46,14 @@ function UnknownMentionItem({
onClick={() => handleAutocomplete(userId, name)} onClick={() => handleAutocomplete(userId, name)}
before={ before={
<Avatar size="200"> <Avatar size="200">
<UserAvatar <AvatarFallback
userId={userId} style={{
renderFallback={() => <Icon size="50" src={Icons.User} filled />} backgroundColor: color.Secondary.Container,
/> color: color.Secondary.OnContainer,
}}
>
<Text size="H6">{query.text[0]}</Text>
</AvatarFallback>
</Avatar> </Avatar>
} }
> >
@ -85,7 +89,6 @@ export function UserMentionAutocomplete({
requestClose, requestClose,
}: UserMentionAutocompleteProps) { }: UserMentionAutocompleteProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const roomId: string = room.roomId!; const roomId: string = room.roomId!;
const roomAliasOrId = room.getCanonicalAlias() || roomId; const roomAliasOrId = room.getCanonicalAlias() || roomId;
const members = useRoomMembers(mx, roomId); const members = useRoomMembers(mx, roomId);
@ -132,6 +135,7 @@ export function UserMentionAutocomplete({
<AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}> <AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}>
{query.text === 'room' && ( {query.text === 'room' && (
<UnknownMentionItem <UnknownMentionItem
query={query}
userId={roomAliasOrId} userId={roomAliasOrId}
name="@room" name="@room"
handleAutocomplete={handleAutocomplete} handleAutocomplete={handleAutocomplete}
@ -139,16 +143,14 @@ export function UserMentionAutocomplete({
)} )}
{autoCompleteMembers.length === 0 ? ( {autoCompleteMembers.length === 0 ? (
<UnknownMentionItem <UnknownMentionItem
query={query}
userId={userIdFromQueryText(mx, query.text)} userId={userIdFromQueryText(mx, query.text)}
name={userIdFromQueryText(mx, query.text)} name={userIdFromQueryText(mx, query.text)}
handleAutocomplete={handleAutocomplete} handleAutocomplete={handleAutocomplete}
/> />
) : ( ) : (
autoCompleteMembers.map((roomMember) => { autoCompleteMembers.map((roomMember) => {
const avatarMxcUrl = roomMember.getMxcAvatarUrl(); const avatarUrl = roomMember.getAvatarUrl(mx.baseUrl, 32, 32, 'crop', undefined, false);
const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication)
: undefined;
return ( return (
<MenuItem <MenuItem
key={roomMember.userId} key={roomMember.userId}
@ -165,12 +167,18 @@ export function UserMentionAutocomplete({
} }
before={ before={
<Avatar size="200"> <Avatar size="200">
<UserAvatar {avatarUrl ? (
userId={roomMember.userId} <AvatarImage src={avatarUrl} alt={getName(roomMember)} />
src={avatarUrl ?? undefined} ) : (
alt={getName(roomMember)} <AvatarFallback
renderFallback={() => <Icon size="50" src={Icons.User} filled />} style={{
/> backgroundColor: color.Secondary.Container,
color: color.Secondary.OnContainer,
}}
>
<Text size="H6">{getName(roomMember)[0]}</Text>
</AvatarFallback>
)}
</Avatar> </Avatar>
} }
> >

View file

@ -18,14 +18,8 @@ import {
ParagraphElement, ParagraphElement,
UnorderedListElement, UnorderedListElement,
} from './slate'; } from './slate';
import { parseMatrixToUrl } from '../../utils/matrix';
import { createEmoticonElement, createMentionElement } from './utils'; import { createEmoticonElement, createMentionElement } from './utils';
import {
parseMatrixToRoom,
parseMatrixToRoomEvent,
parseMatrixToUser,
testMatrixTo,
} from '../../plugins/matrix-to';
import { tryDecodeURIComponent } from '../../utils/dom';
const markNodeToType: Record<string, MarkType> = { const markNodeToType: Record<string, MarkType> = {
b: MarkType.Bold, b: MarkType.Bold,
@ -74,33 +68,11 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
return createEmoticonElement(src, alt || 'Unknown Emoji'); return createEmoticonElement(src, alt || 'Unknown Emoji');
} }
if (node.name === 'a') { if (node.name === 'a') {
const href = tryDecodeURIComponent(node.attribs.href); const { href } = node.attribs;
if (typeof href !== 'string') return undefined; if (typeof href !== 'string') return undefined;
if (testMatrixTo(href)) { const [mxId] = parseMatrixToUrl(href);
const userMention = parseMatrixToUser(href); if (mxId) {
if (userMention) { return createMentionElement(mxId, parseNodeText(node) || mxId, false);
return createMentionElement(userMention, parseNodeText(node) || userMention, false);
}
const roomMention = parseMatrixToRoom(href);
if (roomMention) {
return createMentionElement(
roomMention.roomIdOrAlias,
parseNodeText(node) || roomMention.roomIdOrAlias,
false,
undefined,
roomMention.viaServers
);
}
const eventMention = parseMatrixToRoomEvent(href);
if (eventMention) {
return createMentionElement(
eventMention.roomIdOrAlias,
parseNodeText(node) || eventMention.roomIdOrAlias,
false,
eventMention.eventId,
eventMention.viaServers
);
}
} }
} }
return undefined; return undefined;

View file

@ -51,19 +51,10 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
case BlockType.UnorderedList: case BlockType.UnorderedList:
return `<ul>${children}</ul>`; return `<ul>${children}</ul>`;
case BlockType.Mention: { case BlockType.Mention:
let fragment = node.id; return `<a href="https://matrix.to/#/${encodeURIComponent(node.id)}">${sanitizeText(
node.name
if (node.eventId) { )}</a>`;
fragment += `/${node.eventId}`;
}
if (node.viaServers && node.viaServers.length > 0) {
fragment += `?${node.viaServers.map((server) => `via=${server}`).join('&')}`;
}
const matrixTo = `https://matrix.to/#/${fragment}`;
return `<a href="${encodeURI(matrixTo)}">${sanitizeText(node.name)}</a>`;
}
case BlockType.Emoticon: case BlockType.Emoticon:
return node.key.startsWith('mxc://') return node.key.startsWith('mxc://')
? `<img data-mx-emoticon src="${node.key}" alt="${sanitizeText( ? `<img data-mx-emoticon src="${node.key}" alt="${sanitizeText(
@ -71,7 +62,7 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
)}" title="${sanitizeText(node.shortcode)}" height="32" />` )}" title="${sanitizeText(node.shortcode)}" height="32" />`
: sanitizeText(node.key); : sanitizeText(node.key);
case BlockType.Link: case BlockType.Link:
return `<a href="${encodeURI(node.href)}">${node.children}</a>`; return `<a href="${encodeURIComponent(node.href)}">${node.children}</a>`;
case BlockType.Command: case BlockType.Command:
return `/${sanitizeText(node.command)}`; return `/${sanitizeText(node.command)}`;
default: default:

View file

@ -29,8 +29,6 @@ export type LinkElement = {
export type MentionElement = { export type MentionElement = {
type: BlockType.Mention; type: BlockType.Mention;
id: string; id: string;
eventId?: string;
viaServers?: string[];
highlight: boolean; highlight: boolean;
name: string; name: string;
children: Text[]; children: Text[];

View file

@ -158,14 +158,10 @@ export const resetEditorHistory = (editor: Editor) => {
export const createMentionElement = ( export const createMentionElement = (
id: string, id: string,
name: string, name: string,
highlight: boolean, highlight: boolean
eventId?: string,
viaServers?: string[]
): MentionElement => ({ ): MentionElement => ({
type: BlockType.Mention, type: BlockType.Mention,
id, id,
eventId,
viaServers,
highlight, highlight,
name, name,
children: [{ text: '' }], children: [{ text: '' }],

View file

@ -45,19 +45,18 @@ import {
} from '../../plugins/emoji'; } from '../../plugins/emoji';
import { IEmojiGroupLabels, useEmojiGroupLabels } from './useEmojiGroupLabels'; import { IEmojiGroupLabels, useEmojiGroupLabels } from './useEmojiGroupLabels';
import { IEmojiGroupIcons, useEmojiGroupIcons } from './useEmojiGroupIcons'; import { IEmojiGroupIcons, useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard'; import { preventScrollWithArrowKey } from '../../utils/keyboard';
import { useRelevantImagePacks } from '../../hooks/useImagePacks'; import { useRelevantImagePacks } from '../../hooks/useImagePacks';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../hooks/useRecentEmoji';
import { ExtendedPackImage, ImagePack, PackUsage } from '../../plugins/custom-emoji'; import { ExtendedPackImage, ImagePack, PackUsage } from '../../plugins/custom-emoji';
import { isUserId, mxcUrlToHttp } from '../../utils/matrix'; import { isUserId } from '../../utils/matrix';
import { editableActiveElement, isIntersectingScrollView, targetFromEvent } from '../../utils/dom'; import { editableActiveElement, isIntersectingScrollView, targetFromEvent } from '../../utils/dom';
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch'; import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
import { useDebounce } from '../../hooks/useDebounce'; import { useDebounce } from '../../hooks/useDebounce';
import { useThrottle } from '../../hooks/useThrottle'; import { useThrottle } from '../../hooks/useThrottle';
import { addRecentEmoji } from '../../plugins/recent-emoji'; import { addRecentEmoji } from '../../plugins/recent-emoji';
import { mobileOrTablet } from '../../utils/user-agent'; import { mobileOrTablet } from '../../utils/user-agent';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
const RECENT_GROUP_ID = 'recent_group'; const RECENT_GROUP_ID = 'recent_group';
const SEARCH_GROUP_ID = 'search_group'; const SEARCH_GROUP_ID = 'search_group';
@ -372,13 +371,11 @@ function ImagePackSidebarStack({
packs, packs,
usage, usage,
onItemClick, onItemClick,
useAuthentication,
}: { }: {
mx: MatrixClient; mx: MatrixClient;
packs: ImagePack[]; packs: ImagePack[];
usage: PackUsage; usage: PackUsage;
onItemClick: (id: string) => void; onItemClick: (id: string) => void;
useAuthentication?: boolean;
}) { }) {
const activeGroupId = useAtomValue(activeGroupIdAtom); const activeGroupId = useAtomValue(activeGroupIdAtom);
return ( return (
@ -401,7 +398,7 @@ function ImagePackSidebarStack({
height: toRem(24), height: toRem(24),
objectFit: 'contain', objectFit: 'contain',
}} }}
src={mxcUrlToHttp(mx, pack.getPackAvatarUrl(usage) ?? '', useAuthentication) || pack.avatarUrl} src={mx.mxcUrlToHttp(pack.getPackAvatarUrl(usage) ?? '') || pack.avatarUrl}
alt={label || 'Unknown Pack'} alt={label || 'Unknown Pack'}
/> />
</SidebarBtn> </SidebarBtn>
@ -473,70 +470,68 @@ export function SearchEmojiGroup({
label, label,
id, id,
emojis: searchResult, emojis: searchResult,
useAuthentication,
}: { }: {
mx: MatrixClient; mx: MatrixClient;
tab: EmojiBoardTab; tab: EmojiBoardTab;
label: string; label: string;
id: string; id: string;
emojis: Array<ExtendedPackImage | IEmoji>; emojis: Array<ExtendedPackImage | IEmoji>;
useAuthentication?: boolean;
}) { }) {
return ( return (
<EmojiGroup key={id} id={id} label={label}> <EmojiGroup key={id} id={id} label={label}>
{tab === EmojiBoardTab.Emoji {tab === EmojiBoardTab.Emoji
? searchResult.map((emoji) => ? searchResult.map((emoji) =>
'unicode' in emoji ? ( 'unicode' in emoji ? (
<EmojiItem <EmojiItem
key={emoji.unicode} key={emoji.unicode}
label={emoji.label} label={emoji.label}
type={EmojiType.Emoji} type={EmojiType.Emoji}
data={emoji.unicode} data={emoji.unicode}
shortcode={emoji.shortcode} shortcode={emoji.shortcode}
> >
{NativeEmoji(emoji)} {NativeEmoji(emoji)}
</EmojiItem> </EmojiItem>
) : ( ) : (
<EmojiItem <EmojiItem
key={emoji.shortcode} key={emoji.shortcode}
label={emoji.body || emoji.shortcode} label={emoji.body || emoji.shortcode}
type={EmojiType.CustomEmoji} type={EmojiType.CustomEmoji}
data={emoji.url} data={emoji.url}
shortcode={emoji.shortcode} shortcode={emoji.shortcode}
> >
<img <img
loading="lazy" loading="lazy"
className={css.CustomEmojiImg} className={css.CustomEmojiImg}
alt={emoji.body || emoji.shortcode} alt={emoji.body || emoji.shortcode}
src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url} src={mx.mxcUrlToHttp(emoji.url) ?? emoji.url}
/> />
</EmojiItem> </EmojiItem>
)
) )
)
: searchResult.map((emoji) => : searchResult.map((emoji) =>
'unicode' in emoji ? null : ( 'unicode' in emoji ? null : (
<StickerItem <StickerItem
key={emoji.shortcode} key={emoji.shortcode}
label={emoji.body || emoji.shortcode} label={emoji.body || emoji.shortcode}
type={EmojiType.Sticker} type={EmojiType.Sticker}
data={emoji.url} data={emoji.url}
shortcode={emoji.shortcode} shortcode={emoji.shortcode}
> >
<img <img
loading="lazy" loading="lazy"
className={css.StickerImg} className={css.StickerImg}
alt={emoji.body || emoji.shortcode} alt={emoji.body || emoji.shortcode}
src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url} src={mx.mxcUrlToHttp(emoji.url) ?? emoji.url}
/> />
</StickerItem> </StickerItem>
) )
)} )}
</EmojiGroup> </EmojiGroup>
); );
} }
export const CustomEmojiGroups = memo( export const CustomEmojiGroups = memo(
({ mx, groups, useAuthentication }: { mx: MatrixClient; groups: ImagePack[]; useAuthentication?: boolean }) => ( ({ mx, groups }: { mx: MatrixClient; groups: ImagePack[] }) => (
<> <>
{groups.map((pack) => ( {groups.map((pack) => (
<EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}> <EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}>
@ -552,7 +547,7 @@ export const CustomEmojiGroups = memo(
loading="lazy" loading="lazy"
className={css.CustomEmojiImg} className={css.CustomEmojiImg}
alt={image.body || image.shortcode} alt={image.body || image.shortcode}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url} src={mx.mxcUrlToHttp(image.url) ?? image.url}
/> />
</EmojiItem> </EmojiItem>
))} ))}
@ -562,7 +557,7 @@ export const CustomEmojiGroups = memo(
) )
); );
export const StickerGroups = memo(({ mx, groups, useAuthentication }: { mx: MatrixClient; groups: ImagePack[]; useAuthentication?: boolean }) => ( export const StickerGroups = memo(({ mx, groups }: { mx: MatrixClient; groups: ImagePack[] }) => (
<> <>
{groups.length === 0 && ( {groups.length === 0 && (
<Box <Box
@ -595,7 +590,7 @@ export const StickerGroups = memo(({ mx, groups, useAuthentication }: { mx: Matr
loading="lazy" loading="lazy"
className={css.StickerImg} className={css.StickerImg}
alt={image.body || image.shortcode} alt={image.body || image.shortcode}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url} src={mx.mxcUrlToHttp(image.url) ?? image.url}
/> />
</StickerItem> </StickerItem>
))} ))}
@ -667,7 +662,6 @@ export function EmojiBoard({
const setActiveGroupId = useSetAtom(activeGroupIdAtom); const setActiveGroupId = useSetAtom(activeGroupIdAtom);
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const emojiGroupLabels = useEmojiGroupLabels(); const emojiGroupLabels = useEmojiGroupLabels();
const emojiGroupIcons = useEmojiGroupIcons(); const emojiGroupIcons = useEmojiGroupIcons();
const imagePacks = useRelevantImagePacks(mx, usage, imagePackRooms); const imagePacks = useRelevantImagePacks(mx, usage, imagePackRooms);
@ -761,14 +755,14 @@ export function EmojiBoard({
} else if (emojiInfo.type === EmojiType.CustomEmoji && emojiPreviewRef.current) { } else if (emojiInfo.type === EmojiType.CustomEmoji && emojiPreviewRef.current) {
const img = document.createElement('img'); const img = document.createElement('img');
img.className = css.CustomEmojiImg; img.className = css.CustomEmojiImg;
img.setAttribute('src', mxcUrlToHttp(mx, emojiInfo.data, useAuthentication) || emojiInfo.data); img.setAttribute('src', mx.mxcUrlToHttp(emojiInfo.data) || emojiInfo.data);
img.setAttribute('alt', emojiInfo.shortcode); img.setAttribute('alt', emojiInfo.shortcode);
emojiPreviewRef.current.textContent = ''; emojiPreviewRef.current.textContent = '';
emojiPreviewRef.current.appendChild(img); emojiPreviewRef.current.appendChild(img);
} }
emojiPreviewTextRef.current.textContent = `:${emojiInfo.shortcode}:`; emojiPreviewTextRef.current.textContent = `:${emojiInfo.shortcode}:`;
}, },
[mx, useAuthentication] [mx]
); );
const throttleEmojiHover = useThrottle(handleEmojiPreview, { const throttleEmojiHover = useThrottle(handleEmojiPreview, {
@ -807,7 +801,6 @@ export function EmojiBoard({
!editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt), !editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyBackward: (evt: KeyboardEvent) =>
!editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt), !editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt),
escapeDeactivates: stopPropagation,
}} }}
> >
<EmojiBoardLayout <EmojiBoardLayout
@ -861,7 +854,6 @@ export function EmojiBoard({
usage={usage} usage={usage}
packs={imagePacks} packs={imagePacks}
onItemClick={handleScrollToGroup} onItemClick={handleScrollToGroup}
useAuthentication={useAuthentication}
/> />
)} )}
{emojiTab && ( {emojiTab && (
@ -923,14 +915,13 @@ export function EmojiBoard({
id={SEARCH_GROUP_ID} id={SEARCH_GROUP_ID}
label={result.items.length ? 'Search Results' : 'No Results found'} label={result.items.length ? 'Search Results' : 'No Results found'}
emojis={result.items} emojis={result.items}
useAuthentication={useAuthentication}
/> />
)} )}
{emojiTab && recentEmojis.length > 0 && ( {emojiTab && recentEmojis.length > 0 && (
<RecentEmojiGroup id={RECENT_GROUP_ID} label="Recent" emojis={recentEmojis} /> <RecentEmojiGroup id={RECENT_GROUP_ID} label="Recent" emojis={recentEmojis} />
)} )}
{emojiTab && <CustomEmojiGroups mx={mx} groups={imagePacks} useAuthentication={useAuthentication} />} {emojiTab && <CustomEmojiGroups mx={mx} groups={imagePacks} />}
{stickerTab && <StickerGroups mx={mx} groups={imagePacks} useAuthentication={useAuthentication} />} {stickerTab && <StickerGroups mx={mx} groups={imagePacks} />}
{emojiTab && <NativeEmojiGroups groups={emojiGroups} labels={emojiGroupLabels} />} {emojiTab && <NativeEmojiGroups groups={emojiGroups} labels={emojiGroupLabels} />}
</Box> </Box>
</Scroll> </Scroll>

View file

@ -2,6 +2,8 @@ import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { import {
Avatar, Avatar,
AvatarFallback,
AvatarImage,
Box, Box,
Header, Header,
Icon, Icon,
@ -19,9 +21,8 @@ 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 colorMXID from '../../../util/colorMXID';
import { openProfileViewer } from '../../../client/action/navigation'; import { openProfileViewer } from '../../../client/action/navigation';
import { UserAvatar } from '../user-avatar';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
export type EventReadersProps = { export type EventReadersProps = {
room: Room; room: Room;
@ -31,7 +32,6 @@ export type EventReadersProps = {
export const EventReaders = as<'div', EventReadersProps>( export const EventReaders = as<'div', EventReadersProps>(
({ className, room, eventId, requestClose, ...props }, ref) => { ({ className, room, eventId, requestClose, ...props }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const latestEventReaders = useRoomEventReaders(room, eventId); const latestEventReaders = useRoomEventReaders(room, eventId);
const getName = (userId: string) => const getName = (userId: string) =>
@ -57,10 +57,9 @@ 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 avatarUrl = room
.getMember(readerId) .getMember(readerId)
?.getMxcAvatarUrl(); ?.getAvatarUrl(mx.baseUrl, 100, 100, 'crop', undefined, false);
const avatarUrl = avatarMxcUrl ? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication) : undefined;
return ( return (
<MenuItem <MenuItem
@ -73,12 +72,18 @@ export const EventReaders = as<'div', EventReadersProps>(
}} }}
before={ before={
<Avatar size="200"> <Avatar size="200">
<UserAvatar {avatarUrl ? (
userId={readerId} <AvatarImage src={avatarUrl} />
src={avatarUrl ?? undefined} ) : (
alt={name} <AvatarFallback
renderFallback={() => <Icon size="50" src={Icons.User} filled />} style={{
/> background: colorMXID(readerId),
color: 'white',
}}
>
<Text size="H6">{name[0]}</Text>
</AvatarFallback>
)}
</Avatar> </Avatar>
} }
> >

View file

@ -6,7 +6,6 @@ import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import * as css from './ImageViewer.css'; import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom'; import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan'; import { usePan } from '../../hooks/usePan';
import { downloadMedia } from '../../utils/matrix';
export type ImageViewerProps = { export type ImageViewerProps = {
alt: string; alt: string;
@ -19,9 +18,8 @@ export const ImageViewer = as<'div', ImageViewerProps>(
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2); const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1); const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const handleDownload = async () => { const handleDownload = () => {
const fileContent = await downloadMedia(src); FileSaver.saveAs(src, alt);
FileSaver.saveAs(fileContent, alt);
}; };
return ( return (

View file

@ -1,108 +0,0 @@
import React, { useCallback, useEffect } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Dialog,
Overlay,
OverlayCenter,
OverlayBackdrop,
Header,
config,
Box,
Text,
IconButton,
Icon,
Icons,
color,
Button,
Spinner,
} from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard';
type LeaveRoomPromptProps = {
roomId: string;
onDone: () => void;
onCancel: () => void;
};
export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptProps) {
const mx = useMatrixClient();
const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
useCallback(async () => {
mx.leave(roomId);
}, [mx, roomId])
);
const handleLeave = () => {
leaveRoom();
};
useEffect(() => {
if (leaveState.status === AsyncStatus.Success) {
onDone();
}
}, [leaveState, onDone]);
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}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Leave Room</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text priority="400">Are you sure you want to leave this room?</Text>
{leaveState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
Failed to leave room! {leaveState.error.message}
</Text>
)}
</Box>
<Button
type="submit"
variant="Critical"
onClick={handleLeave}
before={
leaveState.status === AsyncStatus.Loading ? (
<Spinner fill="Solid" variant="Critical" size="200" />
) : undefined
}
aria-disabled={
leaveState.status === AsyncStatus.Loading ||
leaveState.status === AsyncStatus.Success
}
>
<Text size="B400">
{leaveState.status === AsyncStatus.Loading ? 'Leaving...' : 'Leave'}
</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View file

@ -1 +0,0 @@
export * from './LeaveRoomPrompt';

View file

@ -1,108 +0,0 @@
import React, { useCallback, useEffect } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Dialog,
Overlay,
OverlayCenter,
OverlayBackdrop,
Header,
config,
Box,
Text,
IconButton,
Icon,
Icons,
color,
Button,
Spinner,
} from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard';
type LeaveSpacePromptProps = {
roomId: string;
onDone: () => void;
onCancel: () => void;
};
export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptProps) {
const mx = useMatrixClient();
const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
useCallback(async () => {
mx.leave(roomId);
}, [mx, roomId])
);
const handleLeave = () => {
leaveRoom();
};
useEffect(() => {
if (leaveState.status === AsyncStatus.Success) {
onDone();
}
}, [leaveState, onDone]);
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}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Leave Space</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text priority="400">Are you sure you want to leave this space?</Text>
{leaveState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
Failed to leave space! {leaveState.error.message}
</Text>
)}
</Box>
<Button
type="submit"
variant="Critical"
onClick={handleLeave}
before={
leaveState.status === AsyncStatus.Loading ? (
<Spinner fill="Solid" variant="Critical" size="200" />
) : undefined
}
aria-disabled={
leaveState.status === AsyncStatus.Loading ||
leaveState.status === AsyncStatus.Success
}
>
<Text size="B400">
{leaveState.status === AsyncStatus.Loading ? 'Leaving...' : 'Leave'}
</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View file

@ -1 +0,0 @@
export * from './LeaveSpacePrompt';

View file

@ -1,398 +0,0 @@
import React, { ReactNode } from 'react';
import { Box, Chip, Icon, Icons, Text, toRem } from 'folds';
import { IContent } from 'matrix-js-sdk';
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
import { trimReplyFromBody } from '../../utils/room';
import { MessageTextBody } from './layout';
import {
MessageBadEncryptedContent,
MessageBrokenContent,
MessageDeletedContent,
MessageEditedContent,
MessageUnsupportedContent,
} from './content';
import {
IAudioContent,
IAudioInfo,
IEncryptedFile,
IFileContent,
IFileInfo,
IImageContent,
IImageInfo,
IThumbnailContent,
IVideoContent,
IVideoInfo,
} from '../../../types/matrix/common';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader } from './FileHeader';
export function MBadEncrypted() {
return (
<Text>
<MessageBadEncryptedContent />
</Text>
);
}
type RedactedContentProps = {
reason?: string;
};
export function RedactedContent({ reason }: RedactedContentProps) {
return (
<Text>
<MessageDeletedContent reason={reason} />
</Text>
);
}
export function UnsupportedContent() {
return (
<Text>
<MessageUnsupportedContent />
</Text>
);
}
export function BrokenContent() {
return (
<Text>
<MessageBrokenContent />
</Text>
);
}
type RenderBodyProps = {
body: string;
customBody?: string;
};
type MTextProps = {
edited?: boolean;
content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
};
export function MText({ edited, content, renderBody, renderUrlsPreview }: MTextProps) {
const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
return (
<>
<MessageTextBody
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent />}
</MessageTextBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
</>
);
}
type MEmoteProps = {
displayName: string;
edited?: boolean;
content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
};
export function MEmote({
displayName,
edited,
content,
renderBody,
renderUrlsPreview,
}: MEmoteProps) {
const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
return (
<>
<MessageTextBody
emote
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
<b>{`${displayName} `}</b>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent />}
</MessageTextBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
</>
);
}
type MNoticeProps = {
edited?: boolean;
content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
};
export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNoticeProps) {
const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
return (
<>
<MessageTextBody
notice
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent />}
</MessageTextBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
</>
);
}
type RenderImageContentProps = {
body: string;
info?: IImageInfo & IThumbnailContent;
mimeType?: string;
url: string;
encInfo?: IEncryptedFile;
};
type MImageProps = {
content: IImageContent;
renderImageContent: (props: RenderImageContentProps) => ReactNode;
outlined?: boolean;
};
export function MImage({ content, renderImageContent, outlined }: MImageProps) {
const imgInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url;
if (typeof mxcUrl !== 'string') {
return <BrokenContent />;
}
const height = scaleYDimension(imgInfo?.w || 400, 400, imgInfo?.h || 400);
return (
<Attachment outlined={outlined}>
<AttachmentBox
style={{
height: toRem(height < 48 ? 48 : height),
}}
>
{renderImageContent({
body: content.body || 'Image',
info: imgInfo,
mimeType: imgInfo?.mimetype,
url: mxcUrl,
encInfo: content.file,
})}
</AttachmentBox>
</Attachment>
);
}
type RenderVideoContentProps = {
body: string;
info: IVideoInfo & IThumbnailContent;
mimeType: string;
url: string;
encInfo?: IEncryptedFile;
};
type MVideoProps = {
content: IVideoContent;
renderAsFile: () => ReactNode;
renderVideoContent: (props: RenderVideoContentProps) => ReactNode;
outlined?: boolean;
};
export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: MVideoProps) {
const videoInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url;
const safeMimeType = getBlobSafeMimeType(videoInfo?.mimetype ?? '');
if (!videoInfo || !safeMimeType.startsWith('video') || typeof mxcUrl !== 'string') {
if (mxcUrl) {
return renderAsFile();
}
return <BrokenContent />;
}
const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
return (
<Attachment outlined={outlined}>
<AttachmentBox
style={{
height: toRem(height < 48 ? 48 : height),
}}
>
{renderVideoContent({
body: content.body || 'Video',
info: videoInfo,
mimeType: safeMimeType,
url: mxcUrl,
encInfo: content.file,
})}
</AttachmentBox>
</Attachment>
);
}
type RenderAudioContentProps = {
info: IAudioInfo;
mimeType: string;
url: string;
encInfo?: IEncryptedFile;
};
type MAudioProps = {
content: IAudioContent;
renderAsFile: () => ReactNode;
renderAudioContent: (props: RenderAudioContentProps) => ReactNode;
outlined?: boolean;
};
export function MAudio({ content, renderAsFile, renderAudioContent, outlined }: MAudioProps) {
const audioInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url;
const safeMimeType = getBlobSafeMimeType(audioInfo?.mimetype ?? '');
if (!audioInfo || !safeMimeType.startsWith('audio') || typeof mxcUrl !== 'string') {
if (mxcUrl) {
return renderAsFile();
}
return <BrokenContent />;
}
return (
<Attachment outlined={outlined}>
<AttachmentHeader>
<FileHeader body={content.body ?? 'Audio'} mimeType={safeMimeType} />
</AttachmentHeader>
<AttachmentBox>
<AttachmentContent>
{renderAudioContent({
info: audioInfo,
mimeType: safeMimeType,
url: mxcUrl,
encInfo: content.file,
})}
</AttachmentContent>
</AttachmentBox>
</Attachment>
);
}
type RenderFileContentProps = {
body: string;
info: IFileInfo & IThumbnailContent;
mimeType: string;
url: string;
encInfo?: IEncryptedFile;
};
type MFileProps = {
content: IFileContent;
renderFileContent: (props: RenderFileContentProps) => ReactNode;
outlined?: boolean;
};
export function MFile({ content, renderFileContent, outlined }: MFileProps) {
const fileInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url;
if (typeof mxcUrl !== 'string') {
return <BrokenContent />;
}
return (
<Attachment outlined={outlined}>
<AttachmentHeader>
<FileHeader
body={content.body ?? 'Unnamed File'}
mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
/>
</AttachmentHeader>
<AttachmentBox>
<AttachmentContent>
{renderFileContent({
body: content.body ?? 'File',
info: fileInfo ?? {},
mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE,
url: mxcUrl,
encInfo: content.file,
})}
</AttachmentContent>
</AttachmentBox>
</Attachment>
);
}
type MLocationProps = {
content: IContent;
};
export function MLocation({ content }: MLocationProps) {
const geoUri = content.geo_uri;
if (typeof geoUri !== 'string') return <BrokenContent />;
const location = parseGeoUri(geoUri);
return (
<Box direction="Column" alignItems="Start" gap="100">
<Text size="T400">{geoUri}</Text>
<Chip
as="a"
size="400"
href={`https://www.openstreetmap.org/?mlat=${location.latitude}&mlon=${location.longitude}#map=16/${location.latitude}/${location.longitude}`}
target="_blank"
rel="noreferrer noopener"
variant="Primary"
radii="Pill"
before={<Icon src={Icons.External} size="50" />}
>
<Text size="B300">Open Location</Text>
</Chip>
</Box>
);
}
type MStickerProps = {
content: IImageContent;
renderImageContent: (props: RenderImageContentProps) => ReactNode;
};
export function MSticker({ content, renderImageContent }: MStickerProps) {
const imgInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url;
if (typeof mxcUrl !== 'string') {
return <MessageBrokenContent />;
}
const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152);
return (
<AttachmentBox
style={{
height: toRem(height < 48 ? 48 : height),
width: toRem(152),
}}
>
{renderImageContent({
body: content.body || 'Sticker',
info: imgInfo,
mimeType: imgInfo?.mimetype,
url: mxcUrl,
encInfo: content.file,
})}
</AttachmentBox>
);
}

View file

@ -5,7 +5,7 @@ import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import * as css from './Reaction.css'; import * as css from './Reaction.css';
import { getHexcodeForEmoji, getShortcodeFor, getEmojiUrl, isUsingTwemoji } from '../../plugins/emoji'; import { getHexcodeForEmoji, getShortcodeFor, getEmojiUrl, isUsingTwemoji } from '../../plugins/emoji';
import { getMemberDisplayName } from '../../utils/room'; import { getMemberDisplayName } from '../../utils/room';
import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { eventWithShortcode, getMxIdLocalPart } from '../../utils/matrix';
export const Reaction = as< export const Reaction = as<
'button', 'button',
@ -13,9 +13,8 @@ export const Reaction = as<
mx: MatrixClient; mx: MatrixClient;
count: number; count: number;
reaction: string; reaction: string;
useAuthentication?: boolean;
} }
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => ( >(({ className, mx, count, reaction, ...props }, ref) => (
<Box <Box
as="button" as="button"
className={classNames(css.Reaction, className)} className={classNames(css.Reaction, className)}
@ -29,8 +28,7 @@ export const Reaction = as<
{reaction.startsWith('mxc://') ? ( {reaction.startsWith('mxc://') ? (
<img <img
className={css.ReactionImg} className={css.ReactionImg}
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction src={mx.mxcUrlToHttp(reaction) ?? reaction}
}
alt={reaction} alt={reaction}
/> />
) : isUsingTwemoji() ? ( ) : isUsingTwemoji() ? (

View file

@ -1,36 +0,0 @@
import React from 'react';
import parse, { HTMLReactParserOptions } from 'html-react-parser';
import Linkify from 'linkify-react';
import { Opts } from 'linkifyjs';
import { MessageEmptyContent } from './content';
import { sanitizeCustomHtml } from '../../utils/sanitize';
import { highlightText, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
type RenderBodyProps = {
body: string;
customBody?: string;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
};
export function RenderBody({
body,
customBody,
highlightRegex,
htmlReactParserOptions,
linkifyOpts,
}: RenderBodyProps) {
if (body === '') <MessageEmptyContent />;
if (customBody) {
if (customBody === '') <MessageEmptyContent />;
return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
}
return (
<Linkify options={linkifyOpts}>
{highlightRegex
? highlightText(highlightRegex, scaleSystemEmoji(body))
: scaleSystemEmoji(body)}
</Linkify>
);
}

View file

@ -1,39 +1,13 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds'; import { config, toRem } from 'folds';
export const ReplyBend = style({
flexShrink: 0,
});
export const ThreadIndicator = style({
opacity: config.opacity.P300,
gap: toRem(2),
selectors: {
'button&': {
cursor: 'pointer',
},
':hover&': {
opacity: config.opacity.P500,
},
},
});
export const ThreadIndicatorIcon = style({
width: toRem(14),
height: toRem(14),
});
export const Reply = style({ export const Reply = style({
padding: `0 ${config.space.S100}`,
marginBottom: toRem(1), marginBottom: toRem(1),
cursor: 'pointer',
minWidth: 0, minWidth: 0,
maxWidth: '100%', maxWidth: '100%',
minHeight: config.lineHeight.T300, minHeight: config.lineHeight.T300,
selectors: {
'button&': {
cursor: 'pointer',
},
},
}); });
export const ReplyContent = style({ export const ReplyContent = style({
@ -45,3 +19,7 @@ export const ReplyContent = style({
}, },
}, },
}); });
export const ReplyContentText = style({
paddingRight: config.space.S100,
});

View file

@ -1,7 +1,7 @@
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds'; import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk'; import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import React, { MouseEventHandler, ReactNode, useEffect, useMemo, useState } from 'react'; import React, { useEffect, useState } from 'react';
import to from 'await-to-js'; import to from 'await-to-js';
import classNames from 'classnames'; import classNames from 'classnames';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
@ -10,122 +10,94 @@ import { getMxIdLocalPart } from '../../utils/matrix';
import { LinePlaceholder } from './placeholder'; import { LinePlaceholder } from './placeholder';
import { randomNumberBetween } from '../../utils/common'; import { randomNumberBetween } from '../../utils/common';
import * as css from './Reply.css'; import * as css from './Reply.css';
import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content'; import {
import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser'; MessageBadEncryptedContent,
MessageDeletedContent,
type ReplyLayoutProps = { MessageFailedContent,
userColor?: string; } from './MessageContentFallback';
username?: ReactNode;
};
export const ReplyLayout = as<'div', ReplyLayoutProps>(
({ username, userColor, className, children, ...props }, ref) => (
<Box
className={classNames(css.Reply, className)}
alignItems="Center"
alignSelf="Start"
gap="100"
{...props}
ref={ref}
>
<Box style={{ color: userColor, maxWidth: toRem(200) }} alignItems="Center" shrink="No">
<Icon size="100" src={Icons.ReplyArrow} />
{username}
</Box>
<Box grow="Yes" className={css.ReplyContent}>
{children}
</Box>
</Box>
)
);
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
<Box className={css.ThreadIndicator} alignItems="Center" alignSelf="Start" {...props} ref={ref}>
<Icon className={css.ThreadIndicatorIcon} src={Icons.Message} />
<Text size="T200">Threaded reply</Text>
</Box>
));
type ReplyProps = { type ReplyProps = {
mx: MatrixClient; mx: MatrixClient;
room: Room; room: Room;
timelineSet?: EventTimelineSet | undefined; timelineSet: EventTimelineSet;
replyEventId: string; eventId: string;
threadRootId?: string | undefined;
onClick?: MouseEventHandler | undefined;
}; };
export const Reply = as<'div', ReplyProps>((_, ref) => { export const Reply = as<'div', ReplyProps>(
const { mx, room, timelineSet, replyEventId, threadRootId, onClick, ...props } = _; ({ className, mx, room, timelineSet, eventId, ...props }, ref) => {
const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>( const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>(
timelineSet?.findEventById(replyEventId) timelineSet.findEventById(eventId)
); );
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
const { body } = replyEvent?.getContent() ?? {}; const { body } = replyEvent?.getContent() ?? {};
const sender = replyEvent?.getSender(); const sender = replyEvent?.getSender();
const fallbackBody = replyEvent?.isRedacted() ? ( const fallbackBody = replyEvent?.isRedacted() ? (
<MessageDeletedContent /> <MessageDeletedContent />
) : ( ) : (
<MessageFailedContent /> <MessageFailedContent />
); );
useEffect(() => { useEffect(() => {
let disposed = false; let disposed = false;
const loadEvent = async () => { const loadEvent = async () => {
const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, replyEventId)); const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, eventId));
const mEvent = new MatrixEvent(evt); const mEvent = new MatrixEvent(evt);
if (disposed) return; if (disposed) return;
if (err) { if (err) {
setReplyEvent(null); setReplyEvent(null);
return; return;
}
if (mEvent.isEncrypted() && mx.getCrypto()) {
await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
}
setReplyEvent(mEvent);
};
if (replyEvent === undefined) loadEvent();
return () => {
disposed = true;
};
}, [replyEvent, mx, room, replyEventId]);
const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
return (
<Box direction="Column" {...props} ref={ref}>
{threadRootId && (
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
)}
<ReplyLayout
as="button"
userColor={sender ? colorMXID(sender) : undefined}
username={
sender && (
<Text size="T300" truncate>
<b>{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}</b>
</Text>
)
} }
data-event-id={replyEventId} if (mEvent.isEncrypted() && mx.getCrypto()) {
onClick={onClick} await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
}
setReplyEvent(mEvent);
};
if (replyEvent === undefined) loadEvent();
return () => {
disposed = true;
};
}, [replyEvent, mx, room, eventId]);
const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
const bodyJSX = body ? trimReplyFromBody(body) : fallbackBody;
return (
<Box
className={classNames(css.Reply, className)}
alignItems="Center"
gap="100"
{...props}
ref={ref}
> >
{replyEvent !== undefined ? ( <Box
<Text size="T300" truncate> style={{ color: colorMXID(sender ?? eventId), maxWidth: '50%' }}
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX} alignItems="Center"
</Text> shrink="No"
) : ( >
<LinePlaceholder <Icon src={Icons.ReplyArrow} size="50" />
style={{ {sender && (
backgroundColor: color.SurfaceVariant.ContainerActive, <Text size="T300" truncate>
maxWidth: toRem(placeholderWidth), {getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}
width: '100%', </Text>
}} )}
/> </Box>
)} <Box grow="Yes" className={css.ReplyContent}>
</ReplyLayout> {replyEvent !== undefined ? (
</Box> <Text className={css.ReplyContentText} size="T300" truncate>
); {badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
}); </Text>
) : (
<LinePlaceholder
style={{
backgroundColor: color.SurfaceVariant.ContainerActive,
maxWidth: toRem(randomNumberBetween(40, 400)),
width: '100%',
}}
/>
)}
</Box>
</Box>
);
}
);

View file

@ -1,4 +1,4 @@
import React, { ComponentProps } from 'react'; import React from 'react';
import { Text, as } from 'folds'; import { Text, as } from 'folds';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time'; import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
@ -7,23 +7,21 @@ export type TimeProps = {
ts: number; ts: number;
}; };
export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>( export const Time = as<'span', TimeProps>(({ compact, ts, ...props }, ref) => {
({ compact, ts, ...props }, ref) => { let time = '';
let time = ''; if (compact) {
if (compact) { time = timeHourMinute(ts);
time = timeHourMinute(ts); } else if (today(ts)) {
} else if (today(ts)) { time = timeHourMinute(ts);
time = timeHourMinute(ts); } else if (yesterday(ts)) {
} else if (yesterday(ts)) { time = `Yesterday ${timeHourMinute(ts)}`;
time = `Yesterday ${timeHourMinute(ts)}`; } else {
} else { time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`;
time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`;
}
return (
<Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
{time}
</Text>
);
} }
);
return (
<Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
{time}
</Text>
);
});

View file

@ -9,7 +9,7 @@ export const Attachment = recipe({
borderRadius: config.radii.R400, borderRadius: config.radii.R400,
overflow: 'hidden', overflow: 'hidden',
maxWidth: '100%', maxWidth: '100%',
width: toRem(400), // width: toRem(400),
}, },
variants: { variants: {
outlined: { outlined: {
@ -31,7 +31,7 @@ export const AttachmentBox = style([
{ {
maxWidth: '100%', maxWidth: '100%',
maxHeight: toRem(600), maxHeight: toRem(600),
width: toRem(400), // width: toRem(400),
overflow: 'hidden', overflow: 'hidden',
}, },
]); ]);

View file

@ -1,209 +0,0 @@
/* eslint-disable jsx-a11y/media-has-caption */
import React, { ReactNode, useCallback, useRef, useState } from 'react';
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { Range } from 'react-range';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { IAudioInfo } from '../../../../types/matrix/common';
import {
PlayTimeCallback,
useMediaLoading,
useMediaPlay,
useMediaPlayTimeCallback,
useMediaSeek,
useMediaVolume,
} from '../../../hooks/media';
import { useThrottle } from '../../../hooks/useThrottle';
import { secondsToMinutesAndSeconds } from '../../../utils/common';
import {
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
const PLAY_TIME_THROTTLE_OPS = {
wait: 500,
immediate: true,
};
type RenderMediaControlProps = {
after: ReactNode;
leftControl: ReactNode;
rightControl: ReactNode;
children: ReactNode;
};
export type AudioContentProps = {
mimeType: string;
url: string;
info: IAudioInfo;
encInfo?: EncryptedAttachmentInfo;
renderMediaControl: (props: RenderMediaControlProps) => ReactNode;
};
export function AudioContent({
mimeType,
url,
info,
encInfo,
renderMediaControl,
}: AudioContentProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl);
return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo])
);
const audioRef = useRef<HTMLAudioElement | null>(null);
const [currentTime, setCurrentTime] = useState(0);
// duration in seconds. (NOTE: info.duration is in milliseconds)
const infoDuration = info.duration ?? 0;
const [duration, setDuration] = useState((infoDuration >= 0 ? infoDuration : 0) / 1000);
const getAudioRef = useCallback(() => audioRef.current, []);
const { loading } = useMediaLoading(getAudioRef);
const { playing, setPlaying } = useMediaPlay(getAudioRef);
const { seek } = useMediaSeek(getAudioRef);
const { volume, mute, setMute, setVolume } = useMediaVolume(getAudioRef);
const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, ct) => {
setDuration(d);
setCurrentTime(ct);
}, []);
useMediaPlayTimeCallback(
getAudioRef,
useThrottle(handlePlayTimeCallback, PLAY_TIME_THROTTLE_OPS)
);
const handlePlay = () => {
if (srcState.status === AsyncStatus.Success) {
setPlaying(!playing);
} else if (srcState.status !== AsyncStatus.Loading) {
loadSrc();
}
};
return renderMediaControl({
after: (
<Range
step={1}
min={0}
max={duration || 1}
values={[currentTime]}
onChange={(values) => seek(values[0])}
renderTrack={(params) => (
<div {...params.props}>
{params.children}
<ProgressBar
as="div"
variant="Secondary"
size="300"
min={0}
max={duration}
value={currentTime}
radii="300"
/>
</div>
)}
renderThumb={(params) => (
<Badge
size="300"
variant="Secondary"
fill="Solid"
radii="Pill"
outlined
{...params.props}
style={{
...params.props.style,
zIndex: 0,
}}
/>
)}
/>
),
leftControl: (
<>
<Chip
onClick={handlePlay}
variant="Secondary"
radii="300"
disabled={srcState.status === AsyncStatus.Loading}
before={
srcState.status === AsyncStatus.Loading || loading ? (
<Spinner variant="Secondary" size="50" />
) : (
<Icon src={playing ? Icons.Pause : Icons.Play} size="50" filled={playing} />
)
}
>
<Text size="B300">{playing ? 'Pause' : 'Play'}</Text>
</Chip>
<Text size="T200">{`${secondsToMinutesAndSeconds(
currentTime
)} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
</>
),
rightControl: (
<>
<IconButton
variant="SurfaceVariant"
size="300"
radii="Pill"
onClick={() => setMute(!mute)}
aria-pressed={mute}
>
<Icon src={mute ? Icons.VolumeMute : Icons.VolumeHigh} size="50" />
</IconButton>
<Range
step={0.1}
min={0}
max={1}
values={[volume]}
onChange={(values) => setVolume(values[0])}
renderTrack={(params) => (
<div {...params.props}>
{params.children}
<ProgressBar
style={{ width: toRem(48) }}
variant="Secondary"
size="300"
min={0}
max={1}
value={volume}
radii="300"
/>
</div>
)}
renderThumb={(params) => (
<Badge
size="300"
variant="Secondary"
fill="Solid"
radii="Pill"
outlined
{...params.props}
style={{
...params.props.style,
zIndex: 0,
}}
/>
)}
/>
</>
),
children: (
<audio controls={false} autoPlay ref={audioRef}>
{srcState.status === AsyncStatus.Success && <source src={srcState.data} type={mimeType} />}
</audio>
),
});
}

View file

@ -1,43 +0,0 @@
import { ReactNode, useCallback, useEffect } from 'react';
import { IThumbnailContent } from '../../../../types/matrix/common';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
export type ThumbnailContentProps = {
info: IThumbnailContent;
renderImage: (src: string) => ReactNode;
};
export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
useCallback(async () => {
const thumbInfo = info.thumbnail_info;
const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
const encInfo = info.thumbnail_file;
if (typeof thumbMxcUrl !== 'string' || typeof thumbInfo?.mimetype !== 'string') {
throw new Error('Failed to load thumbnail');
}
const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication) ?? thumbMxcUrl;
if (encInfo) {
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo)
);
return URL.createObjectURL(fileContent);
}
return mediaUrl;
}, [mx, info, useAuthentication])
);
useEffect(() => {
loadThumbSrc();
}, [loadThumbSrc]);
return thumbSrcState.status === AsyncStatus.Success ? renderImage(thumbSrcState.data) : null;
}

View file

@ -1,7 +0,0 @@
export * from './ThumbnailContent';
export * from './ImageContent';
export * from './VideoContent';
export * from './AudioContent';
export * from './FileContent';
export * from './FallbackContent';
export * from './EventContent';

View file

@ -1,37 +0,0 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, config } from 'folds';
export const RelativeBase = style([
DefaultReset,
{
position: 'relative',
width: '100%',
height: '100%',
},
]);
export const AbsoluteContainer = style([
DefaultReset,
{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
},
]);
export const AbsoluteFooter = style([
DefaultReset,
{
position: 'absolute',
bottom: config.space.S100,
left: config.space.S100,
right: config.space.S100,
},
]);
export const ModalWide = style({
minWidth: '85vw',
minHeight: '90vh',
});

View file

@ -3,8 +3,5 @@ export * from './placeholder';
export * from './Reaction'; export * from './Reaction';
export * from './attachment'; export * from './attachment';
export * from './Reply'; export * from './Reply';
export * from './content'; export * from './MessageContentFallback';
export * from './Time'; export * from './Time';
export * from './MsgTypeRenderers';
export * from './FileHeader';
export * from './RenderBody';

View file

@ -61,7 +61,6 @@ const highlightAnime = keyframes({
const HighlightVariant = styleVariants({ const HighlightVariant = styleVariants({
true: { true: {
animation: `${highlightAnime} 2000ms ease-in-out`, animation: `${highlightAnime} 2000ms ease-in-out`,
animationIterationCount: 'infinite',
}, },
}); });
@ -144,14 +143,12 @@ export const BubbleContent = style({
}); });
export const Username = style({ export const Username = style({
cursor: 'pointer',
overflow: 'hidden', overflow: 'hidden',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
selectors: { selectors: {
'button&': { '&:hover, &:focus-visible': {
cursor: 'pointer',
},
'button&:hover, button&:focus-visible': {
textDecoration: 'underline', textDecoration: 'underline',
}, },
}, },

View file

@ -1,11 +0,0 @@
import React, { ReactNode } from 'react';
import { as } from 'folds';
import classNames from 'classnames';
import * as css from './styles.css';
type NavCategoryProps = {
children: ReactNode;
};
export const NavCategory = as<'div', NavCategoryProps>(({ className, ...props }, ref) => (
<div className={classNames(css.NavCategory, className)} {...props} ref={ref} />
));

View file

@ -1,19 +0,0 @@
import React, { ReactNode } from 'react';
import classNames from 'classnames';
import { Header, as } from 'folds';
import * as css from './styles.css';
export type NavCategoryHeaderProps = {
children: ReactNode;
};
export const NavCategoryHeader = as<'div', NavCategoryHeaderProps>(
({ className, ...props }, ref) => (
<Header
className={classNames(css.NavCategoryHeader, className)}
variant="Background"
size="300"
{...props}
ref={ref}
/>
)
);

View file

@ -1,40 +0,0 @@
import { Box, config } from 'folds';
import React, { ReactNode } from 'react';
export function NavEmptyCenter({ children }: { children: ReactNode }) {
return (
<Box
style={{
padding: config.space.S500,
}}
grow="Yes"
direction="Column"
justifyContent="Center"
>
{children}
</Box>
);
}
type NavEmptyLayoutProps = {
icon?: ReactNode;
title?: ReactNode;
content?: ReactNode;
options?: ReactNode;
};
export function NavEmptyLayout({ icon, title, content, options }: NavEmptyLayoutProps) {
return (
<Box direction="Column" gap="400">
<Box direction="Column" alignItems="Center" gap="200">
{icon}
</Box>
<Box direction="Column" gap="100" alignItems="Center">
{title}
{content}
</Box>
<Box direction="Column" gap="200">
{options}
</Box>
</Box>
);
}

View file

@ -1,33 +0,0 @@
import classNames from 'classnames';
import React, { ComponentProps, forwardRef } from 'react';
import { Link } from 'react-router-dom';
import { as } from 'folds';
import * as css from './styles.css';
export const NavItem = as<
'div',
{
highlight?: boolean;
} & css.RoomSelectorVariants
>(({ as: AsNavItem = 'div', className, highlight, variant, radii, children, ...props }, ref) => (
<AsNavItem
className={classNames(css.NavItem({ variant, radii }), className)}
data-highlight={highlight}
{...props}
ref={ref}
>
{children}
</AsNavItem>
));
export const NavLink = forwardRef<HTMLAnchorElement, ComponentProps<typeof Link>>(
({ className, ...props }, ref) => (
<Link className={classNames(css.NavLink, className)} {...props} ref={ref} />
)
);
export const NavButton = as<'button'>(
({ as: AsNavButton = 'button', className, ...props }, ref) => (
<AsNavButton className={classNames(css.NavLink, className)} {...props} ref={ref} />
)
);

View file

@ -1,10 +0,0 @@
import React, { ComponentProps } from 'react';
import { Text, as } from 'folds';
import classNames from 'classnames';
import * as css from './styles.css';
export const NavItemContent = as<'p', ComponentProps<typeof Text>>(
({ className, ...props }, ref) => (
<Text className={classNames(css.NavItemContent, className)} size="T300" {...props} ref={ref} />
)
);

View file

@ -1,17 +0,0 @@
import React, { ComponentProps } from 'react';
import { Box, as } from 'folds';
import classNames from 'classnames';
import * as css from './styles.css';
export const NavItemOptions = as<'div', ComponentProps<typeof Box>>(
({ className, ...props }, ref) => (
<Box
className={classNames(css.NavItemOptions, className)}
alignItems="Center"
shrink="No"
gap="0"
{...props}
ref={ref}
/>
)
);

View file

@ -1,6 +0,0 @@
export * from './NavCategory';
export * from './NavCategoryHeader';
export * from './NavEmptyLayout';
export * from './NavItem';
export * from './NavItemContent';
export * from './NavItemOptions';

View file

@ -1,128 +0,0 @@
import { ComplexStyleRule, createVar, style } from '@vanilla-extract/css';
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds';
export const NavCategory = style([
DefaultReset,
{
position: 'relative',
},
]);
export const NavCategoryHeader = style({
gap: config.space.S100,
});
export const NavLink = style({
color: 'inherit',
minWidth: 0,
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
flexGrow: 1,
':hover': {
textDecoration: 'unset',
},
':focus': {
outline: 'none',
},
});
const Container = createVar();
const ContainerHover = createVar();
const ContainerActive = createVar();
const ContainerLine = createVar();
const OnContainer = createVar();
const getVariant = (variant: ContainerColor): ComplexStyleRule => ({
vars: {
[Container]: color[variant].Container,
[ContainerHover]: color[variant].ContainerHover,
[ContainerActive]: color[variant].ContainerActive,
[ContainerLine]: color[variant].ContainerLine,
[OnContainer]: color[variant].OnContainer,
},
});
const NavItemBase = style({
width: '100%',
display: 'flex',
justifyContent: 'start',
cursor: 'pointer',
backgroundColor: Container,
color: OnContainer,
outline: 'none',
minHeight: toRem(36),
selectors: {
'&:hover, &:focus-visible': {
backgroundColor: ContainerHover,
},
'&[data-hover=true]': {
backgroundColor: ContainerHover,
},
[`&:has(.${NavLink}:active)`]: {
backgroundColor: ContainerActive,
},
'&[aria-selected=true]': {
backgroundColor: ContainerActive,
},
[`&:has(.${NavLink}:focus-visible)`]: {
outline: `${config.borderWidth.B600} solid ${ContainerLine}`,
outlineOffset: `calc(-1 * ${config.borderWidth.B600})`,
},
},
'@supports': {
[`not selector(:has(.${NavLink}:focus-visible))`]: {
':focus-within': {
outline: `${config.borderWidth.B600} solid ${ContainerLine}`,
outlineOffset: `calc(-1 * ${config.borderWidth.B600})`,
},
},
},
});
export const NavItem = recipe({
base: [DefaultReset, NavItemBase, Disabled],
variants: {
variant: {
Background: getVariant('Background'),
Surface: getVariant('Surface'),
SurfaceVariant: getVariant('SurfaceVariant'),
Primary: getVariant('Primary'),
Secondary: getVariant('Secondary'),
Success: getVariant('Success'),
Warning: getVariant('Warning'),
Critical: getVariant('Critical'),
},
radii: RadiiVariant,
},
defaultVariants: {
variant: 'Surface',
radii: '400',
},
});
export type RoomSelectorVariants = RecipeVariants<typeof NavItem>;
export const NavItemContent = style({
paddingLeft: config.space.S200,
paddingRight: config.space.S300,
height: 'inherit',
minWidth: 0,
flexGrow: 1,
display: 'flex',
alignItems: 'center',
fontWeight: config.fontWeight.W500,
selectors: {
'&:hover': {
textDecoration: 'unset',
},
[`.${NavItemBase}[data-highlight=true] &`]: {
fontWeight: config.fontWeight.W600,
},
},
});
export const NavItemOptions = style({
paddingRight: config.space.S200,
});

View file

@ -1,148 +0,0 @@
import React, { ComponentProps, MutableRefObject, ReactNode } from 'react';
import { Box, Header, Line, Scroll, Text, as } from 'folds';
import classNames from 'classnames';
import { ContainerColor } from '../../styles/ContainerColor.css';
import * as css from './style.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
type PageRootProps = {
nav: ReactNode;
children: ReactNode;
};
export function PageRoot({ nav, children }: PageRootProps) {
const screenSize = useScreenSizeContext();
return (
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
{nav}
{screenSize !== ScreenSize.Mobile && (
<Line variant="Background" size="300" direction="Vertical" />
)}
{children}
</Box>
);
}
type ClientDrawerLayoutProps = {
children: ReactNode;
};
export function PageNav({ children }: ClientDrawerLayoutProps) {
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
return (
<Box
grow={isMobile ? 'Yes' : undefined}
className={css.PageNav}
shrink={isMobile ? 'Yes' : 'No'}
>
<Box grow="Yes" direction="Column">
{children}
</Box>
</Box>
);
}
export const PageNavHeader = as<'header'>(({ className, ...props }, ref) => (
<Header
className={classNames(css.PageNavHeader, className)}
variant="Background"
size="600"
{...props}
ref={ref}
/>
));
export function PageNavContent({
scrollRef,
children,
}: {
children: ReactNode;
scrollRef?: MutableRefObject<HTMLDivElement | null>;
}) {
return (
<Box grow="Yes" direction="Column">
<Scroll
ref={scrollRef}
variant="Background"
direction="Vertical"
size="300"
hideTrack
visibility="Hover"
>
<div className={css.PageNavContent}>{children}</div>
</Scroll>
</Box>
);
}
export const Page = as<'div'>(({ className, ...props }, ref) => (
<Box
grow="Yes"
direction="Column"
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
{...props}
ref={ref}
/>
));
export const PageHeader = as<'div', css.PageHeaderVariants>(
({ className, balance, ...props }, ref) => (
<Header
as="header"
size="600"
className={classNames(css.PageHeader({ balance }), className)}
{...props}
ref={ref}
/>
)
);
export const PageContent = as<'div'>(({ className, ...props }, ref) => (
<div className={classNames(css.PageContent, className)} {...props} ref={ref} />
));
export const PageHeroSection = as<'div', ComponentProps<typeof Box>>(
({ className, ...props }, ref) => (
<Box
direction="Column"
className={classNames(css.PageHeroSection, className)}
{...props}
ref={ref}
/>
)
);
export function PageHero({
icon,
title,
subTitle,
children,
}: {
icon: ReactNode;
title: ReactNode;
subTitle: ReactNode;
children?: ReactNode;
}) {
return (
<Box direction="Column" gap="400">
<Box direction="Column" alignItems="Center" gap="200">
{icon}
</Box>
<Box as="h2" direction="Column" gap="200" alignItems="Center">
<Text align="Center" size="H2">
{title}
</Text>
<Text align="Center" priority="400">
{subTitle}
</Text>
</Box>
{children}
</Box>
);
}
export const PageContentCenter = as<'div'>(({ className, ...props }, ref) => (
<div className={classNames(css.PageContentCenter, className)} {...props} ref={ref} />
));

View file

@ -1 +0,0 @@
export * from './Page';

View file

@ -1,80 +0,0 @@
import { style } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
export const PageNav = style({
width: toRem(256),
});
export const PageNavHeader = style({
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
flexShrink: 0,
borderBottomWidth: 1,
selectors: {
'button&': {
cursor: 'pointer',
},
'button&[aria-pressed=true]': {
backgroundColor: color.Background.ContainerActive,
},
'button&:hover, button&:focus-visible': {
backgroundColor: color.Background.ContainerHover,
},
'button&:active': {
backgroundColor: color.Background.ContainerActive,
},
},
});
export const PageNavContent = style({
minHeight: '100%',
padding: config.space.S200,
paddingRight: 0,
paddingBottom: config.space.S700,
});
export const PageHeader = recipe({
base: {
paddingLeft: config.space.S400,
paddingRight: config.space.S200,
borderBottomWidth: config.borderWidth.B300,
},
variants: {
balance: {
true: {
paddingLeft: config.space.S200,
},
},
},
});
export type PageHeaderVariants = RecipeVariants<typeof PageHeader>;
export const PageContent = style([
DefaultReset,
{
paddingTop: config.space.S400,
paddingLeft: config.space.S400,
paddingRight: 0,
paddingBottom: toRem(100),
},
]);
export const PageHeroSection = style([
DefaultReset,
{
padding: '40px 0',
maxWidth: toRem(466),
width: '100%',
margin: 'auto',
},
]);
export const PageContentCenter = style([
DefaultReset,
{
maxWidth: toRem(964),
width: '100%',
margin: 'auto',
},
]);

View file

@ -1,14 +0,0 @@
import { style } from '@vanilla-extract/css';
import { color } from 'folds';
export const RoomAvatar = style({
backgroundColor: color.Secondary.Container,
color: color.Secondary.OnContainer,
textTransform: 'capitalize',
selectors: {
'&[data-image-loaded="true"]': {
backgroundColor: 'transparent',
},
},
});

View file

@ -1,56 +0,0 @@
import { JoinRule } from 'matrix-js-sdk';
import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds';
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react';
import * as css from './RoomAvatar.css';
import { joinRuleToIconSrc } from '../../utils/room';
import colorMXID from '../../../util/colorMXID';
type RoomAvatarProps = {
roomId: string;
src?: string;
alt?: string;
renderFallback: () => ReactNode;
};
export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) {
const [error, setError] = useState(false);
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
evt.currentTarget.setAttribute('data-image-loaded', 'true');
};
if (!src || error) {
return (
<AvatarFallback
style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }}
className={css.RoomAvatar}
>
{renderFallback()}
</AvatarFallback>
);
}
return (
<AvatarImage
className={css.RoomAvatar}
src={src}
alt={alt}
onError={() => setError(true)}
onLoad={handleLoad}
draggable={false}
/>
);
}
export const RoomIcon = forwardRef<
SVGSVGElement,
Omit<ComponentProps<typeof Icon>, 'src'> & {
joinRule: JoinRule;
space?: boolean;
}
>(({ joinRule, space, ...props }, ref) => (
<Icon
src={joinRuleToIconSrc(Icons, joinRule, space || false) ?? Icons.Hash}
{...props}
ref={ref}
/>
));

View file

@ -1 +0,0 @@
export * from './RoomAvatar';

View file

@ -1,320 +0,0 @@
import React, { ReactNode, useCallback, useRef, useState } from 'react';
import { MatrixError, Room } from 'matrix-js-sdk';
import {
Avatar,
Badge,
Box,
Button,
Dialog,
Icon,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner,
Text,
as,
color,
config,
} from 'folds';
import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
import * as css from './style.css';
import { RoomAvatar } from '../room-avatar';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { nameInitials } from '../../utils/common';
import { millify } from '../../plugins/millify';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { onEnterOrSpace, stopPropagation } from '../../utils/keyboard';
import { RoomType, StateEvent } from '../../../types/matrix/room';
import { useJoinedRoomId } from '../../hooks/useJoinedRoomId';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import { getRoomAvatarUrl, getStateEvent } from '../../utils/room';
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
type GridColumnCount = '1' | '2' | '3';
const getGridColumnCount = (gridWidth: number): GridColumnCount => {
if (gridWidth <= 498) return '1';
if (gridWidth <= 748) return '2';
return '3';
};
const setGridColumnCount = (grid: HTMLElement, count: GridColumnCount): void => {
grid.style.setProperty('grid-template-columns', `repeat(${count}, 1fr)`);
};
export function RoomCardGrid({ children }: { children: ReactNode }) {
const gridRef = useRef<HTMLDivElement>(null);
useElementSizeObserver(
useCallback(() => gridRef.current, []),
useCallback((width, _, target) => setGridColumnCount(target, getGridColumnCount(width)), [])
);
return (
<Box className={css.CardGrid} direction="Row" gap="400" wrap="Wrap" ref={gridRef}>
{children}
</Box>
);
}
export const RoomCardBase = as<'div'>(({ className, ...props }, ref) => (
<Box
direction="Column"
gap="300"
className={classNames(css.RoomCardBase, className)}
{...props}
ref={ref}
/>
));
export const RoomCardName = as<'h6'>(({ ...props }, ref) => (
<Text as="h6" size="H6" truncate {...props} ref={ref} />
));
export const RoomCardTopic = as<'p'>(({ className, ...props }, ref) => (
<Text
as="p"
size="T200"
className={classNames(css.RoomCardTopic, className)}
{...props}
priority="400"
ref={ref}
/>
));
function ErrorDialog({
title,
message,
children,
}: {
title: string;
message: string;
children: (openError: () => void) => ReactNode;
}) {
const [viewError, setViewError] = useState(false);
const closeError = () => setViewError(false);
const openError = () => setViewError(true);
return (
<>
{children(openError)}
<Overlay open={viewError} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: closeError,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text>{title}</Text>
<Text style={{ color: color.Critical.Main }} size="T300" priority="400">
{message}
</Text>
</Box>
<Button size="400" variant="Secondary" fill="Soft" onClick={closeError}>
<Text size="B400">Cancel</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
</>
);
}
type RoomCardProps = {
roomIdOrAlias: string;
allRooms: string[];
avatarUrl?: string;
name?: string;
topic?: string;
memberCount?: number;
roomType?: string;
viaServers?: string[];
onView?: (roomId: string) => void;
renderTopicViewer: (name: string, topic: string, requestClose: () => void) => ReactNode;
};
export const RoomCard = as<'div', RoomCardProps>(
(
{
roomIdOrAlias,
allRooms,
avatarUrl,
name,
topic,
memberCount,
roomType,
viaServers,
onView,
renderTopicViewer,
...props
},
ref
) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const joinedRoomId = useJoinedRoomId(allRooms, roomIdOrAlias);
const joinedRoom = mx.getRoom(joinedRoomId);
const [topicEvent, setTopicEvent] = useState(() =>
joinedRoom ? getStateEvent(joinedRoom, StateEvent.RoomTopic) : undefined
);
const fallbackName = getMxIdLocalPart(roomIdOrAlias) ?? roomIdOrAlias;
const fallbackTopic = roomIdOrAlias;
const avatar = joinedRoom
? getRoomAvatarUrl(mx, joinedRoom, 96, useAuthentication)
: avatarUrl && mxcUrlToHttp(mx, avatarUrl, useAuthentication, 96, 96, 'crop');
const roomName = joinedRoom?.name || name || fallbackName;
const roomTopic =
(topicEvent?.getContent().topic as string) || undefined || topic || fallbackTopic;
const joinedMemberCount = joinedRoom?.getJoinedMemberCount() ?? memberCount;
useStateEventCallback(
mx,
useCallback(
(event) => {
if (
joinedRoom &&
event.getRoomId() === joinedRoom.roomId &&
event.getType() === StateEvent.RoomTopic
) {
setTopicEvent(getStateEvent(joinedRoom, StateEvent.RoomTopic));
}
},
[joinedRoom]
)
);
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers])
);
const joining =
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
const [viewTopic, setViewTopic] = useState(false);
const closeTopic = () => setViewTopic(false);
const openTopic = () => setViewTopic(true);
return (
<RoomCardBase {...props} ref={ref}>
<Box gap="200" justifyContent="SpaceBetween">
<Avatar size="500">
<RoomAvatar
roomId={roomIdOrAlias}
src={avatar ?? undefined}
alt={roomIdOrAlias}
renderFallback={() => (
<Text as="span" size="H3">
{nameInitials(roomName)}
</Text>
)}
/>
</Avatar>
{(roomType === RoomType.Space || joinedRoom?.isSpaceRoom()) && (
<Badge variant="Secondary" fill="Soft" outlined>
<Text size="L400">Space</Text>
</Badge>
)}
</Box>
<Box grow="Yes" direction="Column" gap="100">
<RoomCardName>{roomName}</RoomCardName>
<RoomCardTopic onClick={openTopic} onKeyDown={onEnterOrSpace(openTopic)} tabIndex={0}>
{roomTopic}
</RoomCardTopic>
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: closeTopic,
escapeDeactivates: stopPropagation,
}}
>
{renderTopicViewer(roomName, roomTopic, closeTopic)}
</FocusTrap>
</OverlayCenter>
</Overlay>
</Box>
{typeof joinedMemberCount === 'number' && (
<Box gap="100">
<Icon size="50" src={Icons.User} />
<Text size="T200">{`${millify(joinedMemberCount)} Members`}</Text>
</Box>
)}
{typeof joinedRoomId === 'string' && (
<Button
onClick={onView ? () => onView(joinedRoomId) : undefined}
variant="Secondary"
fill="Soft"
size="300"
>
<Text size="B300" truncate>
View
</Text>
</Button>
)}
{typeof joinedRoomId !== 'string' && joinState.status !== AsyncStatus.Error && (
<Button
onClick={join}
variant="Secondary"
size="300"
disabled={joining}
before={joining && <Spinner size="50" variant="Secondary" fill="Soft" />}
>
<Text size="B300" truncate>
{joining ? 'Joining' : 'Join'}
</Text>
</Button>
)}
{typeof joinedRoomId !== 'string' && joinState.status === AsyncStatus.Error && (
<Box gap="200">
<Button
onClick={join}
className={css.ActionButton}
variant="Critical"
fill="Solid"
size="300"
>
<Text size="B300" truncate>
Retry
</Text>
</Button>
<ErrorDialog
title="Join Error"
message={joinState.error.message || 'Failed to join. Unknown Error.'}
>
{(openError) => (
<Button
onClick={openError}
className={css.ActionButton}
variant="Critical"
fill="Soft"
outlined
size="300"
>
<Text size="B300" truncate>
View Error
</Text>
</Button>
)}
</ErrorDialog>
</Box>
)}
</RoomCardBase>
);
}
);

View file

@ -1 +0,0 @@
export * from './RoomCard';

View file

@ -1,36 +0,0 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, config } from 'folds';
import { ContainerColor } from '../../styles/ContainerColor.css';
export const CardGrid = style({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: config.space.S400,
});
export const RoomCardBase = style([
DefaultReset,
ContainerColor({ variant: 'SurfaceVariant' }),
{
padding: config.space.S500,
borderRadius: config.radii.R500,
},
]);
export const RoomCardTopic = style({
minHeight: `calc(3 * ${config.lineHeight.T200})`,
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
cursor: 'pointer',
':hover': {
textDecoration: 'underline',
},
});
export const ActionButton = style({
flex: '1 1 0',
minWidth: 1,
});

View file

@ -1,20 +1,14 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { Avatar, Box, Button, Spinner, Text, as } from 'folds'; import { Avatar, AvatarFallback, AvatarImage, Box, Button, Spinner, Text, as, color } from 'folds';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai'; import { openInviteUser, selectRoom } from '../../../client/action/navigation';
import { openInviteUser } from '../../../client/action/navigation'; import { useStateEvent } from '../../hooks/useStateEvent';
import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room'; import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
import { getMemberDisplayName, getStateEvent } from '../../utils/room'; import { getMemberDisplayName, getStateEvent } from '../../utils/room';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { getMxIdLocalPart } from '../../utils/matrix';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { timeDayMonthYear, timeHourMinute } from '../../utils/time'; import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { RoomAvatar } from '../room-avatar';
import { nameInitials } from '../../utils/common';
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
import { mDirectAtom } from '../../state/mDirectList';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
export type RoomIntroProps = { export type RoomIntroProps = {
room: Room; room: Room;
@ -22,22 +16,21 @@ export type RoomIntroProps = {
export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => { export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { navigateRoom } = useRoomNavigate();
const mDirects = useAtomValue(mDirectAtom);
const createEvent = getStateEvent(room, StateEvent.RoomCreate); const createEvent = getStateEvent(room, StateEvent.RoomCreate);
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId)); const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
const name = useRoomName(room); const nameEvent = useStateEvent(room, StateEvent.RoomName);
const topic = useRoomTopic(room); const topicEvent = useStateEvent(room, StateEvent.RoomTopic);
const avatarHttpUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
const createContent = createEvent?.getContent<IRoomCreateContent>(); const createContent = createEvent?.getContent<IRoomCreateContent>();
const ts = createEvent?.getTs(); const ts = createEvent?.getTs();
const creatorId = createEvent?.getSender(); const creatorId = createEvent?.getSender();
const creatorName = const creatorName =
creatorId && (getMemberDisplayName(room, creatorId) ?? getMxIdLocalPart(creatorId)); creatorId && (getMemberDisplayName(room, creatorId) ?? getMxIdLocalPart(creatorId));
const prevRoomId = createContent?.predecessor?.room_id; const prevRoomId = createContent?.predecessor?.room_id;
const avatarMxc = (avatarEvent?.getContent().url as string) || undefined;
const avatarHttpUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc) : undefined;
const name = (nameEvent?.getContent().name || room.name) as string;
const topic = (topicEvent?.getContent().topic as string) || undefined;
const [prevRoomState, joinPrevRoom] = useAsyncCallback( const [prevRoomState, joinPrevRoom] = useAsyncCallback(
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx]) useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
@ -47,12 +40,18 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}> <Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
<Box> <Box>
<Avatar size="500"> <Avatar size="500">
<RoomAvatar {avatarHttpUrl ? (
roomId={room.roomId} <AvatarImage src={avatarHttpUrl} alt={name} />
src={avatarHttpUrl ?? undefined} ) : (
alt={name} <AvatarFallback
renderFallback={() => <Text size="H2">{nameInitials(name)}</Text>} style={{
/> backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
}}
>
<Text size="H2">{name[0]}</Text>
</AvatarFallback>
)}
</Avatar> </Avatar>
</Box> </Box>
<Box direction="Column" gap="300"> <Box direction="Column" gap="300">
@ -83,7 +82,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={() => selectRoom(prevRoomId)}
variant="Success" variant="Success"
size="300" size="300"
fill="Soft" fill="Soft"

View file

@ -1,41 +0,0 @@
import React from 'react';
import { as, Box, Header, Icon, IconButton, Icons, Modal, Scroll, Text } from 'folds';
import classNames from 'classnames';
import Linkify from 'linkify-react';
import * as css from './style.css';
import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
export const RoomTopicViewer = as<
'div',
{
name: string;
topic: string;
requestClose: () => void;
}
>(({ name, topic, requestClose, className, ...props }, ref) => (
<Modal
size="300"
flexHeight
className={classNames(css.ModalFlex, className)}
{...props}
ref={ref}
>
<Header className={css.ModalHeader} variant="Surface" size="500">
<Box grow="Yes">
<Text size="H4" truncate>
{name}
</Text>
</Box>
<IconButton size="300" onClick={requestClose} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Scroll className={css.ModalScroll} size="300" hideTrack>
<Box className={css.ModalContent} direction="Column" gap="100">
<Text size="T300" className={css.ModalTopic} priority="400">
<Linkify options={LINKIFY_OPTS}>{scaleSystemEmoji(topic)}</Linkify>
</Text>
</Box>
</Scroll>
</Modal>
));

View file

@ -1 +0,0 @@
export * from './RoomTopicViewer';

View file

@ -1,23 +0,0 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
export const ModalFlex = style({
display: 'flex',
flexDirection: 'column',
});
export const ModalHeader = style({
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
});
export const ModalScroll = style({
flexGrow: 1,
});
export const ModalContent = style({
padding: config.space.S400,
paddingRight: config.space.S200,
paddingBottom: config.space.S700,
});
export const ModalTopic = style({
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
});

View file

@ -1,39 +0,0 @@
import React, { RefObject, useCallback, useState } from 'react';
import { Box, as } from 'folds';
import classNames from 'classnames';
import * as css from './style.css';
import {
getIntersectionObserverEntry,
useIntersectionObserver,
} from '../../hooks/useIntersectionObserver';
export const ScrollTopContainer = as<
'div',
{
scrollRef?: RefObject<HTMLElement>;
anchorRef: RefObject<HTMLElement>;
onVisibilityChange?: (onTop: boolean) => void;
}
>(({ className, scrollRef, anchorRef, onVisibilityChange, ...props }, ref) => {
const [onTop, setOnTop] = useState(true);
useIntersectionObserver(
useCallback(
(intersectionEntries) => {
if (!anchorRef.current) return;
const entry = getIntersectionObserverEntry(anchorRef.current, intersectionEntries);
if (entry) {
setOnTop(entry.isIntersecting);
onVisibilityChange?.(entry.isIntersecting);
}
},
[anchorRef, onVisibilityChange]
),
useCallback(() => ({ root: scrollRef?.current }), [scrollRef]),
useCallback(() => anchorRef.current, [anchorRef])
);
if (onTop) return null;
return <Box className={classNames(css.ScrollTopContainer, className)} {...props} ref={ref} />;
});

View file

@ -1 +0,0 @@
export * from './ScrollTopContainer';

View file

@ -1,20 +0,0 @@
import { keyframes, style } from '@vanilla-extract/css';
import { config } from 'folds';
const ScrollContainerAnime = keyframes({
'0%': {
transform: `translate(-50%, -100%) scale(0)`,
},
'100%': {
transform: `translate(-50%, 0) scale(1)`,
},
});
export const ScrollTopContainer = style({
position: 'absolute',
top: config.space.S200,
left: '50%',
transform: 'translateX(-50%)',
zIndex: config.zIndex.Z100,
animation: `${ScrollContainerAnime} 100ms`,
});

View file

@ -1,18 +0,0 @@
import React, { ComponentProps } from 'react';
import { Box, as } from 'folds';
import classNames from 'classnames';
import { ContainerColor, ContainerColorVariants } from '../../styles/ContainerColor.css';
import * as css from './style.css';
export const SequenceCard = as<
'div',
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}
data-last-child={lastChild}
{...props}
ref={ref}
/>
));

View file

@ -1 +0,0 @@
export * from './SequenceCard';

View file

@ -1,52 +0,0 @@
import { createVar } from '@vanilla-extract/css';
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
import { config } from 'folds';
const outlinedWidth = createVar('0');
export const SequenceCard = recipe({
base: {
vars: {
[outlinedWidth]: '0',
},
borderStyle: 'solid',
borderWidth: outlinedWidth,
borderBottomWidth: 0,
selectors: {
'&:first-child, :not(&) + &': {
borderTopLeftRadius: config.radii.R400,
borderTopRightRadius: config.radii.R400,
},
'&:last-child, &:not(:has(+&))': {
borderBottomLeftRadius: config.radii.R400,
borderBottomRightRadius: config.radii.R400,
borderBottomWidth: outlinedWidth,
},
[`&[data-first-child="true"]`]: {
borderTopLeftRadius: config.radii.R400,
borderTopRightRadius: config.radii.R400,
},
[`&[data-first-child="false"]`]: {
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
[`&[data-last-child="true"]`]: {
borderBottomLeftRadius: config.radii.R400,
borderBottomRightRadius: config.radii.R400,
},
[`&[data-last-child="false"]`]: {
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
},
},
},
variants: {
outlined: {
true: {
vars: {
[outlinedWidth]: config.borderWidth.B300,
},
},
},
},
});
export type SequenceCardVariants = RecipeVariants<typeof SequenceCard>;

View file

@ -1,249 +0,0 @@
import { createVar, style } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { color, config, DefaultReset, Disabled, FocusOutline, toRem } from 'folds';
import { ContainerColor } from '../../styles/ContainerColor.css';
export const Sidebar = style([
DefaultReset,
{
width: toRem(66),
backgroundColor: color.Background.Container,
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
display: 'flex',
flexDirection: 'column',
color: color.Background.OnContainer,
},
]);
export const SidebarStack = style([
DefaultReset,
{
width: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
gap: config.space.S300,
padding: `${config.space.S300} 0`,
},
]);
const DropLineDist = createVar();
export const DropTarget = style({
vars: {
[DropLineDist]: toRem(-8),
},
selectors: {
'&[data-inside-folder=true]': {
vars: {
[DropLineDist]: toRem(-6),
},
},
'&[data-drop-child=true]': {
outline: `${config.borderWidth.B700} solid ${color.Success.Main}`,
borderRadius: config.radii.R400,
},
'&[data-drop-above=true]::after, &[data-drop-below=true]::after': {
content: '',
display: 'block',
position: 'absolute',
left: toRem(0),
width: '100%',
height: config.borderWidth.B700,
backgroundColor: color.Success.Main,
},
'&[data-drop-above=true]::after': {
top: DropLineDist,
},
'&[data-drop-below=true]::after': {
bottom: DropLineDist,
},
},
});
const PUSH_X = 2;
export const SidebarItem = recipe({
base: [
DefaultReset,
{
minWidth: toRem(42),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
selectors: {
'&:hover': {
transform: `translateX(${toRem(PUSH_X)})`,
},
'&::before': {
content: '',
display: 'none',
position: 'absolute',
left: toRem(-11.5 - PUSH_X),
width: toRem(3 + PUSH_X),
height: toRem(16),
borderRadius: `0 ${toRem(4)} ${toRem(4)} 0`,
background: 'CurrentColor',
transition: 'height 200ms linear',
},
'&:hover::before': {
display: 'block',
width: toRem(3),
},
},
},
Disabled,
DropTarget,
],
variants: {
active: {
true: {
selectors: {
'&::before': {
display: 'block',
height: toRem(24),
},
'&:hover::before': {
width: toRem(3 + PUSH_X),
},
},
},
},
},
});
export type SidebarItemVariants = RecipeVariants<typeof SidebarItem>;
export const SidebarItemBadge = recipe({
base: [
DefaultReset,
{
pointerEvents: 'none',
position: 'absolute',
zIndex: 1,
lineHeight: 0,
},
],
variants: {
hasCount: {
true: {
top: toRem(-6),
left: toRem(-6),
},
false: {
top: toRem(-2),
left: toRem(-2),
},
},
},
defaultVariants: {
hasCount: false,
},
});
export type SidebarItemBadgeVariants = RecipeVariants<typeof SidebarItemBadge>;
export const SidebarAvatar = recipe({
base: [
{
selectors: {
'button&': {
cursor: 'pointer',
},
},
},
],
variants: {
size: {
'200': {
width: toRem(16),
height: toRem(16),
fontSize: toRem(10),
lineHeight: config.lineHeight.T200,
letterSpacing: config.letterSpacing.T200,
},
'300': {
width: toRem(34),
height: toRem(34),
},
'400': {
width: toRem(42),
height: toRem(42),
},
},
outlined: {
true: {
border: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
},
},
},
defaultVariants: {
size: '400',
},
});
export type SidebarAvatarVariants = RecipeVariants<typeof SidebarAvatar>;
export const SidebarFolder = recipe({
base: [
ContainerColor({ variant: 'Background' }),
{
padding: config.space.S100,
width: toRem(42),
minHeight: toRem(42),
display: 'flex',
flexWrap: 'wrap',
outline: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
position: 'relative',
selectors: {
'button&': {
cursor: 'pointer',
},
},
},
FocusOutline,
DropTarget,
],
variants: {
state: {
Close: {
gap: toRem(2),
borderRadius: config.radii.R400,
},
Open: {
paddingLeft: 0,
paddingRight: 0,
flexDirection: 'column',
alignItems: 'center',
gap: config.space.S200,
borderRadius: config.radii.R500,
},
},
},
defaultVariants: {
state: 'Close',
},
});
export type SidebarFolderVariants = RecipeVariants<typeof SidebarFolder>;
export const SidebarFolderDropTarget = recipe({
base: {
width: '100%',
height: toRem(8),
position: 'absolute',
left: 0,
},
variants: {
position: {
Top: {
top: toRem(-4),
},
Bottom: {
bottom: toRem(-4),
},
},
},
});
export type SidebarFolderDropTargetVariants = RecipeVariants<typeof SidebarFolderDropTarget>;

View file

@ -1,8 +0,0 @@
import classNames from 'classnames';
import { as } from 'folds';
import React from 'react';
import * as css from './Sidebar.css';
export const Sidebar = as<'div'>(({ as: AsSidebar = 'div', className, ...props }, ref) => (
<AsSidebar className={classNames(css.Sidebar, className)} {...props} ref={ref} />
));

View file

@ -1,19 +0,0 @@
import React, { ReactNode } from 'react';
import { Box } from 'folds';
type SidebarContentProps = {
scrollable: ReactNode;
sticky: ReactNode;
};
export function SidebarContent({ scrollable, sticky }: SidebarContentProps) {
return (
<>
<Box direction="Column" grow="Yes">
{scrollable}
</Box>
<Box direction="Column" shrink="No">
{sticky}
</Box>
</>
);
}

Some files were not shown because too many files have changed in this diff Show more